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.
theming #
Fuz bases its theme support on my CSS framework Moss, which is based on CSS custom properties. For usage docs see Themed.
csp #
Fuz supports SvelteKit's config for Content Security Policies with the create_csp_directives
helper. Fuz also provides related helpers, types, and
CSP data.
The goal is to provide a simple trust modeling system that balances safety+security+privacy with ergonomics, helping users maintain secure policies without unhelpful burden or restriction. It's restrictive by default and easy to set granular overrides, and there's tiered grants for convenience.
Example usage:
import {create_csp_directives, type Csp_Source_Spec} from '@ryanatkn/fuz/csp.js';
// Create the default CSP with no trusted sources except 'self' and some sensible fallbacks.
// This tries to balance security and privacy with usability,
// helping nonexperts write secure policies while still supporting advanced users.
// More later on the details of the defaults.
const csp = create_csp_directives();
// Use in svelte.config.js:
// export default {kit: {csp}}
// Create a CSP with some trusted sources, using Fuz's CSP default trust levels:
export const my_csp_trusted_sources: Array<Csp_Source_Spec> = [
// Trust in yourself:
{source: 'https://my.domain/', trust: 'high'},
// No scripting allowed on these subdomains:
{source: 'https://*.my.domain/', trust: 'medium'},
// Low but allow scripting:
{source: 'https://me.github.io/', trust: 'low', directives: ['script-src-elem']},
];
const csp = create_csp_directives({
trusted_sources: my_csp_trusted_sources,
});
// Create a CSP that opts out of using Fuz's trust abstraction:
create_csp_directives({
directives: {
'img-src': ['self', 'https://*.my.domain/'],
// ...your explicit directives
},
// Simply omit `trusted_sources`,
// but note the above directives extend the base defaults.
});
// Create a CSP with no hidden base defaults,
// so it's fully declarative and explicit,
// like not using Fuz's CSP helpers at all:
const precise_csp = create_csp_directives({
value_defaults_base: null,
required_trust_defaults_base: null,
value_defaults: {
'img-src': ['self', 'https://my.domain/'],
'connect-src': ['self', 'https://my.domain/'],
},
});
// assert.deepEqual(precise_csp, {
// 'img-src': ['self', 'https://my.domain/'],
// 'connect-src': ['self', 'https://my.domain/'],
// });
// Transform/extend directives by passing a function:
const custom_csp = create_csp_directives({
trusted_sources: my_csp_trusted_sources,
directives: {
// Add additional domains to existing values:
'img-src': (v) => [...v, 'trusted.domain'], // extend trusted sources
// Or completely replace values:
'connect-src': ['self', 'trusted.domain'], // no base trusted sources!
'connect-src': () => ['self', 'trusted.domain'], // equivalent
// Example opt-in to eval:
'script-src-elem': (v) => [...v, 'unsafe-eval', 'wasm-unsafe-eval'], // alert alert
// Returning `undefined` or `null` removes the directive,
// all other values are passed through to SvelteKit.
},
});
Auditability and transparency are key concerns for the API, but some features are designed to help you to trade away some directness for ergonomics, with the idea that we make it easy for nonexpert users to safely configure basic scenarios, and advanced users can opt into using the API with full declarative transparency (and more verbosity and information load).
Fuz defines an optional system with three levels of trust/risk/sensitivity (low/medium/high, Csp_Trust_Level
) that can be configured for each trusted source to give blanket permissions at a specified
tier. Granular overrides are straightforward and declarative.
I'm trying to design for full customizability with clear, intuitive boundaries with escalating security and privacy implications. Fuz includes a debatable set of defaults, and input is appreciated to help tune the tradeoffs.
Trust #
Fuz provides an optional CSP abstraction with three trust levels (of type Csp_Trust_Level
) with tiers of escalating risk and implied permission. Sources can opt-in to blanket
permissions at a specific level:
export const my_csp_trusted_sources: Array<Csp_Source_Spec> = [
{source: 'https://a.domain/'}, // undefined `trust` - same as null
{source: 'https://b.domain/', trust: null}, // no trust
{source: 'https://c.domain/', trust: 'low'}, // passive resources only
{source: 'https://d.domain/', trust: 'medium'}, // no script execution
{source: 'https://e.domain/', trust: 'high'}, // arbitrary code execution
];
trust level | what it means | configured by required_trust_defaults_base |
---|---|---|
| No trust - used for directives that don't support sources | 'default-src', 'script-src-attr', 'manifest-src', 'child-src', 'object-src', 'base-uri', 'upgrade-insecure-requests', 'report-to', 'require-trusted-types-for', 'trusted-types', 'sandbox' |
| Passive resources only - no script execution, no styling or UI control | 'img-src', 'media-src', 'font-src' |
| Content that may affect layout, styling, or embed external browsing contexts, but cannot directly run code in the page's JS execution environment | 'style-src', 'style-src-elem', 'style-src-attr', 'connect-src', 'frame-src', 'frame-ancestors', 'form-action', 'worker-src' |
| Sources that can execute arbitrary code in the page's context | 'script-src', 'script-src-elem' |
The trust system introduces opt-in abstraction and indirection, and a downside of the design is that it encourages over-permissioning at each individual tier. The maintainers currently feel that this granularity with 3 tiers offers an intuitive base that gets most of the important questions right most of the time for most users, and additional safeguards are available for those that want tighter control or less chance of error.
Explicit directives #
The CSP helpers have a convenient, declarative API for defining directives per source. These
override any defaults, and unlike trust
, the directives
do not depend
on an abstraction layer, so WYSIWYG.
export const my_csp_trusted_sources: Array<Csp_Source_Spec> = [
{source: 'https://a.domain/'}, // No explicit directives, will use trust level if any
{source: 'https://b.domain/', directives: null}, // Explicitly no directives
{source: 'https://c.domain/', directives: ['img-src']}, // Only use for images
{source: 'https://d.domain/', directives: ['connect-src', 'font-src']}, // Allow for connections and fonts
];
Explicit directives are additive with the trust system. For example, a source with trust: 'low'
would normally not be allowed for connect-src
, but you
can explicitly permit this by including connect-src
in the directives array.
// Example: explicitly allowing a source for specific directives regardless of trust
export const my_csp_trusted_sources: Array<Csp_Source_Spec> = [
// Allow for specific directives (adds to what trust level allows):
{source: 'https://a.domain/', trust: 'low', directives: ['connect-src']},
// Trust-level provides baseline permissions, explicit directives add specific ones:
{source: 'https://b.domain/', trust: 'medium', directives: ['script-src-elem']},
// Both mechanisms work together - trust level provides baseline permissions
// and explicit directives add specific permissions
];
Base defaults #
The options value_defaults_base
(defaults to csp_directive_value_defaults
) and required_trust_defaults_base
(defaults to csp_directive_required_trust_defaults
) afford full control over defaults:
// Start with completely empty defaults (fully declarative):
const minimal_csp = create_csp_directives({
// Set both base values to null or {} to reset defaults
value_defaults_base: null, // or {} for same effect
required_trust_defaults_base: null, // or {} for same effect
// Define only what you need
value_defaults: {
'script-src': ['self'],
'img-src': ['self', 'data:'],
},
});
// The above is equivalent to not using Fuz's CSP abstraction at all:
assert.deepEqual(minimal_csp, {
'script-src': ['self'],
'img-src': ['self', 'data:'],
});
// Use your own custom base value defaults:
create_csp_directives({
// Define your own value defaults base
value_defaults_base: {
'default-src': ['none'],
'script-src': ['self'],
'img-src': ['self', 'data:'],
},
// Override specific directives in the base
value_defaults: {
'script-src': ['self', 'https://trusted.domain/'],
}
});
// Set custom trust requirements for directives:
create_csp_directives({
// Define your own trust requirements base
required_trust_defaults_base: {
'script-src': 'high',
'connect-src': 'medium',
'img-src': 'low',
},
// Source will be added based on your custom trust requirements
trusted_sources: [
// This source gets trusted for script-src and connect-src and no other directives.
// If the `required_trust_defaults_base` were omitted, it would have the normal defaults.
{source: 'https://somewhat.trusted.domain/', trust: 'medium'},
]
});
Directive specs #
The exported csp_directive_specs
has JSON data about the CSP directives.
Fuz omits deprecated directives.
directive | fallback | fallback of |
---|---|---|
default-src | script-src, script-src-elem, script-src-attr, style-src, style-src-elem, style-src-attr, img-src, media-src, font-src, manifest-src, child-src, connect-src, worker-src, object-src | |
script-src | default-src | script-src-elem, script-src-attr, worker-src |
script-src-elem | script-src, default-src | |
script-src-attr | script-src, default-src | |
style-src | default-src | style-src-elem, style-src-attr |
style-src-elem | style-src, default-src | |
style-src-attr | style-src, default-src | |
img-src | default-src | |
media-src | default-src | |
font-src | default-src | |
manifest-src | default-src | |
child-src | default-src | frame-src, worker-src |
connect-src | default-src | |
frame-src | child-src | |
frame-ancestors | ||
form-action | ||
worker-src | child-src, script-src, default-src | |
object-src | default-src | |
base-uri | ||
upgrade-insecure-requests | ||
report-to | ||
require-trusted-types-for | ||
trusted-types | ||
sandbox |
logos #
Fuz includes a number of logos available as data that can be mounted with the Svg component. Only the ones you use are included in your bundle.
-
<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} />
-
<Svg data={chatgpt_logo} />
-
<Svg data={claude_logo} />
-
<Svg data={gemini_logo} />
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={resolve('/docs/breadcrumb')}
>
<span class="font_size_xl">🔡</span>
{#snippet separator()}.{/snippet}
</Breadcrumb>
Card #
import Card from '@ryanatkn/fuz/Card.svelte';
<Card>
just<br />
a card
</Card>
a card
With a custom icon #
<Card>
custom<br />
icon
{#snippet icon()}📖{/snippet}
</Card>
icon
As a link #
<Card href="/">
a<br />
link
</Card>
link
As the selected link #
<Card href="/docs/card">
href is<br />
selected
</Card>
selected
With a custom HTML tag #
<Card tag="button">
custom<br />
tag
</Card>
With custom alignment #
<Card align="right">
align<br />
icon right
</Card>
icon right
<Card align="above">
align<br />
icon above
</Card>
icon above
<Card align="below">
align<br />
icon below
</Card>
icon below
Contextmenu #
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:
- giving users a powerful and customizable UX
- 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. iOS in particular seems buggy despite my attempts to accommodate it, help is appreciated.
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 contentWith 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 detailsDialog #
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="box">
<div class="pane p_xl box">
<h1>attention</h1>
<p>this is a dialog</p>
<button onclick={close}>ok</button>
</div>
</div>
{/snippet}
</Dialog>
{/if}
Hue_Input #
import Hue_Input from '@ryanatkn/fuz/Hue_Input.svelte';
With bind:value
#
bind:value
<Hue_Input bind:value />
bind:value === 180
With oninput
#
oninput
<Hue_Input
oninput={(v) => (value_from_oninput = v)}
/>
value_from_oninput === undefined
With children
#
children
<Hue_Input>
Some colorful hue input
</Hue_Input>
Docs #
Docs
is the component behind these docs. Its docs are unfinished - for now see
usage in Moss.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} />
npm i -D @ryanatkn/fuz
raw package metadata
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} />
Pending_Animation #
import Pending_Animation from '@ryanatkn/fuz/Pending_Animation.svelte';
<Pending_Animation />
••• The default animation has text children, so they scale with font-size
.
Set size with custom properties:
<Pending_Animation --font_size="var(--font_size_xl5)" />
Set size with classes:
<Pending_Animation attrs={{class: 'font_size_xl3'}} />
Size is inherited by default:
<div class="font_size_xl4"><Pending_Animation /></div>
With inline
#
inline
<Pending_Animation inline />
with inline={}
•••
With custom children #
<Pending_Animation --font_size="var(--font_size_xl6)">
{🐢}
</Pending_Animation>
with children
With children index
prop #
index
prop<Pending_Animation>
{#snippet children(index)}
<div class="box">
{🐸}
{index}
<span class="font_size_xl5">
{⏳}
</span>}
</div>
{/snippet}
</Pending_Animation>
with running={}
and children
With custom duration #
<Pending_Animation --animation_duration="var(--duration_6)" --font_size="var(--font_size_xl4)" />
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 /docs
<Redirect
host="https://www.felt.dev"
path="/docs"
let:url
auto={false}
>
the redirect url is {url}
</Redirect>
the redirect url is https://www.felt.dev/docsSvg #
import Svg from '@ryanatkn/fuz/Svg.svelte';
<Svg data={fuz_logo} />
Fills available space by default:
With custom size #
Set size
: (see the Moss typography docs)
<Svg data={fuz_logo} size="var(--icon_size_xl)" />
<Svg data={fuz_logo} size="var(--icon_size_sm)" />
Set --font_size
on the component or a parent:
<span style:--font_size="var(--icon_size_xl)"><Svg data={fuz_logo} /></span>
With custom color #
Set fill
: (see the Moss colors docs)
<Svg data={fuz_logo} fill="var(--color_d_5)" />
<Svg data={fuz_logo} fill="var(--color_b_5)" />
Set --text_color
on the component or a parent, for svgs that have no default fill:
<span style:--text_color="var(--color_i_5)"><Svg data={github_logo} /></span>
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>
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/themer.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/themer.svelte.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.