Skip to content

Sovereign Design System ​

@sovereignfs/ui is the Sovereign Design System: design tokens and React components shared by the runtime shell, the Console plugin, and every third-party plugin. It is a public contract — token names and component APIs are versioned with the same discipline as the SDK (NFR-04). Renaming a token or changing a component API is a breaking change.

This document is for contributors to the design system and for understanding how theming works. The plugin-developer consumption guide (how to use components and tokens inside a plugin) lives in docs/plugin-development.md (from v0.5).

Contents ​


Design principles ​

  • Monochrome and minimal. The identity is a neutral grey scale with a single near-black/near-white accent — restraint over decoration. Colour is something a tenant adds, not something the system imposes.
  • Tokens, never literals. Components reference --sv-* tokens only. A hardcoded colour, space, or radius in a component is a bug — it can't be themed and it drifts from the scale.
  • RSC-safe by default. Components are presentational and hold no state unless they genuinely need interactivity, so they render in both Server and Client Components.
  • Accessible by default. Visible focus states, correct semantics, and keyboard operability are part of "done," not a later pass.
  • No framework lock-in for tokens. Tokens are plain CSS custom properties — consumable from any CSS, no JS import required.
  • DS-first: plugins are consumers. Reusable UI/UX capability — interaction hooks, overlay surfaces, secondary headers, motion, controls (pickers, calendars) — ships from @sovereignfs/ui, or from the runtime shell when it is shell chrome. It is never implemented plugin-locally: when a gap or defect is discovered while working in a plugin, the fix is designed as a design-system addition that the plugin then consumes — not built in the plugin "to be promoted later". Plugin repositories keep only consumption code and genuinely plugin-specific logic.

Token architecture ​

Two tiers, all prefixed --sv-* (short, tied to the sv CLI identity; never abbreviated after the prefix):

primitives.css   raw, context-free scales — the palette, spacing, type, radii
  --sv-grey-50 … --sv-grey-950 · --sv-blue-* · --sv-space-1 … --sv-space-16
  --sv-font-size-label/xs/caption/sm … --sv-font-size-2xl · --sv-font-weight-bold
  --sv-radius-sm/md/lg/xl/2xl/3xl/full
        │  mapped by
        ▼
