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 { 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
navItems={[
{ id: "plans", label: "Plans", icon: "folder", href: "/plans" },
{ id: "jobs", label: "Jobs", icon: "activity", href: "/jobs" },
]}
tabs={[{ id: "overview", label: "Overview", path: "/" }]}
>
<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
options={[
{ value: "feature", label: "Feature" },
{ value: "bug", label: "Bug" },
]}
value="feature"
onChange={(value) => console.log("Selected:", value)}
/>
</div>
</TendrilShell>
);
}@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 (builds static files 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.
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.
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 126 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 nightly 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 nightly workflow 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: UI/Calendar (seeded with new Date()), Components/WebViewer (frames an external
site), Domain/Loading → Skeleton (picks its line count and widths with Math.random()) and
Components/AgentViewer → WithRichPlanLogs (auto-scrolls, and its embedded diagrams resize the
container as they render, so the scroll lands a few pixels off each run). 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 every push and pull request, 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
&& tsx scripts/patch-tsgolint-win.tssuffix onprepare(leavevp config) - 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) 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.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