The SpaceCorps design system: React component library, renderers, diagrams, charts, and product widgets — published as
@spacecorps/componentsand browsable in Storybook.
The static Storybook is built from the main branch and served by a small nginx container on the
SpaceCorps Sliplane server (see Dockerfile and deploy/). Work lands on development; merging to
main releases, and Sliplane rebuilds on every push to main. The site is behind HTTP basic auth
(STORYBOOK_USER, STORYBOOK_PASSWORD secret env vars); the container refuses to start without a
password unless STORYBOOK_PUBLIC=1. This replaces the GitHub Pages workflow, which cannot publish
from a private repo on the free plan.
@spacecorps/components is structured with modular subpath exports, enabling consumers to import targeted subsets of the library without unnecessary dependencies:
@spacecorps/components— Root entry for common components, utilities, and theme provider@spacecorps/components/ui— 38+ Radix UI & shadcn/ui primitives (Button, Input, Card, Dialog, etc.)@spacecorps/components/renderers— Rich content renderers (Markdown, JSON, XML, HTML, Code, Chat, ErrorBoundary)@spacecorps/components/diagrams— Diagram renderers (Mermaid, Graphviz) that lazy-load mermaid and @hpcc-js/wasm-graphviz on first render@spacecorps/components/tendril— Tendril execution widgets, Shell layout, diff inspection, and dashboard analytics@spacecorps/components/styles/*— Design system tokens and stylesheets (index.css,markdown-spacing.css)
@spacecorps/components is published to GitHub Packages, not npmjs.com. Point the @spacecorps
scope at the GitHub registry once per consuming project, in its .npmrc:
@spacecorps:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}GITHUB_TOKEN needs the read:packages scope. In GitHub Actions the built-in token already has it —
set permissions: packages: read on the job and pass NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}.
Then install as usual:
pnpm add @spacecorps/components
# or
npm install @spacecorps/components
# or
vp install @spacecorps/componentsImport the CSS stylesheet in your application entry point:
@import "@spacecorps/components/styles/index.css";Or in JavaScript/TypeScript:
import "@spacecorps/components/styles/index.css";import { ThemeProvider, useTheme, Button, Card } from "@spacecorps/components";
function App() {
return (
<ThemeProvider defaultTheme="system" storageKey="app-theme">
<Card>
<Button>Hello World</Button>
</Card>
</ThemeProvider>
);
}import {
Button,
Dialog,
DialogTrigger,
DialogContent,
Card,
Badge,
Tabs,
TabsList,
TabsTrigger,
TabsContent,
Input,
cn,
} from "@spacecorps/components/ui";
function DemoComponent() {
return (
<div className={cn("space-y-4 p-4")}>
<Button variant="default" size="lg">
Primary Action
</Button>
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Open Dialog</Button>
</DialogTrigger>
<DialogContent>
<h2>Dialog Content</h2>
<p>Your content here</p>
</DialogContent>
</Dialog>
<Card>
<Badge variant="secondary">Status</Badge>
<Input placeholder="Enter text..." />
</Card>
<Tabs defaultValue="tab1">
<TabsList>
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
<TabsTrigger value="tab2">Tab 2</TabsTrigger>
</TabsList>
<TabsContent value="tab1">Tab 1 Content</TabsContent>
<TabsContent value="tab2">Tab 2 Content</TabsContent>
</Tabs>
</div>
);
}import {
Button,
DatePicker,
Field,
Input,
TimeCalendar,
TimeInput,
} from "@spacecorps/components/ui";
function LoginForm() {
return (
<form onSubmit={signIn} className="flex flex-col gap-4">
<Field label="Username" name="username">
<Input autoComplete="username" />
</Field>
<Field label="Password" name="password" hint="At least 12 characters">
<Input type="password" autoComplete="current-password" />
</Field>
<Button type="submit">Sign in</Button>
</form>
);
}- Form spacing is one rule:
Fieldand the react-hook-formFormItemspace their own label, control, hint and message (gap-1.5), and whatever stacks them spaces the steps between them. Put an errorAlert, every field and the submit button in oneflex flex-col gap-4(orgrid gap-4) container, as in the example above. Without it a bare<form>stacks them with no gap at all: the next label sits on the input above it and the button on the last input.Patterns/Auth forms→SignInSpacingandSignInSpacingWithFormComponentsmeasure it. - Field wires
id,aria-describedby(hint and error),aria-invalid,aria-required,nameanddisabledintoInput,Textarea,Select,Checkbox,Switch,RadioGroup,NumberInput,DatePicker,DateRangePicker,TimeInputandMultipleSelector; the react-hook-formFormItemprovides the same context. A prop on the control itself wins.requiredsetsaria-requiredand the label asterisk, never the native attribute. Never derive anidfrom aname:Fieldgenerates a unique one. Without aFieldthere is no wiring.DialogContent,AlertDialogContent,SheetContentandPopoverContentreset the field context for what they contain; wrap any other portalled overlay inFieldReset, and build a custom control onuseFieldControl(props). - Password input:
type="password"renders a wrapper (data-slot="password-input") with a show/hide button, on by default (passwordToggle={false}for PINs, or when you fliptypewith a button of your own). The button keeps one name, "Show password" (passwordToggleLabel), witharia-pressed. It keeps focus and caret, swaps the type on the same element, and putstype="password"back before submit, on form reset and on page hide; a disabled field never shows its secret.classNamelands on the<input>, except the utilities that size or place it (w-*,max-w-*,flex-*,col-*,self-*,order-*, margins), which move to the wrapper;containerClassNamestyles the wrapper;revealed/onRevealedChangeshare one reveal state across fields. Edge's own reveal eye is hidden on every password input. - Autocomplete: a plain text
Inputrendersautocomplete="off"anddata-1p-ignore. Any explicitautoCompletethat does not containoffrenders as given and dropsdata-1p-ignore. The tokensusername,email,current-password,new-password,one-time-codeandwebauthn, and any password input, drop both and turn spellcheck, auto-capitalisation and autocorrect off;typeemailandurlturn those three off too but keep theoffdefault unless you pass a token (verbatimforces the three off).emailsetsinputmode="email"andone-time-codeinputmode="numeric". A password input without a token omitsautocompleteand warns in development: always pass"current-password"or"new-password".autoComplete={undefined}means "not given";autoComplete="on"rendersautocomplete="on". Browsers only offer saved credentials after one was saved for that origin. - DatePicker / DateRangePicker: ISO
yyyy-mm-ddstrings, shown and typed asyyyy-mm-dd, Monday first (weekStartsOn),min/max,localefor month and weekday names. ArrowDown in the text field opens the calendar on the selected day; closing returns focus to that field.DateRangePickersubmits{name}-fromand{name}-tothrough hidden inputs; itsidgoes on the From input and the group gets{id}-group.CalendartakesmonthLabelandyearLabelfor the names of the month and year boxes in its caption (defaults "Month" and "Year";localedoes not translate them);DatePickerandDateRangePickerforward both to their calendar. A native<input type="date|time">follows the browser's locale instead. - TimeInput: a 24-hour
HH:MMtext field whatever the browser's locale (a native<input type="time">shows09:00 AM). Digits are enough (930and0930become09:30); ArrowUp / ArrowDown step bystepminutes, PageUp / PageDown by an hour, andmin/maxbound both typing and stepping. A list of times (suggestionStep, default 30,0for none;suggestionsfor an explicit list) opens from the clock button, Alt+ArrowDown, or ArrowDown in an empty field (ArrowDown steps down once the field has a time). Text that is not a time is never guessed at: AM/PM is refused, including a pasted1:30 PM, which staysaria-invalidwhile it sits in the field and is discarded, with an announcement, when the field is left (discardedMessagereplaces the wording). A pasted09:30replaces the field and commits at once. A value with seconds (09:30:00, as a databaseTIMEis serialised) is read as09:30. The clock button and the list carry the field's name after their own, which is thearia-labelor the string label of the surroundingField(pickerLabelandlistLabelreplace "Choose time" and "Suggested times"; with a non-text label give the whole name there).namesubmits the value through a hidden input. - TimeCalendar: month grid and week time-grid for tracked time (
eventswithid,date,start,end,hours,title,color,running), per-day totals,onDayClick,onSlotClickandonEventClick, and an agenda below 640 px (compact,compactBelow). The grid is one tab stop (the active day).onNavigatereports the anchor day, the view and the visiblerange; fetch entries withgetFetchRange(date, view), which starts a day earlier thangetVisibleRange, so a night shift that began the evening before the first column still reaches the week view.timeZonedecides what "today" is and how far a running entry has got,nowpins the clock,labelsreplaces every string. All styling is Tailwind utilities; the root exposes--sc-hour,--sc-cell-min,--sc-gutterand--sc-max-body. In the week view, entries sit side by side only where they truly overlap: entries that touch (one ends in the minute the next starts), or have any gap between them, share one full-width column at every density. A short entry is lifted to a least height of 1.35rem (27 minutes at Medium, 32 at Small, 23 at Large) only into free space, never past the start of the next block that shares its width, so an entry with another right after it is drawn only as tall as it is long (or as the gap leaves it). When that leaves too little height the label degrades instead of the width splitting: from the least height it is the full block; from 19.5 minutes (one line of text, with room above it for the ring of an Å, Ä or Ö and below it for descenders, at every density) it is one tight line with the title only; and below that a bar, the colour wash and left edge with no text, which keeps itsaria-labelandtitle, so the name is still read out and shown as a tooltip. Blocks carrydata-fit="full","tight"or"bar". In forced-colors mode (Windows High Contrast) a block is told apart by its border, and a tight block keeps only its left and right edge, because a top and bottom border would take the room its label needs. An entry with no known end has no interval to overlap, so it keeps the least height and whatever starts inside it is put beside it. There is no API change: the layout helpers are not part of the package entries, andPositionedSegment.drawnEndMin(where a block is drawn to) is optional, a position without it drawing as before. Two things follow from drawing every entry at its true length. A 10-minute bar is 6.7 to 9.3px tall, and an entry of a minute or two that another follows is a sliver of a pixel or two (0.8px per minute at Medium), too small to hit with a pointer; it is still reached by Tab and still has its name. And the least height now comes from the layout, no longer from a CSSmin-height, so if you override--sc-hourkeep it at the density's hour height (2.5, 3 and 3.5rem); a smaller one draws the lifted blocks below 1.35rem. - DataTable in a Card:
variantis"bordered"or"embedded". Inside aCardContentit defaults to"embedded", so a table in a card never draws a second border inside the card's; a table inside aDialogContent,AlertDialogContent,SheetContentorPopoverContentopened from a card is not in the card and defaults to"bordered"like anywhere else, and an explicitvariantwins.<CardContent flush>drops the padding so the table runs edge to edge, the first and last column inset by the card padding.Cardhasmin-w-0, so a wide table scrolls inside its own container instead of stretching a card that sits in a flex row or grid cell; addmin-w-fitto aCardthat should grow with its content instead. While a wide table scrolls, the selection column stays pinned left and the row-action column right.showCaptiondraws the caption above the table (the<caption>is always there, visually hidden); right-aligned columns do not wrap (nowrap,wrapText). PassgetRowLabelso a row checkbox reads "Select row Acme AB" and not a UUID. - DataTable keyboard: the body is one tab stop (the active row, moved with the arrow keys,
Home / End and PageUp / PageDown) and each row adds its checkbox, editable cells and row actions as
stops of its own, so a 25-row page with one inline action per row is about 26 stops before the
footer. Enter or Space on a focused row calls
onRowClick; a click on a button, link or input inside a cell does not, and a focused row shows its truncated cells in full. Editable cells open with Enter, Space or F2, Enter commits, Escape discards, and Tab / Shift+Tab commit and move focus to the next / previous editable cell. The page-boundary buttons arearia-disabled(they keep focus), the pagination<nav>is named after the caption, and an action that removes its own row moves focus to the row now at that position. - Keyboard and focus: Dialog, AlertDialog and Sheet return focus to the opener, with or without a
Trigger (
fallbackFocusRefcovers an opener that left the page;onCloseAutoFocuswithpreventDefaultopts out). Dialog and Sheet open ondata-autofocus, else the first text field, else the Close button, and skip the field on touch devices (pointer: coarse) so the keyboard does not cover the dialog; AlertDialog opens on Cancel, not on a field.useReturnFocus(root and/uientries) gives a custom Radix overlay the same return of focus (render itsanchoras the first child of the content).Tabsare Radix (arrow keys, one tab stop, a focusable panel); the sidebar below 768 px is a modal Sheet.ButtonandIconButtontakefocusableWhenDisabled(together withdisabled, plusdisabledReason) to keep a genuinely unavailable control reachable and announced; for a submit button gated on validation, prefer leaving it enabled and showing an error summary. Toasts last 5 s (12 s when destructive or with an action;durationoverrides), close with their own button or Escape, and a click elsewhere on the page does not dismiss them. Escape with a dialog, sheet, alert dialog, menu or listbox open under a toast closes that overlay in one press and clears the toasts; an overlay that vetoes Escape leaves them. At most three toasts are open at once (closed ones do not count) and the slide-in honoursprefers-reduced-motion.DialogContentdraws a 2px ring when the dialog container itself has keyboard focus. A soft-disabled control (GatedActionButton, afocusableWhenDisabledbutton) isaria-disabled, never nativelydisabled. Rings areoutline-hidden+ring, so they survive forced-colors mode. - Page area and sidebar:
AppShellrenders its desktop sidebar as an<aside>named "Sidebar" (sidebarLabel;Sidebartakeslabel). Its page area istabindex="-1"only as the target of theskipLinkprop (truefor "Skip to content", or a label string; off by default); when the page overflows and nothing in it is tabbable,SidebarInsetContentbecomes arole="region"tab stop named "Page content" (contentLabel, orscrollLabelonSidebarInsetContent) so the arrow and Page keys can scroll it. Arole,aria-labeloraria-labelledbyyou pass wins. In theinsetvariant (the default) the island is exactly one viewport tall with its margin included (100svhminus four of--spacing, the margin beingm-2), so a long page scrolls inside it and the document never scrolls as well.ScrollAreadoes the same for its viewport: it is a tab stop and aregion(named byaria-label,aria-labelledbyor "Scrollable content") only while the content overflows.VirtualListdoes the same for its scroll container (default name "Scrollable list"), and stays a tab stop when its rows hold buttons. Name each list once a page has more than one: two unnamed lists are two regions with the same name, which axe reports aslandmark-unique. - Collapsed sidebar rail:
collapsible="icon"is a compact rail.SIDEBAR_WIDTH_ICONkeeps its value (3rem) and meaning; theinsetandfloatingvariants (on eitherside) add a 0.25rem gutter and are 52px wide (they were 64px), the plain variant is 48px. Section labels hidden in the rail no longer take the pointer, so the bottom of a button above one can be clicked and hovered. When the nav is taller than the window the rail scrolls (hidden scrollbar,scroll-py-1so a focus ring is never clipped). The expanded sidebar keeps its geometry, but its hover, press and current-page fills are now 8%, 12% and 12% foreground tints instead of--accent(which equals the sidebar surface, so the current page had no mark);sidebarMenuButtonVariant,SidebarMenuAction,SidebarGroupActionandSidebarMenuSubButtonchange with it, and--accentno longer themes the sidebar. An app that sets--sidebar-width-icongets that width plus the gutter. - Ratings and popover links:
StarRatingandEmojiRatingwithallowHalfare onerole="slider"(Left/Right and Up/Down step 0.5, Page Up / Page Down step 1, Home clears, End gives the highest) witharia-label; without it they are the usual row of buttons.PopoverLinkopens from Enter and Space, and its popover is a dialog named by the phrase. - Gamepad (opt-in, off by default, nothing is listened to or polled until
enabled): wrap the app in<GamepadNavigationProvider enabled>. D-pad or left stick moves focus, A activates (Enter submits a form from a text field), B sends Escape and, when nothing handled it, steps out of a calendar, tree or grid, LB / RB switch tabs. Only pads whoseGamepad.mappingis"standard"are read; wheels, flight sticks and many generic USB pads report""and are ignored unless you passallowNonStandardPads. A widget keeps only the directions it uses and every other direction moves focus on, so nothing traps the pad: tab lists, toolbars and menubars keep Left / Right (the other axis moves on); a radio group keeps its own axis and does not wrap past its first or last radio; a slider keeps Left / Right (Up / Down when vertical) and stops at min and max; a native<select>changes with Left / Right, so passing a select never alters it; a spinbutton keeps Up / Down, and so does a native<input type="number">(the pad steps its value) and any element that carriesdata-gamepad-step(NumberInputandTimeInputdo; the attribute name is exported asGAMEPAD_STEP_ATTRIBUTE); in a menu Right opens a submenu and Left closes it, and in a menubar Left / Right switch menus (Radix gets the real arrow key); tree, grid and treegrid keep all four but pass on a direction they did not act on. A in a text field submits only as Enter would: a form whose default submit button is disabled, or that has several text fields and no submit button, does nothing. Mount oneGamepadNavigationProvider: a second enabled one is inert (with a warning in development) until the first unmounts. The full table is the comment oncompositeDirectionsingamepad-logic.ts.useGamepadNavigation()reportsenabled,connected,padId,modalityandlastAction(itstargetnames the element that took focus after a move or tab switch). The provider setsdata-input-modalityon<html>(gamepad,keyboardorpointer, following the device used last once a pad has been seen) anddata-gamepad-focuson the focused element, and draws a focus ring (ringClassName; pass""to style[data-gamepad-focus]yourself). Untested on a physical pad.
import { MarkdownRenderer, JsonRenderer } from "@spacecorps/components/renderers";
import { MermaidRenderer, GraphvizRenderer } from "@spacecorps/components/diagrams";
function ContentDemo() {
const markdownContent = "# Hello\n\nThis is **Markdown** content.";
const mermaidDiagram = "graph TD; A-->B; B-->C;";
const dotGraph = "digraph G { A -> B -> C; }";
const jsonData = { name: "Example", value: 42 };
return (
<div className="space-y-6">
<MarkdownRenderer content={markdownContent} />
<MermaidRenderer content={mermaidDiagram} />
<GraphvizRenderer content={dotGraph} />
<JsonRenderer data={jsonData} />
</div>
);
}import {
TendrilShell,
AgentViewer,
PlanMarkdown,
TendrilDashboard,
ContentInput,
BadgeSelect,
} from "@spacecorps/components/tendril";
function TendrilApp() {
const dashboardData = {
kpis: [
{ label: "Plans", value: 42, trend: "up", change: 12 },
{ label: "Jobs", value: 128, trend: "up", change: 8 },
],
activity: [],
jobs: [],
trends: [],
};
return (
<TendrilShell
id="shell"
eventHandler={(eventName, widgetId, args) => console.log(eventName, widgetId, args)}
slots={{
SidebarBody: <nav aria-label="Main">{/* ShellNav and section widgets */}</nav>,
Content: (
<div className="space-y-6">
<TendrilDashboard {...dashboardData} />
<AgentViewer events={[]} isStreaming={false} />
<PlanMarkdown content="# Plan Title\n\nPlan description." />
<ContentInput
onSubmit={(text) => console.log("Submitted:", text)}
placeholder="Enter your prompt..."
/>
<BadgeSelect
id="type"
options={[
{ value: "feature", label: "Feature" },
{ value: "bug", label: "Bug" },
]}
value={["feature"]}
multiple={false}
events={["OnChange"]}
eventHandler={(eventName, widgetId, [next]) => console.log("Selected:", next)}
/>
</div>
),
}}
/>
);
}Markup, behaviour and API changes that an existing caller or test can see, with what to do about each. Additive props and new components are in the sections above.
Inputs and forms
- Password
InputDOM:type="password"now renders<div data-slot="password-input"><input><div><button></div></div>.classNameandrefstay on the<input>, but sizing and placement utilities (w-*,max-w-*,flex-*,col-*,self-*,order-*, margins) move to the wrapper; usecontainerClassNamefor wrapper styling. A caller with its own eye button passespasswordToggle={false}. Edge's native reveal eye is hidden. - Credential
autocompletedefaults: a password, or anautoCompletecontainingusername,email,current-password,new-password,one-time-codeorwebauthn, no longer getsautocomplete="off"ordata-1p-ignore, and getsspellcheck="false",autocapitalize="none"andautocorrect="off"(so dotype="email"andtype="url"). A password without a token omitsautocompleteand warns in development. Plain text inputs with noautoComplete, orautoComplete="off", are unchanged; any other explicitautoCompletevalue (for example"name"or"on") now renders as given and no longer addsdata-1p-ignore. autoComplete={undefined}on anInputnow means "not given", so a plain field rendersautocomplete="off"; it used to remove the attribute. PassautoComplete="on"for the browser's default.aria-invalid="true"styling:Input,Textarea, theSelecttrigger,MultipleSelector,RadioGroup,CheckboxandSwitchnow draw a destructive border and focus ring for it (a react-hook-formFormControlemits it). In dark mode the invalid colour is--red, not the dim--destructive.Selectis a function component aroundSelectPrimitive.Rootwith the same props (it also reads aField'snameanddisabled), soSelect === SelectPrimitive.Rootno longer holds.DatePickerandDateRangePicker: closing the calendar returns focus to the text field it was opened from with ArrowDown (otherwise to the calendar button, as before), and the hidden inputs carrydisabled. OnDateRangePickera calleridlands on the first (From) input, not on the group, which gets{id}-group.NumberInputprops now extendInputProps.nameis submitted by a hidden input that holds the raw number, and the visible input carries noname, soFormDataholds the number and not the formatted text.disabledis no longer defaulted tofalse, so aField's applies.MultipleSelector: the popover is no longer modal (the page stays interactive, Tab leaves); Escape closes the list and keeps focus and text (a second Escape clears the text); the chevron is no longer a tab stop; chip remove buttons are namedRemove <label>; Select All and Clear All arerole="option"items inside the list, not buttons in a footer; the empty message is a plaindiv.InputVariants:textInputTrailingIconButtonClasseshas a focus-visible ring instead offocus:outline-hidden, and the Small trailing hit target issize-6(wassize-5).- react-hook-form
Form:FormControlno longer emitsaria-invalid="false"or a danglingaria-describedby;FormMessagehasrole="alert";FormItemreportshasLabelonly while aFormLabelis mounted; error text uses the destructive-800/200 steps.FormItemisgrid gap-1.5(itsgap-y-4did nothing on a blockdiv), so a label no longer sits flush on its control; an item you already spaced yourself, for example withspace-y-2, now has both. Calendarcaption: Enter in the month or year box no longer blurs it.
Borders, surfaces and contrast
- Borders derive from the foreground:
--border,--inputand--sidebar-borderarecolor-mix(in oklab, <foreground> N%, transparent)and are recomputed per theme. Redeclare them on a subtree that overrides--foregroundwithout the.darkclass.getCSSVariable("--border")andThemeColors.borderreturn that colour string, not a hex: fine in a style prop, SVG stroke or stylesheet, not for canvas or colour-math libraries. Do not usevar(--input)orvar(--border)as an opaque fill (a track, thumb or skeleton): they are translucent now, so it is a tint of whatever is behind it; use--mutedor--popover. - Control edges are at 3:1: input, textarea, select trigger, multiselect (it had none; pass
ghostfor the borderless look), checkbox, radio, switch off track, outline Button, Toggle, the calendar chips and the pagination select. Dark--cardand--popoverare lifted above the page, so cards, menus, sheets, toasts, Dialog and AlertDialog read as raised. A dark invalid border uses the named red, not--destructive. - Error text uses
fieldErrorTextClass, nottext-destructive, which is a fill colour (3.3:1 on white, 1.9:1 on the dark page). Dim text istext-muted-foreground, not/70.
Overlays, focus and keyboard
Tabsare always Radix: inactive panels stay in the DOM as empty hiddendiv role="tabpanel", the active panel is focusable, and triggers select on mousedown, focus and keys (a bare click still selects).useRadixis accepted and ignored.Tabslook and overflow:TabsListis a borderedbg-cardsegmented control with an equal gutter on every side; the active tab is a foreground tint and the focus ring is whole. There is novariantprop. When the tabs do not fit, the list ismax-w-fulland scrolls inside itself (snap to the start of each tab, a fade on the side that has more, no scrollbar, the wheel left native) and never widens its box or the page. The active tab is brought into view on first render, whenvaluechanges, on keyboard focus and when the width changes; it works right to left. A vertical list does not scroll. The list is a scroll container (overflow-x-auto overflow-y-hidden) even when it fits, so anything that overhangs its box, such as a badge positioned above a trigger, is clipped; keep such decorations inside the trigger.- Toasts:
useToastno longer dismisses on any document click or Escape. A toast times out after 5 s (12 s when destructive or with an action) unless you passduration.ToastCloseis named "Close". At most three toasts are open at once; closed ones waiting out their exit animation no longer count towards it. Escape with an overlay open under a toast closes the overlay in one press and clears the toasts (theToasterre-sends one Escape from the original target, and leaves a toast alone whose ownonEscapeKeyDowncallspreventDefault); an overlay that vetoes Escape keeps them. The close icon takes the toast's text colour at 70%, and the close button and action have 2px focus rings. - Dialog and Sheet initial focus moves to
data-autofocusor the first text field instead of the Close button (not on touch devices). A caller that relied on Close being first usesonOpenAutoFocus.DialogContentdraws a 2px ring when the container itself has keyboard focus (:focus-visible); it used to hide both outline and ring on any focus (focus:outline-hidden focus:ring-0). - Mobile sidebar (below 768 px) is a portalled modal Sheet instead of an inline fixed
div;SidebarMenuButtonandSidebarMenuSubButtonclose it only for a link with anhref, so a button that routes in script passescloseOnSelect; no tooltip renders on mobile.AppShellwraps its nav in a<nav>, marks the active linkaria-current="page", renders its sidebar as an<aside>named "Sidebar" (sidebarLabel), gives its inset contenttabindex="-1"only as the target of the skip link that the newskipLinkprop adds (truefor "Skip to content", or a label string; off by default), and makes that area a tab stop labelled "Page content" (contentLabel) when the page overflows and has nothing a keyboard can reach.SidebarInsetis already a<main>: do not put another inAppShellchildren. - Desktop
Sidebarroot is an<aside aria-label>(label, default "Sidebar"), not a<div>. Selectors on the tag name change toasideor[data-sidebar]; classes and data attributes are unchanged.SidebarInsetContenttakesscrollLabel; arole,aria-labeloraria-labelledbyyou pass wins over its own. - Sidebar shortcut and drawer: Ctrl/Cmd+B toggles the sidebar only when focus is not in an input,
textarea, select or contenteditable, no other handler has called
preventDefault, and Alt and Shift are not held (it used to toggle from anywhere). The mobile drawer closes by itself when the viewport grows past 768 px, andSidebartakesmobileLabel(default "Navigation") for the drawer's accessible name. - The icon-button and shell tooltip (the one behind
IconButtonandStatusLine, and exported from/tendrilasShellTooltip; theTooltipexported by/uiis the plain Radix wrapper and did not change):triggerDisabledis deprecated and ignored. A natively disabledIconButtonno longer has an unnamed focusable wrapper as a tab stop; usefocusableWhenDisabledfor a disabled icon button that stays reachable. With emptycontentan element child stays mounted, so a control whose tooltip text comes and goes keeps focus, but its trigger is inert: nodata-state, and nothing opens on hover or focus. Text, fragments,asChild={false}andwrapTriggerchildren are returned bare. Button/IconButtonfocusableWhenDisabled(new, opt-in; it only has an effect together withdisabled) rendersaria-disabled="true"anddata-disabledwith no nativedisabled: the control stays a tab stop, ignores click, Enter, Space and submit, dropsonPointerDown/onMouseDownand, when it has a popup, ArrowDown / ArrowUp.disabledReasonis announced as its description (a hidden span inside the button, so not rendered withasChild);IconButtonalso shows it in the tooltip aslabel: reason. A natively disabled button is unchanged.GatedActionButtonandVaultDialogShell's submit are soft-disabled:aria-disabled="true"and no nativedisabled, so assertaria-disabled, nottoBeDisabled();.disabledand:disabledno longer match. Click, Enter and Space are still ignored, the button is a tab stop, and itstextContentis the label followed by the hidden reason (query by role and name).GatedActionButtonPropsomitsfocusableWhenDisabledanddisabledReasonfromButtonProps; the gate sets both.VaultDialogShelltakes a newsubmitDisabledReason, announced as the submit button's description and shown in a tooltip on hover and keyboard focus whilesubmitDisabled; the built-in Vault dialogs pass one.- Focus rings:
CopyToClipboardButtonshows the standard ring after a copy (the oldring-primarywas invisible on the primary fill); menu rows and command items get an inset ring on keyboard focus. PlanWorkspacetool panels (Verifications, Questions): the trigger no longer hasaria-haspopup="dialog"and carriesaria-controlswhile open (the panel is a disclosure). Escape and outside presses leave the panel alone when a nested portalled overlay (popover, select, dialog) handles them. In the overflow menu an action withactive: trueisrole="menuitemcheckbox"witharia-checkedand a check mark, and a loading action isaria-busyand disabled. Selectors onrole="menuitem"for a toggle action needmenuitemcheckbox.
Widgets
BadgeSelect: the trigger is a singlebutton role="combobox"(was abuttonwhen empty and adiv role="combobox"when filled), options arediv role="option"and the remove buttons sit beside the combobox. Queries forgetByRole("button", { name: placeholder })becomecombobox. Escape with the list open closes only the list (a surrounding Dialog or Sheet stays open), Alt chords other than Alt+ArrowDown / Alt+ArrowUp are left to the browser, a selected option that cannot be removed shows a check, the remove buttons have a 24px hit area, and pressing a badge's X while the list is open closes the list. In a single-select, Tab or Alt+ArrowUp with the list open keeps the highlighted option (it firesOnChange) before closing, as a native select does; a multi-select or an action row is not committed.ShellTabsDOM: the.tsh-tabwrapper is gone;.tsh-tab-mainis therole="tab"button (insiderole="tablist""Agent sessions") and the close buttons live in.tsh-tab-closes. CSS or tests keyed on.tsh-tabneed updating. The strip is one roving tab stop (Arrow keys, Home and End move between tabs; Enter or Space selects), and the close buttons aretabIndex=-1, so Tab no longer visits them. Delete on a focused tab closes it whenOnCloseis set. Tabs are 144 to 200px wide and the strip scrolls sideways (the wheel too) with its scrollbar hidden, instead of shrinking the tabs to nothing; a tab has a nativetitle, a 24px close hit area and 36px of right padding, and keeps its highlight while its X is hovered. Closing the last tab with focus in the strip leaves a lone new-session button holding focus until focus moves on (TendrilShellkeeps the Tabs row mounted for that long afterhasTabsturns false).Slider:aria-labelno longer reaches the rootspan; it goes to the thumb, or to the group for a range (thumbLabelsnames each thumb).Stepper: a calleronClickonStepperTriggercomposes with the step change instead of replacing it (the caller runs first; on the default buttonpreventDefault()vetoes the step, but withasChildthe step always moves, so a router link canpreventDefault()to navigate);asChildrenders its single child through Slot, and several children are wrapped in onediv role="button". The root isrole="list"and each itemrole="listitem"; each trigger is described by hidden "Completed", "Upcoming" or "Loading" text througharia-describedby(merged with yours). A disabledasChildstep is dimmed, ignores the pointer and hastabindex="-1".StarRatingandEmojiRating: activating the pressed star or face from the keyboard clears the rating, matchingaria-pressed;EmojiRatingbuttons are named "Rate N out of M". WithallowHalfthey are onerole="slider"(aria-label,aria-valuenow,aria-valuetext), not a group of buttons: there are no per-star buttons oraria-pressed, Left/Right and Up/Down step 0.5, Page Up / Page Down step 1 and Home clears. Code that selects the stars withgetByRole("button")in half mode uses the slider or[data-rating]. Whole mode keeps its row of buttons (still found withgetByRole("button")), now inside arole="group"named byaria-label(default "Rating"), each witharia-pressed. An unrated star is an outline, an unreached emoji is grey and circled, andEmojiRatingno longer dims faces to 40% opacity.- Image viewers:
ImageOverlayand thePlanMarkdownimage viewer render through a Radix Portal as a dialog titled "Image preview" (wasaria-label="Image Overlay"). Escape, focus return and Tab containment come from Radix. InPlanMarkdownthe image is now wrapped in abutton.pmv-img-button(disabled until the image has loaded;aria-label="Open image"when it has no alt) so it opens from the keyboard and focus returns to it on close. The click handler is on that button, not the<img>, and selectors that expect the<img>directly under the paragraph need updating. ScrollArea: the viewport is a tab stop only while its content overflows, and only then is it arole="region"(named byaria-label,aria-labelledbyor "Scrollable content").aria-labelandaria-labelledbynow land on the viewport, not the root. When the content fits there is no role, label or tab stop.VirtualList: while its items overflow, the scroll container is a tab stop and arole="region"named byaria-label, the newaria-labelledbyprop, or "Scrollable list"; a list that is empty or fits has no role, label or tab stop. The container also clips to its parent's radius (rounded-[inherit]) and is its own stacking context (isolate), with the rows beneath its focus outline.useVirtualListis unchanged; a container you bring should follow the same rules.Toolbar: arrow keys navigate only from the toolbar's own items (not from inputs in a slot or from menu content) and ignore Alt, Ctrl and Meta chords.ToolbarButtonno longer rendersdata-disabled="true"; select:disabled. A pressedToolbarToggleButtonhas a bar under the icon as well as the tint. In the overflow menu aToolbarToggleButtonis arole="menuitemcheckbox"witharia-checkedfollowingpressed(it was a plainmenuitem), and an overflowed item'sonClickreceives the menu item's real click event instead of{}.ResizableHandlesetsaria-orientationfrom itsResizablePanelGroupdirection, and its pointer area (::after) is 25.9px across (w-6at this library's 0.27rem spacing) instead of about 4px, so it overlaps each neighbouring panel by about 12px.PopoverLink(additive, nothing to migrate): the trigger is still aspan role="button"so it flows with wrapped text; Enter and Space now open it, and the popover is a dialog named by the link text.
DataTable and Card
DataTablecolumns can drop out as the table narrows:hideBelow(the table's own container width in px, not the viewport) andpriority(lower is more important; the highest number goes first, one at a time, until the estimated width fits;widthin px or rem is the estimate, else 8rem) move a column into a row details panel, reached by a disclosure button that exists only while a column is hidden.hiddenand the column options menu still win. Before the container is measured (server render, first paint) everyhideBelowcolumn and everyprioritycolumn counts as hidden; the most important one (lowestpriority, else lowesthideBelow) is kept only when that would leave the table with no column at all. A hidden column comes back once the container is 24px wider than it took to hide it, so a scrollbar (15px) appearing or disappearing cannot make a table flip between two layouts; ahideBelowcolumn held back that way takes no room from theprioritycolumns, so none of them is hidden to pay for a column that is not showing. Columns with neither prop never move, so existing tables are unchanged.DataTableRowDetail,DataTableRowExpander,DataTableFooterDetailanduseColumnsHiddenByWidth(it still returns theSetof hidden names) are exported for a custom layout, withuseResponsiveFitbelow.- The pinned columns are counted at their real width. 0.2.1 set aside 36px for the details
button (plus 40px for a selection checkbox) and 80px for the actions, about 32px less than two icon
buttons take (116px against 148px, or 156 against 170 with a checkbox), so on a phone it kept
columns that then lay under the pinned actions. The fit now reserves what the cells take at Medium
density and a 16px root font.
DATA_TABLE_RESERVED_WIDTHchanged with it, to{ select: 22, detail: 48, actions: 100 }:detailis the start cell with its details button,selectis what a checkbox adds to that cell (70 in all), andactionsis the actions cell with two icon buttons. A table near a breakpoint therefore hides a column that 0.2.1 kept; which columns go first did not change. - How the pinned width is computed. The cells are whole numbers of spacing steps
(
DATA_TABLE_SPACING_REM, 0.27rem = 4.32px): cell padding 1, 2 or 3 steps for Small, Medium or Large, a checkbox 3, 4 or 5, the details button 7, an icon action button 9 with 1 between them.estimateStartColumnWidthandestimateActionsColumnWidthreturn that without rendering anything (Medium: 48, 70 with a checkbox, 100 for two actions and 57 for one; Small 39, 61, 91, 48; Large 57, 78, 108, 65), andreservedColumnsWidthtakesdensity,inset(a flush card's padding on the first and last cell),unitPxandactionCount. What a model cannot know is measured from the page after every render and before paint: where the buttons actually sit in the actions cell (so a text button such as "Approve" is counted at its label's width, once the page's fonts have loaded, and a cell that was handed spare room does not read wider),--spacing, the root font size and the rendered checkbox. The measurement only ever raises the actions width above the model, and it looks at the first 25 rows: a text-labelled action that first appears in row 40 of a page is not seen, so give such a tablecollapseRowActionsBelowor--ivy-data-table-actions-width. A root font size changed while the page is open is picked up at the table's next render, not at once. In jsdom nothing is laid out and the model alone applies. - Undeclared widths are still estimates. A column counts as its
widthwhen that is a plain px or rem length, else 128px (8rem, following the root font size). A column whose cells truncate (one that is not right-aligned,nowraporwrapText) is as wide as its text up to--ivy-data-table-truncate-width(20rem), whatever itswidthsays: the table cannot know that a column declared 130px holds names 250px long. Declare the width the text really needs, or set the cap to what thewidths claim (className="[--ivy-data-table-truncate-width:8rem]"). - A header is part of its column's width. The header of a column whose cells truncate (one that
is not right-aligned,
nowraporwrapText) now truncates too, at the same cap: the label gets an ellipsis and the sort icon stays. The whole label is still the name of the sort button, and is shown as a tooltip while it is cut off (set when the pointer enters the header, as a cell's is). Before, a label such as "Counterparty" with its icon set the column's minimum width however short the cap made its cells, so the column outgrew what the fit had counted and lay under its neighbour or the pinned actions. A header over a column that cannot truncate (a date, a right-aligned amount, wrapping text) stays whole, so the fit now counts it: a column is as wide as the wider of its declared width and its header.estimateHeaderWidthis that header, the label plus the sort icon and the cell's padding at the table's density and root font size, andgetColumnsHiddenByWidth,getResponsiveFitanduseResponsiveFittake it as an optionalheader(HeaderWidthMetrics; left out, they count what they did before). The label is estimated from its characters and not measured, because a column the fit has hidden has no header on the page to measure, and a measurement would change once the fonts load, a new width for the fit to answer. The estimate is wider than the text in a typical face (10 to 25 percent over Segoe UI), so a table whose non-truncating columns have long headers can now hide a column that 0.2.2 kept: the one that lay under its neighbour. The header of a column that truncates is not counted: it gives way at the cap with the cells, so such a column is itswidthas before, with the same rule (declare what the text needs, or set the cap to what thewidths claim). Windowed tables lay their columns out at fixed widths and count no header. A touch screen has no tooltip, so keep a header short where its full text matters on a phone. collapseRowActionsBelow(a number of px, opt-in) folds the row actions into one "..." menu button while the table's own container is narrower than that. It frees the room of the extra buttons for the data columns: 100px for two icon actions at Medium, 57px for the menu button. The button is namedActions for <getRowLabel(row)>(the row id withoutgetRowLabel); the menu holds the same actions in the same order, an action withchildrenbecomes a labelled group of items (not a sub-menu, which on a phone is a second tap; a disabled group disables its items, as it disables the button that opens them unfolded, and itsdestructivevariant colours its label), and atooltipthat says more than the label is shown under it, since a phone has no hover. Pressing an item callsonRowActionas the button would, closes the menu and returns focus to the "..." button (unless the action moved it, as opening a dialog does).- Per table, then per row. It applies only when some row on the current page has more than one
action, and then folds only the rows that have more than one: a row with a single action keeps
its button, which is already as small as the menu button. Absent,
NaNorInfinity: the actions are buttons at every width. - Narrow first. Before the container is measured (a server render, the first client pass, and
jsdom, where nothing is laid out) the actions are folded, as
hideBelowcolumns are hidden, so a phone never paints a row of buttons that folds afterwards. To test the wide layout give the table's wrapper a width, astests/data-table-responsive.test.tsxdoes, withvi.spyOn(HTMLElement.prototype, "offsetWidth", "get")returning 800 for the element whosedataset.slotis"data-table"and 0 for every other. - A margin, and a cost. Folded actions come back at the threshold plus 24px
(
DATA_TABLE_REVEAL_MARGIN), so a scrollbar that comes and goes cannot make them flip; inside that band a table keeps what it had, and one first measured there shows the buttons. Unfolding takes the room back from the columns: a table widened across the threshold can lose aprioritycolumn that the folded actions left room for, and folding brings one back.
- Per table, then per row. It applies only when some row on the current page has more than one
action, and then folds only the rows that have more than one: a row with a single action keeps
its button, which is already as small as the menu button. Absent,
- New exports.
useResponsiveFit(the fit hook, returning{ hidden, rowActionsCollapsed }),getResponsiveFit(columns, options)(optionshaswidth,reservedWidth,reservedFoldedWidth,collapseBelow,collapseApplies,remPxandprevious; it returns{ hidden, trigger, collapsed }),getRowActionsCollapsed({ width, below, wasCollapsed }),estimateStartColumnWidth,estimateActionsColumnWidth,DATA_TABLE_SPACING_REMand the typesPinnedColumnMetrics,ResponsiveFit,ResponsiveFitOptions,UseResponsiveFitOptionsandUseResponsiveFitResult.getColumnsHiddenByWidthandestimateColumnWidthtake aremPx(default 16).DataTableRowActionshas two new props,collapsedandmenuLabel(default "More actions"). - Windowed tables (
table-layout: fixed) size their pinned columns from the same model and the same measurement, as inline widths, whether or not a column is ranked or the actions fold: the actions column isvar(--ivy-data-table-actions-width, <needed>px), so the property still overrides it, and it counts in the fit only here, since no other layout reads it. It was a fixed 5rem (80px) before, which two icon buttons did not fit in, so the buttons spilled into the neighbouring column. Only the rows in view are in the DOM, so the widest actions cell seen is kept for as long as the rows are the same: scrolling a wide row out of view does not narrow the column or bring a hidden column back.
- The pinned columns are counted at their real width. 0.2.1 set aside 36px for the details
button (plus 40px for a selection checkbox) and 80px for the actions, about 32px less than two icon
buttons take (116px against 148px, or 156 against 170 with a checkbox), so on a phone it kept
columns that then lay under the pinned actions. The fit now reserves what the cells take at Medium
density and a 16px root font.
DataTablein aCardContentdefaults tovariant="embedded"(no border of its own), padded or flush; one in a dialog, alert dialog, sheet or popover opened from a card stays"bordered". Passvariant="bordered"to keep the old look.Cardgainedmin-w-0; usemin-w-fiton aCardthat should grow with a wide child.- Pagination: the first, previous, next and last buttons are
aria-disabled, not nativelydisabled(no:disabledselector or.disabledcheck), and the<nav>is named<caption> paginationwhen the table has a string caption oraria-label. - Editable cells: the cell button's name is
Edit <header>: <value>(wasEdit <header>), Tab in the editor commits and moves on instead of trapping focus, and Escape in the editor does not reach app-level Escape listeners.DataTableCellEditor'sonCommitmay receive the blur event as a second argument and it has a new optionalonNavigate(direction, event)for Tab and Shift+Tab;DataTable'sonCellCommitevent is unchanged. onRowClickno longer fires for a click on a button, link or input inside a custom cell. A consumeronKeyDown,onFocusoronBluronDataTableruns in addition to the table's own.- Markup and look:
showCaptiondraws a separatearia-hiddencopy and leaves the<caption>visually hidden; the row focus indicator is a 2px inset outline (wasring-1); hover and selected row tints are opaque; the sticky header isz-index2 (3 for pinned header corners);dataTableRowVariantnow includes the background and focus classes. The select-all checkbox reports mixed when only some rows of the page are selected. WithmanualPagination,aria-rowindexis offset by the page like a client-paged table. Cells that truncate are capped at--ivy-data-table-truncate-width(20rem;column.widthis only a preferred width), so a long text column no longer stretches the table. Right-aligned cells use tabular numerals.
New since 0.1.2, so nothing to migrate (behaviour worth knowing)
Field, TimeInput, TimeCalendar, GamepadNavigationProvider, useReturnFocus and
useSoftDisabled did not exist in 0.1.2. TimeInput is typed text and does not take the Input
password props (passwordToggle, revealed, containerClassName, verbatim and the rest);
GamepadNavigationProvider ignores pads whose mapping is not "standard" unless
allowNonStandardPads is set, and only one enabled provider is live at a time (a second is inert and
warns in development). GAMEPAD_STEP_ATTRIBUTE (data-gamepad-step) is exported from the root and
/ui entries; put it on a custom field that steps its value with Up / Down.
Storybook runner and lint scans (contributors)
- Keyboard sweep: a story with
parameters.keyboard.sweepnow also fails when a focus indicator has less than 3:1 contrast, when the page has more stops thanmaxStops, or when the theme it rendered in is not the one in the story'sglobals. Per-stop failures readKeyboard sweep: story "<id>" did not pass.followed by the stops; a theme mismatch fails earlier, naming the declared and rendered themes. See Keyboard sweep. tests/focus-class-scan.test.ts: a border colour no longer counts as a focus indicator next tofocus-visible:outline-hidden; a bareoutline-hiddenwith no focus ring in the string is flagged; a stylesheet rule that removes an outline without restoring one under a focus selector is flagged. Existing offenders are listed in the test'sALLOWLISTandCSS_ALLOWLIST; a new one fails the suite.
@spacecorps/components serves as a core UI dependency for SpaceCorps/Tendril-App (a desktop Rust and Tauri application).
Import the standalone compiled CSS bundle at the root of your application:
import "@spacecorps/@spacecorps/components/style.css";This single stylesheet includes compiled design system tokens, Tailwind CSS utility classes, @layer base styles, and component CSS rules.
Import Tendril widgets directly from the ./tendril subpath:
import {
TendrilShell,
PlanMarkdown,
PlanDiffView,
AgentViewer,
TendrilProcessViewer,
ContentInput,
BadgeSelect,
SortableVerificationList,
TendrilDashboard,
} from "@spacecorps/@spacecorps/components/tendril";The design system uses Geist Sans (--font-sans) and Geist Mono (--font-mono). Consumer applications can either:
- Install variable fonts as dependencies:
and import them in the application entry point:
pnpm add @fontsource-variable/geist @fontsource-variable/geist-mono
import "@fontsource-variable/geist"; import "@fontsource-variable/geist-mono";
- Rely on system fallback fonts (
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-seriffor sans,ui-monospace, monospacefor mono).
When embedding in desktop webviews (such as Tauri), clicking external links should not navigate the desktop container away from the application.
PlanMarkdown provides idiomatic React callback props:
onLinkClick?: (href: string, event: React.MouseEvent) => void;: Intercepts link clicks. When omitted, external links (http://,https://) automatically open viawindow.open(href, "_blank", "noopener,noreferrer")to protect the desktop webview container.onFileClick?: (filePath: string, event: React.MouseEvent) => void;: Intercepts local filesystem (file://) link clicks for desktop integration (such as opening the file in an external editor or revealing in the OS file explorer). When omitted anddangerouslyAllowLocalFilesis false, local file links render as non-navigating text elements.
For reproducible consumption in private CI environments:
- Build and pack release tarballs via
pnpm pack. - Attach the generated
.tgzarchive to GitHub Releases or consume via tarball URL inTendril-App. - This ensures reproducible builds without committing private tokens or registry credentials.
This project includes a comprehensive Storybook catalog showcasing all components with interactive examples and documentation.
Launch the Storybook development server:
pnpm run storybook
# or
vp run storybookNavigate to http://127.0.0.1:6006 to explore the component catalog. When 6006 is already taken the
server steps to the next free port in 6006..6015, and either way the chosen URL is printed on startup
before Storybook boots. Override the range with STORYBOOK_PORT_BASE, or pass an exact port through
with pnpm run storybook -- -p 7777.
Build a production-ready static Storybook site:
pnpm run storybook:build
# or
vp run storybook:buildOutput directory: dist-storybook/
Preview the production Storybook build locally:
pnpm run storybook:preview
# or
vp run storybook:previewThe Storybook catalog is organized into the following sections:
- Foundation — Theme provider, typography, design tokens
- UI Primitives — All 38+ base components with variants and states
- Renderers — Content rendering components (Markdown, Mermaid, Graphviz, JSON, XML, HTML)
- Tendril Shell — Navigation, tabs, sidebar components
- Tendril Widgets — Agent viewer, plan markdown, diff view, dashboard, web viewer
- Inputs & Controls — ContentInput, BadgeSelect, SortableVerificationList
- Specialized Components — Chat bubbles, error boundaries, loading states
Install Playwright browser binaries (one-time setup):
pnpm run test-storybook:installBrowser binaries are cached in ~/.cache/ms-playwright (Linux/macOS) or %USERPROFILE%\AppData\Local\ms-playwright (Windows) and are not re-downloaded per clone.
Run Storybook tests against a running dev server:
pnpm run test-storybookRun Storybook tests in CI mode (serves an existing storybook-static/ on port 6006 and tests
against it, so run pnpm run build-storybook first):
pnpm run test-storybook:ciAccessibility audits run automatically via @storybook/addon-a11y and axe-playwright during Storybook test runs.
By default, any axe accessibility violations fail the test suite.
Axe runs once the page's finite animations and transitions have finished (waiting at most two seconds), so an enter animation or a theme switch still in flight is not measured as a half-faded blend; infinite ones such as spinners are not waited for.
To opt out a story when a violation is inherent to what is being demonstrated (such as a typography scale demo showing multiple heading levels): use a scoped per-rule disable with an explanatory comment directly above the parameter:
parameters: {
// heading-order: this story demonstrates the heading scale directly
a11y: { config: { rules: [{ id: "heading-order", enabled: false }] } },
},Every opt-out must include an explanatory comment justifying why the rule cannot be satisfied. If a per-rule disable does not fit, failOnViolation: false downgrades violations to logged warnings, or disable: true can be used for components that cannot be audited in isolation.
A story opts into a Tab sweep:
parameters: { keyboard: { sweep: true, maxStops: 40, forcedColors: true } },The runner presses Tab through the page and, at every stop, compares the element's outline
and box-shadow focused and blurred. Every stop must show a focus indicator that has at least 3:1
contrast against what it is drawn on (the browser's own outline-style: auto ring counts as visible),
or the story fails with "did not pass" and the stop named. A page with more stops than maxStops
fails too, so raise it to cover the page; forcedColors repeats the sweep under
forced-colors: active. The theme swept is the one the story renders in, and it must match the
story's globals: a dark story needs a story-level globals: { theme: "dark" } (Storybook ignores
parameters.globals), which the sweep checks against the dark class on <html>.
tests/focus-class-scan.test.ts is the static half. It fails on outline-none, on
focus-visible:outline-hidden with no ring or outline under a focus variant in the same class string
(a border colour is not a focus indicator), on a bare outline-hidden with no ring or outline under
a focus variant in the same class string, and on a stylesheet rule that
removes an outline without putting one back under a focus selector.
Every story is screenshotted and compared against a committed PNG baseline in .storybook/__image_snapshots__/, named <story-id>-<theme>-<density>.png. A story matches its baseline when at most 1% of pixels differ.
The committed baseline set is protected by a unit test floor in tests/storybook-visual.test.ts that asserts at least 116 PNGs exist, follow the naming pattern, and are non-empty. This guard runs on every vp test execution, so a missing or corrupted baseline set fails the test suite immediately, rather than silently re-recording platform-drifted snapshots or passing with no comparison.
The baselines are rendered inside a container on purpose. Text rasterization differs between DirectWrite (Windows), CoreText (macOS) and FreeType (Linux) on every glyph edge, which is far more than the 1% threshold allows, so a baseline generated on a developer machine can never pass on ubuntu-latest. Both the committed baselines and the CI comparison use mcr.microsoft.com/playwright:v1.63.0-noble-amd64, matching the pinned playwright devDependency.
To refresh the baselines after an intentional design change, run the Docker command below (a weekly workflow handles baselines that drift as main moves forward):
docker run --rm -t \
-v "$(pwd)":/work -v /work/node_modules -v /work/storybook-static \
-w /work mcr.microsoft.com/playwright:v1.63.0-noble-amd64 \
bash -lc 'corepack enable pnpm \
&& corepack prepare pnpm@11.25.0 --activate \
&& pnpm install --frozen-lockfile --ignore-scripts \
&& pnpm run build-storybook \
&& pnpm run test-storybook:visual:update:ci'On a Linux host add --user "$(id -u):$(id -g)", or the PNGs land root-owned. Swap test-storybook:visual:update:ci for test-storybook:visual:ci to verify without writing.
For a quick local loop against a dev server already on port 6006 — useful for checking that a story renders at all, but not for judging pixels, since host rendering differs from the container:
pnpm run test-storybook:visual
pnpm run test-storybook:visual:updateOn failure, annotated diffs are written to .storybook/__image_snapshots__/__diff_output__/ (gitignored) and uploaded as the visual-diffs artifact by CI.
A weekly workflow (Mondays, 04:17 UTC) regenerates the committed baselines in the pinned container and opens a PR when they drift. Baselines go stale when a PR lands between generation and merge — a contributor who changed nothing visual discovers the false failure — so the automation keeps the set current as main moves forward. The workflow also runs on demand via workflow_dispatch in the Actions tab.
The refresh branch is chore/visual-baseline-refresh, force-pushed, with one long-lived PR. Only PNGs that fail the 1% threshold, or that do not yet exist, are written, so the diff is exactly the set of drifted baselines.
Opening the PR requires either the Actions "Allow GitHub Actions to create and approve pull requests" setting (Settings / Actions / General / Workflow permissions), or a BASELINE_REFRESH_TOKEN repository secret. The branch is pushed either way, so the workflow never loses data — it fails with a clear message naming the branch and the setting.
Opt a story out when it cannot produce stable pixels — a clock, a random seed, or an external site. The parameter works on the meta, which covers every story in the file:
const meta: Meta<typeof Thing> = {
title: "UI/Thing",
component: Thing,
parameters: { visual: { disable: true } },
};or on a single story, when the rest of the file is fine:
export const Randomized: Story = {
parameters: { visual: { disable: true } },
};Currently opted out, grouped by why (a bare title means every story under it; Title → Story names
single stories). tests/readme-catalog-section.test.ts fails when a title with an opt-out is missing
from this list:
- Seeded dates or times, and calendars or lists that open in a portal:
UI/Calendar(seeded withnew Date()),UI/DatePicker,UI/TimeInput,UI/TimeCalendar. - Pixels that depend on something else:
Components/Terminal(the emulator measures its font),Components/WebViewer(frames an external site),Domain/Loading→Skeleton(picks its line count and widths withMath.random()) andComponents/AgentViewer→WithRichPlanLogs(auto-scrolls, and its embedded diagrams resize the container as they render, so the scroll lands a few pixels off each run). - Content in a portal outside the captured root, or stories about focus rather than looks:
UI/GamepadNavigation,UI/Keyboard;UI/Dialog→OpenOverPage,ScrollLockDoesNotShiftLayout,ControlledOpen,ChainedDialogs,EscapeWithAToastOver,EscapeVetoedWithAToastOver,OpenedFromContextMenu,AsChild;UI/AlertDialog→ControlledOpen,ConfirmThenReason;UI/Sheet→ControlledOpen;UI/Sidebar→AppShellKeyboard,DrawerWithAMenu,AppShellCollapsedFooter,InsetFitsViewport,InsetFitsViewportDark;UI/MultiSelect→Chips,InsideDialog,WithActions,LongListInsideDialog;UI/Toolbar→OverflowWithToggles;UI/DataTable→DialogOpenedFromCard;Components/BadgeSelect→InsideDialog;Foundation/Borders and Surfaces→DarkTablesInDialog,DarkEmbeddedTableInAlertDialog,DarkEmbeddedTableInSheet,DarkEmbeddedTableInPopover;Components/PlanWorkspace;Components/PlanMarkdown→WithImage;Renderers/MarkdownRenderer→ImagesAndPopoverLinks. - Interaction stories whose look another story already compares:
UI/Tabs→KeyboardNavigation,Controlled,OverflowingKeyboard,ValueChangedFromOutside;UI/Button→FocusableWhenDisabled,FocusableWhenDisabledBothThemes,IconButtonFocusableWhenDisabled.
A new story gets parameters.visual.disable only for one of the reasons above. Leave the flag off a story
that simply has no baseline yet: the next run of the visual-baseline-refresh workflow writes it, and the
Visual Regression job fails until it lands, which is the reminder to run it.
Animated stories do not need it: the runner injects CSS that pauses animations and transitions before screenshotting.
A story whose content is slow rather than unstable needs a settle delay instead of an opt-out. The runner waits 100 ms between freezing the page and screenshotting it; stories that lazy-load a renderer finish later than that and would otherwise be captured showing a loading placeholder:
parameters: { visual: { settleDelay: 3000 } },Renderers/MermaidRenderer, Renderers/GraphvizRenderer and Components/PlanMarkdown use it, because
mermaid and @hpcc-js/wasm-graphviz are imported on first render and lay their diagrams out
asynchronously. A settle delay only helps when the story is still when it finishes — a story that both
renders late and moves (Components/AgentViewer → WithRichPlanLogs) has to be opted out instead.
The storybook-tests.yml workflow runs on pushes to main and on pull requests that target main, as two jobs:
test— builds Storybook and runs the accessibility and interaction pass. It caches Playwright browsers to avoid re-downloading on every run.visual—needs: test, so it only starts once accessibility is green. It runs in the pinned Playwright container (which already ships browsers, so no install step) and fails if any committed baseline is missing or if snapshots do not match.
This project uses Vite+, a unified web toolchain that integrates:
- Rolldown & tsdown for fast bundling and
.d.mtsdeclaration generation - Oxlint & Oxfmt for lightning-fast linting and formatting
- Vitest for unit testing
Run all linting, formatting, and type-checking in one command:
pnpm run check
# or
vp checkAuto-fix issues:
vp check --fixType-aware linting shells out to tsgolint. On Windows, pnpm only installs a tsgolint.CMD shim,
and that shim hands cmd.exe an unnormalized path into the virtual store — which exceeds the
260-character MAX_PATH limit whenever the checkout sits in a deep directory, so vp check fails
with:
Error running tsgolint: "exit status: exit code: 1"
The system cannot find the path specified.
scripts/patch-tsgolint-win.ts fixes this by placing the native tsgolint.exe where Vite+ looks
for it before falling back to the shim, so cmd.exe is never involved. It is a no-op on non-Windows
platforms.
Every pnpm install deletes that binary again while relinking bins, so the script runs from
prepare (which pnpm executes after bin linking) as well as from pnpm lint and pnpm run check.
You should never need to invoke it, but to re-apply the repair by hand:
tsx scripts/patch-tsgolint-win.tsAdd --json to see what it resolved - the source binary, the target path and its length - without
writing anything.
Retiring this repair: The canary in tests/tsgolintWinPatch.test.ts fails when oxlint-tsgolint declares an .exe bin, or when pnpm's shim stops passing an unnormalized path. When that happens, the patch is obsolete and can be removed. Delete:
scripts/patch-tsgolint-win.tstests/tsgolintWinPatch.test.ts- The
tsx scripts/patch-tsgolint-win.ts &&prefix on bothlintandcheckinpackage.json - The
preparescript (tsx scripts/patch-tsgolint-win.ts, its only command) - This README subsection
To confirm manually: run pnpm install --ignore-scripts, then check if node_modules/.pnpm/vite-plus@*/node_modules/.bin/tsgolint.exe exists.
The repo enforces LF line endings via .gitattributes, overriding your local core.autocrlf setting
— you do not need to change your global git config. If you cloned before .gitattributes was
committed, you may see vp check report formatting issues in files that show no diff in git status.
This happens when your working copy has CRLF but the committed blob is LF: git's stat cache keeps
reporting them clean, while oxfmt sees CRLF as incorrect.
To fix it, discard uncommitted work and re-checkout:
git rm --cached -rq . && git reset --hardThis empties the index and forces git to re-checkout every file with the correct line endings.
Warning: The command above discards all uncommitted changes. Commit or stash your work first.
For a single file:
rm <path> && git checkout -- <path>Note: git add --renormalize . normalizes the index but does not update the working tree, so it
will not fix the vp check failure.
Windows process creation fails at 260 characters, independently of the LongPathsEnabled registry flag. Tendril worktrees inherit this ceiling: the native TypeScript compiler (tsc.exe) that vp pack spawns sits at <checkout-path> + 116-character suffix, so a checkout path of 144 characters or more breaks builds with The system cannot find the path specified.
Tendril's worst-case worktree path for this repo is 126 characters (D:\.tendril\Plans\ + a 66-character plan folder + \Worktrees\SpaceCorps\@spacecorps/components), giving a 242-character target with 17 characters of headroom. virtualStoreDirMaxLength: 40 in pnpm-workspace.yaml caps pnpm's virtual store directory names at 40 characters instead of the Windows default of 60, but does not shorten platform-specific package directories like @typescript/typescript-win32-x64 that already fit under the cap. The binding constraint is the native compiler's path length, not the virtual store depth.
tests/max-path-budget.test.ts enforces the budget with three guards: (1) the declared and installed virtualStoreDirMaxLength agree, (2) every executable this repo spawns fits inside MAX_PATH from a maximum-depth Tendril worktree, and (3) a ratchet assertion that the deepest executable path in the tree has not grown. The test runs on Linux CI too, so the constraint is portable.
To measure your current path length, run the diagnostic command (reports targetLength without modifying anything):
tsx scripts/patch-tsgolint-win.ts --jsonRun the test suite:
pnpm test
# or
vp testRun tests in watch mode during development:
vp test --watchBuild the library for production:
pnpm run build
# or
vp packThis command:
- Bundles all entrypoints (
index.ts,ui.ts,renderers.ts,tendril.ts,diagrams.ts,charts.ts,theme.ts) to ESM modules - Generates TypeScript declaration files (
.d.mts) withtsgo - Outputs to
dist/directory with sourcemaps
Verify the build output:
ls dist/
# index.mjs index.d.mts
# ui.mjs ui.d.mts
# renderers.mjs renderers.d.mts
# tendril.mjs tendril.d.mts
# diagrams.mjs diagrams.d.mts
# charts.mjs charts.d.mts
# theme.mjs theme.d.mtsWatch mode for rapid iteration:
pnpm run dev
# or
vp pack --watchThis rebuilds the library automatically on file changes.
@spacecorps/components/
├── src/
│ ├── index.ts # Root entrypoint
│ ├── ui.ts # UI primitives entrypoint
│ ├── renderers.ts # Renderers entrypoint
│ ├── tendril.ts # Tendril widgets entrypoint
│ ├── components/ # Component implementations
│ │ ├── ui/ # 38+ Radix/shadcn primitives
│ │ ├── Shell/ # Tendril shell components
│ │ ├── AgentViewer/ # Agent execution visualizer
│ │ ├── PlanMarkdown/ # Plan markdown renderer
│ │ ├── TendrilDashboard/ # Analytics dashboard
│ │ └── ...
│ ├── lib/ # Utility functions
│ ├── hooks/ # Custom React hooks
│ ├── contexts/ # React contexts
│ ├── styles/ # Global CSS and design tokens
│ └── types/ # TypeScript type definitions
├── tests/ # Vitest test suite
├── stories/ # Storybook stories
├── dist/ # Built library output (gitignored)
├── dist-storybook/ # Built Storybook output (gitignored)
├── package.json # Package manifest & scripts
├── vite.config.ts # Vite+ configuration
├── tsconfig.json # TypeScript configuration
└── README.md # This file
MIT
Contributions are welcome! Please ensure all changes pass quality checks:
vp check && vp test