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.

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-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
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

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
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)
classNamestringAdditional CSS class on the SVG
aria-hiddentrueDecorative use — provide one of aria-hidden or aria-label
aria-labelstringMeaningful 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.


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 targetmin-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,
  useCommitOnEnterOrBlur,
} 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.

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 (enteringopen on open, closingclosed 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.

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/full)Centered fixed-size modalFull-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, 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 is optional (some consumers need the close button present with no title); content with a fully custom header (e.g. an editable title) should render its own header instead of using this component.

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.

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/full sizes; closed state; play function opens and asserts visibility
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
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.