@pyreon/atlas is a component workbench that inverts Storybook's model: you don't author stories — Atlas derives them. Your components and their TypeScript types are the source of truth. Atlas infers controls from prop types, generates scenarios from variant axes (rocketstyle dimensions included), verifies every scenario with a five-check pipeline, and writes the result as a machine-readable catalog that both humans (the workbench) and AI assistants (the agent guide) consume.
Installation
pyreon add @pyreon/atlasOr run it through the CLI front door with zero setup — pyreon atlas <cmd> delegates to the project-local install.
atlas init — write the config the workspace already implies
pyreon atlas init
# writes pyreon.config.ts (or prints it with --dry-run)Atlas works with zero config — atlas scan / atlas dev need nothing at all in a plain component library. atlas init exists for the moment right after that: it detects your workspace's packages (a monorepo becomes AtlasConfig.projects), guesses a site title from the root package.json name, and writes a pyreon.config.ts with every optional field present but commented out — wrapper, pages, scenarios, matrix, parts, browserOnly — so the file documents its own options instead of you having to look them up. It writes no story files and there is no --generate-stories: components, controls, and scenarios stay derived from source.
--force overwrites an existing config; --dry-run prints what would be written instead of writing it; --title <text> overrides the guessed site title.
This pyreon.config.ts's atlas: section is a DIFFERENT, wider shape than the AtlasConfig type @pyreon/atlas exports for createAtlas(...) — the file convention accepts title / projects / pages / scenarios / wrapper / presets / theme / parts / browserOnly / ignore, none of which are part of the programmatic createAtlas() options bag. See Configuration below for the full field list.
The commands
atlas scan — derive + verify the catalog
pyreon atlas scan .
# atlas: discovered 10 component(s), 44 scenario(s) — 42 verified, 2 failing, 0 unverified.
# checks: a11y 18/20 ✗ · interaction 43/43 · ssrParity 43/43 · leak 43/43
# not run: reactivityCoverage, snapshot — browser-only — run `atlas verify-browser`
# → atlas-catalog.json
# → atlas-agent-guide.md
# atlas: 2 failing scenario(s):
# ✗ button--empty
# a11y: missing accessible name: "label" is emptyThe checks: line is the one to read. 42 verified counts scenarios, not checks — a package where @pyreon/runtime-server does not resolve can report every scenario verified having run only two of the six. The tally says which ran, which failed, and the not run: lines say why the rest did not.
The scan discovers components (static TypeScript scan + rocketstyle runtime detection), derives controls and scenarios, mounts every scenario through a real module loader, and runs the node half of the verify pipeline. It writes two artifacts:
atlas-catalog.json— every component, control, scenario, and verdict as data.atlas-agent-guide.md— the compact, prescriptive summary an AI assistant reads to know what exists, what's verified, and what's broken.
A failing scenario is a red exit — wire atlas scan into CI and the catalog gates itself. --no-mount keeps the scan static: no component module is imported, so no component code executes (pyreon.config.ts is still loaded). --no-write runs the scan without writing atlas-catalog.json or atlas-agent-guide.md.
atlas check — validate a proposed usage
pyreon atlas check Button '{"state":"primry"}'
# Button: 1 problem(s):
# · `state` must be one of `primary`, `secondary`, `danger` — got `primry` — did you mean `primary`?Catches the value that typechecks in JS but renders silently wrong — a select-kind prop given a string outside its known options (state="primry"), an unknown prop name, or a value of the wrong TYPE for a control the catalog already knows the shape of. It reads the committed atlas-catalog.json rather than re-scanning — a check has to be instant, and it must agree with the answer the workbench and the agent guide just gave; a rescan here could disagree with the catalog an agent was handed moments earlier. Run atlas scan first (there is nothing to check against otherwise).
The first positional is the component name (matching atlas verify), the second is a JSON object of props to validate; omit it to check nothing but still report any MISSING required props. --cwd <dir> picks the project directory. Exits non-zero on any finding — safe to wire into a pre-commit hook or a CI step that gates a generated-usage PR.
atlas verify — re-check one component
pyreon atlas verify Button
# atlas verify Button: 1 component(s), 15 scenario(s)
# checks: a11y 14/15 ✗ · interaction 15/15 · ssrParity 15/15 · leak 15/15
#
# ✗ button--empty
# a11y: missing accessible name: "label" is empty
#
# 1 failing · 14 verified · 0 unverifiedThe write → verify → fix loop. Discovery still walks the project — a component's file is not known until it does — but mounting, exercising, hydrating and GC-probing run only for the match. On @pyreon/ui-components (108 components, 1090 scenarios) that is 1.35s for a full scan against 0.90s scoped to one component's 60; the verify work drops ~18× while discovery dominates the residual, so it is a focus tool first and a speed tool second.
Failing scenarios print uncapped here, unlike a whole-catalog scan — this is a question about one component, and truncating its answer would defeat the command.
--json emits the same report as data, which is what an agent branches on:
{
"ok": false,
"component": "Button",
"verified": 14, "failed": 1, "unverified": 0,
"tallies": [{ "key": "a11y", "pass": 14, "fail": 1, "skip": 0 }, /* … */],
"failures": [{
"id": "button--empty",
"checks": [{
"key": "a11y",
"findings": [{
"code": "missing-accessible-name",
"message": "missing accessible name: \"label\" is empty",
"fix": "Give \"label\" a non-empty value, or an aria-label if the text is genuinely decorative."
}]
}]
}]
}Findings are structured
Every finding carries three things:
| field | what it is |
|---|---|
code | A stable identifier for the class of failure — hydrate-threw, missing-accessible-name, reactive-nodes-retained. This is what you grep, quote in an issue, or branch on. Codes are permanent once shipped: a reworded message is a patch, a renamed code is a breaking change. |
message | What happened, in prose. Free to be reworded. |
fix | The one concrete thing to change — present only when there is one. A finding that cannot name a single next step omits it rather than inventing one. |
The fix travels with the finding rather than living in a lookup table a consumer has to know to consult, so an agent reading the agent guide, the MCP tools, or --json gets the actionable half without a second call.
This is catalog version: 2. A v1 catalog had plain-string findings; reading one with v2 code would render blanks for every finding, so the MCP server refuses it by version and tells you to re-run atlas scan rather than showing you a component with no failures.
Three things it deliberately refuses to do:
It never writes
atlas-catalog.json. A scoped run holds one component; writing it would replace the whole catalog and silently break the agent guide, the MCP tools andatlas checkfor everything else until the next full scan.An unmatched name is a non-zero exit, with suggestions — filtering to nothing otherwise reports "0 scenarios, 0 failing", which reads as a pass.
A run where nothing could be verified is a non-zero exit. Zero failures is not a pass when zero checks ran.
The first positional is the component (matching atlas check); the directory is --cwd.
--check — the ratchet
Both atlas scan and atlas verify take --check, which compares the run against the committed atlas-catalog.json instead of rewriting it:
pyreon atlas scan . --check
# atlas --check: REGRESSED — 2 check(s) started failing
# ✗ button--empty — now failing: interactionAbsolute counts answer "how is it now". They cannot answer "did I help", which is the question anyone iterating actually has — and the only signal an agent can use to decide whether to keep a change or back it out.
A check that stops running counts as a regression. This is the case counts structurally cannot catch, because losing coverage makes the numbers improve:
# baseline: 2 failing
pyreon atlas scan . --check --no-mount
# atlas: discovered 1 component(s), 2 scenario(s) — 0 verified, 0 failing, 2 unverified.
# ^^^^^^^^^ looks fixed
# atlas --check: REGRESSED — 4 check(s) stopped running
# ✗ button--empty — no longer checked: interaction, leak
# (coverage lost — the failure did not go away, the check did)Delete a wrapper from atlas.config.ts and every mount-dependent check drops to skip: the failures vanish, the counts improve, and the catalog looks better than it did. Losing coverage is the one way to "fix" a red catalog that must never read as green.
Three deliberate behaviours:
--checknever writes the catalog. A ratchet that overwrites its own baseline compares a run against itself and can never report a regression again.A missing baseline is exit 0, with a note. Making the very first
--checkrun red for everybody is how a ratchet gets disabled on day one.A new or removed scenario is not a regression. Adding a component with a failing edge case is new information, and deleting one is a legitimate edit; flagging either would make the ratchet fire on ordinary refactors until people stopped believing it.
Path aliases
Atlas reads resolve.alias out of your project's own vite config — so a component importing through ~/components/… resolves without any Atlas configuration.
Only resolve.alias is taken, never your plugins: Atlas already runs the real @pyreon/vite-plugin, and a second copy would compile every component's JSX twice. resolve.conditions is deliberately not inherited either — Atlas resolves workspace packages through the bun condition on purpose.
If your vite config can't be loaded (it imports a plugin this command doesn't install, or reads env the workbench doesn't set), Atlas says so and you can declare aliases explicitly:
// atlas.config.ts
export const alias = { '~': './src' }Explicit entries win — Vite matches in order, and these are placed first.
A component that fails to load no longer takes down the workbench. Each component is imported individually and non-fatally, so a broken import degrades that component's canvas to an error card naming the real cause (Cannot find module '~/shared/tokens') while every other component still renders.
atlas dev — the workbench
pyreon atlas dev . --port=5210Real Vite + the real Pyreon compiler over your source. The workbench ships:
Sidebar — the derived catalog, nested by directory, with per-scenario verdict dots (ok / fail / unverified — three states, never smoothed). It has its own persistent filter (the ⌘K dialog's query is the dialog's, cleared on exit), parts sit under their parent, a long scenario list is capped with a "show all" row, and ↑↓ walk exactly the rows it shows.
Live rescan — a change to a scanned file re-derives the catalog and reloads the workbench (debounced, serialised; a failed rescan keeps the previous catalog and says so). A component added or a variant declared after boot shows up without a restart.
Links — the URL carries the component, the addon tab, the edited controls, the viewport / background / locale, the brand and appearance, the view (
?view=docs), a forced pseudo state, the Data panel's query state and the active role. Chrome preferences (brand, appearance, panel widths, what is open) persist per browser; a link wins for what it names.Controls — editors inferred from prop types: booleans, strings, selects, numbers, colors.
Canvas addons — viewport presets, backgrounds, zoom, a measure overlay (hover any element for its real layout box), and pseudo-state forcing (
:hover/:focus/:active).A11y panel — static checks plus axe-core on demand, scoped to the preview, with findings that highlight the offending element on hover.
Docs view — an autodocs page per component: description, live preview, props table, usage, scenarios (each one a link that opens the canvas in exactly that state), and lazy-loaded source.
Actions log — every event handler fired, with arguments.
Reactivity Lens — which signals drive what, live.
Components living in files that import @pyreon/atlas are treated as workbench hosts and excluded from the nav (matched on import specifiers, never on file content).
Store panel — the writes an interaction made, steppable
@pyreon/store publishes a mutation stream: every write announces its store, whether it was a patch or a direct set, and the per-key old/new values. Storybook has no equivalent, because React state changes are private to the component that owns them.
Press Record, interact with the preview, then step back through what happened:
step 2 of 3 — stepped back
#1 cart · set count 1
#2 cart · set count 2 ← selected
#3 cart · set name "y"
State at this step
count 1
name "x"
Written more than once
count 2 writesStepping back shows the store as it was, not a recomputation. "Written more than once" flags a key written repeatedly in one interaction — a loop, or a chain of dependent writes. Worth seeing; not automatically wrong.
Recording is explicit rather than always-on: addStorePlugin attaches to every store created afterwards, and a workbench subscribed for the whole session would pay for every write whether anyone is looking or not.
atlas verify-browser — the browser half of verification
pyreon atlas verify-browser .
# atlas verify-browser: 26 scenario(s) — coverage measured on 26, 0 baseline(s) created, 0 visual diff(s).Reactive coverage and snapshots are claims only a real browser can make, and so is the full axe-core accessibility run. This command boots the workbench headlessly (playwright-core is an optional peer — scan/dev work without it), drives every scenario through the workbench model, and:
measures reactive coverage on the page's own devtools bridge — the same reactivity instance your components run on — reporting how many reactive nodes the scenario created and which never re-fired;
runs axe-core against the live preview of every scenario and folds the result into the
a11yverdict: violations fail it (codeaxe-violation, one finding per rule with its impact, help text and first offending node); axe's "needs a human" items are kept as anaxe-incompletefinding on a pass; a run that could not happen is askipwith the reason (axe-core did not run: …), never a pass. The scan's static name check is kept — either side failing fails the verdict.--no-axeskips it (the verdict then stays the static check, and the summary says so);--axe-min-impact <minor|moderate|serious|critical>ignores lower-impact violations. The snapshot is captured only once the preview has stopped changing (DOM, geometry and canvas pixels) for--settle-ms <ms>(default 300);--settle-timeout <ms>(default 5000) caps that wait, and a preview still changing at the cap fails its snapshot (capture-unsettled) instead of recording an arbitrary frame. Page-level rules (landmarks,region, h1) are off, since a preview is a fragment. Violations do not change the exit code — read the catalog (oratlas verify --check); only visual diffs exit non-zero;captures a visual snapshot of the preview and compares it against a per-scenario baseline (a perceptual YIQ comparison that forgives anti-aliasing). First run creates baselines; later runs fail on real diffs and write an
.actual.pngbeside the baseline, plus a.diff.pngwith the differing pixels in red.--update-snapshotsre-baselines.
The click-walk drives handlers without following them. Anchor navigation, <a download> / mailto:, form submit and reset, form.submit(), window.open, alert / confirm / prompt and history.pushState are suppressed in-page for the duration (the handlers themselves still run, and see an un-prevented event so router links behave), and the coverage finding says which were suppressed (interaction-side-effects-suppressed). What no in-page guard can stop — location.assign() / location.href = …, which Chromium makes unforgeable — is detected from outside: that scenario's coverage is a skip with a navigated-away finding naming the destination, the workbench is reloaded, axe and the snapshot still judge the un-interacted render, the summary lists it, and the run carries on to the next scenario. A crash in one scenario's measurement is isolated to that scenario.
Both verdicts merge back into atlas-catalog.json. Baselines are machine-specific (font antialiasing) — keep atlas-snapshots/ gitignored and let each environment create its own.
atlas build — a static, deployable site
pyreon atlas build . --out atlas-dist --base /my-repo/
# atlas build: 108 component(s) → /…/atlas-distCompiles the workbench into plain files for any static host: one URL per component (/button/), no server. The two panels Node answers in atlas dev — the Docs source block and the Reactivity Lens — are baked at build time into one small JSON file per component under _atlas/rpc/, fetched when that component's panel asks. File paths in them are project-relative, so a public deploy does not publish your machine's directory layout. The site is a production build: the dev-only analysis panels (Why?, Perf, reactive coverage) live in atlas dev.
The output directory is emptied first, so it must be new, empty, or a previous atlas build output (marked by a .atlas-build-output file). --out ., the source directory, or a non-empty directory atlas did not write is refused before anything is touched.
CLI conventions
[dir]is the project directory;--dir <path>is the source directory inside it (defaultsrc).atlas verifyandatlas checktake the project with--cwd <dir>, since their positional is the component.Options take a value as
--out xor--out=x. An unknown option is an error with a did-you-mean — never silently ignored.atlas scan --jsonandatlas verify --jsonprint one JSON document on stdout; narration goes to stderr.atlas <command> --helpandatlas --versiondo what they say.atlas devuses port 5210, or the next free one;--port <n>requires that port.A component whose module fails to load (a syntax error, an unresolvable import) makes
atlas scanexit non-zero: it was catalogued from its source but none of its checks ran.
The six-check verify verdict
Every scenario carries a verify verdict with three honest states — a check either passed, failed, or was skipped (never counted as verified):
| Check | What it claims | Where it runs |
|---|---|---|
a11y | static accessibility rules (empty labels, missing names) at scan time; axe-core on the rendered preview once atlas verify-browser has run | atlas scan, then atlas verify-browser |
interaction | mounted + interacted without throwing — your play script, or an automatic click-walk | atlas scan |
leak | repeated mounts don't accumulate reactive-graph nodes past GC | atlas scan (needs a GC hook: bun, or node --expose-gc) |
reactivityCoverage | reactive nodes measured in real Chromium; findings carry the numbers | atlas verify-browser |
snapshot | the preview matches its visual baseline | atlas verify-browser |
ssrParity | the scenario renders with renderToString, hydrates without throwing or reporting a mismatch, and the hydrated DOM equals a fresh client mount | atlas scan |
ok means at least one check ran and none failed. A scenario with zero checks run reports unverified — Atlas never claims what it didn't measure.
Configuration (atlas.config.ts)
Everything is optional — scan and dev work with no config at all. A config adds what derivation can't know:
// atlas.config.ts — named exports, plain values, no JSX
export const presets = {
viewports: [
{ id: 'full', label: 'Full', width: null },
{ id: 'kiosk', label: 'Kiosk', width: 900 },
],
locales: [
{ id: 'en', label: 'English' },
{ id: 'ar', label: 'Arabic', dir: 'rtl' as const },
],
roles: [
{ id: 'anonymous', label: 'Anonymous', hint: 'nothing granted' },
{ id: 'ops', label: 'Ops', grants: ['posts.delete'] },
],
}theme— your design tokens. This is what resolves rocketstylevariant/sizeaxes: those chains are call expressions the static scanner can't see, so Atlas loads them and reads the dimensions — which requires the theme their callbacks dereference. Without it, rocketstyle components are still discovered but lose their axes.wrapper— the providers your components genuinely need to mount (PyreonUI, aPermissionsProvider, …). Without it, provider-dependent scenarios honestly fail withthrew while mounted— they are not quietly skipped. In the workbench it also receives the appearance to render in —mode('light' | 'dark'),darkandbrand({ id, name, accent }), each an accessor (AtlasWrapperPropsfrom@pyreon/atlas/core). Forwardmodeto your provider —h(PyreonUI, { theme, mode: props.mode }, props.children)— or the dark workbench renders your components in their light mode. Mapprops.brand().idto one of your own themes to make the Theme Lab's brand tiles mean something; a wrapper that never readsbrandmakes the Lab show one tile per mode and say why, rather than tile identical cards. All three are absent underatlas scan, so treat a missing accessor as "use your default".presets— per-project viewports, locales (RTL supported), and permission roles for the canvas toolbars. Each family replaces the shipped defaults; omitted families keep them.scenarios— authored scenarios withplayscripts (below). Authored entries win over generated ones with the same id. Args stay live: a render-prop child (children: (state) => …) or anh()tree written here reaches the canvas intact — the JSON catalog marks them as living in the config. An authoredDefaultis the base every derived scenario is built on, sosize=mediumon a render-prop component is the authored composition at medium size, not an empty base.matrix— how variant scenarios are derived from a component's axes.'axes'(default) fans one axis at a time (Defaultplus one scenario per axis value, the other axes at their defaults —Σ|axis|);'full'crosses every axis (Π|axis|) — opt in when the axes genuinely interact, knowing astate × size × variantcomponent then carries 60+ scenarios.browserOnly— component names that rendernulloutside a browser (an overlay gated onisServer, which a Node scan decides before any DOM exists). Their empty render reportsbrowser-onlyinstead of failing;atlas verify-browserjudges them.parts—{ TabPanel: 'Tabs', AccordionContent: 'Accordion' }. A part renders nothing on its own; the scan reportspart-offor it, and the workbench canvas shows it inside its parent's opening scenario.
Every scenario that mounts cleanly but produces no DOM at all — no element, no text, nothing portaled — fails the interaction check with empty-render and a fix that names where the missing data props or render-prop child belong. "Mounts, clicks and unmounts without throwing" was true of an empty container; this is the check that is not.
Play functions — authored interaction scripts
Derivation can't know that clicking a button three times is the state worth verifying. You can:
export const scenarios = {
Button: [
{
name: 'Triple click',
args: { label: 'Storm target' },
play: async ({ root, step }) => {
await step('find the button', () => {
if (!root.querySelector('button')) throw new Error('no button')
})
await step('click it three times', () => {
const target = root.querySelector<HTMLButtonElement>('button')!
for (let i = 0; i < 3; i += 1) target.click()
})
},
},
],
}The scan runs play instead of the automatic click-walk; a throw fails the interaction check naming the exact step. In the workbench, hand-catalog scenarios with play get a ▶ button that runs the script against the live preview, logging each step to Actions. (Derived-catalog play scripts run in the scan — functions can't cross the JSON boundary into the served catalog.)
The catalog as an AI surface
atlas-catalog.json + atlas-agent-guide.md are written for machine consumption first: an assistant can read which components exist, what props they take (typed controls with defaults), which states are verified, and which are broken — without parsing your source. That's the core inversion: the workbench UI and the AI assistant read the same derived, verified data.
CI wiring
- run: bunx atlas scan . # red exit on failing scenarios
- run: bunx atlas verify-browser . # red exit on visual diffsscan under bun gets the leak check for free (GC hook present). verify-browser needs Playwright Chromium in the environment; snapshot baselines should be created per-runner (first run) rather than committed across platforms.
vs Storybook
| Storybook | Atlas | |
|---|---|---|
| Stories | authored by hand (CSF) | derived from types + variant axes; authoring is opt-in enrichment |
| Verification | separate addons/test-runner | five-check verdict built into the scan, three honest states |
| AI surface | none (catalog is your source) | atlas-catalog.json + agent guide, first-class |
| Compat | — | Storybook stays a supported bridge (@pyreon/storybook renderer); Atlas never requires migrating stories |
Honest limits
Play in the workbench UI runs for hand catalogs only; derived play scripts verify in the scan.
verify-browsercan't drive components defined in workbench-host files — the summary names each one (notDriven), never silently skips.The leak check skips (never fake-passes) without a GC hook.
Reactive coverage is a measurement, not a threshold gate — the findings carry the numbers; judge them.