openstylus
v0.8.0
Published
Brand-neutral, code-on-disk canvas for iterating on rendered React/CSS assets. One local node-24 process hosts a Streamable-HTTP canvas-loop MCP + a first-party SSE channel + the web UI over one node:sqlite sidecar; the engine is config-parameterized and
Readme
openstylus
A local, brand-neutral, code-on-disk canvas for iterating on rendered React/CSS assets. You open a board, see a pan/zoom grid of live component-variation renders, curate and comment on them, then run Claude Code to generate the next round — variations stream onto the board, you iterate, and the winner graduates into a kit. The whole loop runs on your machine; the only thing off-box is the LLM (Claude Code).
The outcome: generate → compare → curate → comment → iterate → graduate — and what you land is real code you can ship, not a cloud document or a one-way export.
Brand-neutral by contract. The engine carries zero brand identifiers — every design-system path, token bundle, and graduate destination is read from a
canvas.config.ts, so the same build serves any design system with only config changing. openstylus ships a neutral example design system, proven against a second example. Files on disk are the sole source of truth; the canvas is a render view. The one remote dependency is Claude Code — there is no other LLM and no cloud.
Where it sits
openstylus is a standalone tool — a local server + web UI + an MCP that reads a design
system (paths/tokens from a config) and writes back to it only through graduate. It carries
zero brand identifiers and ships a neutral example design system (fixtures/example-ds), so
the same build serves any design system with only the config changing.
Use it from another repo. openstylus is published to npm (openstylus), built dist-only
(no source ships in the package). A consuming repo owns its config and runs the tool by name:
bun add -D openstylus
# author a config pointing at YOUR design system (see configs/example.config.ts), then:
CANVAS_CONFIG=./your.config.ts openstylusReact 19 peer. openstylus declares
react/react-dom^19as peer dependencies — the shippeddist/webbundles one React 19, shared into your DS components and agent variations through the vendor shims. Installing under React 18 raises a peer-dependency warning; upgrade to React 19.
The server reads CANVAS_CONFIG from the environment (no --config flag yet). For live
development of openstylus itself from a consumer, bun link overrides the published package.
(It was extracted from the Open Knowledge GTM monorepo, which owns an ok.config.ts pointing at
its design system and runs bun run openstylus.)
Package managers + the node-24
enginesfloor. openstylus declaresengines.node >= 24(see Quickstart Prerequisites — the binary importsnode:sqliteat its entry point, so every invocation needs it). In an engine-strict pnpm workspace,pnpm add -D openstyluson node < 24 fails by design (ERR_PNPM_UNSUPPORTED_ENGINE) — the contract refusing an unsupported runtime, not a bug. To run a verb out-of-band, usepnpm dlx openstylus ds validate …with node ≥ 24 onPATH—dlxis a runner, never a way around the floor.
Validate your DS manifest
Author your design.json with editor validation + autocomplete by referencing the published
JSON Schema:
{ "$schema": "https://schemas.openstylus.ai/ds-manifest/v1.json", "namespace": "...", "...": "..." }The schema also ships in the package (node_modules/openstylus/design.schema.json) for offline /
CI use, and openstylus ds validate <design.json> runs the full definition-time check — manifest
shape plus token resolution, component-surface extraction, and born-conformance — exiting non-zero
with located findings on any failure.
As of a July-2026 prior-art survey (reports/ds-schematization-prior-art/),
no surveyed system — shadcn registry, v0/Geist, Claude Design, open-design, DESIGN.md,
DTCG/Code Connect/CEM — combines an authored design-system declaration, variant axes derived
from component source, and a definition-time conformance gate in one code-first artifact.
Consumer preview inputs
Point dsManifest at your design system and the navigator renders every member × governed variant with
zero preview authoring — it derives the surface from your source. Some members need inputs the substrate
cannot derive (an icon, a provider, an asset, fixture data); you declare those once, in code, next to your
DS, and the navigator honors them in its browser realm. Each channel is optional — absent ⇒ the bare-derive
path, unchanged:
ds-preview.tsx— a CSF-style preview module discovered at your DS root (or pointed at by the manifest'spreviewfield). Its project annotations — decorators, globals (viainitialGlobals/globalTypes), parameters, args, render — wrap every member in the browser realm, so a context-coupled member (one that needs an ancestor provider: a theme, a Tooltip root) renders clean. Annotations apply in Storybook layering order (project outermost, last wins); run-time-only kinds (loaders/play/beforeEach/beforeAll/afterEach) are documented-unsupported — they never run on a synchronous compose.*.example.tsx— a CSF-shaped example sidecar next to a member's source (or the manifest's per-componentexampleoverride), supplying only the un-derivable inputs (a required icon, a component-typed prop). Governed axes stay derived, so an example never collapses the member × variant grid.publicDir— your static/public asset directory, declared in the canvas config and served at app-absolute URLs so a member's<img src="/logo.svg">orurl(/mask.png)paints in the gallery.- The fixture-data layer — a seeded provider spine (+ msw escalation) for members that read a client cache. See Data-coupled members below.
identityExternals(manifest) — additive context-identity externals vendored from your project so a provider and a member resolve one shared context instance. It extends the derived set, never replaces it; an entry naming an uninstalled package is ignored with a located finding (fails closed).
Whatever a member still can't render, the navigator labels — never fabricates. The Support boundaries section is the whole map: every member is either rendered faithfully or told exactly what it needs.
Data-coupled members: the fixture-data layer
Members that read data from a client cache (a TanStack Query QueryClient, an offline store) can't be
bare-derived — with no provider in scope their hooks throw. Supply the data next to your DS, in code, in
three layers (worked example: fixtures/data-ds):
- Seeded provider spine (the default). Your
ds-preview.tsxwraps every frame in a provider that holds a pre-seeded client, so the member renders its seeded content offline — no network. This is the normal path for a member that reads a cache the app populates elsewhere. - msw escalation (for true fetchers). A member that actually fetches keeps its real query; mock the
network with
mswso the same member renders identically whether the data came from a direct seed or the mocked transport. - A fresh client per mount (the isolation rule). Create the client inside the provider (e.g. a
useStateinitializer), never a module-level singleton. Stories compose synchronously here, sobeforeEach-cleared shared clients — Storybook's other documented pattern — are structurally unusable, and concurrent gallery cells sharing one cache would bleed across members. Fresh-per-mount keeps every cell isolated.
Reachable provider requirement. If your contexts are module-private (the client is created inside your
app and never exported), the DS must export its preview/fixture provider so the navigator's realm can
reach it — a bare dsManifest cannot construct a client it can't import. fixtures/data-ds exports
DataFixtureProvider (a fresh, pre-seeded client per mount) and its ds-preview.tsx consumes it as the
project decorator.
Context identity is handled for you: the query engine rides the substrate's per-DS derived vendor singleton (the same mechanism that gives your DS components one React), so the provider and the member resolve to one client instance.
Support boundaries
The navigator is a truthful gallery: every member either renders faithfully or is labeled with exactly
what it needs. Nothing here gates — these boundaries label; your DS is never marked non-conformant for
crossing one, and the ds validate exit code stays the gating findings' alone. This is the whole floor-and-tier
map, so you can place any member — in, degraded, or out — without reading substrate source.
Two channels carry the signals. Advisories print from openstylus ds validate as a non-gating ℹ block,
each located (file · member · class). Cell states label each navigator cell in place with its member, its
class, and the remedy. A member can carry both.
| Member class | What the substrate does | Signal you see | Remedy / boundary |
|---|---|---|---|
| Args-coupled — a required, un-derivable prop (an icon, a component-typed prop) | flags it statically; the cell labels it | preview-incomplete-member advisory · a needs input cell (or a flagged badge if it still renders) | author a <Member>.example.tsx supplying the input |
| Context-coupled — needs an ancestor provider (a theme, a Tooltip root) | not statically flagged (undecidable — zero required props); caught when it fails to mount | a failed to mount cell (view-time) | declare the provider in ds-preview.tsx |
| Data-coupled — reads a client cache (TanStack Query, a store) | the same empirical mount failure with no provider in scope | a failed to mount cell | a seeded fixture provider (see Data-coupled members); export it if your contexts are module-private |
| Asset-coupled — an app-absolute image/mask URL | serves it from publicDir; absent ⇒ the asset 404s (a no-public-dir boundary signal) | an asset degraded cell (the member still mounts) | declare publicDir in the canvas config |
| CSS-importing — a co-located .css | serves + injects it (not emptied) — the token stylesheet still applies | rendered (accommodated) | none — it just works |
| import.meta.env — degraded tier | folds the five Vite builtins + the whole import.meta.env object at compile | rendered, with the env folded | boundary: the fold is dev values (MODE:"development", DEV:true), not your build's env |
| next/image — degraded tier | aliases to a harness-owned <img> degradation shim (no loader URL is emitted) | rendered; a missing asset ⇒ asset degraded | boundary: no image optimization/loader — the raw src paints |
| next/link — degraded tier | aliases to a harness-owned <a> degradation shim — the real App Router <Link> renders exactly an anchor without a router (Next-navigation props are dropped) | rendered — a plain anchor with your href | boundary: no client navigation/prefetch — a clicked link navigates natively |
| Unshimmed framework import — next/navigation, next/router, next/head, next/script, next/font/* | signals rather than accommodates (these have no faithful non-Next degradation — they hard-require a runtime absent here) | framework-coupling advisory · a framework boundary cell | boundary: make the member framework-portable |
| Non-client-renderable — an async (Server) component, or a server-only / node: import | excluded from the client mount (the navigator mounts client-side only) | client-renderable advisory (it will also fail to load or mount at view time) | boundary: members must be client-renderable |
| Live-transport — a CRDT / socket engine, such as yjs, y-websocket, socket.io-client, sharedb, @liveblocks/client, partysocket | deliberately does not mount it | the needs: live-provider signal · an excluded cell | boundary: preview it live on a board |
| Custom module transforms — SVGR (.svg as a component), .scss, .mdx | never runs your bundler's transforms (esbuild has no loader for these) | a failed to compile cell | boundary: these imports aren't transformed here (≈0 in the reference consumers) |
| React-major skew — your project declares a React major ≠ 19 | mounts every member under the host's React 19 regardless | react-major-mismatch advisory | boundary: align the React major, or expect version-skew |
| Video-kind — a kind: "video" member | previews a placeholder + show-source, never live-mounts (a frame-clock composition needs its engine's frame context, deliberately absent) | a Video boundary placeholder | boundary: preview playback on a board |
Two closing notes:
- CSS-modules are handled, not a boundary. A
.module.cssimport compiles under esbuild's native CSS-modules support (class names map) and its styles inject like any co-located.css— so it renders, it is not in the custom-transform boundary above. - A detector fault is never your member's fault. If the mount-conformance detector itself errors on a
pathological input, you get a distinct
mount-conformance-erroradvisory — never a false member finding. - Large galleries (~300 cumulative mounted cells). Past roughly 300 cumulative mounted cells, offscreen
unmounting becomes worthwhile. This is a deferred, measured refinement — a tracked in-browser trend, not
a gate (the candidate mechanism is React
Activity mode="hidden", which retains DOM but tears down effects). No signal today; recorded so the ceiling is known.
Every advisory code above prints located and advisory-only; every degraded cell — needs input, failed to
mount, asset degraded, failed to compile, framework boundary, excluded, or the rendered-but-flagged
badge — names its remedy in place, so no cell is ever blank without an explanation.
Quickstart
Prerequisites: macOS (the file-watcher ships a darwin-arm64 prebuild) · node 24
(the binary's entry point statically imports the built-in node:sqlite via its database module, so every
invocation — the server and every openstylus ds … CLI verb alike — needs it, and bun lacks it — the
package's hard engines.node >= 24 floor) · bun (the script runner).
# 1. Install.
bun install
# 2. Build — lib + web UI. The build reads CANVAS_CONFIG (defaults to the bundled neutral
# example config), so swapping that env var swaps the design system, no code change.
bun run build
# 3. Start the server (runs under node 24) against the bundled example design system.
# It prints its URL + the connect command.
bun run dev
# [canvas] serving http://127.0.0.1:4319 (boards: …)
# [canvas] connect Claude Code:
# claude mcp add --transport http canvas http://127.0.0.1:4319/mcp
# 4. Point Claude Code (CLI or desktop app) at the board's MCP.
claude mcp add --transport http canvas http://127.0.0.1:4319/mcpThen open http://127.0.0.1:4319/ in a browser — the launcher lets you create a board or open a recent one. (Until the UI is built it serves a placeholder page that just shows the connect command.)
Useful env vars: CANVAS_CONFIG (path to a canvas.config.ts — also enables graduate),
CANVAS_PORT (default 4319), CANVAS_BOARDS_ROOT (where boards live).
Seeded demo board (dev + agent QA)
bun run dev starts an empty canvas. To launch on a pre-seeded board — for local
development, or for a /qa agent that needs a real running system to verify against — use:
bun run seed:demo # a board of live-rendered variations on :4330
bun run seed:demo --scenario states # every card state + curationThe board scenarios live in examples/ and are the single source of truth shared by
this CLI, the Playwright fixture, and the scenario smoke test — so QA and the suite see identical
boards. See examples/README.md.
The loop
A board is one asset plus its full variation tree and feedback. The happy path:
Open a board. Creating one from an existing component copies its source in (shadcn-style) as the editable round-0 seed; an empty board scaffolds the next step rather than showing a blank canvas.
See variations. Each card is a live render — the CSF export mounted directly (
composeStory→createRoot), one realm, shared tokens/fonts, a per-frame error boundary so a throwing variation shows an inline error card while its siblings render. Rounds lay out as rows (newest on top); fullscreen-a-card zoom-fits one variation.Curate. Like any variation (a durable, multi-select shortlist, shown by icon + border — never color alone) and mark one pick per board (a stronger winner state). Both persist across reload and are readable by the agent.
Comment. Drop Figma-style pins — a point on a sub-region, a click-drag drawn area over a region, or a whole-card note — plus a meta comment across a whole round. Freeform text per pin; each comment carries a 4-tier anchor and links to its lineage node.
Run a round (Claude Code). The canvas surfaces the board's live state and a copy-pasteable next prompt; you run it in Claude Code against the MCP. Claude Code
create_rounds — the board immediately paintsnqueued cards — then reads your curation + comments + lineage, writes CSF to disk, self-heals (build → play/a11y → screenshot → vision-critique), and finalizes lineage as files land. Three modes:| Mode | Parents | Use | |---|---|---| |
fresh| 0 | generate from the brief only (defaults to 3 variations) | |sequential| 1 | refine one parent | |merge| 2+ | blend several parents — the differentiator no incumbent ships |Watch it stream. Cards move
queued → generating → rendered(or inlineerror) modelessly — never a dialog. A round that dies mid-flight resolves its open cards tostalerather than hanging.Iterate. Repeat 3–6; the lineage DAG grows; round-meta comments steer direction.
Graduate. Confirm the pick and
graduateit to a configured destination (canonical/generated/ a custom path) — the winner's source file lands in the kit. This is the only write-back path; exploration never mutates a canonical component.
Comments, drawn areas, and the agent (D11-AREA)
Comments are the agent's primary instruction channel. A region pin defaults to a point
(click); click-drag rubber-bands a drawn rectangle over a sub-region (opt-in,
mode-gated so it never fights pan/zoom — no arrows or freehand). This is load-bearing: when
a region carries a non-zero width/height, the agent pipeline composites that rect as a
visible box onto the screenshot it critiques (and/or crops to it) — the "set-of-mark"
effect that makes a vision model localize reliably. Bare {x,y,w,h} numbers are not enough;
the drawn box is. The rectangle rides in the existing comment anchor, so this is UI-only
— no schema or MCP-contract change.
Architecture
One node-24 process is the whole spine. It hosts three surfaces over one shared store:
POST/GET /mcp— the canvas-loop MCP over Streamable HTTP. This is what Claude Code dials. Its server→client SSE stream carries round notifications.GET /events— a first-party SSE channel the browser subscribes to (the UI is not an MCP client; it gets its own push channel). This is how a Claude-Code-driven round shows up live in the UI, not just on the next disk-watch tick.GET /*— the static web UI (xyflow board). Same origin, loopback-only.
Underneath sits one gitignored .canvas/ sidecar: a single node:sqlite database plus
an append-only events.jsonl (the durable WAL — delete the .db, rebuild an identical
graph). The database holds only what can't be re-derived from code: lineage (node +
edge, where edges are rows — 0 = fresh, 1 = sequential, N = merge, the only shape that
expresses an N-parent merge as a real DAG), comments (4-tier anchor + FK into lineage),
and positions/selection.
Code on disk is the source of truth: variations are CSF *.stories.tsx under
<boardsRoot>/.canvas/<board-id>/, each carrying a stable parameters.canvasId (= the
sidecar key, survives rename). A parcel-watcher reconciles disk → view by content hash —
re-read + re-render, no code merge (code never enters a CRDT, so a concurrent edit
collapses to "render whatever landed on disk"). The trigger is always the human running
Claude Code; the canvas never spawns the agent in P0.
The canvas-loop MCP
The one contract the web UI, Claude Code, and the test harness all bind to. Six tools (names + semantics are a frozen contract):
| Tool | Does |
|---|---|
| list_selected(boardId) | the curated set — liked shortlist + the one pick. Read first. |
| get_comments(boardId, ids?) | anchored feedback, anchors intact; ids narrows to variations. |
| get_lineage(ids) | each variation's parents/children/round/mode. |
| create_round({boardId, mode, parents?, brief?, n?}) | open a round → board paints n queued cards; returns the variation ids to write. |
| graduate({node_id, target}) | mark the winner + run the configured destination workflow. |
| add_edge({child_id, parent_id, ordinal?}) | record a derivation edge (cycle-checked on write); usually automatic. |
A round is a two-phase protocol: create_round opens/announces it; per-variation
completion finalizes node + edge rows as files land (a merge records N edges with
ordinals). Domain violations (unknown id, mode/parents mismatch, a cycle) come back as
actionable, self-correctable tool errors — never a protocol abort.
Configuration
A target project authors a canvas.config.ts (paths relative to that file); loadConfig
validates it and resolves every declared path to absolute. The engine never hardcodes a brand.
Only name is required — the config tiers: a DS-less board ({ name }), a consume-only
adopter that just reads a DS ({ name, dsManifest }), and a full authoring target that adds the
generation/render/graduate fields. Every optional field is still validated when present.
| Field | Required? | Purpose |
|---|---|---|
| name | yes | a label for the launcher; never branches engine behavior. |
| dsManifest | optional | path to this board's DS-substrate manifest — the board→DS link get_design_system/lint_variation read. Absent ⇒ a board with no DS. |
| designSystem | optional | { packageName?, root } — the DS resolved by path. packageName is needed only to graduate package-subpath targets; root is required when the block is present. |
| tokensCss / fontsCss | optional | the one shared token + font load for the render realm. |
| storyGlobs | optional | where authored CSF lives (indexing + Tailwind @source scanning). |
| boardsRoot | optional | where <board-id>/ folders + the .canvas/ sidecar live (falls back to cwd). |
| publicDir | optional | consumer static/public dir, served at app-absolute URLs so members' asset URLs paint (realpath-contained). Absent ⇒ a 404 boundary signal. |
| graduateTargets | optional | R7 destinations by key (package-subpath or absolute path). |
Both the server (CANVAS_CONFIG) and the build read the config, so swapping
CANVAS_CONFIG swaps the design system with zero code change. The bundled
configs/example.config.ts is the neutral default (it points at
fixtures/example-ds/); fixtures/example-alt/
is a second config that proves neutrality (and doubles as the test substrate). Without a config
the board still serves and curates —
only graduate is disabled.
Testing
Every seam ships an agent-emulated E2E test, in two modes tiered by determinism (the
in-production ~/openbolts pattern):
- Mode B — emulated (gates every run, $0). The Claude Code piece is a fixture-replay
round-driver that issues the real tool sequence against the real handlers, real disk, and
real
node:sqlite— only the agent's decisions are canned. Deterministic seams (UI ↔ MCP ↔ SQLite ↔ disk ↔ render) run hermetically. - Mode A — real (on-demand, NOT CI). The same paths against real Claude Code, for
drift detection. Spawns real
claude -pturns; never gates a build.
A shared assert<Seam>Contract helper is invoked by both sides (authored once,
@seam-paired-with-linked, lint-enforced by seams:check), so the mock can't silently
drift; a drift canary backstops the wire shape. Assertions check observable state
(lineage rows, status, on-disk CSF, tool-call presence) — never LLM text or quality. The
deterministic tiers pin CANVAS_ALLOW_CLAUDE_RUN=false so a stray real-model call throws
instead of becoming a billable run.
bun run check # validate + seams:check + typecheck + test + build (the gate)
bun run test # vitest unit + e2e (Mode B, gates every run)
bun run test:llm # vitest llm (Mode A real Claude, on-demand)Test tiers are keyed by filename: *.test.ts (unit) · *.e2e.test.ts (Mode B, gating) ·
*.llm.test.ts (Mode A real Claude, on-demand) · *.live.test.ts (non-Claude external).
check runs unit + e2e and excludes the real-Claude tier — no LLM step ever runs in CI.
Scripts
| Script | Purpose |
|---|---|
| dev | start the server (node 24) — MCP + events + UI over one sidecar. |
| build | tsc (server/lib) + vite build (web UI → dist/web). |
| test | Vitest unit + e2e (Mode B — gates every run). |
| test:llm | Vitest llm tier (Mode A — real Claude, on-demand). |
| validate | brand-string grep over the engine (must find none) + config checks. |
| seams:check | fail on an orphaned @seam-paired-with link. |
| typecheck | tsc --noEmit. |
| check | validate + seams:check + typecheck + test + build — the full gate. |
Constraints / boundaries
Worth knowing before you change anything:
- The SQLite schema and the MCP tool surface are frozen contracts — the UI, Claude Code, and the test harness all bind to them. Changing a table, column, tool name, or its semantics is a contract change, not a refactor.
- The agent writes CSF only into
<board>/.canvas/<board-id>/. Write-back to the kit happens only viagraduate; a canonical component is never mutated during exploration. - No LLM or vision step runs in CI. Vision-critique is capped self-correction + a recorded advisory that never blocks surfacing — the human is the judge of record.
- Code never enters a CRDT. Concurrent edits reconcile by re-read + re-render; a git
conflict on a
.tsxis the user's git's concern.
P0 covers components + static graphics. Video/deck assets, a canvas-UI spawn trigger, auto-quality-scoring, and multi-user realtime are explicitly Future (see the SPEC §15).
