fuz

a friendly brown spider facing you
friendly user zystem 🧶

Svelte UI library

npm i -D @ryanatkn/fuz

introduction #

Fuz is a Svelte UI library that builds on my CSS framework Moss. It's in early alpha and there will be many breaking changes. I'm making it to support Zzz and my other projects that focus on end-users, so it'll continue to grow slowly as I find the time and usecases.

The goal is to make a fully-featured Svelte UI library, but it'll take extra time to get there. That lets us prioritize patterns like APIs above features, making it an interesting project for learning and experimentation. If you're interested in using Fuz, helping out, or just following along, see contributing.md.

These docs are a work in progress - for an overview see the readme.

Themed #

Fuz provides UI components that use Moss' theming system for dark mode and custom themes.

Themed adds global support for both the browser's color-scheme and custom themes based on Moss style variables, which use CSS custom properties . Themed is a singleton component that's mounted at the top-level of the page:

import Themed from '@ryanatkn/fuz/Themed.svelte';
<!-- +layout.svelte -->
<Themed>
	{@render children()}
</Themed>
Why the singleton?
Why nested children?

Color scheme
#

Themed defaults to automatic color-scheme detection with prefers-color-scheme , and users can also set it directly:

import Color_Scheme_Input from '@ryanatkn/fuz/Color_Scheme_Input.svelte';
<Color_Scheme_Input />

Pass props to override the default:

<Color_Scheme_Input
	value={{color_scheme: 'auto'}}
	onchange={...}
/>

The builtin themes support both dark and light color schemes. Custom themes may support one or both color schemes.

More about Color_Scheme_Input

Builtin themes
#