semantic.css     contextual roles — what components and plugins reference
  --sv-color-surface · --sv-color-text-primary · --sv-color-border
  --sv-color-accent · --sv-color-info-* · --sv-shadow-card/hover/popover …
  • Primitives are fixed. Theming never overrides them.
  • Semantic tokens are the theming surface. Dark mode and tenant theming override these values (at :root or under [data-theme]) — and because components only reference semantic tokens, nothing else changes.
  • Reference semantic colour tokens, not primitive colours, in components and plugin CSS. The scale tokens (--sv-space-*, --sv-radius-*, --sv-font-size-*) are theme-stable and used directly — they have no separate semantic layer because they don't change per theme.
    • Exception: --sv-radius-sm through --sv-radius-3xl are proportional to --sv-radius-scale (default 1, today's values), which an instance's corner-radius preset (None/XS/S/M/L, Console → Instance identity) can override at :root — the same InstanceProvider injection mechanism as --sv-color-accent (RFC 0077). Component and plugin code is unaffected: keep referencing --sv-radius-sm etc. by name, never --sv-radius-scale directly. --sv-radius-full (avatars, pills) is not part of the scale and stays fixed regardless of the preset.
    • Exception: every --sv-font-size-* is rem-based, so it scales with --sv-text-size-scale (default 1; 1.125 at [data-text-size='large'], 1.25 at [data-text-size='larger']) — the user's in-app text-size preference (Account → Preferences → Appearance, task 10.2), applied pre-paint the same way [data-theme] is. Unlike the radius exception, this also scales --sv-space-* and --sv-radius-*, since rem resolves against the root element's actual font-size, not a per-token override — see packages/ui/src/tokens/primitives.css's comment on --sv-text-size-scale.

The tokens ship as plain .css files. The runtime shell loads them once (@sovereignfs/ui/tokens.css, which imports primitives then semantic) so the variables are available globally to every plugin.

Token reference ​

Primitive tokens (src/tokens/primitives.css) ​

GroupTokens
Palette--sv-white, --sv-black, --sv-grey-50 … --sv-grey-950
Status/info--sv-red-*, --sv-amber-*, --sv-green-*, --sv-blue-* — see semantic layer for use
Spacing (4px)--sv-space-1 (4px) … --sv-space-16 (64px) — steps 1,2,3,4,5,6,8,10,12,16
Font family--sv-font-family (Hanken Grotesk → system-ui), --sv-font-family-mono (JetBrains Mono → ui-monospace)
Font size--sv-font-size-2xs (10px), -label (11px), -xs (12px), -caption (13px), -sm (14px), -md (16px), -lg (18px), -xl (20px), -2xl (24px)
Font weight--sv-font-weight-regular (400), -medium (500), -semibold (600), -bold (700)
Radius--sv-radius-sm (6px), -md (8px), -lg (11px), -xl (12px), -2xl (14px), -3xl (20px), -full
Border width--sv-border-width-hairline (1px) — RFC 0094, referenced instead of a literal border width
Icon size--sv-icon-size-xs (12px), -sm (16px), -md (20px), -lg (24px)

Font families: --sv-font-family names Hanken Grotesk as the preferred body font with a full system-font fallback stack; --sv-font-family-mono names JetBrains Mono. The web fonts are not loaded by the design system — operators must supply a <link> or @font-face via their instance CSS. The system fallback applies automatically when the fonts are absent.

Typography hierarchy — chrome vs. content: the persistent app chrome (the instance brand name in the sidebar / mobile header) and a page or overlay's own <h1> deliberately sit on different steps of the type scale. This is a design decision, not an oversight — don't "fix" it by making them match.

ElementTokenWhy
Brand name (sidebar / mobile header)--sv-font-size-md (16px)Wayfinding, not content — present and recognisable, never competing for attention
Page / overlay title (<h1>)--sv-font-size-2xl (24px)The primary thing on the screen — carries the strongest visual weight

Persistent chrome should never be the largest text on a page. If a change makes the brand name the same size as (or larger than) a page title, that's a hierarchy regression, not a fix.

Radius scale guidance:

TokenValueUse
--sv-radius-sm6pxbadge, tag
--sv-radius-md8pxbutton, input
--sv-radius-lg11pxsidebar icon, small card
--sv-radius-xl12pxcard, panel
--sv-radius-2xl14pxpopover
--sv-radius-3xl20pxbottom sheet (Drawer top corners)
--sv-radius-full9999pxpill, avatar

Semantic tokens (src/tokens/semantic.css) ​

TokenLightDarkRole
--sv-color-surfacewhitegrey-950Default background
--sv-color-surface-sunkengrey-50grey-900Recessed areas
--sv-color-surface-raisedwhitegrey-900Cards, popovers
--sv-color-text-primarygrey-950grey-50Primary text
--sv-color-text-mutedgrey-500grey-400Secondary text
--sv-color-text-subtlegrey-400grey-600Tertiary / de-emphasised text
--sv-color-text-on-accentwhitegrey-950Text on accent fills
--sv-color-bordergrey-200grey-800Hairline borders
--sv-color-border-stronggrey-300grey-700Emphasised borders
--sv-color-accentgrey-900grey-50Brand / interaction colour
--sv-color-accent-hovergrey-700grey-200Accent hover state
--sv-color-accent-subtlecolor-mix (accent 12%)color-mix (accent 12%)Tinted background paired with --sv-color-accent text, e.g. badges/chips
--sv-color-focus-ringgrey-900grey-100Focus outline
--sv-color-scrimcomposed rgbacomposed rgbaDialog backdrop overlay
--sv-shadow-cardcomposed shadowcomposed shadowCard elevation
--sv-shadow-hovercomposed shadowcomposed shadowCard hover lift (e1)
--sv-shadow-popovercomposed shadowcomposed shadowFloating panels (e2)
--sv-shadow-overlaycomposed shadowcomposed shadowDialog / overlay elevation
--sv-shadow-controlcomposed shadowcomposed shadowSmall interactive-control shadows, e.g. Toggle thumb
--sv-button-shadownonenoneRFC 0094 — Button's own shadow; a no-op default, set by a theme preset
--sv-button-press-x00RFC 0094 — Button's :active press-offset X; a no-op default
--sv-button-press-y00RFC 0094 — Button's :active press-offset Y; a no-op default

Status colours ​

A minimal error (red), warning (amber), and success (green) palette for banners, badges, and inline validation. Always reference these tokens — never hardcode colour hex in a component or plugin.

TokenLightDarkRole
--sv-color-error-surfacered-100red-900Error banner / badge background
--sv-color-error-textred-800red-200Text on error surface
--sv-color-error-borderred-200red-700Error surface border
--sv-color-warning-surfaceamber-100amber-900Warning banner / badge background
--sv-color-warning-textamber-800amber-200Text on warning surface
--sv-color-warning-borderamber-200amber-800Warning surface border
--sv-color-success-surfacegreen-100green-900Success banner / badge background
--sv-color-success-textgreen-800green-200Text on success surface
--sv-color-success-bordergreen-200green-800Success surface border
--sv-color-info-surfaceblue-100blue-900Info banner / badge background
--sv-color-info-textblue-800blue-100Text on info surface
--sv-color-info-borderblue-200blue-800Info surface border

These tokens are backed by --sv-red-*, --sv-amber-*, --sv-green-*, and --sv-blue-* primitive swatches defined in primitives.css. The primitives are fixed across themes; only the semantic mapping changes.

WCAG 2.1 AA contrast commitment: every status colour pair satisfies the minimum contrast requirements — 4.5:1 for text (≥18px bold or ≥24px regular), 3:1 for UI components and graphical objects. Test with the --sv-color-*-text token over its matching --sv-color-*-surface.

Accessibility ​

Focus visible ​

Every interactive component in @sovereignfs/ui exposes a :focus-visible outline referencing --sv-color-focus-ring. Third-party plugins must do the same — never suppress the focus ring with outline: none without providing an equivalent visible indicator.

css
/* Reference implementation (used in Button, Input, etc.): */
:focus-visible {
  outline: 2px solid var(--sv-color-focus-ring);
  outline-offset: 2px;
}

Reduced motion ​

Animated components honour prefers-reduced-motion: reduce. The OfflineBanner slide-in animation is suppressed when the user has requested reduced motion. Any new animated component must follow the same pattern:

css
@media (prefers-reduced-motion: reduce) {
  .myComponent {
    animation: none;
    transition: none;
  }
}

Per-component a11y contract ​

ComponentRole / elementKeyboardARIAFocus order
Button<button>Enter / Space activatedisabled attributeNatural
Input<input>Standard field editingtype, required, aria-invalid via parentNatural; label via htmlFor+id
Textarea<textarea>Standard field editingrequired, aria-invalid via parentNatural; label via htmlFor+id
CodeTextarea<textarea>Standard field editing; preserves whitespacerequired, aria-invalid via parentNatural; label via htmlFor+id
TagInput<input> + chip buttonsEnter/comma add; Backspace removes last chip when empty; chip buttons remove valuesaria-describedby/aria-invalid forwarded to the inner input; validation messages use role="alert"Natural through input and chip buttons
SuggestionInputrole="combobox" + role="listbox" panelArrowUp/ArrowDown cycle options; Enter selects active (or the create row); Esc closesaria-expanded, aria-controls, aria-activedescendant, options use role="option"/aria-selectedNatural; panel is non-modal, doesn't trap focus
IconPicker<button> trigger + role="listbox" panelStandard button activation opens/closes; options are plain buttons, Tab-navigablearia-haspopup, aria-expanded on trigger; options use role="option"/aria-selectedNatural; panel is non-modal, doesn't trap focus
QuantityStepper<input type="number"> + two buttonsStandard number-field editing; +/- buttons are a convenience, not the only input patharia-label required on the field; buttons use aria-label="Increase/Decrease {label}"Natural
CheckableListRowrole="checkbox" on the rowSpace/Enter togglesaria-checked, aria-label (the item label), aria-disabled when disabledNatural; trailing slot's own controls sit outside the checkbox role
NavList<nav> + <a> rows (or renderLink override)Standard link activationaria-label on the <nav>; aria-current="page" on the active row (variant="static" only)Natural
StatusBadge<span>N/AOptional aria-label for abbreviated visible labelsN/A
BalanceChip<span>N/APlain text content conveys the owed/owes/settled state — no separate aria-label neededN/A
CurrencyInput<input> (via Input)Standard field editingForwards all Input props, incl. aria-label/required/aria-invalidNatural; label via htmlFor+id or aria-label
SplitMethodSelectorrole="radiogroup" (via SegmentedControl)Standard SegmentedControl keyboard supportSame as SegmentedControl; aria-label defaults to "Split method"Natural
MemberMultiSelectList of Checkbox rowsStandard checkbox keyboard support per rowEach row delegates to Checkbox's own label/htmlFor pairingNatural
SplitPane<section> panes + resize buttonDrag resize button; ArrowLeft/ArrowRight resize; Home/End min/maxPane labels; resize button label includes the current primary-pane percentageResize button participates when resizable
FormField<label> + <div> wrapperN/A (delegates to its children control)Generates id/aria-describedby/aria-invalid, passed to the control via the render-prop childrenNatural
Dialogrole="dialog"Esc close, Tab/Shift-Tab traparia-modal="true", aria-label requiredFirst focusable on open; restore on close
Drawerrole="dialog"Esc close, Tab/Shift-Tab traparia-modal="true", aria-label requiredFirst focusable on open; restore on close
IconSVGNot focusablearia-hidden (decorative) or aria-label+role="img" (meaningful)N/A

Label association: prefer FormField — it generates the id (via useId() if none is given) and passes { id, 'aria-describedby', 'aria-invalid', required } to the control through its render-prop children, so the label, hint, and error stay correctly wired without any manual htmlFor bookkeeping:

tsx
<FormField label="Email" hint="We'll never share this.">
  {(field) => <Input {...field} type="email" />}
</FormField>

For a bare control outside FormField (e.g. Input used standalone), pair it with an explicit htmlFor on the label — jsx-a11y cannot trace through the custom wrapper to verify implicit association:

tsx
<label htmlFor="user-email">
  Email
  <Input id="user-email" type="email" />
</label>

Building a component ​

  1. Location: src/components/<Name>/<Name>.tsx with a co-located <Name>.module.css and <Name>.test.tsx.

  2. CSS Modules only. Style with a .module.css file; no inline styles, no CSS-in-JS, no Tailwind.

  3. Tokens only. Every colour, space, and radius value must be a --sv-* token reference. pnpm design:tokens:check (below) enforces this in CI. Documented exceptions — fixed px values that are legitimately below the scale or describe a control's own geometry, not a design value:

    • hairline borders (border: 1px solid …) and focus-ring widths/offsets (outline-width, outline-offset);
    • fixed affordance dimensions (checkbox/toggle/avatar/icon sizes, e.g. width: 18px for a toggle thumb);
    • alignment micro-adjustments (left: 2px, margin-top: -1px) that position a control detail relative to its own border;
    • animation transform distances (translateX(16px)) computed from the component's own fixed dimensions, not a design token.

    Anything else — a colour, a spacing gap, a border-radius — must be a token. pnpm design:tokens:check also fails on hardcoded hex/rgb() colour literals in packages/ui/src/components, runtime/app, and plugins/*/app — the check isn't limited to the design system package itself, since a hardcoded literal in the shell or a first-party plugin is the same drift with the same fix (add or reuse a token).

  4. RSC-safe. Keep components presentational and prop-forwarding. Add 'use client' only when the component genuinely needs hooks or browser state.

  5. Accessibility. Use the correct element/role, a visible :focus-visible state, and a :disabled treatment. Components must be keyboard-operable.

  6. Export the component and its prop types from src/index.ts.

  7. Test with Vitest + Testing Library. Component tests start with // @vitest-environment jsdom. At minimum, assert the component renders and exposes its key props.

Button is the reference implementation of all of the above.

Token validation ​

pnpm design:tokens:check (scripts/design-tokens-check.ts) scans every var(--sv-...) reference in packages/ui/src, runtime/app, and plugins/*/app and fails if it doesn't resolve to a token actually defined in packages/ui/src/tokens/{primitives,semantic}.css (plus the runtime-shell layout namespace — --sv-shell-* / --sv-dialog-inset-* — defined in runtime/app/globals.css and shell.module.css; see --sv-dialog-inset-top below). It also fails on hardcoded hex/rgb()/rgba() colour literals in .module.css files under packages/ui/src/components, runtime/app, and plugins/*/app. packages/ui is checked because third-party plugins inherit whatever ships there; the shell and first-party plugins are checked because a literal reached for under time pressure instead of a token is exactly how a screen ends up half-adhering to the design system, and it's cheaper to catch mechanically than in review. Runs in CI (design-tokens job) after typecheck, and as part of pnpm verify:push (the pre-push hook).

Icon system (RFC 0011) ​

The design system ships a curated set of Lucide icons as inline, RSC-safe SVG components. lucide is a build-time devDependency only — the published @sovereignfs/ui package carries zero runtime or peer icon dependencies.

<Icon> component ​

tsx
import { Icon } from '@sovereignfs/ui';

// Decorative — described by surrounding text; hidden from screen readers.
<Icon name="house" size="lg" aria-hidden />

// Meaningful — standalone affordance; announced by screen readers.
<Icon name="log-out" size="md" aria-label="Sign out" />

Props:

PropTypeDefaultDescription
nameIconNamerequiredName from the curated icon set
size"xs" | "sm" | "md" | "lg""md"Binds to --sv-icon-size-* tokens (12 / 16 / 20 / 24px)
classNamestring—Additional CSS class on the SVG
aria-hiddentrue—Decorative use — provide one of aria-hidden or aria-label
aria-labelstring—Meaningful use — adds role="img" automatically

Color follows currentColor — an icon inherits the text color of its container and recolors with theme changes automatically. No extra CSS required.

Token binding ​

Icons use the --sv-icon-size-* primitive scale tokens:

TokenValueUse
--sv-icon-size-xs12pxInline metadata (e.g. a due-date badge)
--sv-icon-size-sm16pxInline with body text
--sv-icon-size-md20pxStandard affordance (default)
--sv-icon-size-lg24pxProminent / standalone

Curated icon set ​

The icon list lives in scripts/icon-list.ts. To add an icon:

  1. Add the Lucide kebab-case name to the list.
  2. Run pnpm generate:icons.
  3. Commit the new file in packages/ui/src/components/Icon/icons/ alongside the updated index.ts.

The set is intentionally small — add only icons the platform chrome or plugin ecosystem actively uses.

Current icons: house, settings, log-out, chevron-right/left/down/up, x, check, plus, trash-2, pencil, rotate-ccw, search, user, shield, lock, eye, eye-off, mail, bell, activity, package, grid-2x2, info, alert-triangle, calendar, sliders-horizontal, ellipsis-vertical, external-link, plus a file/upload pair and a curated grocery-item/category set (Sovereign Shopper, SHP-05) — see scripts/icon-list.ts for the full, authoritative list.

Plugin-identity icons vs UI-affordance icons ​

There is an explicit split between two kinds of icons:

  • UI-affordance icons (home, settings, close, …) — use <Icon name="…">.
  • Plugin-identity icons — the author-supplied icon.svg in the plugin's root directory, wired into the Launcher tile and sidebar via <img src="/plugin-icons/<id>.svg" alt="">. Never injected with dangerouslySetInnerHTML — SVG from third-party plugins is untrusted markup (RFC 0008 §4). The monogram is the fallback when a plugin ships no icon.svg.

Plugin icons are copied to runtime/public/plugin-icons/ by the generate script at dev startup and build time, served statically at /plugin-icons/<id>.svg without a session gate.

Lucide license ​

Lucide is ISC-licensed. The attribution is in packages/ui/NOTICE, which ships with the published package.

Theming ​

Theming swaps semantic token values; components never change.

Dark mode ships in semantic.css as a [data-theme='dark'] block. Set the attribute on a root element to switch:

html
<html data-theme="dark">
  …
</html>

Tenant theming (CON-08) works the same way: a tenant overrides semantic tokens at :root (for example, giving Sovereign a brand colour by setting --sv-color-accent). Primitives stay fixed; only the semantic layer is overridden. Because every component references the semantic layer, a theme is purely a set of CSS variable values — no component or build changes required.

Theme presets (RFC 0094/0095) generalize this into a closed, built-in set of full visual identities — default (today's look, byte-identical) and neobrutalism — each bundling border width, shadow shape, and corner radius together, defined once in packages/ui/src/tokens/theme-presets.ts and consumed both as generated CSS ([data-theme-preset='neobrutalism'], for Storybook and standalone use) and as a plain TS object (imported directly by runtime/src/instance-style.ts for live-instance delivery via the same InstanceProvider :root-injection pipe accent colour and radius already use — never as an <html> attribute in production). An operator picks a preset via Console → Instance identity or INSTANCE_THEME_PRESET; their already-configured accent colour and radius preset continue to override the chosen preset's own defaults for those specific tokens.

Important — --sv-radius-scale and any future preset token that composes into another token via calc() can only be overridden at the actual :root element (<html>), never a scoped/nested wrapper. --sv-radius-sm through -3xl are each calc(var(--sv-radius-scale) * ...), declared once at :root in primitives.css — a custom property referenced inside another custom property's calc() resolves using the value cascaded at the element where the outer property is declared, not re-resolved per descendant. See .storybook/preview.ts's withRadius/withThemePreset decorators for the reference implementation of this constraint. Leaf tokens (e.g. --sv-border-width-hairline, --sv-button-shadow) have no such restriction.


Standalone usage (outside the plugin runtime) ​

@sovereignfs/ui works as an ordinary npm dependency in an app that is not a Sovereign plugin and does not run inside the Sovereign runtime shell — no plugin manifest, no runtime composition, no session access, and no dependency on @sovereignfs/sdk (RFC 0073). It's published to the npm registry today; check npm view @sovereignfs/ui for the current version.

Installation ​

bash
npm install @sovereignfs/ui

Importing tokens ​

The package exports a standalone tokens stylesheet — import it once, at your app's root:

ts
import '@sovereignfs/ui/tokens.css';

This pulls in both tiers (primitives then semantic). If you only need one tier directly, @sovereignfs/ui/tokens/primitives.css and @sovereignfs/ui/tokens/semantic.css are separately importable too.

No root provider required ​

Every component in @sovereignfs/ui is a pure CSS-variable/props consumer — there is no SovereignUIRoot-style wrapper to mount and none is required to use components outside the runtime shell. The package has zero @sovereignfs/sdk coupling and no createContext/useContext usage that crosses component boundaries (Toast and Dialog manage their own local state internally, not a shared runtime-injected global). If a future component needs shared context (theme, density, locale), introducing one would be a breaking change subject to the semver policy below — not something to assume silently.

Hooks are runtime-independent ​

useIsMobile, useIsOffline, useLongPress, useDoubleTapHandler, and useCommitOnEnterOrBlur have no dependency on the Sovereign runtime or any other package in this monorepo — they're safe to use in any React 18+ app, standalone or otherwise.

Dark mode outside the runtime shell ​

Inside the runtime shell, dark mode is driven by a [data-theme='dark'] attribute the shell sets for you (see Theming above). A standalone consumer sets the same attribute itself, on its own root:

ts
document.documentElement.dataset.theme = 'dark'; // or 'light'

There's no built-in prefers-color-scheme auto-detection — that choice (system preference vs. explicit toggle vs. always-light) is left to the consuming app.

No same-origin or runtime-relative asset dependencies ​

Verified for this task: @sovereignfs/ui has no fetch(), new URL(), or src="/…"-style runtime-relative asset reference anywhere in its source. Icons in particular (Icon system) are bundled as inline SVG React components at build time — no icon font, sprite sheet, or CDN request, and no dependency on being served from the same origin as any Sovereign instance.

Minimal example ​

tsx
import '@sovereignfs/ui/tokens.css';
import { FormField, Input, Button } from '@sovereignfs/ui';

function ExampleForm() {
  return (
    <form>
      <FormField label="Email" id="email">
        {(field) => <Input {...field} type="email" name="email" />}
      </FormField>
      <Button type="submit">Submit</Button>
    </form>
  );
}

Standalone-surface stability ​

@sovereignfs/ui follows the same strict semver discipline as @sovereignfs/sdk (NFR-04 — see docs/sdk-stability.md): patch releases never break, minor releases are additive-only, and breaking changes require a major bump plus a migration note in docs/upgrade.md. For the specific case of standalone (non-plugin) consumption:

  • Stable — token names (both tiers), and the core primitives (Button, FormField, Input, Card, StatusBadge, Icon, and the four hooks listed above). Safe to build against today.
  • Experimental for standalone use — editor-workflow primitives added for plugin-internal use (e.g. SplitPane, epic task 9.16) have not been independently audited for standalone (non-runtime) behavior. They likely work, but carry a smaller compatibility guarantee until audited.

Instance identity tokens (RFC 0027 / RFC 0032) ​

The --sv-instance-* namespace is a separate tier from --sv-color-*. Instance tokens hold URLs (logo and favicon paths), not colours. They are set once by the operator at deploy time or via Console → Settings → Instance identity — they do not change with dark mode or user preferences.

css
:root {
  --sv-instance-logo: none; /* URL of the light-theme logo */
  --sv-instance-logo-dark: none; /* URL of the dark-theme logo (falls back to --sv-instance-logo) */
  --sv-instance-favicon: none; /* URL of the favicon */
}

InstanceProvider (a runtime server component) sets these at :root from the instance_config table, merged over INSTANCE_* env-var defaults. Plugins running inside the shell receive the values automatically — no import required.

Why a separate namespace? ​

Property--sv-color-*--sv-instance-*
Value typeColour (#hex, HSL)URL (url(…))
Changes onDark mode, user prefsOperator deploy
Theming useDynamic swapStatic identity

Mixing URL values into the colour namespace would conflate two concerns and confuse plugin developers — a colour picker returns #hex, not a url().

Using instance tokens in plugin CSS ​

css
/* In your plugin's CSS module */
.logo {
  background-image: var(--sv-instance-logo);
  background-size: contain;
  width: 36px;
  height: 36px;
}

Note: --sv-instance-logo is a CSS url() value, so it works in background-image but not in src attributes directly. Use the API routes (/api/instance/logo, /api/instance/favicon) in HTML <img> tags, or use sdk.platform.getConfig() which returns instanceName as a string prop.

Instance name ​

The instance name is a React prop, not a CSS custom property. CSS custom properties cannot supply rendered text content for HTML elements — they only work with content: on pseudo-elements, which is too narrow for the shell chrome. InstanceProvider passes instanceName as a render-prop to the layout.

Plugin developers read it from sdk.platform.getConfig():

ts
const config = await sdk.platform.getConfig();
// config.instanceName — the operator-configured display name
// config.instancePrimaryColor — validated hex or undefined

Responsive & mobile ​

This section covers the design-system conventions for building mobile-responsive UI — the breakpoint constant, the Drawer primitive, touch-target sizing, safe areas, and dynamic viewport height. (RFC 0013.) For the practical, plugin-author- facing version of this material — a long-press recipe, a PWA-feel checklist, and guidance on when double-tap latency is and isn't worth it — see docs/plugin-development.md's "Building for mobile".

Breakpoint convention ​

Sovereign uses a single mobile breakpoint: 768px.

css
@media (max-width: 768px) {
  /* mobile styles */
}

All shell components (sidebar/header/footer, Dialog mobile sheet, Drawer) flip at 768px. Because CSS custom properties cannot be used inside @media conditions, this value is a documented constant rather than a --sv-* token — reference this doc, not a magic number. Per-component custom breakpoints are rejected in design-system code reviews; a single documented threshold prevents the 641–768px mismatch band that previously existed between the shell (768px) and the Dialog (640px).

Dynamic viewport height ​

Use 100dvh (with a 100vh fallback) instead of 100vh for full-screen surfaces. Mobile browser UI (address bar, tab strip) and the virtual keyboard shrink 100vh; dvh tracks the actual available height:

css
.fullBleed {
  min-height: 100vh; /* fallback for older engines */
  min-height: 100dvh;
}

The shell already uses this convention; follow it in full-screen plugin UI.

Safe-area insets ​

In standalone/fullscreen PWA mode, viewport-fit=cover extends content into the notch and home-indicator areas. Apply env(safe-area-inset-*) to elements that would otherwise collide with hardware:

css
.header {
  padding-top: max(var(--sv-space-3), env(safe-area-inset-top));
}

.footer {
  padding-bottom: max(var(--sv-space-2), env(safe-area-inset-bottom));
}

Using max(...) means the inset only applies when it is larger than the base padding — the element keeps its minimum spacing on devices without notches.

Touch targets — --sv-touch-target-min ​

The primitive token --sv-touch-target-min: 44px is the minimum hit-area dimension for icon-only interactive controls (avatar button, footer actions, Drawer nav items). 44px matches the Apple HIG / Material Design / WCAG 2.5.5 guideline for reliable tap without precision pointing:

css
.iconButton {
  min-width: var(--sv-touch-target-min, 44px);
  min-height: var(--sv-touch-target-min, 44px);
}

This applies on mobile. Desktop icon-only controls (sidebar icons) can be smaller because mouse interaction is more precise.

Hover guard convention — @media (hover: hover) ​

Every :hover rule in packages/ui is wrapped in @media (hover: hover). Without the guard, a tap on a touch device generates a synthetic hover state that sticks until the user taps elsewhere — a button stays visibly "hovered" (often mid-transition to its darker/lighter hover color) after the tap has already completed its action, reading as a stuck or broken control:

css
/* Wrong — sticks after a tap on touch devices */
.button:hover {
  background-color: var(--sv-color-accent-hover);
}

/* Right */
@media (hover: hover) {
  .button:hover {
    background-color: var(--sv-color-accent-hover);
  }
}

:focus-visible and :active are never guarded — focus and press feedback are wanted on every input type, hover feedback is not. When a selector combines :hover with another pseudo-class in one rule (e.g. .handle:hover, .handle:focus-visible), split it: only the :hover branch goes behind the guard.

A hover-triggered reveal (an element that is invisible until its container is hovered, e.g. DragHandleRow's drag handle) needs the same guard for a different reason: :not(:hover) is unconditionally true wherever hover doesn't exist at all, so an unguarded reveal-on-hover pattern never reveals anything on a touch device. Gate the whole hide rule behind @media (hover: hover) so the element defaults to visible when hover isn't available.

Button and Checkbox touch ergonomics ​

Button:

  • Touch target — min-height: var(--sv-touch-target-min, 44px) under @media (pointer: coarse) (all sizes, including sm).
  • :active pressed state — a color-mix()-darkened background per variant, unguarded (touch and mouse both want press feedback; see Hybrid devices below for why this is safe on desktop).
  • loading — new boolean prop. Disables the button, sets aria-busy, and renders a small currentColor spinner before the children (children stay mounted and visible — the spinner doesn't replace them). The spinner is a self-contained token-only element in Button.module.css, not a reuse of the Spinner component: Spinner's border colors are semantic-fixed (--sv-color-accent), which would be invisible against a primary button's accent background, and overriding one CSS module's rule from a second module on the same element is a cross-module cascade-order gamble (same tradeoff ConfirmDialog's .destructiveConfirm already documents). currentColor picks up each variant's own text color for free.
  • touch-action: manipulation — removes the ~300ms double-tap-to-zoom delay on touch.

ConfirmDialog's .destructiveConfirm — a plain <button> styled to match Button's .button + .md rules exactly (see the component doc above for why it isn't a literal reuse of Button) — carries the same touch target, hover guard, and active state so the two stay visually identical.

Checkbox:

  • Hit-area expansion, not box growth. The visible box stays 18px (task rows align it to the pixel against header icons — growing the box would shift layout). The invisible native <input type="checkbox">, which already covers the box via position: absolute; inset: 0, expands past the box's edges under @media (pointer: coarse):

    css
    .box {
      --checkbox-box-size: 18px;
    }
    
    @media (pointer: coarse) {
      .input {
        inset: calc((var(--checkbox-box-size) - var(--sv-touch-target-min, 44px)) / 2);
      }
    }

Both expansions gate on (pointer: coarse) — the primary pointer, not (any-pointer: coarse). A touchscreen laptop with a mouse/trackpad as its primary pointer keeps desktop density; only a device whose primary input is actually a finger gets the larger target. This also means Button's new :active state renders on a plain mouse click, not just touch — that is intended pressed-state feedback, not a mobile-only affordance.

Interaction hooks ​

Touch gesture handling is deceptively easy to get wrong — a naive long-press timer or a hand-rolled matchMedia check reliably misfires in ways that read as "the app feels janky." These hooks carry the fixes so no plugin has to rediscover them. Per the DS-first principle (see "Design principles" above), build touch gesture handling here, not in a plugin.

tsx
import {
  useLongPress,
  useDoubleTapHandler,
  useSingleOrDoubleTap,
  useIsMobile,
  useIsOffline,
  useOfflineTileState,
  useCommitOnEnterOrBlur,
  OfflineGate,
  DeviceOnlyGate,
  DeviceStorageKeyGate,
} from '@sovereignfs/ui';

useLongPress({ onLongPress, delay?, moveTolerance?, suppressClickMs?, vibrate?, disabled? }) Returns a props object to spread onto the target element (onPointerDown/onPointerMove/onPointerUp/onPointerCancel/onPointerLeave/ onContextMenu/onClick/style). Handles the failure modes a bare setTimeout misses:

  • Movement tolerance (moveTolerance, default 10px): a real finger jitters a few px even holding still — cancelling on any movement makes the gesture misfire constantly.
  • pointercancel, not just pointerup/pointermove/pointerleave: when the browser converts the touch into a scroll, the timer is cleared instead of firing mid-scroll.
  • OS callout suppression: onContextMenu preempts Android's link/image context menu; the returned style sets -webkit-touch-callout: none (iOS link-preview), user-select: none, and touch-action: manipulation — but only when the device's primary pointer is coarse ((pointer: coarse)), so desktop mouse text-selection is never disabled by a hook that only matters for touch.
  • Time-boxed click suppression (suppressClickMs, default 700ms): the click that may or may not follow a fired long-press is swallowed for a bounded window, not indefinitely — iOS often sends no click at all after a long hold, so a flag that only clears "on the next click" stays armed and silently eats the user's next unrelated tap.
tsx
function Row({ onSelect }: { onSelect: () => void }) {
  const longPress = useLongPress({ onLongPress: onSelect });
  return <div {...longPress}>Hold to select</div>;
}

useDoubleTapHandler(onDoubleTap) / useSingleOrDoubleTap(onSingle, onDouble) Desktop double-clicks report e.detail === 2 natively; touch has no equivalent signal, so double-tap is detected by timing two taps within 350ms. Use useDoubleTapHandler when the single tap has no default action to cancel (e.g. a colour swatch). Use useSingleOrDoubleTap when it does (e.g. navigation) — it defers the single action by the double-tap window so a following second tap can still preempt it; this means every single tap through it incurs that latency, so reach for it only where the preemption is genuinely needed.

useCommitOnEnterOrBlur(onCommit) Returns { onKeyDown, onBlur } to spread onto a quick-entry input/textarea so Enter and losing focus both commit through the same callback. iOS Safari adds its own "Previous / Next / Done" toolbar above the software keyboard whenever more than one focusable field is nearby (a Sheet with several fields, a form, etc.) — there is no supported way to suppress it, since it's WebKit's own field-detection heuristic, not something the page controls. Tapping that toolbar's Done/checkmark only ever fires a native blur; it is not a form submit and dispatches no keydown, so a field that commits only on Enter silently discards whatever was typed the moment a user dismisses the keyboard that way instead of pressing the on-screen Return key — the two dismissal paths look identical to the user but produce different outcomes.

tsx
function AddTaskRow({ onAdd }: { onAdd: (title: string) => void }) {
  const [title, setTitle] = useState('');
  const commit = () => {
    const trimmed = title.trim();
    if (!trimmed) return; // onCommit owns its own empty no-op
    onAdd(trimmed);
    setTitle('');
  };
  const commitHandlers = useCommitOnEnterOrBlur(commit);
  return <input value={title} onChange={(e) => setTitle(e.target.value)} {...commitHandlers} />;
}

Use this for any input where Enter is the primary, sole way to commit (quick-add rows, inline rename) — not for a field inside a form with its own always-visible submit button (login, payment, a dialog's "Save"): there, losing focus should not silently submit, so leaving blur alone is correct. onCommit is called unconditionally on both Enter and blur; it owns any empty-value no-op itself, the same way every call site's commit function already guards before doing anything.

useIsMobile(breakpointPx?) and MOBILE_BREAKPOINT_PX (768) SSR-safe viewport check — defaults to false (desktop) until the client mounts and reads the real viewport, matching every other SSR-safe hook in this system. Defaults to the platform's single documented breakpoint (768px, see above); pass a different value only when a layout genuinely needs its own threshold.

useIsOffline() SSR-safe navigator.onLine tracking — defaults to false (online) until the client mounts and reads the real value, same SSR-safety pattern as useIsMobile. Reflects only the browser's own signal: true whenever the OS reports a network interface is up, even without real upstream connectivity (e.g. Wi-Fi with no internet) — there is currently no better cross-browser signal available. The platform shell's own OfflineBanner is built on this hook rather than deriving connectivity separately (research 0012); use it directly for any other connectivity-reactive UI.

OfflineGate({ children, surfaceName?, className? }) Wraps a surface's content and swaps it for an EmptyState while useIsOffline() is true, instead of rendering children against whatever happens to be cached. Reach for this only where operating against stale data is actively wrong — an administrative or settings surface (Console, Account) whose content is a point-in-time snapshot with no way to signal it may be stale — not for ordinary pages, where the shell's OfflineBanner informing the user is enough and the content itself is still worth showing.

tsx
<OfflineGate surfaceName="Console">
  <UserList />
</OfflineGate>

useOfflineTileState(offline, deviceOnlyAvailable) Which of two app-tile states, if any, applies given a plugin's declared offline manifest tier (research 0012, epic task 2.33): 'connectivity-dimmed' (a no-tier app, reactive and temporary, only while genuinely offline right now) or 'capability-restricted' (a device-only app on a surface without the store it needs — static, unrelated to connectivity, and must never read as "offline" to a user who plainly isn't). deviceOnlyAvailable is the caller's own isDeviceOnlyTierAvailable() result (from @sovereignfs/sdk/device-client) — passed in rather than computed here, so this package never depends on @sovereignfs/sdk. The Launcher's PluginTile and the shell's own Apps-drawer item both apply this identically rather than each re-deriving the two states slightly differently.

tsx
const state = useOfflineTileState(plugin.offline, isDeviceOnlyTierAvailable());
// 'connectivity-dimmed' | 'capability-restricted' | null

DeviceOnlyGate({ children, surfaceName?, available, className? }) The device-only-tier counterpart to OfflineGate: blocks a plugin's own content from rendering on a surface that can't provide the durable, encrypted, device-auth-gated store that tier requires, showing an explanatory empty state instead of a broken screen. A device-only plugin wraps its own root content in this the same way Console/Account wrap theirs in OfflineGate — an opt-in pattern each surface applies to itself, not a platform-level route gate, since a user can always reach a route directly (a bookmark, a deep link) without passing through a launcher tile first. available is the same isDeviceOnlyTierAvailable() result useOfflineTileState takes — not a security boundary; the actual protection is that the data is encrypted and the key requires device auth to release (epic task 1.22), independent of whether this gate is present, bypassed, or never reached at all.

tsx
<DeviceOnlyGate available={isDeviceOnlyTierAvailable()} surfaceName="Wallet">
  <CardList />
</DeviceOnlyGate>

DeviceStorageKeyGate({ children, surfaceName?, status, setupAction?, className? }) The enrollment counterpart to DeviceOnlyGate: that gate checks whether the device-only tier is available on this surface at all; this one checks whether the user has actually set up their Device Storage Key (RFC 0093 §2, epic task 1.22) — the one-time, per-device secret every device-only plugin on that device shares. status is the caller's own getDeviceStorageKeyStatus() result (from @sovereignfs/sdk/device-only-storage) — passed in rather than computed here, same boundary available draws for DeviceOnlyGate. Enrollment is centralized in Account → Security, not per-plugin — a device-only plugin never runs its own enrollment ceremony; it wraps its own root content in this gate (typically nested inside DeviceOnlyGate) and supplies setupAction, a link to Account → Security, for the 'not-set-up' case.

status also carries a 'no-device-auth' value — RFC 0093 §5's hard-block case, where the environment supports the tier but this device has no passcode, fingerprint, or face unlock configured at all, so a key cannot be created here regardless of what the user does in your plugin. Distinct from 'not-set-up' (which the user can fix by clicking through setupAction): this one shows an explanatory message with no setupAction, since the fix is a device settings change, not anything Account → Security can help with — the same reasoning DeviceOnlyGate's "Phone only" message has no action either.

tsx
<DeviceOnlyGate available={isDeviceOnlyTierAvailable()} surfaceName="Notes">
  <DeviceStorageKeyGate
    status={deviceStorageKeyStatus}
    surfaceName="Notes"
    setupAction={<Link href="/account/security">Set up Device Storage Key</Link>}
  >
    <NoteList />
  </DeviceStorageKeyGate>
</DeviceOnlyGate>

useSwipeReveal({ revealWidth, open, onOpen, onClose, disabled? }) Horizontal swipe-to-reveal for a row's trailing actions (e.g. Done/Delete on a task or list row) — extracted from sovereign-tasks' two independent hand-rolled implementations (RFC 0079). Returns { rowRef, handlers } to spread onto the row. Axis-locks after 8px of movement (horizontal vs. vertical) so a vertical list scroll and this gesture never fight each other; while locked horizontal, it writes the row's transform directly to the DOM node during the drag (bypassing React re-renders, for 60fps tracking with no per-pointermove render), and resolves open/closed only on release, past the reveal's halfway point. open is fully controlled — this hook has no internal open state — so a caller can close any other open row the moment a different one opens (only one row's actions revealed at a time).

tsx
const { rowRef, handlers } = useSwipeReveal({
  revealWidth: 88,
  open,
  onOpen: () => setOpen(true),
  onClose: () => setOpen(false),
});
<div ref={rowRef} {...handlers} style={{ transform: open ? 'translateX(-88px)' : undefined }}>
  Row content
</div>;

useSnapCarousel({ itemCount, onSettle?, debounceMs? }) Debounced "settled slide" detection over a native scroll-snap-type: x container — extracted from sovereign-tasks and sovereign-shopper's independently duplicated carousels (RFC 0079). Momentum and rubber-banding come entirely from the browser's own scroll physics; this hook only handles "which slide did the user land on," via a passive scroll listener debounced 120ms by default (not the scrollend event, which pre-17.4 iOS Safari/WKWebView doesn't support), deduped so a settle on the same index isn't reported twice. Returns { scrollRef, scrollToIndex } — attach scrollRef to the scroll container; call scrollToIndex to jump programmatically (e.g. from a dot indicator). This is the low-level primitive SwipableMobileCarousel (below) wraps — reach for that compound component directly for a route-driven carousel; use this hook directly only for a simpler, non-route-synced swipeable strip.

tsx
const { scrollRef, scrollToIndex } = useSnapCarousel({
  itemCount: slides.length,
  onSettle: (index) => setActiveIndex(index),
});
<div ref={scrollRef} style={{ display: 'flex', overflowX: 'auto', scrollSnapType: 'x mandatory' }}>
  {slides.map((slide) => (
    <div style={{ scrollSnapAlign: 'start' }}>{slide}</div>
  ))}
</div>;

useReorderSensors({ mouseActivationDistancePx?, touchActivationDelayMs?, touchActivationTolerancePx? }) dnd-kit sensor set for a drag-reorder list, extracted from plugins/account's sidebar-order UI after the identical pattern turned up duplicated byte-for-byte across three separate plugins. Returns dnd-kit's sensors value — pass straight to <DndContext sensors={...}>. Bundles a MouseSensor (handle-initiated, desktop) and a TouchSensor (long-press lift, mobile — dnd- kit's built-in pointer sensor never implements native HTML5 Drag-and-Drop for touch input, so this is what makes reordering usable on iOS PWA/Safari) with a shared exclusion rule: a drag started inside an element marked data-no-dnd is refused, so a nested interactive control (a visibility toggle, a delete button) fires its own handler instead of lifting the row. A bare call keeps the values plugins/account already shipped: 8px mouse activation distance, 300ms touch long-press delay, 8px touch drift tolerance. The exclusion predicate is also exported standalone as shouldHandleDndEvent(target) for unit testing without spinning up dnd-kit.

tsx
const sensors = useReorderSensors();
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
  <SortableContext items={ids} strategy={verticalListSortingStrategy}>
    {/* rows — a nested toggle/button opts out of drag via data-no-dnd */}
  </SortableContext>
</DndContext>;

@dnd-kit/core/@dnd-kit/sortable are optional peer dependencies of @sovereignfs/ui — a consumer must install them itself (the package never bundles dnd-kit, so exactly one shared DndContext module instance is used across the app).

Some views are route-based on web but want a genuinely different mobile UX — a swipeable filmstrip of the same sibling routes, not a squeeze of a multi-column layout. This is a recurring pattern, not a one-off: it shows up wherever a plugin has a set of sibling list/detail routes (a task list app, a shopping-list app). Build it with these shared primitives, not a hand-rolled scroller — every hand-rolled version of this so far has independently duplicated the same scroll-snap CSS and pathname-sync logic.

tsx
import {
  SwipableMobileCarousel,
  SwipableMobileCarouselSlide,
  SwipableMobileCarouselSlideHeader,
  SwipableMobileCarouselSlideBody,
  SwipableMobileCarouselSlideFooter,
  SwipableMobileCarouselDots,
  useCarouselRouteSync,
  ResponsiveSurface,
  useResponsiveLayout,
} from '@sovereignfs/ui';

ResponsiveSurface({ web, mobile, breakpointPx? }) and useResponsiveLayout({ web, mobile, breakpointPx? }) Formalize the "render an entirely different component tree below a breakpoint, not a CSS squeeze of the same one" fork — only the active side is ever mounted. Built on useIsMobile (same breakpoint/SSR-safe-default behavior, not reimplemented). Use the component form for the common two-tree JSX case; use the hook form when picking between two non-JSX values, or when more than a simple tree-swap is needed.

tsx
<ResponsiveSurface web={<ThreeColumnLayout />} mobile={<MobileCarousel />} />

SwipableMobileCarousel + SwipableMobileCarouselSlide (with SwipableMobileCarouselSlideHeader/Body/Footer) This library's first compound component — every other component here is flat/prop-based, but slide content is large, arbitrary, per-plugin subtrees that need a header-known/body-loading split an array-of-objects prop would only express with worse JSX ergonomics. Wraps useSnapCarousel internally: native scroll-snap-type: x supplies the actual swipe physics, this component only handles mount-window lazy rendering, a dev-mode guard against non-Slide children, and re-snapping when the active slide's position changes (e.g. a caller re-sorts their slide array while mounted).

tsx
const { activeIndex, onSettle } = useCarouselRouteSync({
  indexForPathname: (p) => indexForPathname(p, lists), // your plugin's own logic, unchanged
  pathForIndex: (i) => pathForIndex(i, lists),
  pathname: usePathname(),
  onNavigate: (path) => router.replace(path, { scroll: false }),
});

<SwipableMobileCarousel activeIndex={activeIndex} onSettle={onSettle} aria-label="Task lists">
  {lists.map((list) => (
    <SwipableMobileCarouselSlide key={list.id} slideKey={list.id} label={list.title}>
      <SwipableMobileCarouselSlideHeader>{list.title}</SwipableMobileCarouselSlideHeader>
      <SwipableMobileCarouselSlideBody loading={!listState[list.id]}>
        <TasksPane list={list} tasks={listState[list.id]?.tasks ?? []} />
      </SwipableMobileCarouselSlideBody>
    </SwipableMobileCarouselSlide>
  ))}
</SwipableMobileCarousel>;

SwipableMobileCarouselSlideHeader/Footer always render their children — no loading concept. SwipableMobileCarouselSlideBody's loading prop scopes the loading-placeholder swap to just that region. This split matters: a caller renders the header from data it already has synchronously (a list's title) unconditionally, and gates only the body on its own slower fetch — the natural way to compose these three as siblings already produces a title that shows immediately while only the body shows a brief loading state, instead of blanking the whole slide (title included) behind one boolean until the fetch resolves.

SwipableMobileCarouselDots({ count, activeIndex, onJump, labels?, 'aria-label', density? }) A real, tappable, labeled slide indicator (role="tablist"/role="tab", keyboard-focusable) — standalone and reusable outside a carousel too (e.g. an image gallery), and also SwipableMobileCarousel's default renderIndicator. Pass renderIndicator={null} to render no indicator, or a render function to substitute a custom one. density?: 'default' | 'compact' (default 'default') halves the gap between dots for a carousel with enough slides that the default spacing reads as long/cramped on a narrow viewport — opt-in, every existing consumer's spacing is unchanged unless it passes this prop. Only the gap changes; each dot keeps its own 20px hit target. To use it on SwipableMobileCarousel's own default indicator (rather than standalone), supply a renderIndicator that forwards it: renderIndicator={(props) => <SwipableMobileCarouselDots {...props} density="compact" />}.

useCarouselRouteSync({ indexForPathname, pathForIndex, pathname, onNavigate }) Router-agnostic pathname↔slide-index sync — this package has zero runtime dependencies and never imports a router; the caller supplies pathname (e.g. via next/navigation's usePathname()) and an onNavigate callback (e.g. (path) => router.replace(path, { scroll: false })). Centralizes the "was this pathname change our own swipe-settle, or an external navigation (a tapped <Link>, browser back/forward)" distinction — getting this wrong produces a visible double-flicker, re-snapping the scroller right after the user's own gesture already landed correctly.

Ownership boundary — read this before building a slide. SwipableMobileCarousel and its Slide/Header/Body/Footer own rendering and mount-window mechanics only. They have no opinion on where slide data lives, and nothing here stops you from doing the wrong thing — this is a rule to follow, not a type-level constraint:

  • Do not aggregate cross-slide data inside a SlideBody. A rollup that reads across every slide's data (e.g. a "starred across all lists" view) recomputes on every slide's own render regardless of which slide is active — it belongs in the parent component, which already knows every slide exists, not inside one slide's body.
  • Do not mount a detail/edit overlay (Sheet, Dialog) inside a Slide's children. Mount it as a sibling of SwipableMobileCarousel instead, controlled by the same state that would otherwise drive a routed page's overlay. An overlay nested inside a slide recomputes its own optimistic/authoritative merge logic on every render of that slide, and gets torn down by the mount-window whenever its owning slide scrolls out of the prefetch window.

This isn't a hypothetical: a real hand-rolled carousel in this codebase does both of the above, and is measurably laggier than a sibling implementation that keeps its overlay in the routed pane instead. Keep the split.

Swipe stack (card triage) ​

A different mobile pattern from the carousel above: dispose of one card at a time (Done/Defer/Skip) by dragging it left/right/up/down, dating-app-style, rather than browsing a fixed set of sibling routes. The motivating case is a "Today's Agenda" view, but the component itself has no domain vocabulary — it reports which direction a card left in and lets the caller decide what that means.

tsx
import { SwipeStack, SwipeStackCard, useSwipeStack } from '@sovereignfs/ui';

useSwipeStack({ directions, threshold?, onCommit, disabled? }) Hand-rolled free drag extending useSwipeReveal's pattern (raw Pointer Events, a deadzone, transform written straight to a ref, resolved only on release) from one axis with two outcomes to four directions with a tilt. Threshold-only on purpose, matching useSwipeReveal — no velocity tracking, so a fast short flick under threshold (default 96px) still springs back. Returns { cardRef, wrapRef, handlers, touchAction, triggerCommit }: cardRef goes on the element that translates/rotates; wrapRef goes on the ancestor of both the card and any stamp overlays, since the hook writes live drag progress there as --sv-stack-progress-{left,right,up,down} custom properties (0–1) for stamps to react to without a re-render per pointermove; touchAction is the CSS value to apply to the card, derived from which directions are live (see below); triggerCommit(direction) plays the same fling animation and fires the same onCommit a real drag past threshold would, for a non-gesture trigger.

A direction absent from directions is a wall, not just an unreachable outcome — the card will not visually move that way at all (dragging toward it is clamped to zero on that axis). This is also what touchAction is derived from: an axis with nothing configured on it stays native (pan-x/pan-y), so a page can still scroll through a stack that only uses the other axis; touchAction is none only once both axes are actually needed.

SwipeStack + SwipeStackCard The compound component wrapping useSwipeStack, mirroring SwipableMobileCarousel's children-based composition (SwipeStackCard plays SwipableMobileCarouselSlide's role: cardId is its stable identity, read via child.props the same way slideKey is, since a component can't see its own React key). Unlike the carousel, only the top two cards (current + a peeking preview) are ever mounted — there is no scroll-snap track requiring every sibling to exist in the DOM, so there is no mount-window concept to configure.

tsx
<SwipeStack
  aria-label="Today's agenda"
  directions={{
    up: { label: 'Done', icon: 'check' },
    down: { label: 'Defer', icon: 'history' },
    right: { label: 'Skip', icon: 'chevron-right' },
    // left intentionally omitted — not mandatory
  }}
  onSwipe={(direction, cardId) => {
    if (direction === 'up') markDone(cardId);
    else if (direction === 'down') defer(cardId);
    else skip(cardId);
  }}
>
  {tasks.map((task) => (
    <SwipeStackCard key={task.id} cardId={task.id}>
      <TaskAgendaCard task={task} />
    </SwipeStackCard>
  ))}
</SwipeStack>

Every configured direction behaves the same way once triggered: the card leaves for good, there is no reversible "go back" state. SwipeStack tracks its own dismissed-card set internally, so a swiped card never reappears even if the caller is slow to remove it from children (e.g. an optimistic update that hasn't landed yet) — the same "no opinion on where card data lives" boundary SwipableMobileCarousel draws for slide data; the caller is responsible for eventually no longer including a dismissed cardId, but SwipeStack's own display never depends on how fast that happens.

A non-gesture fallback — a button per configured direction, using that direction's label/icon — is not optional and not a separate code path: it renders automatically from the same directions config and calls triggerCommit under the hood, so it is wired to the identical commit logic and exit animation a real drag produces. A SwipeStack with zero gesture support (all directions triggered only via the buttons) is not a degraded state — it's the same component working as designed for a caller who wants that.

Motion ​

Primitive tokens for overlay enter/exit transitions, theme-stable like the other scale tokens (no semantic tier — dark mode does not change motion):

css
--sv-motion-duration-fast: 150ms; /* micro-interactions */
--sv-motion-duration-base: 250ms; /* default overlay enter/exit */
--sv-motion-duration-slow: 350ms; /* larger surfaces, longer travel */
--sv-motion-ease-out: cubic-bezier(0, 0, 0.2, 1); /* entrances */
--sv-motion-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); /* state changes */
--sv-motion-ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); /* emphasis — use sparingly */

Dialog and Drawer (and their Phase-B successors: Sheet, ConfirmDialog, Menu) use --sv-motion-duration-base / --sv-motion-ease-out for their enter/exit transitions — fade + scale for Dialog on desktop, slide-up on mobile and for Drawer always. Both components' public open/onClose API is unchanged: closing stays internally mounted for the exit transition before actually unmounting, so a consumer never needs to coordinate with the animation.

Building an animated overlay in packages/ui: use the internal useMountTransition(open, durationMs) hook (src/motion.ts, not exported from index.ts) — a two-phase-mount state machine (entering → open on open, closing → closed on close) that both Dialog and Drawer share, so every overlay in the system animates consistently instead of each component re-deriving the same rAF-timing logic. Pair it with usePrefersReducedMotion() (same file) to pass a near-zero durationMs when the user has motion disabled, and add a matching @media (prefers-reduced-motion: reduce) CSS rule collapsing the same transition to near-instant — the JS unmount timer and the CSS transition duration must agree, or the component stays mounted (invisibly) for the normal duration even though it already looks closed.

Page layout — PageContainer ​

As of task 9.25, the runtime shell applies no padding to plugin content at all. (Earlier versions of this doc — and of the shell — said the opposite: the shell padded every plugin and PageContainer only constrained width. That's no longer true; see docs/upgrade.md if you're updating a plugin written against the old contract.) The shell still reserves clearance for chrome a plugin cannot measure itself — space below the offline banner while it's visible, space above the mobile footer — but contributes no inset of its own. A plugin that renders no PageContainer sits edge-to-edge.

Wrap every plugin page (or its root layout) in PageContainer. It is the single place a plugin's padding and max-width come from — nowhere else:

tsx
import { PageContainer } from '@sovereignfs/ui';

export default function Layout({ children }: { children: React.ReactNode }) {
  return <PageContainer maxWidth="md">{children}</PageContainer>;
}

Both props default to the common case — a full-width page with the shell's former gutter — so a bare <PageContainer>{children}</PageContainer> is a safe starting point for any plugin.

maxWidthPixel valueUse when
sm640pxA narrow single-column form or document (e.g. a text editor)
md960pxA readable-width content page
lg1200pxWide list/table-heavy screens
fullnoneNo width constraint — the common case (default)

maxWidth bounds the whole box, padding included — PageContainer is border-box, so maxWidth="md" with the default padding="md" yields a 960px box with 896px of content, not 1024px.

paddingDesktop≤768pxUse when
none00An ancestor already pads this content, or a fullbleed layout
sm--sv-space-4 (16px)--sv-space-3 (12px)A dense, chrome-like page
md--sv-space-8 (32px)--sv-space-4 (16px)The common case (default) — matches the shell's former gutter
lg--sv-space-12 (48px)--sv-space-6 (24px)A spacious marketing-style layout

A fixed four-step scale, deliberately — not a free-form value. A consistency review (the one that motivated PageContainer in the first place, task 9.18) found four different ad-hoc padding combinations across plugins; a free-form prop would just relocate that inconsistency from CSS modules into JSX. Do not add your own outer padding or max-width in a plugin's app/layout.tsx or page-level CSS module — declare it through PageContainer's props instead. Adding both stacks the two insets (a real bug this fixed: every example plugin under example-plugins/ was double-padded until task 9.25).

Plugins that manage their own full-bleed shell/sidebar/content grid opt out via data-plugin-fullbleed on their root element (sovereign-tasks, sovereign-shopper) and should not use PageContainer — it has nothing to add once a plugin owns its layout entirely.

Inside an overlay (shell: "overlay"). An overlay plugin's page renders into two different hosts from one component tree — the Dialog on a soft navigation, the runtime shell's full-page fallback on a hard one — and Dialog's own content region is already padded. Wrap the page in PageContainer as usual; a Dialog ancestor sets --sv-page-gutter: 0 on its content region, which PageContainer reads as a stand-down signal and renders with no padding of its own, so the same tree is correctly padded exactly once in both hosts. This is automatic — no prop, no per-context branching in plugin code.

MobileHeader and MobileFooter components (RFC 0088) ​

The runtime shell's mobile header and footer are built from two presentational, prop-driven components — MobileHeader and MobileFooter. Both draw a deliberate line between what's immutable (rendered unconditionally, no prop controls it) and what's overridable:

ComponentImmutableOverridable
MobileHeaderBell, avatar menulogo — defaults to an "S" badge (matches the shell's own logo-less fallback) when omitted; title — absent by default
MobileFooterCentered "Apps" launcher (onOpenApps)leftIcons / rightIcons — 1 or 2 items each, symmetric
tsx
import { MobileHeader, MobileFooter } from '@sovereignfs/ui';

<MobileHeader
  logo={<Link href="/">…</Link>} // optional — omit to get the default "S" badge
  title="Tasks" // optional — omit to match the no-title default
  bell={<NotificationBell />}
  avatarMenu={<AccountMenu placement="header" {...userProps} />}
/>;

<MobileFooter
  onOpenApps={() => setDrawerOpen(true)}
  leftIcons={[{ icon: <Icon name="house" aria-hidden />, label: 'Home', href: '/' }]}
  rightIcons={[{ icon: <Icon name="search" aria-hidden />, label: 'Search', onClick: openSearch }]}
/>;

Neither component fetches data or imports @sovereignfs/sdk — bell and avatarMenu are supplied as already-wired ReactNodes by the consumer (the runtime shell today), and leftIcons/rightIcons entries take a plain href or onClick rather than a router import, keeping both components framework-agnostic. MobileFooter logs a dev-mode-only console.error (never thrown) if leftIcons.length !== rightIcons.length, since a mismatch would visually uncenter the launcher — the same non-fatal guard pattern used by SwipableMobileCarousel's non-Slide-children check.

Both components own their own chrome — background, border, safe-area-aware padding, sticky positioning, and a 768px max-width (so neither stretches full-bleed if mounted outside an actual mobile viewport) — matching the runtime shell's own hand-rolled mobile header/footer markup exactly, since these components are meant to eventually replace it. A consumer that disables the shell's own header/footer for its route (manifest.json's shellConfig.mobileHeader/mobileFooter: false, RFC 0075) to render these itself gets --sv-shell-header-height/--sv-shell-footer-height re-declared automatically — both components measure their own rendered height and publish it on the shell root (usePublishShellChromeHeight; the shell zeroes the variable when that chrome is off, and this overrides that zero back to the real value) — no manual re-declaration needed. Drawer, Dialog, and Sheet all read those variables to know how much screen edge to clear; this is what stops them rendering straight to the true viewport edges, underneath the consumer's own self-rendered chrome. See docs/architecture-rules.md for the incident this closes (found live in sovereign-tasks, generic to any plugin using this pattern).

This extraction is deliberately scoped as groundwork, not a plugin-facing override mechanism — see RFC 0088 for the full immutable/overridable rationale and the open questions a future RFC would need to resolve before a plugin could render this chrome itself.

MobileAppsDrawer component ​

A bottom-sheet "Apps" launcher grid — the same 3-column, 56px/radius-14px tile layout as the runtime shell's own Apps drawer (opened from MobileFooter's launcher button), generalized so the tile list comes from the consumer via an items prop instead of being hardcoded to installed plugins:

tsx
import { MobileAppsDrawer } from '@sovereignfs/ui';

<MobileAppsDrawer
  open={open}
  onClose={() => setOpen(false)}
  aria-label="Sections"
  items={sections.map((s) => ({
    key: s.slug,
    icon: <Icon name={s.icon} size="lg" aria-hidden />,
    label: s.label,
    onClick: () => {
      setOpen(false);
      goTo(s.slug);
    },
  }))}
/>;

Deliberately has no header row (title/close button) — Drawer's own grab handle plus swipe-down/scrim-tap dismissal are enough, and a header ate into the vertical space a launcher grid needs most. items accept either an href (renders an <a>) or an onClick (renders a <button>), same dual-mode convention as MobileFooter's FooterIcon.

Overlay surfaces — which component for which job ​

One API per surface; presentation adapts per platform. On mobile the shell chrome (header + footer nav) stays visible and interactive — these overlays fill the space between, never cover it.

DesktopMobileUse when
Dialog (sm/md/lg/auto)Centered fixed-size modal, optional header/footer rowsFull-page modal page between shell header/footer, slide transition, OverlayHeader on topModal: the user must act before continuing (overlay-shell plugins — Console, Account)
ConfirmDialogSmall content-sized centered cardSame centered card (decision D4 — not a full-screen sheet)Destructive / confirm prompts
DrawerBottom sheet (rare on desktop)Partial-height bottom sheet (half screen or less), grab handle, swipe-down dismiss, snapHeightNavigation or options revealed by tapping a trigger (Apps button, Menu on mobile)
Sheetn/a — desktop shows the same content inline (columns/panes)Full-page slide-in page replacementDetail views inside a plugin (task detail, list edit)
Menu (adaptive)PopoverDrawerContext / ⋯ action menus

All overlay components share --sv-color-scrim / --sv-shadow-overlay tokens, the same focus-trap / Esc / scrim-click dismissal convention (Sheet has no scrim — see its own doc comment), and the two-phase-mount animation described in Motion above.

OverlayHeader ​

tsx
import { OverlayHeader } from '@sovereignfs/ui';

<OverlayHeader
  title="Edit list"
  onClose={onClose}
  onBack={onBack}
  action={<Button size="sm">Save</Button>}
/>;

The shared fixed secondary header for Dialog's mobile mode (and Dialog's header prop, both breakpoints — see below), Sheet, and Drawer: title + close, optional back button, trailing action, and an optional second row (e.g. a tab strip). Not itself position: sticky or fixed — it stays visually pinned because the consuming overlay renders it as a non-scrolling flex sibling before its own scrollable content region. title accepts any ReactNode (not just a string) and is optional (some consumers need the close button present with no title); content needing a genuinely different row shape (not a simple title/back/action/close layout) should render its own header instead of using this component.

Three shapes via two optional props, header/footer (both ReactNode, alongside the existing children as the body):

ShapeProps passed
Body only (default)neither
Header + Bodyheader
Header + Body + Footerheader and footer
tsx
import { Button, Dialog } from '@sovereignfs/ui';

<Dialog
  open={open}
  onClose={onClose}
  header="Edit card"
  footer={
    <div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
      <Button variant="secondary" onClick={onClose}>
        Cancel
      </Button>
      <Button onClick={onSave}>Save</Button>
    </div>
  }
>
  {/* scrollable body */}
</Dialog>;

header, when provided, supersedes the plain title prop for visible content and renders on both desktop and mobile via the same OverlayHeader component — unlike title alone, which only ever appears in the mobile-only title bar (desktop has no header row by default). title remains the aria-label fallback either way, so pass it (or aria-label directly) even when using header. footer renders as a second pinned row after the scrollable body, on both breakpoints; omit it and any actions stay inside the body's own scroll region, matching the pre-existing behavior. Both new rows are non-scrolling flex siblings around .content — the same technique OverlayHeader itself already uses, not position: sticky — so .content remains the single scrollable region in every shape. footer is layout-agnostic (no built-in button alignment); arrange its content same as children.

useOverlaySecondRow — the double-header problem ​

An overlay-shell plugin (Console, Account) is rendered by Dialog on mobile, which shows its own OverlayHeader (title from the manifest + close) above the plugin's children. If the plugin also renders its own title/tab-strip header as ordinary content, mobile ends up with two stacked header bars — the Dialog's and the plugin's own. useOverlaySecondRow solves this by letting content inside Dialog (however deeply nested — a plugin's route layout, several component layers below wherever <Dialog> itself is instantiated) supply the Dialog's second-row content directly:

tsx
import { useOverlaySecondRow } from '@sovereignfs/ui';

function AccountLayout({ children }) {
  const tabStrip = <nav aria-label="Account sections">{/* ...tab links... */}</nav>;
  // true when an enclosing Dialog actually received the tab strip; false
  // when this layout is rendered standalone (no Dialog ancestor) — e.g. a
  // hard-navigated, non-overlay route showing the same layout.
  const insideOverlay = useOverlaySecondRow(tabStrip);

  return (
    <div>
      {/* Only reached when NOT inside a Dialog — hide this inline copy on
          mobile when insideOverlay is true, since the Dialog's own
          OverlayHeader is showing tabStrip there instead. */}
      <header className={insideOverlay ? 'hiddenOnMobile' : undefined}>
        <h1>Account</h1>
        {tabStrip}
      </header>
      {children}
    </div>
  );
}

A no-op (returns false, does nothing) when there is no enclosing Dialog — safe to call unconditionally, including from a layout that's also rendered on a plain non-overlay route. The caller is still responsible for hiding its own inline header copy on mobile when the return value is true, via its own CSS — useOverlaySecondRow only moves the content, it doesn't hide anything itself, since the same JSX renders in both places by design (single source of tab-strip markup, no duplicated component). See plugins/account/app/layout.tsx and plugins/console/app/layout.tsx for the reference implementation.

Drawer component ​

tsx
import { Drawer } from '@sovereignfs/ui';

<Drawer open={open} onClose={() => setOpen(false)} aria-label="App navigation" snapHeight="content">
  {/* any content — lists, grids, etc. */}
</Drawer>;

The Drawer panel sizes to its content by default (capped at 80dvh; snapHeight="half" fixes it to 50dvh instead — per the "half screen or less" convention, taller content belongs in Sheet), is safe-area-aware on the bottom (env(safe-area-inset-bottom) padding), and is focus-trapped. The scrim covers the full viewport so it works in any shell context. A built-in grab handle is the sole drag-initiation region for swipe-down-to-dismiss (dragging body content would fight its own internal scroll) — release past 100px dismisses; release under that, or a pointercancel, snaps back open. Like Sheet, passing title renders a built-in OverlayHeader (title + close) below the grab handle; omit it (the default) when the grab handle and swipe-down/scrim-tap dismissal are enough on their own, or the content supplies its own header.

Sheet component ​

tsx
import { Sheet } from '@sovereignfs/ui';

<Sheet open={open} onClose={onClose} title="Task detail">
  {/* detail content */}
</Sheet>;

Fills a plugin's content area between the shell's fixed header and footer, sliding in from an edge (slideFrom="bottom" default, or "top" for a short options menu near a header trigger) instead of centering like Dialog. No desktop equivalent — a desktop layout shows the same content inline (columns, panes) rather than mounting a Sheet at all. No scrim: a Sheet visually replaces the region it covers, so there's no "outside" to tap-dismiss. Passing title renders a built-in OverlayHeader; omit it when the content supplies its own header.

ConfirmDialog component ​

tsx
import { ConfirmDialog } from '@sovereignfs/ui';

<ConfirmDialog
  open={open}
  onClose={onClose}
  onConfirm={onConfirm}
  title="Remove passkey"
  message="You will no longer be able to sign in with this passkey."
  confirmLabel="Remove"
  destructive
/>;

Built on the native <dialog> element (top-layer rendering and ::backdrop for free — what makes a confirm opened from inside another overlay reliably stack above it with no manual z-index scheme), not Dialog, which is a fixed-size box by design. destructive renders a solid red confirm action (via --sv-color-error-solid / --sv-color-text-on-error, not Button's own destructive variant, which is an outlined/lower-emphasis style). pending + error support an async onConfirm that should keep the dialog open to report a failure — onConfirm never closes the dialog itself; the caller decides via onClose/open.

tsx
import { Menu } from '@sovereignfs/ui';

<Menu
  trigger={<Button size="sm">Actions</Button>}
  open={open}
  onClose={onClose}
  aria-label="List actions"
  items={[
    { label: 'Rename', icon: 'pencil', onSelect: handleRename },
    { label: 'Delete', icon: 'trash-2', destructive: true, onSelect: handleDelete },
  ]}
/>;

Adaptive: Popover on desktop, Drawer on mobile — the same items list renders in both. Selecting an item both closes the menu and calls its onSelect; a consumer's onSelect never needs to call onClose itself. Replaces the desktop-Popover/mobile-Drawer fork a ⋯ action menu otherwise re-derives per plugin.

items accepts three entry shapes, mixed freely in one list — enough to express a native-OS-style menu with grouped sections above a run of plain actions:

tsx
items={[
  { type: 'label', label: 'Sort by' }, // non-interactive section heading
  { label: 'Manual', checked: sortBy === 'manual', onSelect: () => setSortBy('manual') },
  { label: 'Title', checked: sortBy === 'title', onSelect: () => setSortBy('title') },
  { type: 'separator' }, // a divider
  { label: 'Delete list', icon: 'trash-2', destructive: true, onSelect: handleDelete },
]}

checked marks an item as one of a mutually-exclusive set — it renders role="menuitemradio" with a leading checkmark instead of role="menuitem". Pass it on every item in the group, including the unchecked ones, so their labels stay aligned with the checked one's reserved checkmark gutter; omit it entirely on plain action items (e.g. "Delete list"), which render with no reserved leading space at all.

--sv-dialog-inset-top ​

Mirrors --sv-dialog-inset-left on the vertical axis. The runtime shell sets it to the mobile header height (--sv-shell-header-height) so an open Dialog or overlay-sheet begins below the sticky header — the header stays visible and interactive. Defaults to 0 (full-viewport scrim) for standalone use:

css
/* Shell sets this on mobile: */
.shell {
  --sv-dialog-inset-top: var(--sv-shell-header-height);
}

Plugin code does not need to set this; it is a platform-level concern wired by the shell.

Calendar and DatePicker ​

tsx
import { DatePicker } from '@sovereignfs/ui';

<DatePicker
  value={dueDate}
  onChange={setDueDate}
  aria-label="Due date"
  placeholder="Select date"
/>;

Calendar is a keyboard-navigable month grid — date-only (no time or range selection yet; recurrence UI stays plugin-side). Keyboard follows the WAI-ARIA APG grid pattern with roving tabindex: arrow keys move focus by day/week, Home/End jump to the start/end of the focused week, PageUp/PageDown change month, Enter/Space selects. minDate/maxDate disable out-of-range dates.

DatePicker wraps Calendar behind a form-field trigger (styled to match Input): Popover on desktop, a bottom-sheet Drawer on mobile — the platform's standard adaptive-surface pattern (see Menu, docs/design-system.md's Responsive & mobile section). Unlike Menu, the trigger is built in rather than supplied by the caller, since a date picker is a form field, not an arbitrary action-menu trigger.

tsx
import { Calendar } from '@sovereignfs/ui';

// Embed the grid directly (e.g. inside a custom Popover/Drawer) instead of
// using DatePicker's built-in trigger:
<Calendar value={selected} onChange={setSelected} minDate={today} aria-label="Pick a date" />;

Component stories (Storybook) ​

Every @sovereignfs/ui component has a Storybook story. The Storybook instance is the living design reference for component authors, plugin developers, and designers — each component is rendered in isolation with every meaningful variant, both light and dark themes, and responsive viewports.

Hosted: sovereignfs.github.io/storybook — deployed by pushing a sb-v* tag (e.g. git tag sb-v0.11.0 && git push origin sb-v0.11.0), via .github/workflows/storybook-deploy.yml. The built static site is published to sovereignfs/storybook; source stories live in this repository. See Sovereign repositories for the full support-repository map.

Running locally ​

bash
# From the monorepo root:
pnpm storybook        # starts dev server at http://localhost:6006

# Or directly from the package:
pnpm --filter @sovereignfs/ui storybook

The dev server hot-reloads on source changes to both components and CSS tokens.

Story organisation ​

Stories live under two roots inside packages/ui/src/:

RootPurpose
stories/Cross-cutting reference docs: Overview, Token Gallery, Mobile Patterns
components/<Name>/Per-component story file co-located with the component

What's covered ​

Overview & reference (src/stories/) ​

Story fileWhat it documents
DesignSystemOverview.stories.tsxFull component gallery with live demos and import lines; colour palette; type scale; shadow scale; design rules
TokenGallery.stories.tsxLive gallery of every --sv-* token tier — colours, space, typography, radius, icon sizes, shadows — read from computed styles
MobilePatterns.stories.tsxMobile layout reference: breakpoints (640/768 px), constrained column, auto-adapting components, shell chrome anatomy (header/footer/drawer/search overlay), touch targets, safe-area insets, typography scale, readiness checklist

Components (src/components/<Name>/) ​

Story fileKey variants and notes
Avatar.stories.tsxInitials fallback; image src; sm/md/lg sizes
Badge.stories.tsxAll status variants; subtle vs filled
BalanceChip.stories.tsxOwed (positive); owes (negative); settled up (zero); all three states together
Button.stories.tsxAll variant × size combinations; disabled; icon-leading; icon-only; AllVariants grid
Card.stories.tsxsm/md/lg padding; interactive hover; semantic element variants
CheckableListRow.stories.tsxDefault; checked; with icon and trailing; checked with icon and trailing; disabled; multiple rows in a list context
CodeTextarea.stories.tsxDefault; FormField integration; error; disabled; long content; mobile viewport
CurrencyInput.stories.tsxEmpty; prefilled cents value; disabled
Dialog.stories.tsxsm/md/lg/auto sizes; closed state; play function opens and asserts visibility; Body only / Header + Body / Header + Body + Footer composition shapes
Drawer.stories.tsxMobile viewport default; closed; play function opens and asserts panel visible
EmptyState.stories.tsxHeading only; with icon; with action
FormField.stories.tsxDefault; with hint; with error (role="alert"); render-prop children wires field props (id, aria-describedby, aria-invalid, required) onto the control
Icon.stories.tsxDecorative vs meaningful a11y variants; all three sizes; AllIcons full grid
IconPicker.stories.tsxDefault; preselected value; disabled
Input.stories.tsxText/email/password; disabled; error state with aria-invalid
MemberMultiSelect.stories.tsxDefault checked/unchecked rows; with a per-row trailing CurrencyInput
NavList.stories.tsxstatic with an active item; drilldown mobile viewport; ungrouped; custom renderLink
NavTabs.stories.tsxDefault; active tab; mobile horizontal-scroll viewport
PageHeader.stories.tsxTitle only; with description; with action slot
Popover.stories.tsxFour placements; trigger + content
QuantityStepper.stories.tsxDefault; with unit; fractional step (0.5 kg); at minimum; with max; disabled
SegmentedControl.stories.tsxTwo and three options; controlled selection
Select.stories.tsxDefault; disabled; with placeholder
Spinner.stories.tsxsm/md/lg sizes; reduced-motion note
SplitMethodSelector.stories.tsxControlled Equal/Amount/Percentage/Shares selection
SplitPane.stories.tsxResizable editor/preview; fixed panes; keyboard resizing; long content; mobile single-column fallback
StatusBadge.stories.tsxAll editor status states; accessible abbreviated label; long content; mobile wrapping
SuggestionInput.stories.tsxControlled with matched options; without create row; open with pre-filled matches; loading; empty (no matches); disabled
SystemBanner.stories.tsxAll four categories (info/success/warning/error); dismissible
Tabs.stories.tsxControlled tabs; default selected; keyboard navigation
TagInput.stories.tsxControlled tags; FormField integration; error; disabled; keyboard behavior; long content; mobile viewport
Textarea.stories.tsxDefault; with value; custom rows; disabled
Toast.stories.tsxAll six categories triggered imperatively via useToast
Toggle.stories.tsxOn/off; disabled; label association
Tooltip.stories.tsxFour placement variants; hover and focus triggers

Themes toolbar ​

The Themes toolbar addon (top-right in the canvas) toggles data-theme="dark" on the canvas root. Since all component styles reference semantic tokens, switching themes re-renders correctly without touching any component code.

A11y panel ​

Every story runs @storybook/addon-a11y automatically. The Accessibility panel (bottom of the canvas) reports WCAG 2.1 violations. Violations are treated as build errors in CI — storybook build fails if any story has an a11y issue.

Adding a story for a new component ​

  1. Create packages/ui/src/components/<Name>/<Name>.stories.tsx.
  2. Use satisfies Meta<typeof YourComponent> (not Meta<typeof YourComponent> alone) so TypeScript infers arg types.
  3. Add the component to the Component Gallery section of DesignSystemOverview.stories.tsx — both the import and a <ComponentCard> entry.
  4. Always pair <Input> and similar bare elements with a <label> in stories — the a11y addon flags unlabelled controls as errors.
  5. Use aria-hidden for decorative icons and aria-label for meaningful ones.
  6. Run pnpm storybook locally and confirm the a11y panel shows no violations.
  7. Run pnpm --filter @sovereignfs/ui typecheck to confirm the stories are type-correct.

Building and deploying ​

bash
pnpm build-storybook          # static output → packages/ui/storybook-static/
pnpm --filter @sovereignfs/ui build-storybook  # same, scoped

CI (storybook-build job): runs build-storybook on every non-draft PR and uploads the static output as a workflow artifact (7-day retention) — download storybook-static from the Actions run to review the PR's stories without a live hosting service.

Hosted deploy (.github/workflows/storybook-deploy.yml): pushes the static build to the gh-pages branch of sovereignfs/storybook. Triggered by pushing a sb-v* tag — the tag does not need to match a package version, just mark the point you want live. Can also be triggered manually via Actions → Deploy Storybook → Run workflow.

bash
git tag sb-v0.11.0
git push origin sb-v0.11.0

Open source under AGPL-3.0. Each Sovereign instance is independently operated.