@factory/ui
Design system for AI Software Factory: design tokens, theming, pure
logic (formatters, focus trap, command palette, toasts) and thin
Radix-based React components. Source-first (no build step) — main/types
point at src/index.ts.
Public API
| Entry point | Exports |
|---|---|
@factory/ui (root) |
everything below, by name |
@factory/ui/tokens |
THEME_TOKENS, TOKEN_NAMES, cssVariables(theme), cssTokensFile() |
@factory/ui/theme |
Theme, resolveTheme(pref, systemDark), applyThemeTo(doc, pref, window?), themeLocalStorageKey(), systemDarkQuery() |
@factory/ui/formatters |
formatBytes, formatNumber, formatRelativeTime, formatDurationMs, initials, truncate, formatDate |
@factory/ui/focus |
FOCUSABLE_SELECTOR, isFocusable(el), nextFocusable(...) (pure), trapFocus({ container, returnFocus? }) (thin adapter) |
@factory/ui/components |
Button, Dialog/DialogContent/… + useDialogState, DropdownMenu… + useMenuState, Popover…, Tooltip…, Avatar, Checkbox, Switch, Tabs…, Slider, Separator, ContextMenu…, CommandPalette + useCommandPalette, Spinner, Badge, Skeleton, EmptyState, ToastProvider + toastStore/useToasts |
Also exported from the root: color.ts (hashNameToHue, hashString,
avatarHsl), command-filter.ts (scoreCommand, filterCommands,
commandNav), toast-store.ts (ToastStore — pure, injectable clock).
Usage
import { Button, Dialog, useDialogState } from "@factory/ui";
import { cssTokensFile } from "@factory/ui/tokens"; // → drop into tokens.css
import { applyThemeTo, themeLocalStorageKey } from "@factory/ui/theme";
const { open, openDialog, closeDialog, state } = useDialogState();
<Button variant="primary" size="md" onClick={openDialog}>Open</Button>
<Dialog open={open} onOpenChange={(o) => (o ? openDialog() : closeDialog())}>
<DialogTrigger asChild><Button variant="ghost">Trigger</Button></DialogTrigger>
<DialogContent><DialogHeader><DialogTitle>…</DialogTitle></DialogHeader>…</DialogContent>
</Dialog>
Theme bootstrap (once in @factory/web): applyThemeTo(document, pref, window)
sets <html data-theme="light|dark">; CSS comes from cssTokensFile()
(:root = light, [data-theme="dark"] = dark).
Phases
Every UI phase builds on this package: 3 (design tokens + base components), 4 (editor chrome, dialogs/menus), 5 (database UI: badges, tabs, density), 6 (settings: switch/tabs/tooltip), 10 (polish: toasts, command palette, empty states).
Recorded decisions
-
Presence palette (TASK-0093): the 20-name presence palette (
PRESENCE_PALETTE) + the deterministicpresenceColor(userId)(fnv-1a → palette index) live in@factory/shared(packages/shared/src/presence.ts), shared so server + client agree on the color. No per-presence color tokens exist in@factory/ui(the--factory-color-*/--block-color-*tokens cover only the 9 block colors, not the 20 presence names — recorded deviation, the spec's "the 20 colors are design tokens" is a follow-up). The web renders each name as a distinct hue (index × 18°,hsl()at runtime — no hex literals in the component, the Phase-4 rule), reusing the same sanctioned-exception pattern as the avatar hue below (user-derived, not a theme token). The name label is mandatory, so color is never the sole identifier (the a11y rule). -
Tokens: one semantic table per theme + the 18 block colors / 9 status colors reused from
@factory/shared(BLOCK_COLORS,STATUS_COLORS) — never duplicated.status-*vars map 1:1 to the same-named block-color text hue. Deterministic key order (base → block → status). No component hard-codes a color — everything isvar(--token). Only sanctioned exception: avatar hue is user-derived (hashNameToHue→hsl(…)), not a theme token. Var naming:--block-color-<token>derives 1:1 from the sharedtokenfield, so backgrounds are--block-color-blue_bg(underscore preserved, matching@factory/shared's own doc style--factory-color-red_bg) — not a re-styledblue-bg. Consumers should build names via`--block-color-${color.token}`. -
Focus trap: decision logic is the pure
nextFocusable(candidates, current, shift, focusableFlags)(wrap, skip unfocusable, -1 when none);trapFocusis a thin keydown adapter. Radix primitives (Dialog, menus, Popover) trap focus internally —trapFocusis for non-Radix surfaces (command palette). -
Command palette: fuzzy subsequence scoring (+1/char, +4 contiguous, +2 word-start, −0.1/gap), non-subsequences dropped, ties by original index (deterministic). Keyboard is the pure
commandNavreducer (ArrowUp/Down wrap, Home/End, Enter = side effect, Escape closes). -
Toasts:
ToastStoreis pure with an injectable clock (now()+schedule(fn, ms)); expiry is timer-driven AND lazily swept initems()(drop-timer safe). React layer is a thinToastProvider. -
Peer deps (aligned with
apps/webpins):react/react-dom18.3.1 (declared^18.3.0),clsx^2.1.1,lucide-react^0.460.0(web app currently pins^0.427.0— caret ranges overlap; web may bump), Radix@radix-ui/react-*at the majors listed inpackage.json(no existing repo pin to align against). Radix + React are peers, not deps: the package is never executed here and the app provides them.