A theme is a simple JSON collection of Moss style variables that can be transformed into CSS that set custom properties. Each variable can have values for light and/or dark color schemes. In other words, "dark" isn't a theme, it's a mode that any theme can implement.

  • Example usage
    #

    Themes are plain CSS that can be sourced in a variety of ways.

    To use Fuz's base theme:

    <!-- +layout.svelte -->
    <script>
    	import '@ryanatkn/moss/style.css';
    	import '@ryanatkn/moss/theme.css';
    	import Themed from '@ryanatkn/fuz/Themed.svelte';
    	import type {Snippet} from 'svelte';
    
    	interface Props {
    		children: Snippet;
    	}
    	
    	const {children}: Props = $props();
    <script>
    
    <!-- enable theme and color-scheme support -->
    <Themed>
    	{@render children()}
    </Themed>

    Themed can be customized with the the nonreactive prop themer:

    import {Themer} from '@ryanatkn/fuz/theme.svelte.js';
    const themer = new Themer(...);
    <Themed {themer}>
    	{@render children()}
    </Themed>

    Themed sets the themer in the Svelte context:

    // get values from the Svelte context provided by
    // the nearest `Themed` ancestor:
    import {themer_context} from '@ryanatkn/fuz/theme.js';
    const themer = themer_context.get();
    themer.theme.name; // 'base'
    themer.color_scheme; // 'auto'

    For a more complete example, see fuz_template.

    More details
    #

    Themed initializes the system's theme support. Without it, the page will not reflect the user's system color-scheme. By default, Themed applies the base theme to the root of the page via create_theme_setup_script. It uses JS to add the .dark CSS class to the :root element.

    This strategy enables color scheme and theme support with minimal CSS and optimal performance for most use cases. The system supports plain CSS usage that can be static or dynamic, or imported at buildtime or runtime. It also allows runtime access to the underlying data like the style variables if you want to pay the performance costs. Scoped theming to one part of the page is planned.

    The theme setup script interacts with sync_color_scheme to save the user's preference to localStorage. See also Color_Scheme_Input.

    The setup script avoids flash-on-load due to color scheme, but currently themes flash in after loading. We'll try to fix this when the system stabilizes.

    Alert #

    import Alert from '@ryanatkn/fuz/Alert.svelte';
    <Alert>info</Alert>

    With custom icon
    #

    icon can be a string prop or snippet:

    <Alert icon="">
    	icon as a string prop
    </Alert>
    <Alert>
    	{#snippet icon(t)}{t}{t}{/snippet}
    	icon as a snippet
    </Alert>

    As optional button
    #

    Alerts can be buttons by including an onclick prop. This API may change because it's a bit of a mess - a separate Alert_Button may be better.

    <Alert onclick={() => clicks++}>
    	alerts can be buttons{'.'.repeat(clicks)}
    </Alert>

    clicks: 0

    With custom status
    #

    The status prop, which defaults to 'inform', changes the default icon and color.

    // @ryanatkn/fuz/alert.js
    export type Alert_Status = 'inform' | 'help' | 'error';
    <Alert status="error">
    	the computer is mistaken
    </Alert>
    <Alert status="help">
    	here's how to fix it
    </Alert>
    <Alert status="help" color="var(--color_d_5)">
    	the <code>color</code> prop overrides the status color
    </Alert>

    Breadcrumb #

    import Breadcrumb from '@ryanatkn/fuz/Breadcrumb.svelte';
    <Breadcrumb />

    With custom icon
    #

    <Breadcrumb>🏠</Breadcrumb>

    With custom separator
    #

    <Breadcrumb>
    	{#snippet separator()}.{/snippet}
    </Breadcrumb>

    With custom paths
    #

    <Breadcrumb
    	path="/a/b/c/d"
    	selected_path="/a/b"
    	base_path="{base}/library/breadcrumb"	
    >
    	<span class="size_xl">🔡</span>
    	{#snippet separator()}.{/snippet}
    </Breadcrumb>

    Card #

    import Card from '@ryanatkn/fuz/Card.svelte';
    <Card>
      just<br />
      a card
    </Card>
    🪧
    just
    a card

    With a custom icon
    #

    <Card>
      custom<br />
      icon
      {#snippet icon()}📖{/snippet}
    </Card>
    📖
    custom
    icon

    As a link
    #

    <Card href="/">
      a<br />
      link
    </Card>
    🔗
    a
    link

    As the selected link
    #

    <Card href="/library/card">
      href is<br />
      selected
    </Card>
    🔗
    href is
    selected

    With a custom HTML tag
    #

    <Card tag="button">
      custom<br />
      tag
    </Card>

    With custom alignment
    #

    <Card align="right">
      align<br />
      icon right
    </Card>
    align
    icon right
    🪧
    <Card align="above">
      align<br />
      icon above
    </Card>
    🪧
    align
    icon above
    <Card align="below">
      align<br />
      icon below
    </Card>
    align
    icon below
    🪧

    Contextmenu #

    Basic usage
    #

    Try opening the contextmenu on this panel with rightclick or longpress.

    <Contextmenu_Root scoped>
      <Contextmenu>
        {#snippet entries()}
          <Contextmenu_Entry run={() => (greeted = !greeted)}> <!-- false />
            Hello world
          </Contextmenu_Entry>
          <Contextmenu_Entry run={() => (greeted_icon_snippet = !greeted_icon_snippet)}> <!-- false />
            {#snippet icon()}🌞{/snippet}
            Hello with an optional icon snippet
          </Contextmenu_Entry>
          <Contextmenu_Entry run={() => (greeted_icon_string = !greeted_icon_string)} icon="🌚"> <!-- false />
            Hello with an optional icon string
          </Contextmenu_Entry>
        {/snippet}
        ...markup with the above contextmenu behavior...
      </Contextmenu>
      ...markup with only default contextmenu behavior...
    </Contextmenu_Root>
    ...markup without contextmenu behavior...
    greeted = false
    greeted_icon_snippet = false
    greeted_icon_string = false

    Default behaviors
    #

    <Contextmenu_Root scoped>
      ...<a href="https://www.fuz.dev/">
        a link like this one
      </a>...
    </Contextmenu_Root>

    Opening the contextmenu on a link like this one has special behavior by default. To accesss your browser's normal contextmenu, open the contextmenu on the link inside the contextmenu itself or hold Shift.

    Although disruptive to default browser behavior, this allows links to have contextmenu behaviors, and it allows you to open the contextmenu anywhere to access all contextual behaviors.

    Custom instance
    #

    const contextmenu = create_contextmenu();
    <Contextmenu_Root {contextmenu} scoped>...

    The Contextmenu_Root prop contextmenu provides more control. Try opening the contextmenu on this panel.

    Select text
    #

    If a contextmenu is triggered on selected text, it includes a Copy text entry.

    Try and then opening the contextmenu on it.

    Opening the contextmenu on an input or textarea opens the browser's default contextmenu.

    contenteditable likewise has your browser's default contextmenu behavior.

    contenteditable

    contenteditable="plaintext-only"

    Full example
    #

    🏠
    😺Alyssa
    😸Ben
    🌄
    View example source

    Expected behaviors
    #

    The Contextmenu overrides the system contextmenu to provide capabilities specific to your app. The motivation docs explain why Fuz breaks web platform expectations.

    On touch devices, we detect tap-and-hold (aka longpress) instead of simply overriding the web's contextmenu event because iOS does not support this web standard as of July 2023 as described in this WebKit bug report. The Fuz implementation therefore has hacks that may cause corner case bugs on various devices and browsers, and it breaks navigator.vibrate on all mobile browsers that I've tested because it triggers the gesture on a timeout, not a user action.

    When you rightclick or longpress inside a Contextmenu_Root, it searches for behaviors defined with Contextmenu starting from the target element up to the root. If any behaviors are found, the Fuz contextmenu opens, with the caveats below. The contextmenu displays the available behaviors which you can then activate. If no behaviors are found, the system contextmenu opens.

    Devices with a mouse

    • rightclick opens the Fuz contextmenu and not the system contextmenu except on input/textarea/contenteditable
    • rightclick on the Fuz contextmenu opens the system contextmenu
    • rightclick while holding Shift opens the system contextmenu
    • keyboard navigation and activation should work similarly to the W3C APG menubar pattern

    Touch devices

    • longpress opens the Fuz contextmenu and not the system contextmenu
    • longpress on the Fuz contextmenu (two longpresses) opens the system contextmenu
    • double-tap-and-hold (aka tap-then-longpress) opens the system contextmenu or performs other default behavior like selecting text - does not work for cases where the first tap performs some action on an element, like links - use two longpresses for those cases (this may need more design work, possibly adding a different gesture or a contextmenu entry for touch devices that triggers the system conextmenu on the next longpress)
    • a longpress is canceled if you move the touch past a threshold before it triggers
    • the contextmenu closes if you move past a threshold without lifting the longpress touch that opened it
    • gives haptic feedback on open with navigator.vibrate (currently broken, may remain so due to the iOS longpress workaround)

    Motivation
    #

    Fuz takes two things very seriously, in no particular order:

    1. giving users a powerful and customizable UX
    2. aligning with the web platform and not breaking its standard behaviors

    For #1, Fuz includes a custom contextmenu. Like Google Docs, when you right-click or tap-and-hold (aka longpress) on an element inside Fuz's Contextmenu, you'll see app-specific options and actions for your current context.

    This is a powerful UX pattern, but it violates #2. The Fuz contextmenu breaks the normal browser behavior of showing the system contextmenu and device-specific behaviors like selecting text on a longpress.

    Balancing these two concerns is going to be an ongoing challenge, and my current belief is that the contextmenu is too useful and powerful to ignore. I'm open to critical feedback, and I'll do what I can to minimize the harmful effects of choices like this.

    Mitigations:

    • The Fuz contextmenu does not open on elements that allow clipboard pasting like inputs, textareas, and contenteditables.
    • To bypass the Fuz contextmenu on a device with a keyboard, hold the Shift key.
    • To bypass the Fuz contextmenu on a touch device, like to select text, tap one extra time before your longpress. This means double-tap-and-hold should behave the same as tap-and-hold on standard web pages.
    • Triggering the contextmenu inside of the Fuz contextmenu shows your system contextmenu. This means you can either double-right-click or longpress twice to access your system contextmenu as an alternative to holding Shift or double-tap-and-hold, However a caveat is that the target of your action will be some element inside the Fuz contextmenu, so to select text or access a link's system contextmenu on a touch device, you must use double-tap-and-hold. When you open the Fuz contextmenu on a link, you'll see the link again in the menu under your pointer by default, so to access your system's functionality on links, tap-and-hold twice.

    Details #

    The Details component is an alternative to the details element. By default it's lazy, and you can pass eager to render the children immediately like the base element.

    Benefits of lazy children:

    • children are transitioned in/out with an animation (TODO this may be doable with eager children, if so it would probably be the better default, and then the prop should be swapped to lazy)
    • improved performance, can significantly improve UX in some cases

    Tradeoffs:

    • ctrl+f does not work to find text and auto-open the details
    • you may desire some behavior caused by mounting the children

    With lazy rendering by default
    #

    <Details>
      {#snippet summary()}summary content{/snippet}
      lazy children content
    </Details>
    summary content

    With eager rendering
    #

    <Details eager>
      {#snippet summary()}summary content{/snippet}
      eager children content
    </Details>
    summary content eager children content

    With the base details element
    #

    <details>
      <summary>a summary element instead of a snippet</summary>
      the plain details
    </details>
    a summary element instead of a snippet the plain details

    Dialog #

    A modal that overlays the entire page. Uses Teleport to allow usage from any component without inheriting styles.

    <button onclick={() => (opened = true)}>
    	open a dialog
    </button>
    {#if opened}
    	<Dialog onclose={() => (opened = false)}>
    		{#snippet children(close)}
    			<div class="pane p_xl box">
    				<h1>attention</h1>
    				<p>this is a dialog</p>
    				<button onclick={close}>ok</button>
    			</div>
    		{/snippet}
    	</Dialog>
    {/if}

    Hue_Input #

    import Hue_Input from '@ryanatkn/fuz/Hue_Input.svelte';

    With bind:value
    #

    <Hue_Input bind:value />
    bind:value === 180

    With oninput
    #

    <Hue_Input
    	oninput={(v) => (value_from_oninput = v)}
    />
    value_from_oninput === undefined

    With children
    #

    <Hue_Input>
    	Some colorful hue input
    </Hue_Input>

    Library #

    The Library is the component behind these docs. Its docs are unfinished - for now see usage in Moss.

    logos #

    • <Svg data={zzz_logo} />
    • <Svg data={moss_logo} />
    • <Svg data={belt_logo} />
    • <Svg data={gro_logo} />
    • <Svg data={fuz_logo} />
    • <Svg data={webdevladder_logo} />
    • <Svg data={fuz_blog_logo} />
    • <Svg data={fuz_mastodon_logo} />
    • <Svg data={fuz_code_logo} />
    • <Svg data={fuz_gitops_logo} />
    • <Svg data={fuz_template_logo} />
    • <Svg data={earbetter_logo} />
    • <Svg data={spiderspace_logo} />
    • <Svg data={github_logo} />
    • <Svg data={mdn_logo} />

    Package_Detail #

    This is a component related to Gro's public packages features.

    import Package_Detail from '@ryanatkn/fuz/Package_Detail.svelte';
    <Package_Detail {pkg} />
    fuz 🧶
    Svelte UI library
    friendly user zystem
    npm i -D @ryanatkn/fuz
    homepage repo npm version license data
  • alert.js
    • Alert_Status
    • Alert_Status_Options
    • alert_status_options
  • context_helpers.js
    • create_context
  • contextmenu_state.svelte.js
    • Contextmenu_Params
    • Item_State
    • Entry_State
    • Submenu_State
    • Root_Menu_State
    • Contextmenu_Run
    • Contextmenu_State_Options
    • Contextmenu_State
    • contextmenu_action
    • open_contextmenu
    • contextmenu_context
    • contextmenu_submenu_context
    • contextmenu_dimensions_context
  • dialog.js
    • to_dialog_params
    • Dialog_Params
    • Dialog_Layout
    • dialog_layouts
  • intersect.js
    • Intersect_Params
    • Intersect_Params_Or_Callback
    • intersect
    • On_Intersect
    • Intersect_State
    • On_Disconnect
    • Disconnect_State
  • library_helpers.svelte.js
    • DEFAULT_LIBRARY_PATH
    • to_library_path_info
    • library_links_context
    • Library_Link_Tag
    • Library_Link
    • Library_Links
  • logos.js
    • zzz_logo
    • gro_logo
    • fuz_logo
    • moss_logo
    • belt_logo
    • fuz_code_logo
    • fuz_blog_logo
    • fuz_mastodon_logo
    • fuz_gitops_logo
    • fuz_template_logo
    • webdevladder_logo
    • earbetter_logo
    • spiderspace_logo
    • github_logo
    • mdn_logo
  • theme.svelte.js
    • Themer
    • Themer_Json
    • themer_context
    • sync_color_scheme
    • COLOR_SCHEME_STORAGE_KEY
    • save_color_scheme
    • load_color_scheme
    • THEME_STORAGE_KEY
    • save_theme
    • load_theme
    • create_theme_setup_script
    • create_theme_style_html
  • tome.js
    • Tome
    • to_tome_pathname
    • tomes_context
    • get_tome_by_name
    • tome_context
  • raw data for pkg: Package_Meta

    Package_Summary #

    This is a component related to Gro's public packages features.

    import Package_Summary from '@ryanatkn/fuz/Package_Summary.svelte';
    <Package_Summary {pkg} />
    fuz
    a friendly brown spider facing you
    friendly user zystem 🧶

    Svelte UI library

    npm i -D @ryanatkn/fuz

    Pending_Animation #

    import Pending_Animation from '@ryanatkn/fuz/Pending_Animation.svelte';
    <Pending_Animation running={true} />
    <Pending_Animation
    	attrs={{class: 'size_xl5'}}
    	running={true}
    />

    With custom children
    #

    <div
    	style:font-size="var(--size_xl6)"
    	style:--animation_duration="var(--duration_6)"
    >
    	<Pending_Animation running={false}>
    		{🐢}
    	</Pending_Animation>
    </div>

    with children

    🐢🐢🐢

    With children index prop
    #

    <Pending_Animation running={false}>
    	{#snippet children(index)}
    		<div class="row box">
    			{🐸}
    			{index}
    			<span class="size_xl5">
    				{}
    			</span>}
    		</div>
    	{/snippet}
    </Pending_Animation>

    with running={}

    and children

    🐸 0
    🐸 1
    🐸 2

    Pending_Button #

    Preserves a button's normal width while animating.

    import Pending_Button from '@ryanatkn/fuz/Pending_Button.svelte';

    <Pending_Button
    	pending={false}
    	onclick={() => (pending_1 = !pending_1)}
    >
    	do something async
    </Pending_Button>

    <Pending_Button
    	pending={true}
    	onclick={() => (pending_2 = !pending_2)}
    >
    	do another
    </Pending_Button>

    Redirect #

    Adds a redirect for a page using a meta tag with the refresh header . Includes a rendered link and JS navigation fallback.

    import Redirect from '@ryanatkn/fuz/Redirect.svelte';
    <Redirect auto={false} />

    redirect to /library

    <Redirect
    	host="https://www.felt.dev"
    	path="/library"
    	let:url
    	auto={false}
    >
    	the redirect url is {url}
    </Redirect>
    the redirect url is https://www.felt.dev/library

    Teleport #

    Relocates elements in the DOM, in the rare cases that's useful and the best solution. The Dialog uses this to mount dialogs from any component without inheriting styles.

    import Teleport from '@ryanatkn/fuz/Teleport.svelte';
    <Teleport to={swap ? teleport_1 : teleport_2}>
    	🐰
    </Teleport>
    <div class="teleports">
    	<div class="panel" bind:this={teleport_1} />
    	<div class="panel" bind:this={teleport_2} />
    </div>
    <button onclick={() => (swap = !swap)}>
    	teleport the bunny
    </button>