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
:rootor 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)
| Group | Tokens |
|---|---|
| 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.
| Element | Token | Why |
|---|---|---|
| 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:
| Token | Value | Use |
|---|---|---|
--sv-radius-sm | 6px | badge, tag |
--sv-radius-md | 8px | button, input |
--sv-radius-lg | 11px | sidebar icon, small card |
--sv-radius-xl | 12px | card, panel |
--sv-radius-2xl | 14px | popover |
--sv-radius-3xl | 20px | bottom sheet (Drawer top corners) |
--sv-radius-full | 9999px | pill, avatar |
Semantic tokens (src/tokens/semantic.css)
| Token | Light | Dark | Role |
|---|---|---|---|
--sv-color-surface | white | grey-950 | Default background |
--sv-color-surface-sunken | grey-50 | grey-900 | Recessed areas |
--sv-color-surface-raised | white | grey-900 | Cards, popovers |
--sv-color-text-primary | grey-950 | grey-50 | Primary text |
--sv-color-text-muted | grey-500 | grey-400 | Secondary text |
--sv-color-text-subtle | grey-400 | grey-600 | Tertiary / de-emphasised text |
--sv-color-text-on-accent | white | grey-950 | Text on accent fills |
--sv-color-border | grey-200 | grey-800 | Hairline borders |
--sv-color-border-strong | grey-300 | grey-700 | Emphasised borders |
--sv-color-accent | grey-900 | grey-50 | Brand / interaction colour |
--sv-color-accent-hover | grey-700 | grey-200 | Accent hover state |
--sv-color-accent-subtle | color-mix (accent 12%) | color-mix (accent 12%) | Tinted background paired with --sv-color-accent text, e.g. badges/chips |
--sv-color-focus-ring | grey-900 | grey-100 | Focus outline |
--sv-color-scrim | composed rgba | composed rgba | Dialog backdrop overlay |
--sv-shadow-card | composed shadow | composed shadow | Card elevation |
--sv-shadow-hover | composed shadow | composed shadow | Card hover lift (e1) |
--sv-shadow-popover | composed shadow | composed shadow | Floating panels (e2) |
--sv-shadow-overlay | composed shadow | composed shadow | Dialog / overlay elevation |
--sv-shadow-control | composed shadow | composed shadow | Small 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.
| Token | Light | Dark | Role |
|---|---|---|---|
--sv-color-error-surface | red-100 | red-900 | Error banner / badge background |
--sv-color-error-text | red-800 | red-200 | Text on error surface |
--sv-color-error-border | red-200 | red-700 | Error surface border |
--sv-color-warning-surface | amber-100 | amber-900 | Warning banner / badge background |
--sv-color-warning-text | amber-800 | amber-200 | Text on warning surface |
--sv-color-warning-border | amber-200 | amber-800 | Warning surface border |
--sv-color-success-surface | green-100 | green-900 | Success banner / badge background |
--sv-color-success-text | green-800 | green-200 | Text on success surface |
--sv-color-success-border | green-200 | green-800 | Success surface border |
--sv-color-info-surface | blue-100 | blue-900 | Info banner / badge background |
--sv-color-info-text | blue-800 | blue-100 | Text on info surface |
--sv-color-info-border | blue-200 | blue-800 | Info 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.
/* 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:
@media (prefers-reduced-motion: reduce) {
.myComponent {
animation: none;
transition: none;
}
}Per-component a11y contract
| Component | Role / element | Keyboard | ARIA | Focus order |
|---|---|---|---|---|
Button | <button> | Enter / Space activate | disabled attribute | Natural |
Input | <input> | Standard field editing | type, required, aria-invalid via parent | Natural; label via htmlFor+id |
Textarea | <textarea> | Standard field editing | required, aria-invalid via parent | Natural; label via htmlFor+id |
CodeTextarea | <textarea> | Standard field editing; preserves whitespace | required, aria-invalid via parent | Natural; label via htmlFor+id |
TagInput | <input> + chip buttons | Enter/comma add; Backspace removes last chip when empty; chip buttons remove values | aria-describedby/aria-invalid forwarded to the inner input; validation messages use role="alert" | Natural through input and chip buttons |
SuggestionInput | role="combobox" + role="listbox" panel | ArrowUp/ArrowDown cycle options; Enter selects active (or the create row); Esc closes | aria-expanded, aria-controls, aria-activedescendant, options use role="option"/aria-selected | Natural; panel is non-modal, doesn't trap focus |
IconPicker | <button> trigger + role="listbox" panel | Standard button activation opens/closes; options are plain buttons, Tab-navigable | aria-haspopup, aria-expanded on trigger; options use role="option"/aria-selected | Natural; panel is non-modal, doesn't trap focus |
QuantityStepper | <input type="number"> + two buttons | Standard number-field editing; +/- buttons are a convenience, not the only input path | aria-label required on the field; buttons use aria-label="Increase/Decrease {label}" | Natural |
CheckableListRow | role="checkbox" on the row | Space/Enter toggles | aria-checked, aria-label (the item label), aria-disabled when disabled | Natural; trailing slot's own controls sit outside the checkbox role |
StatusBadge | <span> | N/A | Optional aria-label for abbreviated visible labels | N/A |
BalanceChip | <span> | N/A | Plain text content conveys the owed/owes/settled state — no separate aria-label needed | N/A |
CurrencyInput | <input> (via Input) | Standard field editing | Forwards all Input props, incl. aria-label/required/aria-invalid | Natural; label via htmlFor+id or aria-label |
SplitMethodSelector | role="radiogroup" (via SegmentedControl) | Standard SegmentedControl keyboard support | Same as SegmentedControl; aria-label defaults to "Split method" | Natural |
MemberMultiSelect | List of Checkbox rows | Standard checkbox keyboard support per row | Each row delegates to Checkbox's own label/htmlFor pairing | Natural |
SplitPane | <section> panes + resize button | Drag resize button; ArrowLeft/ArrowRight resize; Home/End min/max | Pane labels; resize button label includes the current primary-pane percentage | Resize button participates when resizable |
FormField | <label> + <div> wrapper | N/A (delegates to its children control) | Generates id/aria-describedby/aria-invalid, passed to the control via the render-prop children | Natural |
Dialog | role="dialog" | Esc close, Tab/Shift-Tab trap | aria-modal="true", aria-label required | First focusable on open; restore on close |
Drawer | role="dialog" | Esc close, Tab/Shift-Tab trap | aria-modal="true", aria-label required | First focusable on open; restore on close |
Icon | SVG | Not focusable | aria-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:
<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:
<label htmlFor="user-email">
Email
<Input id="user-email" type="email" />
</label>Building a component
Location:
src/components/<Name>/<Name>.tsxwith a co-located<Name>.module.cssand<Name>.test.tsx.CSS Modules only. Style with a
.module.cssfile; no inline styles, no CSS-in-JS, no Tailwind.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 — fixedpxvalues 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: 18pxfor 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:checkalso fails on hardcoded hex/rgb()colour literals inpackages/ui/src/components,runtime/app, andplugins/*/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).- hairline borders (
RSC-safe. Keep components presentational and prop-forwarding. Add
'use client'only when the component genuinely needs hooks or browser state.Accessibility. Use the correct element/role, a visible
:focus-visiblestate, and a:disabledtreatment. Components must be keyboard-operable.Export the component and its prop types from
src/index.ts.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
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:
| Prop | Type | Default | Description |
|---|---|---|---|
name | IconName | required | Name from the curated icon set |
size | "xs" | "sm" | "md" | "lg" | "md" | Binds to --sv-icon-size-* tokens (12 / 16 / 20 / 24px) |
className | string | — | Additional CSS class on the SVG |
aria-hidden | true | — | Decorative use — provide one of aria-hidden or aria-label |
aria-label | string | — | 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:
| Token | Value | Use |
|---|---|---|
--sv-icon-size-xs | 12px | Inline metadata (e.g. a due-date badge) |
--sv-icon-size-sm | 16px | Inline with body text |
--sv-icon-size-md | 20px | Standard affordance (default) |
--sv-icon-size-lg | 24px | Prominent / standalone |
Curated icon set
The icon list lives in scripts/icon-list.ts. To add an icon:
- Add the Lucide kebab-case name to the list.
- Run
pnpm generate:icons. - Commit the new file in
packages/ui/src/components/Icon/icons/alongside the updatedindex.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.svgin the plugin's root directory, wired into the Launcher tile and sidebar via<img src="/plugin-icons/<id>.svg" alt="">. Never injected withdangerouslySetInnerHTML— SVG from third-party plugins is untrusted markup (RFC 0008 §4). The monogram is the fallback when a plugin ships noicon.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 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.
: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 type | Colour (#hex, HSL) | URL (url(…)) |
| Changes on | Dark mode, user prefs | Operator deploy |
| Theming use | Dynamic swap | Static 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
/* In your plugin's CSS module */
.logo {
background-image: var(--sv-instance-logo);
background-size: contain;
width: 36px;
height: 36px;
}Note:
--sv-instance-logois a CSSurl()value, so it works inbackground-imagebut not insrcattributes directly. Use the API routes (/api/instance/logo,/api/instance/favicon) in HTML<img>tags, or usesdk.platform.getConfig()which returnsinstanceNameas 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():
const config = await sdk.platform.getConfig();
// config.instanceName — the operator-configured display name
// config.instancePrimaryColor — validated hex or undefinedResponsive & 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.
@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:
.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:
.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:
.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:
/* 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, includingsm). :activepressed state — acolor-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, setsaria-busy, and renders a smallcurrentColorspinner before the children (children stay mounted and visible — the spinner doesn't replace them). The spinner is a self-contained token-only element inButton.module.css, not a reuse of theSpinnercomponent:Spinner's border colors are semantic-fixed (--sv-color-accent), which would be invisible against aprimarybutton'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 tradeoffConfirmDialog's.destructiveConfirmalready documents).currentColorpicks 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 viaposition: 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.
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 justpointerup/pointermove/pointerleave: when the browser converts the touch into a scroll, the timer is cleared instead of firing mid-scroll.- OS callout suppression:
onContextMenupreempts Android's link/image context menu; the returnedstylesets-webkit-touch-callout: none(iOS link-preview),user-select: none, andtouch-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.
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.
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):
--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.
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.
| Desktop | Mobile | Use when | |
|---|---|---|---|
Dialog (sm/md/lg/full) | Centered fixed-size modal | Full-page modal page between shell header/footer, slide transition, OverlayHeader on top | Modal: the user must act before continuing (overlay-shell plugins — Console, Account) |
ConfirmDialog | Small content-sized centered card | Same centered card (decision D4 — not a full-screen sheet) | Destructive / confirm prompts |
Drawer | Bottom sheet (rare on desktop) | Partial-height bottom sheet (half screen or less), grab handle, swipe-down dismiss, snapHeight | Navigation or options revealed by tapping a trigger (Apps button, Menu on mobile) |
Sheet | n/a — desktop shows the same content inline (columns/panes) | Full-page slide-in page replacement | Detail views inside a plugin (task detail, list edit) |
Menu (adaptive) | Popover | Drawer | Context / ⋯ 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
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:
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
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
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
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.
Menu component
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:
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:
/* 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
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.
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
# From the monorepo root:
pnpm storybook # starts dev server at http://localhost:6006
# Or directly from the package:
pnpm --filter @sovereignfs/ui storybookThe dev server hot-reloads on source changes to both components and CSS tokens.
Story organisation
Stories live under two roots inside packages/ui/src/:
| Root | Purpose |
|---|---|
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 file | What it documents |
|---|---|
DesignSystemOverview.stories.tsx | Full component gallery with live demos and import lines; colour palette; type scale; shadow scale; design rules |
TokenGallery.stories.tsx | Live gallery of every --sv-* token tier — colours, space, typography, radius, icon sizes, shadows — read from computed styles |
MobilePatterns.stories.tsx | Mobile 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 file | Key variants and notes |
|---|---|
Avatar.stories.tsx | Initials fallback; image src; sm/md/lg sizes |
Badge.stories.tsx | All status variants; subtle vs filled |
BalanceChip.stories.tsx | Owed (positive); owes (negative); settled up (zero); all three states together |
Button.stories.tsx | All variant × size combinations; disabled; icon-leading; icon-only; AllVariants grid |
Card.stories.tsx | sm/md/lg padding; interactive hover; semantic element variants |
CheckableListRow.stories.tsx | Default; checked; with icon and trailing; checked with icon and trailing; disabled; multiple rows in a list context |
CodeTextarea.stories.tsx | Default; FormField integration; error; disabled; long content; mobile viewport |
CurrencyInput.stories.tsx | Empty; prefilled cents value; disabled |
Dialog.stories.tsx | sm/md/lg/full sizes; closed state; play function opens and asserts visibility |
Drawer.stories.tsx | Mobile viewport default; closed; play function opens and asserts panel visible |
EmptyState.stories.tsx | Heading only; with icon; with action |
FormField.stories.tsx | Default; with hint; with error (role="alert"); render-prop children wires field props (id, aria-describedby, aria-invalid, required) onto the control |
Icon.stories.tsx | Decorative vs meaningful a11y variants; all three sizes; AllIcons full grid |
IconPicker.stories.tsx | Default; preselected value; disabled |
Input.stories.tsx | Text/email/password; disabled; error state with aria-invalid |
MemberMultiSelect.stories.tsx | Default checked/unchecked rows; with a per-row trailing CurrencyInput |
NavTabs.stories.tsx | Default; active tab; mobile horizontal-scroll viewport |
PageHeader.stories.tsx | Title only; with description; with action slot |
Popover.stories.tsx | Four placements; trigger + content |
QuantityStepper.stories.tsx | Default; with unit; fractional step (0.5 kg); at minimum; with max; disabled |
SegmentedControl.stories.tsx | Two and three options; controlled selection |
Select.stories.tsx | Default; disabled; with placeholder |
Spinner.stories.tsx | sm/md/lg sizes; reduced-motion note |
SplitMethodSelector.stories.tsx | Controlled Equal/Amount/Percentage/Shares selection |
SplitPane.stories.tsx | Resizable editor/preview; fixed panes; keyboard resizing; long content; mobile single-column fallback |
StatusBadge.stories.tsx | All editor status states; accessible abbreviated label; long content; mobile wrapping |
SuggestionInput.stories.tsx | Controlled with matched options; without create row; open with pre-filled matches; loading; empty (no matches); disabled |
SystemBanner.stories.tsx | All four categories (info/success/warning/error); dismissible |
Tabs.stories.tsx | Controlled tabs; default selected; keyboard navigation |
TagInput.stories.tsx | Controlled tags; FormField integration; error; disabled; keyboard behavior; long content; mobile viewport |
Textarea.stories.tsx | Default; with value; custom rows; disabled |
Toast.stories.tsx | All six categories triggered imperatively via useToast |
Toggle.stories.tsx | On/off; disabled; label association |
Tooltip.stories.tsx | Four 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
- Create
packages/ui/src/components/<Name>/<Name>.stories.tsx. - Use
satisfies Meta<typeof YourComponent>(notMeta<typeof YourComponent>alone) so TypeScript infers arg types. - Add the component to the Component Gallery section of
DesignSystemOverview.stories.tsx— both the import and a<ComponentCard>entry. - Always pair
<Input>and similar bare elements with a<label>in stories — the a11y addon flags unlabelled controls as errors. - Use
aria-hiddenfor decorative icons andaria-labelfor meaningful ones. - Run
pnpm storybooklocally and confirm the a11y panel shows no violations. - Run
pnpm --filter @sovereignfs/ui typecheckto confirm the stories are type-correct.
Building and deploying
pnpm build-storybook # static output → packages/ui/storybook-static/
pnpm --filter @sovereignfs/ui build-storybook # same, scopedCI (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.
git tag sb-v0.11.0
git push origin sb-v0.11.0