statecraft.yaml
The design-system manifest. The import agent writes it for you, so treat this as the page you come to when something specific is wrong — not as something to read start to finish. For what to do about a broken import, start at Design systems.
The file and the row are two documents
This trips up everyone once. A kind: live manifest carries two kinds of field, and they go to different places.
- Editor-facing fields —
name,source,scope,components,typography,tokens,providers,peers,stylesheets,fonts— describe what the canvas renders. These are mirrored onto your workspace's design-system row on every successful build. - Build-only blocks —
build,install,tailwind.build,cssEngine,pandaConfig,unocss,resolveConditions,aliases,scssShared,defines— describe how to compile the bundle. These are stripped before the body reaches the row.
So the YAML you see in the app is a subset of the file in your repo. That is why the in-app editor is read-only for manifest-managed rows: it couldn't round-trip the build config even if it wanted to. Edit the file.
tailwind has two sibling sub-blocks and only one is stripped. tailwind.build (mode, globals, config path) is build-time. tailwind.render (an inline config object for the preview iframe's Tailwind CDN) is row-side and survives. vueVersion is the other shared key — it pins the compiler and the iframe runtime together.
source
Exactly one of five kinds. Everything else in the file depends on this choice. See Design systems for which to pick.
source:
live: true # your repo, compiled by Statecraft (canonical for real teams)
source:
npm: "antd" # a published package, from esm.sh
version: "5.22.0" # exact semver — ranges and "latest" are rejected
deps: # optional: pin transitive peers esm.sh resolves wrong
"@mantine/hooks": "7.14.1"
source:
css: true # a stylesheet; components synthesised from cssMapping
source:
kind: none # plain HTML tags
source:
kind: host # a pre-built ESM bundle you hostVersion pins for npm must be exact — a range would let a compromised upstream propagate into your canvas automatically. Subpaths go directly in npm (e.g. @mui/material/styles).
For css, each component entry declares a cssMapping and Statecraft synthesises a wrapper that renders the mapped tag and merges variant class names:
components:
- label: Button
tagName: Button
snippet: '<Button variant="primary">Click</Button>'
cssMapping:
tag: button
defaultClass: button
variants:
variant: # enum-style: value -> class
primary: is-primary
danger: is-danger
# dismissible: is-dismissible # boolean-style: truthy prop -> classDeliberately simple — no ref forwarding, no internal state. Anything that needs behaviour (Tooltip, Dropdown) wants a real component library.
build
What the bundle is. entry and framework are required when the block is present, and paths resolve relative to the manifest.
build:
entry: ./src/index.ts # bundle entry
framework: react # react | vue — must match the row
frame: ./src/Frame.tsx # optional provider wrapper (a file in your repo)
# OR, when no such file exists, inline it — relative imports resolve
# against the REPO ROOT, not the manifest:
# frameSource: |
# import "./src/styles/globals.css";
# export default ({ children }) => <ThemeProvider>{children}</ThemeProvider>;
# frameLanguage: tsx # tsx | jsx | vue (defaults from framework)The frame is how you reproduce your app shell — the global stylesheet, the theme provider, the CSS reset that your components silently depend on. It is the fix for "everything renders but unstyled". frame and frameSource are mutually exclusive.
install
Where and how to install dependencies before bundling.
install: installCwd: .. # run install at the workspace root (monorepos) packageManager: pnpm # npm | pnpm | yarn | bun installCommand: "pnpm install --filter ui..." skipInstall: true # reuse an existing node_modules (CI) runInstallScripts: true # allow dependency postinstall scripts workspaceBuildCommand: "pnpm -F ui build" # run before the bundle build
skipInstall is what you want in CI, where your workflow has already installed. packageManager: pnpm is the escape from the npm 11 arborist crash on Node 22+.
tailwind
tailwind:
build: # build-time (stripped from the row)
mode: v4 # v3 | v4 | none
globalsCss:
- ./src/styles/globals.css # required when mode is set
config: ./tailwind.config.ts # REQUIRED for v3 — there is no fallback
content: ["./src/**/*.tsx"] # extra template globs to scan
render: # runtime (survives onto the row)
enabled: true
config: { theme: { extend: {} } }None of this is detected for you. Tailwind setup is explicit configuration — if the mode is set and globalsCss is missing, or v3 is set without config, the build fails rather than guessing.
cssEngine
Declare your CSS-in-JS engine and the bundler installs the matching Vite / Babel / PostCSS plugin. Three families behave differently:
| Family | Engines | Notes |
|---|---|---|
| Compile-time | vanilla-extract, Linaria, Pigment CSS, StyleX, Compiled, Macaron, Windi, Panda | Emit real CSS at build. Panda additionally needs pandaConfig and a cssEngineImports entry pointing at your @layer CSS file — without it the bundle ships 0 KB of CSS. |
| Runtime | Emotion, styled-components, Stitches, Goober, JSS, Theme UI, Fela | Ship as normal npm libraries and inject styles at render. Declaring the engine tells Statecraft a 0 KB CSS bundle is expected, not a failure. |
| UnoCSS | UnoCSS | Has its own block, not a cssEngine value. |
cssEngine: vanilla-extract
# Panda needs both of these:
# pandaConfig: ./panda.config.ts
# cssEngineImports: [./src/styles/index.css]
# UnoCSS rides its own block:
# unocss:
# build:
# config: uno.config.ts
# presets: [uno]
# Vite passthrough:
# aliases:
# - { name: "@", path: src }
# resolveConditions: [source]
# scssShared: ./src/styles/_shared.scss
# defines:
# - { key: __DEV__, value: "false" }scope
Which exports are legal as JSX tags. Hand-authored — Statecraft never writes this for you, because guessing would expose hooks and cva() helpers as broken pseudo-components. The real export list is written to _design-systems/<slug>.exports.txt for you to copy from.
scope: - Button # import Button, use as <Button> - default as AcmeLogo # import default, use as <AcmeLogo> - Card as Panel # import Card, use as <Panel> # Or expose a whole module under one name: scopeNamespace: antd # JSX: <antd.Button>, <antd.Card>
The two can coexist. Remember that scope alone gives you a working design system with an empty palette — you also need a components entry for anything you want to drag out.
components
The palette. snippet is the JSX inserted when you place one; props tells the Properties inspector what editors to show.
components:
- label: Button
tagName: Button
category: Form
description: Primary action trigger
docsUrl: "https://ui.shadcn.com/docs/components/button"
snippet: "<Button>Click me</Button>"
props:
- name: variant
type: string
label: Variant
group: appearance
defaultValue: default
options:
- { label: Default, value: default }
- { label: Ghost, value: ghost }
- name: disabled
type: boolean
label: Disabledtype | Editor |
|---|---|
string | Text input, or a dropdown when options is given. |
number | Numeric input. |
boolean | Checkbox. |
expression | Free-form JSX expression — onClick handlers and the like. |
color | Colour picker plus the colours-token dropdown. |
token | Paired with tokenKind (e.g. spacing), surfaces that token category as a dropdown. |
icon | Paired with iconSource (a scope namespace), renders an icon picker. |
responsive: true on any prop wraps it in a breakpoint tab strip.
typography
Your type ramp. Declaring it drives the canvas Text tool's style dropdown and the inspector's text-style switcher; leave it out and both fall back to plain HTML roles. Each snippet is one root element whose text is Text.
typography:
- label: Display
snippet: '<Heading size="9">Text</Heading>'
- label: Body
snippet: '<Text size="3">Text</Text>'
- label: Eyebrow # class-based ramps work too
snippet: '<p className="eyebrow">Text</p>'tokens
Each category is a flat map. The inspector offers them as quick-picks on the matching CSS property, and they're emitted as CSS custom properties in the preview iframe, so snippets can reach them via var(--color-brand). Key casing is preserved — textFaint becomes var(--color-textFaint).
tokens:
colors:
brand: "#3245ff"
colorsDark: # optional parallel override for dark mode
brand: "#7a8fff"
spacing:
md: "16px"
radii:
pill: "999px"Categories: colors, colorsDark, spacing, radii, fontFamilies, fontSizes, fontWeights, lineHeights, letterSpacing, shadows, borderWidths, breakpoints, zIndex, opacity, durations, easings.
providers
The root wrapper chain, outermost first. Each wraps the next; the last wraps your canvas content.
providers:
- import: ThemeProvider
props:
theme:
$factory: createTheme # call a factory from the module
$args:
- palette: { mode: light }
siblings:
- import: CssBaseline # side-effect component, not a wrapper
- import: ToastProvider
props: {}$ref resolves a dotted path to a module export ($ref: theme.darkAlgorithm). siblings are for components that must live inside the provider but don't compose as wrappers — MUI's <CssBaseline />, Chakra's <ColorModeScript />.
Related: styleCache (emotion, antd-cssinjs, griffel, styled-components, styletron) is an optional hint that shares one engine instance across your library and its additionalModules. You usually don't need it — each design system already renders in its own iframe, so its styles can't leak.
peers
If your bundle bare-imports something Statecraft doesn't pin, the import fails at load. Declare it here to append an importmap entry.
peers:
- specifier: "@tanstack/react-query"
url: "https://esm.sh/@tanstack/react-query@5.64.0?external=react,react-dom"Eleven specifiers are locked and will fail validation: react, react-dom, react-dom/client, scheduler, and the routing primitives (react-router-dom, wouter, wouter/memory-location, @tanstack/react-router, next/link, next/navigation, next/router). These must stay singletons with the host — selection, inline editing and the canvas's no-op router all depend on both sides resolving the same module instance.
A peer the host already pins can't be overridden — browser importmap rules don't allow it, and the bundle resolves to the host's version. The dev console logs a warning so the mismatch is at least visible.
For icon packs and companion libraries, use additionalModules instead — each gets its own version pin, scope list and optional namespace:
additionalModules:
- npm: "@radix-ui/react-icons"
version: "1.3.2"
scope: [HeartIcon, StarIcon]
scopeNamespace: Icons # JSX: <Icons.HeartIcon />Styling & fonts
These feed the preview iframe directly and work with every source kind.
stylesheets: # <link rel="stylesheet"> in each iframe head
- "https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css"
globalCss: | # raw CSS injected into each iframe head
body { margin: 0 }
fonts:
- family: Inter
url: "https://fonts.googleapis.com/css2?family=Inter:wght@400;600"
themeModes: [light, dark] # what the canvas theme toggle offers