feat(skills): add the directions-lab skill and its lab generator (#17437)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Design explorations for the board UI are drawn as coded "directions
labs": one page that shows what ships today next to numbered design
directions, on every surface a feature appears. The artifact directions
lab (#17213) is the first one, and it fed the Artifacts V1 work
(#17228).
> - That lab was built by hand. Each new lab would repeat the same
plumbing: the URL state, the toolbar, the theme and animation toggles,
the fetch stub, the frames, the capture script and the Storybook
registration.
> - Labs should also look and move the same way, so a shared link reads
the same to everyone, and the design-proposals Storybook should be the
place where every exploration is registered.
> - This pull request adds the `directions-lab` agent skill. It asks how
many directions and which surfaces, then a generator scaffolds a
complete, verified lab in one command and registers it in the
design-proposals Storybook.
> - The benefit is that a designer gets a working lab with production
next to the directions in minutes, and the agent spends its time on the
drawing, not on the plumbing.

## Linked Issues or Issue Description

Refs #17213 (the artifact directions lab, the precedent the templates
were derived from) and #17228 (Artifacts V1, which shipped a direction
from that lab).

**Subsystem affected**

Agent skills (`.agents/skills/`) for UI design work, the `ui/` Vite
build, and the design-proposals section of the Storybook
(`ui/storybook/stories/design-proposals/`).

**Problem or motivation**

A directions lab is the repo's way to compare design directions with
production, but there is no repeatable way to make one. The artifact lab
took many sessions of hand work, and a second lab would copy that work
file by file and drift from the first. The animation details, the
toggles and the Storybook metadata are easy to get wrong by hand.

**Proposed solution**

Add a skill that does the intake (two questions: how many directions,
which surfaces) and a generator, `scripts/scaffold.mjs`, that writes a
self-contained lab from templates derived from the artifact lab. The
generator verifies the lab with the catalog check, the lab's tests and a
Vite build, and removes everything again when a check fails. Every
direction carries the V1 MVP hover motion verbatim, and a test in the
skill fails when the precedent's motion block changes.

**Alternatives considered**

A shared runtime under `ui/src/directions-lab/` that every lab imports.
It removes the per-lab copies, but it is product code under typecheck
and a much larger change; it stays a follow-up. Instructions only, with
no generator: the agent would rebuild the plumbing every time and the
labs would drift.

**Roadmap alignment**

This fits the skills layer in `ROADMAP.md` (reusable procedures for
agents). It does not change the Skills Manager, Skill Studio or the
Skills Store, and it adds no product feature.

## What Changed

- Add `.agents/skills/directions-lab/` with `SKILL.md`,
`agents/openai.yaml` (Codex metadata) and the
`.claude/skills/directions-lab` symlink, the same layout as the other
repo skills.
- Add `scripts/scaffold.mjs` with the subcommands `new`, `check`,
`add-direction` and `list`, plus `scripts/lib/*`.
- `new` renders in memory and refuses comment lines, files of 600 lines
or more, and existing paths.
- It then writes the lab, two `.claude/launch.json` entries and one
`.gitignore` line.
  - It verifies the result, and rolls everything back on failure.
- Add 34 templates under `assets/templates/` and the motion block
`assets/motion/v1-mvp-motion.css`, derived from
`ui/src/artifact-directions` at the commit recorded in
`assets/PRECEDENT`.
- A generated lab has:
  - an html entry and a Vite config;
- a registry that the toolbar, keyboard digits, Board and capture script
read at runtime;
- a toolbar whose surfaces row is written only for a multi-surface lab;
- a Today kit stub, one kit per direction with a motion specimen, five
Vitest files and `shot.cjs`;
- a design-proposals story with meta id `design-<slug>-directions` and a
literal `parameters.paperclip` block (`status: "proposed"`, `home:
"design-proposals"`).
- Add ten references:
  - lab anatomy;
  - Today, fixtures, build and deploy;
  - the motion spec;
  - questions and defaults;
  - drawing phases;
  - design-proposals Storybook registration;
  - lifecycle;
  - repo gates;
  - the generator;
- `canonical-directory.md`, the paste-ready governance diffs that would
make the design-proposals section the source of truth. They are for a
separate pull request and are not applied here.
- Add three eval cases under `evals/`: a two-surface lab with three
directions, a one-surface lab with two directions, and a terse brief
that must make the skill ask how many directions.

## Verification

- `node --test .agents/skills/directions-lab/scripts/scaffold.test.mjs`:
29 tests pass in under a second. The tests cover flag parsing, context
derivation and refused inputs, ports, the write guards and the
shared-file edits. They also render the three eval shapes into a
temporary root, check the surfaces-row rule and the mode-B story status
rule, and run the motion drift test against
`ui/src/artifact-directions/directions/d5-v1-mvp/tokens.css`.
- A real scaffold into this checkout at the branch head:

  ```sh
node .agents/skills/directions-lab/scripts/scaffold.mjs new --slug
prsmoke --title "PR smoke" --surfaces
thread:Task-thread:960@tasks,panel:Right-panel:434@tasks --directions 3
  ```

The built-in checks passed: `node scripts/storybook-catalog.mjs
--check`, `npx vitest run src/prsmoke-directions` and the lab's Vite
build. The lab was then removed, and `git status` shows only the skill.
- Browser check on a scaffolded lab (composer, two surfaces, three
directions), done earlier in the same session:
- The toolbar shows both rows with Today last as `0.Today`, and the
theme and animation toggles.
- A hovered motion specimen computes `translateY(-6px)` on the front,
and the backings turn ±5° from their resting angles with a 300 ms
transition.
  - After pressing `a`, nothing moves.
- `add-direction` and `check --json` were proven on a second scaffolded
lab: the toolbar legend reads `0–3` from the registry without a hand
edit.
- The root Vitest run is unaffected: `vitest.config.ts` lists explicit
projects, and `.agents` is not one of them. The skill tests with `node
--test` like the sibling skills.
- Not run: `pnpm -r typecheck`, `pnpm build` and `pnpm build-storybook`.
This change adds only files under the git-ignored `.agents/` tree plus
one symlink, and touches no product code.

## Risks

Low risk. The pull request adds a skill and a symlink and changes no
product code, no dependencies and no lockfile. A lab that someone later
scaffolds lands under `ui/src/`, where `tsc -b` and `pnpm test` see it.
The generator verifies each lab and rolls back on failure, so a
half-written lab is not left behind. `/.agents/` is git-ignored, so
later edits to the skill must be force-added (`git add -f`), as for the
other repo skills. The templates are copies of the precedent. When the
artifact lab changes, the skill's drift test fails until the templates
are updated and `assets/PRECEDENT` is bumped.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Fable 5.1 (Anthropic), model ID `claude-fable-5-1`, with
extended thinking, for most of the work: mapping the precedent, the
references, the generator design and implementation, and the proofs.
Claude Opus 5.5 (Anthropic), model ID `claude-opus-5-5`, for the final
cleanup and this pull request. Both ran in Claude Code with tool use
(file edits, shell, the in-app browser) and multi-agent workflows for
parallel mapping, judging and verification.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
TonioandClaude Opus 5.5 authored and GitHub committed 2026-10-26 12:26:44 -07:00
1 parent 46de15ff4b
commit dfedd73f56
71 files changed
+6965

No files matched your search

+294
View File
@@ -0,0 +1,294 @@
---
name: directions-lab
description: Scaffold a directions lab that shows shipped UI (Today) beside 2-3 design directions, with surface tabs, theme and animation toggles and the V1 MVP hover motion, registered in design-proposals Storybook. Use for a directions page or lab, or to explore design directions next to production.
---
# Directions lab
A directions lab is how design explorations are made in this repo: one page,
one feature, Today plus two or three directions, every surface the feature
appears on, and the same controls every time, so a shared link reads the same
to everyone. The artifact directions lab (`ui/src/artifact-directions`) is the
precedent and stays the frozen reference; this skill generates new labs in the
same shape without redrawing the plumbing by hand.
## What you are making
- **The page.** `ui/<slug>-directions.html` plus `ui/src/<slug>-directions/`,
a second Vite entry with no backend. All state lives in the URL
(`?d=&s=&theme=&chrome=&anim=`, plus `proposal=1` for the proposal palette).
A registry module is the single source for the direction ids, digit keys,
labels, surfaces, landing view and story id; the toolbar, keyboard, overview
board and capture script read it at runtime, and `add-direction` keeps the
story's per-direction export in step, so a direction is one registry entry
plus one directory.
- **The toolbar.** Row 1 is the directions row: the numbered directions then
`0.Today` last, as a segmented control, with Theme and Animation
right-aligned. Row 2 is the surfaces row: Overview plus one tab per surface.
Row 2 is written only when the lab is scaffolded with more than one surface
(a surface added later to a one-surface lab needs row 2 by hand,
`references/lab-anatomy.md` section 8); with one surface the keyboard legend
joins row 1. Nothing else goes in the header.
- **Today.** Always present, always `0.Today`, always the shipped components
mounted on the lab's fixtures through small adapters and a fetch stub. It is
the control every direction is judged against, so it is never a redraw.
- **The directions.** Two or three, numbered, each a self-contained kit
directory with its own scoped `tokens.css`. Every direction carries the V1
MVP motion block verbatim and a test that proves it, so hover feels the same
across labs. Read `references/motion-spec.md` before touching any of it.
- **The directory entry.** One story file under
`ui/storybook/stories/design-proposals/` with a stable id, catalogued as a
proposal. The design-proposals Storybook is becoming the canonical directory
of the design system; a lab exists there from its first commit.
Hard rules, because the repo's gates enforce them and reviewers expect them:
no code comments in anything you commit, no file of 600 lines or more in a lab
(`check` refuses at 600), no
`package.json` script, no lockfile change, no screenshots or generated catalog
files in the repo.
## Before you start
Run from the repo root on a branch of your own (`claude/<slug>-directions`,
from `origin/master`). Check that `ui/node_modules/.bin/vite` exists; if not,
`pnpm install --frozen-lockfile`. Then run the generator's own tests:
```sh
node --test .agents/skills/directions-lab/scripts/scaffold.test.mjs
```
Red here usually means a template or the motion asset has drifted from the
precedent (`ui/src/artifact-directions/directions/d5-v1-mvp/tokens.css`); two
cases also read the real feature map and the catalog script, so a changed
feature-map row or catalog message shows up here too. Fix the generator before
scaffolding anything; a lab built on a stale template fails later in ways that
look like your drawing.
## Read the brief, then ground the subject
Extract four things from what Tonio wrote: the subject (its slug and title),
the places it appears (one surface per place), a stated count or names of the
directions, and any flag or component names. Then look at the code before you
ask anything:
```sh
rg -l "<Subject>" ui/src/components ui/src/pages
rg -n "<subject word>" maps/feature-map/*.md
```
The first grep gives the component files, the flag hook
(`use<Feature>Enabled`), and, by following `useQuery`/`queryFn` into
`ui/src/api/*`, the `/api/` routes Today must answer. The second gives the
capability ids the story cites. This grounding is what makes the surfaces
question specific instead of generic. `references/questions-and-defaults.md`
has the full extraction and derivation tables.
## Ask, in one call
Ask at most two questions, in a single `AskUserQuestion` call, and skip each
one the brief already answers:
1. **How many directions?** Options `2` and `3`. Say in the question that
three unnamed directions scaffold as `1.Refine` (tokens and spacing, same
structure), `2.Rework` (new components, same layout) and `3.Rethink` (new
layout), two as `1.Refine` and `2.Rethink`, and are renamed later. Skip
when the brief states a count or names the directions.
2. **Which surfaces?** Multi-select from the usage sites the grep found, plus
"only one place". Two or more selections produce the surfaces row. Skip
when the brief names the places.
Never ask about theme, animation, Today, ports, the slug or the landing view;
those are fixed by the skill. When no question can be asked (a headless run),
write this before doing anything else, then proceed: "Defaulting to three
directions and one surface (<surface>) because I cannot ask; change later with
`scaffold.mjs add-direction`."
## State what you derived, then scaffold
Before running the generator, state every derived value in your reply: slug
and title, each surface as `id:Label:width@nav` with the line the width comes
from, the direction count and names, the group, the capabilities with their
feature-map rows (or `[]` and the exception sentence), the flag hook and the
`/api/` paths Today will need. A wrong slug or capability is cheap to catch
here and expensive after files exist. Then:
```sh
node .agents/skills/directions-lab/scripts/scaffold.mjs new \
--slug composer --title "Composer" \
--surfaces thread:Task-thread:960@tasks,panel:Right-panel:434@tasks \
--directions 3 --dry-run
```
Read the plan (paths, ports, cells, story), drop `--dry-run`, and run it.
`new` renders everything in memory, refuses existing paths, writes the lab,
the html entry, the Vite config, the story file, two `.claude/launch.json`
entries and one `.gitignore` line, then verifies: the Storybook catalog check,
the lab's own Vitest files, and a Vite build. On any failure it deletes what
it wrote and restores the two shared files, so a half-written lab never
breaks `pnpm test` or `tsc -b` for the whole repo (`ui/vitest.config.ts` has no
include filter and `ui/tsconfig.json` includes all of `src`). Quote the
`--json` output in your reply.
`--surfaces` takes `id:Label:width@nav` per place, where `nav` is the sidebar
item the shell highlights (`tasks`, `artifacts` or `other`) and the width is the
product's own constant for that place (see `references/questions-and-defaults.md`).
Flags you will use: `--names a,b,c` for stated names, `--group
Components|Pages|Layouts|Patterns` (Components when the grep hits
`ui/src/components`), `--capabilities area/key,...` (validated against
`maps/feature-map`; omit for `[]` plus an exception sentence), `--company
name:PREFIX` (default `acmehats:ACM`), `--landing auto|all|today|<id>`,
`--ports dev,preview` (the generator picks free ones), `--keep-on-failure` to
inspect a failed scaffold.
## Look at it
Start the dev entry the generator added, `preview_start
{name: "<slug>-directions-dev"}`, and open three URLs: `/` (the landing),
`/?d=today&s=<first surface>` and `/?d=<first direction id>&s=<first surface>`
(`d1-refine` when the directions are unnamed). Confirm
`document.documentElement.dataset.labAnim === "1"`, hover a motion specimen
and watch the front rise and the two pages turn, press `a`, hover again and
see only the fill change. Read the console for `[<slug>-directions] unhandled`
lines: each one is a route Today will need. The overview board and the
capture script never show the movement (iframes take no pointer; stills ask
for reduced motion; `--hover` records one hovered frame, not the transition),
so the transition is verified only this way.
## Commit the scaffold, then draw
Commit the untouched scaffold first as `feat(ui): scaffold the <title>
directions lab`. Reviewers then see the redraw as its own diff, not buried
under plumbing. Draw in this order; `references/drawing-phases.md` has the
done-when for each:
1. **Frames.** Replace each `data-lab-stub="frame-<surface>"` region with the
product chrome around that place: copy the product's class strings, freeze
the registry width, reuse presentational primitives, let nothing scroll
internally. The precedent frames show how (`ThreadFrame`, `PanelFrame`,
`GalleryFrame`, `AppShell`).
2. **Fixtures and Today.** Retype the per-surface data in `kit.ts` to the
props each place receives, fill `fixtures.ts`, mount the shipped component
in `directions/today/<Pascal>Body.tsx` (PascalCase of the surface id, so
`ThreadBody.tsx` for `thread`) through `adapters.ts`, and register exactly
the routes it fetches inside `registerFeatureRoutes()` in `api-routes.ts`.
Today is done when it matches the product on the same data and its test
passes with the real component.
3. **Directions.** One at a time: replace the motion specimen with the
drawing, keep the hook names (`data-front` on every treatment branch,
`data-rotate` and `data-spread` with the inline base angle), put every raw
value in that direction's `tokens.css` (light base, `.dark` override), and
leave the motion block alone. Run `scaffold.mjs check --slug <slug>` after
each phase.
Phase 2 comes before 3 because the slot props change shape in phase 2 and
every kit follows the type.
## Capture
```sh
node ui/src/<slug>-directions/shot.cjs --port <dev> --d all --out <scratch dir>
```
Captures are rest states at 1440×900, DPR 2, both themes; attach the PNGs to
the PR, never to the repo. `--hover` adds a rest/hover pair per direction from
a second browser context that allows motion; the browser pane with the Animation
toggle on is still the only place to judge the motion itself.
## Hand off
```sh
node .agents/skills/directions-lab/scripts/scaffold.mjs check --slug <slug> --strict --run
```
`--strict` refuses any remaining `data-lab-stub` marker; `--run` runs the
lab's tests, the catalog check, the token gates, a Vite build of the lab and
`cd ui && pnpm typecheck`, stopping at the first failure, and prints a
`## Verification` block whose `Not run:` lines name what stays manual
(`pnpm build-storybook`, the repo-wide typecheck); `--json` returns the same
results as data, with those items under `notRun`. Open the PR with every
section of `.github/PULL_REQUEST_TEMPLATE.md` filled, the linked-issue path B
with three filled feature-request labels, the story id, the launch entries
and the Vercel alias if you deployed. Offer the DESIGN.md directory row as
text in the PR body; never apply it from a lab PR.
If the skill itself is in your diff, force-add it: `/.agents/` is git-ignored
and the repo's tracked skills were added with `git add -f`.
## Storybook and the design directory
`new` writes `ui/storybook/stories/design-proposals/<slug>-directions.stories.tsx`
with meta id `design-<slug>-directions`, title `Design proposals/<Group>/<Title>
directions/Compare`, a literal `parameters.paperclip` block (`status:
"proposed"`, `home: "design-proposals"`, `implementation: "prototype"`,
`boundary: "fixtures"`, capabilities, exception, notes) and stories Compare,
Today, one per direction, and `TodayProductionPalette` (the decorator themes
the whole iframe with the proposal palette; this one opts out so Today can be
seen as shipped). The page itself renders the production palette by default;
`?proposal=1` wraps it in the proposal theme for a side-by-side with the entry.
The catalog script validates all of it at Storybook start, in
`cd ui && pnpm typecheck` and in CI.
The lifecycle a direction moves through, and what to edit by hand at each
step, is in `references/lifecycle.md`: proposed (what `new` writes), adopted
(product code behind an experimental setting, with a feature-home story),
current (flag removed), historical (retired with a `replacement`, moved out of
the proposals folder keeping its id). Today the catalog forces every entry in
the proposals folder to stay `proposed`; the governance change that would let
the directory carry `current` entries is written up as paste-ready diffs in
`references/canonical-directory.md`. Offer it once, as its own PR, only when
Tonio says yes.
## Later asks
- Add a direction: `scaffold.mjs add-direction --slug <slug> --name <Name>`
(next digit, registry entry, kit directory with the motion block and a
specimen, the story's `D<N>` export, then `check`, rolling back on failure).
The toolbar legend follows the registry at runtime. The generator refuses a
fourth without `--force-count`, which also records `allowMore` in the
registry; the story description's "1–N" sentence is the one thing to
update by hand. Once phase 2 has retyped the slot props in `kit.ts`,
`add-direction` refuses and prints the by-hand steps instead, because a
generated kit would not fit the new props.
- List labs: `scaffold.mjs list`.
- Rename a direction, add a surface, or move a direction through the
lifecycle: by hand, following `references/lab-anatomy.md` section 5 and 8
and `references/lifecycle.md`.
- Publish: build with `<SLUG>_DIRECTIONS_OUT_DIR=<dir>/paperclip-<slug>-directions`,
remove `sw.js` from the output, `vercel deploy <that dir> --prod --yes` from
its parent; the basename names the project and the alias is the public URL.
`references/today-fixtures-and-build.md` section 11 has the details.
## Do not
- Run two full `ui` Vitest suites at once; run the lab's files alone.
- Land on `d=all&s=board` from a dev server with more than eight cells; use
the static launch entry (`<slug>-directions`) for the full board.
- Copy the artifact lab's `html:not(.dark)` token idiom; new labs put light
values in the base block and dark ones under `.dark [data-direction=…]`.
- Edit the motion block in a lab. A different rise or fan is Tonio's decision.
- Add comments, `package.json` scripts, lockfile changes, PAP ids or localhost
URLs to anything that goes in the PR.
## Maintaining the skill
The generator's templates were derived from the precedent at the commit
recorded in `assets/PRECEDENT`. `scripts/scaffold.test.mjs` pins the generated
structure and asserts the motion asset still equals the precedent's block
after substitution; when the precedent changes, re-derive the template and
bump the pin. `references/generator.md` explains the template conventions
and the context object.
## References
| File | Read it when |
| --- | --- |
| `references/questions-and-defaults.md` | Before asking; derivation tables, eval prompts as flag lines |
| `references/lab-anatomy.md` | Drawing frames or changing the toolbar, state or board |
| `references/today-fixtures-and-build.md` | Mounting Today, the fetch stub, build, capture, deploy |
| `references/motion-spec.md` | Any hover motion; the per-direction checklist |
| `references/drawing-phases.md` | After the scaffold, phase by phase |
| `references/design-proposals-storybook.md` | The story file, the catalog gate, the theme |
| `references/lifecycle.md` | Adopting, shipping or retiring a direction |
| `references/canonical-directory.md` | Only when Tonio approves the governance PR |
| `references/repo-gates.md` | Before the PR |
| `references/generator.md` | Changing the generator |
@@ -0,0 +1,4 @@
interface:
display_name: "Directions Lab"
short_description: "Scaffold a Today-plus-directions lab page registered in the design-proposals Storybook"
default_prompt: "Use $directions-lab to stand up a directions lab for <feature> that shows what ships today next to 2-3 explorations."
@@ -0,0 +1 @@
5e26b733b 2026-10-24 origin/master at derivation; templates mirror ui/src/artifact-directions, ui/artifact-directions.html, ui/vite.artifact-directions.config.mjs and .claude/launch.json
@@ -0,0 +1,21 @@
[data-direction="<id>"] [data-rotate] {
transform: rotate(var(--<p>-base, 0deg));
}
[data-direction="<id>"] {
--<p>-hover-ease: 300ms cubic-bezier(0.3, 0, 0.1, 1);
}
@media (prefers-reduced-motion: no-preference) {
html[data-lab-anim="1"] [data-direction="<id>"] :is([data-rotate][data-spread], [data-front]) {
transition: transform var(--<p>-hover-ease);
}
html[data-lab-anim="1"] [data-direction="<id>"] button:hover [data-front] {
transform: translateY(-6px);
}
html[data-lab-anim="1"] [data-direction="<id>"] button:hover [data-spread="left"] {
transform: rotate(calc(var(--<p>-base, 0deg) + 5deg));
}
html[data-lab-anim="1"] [data-direction="<id>"] button:hover [data-spread="right"] {
transform: rotate(calc(var(--<p>-base, 0deg) - 5deg));
}
}
@@ -0,0 +1,16 @@
export default () => `import { registerApiRoute } from "./api-stub";
import { registry } from "./registry";
let flags: Record<string, boolean> = { ...registry.today.flags };
export const currentFlags = (): Record<string, boolean> => flags;
export function setLabFlags(next: Record<string, boolean>): void {
flags = { ...next };
}
export function registerFeatureRoutes(): void {}
registerApiRoute((pathname) => (pathname.endsWith("/instance/settings/experimental") ? currentFlags() : undefined));
registerFeatureRoutes();
`;
@@ -0,0 +1,53 @@
export default (ctx) => `import { COMPANY } from "./fixtures";
export type ApiRoute = (pathname: string, method: string) => unknown | undefined;
const routes: ApiRoute[] = [];
export function registerApiRoute(route: ApiRoute): void {
routes.push(route);
}
const json = (body: unknown) =>
new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } });
function respond(pathname: string, method: string): Response {
for (const route of routes) {
const body = route(pathname, method);
if (body !== undefined) return json(body);
}
if (pathname.endsWith("/auth/get-session")) {
return json({ session: { id: "lab-session", userId: "lab-user" }, user: { id: "lab-user", name: "Lab", email: \`lab@\${COMPANY.name}.example\` } });
}
if (pathname.endsWith("/companies")) return json([COMPANY]);
console.debug("${ctx.tag} unhandled", method, pathname);
return json({});
}
let holders = 0;
let realFetch: typeof window.fetch | null = null;
let stubFetch: typeof window.fetch | null = null;
export function installApiStub(): () => void {
if (holders === 0) {
const original = window.fetch;
const real = original.bind(window);
realFetch = original;
stubFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
if (!url.includes("/api/")) return real(input as RequestInfo, init);
const { pathname } = new URL(url, window.location.origin);
return respond(pathname, (init?.method ?? "GET").toUpperCase());
}) as typeof window.fetch;
window.fetch = stubFetch;
}
holders += 1;
let released = false;
return () => {
if (released) return;
released = true;
holders -= 1;
if (holders === 0 && realFetch && window.fetch === stubFetch) window.fetch = realFetch;
};
}
`;
@@ -0,0 +1,5 @@
export const envDts = (ctx) => `declare const ${ctx.buildDefine}: string | undefined;
`;
export default (ctx) => `export const LAB_BUILD: string = typeof ${ctx.buildDefine} === "string" ? ${ctx.buildDefine} : "dev";
`;
@@ -0,0 +1,77 @@
export default () => `export type ShellNav = "tasks" | "artifacts" | "other";
export type LabStage = "shipped" | "proposed" | "adopted" | "current" | "historical";
export interface LabSurface {
readonly id: string;
readonly label: string;
readonly width: number;
readonly nav: ShellNav;
}
export interface LabDirection {
readonly id: string;
readonly key: string;
readonly name: string;
readonly tagline: string;
readonly baseline?: boolean;
readonly stage: LabStage;
}
export interface LabTask {
readonly identifier: string;
readonly title: string;
}
export interface LabRegistry<D extends LabDirection = LabDirection, S extends LabSurface = LabSurface> {
readonly slug: string;
readonly title: string;
readonly storyId: string;
readonly group: string;
readonly capabilities: readonly string[];
readonly publicUrl: string;
readonly surfaces: readonly S[];
readonly directions: readonly D[];
readonly baselinePosition: "last";
readonly landing: { readonly direction: D["id"] | "all"; readonly view: S["id"] | "board" };
readonly today: { readonly flags: Readonly<Record<string, boolean>> };
readonly shell: {
readonly company: { readonly id: string; readonly name: string; readonly issuePrefix: string };
readonly tasks: readonly LabTask[];
};
readonly provenance: { readonly runtimeCommit: string; readonly skillVersion: string; readonly createdAt: string };
readonly allowMore?: boolean;
readonly allow: readonly string[];
}
function fail(slug: string, message: string): never {
throw new Error(\`defineLab(\${slug}): \${message}\`);
}
export function defineLab<const D extends LabDirection, const S extends LabSurface>(registry: LabRegistry<D, S>): LabRegistry<D, S> {
const { slug, directions, surfaces, landing } = registry;
if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(slug)) fail(slug, "slug must be kebab-case");
const ids: string[] = directions.map((direction) => direction.id);
if (new Set(ids).size !== ids.length) fail(slug, "direction ids must be unique");
const keys = directions.map((direction) => direction.key);
if (new Set(keys).size !== keys.length || keys.some((key) => !/^[0-9]$/.test(key))) fail(slug, "direction keys must be unique single digits");
const baselines = directions.filter((direction) => direction.baseline);
if (baselines.length !== 1 || baselines[0].id !== "today" || baselines[0].key !== "0") fail(slug, "exactly one baseline, with id today and key 0");
if (directions[directions.length - 1]?.id !== "today") fail(slug, "the baseline comes last");
const explorations = directions.filter((direction) => !direction.baseline);
if (!registry.allowMore && (explorations.length < 2 || explorations.length > 3)) fail(slug, "2 or 3 explorations unless allowMore is set");
for (const direction of explorations) {
const match = /^d([1-9])-[a-z0-9]+(-[a-z0-9]+)*$/.exec(direction.id);
if (!match) fail(slug, \`exploration id \${direction.id} must be d<N>-<name>\`);
if (match[1] !== direction.key) fail(slug, \`exploration \${direction.id} must have key \${match[1]}\`);
if (!direction.name.startsWith(\`\${direction.key}.\`)) fail(slug, \`exploration \${direction.id} must be named "\${direction.key}.<Name>"\`);
}
if (surfaces.length < 1 || surfaces.length > 6) fail(slug, "1 to 6 surfaces");
const surfaceIds: string[] = surfaces.map((surface) => surface.id);
if (new Set(surfaceIds).size !== surfaceIds.length) fail(slug, "surface ids must be unique");
if (surfaceIds.includes("board")) fail(slug, "board is the overview, not a surface");
if (surfaces.some((surface) => !(surface.width > 0))) fail(slug, "surface widths must be positive");
if (landing.direction !== "all" && !ids.includes(landing.direction)) fail(slug, "landing direction must be a direction id or all");
if (landing.view !== "board" && !surfaceIds.includes(landing.view)) fail(slug, "landing view must be a surface id or board");
return registry;
}
`;
@@ -0,0 +1,56 @@
export default (ctx, direction) => {
const p = direction.prefix;
return `import type { CSSProperties } from "react";
import type { LabRecord } from "../../kit";
const BOX = { width: 168, height: 150 };
const SHEET = { width: 112, height: 134 };
const FRONT = { left: (BOX.width - SHEET.width) / 2, top: 8 };
const BACKINGS = [
{ spread: "left", rotate: -14, left: FRONT.left - 14, top: FRONT.top + 4 },
{ spread: "right", rotate: -5, left: FRONT.left + 14, top: FRONT.top + 4 },
] as const;
type Backing = (typeof BACKINGS)[number];
function BackingSheet({ spread, rotate, left, top }: Backing) {
const style = {
left,
top,
width: SHEET.width,
height: SHEET.height,
transformOrigin: \`\${SHEET.width / 2}px \${SHEET.height / 2}px\`,
filter: "var(--${p}-shadow-sheet)",
"--${p}-base": \`\${rotate}deg\`,
} as CSSProperties;
return <div data-rotate data-spread={spread} aria-hidden className="absolute rounded-(--${p}-sheet-radius) bg-(--${p}-paper)" style={style} />;
}
export function MotionSpecimen({ item }: { item: LabRecord }) {
return (
<button
type="button"
aria-label={\`Open \${item.title}\`}
className="flex h-(--${p}-tile-h) w-full flex-col items-center gap-3 rounded-(--${p}-tile-radius) px-4 pt-4 text-left outline-none transition-colors hover:bg-(--${p}-tile-hover) focus-visible:bg-(--${p}-tile-hover) focus-visible:ring-2 focus-visible:ring-ring"
>
<div className="relative shrink-0" style={{ width: BOX.width, height: BOX.height }}>
{BACKINGS.map((backing) => (
<BackingSheet key={backing.spread} {...backing} />
))}
<div
data-front
className="absolute flex items-end rounded-(--${p}-sheet-radius) bg-(--${p}-paper) p-2 text-(length:--text-micro) leading-4 text-(--${p}-ink)"
style={{ left: FRONT.left, top: FRONT.top, width: SHEET.width, height: SHEET.height, filter: "var(--${p}-shadow-sheet)" }}
>
<span className="line-clamp-3 break-words">{item.title}</span>
</div>
</div>
<span className="line-clamp-2 w-full text-center text-sm font-medium leading-5 text-foreground">{item.title}</span>
<span className="text-xs leading-4 text-muted-foreground">
{item.author} · {item.updatedLabel}
</span>
</button>
);
}
`;
};
@@ -0,0 +1,15 @@
export default (ctx, direction, surface) => `import type { SurfaceData } from "../../kit";
import { MotionSpecimen } from "./MotionSpecimen";
export function ${surface.body}({ items }: SurfaceData["${surface.id}"]) {
return (
<ul data-lab-stub="${direction.id}-${surface.id}" className="grid list-none grid-cols-(--${direction.prefix}-grid) gap-4 p-0">
{items.map((item) => (
<li key={item.id} className="min-w-0">
<MotionSpecimen item={item} />
</li>
))}
</ul>
);
}
`;
@@ -0,0 +1,11 @@
export default (ctx, direction) => `import type { DirectionKit } from "../../kit";
${ctx.surfaces.map((surface) => `import { ${surface.body} } from "./${surface.body}";`).join("\n")}
import "./tokens.css";
export const kit: DirectionKit = {
id: ${JSON.stringify(direction.id)},
name: ${JSON.stringify(direction.name)},
tagline: ${JSON.stringify(direction.tagline)},
${ctx.surfaces.map((surface) => ` ${surface.body},`).join("\n")}
};
`;
@@ -0,0 +1,30 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const MOTION_ASSET = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../motion/v1-mvp-motion.css");
export const motionBlock = (direction) => readFileSync(MOTION_ASSET, "utf8").replaceAll("<id>", direction.id).replaceAll("<p>", direction.prefix);
export default (ctx, direction) => {
const p = direction.prefix;
return `[data-direction="${direction.id}"] {
--${p}-paper: #fafafa;
--${p}-ink: #262626;
--${p}-grid: repeat(auto-fill, minmax(200px, 1fr));
--${p}-tile-h: 240px;
--${p}-tile-radius: 12px;
--${p}-tile-hover: var(--accent);
--${p}-sheet-radius: 8px;
--${p}-shadow-sheet: drop-shadow(0 3.406px 10px rgba(26, 26, 26, 0.0924));
--${p}-shadow-print: 0 3.406px 20px rgba(26, 26, 26, 0.0924);
}
.dark [data-direction="${direction.id}"] {
--${p}-tile-hover: #202020;
--${p}-shadow-sheet: drop-shadow(0 3.406px 10px rgba(26, 26, 26, 0.18));
--${p}-shadow-print: 0 3.406px 20px rgba(26, 26, 26, 0.18);
}
${motionBlock(direction)}`;
};
@@ -0,0 +1,27 @@
export default (ctx) => `<!DOCTYPE html>
<html lang="en" class="dark" style="color-scheme: dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#18181b" />
<title>${ctx.title} directions</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<style>
html {
height: auto;
min-height: 100%;
overflow-y: auto;
}
body {
height: auto;
min-height: 100%;
overflow: visible;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/${ctx.slug}-directions/main.tsx"></script>
</body>
</html>
`;
@@ -0,0 +1,23 @@
const PLACEHOLDERS = [
{ title: "First placeholder record", author: "Brand and Presentation Designer", at: "2026-09-28T11:07:00", label: "Sep 28, 2026, 11:07 AM" },
{ title: "Second placeholder record, with a longer title that wraps to a second line", author: "CEO", at: "2026-09-26T15:40:00", label: "Sep 26, 2026, 3:40 PM" },
{ title: "Third placeholder record", author: "Brand and Presentation Designer", at: "2026-09-24T09:12:00", label: "Sep 24, 2026, 9:12 AM" },
];
export default (ctx) => `import type { LabRecord } from "./kit";
import { registry } from "./registry";
export const COMPANY = registry.shell.company;
export const TASKS = registry.shell.tasks;
export const LAB_NOW = new Date("2026-09-28T18:00:00");
const record = (id: string, title: string, author: string, updatedAt: string, updatedLabel: string): LabRecord => ({ id, title, author, updatedAt, updatedLabel });
${ctx.surfaces
.map(
(surface) => `export const ${surface.fixture}: LabRecord[] = [
${PLACEHOLDERS.map((item, index) => ` record("${surface.id}-${index + 1}", ${JSON.stringify(item.title)}, ${JSON.stringify(item.author)}, "${item.at}", "${item.label}"),`).join("\n")}
];`,
)
.join("\n\n")}
`;
@@ -0,0 +1,159 @@
export default () => `import type { ReactNode } from "react";
import {
Boxes,
ChevronsUpDown,
CircleCheck,
FolderOpen,
GitBranch,
History,
Inbox,
LayoutDashboard,
LifeBuoy,
Package,
Repeat,
Search,
SquarePen,
Unplug,
Users,
type LucideIcon,
} from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
import type { ShellNav } from "../define-lab";
import { COMPANY, TASKS } from "../fixtures";
interface NavEntry {
label: string;
icon: LucideIcon;
nav?: ShellNav;
}
const TOP: NavEntry[] = [
{ label: "New Task", icon: SquarePen },
{ label: "Search", icon: Search },
{ label: "Dashboard", icon: LayoutDashboard },
{ label: "Inbox", icon: Inbox },
];
const WORK: NavEntry[] = [
{ label: "Tasks", icon: CircleCheck, nav: "tasks" },
{ label: "Projects", icon: FolderOpen },
{ label: "Routines", icon: Repeat },
{ label: "Artifacts", icon: Package, nav: "artifacts" },
{ label: "Workspaces", icon: GitBranch },
];
const ORG: NavEntry[] = [
{ label: "Agents", icon: Users },
{ label: "Skills", icon: Boxes },
{ label: "Connectors", icon: Unplug },
{ label: "Audit", icon: History },
];
const RECENT = TASKS.map((task) => task.title);
function NavItem({ label, icon: Icon, active }: { label: string; icon?: LucideIcon; active?: boolean }) {
return (
<span
className={cn(
"flex items-center gap-2.5 mx-2 rounded-lg px-2 py-1.5 text-(length:--text-compact) font-medium transition-colors",
active ? "bg-sidebar-accent text-sidebar-accent-foreground" : "text-foreground/80",
)}
>
{Icon ? (
<span className="relative shrink-0">
<Icon className="h-4 w-4" />
</span>
) : null}
<span className="min-w-0 flex-1 truncate">{label}</span>
</span>
);
}
function Section({ label, children }: { label: string; children: ReactNode }) {
return (
<div>
<div className="px-3 py-1.5">
<div className="relative flex min-h-6 min-w-0 items-center gap-1">
<div className="inline-flex min-w-0 max-w-full items-center px-1 py-0.5">
<span className="text-(length:--text-nano) font-medium uppercase tracking-widest font-mono text-muted-foreground/60">{label}</span>
</div>
</div>
</div>
<div className="flex flex-col gap-0.5 mt-0.5">{children}</div>
</div>
);
}
function Sidebar({ active }: { active: ShellNav }) {
return (
<aside className="flex w-60 shrink-0 flex-col primary-sidebar-surface" aria-label="Primary">
<div className="flex h-(--sz-60px) shrink-0 items-center gap-1 px-3">
<span className="flex h-9 min-w-0 flex-1 items-center justify-start gap-2 rounded-md px-4 text-left">
<span className="flex min-w-0 flex-1 items-center gap-2">
<span
className="flex size-5 shrink-0 items-center justify-center rounded-md text-(length:--text-micro) font-semibold text-white"
style={{ background: "var(--status-task-done)" }}
>
{COMPANY.name.charAt(0).toUpperCase()}
</span>
<span className="min-w-0 truncate text-sm font-bold text-foreground">{COMPANY.name}</span>
</span>
<ChevronsUpDown className="size-3.5 shrink-0 text-muted-foreground" />
</span>
</div>
<nav className="flex min-h-0 flex-1 flex-col gap-4 px-3 py-2">
<div className="flex flex-col gap-0.5">
{TOP.map((entry) => (
<NavItem key={entry.label} {...entry} />
))}
</div>
<Section label="Work">
{WORK.map((entry) => (
<NavItem key={entry.label} {...entry} active={entry.nav === active} />
))}
</Section>
<Section label="Org">
{ORG.map((entry) => (
<NavItem key={entry.label} {...entry} />
))}
</Section>
<Section label="Recent Tasks">
{RECENT.map((title) => (
<NavItem key={title} label={title} />
))}
</Section>
</nav>
<div className="bg-border/50 px-3 py-2 dark:bg-muted">
<div className="flex items-center gap-0.5 px-2">
<span className="flex min-w-0 flex-1 items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-(length:--text-compact) font-medium text-foreground/80">
<Avatar size="sm">
<AvatarFallback>LA</AvatarFallback>
</Avatar>
<span className="min-w-0 flex-1 truncate">lab@{COMPANY.name}.example</span>
</span>
</div>
</div>
</aside>
);
}
export function SupportButton() {
return (
<span className="ml-auto flex shrink-0 items-center gap-1 pl-2">
<span className="inline-flex h-9 items-center gap-2 rounded-md px-3 text-sm font-medium text-muted-foreground">
<LifeBuoy className="size-4" />
Support
</span>
</span>
);
}
export function AppShell({ active, children }: { active: ShellNav; children: ReactNode }) {
return (
<div className="bg-background text-foreground flex min-h-225 flex-col">
<div className="flex min-h-0 flex-1">
<Sidebar active={active} />
<div className="flex min-w-0 flex-1 flex-col">{children}</div>
</div>
</div>
);
}
`;
@@ -0,0 +1,124 @@
export default () => `import { useCallback, useEffect, useRef, useState, type CSSProperties, type SyntheticEvent } from "react";
import { DIRECTION_IDS, KIT_META, SURFACE_IDS, surfaceOf, type DirectionId, type SurfaceId } from "../kit";
import { labSearch, type LabTheme, type LabView } from "../lab-state";
const FRAME_WIDTH = 1440;
const FRAME_MIN_HEIGHT = 900;
const LABEL = "font-mono text-(length:--text-nano) uppercase tracking-(--tracking-label) text-muted-foreground";
interface Toggles {
theme: LabTheme;
anim: boolean;
proposal: boolean;
}
interface TileProps extends Toggles {
direction: DirectionId;
view: SurfaceId;
maxHeight?: number;
}
function Tile({ direction, view, theme, anim, proposal, maxHeight }: TileProps) {
const wrapRef = useRef<HTMLAnchorElement>(null);
const contentObserver = useRef<ResizeObserver | null>(null);
const [scale, setScale] = useState(0);
const [height, setHeight] = useState(FRAME_MIN_HEIGHT);
useEffect(() => {
const wrap = wrapRef.current;
if (!wrap) return;
const observer = new ResizeObserver(([entry]) => setScale(entry.contentRect.width / FRAME_WIDTH));
observer.observe(wrap);
return () => observer.disconnect();
}, []);
useEffect(() => () => contentObserver.current?.disconnect(), []);
const onLoad = useCallback((event: SyntheticEvent<HTMLIFrameElement>) => {
const root = event.currentTarget.contentDocument?.getElementById("root");
if (!root) return;
const measure = () => setHeight(Math.max(FRAME_MIN_HEIGHT, Math.ceil(root.getBoundingClientRect().height)));
contentObserver.current?.disconnect();
contentObserver.current = new ResizeObserver(measure);
contentObserver.current.observe(root);
measure();
}, []);
const search = labSearch({ direction, view, theme, chrome: false, anim, proposal });
const shown = Math.min(height, maxHeight ?? height);
return (
<a
ref={wrapRef}
href={labSearch({ direction, view, theme, chrome: true, anim, proposal })}
title={\`Open \${KIT_META[direction].name} · \${view}\`}
className="block overflow-hidden rounded-sm border border-border outline-none focus-visible:ring-2 focus-visible:ring-ring"
style={{ height: scale ? shown * scale : undefined }}
>
<iframe
src={search}
title={\`\${KIT_META[direction].name} · \${view}\`}
tabIndex={-1}
scrolling="no"
onLoad={onLoad}
className="pointer-events-none block origin-top-left border-0"
style={{ width: FRAME_WIDTH, height, transform: \`scale(\${scale})\` }}
/>
</a>
);
}
function ColumnHead({ children }: { children: string }) {
return <span className={LABEL}>{children}</span>;
}
const columns = (count: number, head: string) => ({ "--gtc-board": \`\${head}repeat(\${count}, minmax(0, 1fr))\` }) as CSSProperties;
function KitBoard({ direction, ...toggles }: Toggles & { direction: DirectionId }) {
return (
<div className="grid grid-cols-(--gtc-board) items-start gap-4" style={columns(SURFACE_IDS.length, "")}>
{SURFACE_IDS.map((view) => (
<div key={view} className="flex min-w-0 flex-col gap-2">
<ColumnHead>{surfaceOf(view).label}</ColumnHead>
<Tile direction={direction} view={view} {...toggles} maxHeight={2 * FRAME_MIN_HEIGHT} />
</div>
))}
</div>
);
}
function AllBoard({ view, ...toggles }: Toggles & { view: LabView }) {
const surfaces: readonly SurfaceId[] = view === "board" ? SURFACE_IDS : [view];
return (
<div className="grid grid-cols-(--gtc-board) items-start gap-x-4 gap-y-8" style={columns(surfaces.length, "7rem ")}>
<span />
{surfaces.map((surface) => (
<ColumnHead key={surface}>{surfaceOf(surface).label}</ColumnHead>
))}
{DIRECTION_IDS.map((direction) => (
<div key={direction} className="contents">
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold text-foreground">{KIT_META[direction].name}</span>
<span className="text-xs text-muted-foreground">{KIT_META[direction].tagline}</span>
</div>
{surfaces.map((surface) => (
<Tile key={surface} direction={direction} view={surface} {...toggles} maxHeight={2 * FRAME_MIN_HEIGHT} />
))}
</div>
))}
</div>
);
}
export function Board({ direction, view, ...toggles }: Toggles & { direction: DirectionId | "all"; view: LabView }) {
return (
<div className="flex min-h-225 flex-col gap-6 bg-background p-6 text-foreground">
<header className="flex flex-col gap-1">
<p className={LABEL}>Contact sheet · {toggles.theme}</p>
<h1 className="text-lg font-semibold">{direction === "all" ? "All directions" : KIT_META[direction].name}</h1>
</header>
{direction === "all" ? <AllBoard view={view} {...toggles} /> : <KitBoard direction={direction} {...toggles} />}
</div>
);
}
`;
@@ -0,0 +1,105 @@
export default (ctx) => {
const row2 = ` <span className={TITLE}>${ctx.title} surfaces</span>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<Segmented label="Surface" options={VIEW_OPTIONS} value={state.view} onChange={(view) => onChange({ view })} />
<span className="ml-auto hidden lg:inline">{LEGEND}</span>
</div>
`;
const legendInRow1 = ` <span className="hidden lg:inline">{LEGEND}</span>
`;
return `import { cn } from "@/lib/utils";
import { LAB_BUILD } from "../build-info";
import type { LabStage } from "../define-lab";
import { DIRECTION_IDS, KIT_KEYS, KIT_META, type DirectionId } from "../kit";
import { landingSearch, type LabState, type LabTheme, type LabView } from "../lab-state";
import { registry } from "../registry";
interface Option<T extends string> {
value: T;
label: string;
hint?: string;
}
function Segmented<T extends string>({
label,
options,
value,
onChange,
}: {
label: string;
options: Option<T>[];
value: T;
onChange: (value: T) => void;
}) {
return (
<div role="radiogroup" aria-label={label} className="flex items-center gap-0.5 rounded-md bg-muted p-0.5">
{options.map((option) => (
<button
key={option.value}
type="button"
role="radio"
data-size="xs"
aria-checked={option.value === value}
title={option.hint}
onClick={() => onChange(option.value)}
className={cn(
"rounded-sm px-2 py-1 leading-none transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring",
option.value === value ? "bg-background text-foreground shadow-xs" : "text-muted-foreground hover:text-foreground",
)}
>
{option.label}
</button>
))}
</div>
);
}
const stageSuffix = (stage: LabStage | undefined) => (stage === "adopted" || stage === "current" ? \` · \${stage}\` : "");
const stageHint = (id: DirectionId) => stageSuffix(registry.directions.find((direction) => direction.id === id)?.stage);
const KIT_OPTIONS: Option<DirectionId | "all">[] = DIRECTION_IDS.map((id) => ({
value: id,
label: KIT_META[id].name,
hint: \`\${KIT_META[id].tagline} (\${KIT_KEYS[id]})\${stageHint(id)}\`,
}));
const MAX_KIT_DIGIT = Math.max(...Object.values(KIT_KEYS).map(Number));
const LEGEND = \`0–\${MAX_KIT_DIGIT} kit · t theme · a animation\`;
${ctx.multiSurface ? `const VIEW_OPTIONS: Option<LabView>[] = [
{ value: "board", label: "Overview", hint: \`The kit's \${registry.surfaces.length} surfaces on one sheet\` },
...registry.surfaces.map((surface) => ({ value: surface.id, label: surface.label })),
];
` : ""}const THEME_OPTIONS: Option<LabTheme>[] = [
{ value: "dark", label: "dark", hint: "Toggle with t" },
{ value: "light", label: "light", hint: "Toggle with t" },
];
const ANIM_OPTIONS: Option<"off" | "on">[] = [
{ value: "off", label: "off", hint: "Hover animations off (toggle with a)" },
{ value: "on", label: "on", hint: "Hover animations on: the front sheet rises, the backings turn (toggle with a)" },
];
const TITLE = "font-medium uppercase tracking-(--tracking-label) text-foreground";
export function LabToolbar({ state, onChange }: { state: LabState; onChange: (patch: Partial<LabState>) => void }) {
return (
<div
className="sticky top-0 z-50 grid items-center gap-x-4 gap-y-2 border-b border-border bg-background px-4 py-2 font-mono text-(length:--text-micro) text-muted-foreground"
style={{ gridTemplateColumns: "max-content 1fr" }}
>
<a href={landingSearch()} title={\`story \${registry.storyId} · build \${LAB_BUILD}\`} className={TITLE}>
${ctx.title} directions
</a>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<Segmented label="Kit" options={KIT_OPTIONS} value={state.direction} onChange={(direction) => onChange({ direction })} />
<div className="ml-auto flex items-center gap-4">
${ctx.multiSurface ? "" : legendInRow1} <Segmented label="Theme" options={THEME_OPTIONS} value={state.theme} onChange={(theme) => onChange({ theme })} />
<span className="flex items-center gap-1.5">
<span>animation</span>
<Segmented label="Animation" options={ANIM_OPTIONS} value={state.anim ? "on" : "off"} onChange={(value) => onChange({ anim: value === "on" })} />
</span>
</div>
</div>
${ctx.multiSurface ? row2 : ""} </div>
);
}
`;
};
@@ -0,0 +1,11 @@
export default () => `import type { ReactNode } from "react";
import type { DirectionKit } from "../kit";
export function Slot({ kit, className, children }: { kit: DirectionKit; className?: string; children: ReactNode }) {
return (
<div data-direction={kit.id} className={className}>
{children}
</div>
);
}
`;
@@ -0,0 +1,32 @@
export default (ctx, surface) => `import { surfaceOf, type DirectionKit } from "../kit";
import { ${surface.fixture} } from "../fixtures";
import { AppShell, SupportButton } from "./AppShell";
import { Slot } from "./Slot";
const SURFACE = surfaceOf("${surface.id}");
export function ${surface.frame}({ kit }: { kit: DirectionKit }) {
return (
<AppShell active={SURFACE.nav}>
<div className="relative flex h-(--sz-60px) shrink-0 items-center border-b border-border px-4 md:px-6">
<span className="min-w-0 truncate font-semibold uppercase tracking-wider text-muted-foreground">{SURFACE.label}</span>
<SupportButton />
</div>
<main className="flex-1 p-4 md:p-6">
<div
data-lab-stub="frame-${surface.id}"
className="mx-auto flex w-full flex-col gap-3 rounded-md border border-dashed border-border p-4"
style={{ maxWidth: SURFACE.width }}
>
<p className="font-mono text-(length:--text-micro) uppercase tracking-(--tracking-label) text-muted-foreground">
Frame · {SURFACE.label} · {SURFACE.width}px · draw the product chrome here
</p>
<Slot kit={kit}>
<kit.${surface.body} items={${surface.fixture}} />
</Slot>
</div>
</main>
</AppShell>
);
}
`;
@@ -0,0 +1,13 @@
export default (ctx) => `import type { FC } from "react";
import type { DirectionKit, SurfaceId } from "../kit";
${ctx.surfaces.map((surface) => `import { ${surface.frame} } from "./${surface.frame}";`).join("\n")}
const FRAMES: Record<SurfaceId, FC<{ kit: DirectionKit }>> = {
${ctx.surfaces.map((surface) => ` ${JSON.stringify(surface.id)}: ${surface.frame},`).join("\n")}
};
export function SurfaceView({ kit, surface }: { kit: DirectionKit; surface: SurfaceId }) {
const Frame = FRAMES[surface];
return <Frame kit={kit} />;
}
`;
@@ -0,0 +1,49 @@
export default (ctx) => `import type { FC } from "react";
import { registry } from "./registry";
export type DirectionId = (typeof registry.directions)[number]["id"];
export type SurfaceId = (typeof registry.surfaces)[number]["id"];
export interface LabRecord {
id: string;
title: string;
author: string;
updatedAt: string;
updatedLabel: string;
}
export interface SurfaceData extends Record<SurfaceId, unknown> {
${ctx.surfaces.map((surface) => ` ${JSON.stringify(surface.id)}: { items: LabRecord[] };`).join("\n")}
}
export interface DirectionKit {
id: DirectionId;
name: string;
tagline: string;
flags?: Record<string, boolean>;
${ctx.surfaces.map((surface) => ` ${surface.body}: FC<SurfaceData["${surface.id}"]>;`).join("\n")}
}
export const DIRECTION_IDS: readonly DirectionId[] = registry.directions.map((direction) => direction.id);
export const SURFACE_IDS: readonly SurfaceId[] = registry.surfaces.map((surface) => surface.id);
export const KIT_KEYS = Object.fromEntries(registry.directions.map((direction) => [direction.id, direction.key])) as Record<DirectionId, string>;
export const KIT_META = Object.fromEntries(registry.directions.map((direction) => [direction.id, { name: direction.name, tagline: direction.tagline }])) as Record<
DirectionId,
{ name: string; tagline: string }
>;
export function surfaceOf(id: SurfaceId) {
const surface = registry.surfaces.find((entry) => entry.id === id);
if (!surface) throw new Error(\`No surface \${id} in the ${ctx.slug} registry\`);
return surface;
}
const KIT_LOADERS = import.meta.glob<{ kit: DirectionKit }>("./directions/*/index.tsx");
export function loadKit(id: DirectionId): Promise<DirectionKit> {
const load = KIT_LOADERS[\`./directions/\${id}/index.tsx\`];
if (!load) return Promise.reject(new Error(\`No kit at directions/\${id}/index.tsx\`));
return load().then((module) => module.kit);
}
`;
@@ -0,0 +1,47 @@
export default () => `import { DIRECTION_IDS, SURFACE_IDS, type DirectionId, type SurfaceId } from "./kit";
import { registry } from "./registry";
export type LabView = SurfaceId | "board";
export type LabTheme = "dark" | "light";
export const LAB_VIEWS: readonly LabView[] = [...SURFACE_IDS, "board"];
export const FIRST_SURFACE: SurfaceId = SURFACE_IDS[0];
export interface LabState {
direction: DirectionId | "all";
view: LabView;
theme: LabTheme;
chrome: boolean;
anim: boolean;
proposal: boolean;
}
export const isDirection = (value: string | null): value is DirectionId => value !== null && (DIRECTION_IDS as readonly string[]).includes(value);
export const isView = (value: string | null): value is LabView => value !== null && (LAB_VIEWS as readonly string[]).includes(value);
export function readLabState(search: string): LabState {
const params = new URLSearchParams(search);
const d = params.get("d");
const s = params.get("s");
return {
direction: d === "all" ? "all" : isDirection(d) ? d : s ? "today" : registry.landing.direction,
view: isView(s) ? s : d && d !== "all" ? FIRST_SURFACE : registry.landing.view,
theme: params.get("theme") === "light" ? "light" : "dark",
chrome: params.get("chrome") !== "0",
anim: params.get("anim") !== "0",
proposal: params.get("proposal") === "1",
};
}
export function labSearch(state: LabState): string {
const params = new URLSearchParams({ d: state.direction, s: state.view, theme: state.theme });
if (!state.chrome) params.set("chrome", "0");
if (!state.anim) params.set("anim", "0");
if (state.proposal) params.set("proposal", "1");
return \`?\${params.toString()}\`;
}
export const landingSearch = (): string => labSearch(readLabState(""));
export const kitIdOf = (state: LabState): DirectionId => (state.direction === "all" ? "today" : state.direction);
`;
@@ -0,0 +1,4 @@
export default () => `:root {
--font-mono: var(--font-paper-mono);
}
`;
@@ -0,0 +1,19 @@
export const launchNames = (ctx) => [`${ctx.slug}-directions-dev`, `${ctx.slug}-directions`];
export default (ctx) => [
{
name: `${ctx.slug}-directions-dev`,
runtimeExecutable: "/bin/sh",
runtimeArgs: ["-c", `cd ui && npx vite --config ${ctx.configFile} --port ${ctx.ports.dev} --strictPort`],
port: ctx.ports.dev,
},
{
name: `${ctx.slug}-directions`,
runtimeExecutable: "/bin/sh",
runtimeArgs: [
"-c",
`cd ui && export ${ctx.envOutDir}=${ctx.staticDir} && npx vite build --config ${ctx.configFile} && npx vite preview --config ${ctx.configFile} --port ${ctx.ports.preview} --strictPort`,
],
port: ctx.ports.preview,
},
];
@@ -0,0 +1,185 @@
export default (ctx) => `import { Component, StrictMode, useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { AvatarInitialProvider } from "@/components/ui/avatar-initials";
import { useTheme } from "@/context/ThemeContext";
import "../index.css";
import "./lab.css";
import { installApiStub } from "./api-stub";
import "./api-routes";
import { LAB_BUILD } from "./build-info";
import { DIRECTION_IDS, KIT_KEYS, KIT_META, SURFACE_IDS, loadKit, type DirectionId, type DirectionKit } from "./kit";
import { kitIdOf, labSearch, readLabState, type LabState, type LabTheme } from "./lab-state";
import { createLabQueryClient, LabProviders } from "./providers";
import { registry } from "./registry";
import { Board } from "./frames/Board";
import { LabToolbar } from "./frames/LabToolbar";
import { SurfaceView } from "./frames/SurfaceView";
installApiStub();
const initial = readLabState(window.location.search);
function applyTheme(theme: LabTheme) {
document.documentElement.classList.toggle("dark", theme === "dark");
document.documentElement.style.colorScheme = theme;
}
applyTheme(initial.theme);
function applyAnim(anim: boolean) {
if (anim) document.documentElement.dataset.labAnim = "1";
else delete document.documentElement.dataset.labAnim;
}
applyAnim(initial.anim);
(window as unknown as { __LAB__: unknown }).__LAB__ = {
slug: registry.slug,
directions: DIRECTION_IDS,
surfaces: SURFACE_IDS,
keys: KIT_KEYS,
names: Object.fromEntries(DIRECTION_IDS.map((id) => [id, KIT_META[id].name])),
labels: Object.fromEntries(registry.surfaces.map((surface) => [surface.id, surface.label])),
storyId: registry.storyId,
build: LAB_BUILD,
};
function ThemeSync({ theme }: { theme: LabTheme }) {
const { theme: current, setTheme } = useTheme();
useEffect(() => {
if (current !== theme) setTheme(theme);
}, [current, setTheme, theme]);
return null;
}
function isTyping(target: EventTarget | null): boolean {
return target instanceof HTMLElement && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName));
}
function KitProblem({ id, error }: { id: DirectionId; error: unknown }) {
return (
<div role="alert" data-kit-error={id} className="m-6 rounded-md bg-destructive/10 p-4 font-mono text-xs text-destructive">
{KIT_META[id].name} failed: {error instanceof Error ? error.message : String(error)}
</div>
);
}
class KitBoundary extends Component<{ id: DirectionId; children: ReactNode }, { error: unknown }> {
state: { error: unknown } = { error: null };
static getDerivedStateFromError(error: unknown) {
return { error };
}
render() {
return this.state.error ? <KitProblem id={this.props.id} error={this.state.error} /> : this.props.children;
}
}
type KitState = { id: DirectionId; kit?: DirectionKit; error?: unknown };
function useKit(id: DirectionId): KitState {
const [state, setState] = useState<KitState>({ id });
useEffect(() => {
let live = true;
loadKit(id).then(
(kit) => live && setState({ id, kit }),
(error: unknown) => {
console.error(error);
if (live) setState({ id, error });
},
);
return () => {
live = false;
};
}, [id]);
return state.id === id || !state.kit ? state : { id: state.id, kit: state.kit };
}
function useScrollKeeper(deps: unknown[]) {
const pending = useRef<number | null>(null);
const keep = useCallback(() => {
pending.current = window.scrollY;
}, []);
useLayoutEffect(() => {
const top = pending.current;
if (top === null) return;
const restore = () => window.scrollTo({ top, left: window.scrollX, behavior: "instant" });
restore();
const frame = window.requestAnimationFrame(restore);
const late = window.setTimeout(() => {
restore();
if (pending.current === top) pending.current = null;
}, 200);
return () => {
window.cancelAnimationFrame(frame);
window.clearTimeout(late);
};
}, deps);
return keep;
}
function Lab() {
const [state, setState] = useState<LabState>(initial);
const kitId = kitIdOf(state);
const { id: shownKitId, kit, error } = useKit(kitId);
const keepScroll = useScrollKeeper([state, shownKitId]);
const change = useCallback(
(patch: Partial<LabState>) => {
keepScroll();
setState((current) => {
const next = { ...current, ...patch };
window.history.replaceState(null, "", labSearch(next));
return next;
});
},
[keepScroll],
);
useEffect(() => applyTheme(state.theme), [state.theme]);
useEffect(() => applyAnim(state.anim), [state.anim]);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.metaKey || event.ctrlKey || event.altKey || isTyping(event.target)) return;
if (event.key === "t") change({ theme: state.theme === "dark" ? "light" : "dark" });
if (event.key === "a") change({ anim: !state.anim });
const direction = DIRECTION_IDS.find((id) => KIT_KEYS[id] === event.key);
if (direction) change({ direction });
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [change, state.theme, state.anim]);
const surface = state.view === "board" ? null : state.view;
const page = (
<>
<ThemeSync theme={state.theme} />
{state.chrome ? <LabToolbar state={state} onChange={change} /> : null}
{surface === null || state.direction === "all" ? (
<Board direction={state.direction} view={state.view} theme={state.theme} anim={state.anim} proposal={state.proposal} />
) : error ? (
<KitProblem id={kitId} error={error} />
) : kit ? (
<KitBoundary key={shownKitId} id={shownKitId}>
<SurfaceView kit={kit} surface={surface} />
</KitBoundary>
) : null}
</>
);
return state.proposal ? (
<AvatarInitialProvider>
<div className="design-proposal-theme">{page}</div>
</AvatarInitialProvider>
) : (
page
);
}
const queryClient = createLabQueryClient();
createRoot(document.getElementById("root")!).render(
<StrictMode>
<LabProviders client={queryClient}>
<Lab />
</LabProviders>
</StrictMode>,
);
`;
@@ -0,0 +1,25 @@
export default () => `import type { ReactNode } from "react";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { CompanyProvider } from "@/context/CompanyContext";
import { ThemeProvider } from "@/context/ThemeContext";
import { TooltipProvider } from "@/components/ui/tooltip";
export function createLabQueryClient(): QueryClient {
return new QueryClient({ defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } } });
}
export function LabProviders({ client, children }: { client: QueryClient; children: ReactNode }) {
return (
<MemoryRouter>
<QueryClientProvider client={client}>
<ThemeProvider>
<CompanyProvider>
<TooltipProvider>{children}</TooltipProvider>
</CompanyProvider>
</ThemeProvider>
</QueryClientProvider>
</MemoryRouter>
);
}
`;
@@ -0,0 +1,35 @@
const quote = (value) => JSON.stringify(value);
export const directionEntry = (direction) =>
` { id: ${quote(direction.id)}, key: ${quote(direction.key)}, name: ${quote(direction.name)}, tagline: ${quote(direction.tagline)}${direction.baseline ? ", baseline: true" : ""}, stage: ${quote(direction.stage)} },`;
export const surfaceEntry = (surface) => ` { id: ${quote(surface.id)}, label: ${quote(surface.label)}, width: ${surface.width}, nav: ${quote(surface.nav)} },`;
export default (ctx) => `import { defineLab } from "./define-lab";
export const registry = defineLab({
slug: ${quote(ctx.slug)},
title: ${quote(ctx.title)},
storyId: ${quote(ctx.storyId)},
group: ${quote(ctx.group)},
capabilities: [${ctx.capabilities.map(quote).join(", ")}],
publicUrl: ${quote(ctx.publicUrl)},
surfaces: [
${ctx.surfaces.map(surfaceEntry).join("\n")}
],
directions: [
${ctx.kits.map(directionEntry).join("\n")}
],
baselinePosition: "last",
landing: { direction: ${quote(ctx.landing.direction)}, view: ${quote(ctx.landing.view)} },
today: { flags: {} },
shell: {
company: { id: ${quote(ctx.company.id)}, name: ${quote(ctx.company.name)}, issuePrefix: ${quote(ctx.company.issuePrefix)} },
tasks: [
${ctx.tasks.map((task) => ` { identifier: ${quote(task.identifier)}, title: ${quote(task.title)} },`).join("\n")}
],
},
provenance: { runtimeCommit: ${quote(ctx.provenance.runtimeCommit)}, skillVersion: ${quote(ctx.provenance.skillVersion)}, createdAt: ${quote(ctx.provenance.createdAt)} },
${ctx.forceCount ? " allowMore: true,\n" : ""} allow: [],
});
`;
@@ -0,0 +1,240 @@
export default (ctx) => `#!/usr/bin/env node
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const THEMES = ["dark", "light"];
const HELP = \`Usage:
node ${ctx.labDir}/shot.cjs --port <p> --d <kit|all> --out <dir> [--s <surfaces,board>] [--theme <themes>] [--hover]
--port port of a running lab dev server (required)
--d a direction id | all (default: today; the ids come from the page's window.__LAB__)
"all" captures every kit; for the board it composes the one all-kits sheet
--out output directory (required; created if missing)
--s comma-separated surfaces and/or board (default: every surface and the board)
--theme comma-separated: \${THEMES.join(",")} (default: both)
--hover also capture each surface with the first kit button hovered (no reduced motion)
Start the dev server first (it is not started for you):
cd ui && npx vite --config ${ctx.configFile} --port <p> --strictPort
\`;
function parseArgs(argv) {
const args = {};
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (token === "--help" || token === "-h") args.help = true;
else if (token === "--hover") args.hover = true;
else if (token.startsWith("--")) {
const [key, inline] = token.slice(2).split("=");
args[key] = inline ?? argv[(i += 1)];
}
}
return args;
}
function list(value, allowed, name) {
if (!value) return allowed;
const picked = value.split(",").map((item) => item.trim()).filter(Boolean);
const unknown = picked.filter((item) => !allowed.includes(item));
if (unknown.length > 0) fail(\`Unknown \${name}: \${unknown.join(", ")} (expected \${allowed.join(", ")})\`);
return picked;
}
function fail(message) {
console.error(\`shot: \${message}\\n\`);
console.error(HELP);
process.exit(2);
}
function loadChromium() {
for (const name of ["@playwright/test", "playwright"]) {
try {
return require(name).chromium;
} catch {
continue;
}
}
throw new Error("Playwright is not installed (looked for @playwright/test and playwright). Run pnpm install.");
}
async function settleFrame() {
for (const img of document.querySelectorAll('img[loading="lazy"]')) img.loading = "eager";
await document.fonts.ready;
const quiet = () =>
new Promise((resolve) => {
let timer = setTimeout(done, 400);
const observer = new MutationObserver(() => {
clearTimeout(timer);
timer = setTimeout(done, 400);
});
function done() {
observer.disconnect();
resolve();
}
observer.observe(document.documentElement, { subtree: true, childList: true, attributes: true, characterData: true });
});
await quiet();
await Promise.all(
[...document.images].map((img) =>
img.complete ? null : new Promise((resolve) => ["load", "error"].forEach((type) => img.addEventListener(type, resolve, { once: true }))),
),
);
await Promise.all(
[...document.querySelectorAll("video")].map((video) =>
video.readyState >= 2 ? null : new Promise((resolve) => {
["loadeddata", "error"].forEach((type) => video.addEventListener(type, resolve, { once: true }));
setTimeout(resolve, 3500);
}),
),
);
await quiet();
}
function watch(page) {
const problems = [];
page.on("console", (message) => {
if (message.type() === "error") problems.push(\`console error: \${message.text()}\`);
});
page.on("pageerror", (error) => problems.push(\`page error: \${error.message}\`));
page.on("requestfailed", (request) => problems.push(\`request failed: \${request.url()} (\${request.failure()?.errorText ?? "unknown"})\`));
page.on("response", (response) => {
if (response.status() >= 400) problems.push(\`HTTP \${response.status()}: \${response.url()}\`);
});
return problems;
}
async function discoverLab(browser, base) {
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await context.newPage();
const problems = watch(page);
await page.goto(\`\${base}/?chrome=0\`, { waitUntil: "networkidle", timeout: 120000 });
const lab = await page.evaluate(() => window.__LAB__);
await context.close();
if (!lab) throw new Error(\`page did not expose window.__LAB__; main.tsx did not run, read the console\${problems.length ? \`:\\n \${problems.join("\\n ")}\` : ""}\`);
return lab;
}
const shotUrl = (base, shot) => \`\${base}/?d=\${shot.kit}&s=\${shot.surface}&theme=\${shot.theme}&chrome=0\`;
async function capture(browser, base, shot, outDir) {
const context = await browser.newContext({
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 2,
reducedMotion: "reduce",
});
const page = await context.newPage();
const problems = watch(page);
await page.goto(shotUrl(base, shot), { waitUntil: "networkidle", timeout: 120000 });
for (const frame of page.frames()) await frame.evaluate(settleFrame);
const file = path.join(outDir, \`\${shot.kit}__\${shot.surface}__\${shot.theme}.png\`);
await page.screenshot({ path: file, fullPage: true });
await context.close();
return { file, problems };
}
async function captureHover(browser, base, shot, outDir) {
const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 });
const page = await context.newPage();
const problems = watch(page);
await page.goto(shotUrl(base, shot), { waitUntil: "networkidle", timeout: 120000 });
for (const frame of page.frames()) await frame.evaluate(settleFrame);
const target = page.locator("[data-direction] button").first();
const file = path.join(outDir, \`\${shot.kit}__\${shot.surface}__\${shot.theme}__hover.png\`);
if ((await target.count()) === 0) problems.push(\`no button inside [data-direction] to hover at \${shotUrl(base, shot)}\`);
else {
await target.hover();
await page.waitForTimeout(350);
await page.screenshot({ path: file, fullPage: true });
}
await context.close();
return { file, problems };
}
async function captureBoard(browser, base, lab, kit, theme, outDir) {
const kits = kit === "all" ? lab.directions : [kit];
const problems = [];
for (const k of kits) {
for (const surface of lab.surfaces) {
if (fs.existsSync(path.join(outDir, \`\${k}__\${surface}__\${theme}.png\`))) continue;
const result = await capture(browser, base, { kit: k, surface, theme }, outDir);
problems.push(...result.problems);
}
}
const dark = theme === "dark";
const tile = (k, surface) => \`<figure style="height:1100px"><img src="\${k}__\${surface}__\${theme}.png" alt="\${k} \${surface}"></figure>\`;
const rows = kits.map((k) => \`<section><h2>\${lab.names[k]}</h2>\${lab.surfaces.map((surface) => tile(k, surface)).join("")}</section>\`).join("");
const heads = lab.surfaces.map((surface) => \`<span>\${lab.labels[surface]}</span>\`).join("");
const html = \`<!doctype html><meta charset="utf-8"><style>
body{margin:0;padding:32px;background:\${dark ? "#0f0f0f" : "#e9e9e9"};color:\${dark ? "#f5f5f5" : "#171717"};font:600 22px/1.2 Inter,system-ui,sans-serif}
header,section{display:grid;grid-template-columns:150px repeat(\${lab.surfaces.length},minmax(0,1fr));gap:16px;align-items:start}
header{font:500 13px/1 ui-monospace,Menlo,monospace;letter-spacing:.08em;text-transform:uppercase;opacity:.6;margin-bottom:12px}
section{margin-bottom:32px}h2{margin:0;font:inherit}
figure{margin:0;overflow:hidden;border-radius:6px;outline:1px solid \${dark ? "#ffffff1f" : "#0000001f"}}
img{display:block;width:100%}
</style><header><span>\${theme}</span>\${heads}</header>\${rows}\`;
const sheet = path.join(outDir, \`.board-\${kit}-\${theme}.html\`);
fs.writeFileSync(sheet, html);
const context = await browser.newContext({ viewport: { width: 2880, height: 1200 }, deviceScaleFactor: 1 });
const page = await context.newPage();
await page.goto(\`file://\${sheet}\`, { waitUntil: "load" });
const file = path.join(outDir, \`\${kit}__board__\${theme}.png\`);
await page.screenshot({ path: file, fullPage: true });
await context.close();
fs.unlinkSync(sheet);
return { file, problems };
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log(HELP);
return;
}
if (!args.port) fail("--port is required");
if (!args.out) fail("--out is required");
const base = \`http://127.0.0.1:\${args.port}\`;
const browser = await loadChromium().launch({ headless: true });
let failed = 0;
let shots = [];
try {
const lab = await discoverLab(browser, base);
const kit = args.d ?? "today";
if (kit !== "all" && !lab.directions.includes(kit)) fail(\`Unknown --d: \${kit} (expected \${lab.directions.join(", ")} or all)\`);
const views = list(args.s, [...lab.surfaces, "board"], "surface");
const themes = list(args.theme, THEMES, "theme");
for (const surface of [...views.filter((s) => s !== "board"), ...views.filter((s) => s === "board")]) {
const kits = kit === "all" && surface !== "board" ? lab.directions : [kit];
for (const k of kits) for (const theme of themes) shots.push({ kit: k, surface, theme });
}
const outDir = path.resolve(args.out);
fs.mkdirSync(outDir, { recursive: true });
for (const shot of shots) {
const results = [];
if (shot.surface === "board") results.push(await captureBoard(browser, base, lab, shot.kit, shot.theme, outDir));
else {
results.push(await capture(browser, base, shot, outDir));
if (args.hover) results.push(await captureHover(browser, base, shot, outDir));
}
for (const { file, problems } of results) {
console.log(\`\${problems.length > 0 ? "FAIL" : "ok "} \${file}\`);
for (const problem of [...new Set(problems)]) console.log(\` \${problem}\`);
if (problems.length > 0) failed += 1;
}
}
} finally {
await browser.close();
}
if (failed > 0) {
console.error(\`\\nshot: \${failed} capture(s) reported errors.\`);
process.exit(1);
}
}
main().catch((error) => {
console.error(\`shot: \${error.message}\`);
process.exit(1);
});
`;
@@ -0,0 +1,89 @@
const quote = (value) => JSON.stringify(value);
export default (ctx) => `import type { Meta, StoryObj } from "@storybook/react-vite";
import { useEffect, useState } from "react";
import { installApiStub } from "@/${ctx.slug}-directions/api-stub";
import "@/${ctx.slug}-directions/api-routes";
import { DIRECTION_IDS, KIT_META, SURFACE_IDS, loadKit, surfaceOf, type DirectionId, type DirectionKit, type SurfaceId } from "@/${ctx.slug}-directions/kit";
import { SurfaceView } from "@/${ctx.slug}-directions/frames/SurfaceView";
type Args = { direction: DirectionId; surface: SurfaceId; animation: boolean };
function DirectionView({ direction, surface, animation }: Args) {
const [kit, setKit] = useState<DirectionKit | null>(null);
useEffect(() => {
const restoreFetch = installApiStub();
let live = true;
loadKit(direction).then((loaded) => {
if (live) setKit(loaded);
});
return () => {
live = false;
restoreFetch();
};
}, [direction]);
useEffect(() => {
const previous = document.documentElement.dataset.labAnim;
if (animation) document.documentElement.dataset.labAnim = "1";
else delete document.documentElement.dataset.labAnim;
return () => {
if (previous === undefined) delete document.documentElement.dataset.labAnim;
else document.documentElement.dataset.labAnim = previous;
};
}, [animation]);
if (!kit) return null;
return <SurfaceView kit={kit} surface={surface} />;
}
const meta = {
id: ${quote(ctx.storyId)},
title: ${quote(`Design proposals/${ctx.group}/${ctx.title} directions/Compare`)},
component: DirectionView,
argTypes: {
direction: {
control: { type: "select" },
options: DIRECTION_IDS,
labels: Object.fromEntries(DIRECTION_IDS.map((id) => [id, KIT_META[id].name])),
description: "Which direction's kit renders.",
},
surface: {
control: { type: "select" },
options: SURFACE_IDS,
labels: Object.fromEntries(SURFACE_IDS.map((id) => [id, surfaceOf(id).label])),
description: "Which product surface.",
},
animation: { control: { type: "boolean" }, description: "The lab's hover-motion toggle (html[data-lab-anim])." },
},
args: { direction: "today", surface: ${quote(ctx.surfaces[0].id)}, animation: true },
parameters: {
layout: "fullscreen",
docs: {
description: {
component:
${quote(`Pick a direction and a surface. 0.Today mounts the shipped ${ctx.title} components on the lab's fixtures; 1–${ctx.directions.length} are the explored directions. Page: ${ctx.publicUrl}.`)},
},
},
paperclip: {
capabilities: [${ctx.capabilities.map(quote).join(", ")}],
status: "proposed",
home: "design-proposals",
implementation: "prototype",
boundary: "fixtures",
exception: ${quote(ctx.exception)},
notes: ${quote(ctx.notes)},
},
},
} satisfies Meta<typeof DirectionView>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Compare: Story = { name: "Any direction, any surface" };
export const Today: Story = { name: ${quote(ctx.today.name)}, args: { direction: "today" } };
${ctx.directions.map((direction) => `export const D${direction.digit}: Story = { name: ${quote(direction.name)}, args: { direction: ${quote(direction.id)} } };`).join("\n")}
export const TodayProductionPalette: Story = {
name: "0.Today · production palette",
args: { direction: "today" },
parameters: { designProposalTheme: false },
};
`;
@@ -0,0 +1,46 @@
export default () => `// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import { installApiStub, registerApiRoute } from "./api-stub";
import { COMPANY } from "./fixtures";
describe("installApiStub", () => {
const original = window.fetch;
const restores: Array<() => void> = [];
afterEach(() => {
while (restores.length) restores.pop()?.();
window.fetch = original;
});
it("answers /api/ from the fixtures and puts fetch back when restored", async () => {
restores.push(installApiStub());
expect(window.fetch).not.toBe(original);
const companies = await (await window.fetch("/api/companies")).json();
expect(companies).toEqual([COMPANY]);
restores.pop()?.();
expect(window.fetch).toBe(original);
});
it("serves a registered route and answers anything else under /api/ with an empty object", async () => {
registerApiRoute((pathname) => (pathname === "/api/lab/ping" ? { pong: true } : undefined));
restores.push(installApiStub());
expect(await (await window.fetch("/api/lab/ping")).json()).toEqual({ pong: true });
expect(await (await window.fetch("/api/lab/nothing", { method: "POST" })).json()).toEqual({});
});
it("keeps one stub across nested installs and restores on the last release", () => {
const first = installApiStub();
const stub = window.fetch;
const second = installApiStub();
restores.push(first, second);
expect(window.fetch).toBe(stub);
first();
expect(window.fetch).toBe(stub);
first();
expect(window.fetch).toBe(stub);
second();
expect(window.fetch).toBe(original);
});
});
`;
@@ -0,0 +1,64 @@
export default (ctx) => {
const first = ctx.surfaces[0].id;
const second = ctx.surfaces[1]?.id ?? first;
const d1 = ctx.directions[0].id;
const d2 = ctx.directions[1].id;
const landing = `?d=${ctx.landing.direction}&s=${ctx.landing.view}&theme=dark`;
return `import { describe, expect, it } from "vitest";
import { kitIdOf, labSearch, landingSearch, readLabState } from "./lab-state";
describe("readLabState", () => {
it("lands a bare URL on the registry's landing view, dark, with animation on and the production palette", () => {
expect(readLabState("")).toEqual({ direction: ${JSON.stringify(ctx.landing.direction)}, view: ${JSON.stringify(ctx.landing.view)}, theme: "dark", chrome: true, anim: true, proposal: false });
});
it("reads a kit with no surface as its first surface", () => {
expect(readLabState("?d=${d1}")).toMatchObject({ direction: "${d1}", view: "${first}" });
});
it("reads a surface with no kit as Today's", () => {
expect(readLabState("?s=${second}")).toMatchObject({ direction: "today", view: "${second}" });
});
it("keeps the every-direction board reachable by URL", () => {
expect(readLabState("?d=all")).toMatchObject({ direction: "all" });
expect(readLabState("?d=all&s=${first}")).toMatchObject({ direction: "all", view: "${first}" });
});
it("ignores an unknown kit or surface", () => {
expect(readLabState("?d=nope&s=${first}")).toMatchObject({ direction: "today", view: "${first}" });
expect(readLabState("?d=${d2}&s=nope")).toMatchObject({ direction: "${d2}", view: "${first}" });
});
it("turns the light theme, the bare frame, animation off and the proposal palette on only on request", () => {
expect(readLabState("?theme=light&chrome=0&anim=0&proposal=1")).toMatchObject({ theme: "light", chrome: false, anim: false, proposal: true });
expect(readLabState("?theme=dusk&chrome=1&anim=1&proposal=0")).toMatchObject({ theme: "dark", chrome: true, anim: true, proposal: false });
});
});
describe("labSearch", () => {
it("writes the defaults as the short form", () => {
expect(labSearch(readLabState(""))).toBe(${JSON.stringify(landing)});
expect(landingSearch()).toBe(${JSON.stringify(landing)});
});
it("carries chrome, animation and proposal only when they differ from the defaults", () => {
expect(labSearch({ direction: "today", view: "${first}", theme: "light", chrome: false, anim: false, proposal: true })).toBe("?d=today&s=${first}&theme=light&chrome=0&anim=0&proposal=1");
expect(labSearch({ direction: "${d1}", view: "board", theme: "dark", chrome: true, anim: true, proposal: false })).toBe("?d=${d1}&s=board&theme=dark");
});
it("round-trips through readLabState", () => {
for (const search of ["?d=${d2}&s=${second}&theme=light", "?d=all&s=board&theme=dark&anim=0", "?d=today&s=${first}&theme=dark&chrome=0&proposal=1"]) {
expect(labSearch(readLabState(search))).toBe(search);
}
});
});
describe("kitIdOf", () => {
it("reads the every-direction board as Today and a direction as itself", () => {
expect(kitIdOf(readLabState("?d=all&s=${first}"))).toBe("today");
expect(kitIdOf(readLabState("?d=${d1}"))).toBe("${d1}");
});
});
`;
};
@@ -0,0 +1,118 @@
export default (ctx) => `import { existsSync, readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { registry } from "./registry";
const CURVE = "300ms cubic-bezier(0.3, 0, 0.1, 1)";
function labRoot(): string {
const here = path.dirname(fileURLToPath(import.meta.url));
if (existsSync(path.join(here, "directions"))) return here;
let dir = process.cwd();
for (;;) {
const candidate = path.join(dir, "ui/src/${ctx.slug}-directions");
if (existsSync(path.join(candidate, "directions"))) return candidate;
const parent = path.dirname(dir);
if (parent === dir) throw new Error("cannot locate ui/src/${ctx.slug}-directions from " + process.cwd());
dir = parent;
}
}
function sources(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const file = path.join(dir, entry.name);
if (entry.isDirectory()) return sources(file);
return entry.name.endsWith(".tsx") ? [file] : [];
});
}
function mediaBlock(css: string): { inner: string; rest: string } {
const open = css.indexOf("@media (prefers-reduced-motion: no-preference) {");
expect(open, "a prefers-reduced-motion: no-preference block").toBeGreaterThan(-1);
let depth = 0;
for (let i = css.indexOf("{", open); i < css.length; i += 1) {
if (css[i] === "{") depth += 1;
if (css[i] === "}") {
depth -= 1;
if (depth === 0) {
const start = css.indexOf("{", open) + 1;
return { inner: css.slice(start, i), rest: css.slice(0, open) + css.slice(i + 1) };
}
}
}
throw new Error("unbalanced @media block");
}
const rules = (css: string) => [...css.matchAll(/([^{}]+)\\{([^{}]*)\\}/g)].map((match) => ({ selector: match[1].trim(), body: match[2].trim() }));
const explorations = registry.directions.filter((direction) => !direction.baseline);
const root = labRoot();
describe("the V1 MVP motion contract", () => {
it("gives Today no tokens.css and every exploration one", () => {
expect(existsSync(path.join(root, "directions/today/tokens.css"))).toBe(false);
for (const direction of explorations) expect(existsSync(path.join(root, "directions", direction.id, "tokens.css")), direction.id).toBe(true);
});
for (const direction of explorations) {
const id = direction.id;
const prefix = id.replace(/^d\\d+-/, "");
const scope = \`[data-direction="\${id}"]\`;
const gate = \`html[data-lab-anim="1"] \${scope}\`;
describe(id, () => {
const css = readFileSync(path.join(root, "directions", id, "tokens.css"), "utf8").replace(/\\/\\*[\\s\\S]*?\\*\\//g, "");
const { inner, rest } = mediaBlock(css);
it("declares the one curve once, under its own scope, and no other timing", () => {
const eases = [...css.matchAll(/--([a-z0-9-]+)-hover-ease:\\s*([^;]+);/g)];
expect(eases).toHaveLength(1);
expect(eases[0][1]).toBe(prefix);
expect(eases[0][2].trim()).toBe(CURVE);
expect(rules(rest).some((rule) => rule.selector === scope && rule.body.includes(\`--\${prefix}-hover-ease: \${CURVE}\`))).toBe(true);
expect(css.match(/cubic-bezier\\(/g)).toHaveLength(1);
expect(css.match(/\\d+ms/g)).toHaveLength(1);
});
it("transitions transform alone, on the backings and the front, with the curve", () => {
const transitions = [...css.matchAll(/transition:\\s*([^;]+);/g)];
expect(transitions).toHaveLength(1);
expect(transitions[0][1].trim()).toBe(\`transform var(--\${prefix}-hover-ease)\`);
expect(css).not.toMatch(/transition:\\s*(filter|box-shadow|opacity|scale)/);
const transitionRule = rules(inner).find((rule) => rule.body.startsWith("transition:"));
expect(transitionRule?.selector).toBe(\`\${gate} :is([data-rotate][data-spread], [data-front])\`);
});
it("gates every movement on the lab toggle and reduced motion", () => {
const gated = rules(inner);
expect(gated).toHaveLength(4);
for (const rule of gated) expect(rule.selector.startsWith(gate)).toBe(true);
expect(rest).not.toContain("data-lab-anim");
});
it("rises the front 6px and fans the backings 5 degrees on hover", () => {
const hover = rules(inner).filter((rule) => !rule.body.startsWith("transition:"));
const triggers = [\`\${gate} button:hover \`, \`\${gate} .\${prefix}-tile:hover \`];
const byTarget = Object.fromEntries(hover.map((rule) => [rule.selector.slice(rule.selector.lastIndexOf(" ") + 1), rule]));
expect(Object.keys(byTarget).sort()).toEqual(['[data-front]', '[data-spread="left"]', '[data-spread="right"]']);
for (const rule of hover) expect(triggers.some((trigger) => rule.selector.startsWith(trigger)), rule.selector).toBe(true);
expect(byTarget["[data-front]"].body).toBe("transform: translateY(-6px);");
expect(byTarget['[data-spread="left"]'].body).toBe(\`transform: rotate(calc(var(--\${prefix}-base, 0deg) + 5deg));\`);
expect(byTarget['[data-spread="right"]'].body).toBe(\`transform: rotate(calc(var(--\${prefix}-base, 0deg) - 5deg));\`);
});
it("rests the backings on their inline angle outside both gates", () => {
const rest_ = rules(rest).find((rule) => rule.selector === \`\${scope} [data-rotate]\`);
expect(rest_?.body).toBe(\`transform: rotate(var(--\${prefix}-base, 0deg));\`);
});
it("carries the three hooks in its components", () => {
const code = sources(path.join(root, "directions", id)).map((file) => readFileSync(file, "utf8")).join("\\n");
for (const hook of ["data-front", "data-rotate", "data-spread"]) expect(code, hook).toContain(hook);
expect(code).toContain(\`--\${prefix}-base\`);
});
});
}
});
`;
@@ -0,0 +1,55 @@
export default (ctx) => `import { describe, expect, it } from "vitest";
import { DIRECTION_IDS, KIT_KEYS, KIT_META, SURFACE_IDS, loadKit } from "./kit";
import { registry } from "./registry";
const KIT_DIRECTORIES = Object.keys(import.meta.glob("./directions/*/index.tsx")).map((file) => file.split("/")[2]);
describe("the ${ctx.slug} registry", () => {
it("has unique direction ids and unique single-digit keys", () => {
expect(new Set(DIRECTION_IDS).size).toBe(DIRECTION_IDS.length);
const keys = Object.values(KIT_KEYS);
expect(new Set(keys).size).toBe(keys.length);
for (const key of keys) expect(key).toMatch(/^[0-9]$/);
});
it("keeps Today as the single baseline, keyed 0 and placed last", () => {
const baselines = registry.directions.filter((direction) => direction.baseline);
expect(baselines.map((direction) => direction.id)).toEqual(["today"]);
expect(KIT_KEYS.today).toBe("0");
expect(DIRECTION_IDS[DIRECTION_IDS.length - 1]).toBe("today");
expect(registry.baselinePosition).toBe("last");
});
it("names every exploration after its digit", () => {
for (const direction of registry.directions.filter((entry) => !entry.baseline)) {
expect(direction.id).toMatch(new RegExp(\`^d\${direction.key}-\`));
expect(direction.name.startsWith(\`\${direction.key}.\`)).toBe(true);
}
});
it("has one kit directory per direction and no stray kit", () => {
expect([...KIT_DIRECTORIES].sort()).toEqual([...DIRECTION_IDS].sort());
});
it("gives each kit the registry's id, name and tagline", async () => {
for (const id of DIRECTION_IDS) {
const kit = await loadKit(id);
expect(kit.id).toBe(id);
expect(kit.name).toBe(KIT_META[id].name);
expect(kit.tagline).toBe(KIT_META[id].tagline);
for (const surface of SURFACE_IDS) expect(typeof kit[\`\${surface.charAt(0).toUpperCase()}\${surface.slice(1).replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase())}Body\` as keyof typeof kit]).toBe("function");
}
});
it("lands on a view the registry knows", () => {
expect([...DIRECTION_IDS, "all"]).toContain(registry.landing.direction);
expect([...SURFACE_IDS, "board"]).toContain(registry.landing.view);
});
it("records its story id and provenance", () => {
expect(registry.storyId).toBe(${JSON.stringify(ctx.storyId)});
expect(registry.provenance.createdAt).toMatch(/^\\d{4}-\\d{2}-\\d{2}$/);
for (const direction of registry.directions) expect(["shipped", "proposed", "adopted", "current", "historical"]).toContain(direction.stage);
});
});
`;
@@ -0,0 +1,67 @@
export default (ctx) => `// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { installApiStub } from "./api-stub";
import "./api-routes";
import { kit } from "./directions/today";
import { ${ctx.surfaces.map((surface) => surface.fixture).join(", ")} } from "./fixtures";
import { Slot } from "./frames/Slot";
import { createLabQueryClient, LabProviders } from "./providers";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
async function settle(client: ReturnType<typeof createLabQueryClient>) {
await act(async () => {
await Promise.resolve();
});
for (let round = 0; round < 50 && client.isFetching() > 0; round += 1) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
}
}
describe("the Today kit on the lab fixtures", () => {
let container: HTMLDivElement;
let root: Root;
let restoreFetch: () => void;
beforeEach(() => {
restoreFetch = installApiStub();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
restoreFetch();
});
${ctx.surfaces
.map(
(surface) => `
it("renders something inside the ${surface.id} slot", async () => {
const client = createLabQueryClient();
await act(async () => {
root.render(
<LabProviders client={client}>
<Slot kit={kit}>
<kit.${surface.body} items={${surface.fixture}} />
</Slot>
</LabProviders>,
);
});
await settle(client);
const slot = container.querySelector('[data-direction="today"]');
expect(slot).not.toBeNull();
expect(slot?.children.length ?? 0).toBeGreaterThan(0);
expect(slot?.textContent?.trim()).not.toBe("");
client.clear();
});`,
)
.join("\n")}
});
`;
@@ -0,0 +1,22 @@
export default (ctx, surface) => `import type { SurfaceData } from "../../kit";
export function ${surface.body}({ items }: SurfaceData["${surface.id}"]) {
return (
<div data-lab-stub="today-${surface.id}" className="flex flex-col gap-2 rounded-md border border-dashed border-border p-3 text-sm">
<p className="font-mono text-(length:--text-micro) uppercase tracking-(--tracking-label) text-muted-foreground">
Today · ${surface.label} · mount the shipped ${ctx.title} component here
</p>
<ul className="flex list-none flex-col gap-1 p-0">
{items.map((item) => (
<li key={item.id} className="flex items-baseline justify-between gap-3">
<span className="min-w-0 truncate text-foreground">{item.title}</span>
<span className="shrink-0 text-xs text-muted-foreground">
{item.author} · {item.updatedLabel}
</span>
</li>
))}
</ul>
</div>
);
}
`;
@@ -0,0 +1,16 @@
export default () => `import { COMPANY } from "../../fixtures";
export function guid(seed: string): string {
let h = 0x811c9dc5;
let hex = "";
for (let round = 0; hex.length < 32; round += 1) {
for (const char of \`\${seed}:\${round}\`) h = Math.imul(h ^ char.charCodeAt(0), 0x01000193);
hex += (h >>> 0).toString(16).padStart(8, "0");
}
return \`\${hex.slice(0, 8)}-\${hex.slice(8, 12)}-\${hex.slice(12, 16)}-\${hex.slice(16, 20)}-\${hex.slice(20, 32)}\`;
}
export const agentId = (name: string) => guid(\`agent:\${name}\`);
export const issueIdFor = (taskIdentifier: string) => guid(\`issue:\${taskIdentifier}\`);
export const issueHref = (taskIdentifier: string) => \`/\${COMPANY.issuePrefix}/issues/\${taskIdentifier}\`;
`;
@@ -0,0 +1,10 @@
export default (ctx) => `import type { DirectionKit } from "../../kit";
${ctx.surfaces.map((surface) => `import { ${surface.body} } from "./${surface.body}";`).join("\n")}
export const kit: DirectionKit = {
id: "today",
name: ${JSON.stringify(ctx.today.name)},
tagline: ${JSON.stringify(ctx.today.tagline)},
${ctx.surfaces.map((surface) => ` ${surface.body},`).join("\n")}
};
`;
@@ -0,0 +1,96 @@
export default (ctx) => `import fs from "node:fs";
import { execFileSync } from "node:child_process";
import path from "path";
import { fileURLToPath } from "url";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ENTRY = "${ctx.entry}";
const DEFAULT_PORT = ${ctx.ports.dev};
function portFromArgv() {
const argv = process.argv;
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === "--port" && argv[i + 1]) return Number(argv[i + 1]);
if (argv[i].startsWith("--port=")) return Number(argv[i].slice("--port=".length));
}
return null;
}
function buildCommit() {
try {
return execFileSync("git", ["rev-parse", "--short", "HEAD"], { cwd: __dirname, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || "unknown";
} catch {
return "unknown";
}
}
const PORT = portFromArgv() || Number(process.env.${ctx.envPort}) || DEFAULT_PORT;
const OUT_DIR = process.env.${ctx.envOutDir}
? path.resolve(process.env.${ctx.envOutDir})
: path.resolve(__dirname, "dist-${ctx.slug}-directions");
const LAB_WATCH_PATHS = [
path.resolve(__dirname, "src/${ctx.slug}-directions"),
path.resolve(__dirname, "src/index.css"),
path.resolve(__dirname, ENTRY),
];
function outsideLab(watchedPath) {
const target = path.resolve(__dirname, String(watchedPath));
if (LAB_WATCH_PATHS.some((lab) => target === lab || target.startsWith(lab + path.sep))) return false;
return !LAB_WATCH_PATHS.some((lab) => lab.startsWith(target + path.sep));
}
const entryAsRoot = {
name: "${ctx.slug}-directions-entry-as-root",
configureServer(server) {
server.middlewares.use((req, _res, next) => {
const [pathname, query] = (req.url || "").split("?");
if (pathname === "/" || pathname === "/index.html") {
req.url = \`/\${ENTRY}\${query ? \`?\${query}\` : ""}\`;
}
next();
});
},
};
const htmlAsIndex = {
name: "${ctx.slug}-directions-html-as-index",
closeBundle() {
const built = path.resolve(OUT_DIR, ENTRY);
if (!fs.existsSync(built)) return;
fs.renameSync(built, path.resolve(OUT_DIR, "index.html"));
},
};
export default defineConfig({
appType: "mpa",
plugins: [react(), tailwindcss(), entryAsRoot, htmlAsIndex],
define: { ${ctx.buildDefine}: JSON.stringify(buildCommit()) },
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
lexical: path.resolve(__dirname, "./node_modules/lexical/dist/Lexical.mjs"),
},
},
cacheDir: path.resolve(__dirname, \`node_modules/.vite-${ctx.slug}-directions-\${PORT}\`),
server: {
host: "127.0.0.1",
port: PORT,
strictPort: true,
watch: { usePolling: true, interval: 250, ignored: outsideLab },
},
build: {
outDir: OUT_DIR,
emptyOutDir: true,
minify: "esbuild",
rollupOptions: {
input: path.resolve(__dirname, ENTRY),
},
},
});
`;
@@ -0,0 +1,4 @@
schema_version: "1.1"
name: asks-how-many-directions
expected_outcome: |
Before scaffolding, the run asks how many directions to draw (2 or 3) and which surfaces the card appears on, or, when no question can be asked, states that it is defaulting to three directions and one surface and proceeds. The scaffolded lab then matches the answer.
@@ -0,0 +1,7 @@
---
type: llm
focus: trace
weight: 1
---
The result matches this description: Before scaffolding, the run asks how many directions to draw (2 or 3) and which surfaces the card appears on, or, when no question can be asked, states that it is defaulting to three directions and one surface and proceeds. The scaffolded lab then matches the answer.
@@ -0,0 +1,8 @@
---
tags: [directions-lab]
max_turns: 60
timeout_seconds: 1800
allowed_tools: [Read, Glob, Grep, Skill, Write, Edit, Bash, AskUserQuestion]
---
make me a directions lab for the agent profile card
@@ -0,0 +1,4 @@
schema_version: "1.1"
name: multi-surface-three-directions
expected_outcome: |
A new lab under ui/src/<slug>-directions with its own html entry and vite config. The header has a directions row with Today and three numbered directions and a surfaces row with two tabs (thread, panel), plus theme and animation toggles. Every direction's tokens.css carries the V1 MVP hover motion (300ms cubic-bezier(0.3, 0, 0.1, 1), the 6px rise, the 5 degree fan, gated on data-lab-anim and reduced motion). The Today kit mounts the shipped composer components on fixtures. The lab builds with vite, its tests pass, and the design-proposals Storybook gets a catalogued story for the lab.
@@ -0,0 +1,7 @@
---
type: llm
focus: trace
weight: 1
---
The result matches this description: A new lab under ui/src/<slug>-directions with its own html entry and vite config. The header has a directions row with Today and three numbered directions and a surfaces row with two tabs (thread, panel), plus theme and animation toggles. Every direction's tokens.css carries the V1 MVP hover motion (300ms cubic-bezier(0.3, 0, 0.1, 1), the 6px rise, the 5 degree fan, gated on data-lab-anim and reduced motion). The Today kit mounts the shipped composer components on fixtures. The lab builds with vite, its tests pass, and the design-proposals Storybook gets a catalogued story for the lab.
@@ -0,0 +1,8 @@
---
tags: [directions-lab]
max_turns: 60
timeout_seconds: 1800
allowed_tools: [Read, Glob, Grep, Skill, Write, Edit, Bash, AskUserQuestion]
---
We need a directions page for the task composer. The composer shows up in two places: the empty composer at the bottom of the task thread, and the composer inside the right panel's chat tab. Show what ships today next to 3 new directions, with the same light/dark and animation controls the artifact lab has, and keep the same hover motion as the MVP V1 direction.
@@ -0,0 +1,4 @@
schema_version: "1.1"
name: single-surface-two-directions
expected_outcome: |
A new lab for one surface: the header has the directions row (Today plus two numbered directions) with the theme and animation toggles, and no surfaces row because the focus covers a single surface. The V1 MVP motion tokens are carried over. The lab builds, its tests pass, and a design-proposals story is catalogued.
@@ -0,0 +1,7 @@
---
type: llm
focus: trace
weight: 1
---
The result matches this description: A new lab for one surface: the header has the directions row (Today plus two numbered directions) with the theme and animation toggles, and no surfaces row because the focus covers a single surface. The V1 MVP motion tokens are carried over. The lab builds, its tests pass, and a design-proposals story is catalogued.
@@ -0,0 +1,8 @@
---
tags: [directions-lab]
max_turns: 60
timeout_seconds: 1800
allowed_tools: [Read, Glob, Grep, Skill, Write, Edit, Bash, AskUserQuestion]
---
i want to explore 2 directions for the inbox row on the inbox page, next to what we ship now. same toggles as the artifact directions lab.
@@ -0,0 +1,246 @@
# Canonical directory: the governance PR (separate, opened only on Tonio's yes)
Paste-ready diffs that make the Storybook **Design proposals** section the design directory:
the source of truth for visual references, with `DESIGN.md` recording decisions and pointing at
entries. The skill does not need it: every lab ships in mode A (story `status: "proposed"`, stage
carried by the registry's `stage` field and, once a per-direction story is written by hand, by its
first sentence; see `lifecycle.md`). Part 1 is the governance change, prose only. Part 2 is the
optional mode-B script change that lets an entry carry `current` or `historical`. Today's framing,
and why it exists, is `design-proposals-storybook.md` section 6; it is not repeated here.
Rules:
- Never apply any of this inside a lab PR. Offer it once, in one line of the lab PR body:
"The design-directory governance change (`canonical-directory.md`) is ready as a separate PR
on request." Open it only when Tonio says yes: branch `claude/design-directory`, title
`docs(design): make the Design proposals section the design directory`, body from
`.github/PULL_REQUEST_TEMPLATE.md` with every section filled, including Model Used.
- Part 1 alone is a complete PR. Part 2 alone is not: do not change the script while
`DESIGN.md:9` still says Storybook does not define the system.
- Line numbers were read on 2026-10-24 at `5e26b733b`. Re-check each with `sed -n` before
editing: every `-` line below must still match byte for byte, or the diff is stale.
## Part 1: governance diffs (prose only, no script change)
### 1.1 `DESIGN.md:9`, the source-of-truth sentence (under `## What this document is for`, line 7)
```diff
-Agents and humans modifying `ui/` treat this file as the source of truth for design decisions. Storybook is the verification surface — it documents the system; it does not define it. If a change conflicts with this document, change this document first (with review) or change the code.
+Agents and humans modifying `ui/` treat this file and the Storybook **Design proposals** directory as the source of truth: a directory entry (meta id `design-*`) is the canonical visual reference for its subject at its recorded stage; this document records the decisions and points at the entries. The rest of Storybook is the verification surface — it documents the system; it does not define it. If a change conflicts with this document or with a directory entry, change the document or the entry first (with review) or change the code.
```
Why "at its recorded stage": a `proposed` entry is canonical for what the proposal looks like,
not a claim that the product renders it. The stage is what separates the two.
### 1.2 New `## Design directory` section (additive)
Insert after line 9, before `## Product stance` (`DESIGN.md:11`), beside the sentence that
grants the authority:
```markdown
## Design directory
The Storybook **Design proposals** section is the design directory. Each row names the
entry (its stable meta id, `design-*`) that is the canonical visual reference for a subject
and the stage recorded for it. Advance a row's stage in the PR that advances the entry.
A subject without a row has no canonical reference yet. Entries never count as
production coverage; the product's own feature-home stories do.
| Subject | Entry | Stage |
| --- | --- | --- |
| Artifacts | `design-artifact-directions-compare` | adopted (`enableArtifactsV1`) |
```
No URL column: the meta id is the durable pointer (`/iframe.html?id=<meta id>--<export>`); the
published host is not. First-row facts: the id is at
`ui/storybook/stories/design-proposals/artifact-directions-compare.stories.tsx:39` (the story is
`status: "proposed"`, `home: "design-proposals"`, lines 52-53); the flag is `enableArtifactsV1`
(`packages/shared/src/feature-catalog.ts:202`, read by `ui/src/hooks/useArtifactsV1Enabled.ts:7-8`).
The artifact lab predates the `design-<slug>-directions` id convention, hence the `-compare`
suffix; new labs use `registry.storyId`. `lifecycle.md` section 10 offers a lab's row as text in
this table's shape; the table stays literal Markdown.
### 1.3 `DESIGN.md:304` and `:311-313`, "review references" and the move on adoption
Both sit in one paragraph (`DESIGN.md:297-313`) of `## Task-row refresh: proposed specimen`
(`:289`). Leave line 303 (`home: "design-proposals"`, `status: "proposed"`, "an explicit staging
reason") alone in Part 1: the catalog still enforces exactly that (`scripts/storybook-catalog.mjs:99-100`).
Line 311 starts mid-sentence; keep its first clause.
```diff
-These stories are review references and do not count as current production coverage.
+Directory entries are the canonical references for their subjects and never count as production coverage.
...
-for tasks explicitly implementing the refresh. After design review and adoption
-in the app, move each suite to its feature or Foundations home and update its
-classification while retaining its explicit story ID and exports.
+for tasks explicitly implementing the refresh. After adoption in the app the entry stays in
+the directory with its ID and exports; the shipped component gets its own story in its
+feature home, and the Design directory table above records the new stage.
```
### 1.4 `maps/storybook-map/README.md:34-35`, the exception is the entry's purpose
```diff
-Staged design work may use `home: "design-proposals"`, with `status: "proposed"`
-and an exception explaining why it is staged separately from production references.
+Design directory entries use `home: "design-proposals"`, `status: "proposed"` and an
+exception stating the entry's purpose: the subject it is the canonical reference for and
+what it compares.
```
### 1.5 `maps/storybook-map/README.md:60-75`, the section's role and the stages
Lines 61-69 (folder, title prefix, group order, "Preserve explicit story IDs and exports") stay.
```diff
-**Design proposals** is a separate top-level review section. Its stories live in
+**Design proposals** is the design directory: a separate top-level section whose entries
+are the canonical visual references named in `DESIGN.md` (Design directory). Its stories live in
...
-Retain capability references so proposals remain discoverable from the feature
-guide, but never count them as current production examples. Keep using shared
-components and tokens rather than creating a parallel design implementation.
-When a proposal is reviewed and adopted in the app, move its story into the usual
-feature or Foundations home, update its classification and staging metadata, and
-preserve the explicit CSF ID and exports so review links remain valid.
+Retain capability references so entries remain discoverable from the feature guide,
+but never count them as production coverage. Keep using shared components and tokens
+rather than creating a parallel design implementation. An entry has one of four stages:
+proposed, adopted (behind a flag), current (shipped as the default) and
+historical (superseded, with `replacement` naming the successor). The stage is recorded
+in the entry's description and in the `DESIGN.md` Design directory table; the catalog
+status stays `proposed` while the catalog accepts only that status in this home. Entries
+stay in the directory at every stage and keep their explicit CSF ID and exports; the
+shipped component gets its own story in its feature home.
```
Why entries stay put: fifteen of the twenty directory ids are pinned by Playwright specs, and a
directory whose entries leave when they succeed is not a directory. The count, from the repo root
(two of the fifteen, `tasks-list-page-design` and `tasks-row-design`, have no `design-` prefix):
```sh
for id in $(git ls-files 'ui/storybook/stories/design-proposals/*.stories.tsx' | xargs grep -hoE '^\s*id: "[^"]+"' | cut -d'"' -f2); do grep -rqF -- "$id" tests/storybook-visual && echo "$id"; done | wc -l # 15 of the 20 ids
```
### Part 1 verification
Nothing executable reads this prose. Run what AGENTS.md requires after a `maps/` edit, plus the
catalog, which must be unaffected:
```sh
node scripts/check-maps.mjs # today: "Maps aligned: 35 areas, 191 capability IDs"
node scripts/storybook-catalog.mjs --check # today: "Storybook map: 316 files, 2973 stories."
```
## Part 2 (optional): mode-B script change
Mode B lets a directory entry carry `status: "current"` or `"historical"` under
`home: "design-proposals"` while still never counting as coverage. The skill detects mode A by
the exact message `design proposal home requires proposed status` in `scripts/storybook-catalog.mjs`
(`scripts/lib/modes.mjs`; `lifecycle.md` section 2). The new message below does not contain that substring (the comma
breaks it), which is what flips detection to mode B. Do not shorten the message back.
### 2.1 `scripts/storybook-catalog.mjs:99`, the status gate
```diff
- if (entry.status !== "proposed") errors.push(`${label}: design proposal home requires proposed status`);
+ if (!["proposed", "current", "historical"].includes(entry.status)) errors.push(`${label}: design proposal home requires proposed, current or historical status`);
```
`mixed` and `unreviewed` stay rejected: a directory entry is a decision, and both mean the
decision has not been made. Line 100 (the exception requirement) is unchanged.
### 2.2 `scripts/storybook-catalog.mjs:133`, the home description in the generated index
```diff
- { id: "design-proposals", title: "Proposed design system", section: "Design proposals", description: "Staged design work for review. These examples retain capability references but do not count as current product coverage." },
+ { id: "design-proposals", title: "Proposed design system", section: "Design proposals", description: "Design directory. Canonical references for design subjects; they never count as product coverage." },
```
### 2.3 `scripts/storybook-catalog.mjs:165` and `:169`, coverage excludes the home regardless of status
```diff
- const examples = catalog.entries.filter(entry => entry.implementation !== "guide" && !["historical", "proposed"].includes(entry.status));
+ const examples = catalog.entries.filter(entry => entry.implementation !== "guide" && !["historical", "proposed"].includes(entry.status) && storyHome(entry) !== "design-proposals");
...
- `${capabilities.length} capabilities; ${missing.length} without a current, mixed or unreviewed example. Historical examples, proposals and guides do not remove gaps.`, "",
+ `${capabilities.length} capabilities; ${missing.length} without a current, mixed or unreviewed example. Historical examples, proposals, design directory entries and guides do not remove gaps.`, "",
```
`storyHome` is defined in the same module (`:70-72`). Without the new clause a `current`
directory entry silently closes a capability gap; the test in 2.4 fails on exactly that
(verified: with only this clause reverted, "1 without" becomes "0 without"). The line-169
wording keeps the generated `maps/generated/verification.md` true; no test matches that tail
(`storybook-catalog.test.mjs:98,159` match only `/2 without a current, mixed or unreviewed example/`).
### 2.4 `scripts/storybook-catalog.test.mjs:101-115`
Lines 109-114 (exception, title and folder assertions) are unchanged. `renderVerification` is
already imported at line 6.
```diff
-test("design proposal homes require proposed status, a staging reason and their own navigation", t => {
+test("design proposal homes require a directory status, a staging reason and their own navigation", t => {
const f = mapFixture(t);
const metadata = { home: "design-proposals", status: "proposed", capabilities: ["tasks/create"], exception: "Staged refresh for review." };
const options = { area: "design-proposals", title: "Design proposals/Tasks/Task row" };
- for (const status of ["current", "mixed", "unreviewed", "historical"]) {
+ for (const status of ["mixed", "unreviewed"]) {
f.story("proposal", { ...metadata, status }, options);
- assert.match(inspect(f).errors.join("\n"), /design proposal home requires proposed status/);
+ assert.match(inspect(f).errors.join("\n"), /design proposal home requires proposed, current or historical status/);
}
+ f.story("proposal", { ...metadata, status: "current", capabilities: ["tasks/discussion"] }, options);
+ const current = inspect(f);
+ assert.deepEqual(current.errors, []);
+ assert.match(renderVerification(current.catalog, current.index), /1 without a current, mixed or unreviewed example/);
+ f.story("proposal", { ...metadata, status: "historical" }, options);
+ assert.deepEqual(inspect(f).errors, []);
f.story("proposal", { ...metadata, exception: undefined }, options);
```
Why `tasks/discussion`: `mapFixture` (`scripts/map-test-fixture.mjs:31-32`) seeds `tasks` with
`create` and `discussion` and one `unreviewed` story on `tasks/create`, so `discussion` is the
one open gap. A `current` directory entry on it must leave "1 without"; "0 without" means the
entry counted as coverage.
### Part 2 verification
Run from the repo root and paste the three results into the PR's Verification section:
```sh
node --test scripts/storybook-catalog.test.mjs # 13 tests, 13 pass, before and after (one test rewritten, none added)
node scripts/storybook-catalog.mjs --check # "Storybook map: 316 files, 2973 stories."; all 20 directory entries are proposed, so nothing flips
cd ui && npx vitest run storybook/map-tooling.test.ts # 1 passed; wraps the three node --test files (ui/storybook/map-tooling.test.ts:6)
```
The vitest wrapper is the CI signal (`maps/storybook-map/README.md:104-105`); a green
`node --test` alone is not. `cd ui && pnpm typecheck` also re-runs the catalog (`ui/package.json:23`);
report it as not run if `@paperclipai/plugin-sdk` is not built.
With Part 2 merged, a lab records stages as `lifecycle.md` mode B says: proposed and adopted map
to `status: "proposed"`, current to `"current"`, historical to `"historical"` plus `replacement`.
`scaffold.mjs check` then derives the expected Compare `status` from the registry stages
(`scripts/lib/modes.mjs`, `labStage`): `"proposed"` while any exploration is proposed or adopted,
`"historical"` plus `replacement` when every exploration is historical, `"current"` otherwise.
In the same PR amend README line 34 from `status: "proposed"` to "`status` of `proposed`,
`current` or `historical` (adopted entries keep `proposed`)" and drop the 1.5 clause "the catalog status stays
`proposed` while the catalog accepts only that status in this home".
## What does not change
- The literal-only rule: `parameters.paperclip` is parsed, never executed, and must follow
spreads (`scripts/storybook-catalog.mjs:26-59`). Stage never becomes a computed value.
- The `statuses` list (`:13`) and the `home` allow-list (`:95`): no new status, no new home.
- Meta ids and exports: `stableId` is required (`:109`) and every id stays; Part 1 removes the
move-on-adoption that used to put them at risk.
- The folder and title rules (`:107-108`): entries live in `ui/storybook/stories/design-proposals/`
with titles under `Design proposals/`; the sidebar slot (`scripts/storybook-feature-index.mjs:11`)
and the proposal theme scope (`ui/storybook/.storybook/preview-base.tsx:853`) key on the same home.
- The home's `title` ("Proposed design system") and `section` ("Design proposals") at `:133`:
only the description changes, so the Overview guide and sidebar labels are untouched.
- The capability lookup (`:159`) keeps printing `(<status>)` after each entry; under mode B a
directory entry shows `(current)` there while `verification.md` still lists the gap. Intended:
lookup is navigation, coverage is `verification.md`.
- `doc/DEVELOPING.md` and the three commands above: no command changes.
@@ -0,0 +1,397 @@
# Design-proposals Storybook - registering a lab and graduating a direction
For the agent scaffolding a new directions lab under `ui/storybook/stories/design-proposals/`. Line numbers confirmed at `5e26b733b`; re-check after the cited files change. Working directory: repo root.
## Contents
1. The catalog gate
2. The proposal theme
3. Precedent: the two artifact lab stories
4. The story file `new` writes
5. Lifecycle: proposed, adopted, current, historical
6. Source-of-truth note
## 1. The catalog gate
`scripts/storybook-catalog.mjs` stands between a story file and the sidebar. It parses every
`ui/storybook/stories/**/*.stories.(ts|tsx|mdx)` with Storybook's CSF parser and never executes the
module (lines 61-67). It reads only the default-export meta's `parameters` (line 46).
Rules for a story homed in `design-proposals`:
- Explicit static meta `id` (109: "explicit meta ID is required"). Folder convention `design-<slug>`
(18 of 20 files). Story ids are `<meta id>--<kebab-case export name>`; `name` does not change them.
- `title` starts `Design proposals/` (17, 106-108), then `<Group>/<Suite>`. Group vocabulary
(`maps/storybook-map/README.md:62-65`, `DESIGN.md:299-301`): Foundations, Components, Layouts,
Pages, Patterns, Guidelines, in that order. The pinned sort order in
`scripts/storybook-feature-index.mjs:11` adds "AI Generated Test Pages" between Pages and Patterns
and is asserted by `scripts/storybook-feature-index.test.mjs:19`. An unlisted group (today
"Artifact directions") validates and sorts after Guidelines in file-discovery order; to pin a lab
group, extend line 11 and the test, never the generated `preview.tsx`. No empty groups (`README.md:65`).
- File path starts `ui/storybook/stories/design-proposals/` (107). Subfolders pass: `files()`
recurses (19-24) and the check is `startsWith`.
- Exactly one literal `parameters.paperclip` on the meta (45-59): `parameters` is an object literal
(47) with one `paperclip` key (49), non-computed (51), and no `...spread` or `[computed]` key after
it (52-53), so put it last. Values are string, boolean, number, array or object literals; `as` and
`satisfies` wrappers are unwrapped (26-43). Template literals, identifiers, calls and `null` throw
"metadata must be literal; expressions are not executed". To omit a key, leave it out.
- Closed field set (16): `capabilities`, `status`, `implementation`, `boundary`, `notes`, `home`,
`exception`, `replacement`. Any other key throws "unknown paperclip field <key>" (57).
- Enums (13-15): status `current | proposed | historical | mixed | unreviewed`; implementation
`production | prototype | mixed | guide | unreviewed`; boundary `fixtures | fixture-server | static |
unreviewed`. Omitted status, implementation and boundary default to `unreviewed`, notes to `""` (77).
- `capabilities` is required even as `[]` (94); every id must exist in the feature index (102), no
duplicates (104), and `[]` needs an `exception` (103). Ids are `<feature>/<key>` from the
`## Sub-features` bullets of `maps/feature-map/<feature>.md` (`scripts/feature-map.mjs:28,34`;
example `maps/feature-map/documents-artifacts.md:17-21`). No `design-proposals/*` capability or
recipe exists (145). Adding a capability means editing the recipe and its agent-access companion,
then `node scripts/check-maps.mjs`; `generateStorybook` aborts on map errors first (184-185).
- `home` may only be `foundations` or `design-proposals` (95). `home: "design-proposals"` forces
`status: "proposed"` (99) and a nonempty `exception` (96, 100);
`scripts/storybook-catalog.test.mjs:101-115` asserts current, mixed, unreviewed and historical fail.
- Uniqueness (112-114): meta ids unique among meta ids; story ids unique among story ids plus the
generated `feature-map-<featureId>--overview`. Avoid a `feature-map-` prefix by convention.
`replacement`, when present, names an existing story file (110).
Two failure modes: `annotation()` and `literal()` throw and abort the whole scan (26-59), so
Storybook does not start and `tsc` never runs; `validateCatalog` collects errors and throws them
joined (83-116, 189-190). A `.mdx` file passes the filename filter (64) but the parser throws a raw
Babel error: emit TSX only, prose goes in `parameters.docs.description.component`. Only the meta
block counts: a story-level `parameters.paperclip` is ignored, and a file whose only block is
story-level fails "declare parameters.paperclip as literal metadata" (47) when the meta has no
`parameters`, else "expected one parameters.paperclip annotation" (49). Storybook merges story
parameters at runtime, so a story-level block still reaches the theme decorator (section 2).
How it runs:
- Storybook start and build: `ui/storybook/.storybook/main.ts:10` calls `generateStorybook()`.
- `cd ui && pnpm typecheck` is `node ../scripts/storybook-catalog.mjs && tsc -b`
(`ui/package.json:23`): write mode, then `tsc` over `src` and `storybook` (`ui/tsconfig.json:19`).
- CI: `.github/workflows/pr-trusted.yml:332-333` runs `pnpm run typecheck:build-gaps`
(`package.json:25`); `scripts/run-typecheck-build-gaps.mjs:143-149,181-182` typechecks every
workspace whose `build` script lacks `tsc`, and `ui` builds with `vite build` (`ui/package.json:18`).
`release-verify.yml:118` and `docker-runner-check.yml:271` run `pnpm -r typecheck`.
- `pnpm test` runs `ui/storybook/map-tooling.test.ts:5-11`, which shells out to `node --test
scripts/check-maps.test.mjs scripts/storybook-catalog.test.mjs scripts/storybook-feature-index.test.mjs`.
Those are fixture-based: they prove the checker, not your story, which only the paths above and `--check` validate.
`--check`: `node scripts/storybook-catalog.mjs --check` validates without writing (202-206). It still
runs `checkMaps` and resolves `storybook/internal/csf-tools` through `ui/package.json` (62-63), so
`pnpm install` comes first. Current output, exit 0: `Storybook map: 316 files, 2973 stories. Structure
only; no runtime proof.` Every failure above is an uncaught throw: exit 1, stack on stderr, no summary
line. Any flag other than `--write` or `--check`, or two flags, throws a usage error (204).
Every generated output is git-ignored and never committed: `ui/storybook/generated/`,
`ui/storybook/.storybook/preview.tsx`, `maps/generated/`, `ui/storybook-static/` (`.gitignore:11-14`;
`README.md:86-90`). Add decorators and fixtures to `preview-base.tsx` only. Do not record review state
in the annotation: `fingerprint` fails as an unknown field (`storybook-catalog.test.mjs:49-50`), as
`reviewedAt` would (16, 57); evidence goes in the PR (`README.md:107-113`).
## 2. The proposal theme
One decorator, `ui/storybook/.storybook/preview-base.tsx:843-857`. Line 853, verbatim:
```tsx
{context.parameters.paperclip?.home === "design-proposals" && context.parameters.designProposalTheme !== false ? <AvatarInitialProvider><div className="design-proposal-theme"><Story /></div></AvatarInitialProvider> : <Story />}
```
Opt out per meta or per story with `parameters: { designProposalTheme: false }`. The parameter exists
only in that line (no doc names it); its one user is the `TodayProductionPalette` export the generator
writes (section 4). Opting out also drops
`AvatarInitialProvider`, the single-initial fallback-avatar rule for proposals (`DESIGN.md:305-308`).
All proposal CSS lives in `ui/src/index.css` in additive blocks keyed on the marker:
`html:has(.design-proposal-theme)` 3916-3933 and 3938-4091 (conversation typography),
`html.dark:has(.design-proposal-theme)` 4097-4111, `html:not(.dark):has(...)` 4114-4120, element rules
4121-4144 (body and `#storybook-root` fill 4123, inputs 4124-4125, focus 4129-4140, cards 4141-4144),
group labels 4508-4509, canonical toolbar 4625-4698 and 4767. The scope is `html:has(...)`, so the
marker anywhere re-themes the whole iframe including Radix portals (comment 4093-4096); a column-wise
production-vs-proposal split inside one themed story is impossible. Either opt out and scope a
direction's tokens to `[data-direction="<id>"]` (section 3), or theme every column. Adopting a theme
value means lifting it out of the scoped block into `:root` or `.dark` and deleting it there;
"removing the story marker restores the existing production theme" (4095-4096).
What the theme changes: `--font-mono` and `--default-mono-font-family` become `--font-paper-mono`
(Paper Mono; `@font-face` 126-132, token 214); `--font-weight-medium` collapses to normal and
`--font-weight-bold` to semibold; `--text-micro` aliases `--text-xs` (3917-3922). In dark mode the
Paper palette applies: `--background: #1a1a1a`, `--muted-alt: #202020`, card and popover equal
background, `--accent` from `--muted-highlight`, `--input` from `--border`, `--ring` from
`--foreground` (4098-4110). Light inherits production colors and only aliases `--muted-alt` and four
`--design-sidebar-*` names (4115-4119). No radius changes.
Light and dark come from the toolbar global `theme` (default `dark`, 858-872), applied as `.dark` on
`<html>`. Pin per story with `globals: { theme: "light" }`
(`ui/storybook/stories/design-proposals/bulk-agent-settings.stories.tsx:33`). Proposals follow the
toolbar and never lock a mode (`DESIGN.md:150-151`). The backgrounds addon is disabled (878-880).
Global for every file: `a11y: { test: "error" }` (875-877) with `@storybook/addon-a11y` (`main.ts:25`),
so an axe violation in any direction fails the a11y run; override per story, never in `preview-base`.
`docs: { autodocs: true }` (`main.ts:30-32`) plus `docs.toc` (888-890): every file gets a Docs page from
`parameters.docs.description.component`. Opt-out precedents: `tags: ["!autodocs"]`
(`scripts/storybook-feature-index.mjs:49`) and `docs: { story: { inline: false } }` (`buttons.stories.tsx:23`).
Viewport presets (892-908): `mobile` 390x844, `tablet` 834x1112, `desktop` 1440x960, plus
`MINIMAL_VIEWPORTS` (`mobile1` 320x568, `mobile2` 414x896). Select with
`globals: { viewport: { value: "mobile", isRotated: false } }`; add `parameters.waitForViewport: true`
to defer mounting until the iframe has that width (847-850). Declare a custom width per story in
`parameters.viewport.options` (`ui/storybook/stories/documents-artifacts/rich-work-product-cards.stories.tsx:249-253`).
`parameters.initialEntries` seeds the MemoryRouter (default `/PAP/storybook`).
## 3. Precedent: the two artifact lab stories
Both live in `ui/storybook/stories/design-proposals/`, import the lab from `ui/src/artifact-directions/`
through the `@` alias (`main.ts` maps `@` to `ui/src`), and share one classification: `status: "proposed"`,
`home: "design-proposals"`, `implementation: "prototype"`, `boundary: "fixtures"`, capabilities
`documents-artifacts/library`, `documents-artifacts/work-products`, `documents-artifacts/attachments`.
`artifact-directions-compare.stories.tsx`: id `design-artifact-directions-compare`, title
`Design proposals/Artifact directions/Compare` (38-40). `exception` (56): "Design exploration staged
in the artifact directions lab: the shipped baseline and five proposed directions compared on static
mocks of the task thread, the side panel and the Artifacts page." `notes` (57) end "nothing here
establishes production behaviour or access checks." Mount (21-36): `DirectionView({ direction, view })`
holds `useState<DirectionKit | null>`; a `useEffect` keyed on `direction` calls `installApiStub()`
(`@/artifact-directions/api-stub`, ref-counted, returns a restore function; `api-stub.ts:50-71`), then
`loadKit(direction)` (an `import.meta.glob` over `directions/*/index.tsx`, `kit.ts:157-163`), and
restores fetch in the cleanup. It renders `<KindsSheet kit>` or `<SurfaceView kit surface>`
(`frames/SurfaceView.tsx:9-31`; surfaces `thread | panel | gallery | stack`). Controls (60-73):
`direction` is a select over `DIRECTION_IDS` with `labels` from `KIT_META[id].name`
(`kit.ts:130,143-150`); `view` is a select over `[...SURFACE_IDS, "kinds"]`; default
`{ direction: "d4-hybrid", view: "gallery" }`. One export, `AnyDirection`, named "Any direction, any
surface" (79). The stub exists because the `today` kit mounts shipped components that fetch `/api/`;
it layers over Storybook's own fetch fixtures (`preview-base.tsx:95,785-788`). The generator's story
template (`assets/templates/stories/compare.stories.tsx.mjs`) is this file parameterised (section 4).
`artifact-directions-hybrid.stories.tsx`: id `design-artifact-directions-hybrid`, title
`Design proposals/Artifact directions/4.Hybrid` (27-29; the suite name is the `KIT_META` name).
Static `import { kit as hybrid }` (3); meta is `satisfies Meta` with no `component` (48). Four
`Surface · ...` exports render `<SurfaceView kit={hybrid} surface=... />` (56-74); `Kinds` renders
`<KindsSheet>` (76-79). Five `Slot · ...` exports wrap one kit slot in `Stage` (87-95):
`<div className="min-h-screen bg-background p-6 text-foreground">` around `<Slot kit={hybrid}>`
(`frames/Slot.tsx:8-14` emits `data-direction={kit.id}`, which scopes a kit's `tokens.css`) at
product widths 960, 1152 and `TASK_DETAIL_PANE_WIDTH - 32` (84-85, 139-140). A slot with its own
Controls is typed `StoryObj<SlotArgs>` with local `args` and `argTypes` (105-125). No stub: lab-only
kits do not fetch.
Classification rule: a lab is `implementation: "prototype"` even when one direction mounts production
primitives ("A prototype that imports production primitives remains a prototype of its outer subject",
`README.md:46-47`), and `boundary: "fixtures"` because frames are static mocks on fixture data. A kit's
`tokens.css` is tolerated as lab code: the token gate scans only `ui/src/components` and `ui/src/pages`
(`scripts/check-token-gates.mjs:79-80`), but `DESIGN.md:17` forbids a second token source, so adoption
converts kit tokens into `index.css` tokens.
## 4. The story file `new` writes
File: `ui/storybook/stories/design-proposals/<slug>-directions.stories.tsx`, rendered by
`node .agents/skills/directions-lab/scripts/scaffold.mjs new` from
`assets/templates/stories/compare.stories.tsx.mjs`; `<...>` marks what the flags fill. Every `paperclip`
value is emitted with `JSON.stringify`, so it stays a plain double-quoted string literal. The id is
`design-<slug>-directions` (`registry.storyId`), chosen once; it is permanent.
```tsx
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useEffect, useState } from "react";
import { installApiStub } from "@/<slug>-directions/api-stub";
import "@/<slug>-directions/api-routes";
import { DIRECTION_IDS, KIT_META, SURFACE_IDS, loadKit, surfaceOf, type DirectionId, type DirectionKit, type SurfaceId } from "@/<slug>-directions/kit";
import { SurfaceView } from "@/<slug>-directions/frames/SurfaceView";
type Args = { direction: DirectionId; surface: SurfaceId; animation: boolean };
function DirectionView({ direction, surface, animation }: Args) {
const [kit, setKit] = useState<DirectionKit | null>(null);
useEffect(() => {
const restoreFetch = installApiStub();
let live = true;
loadKit(direction).then((loaded) => {
if (live) setKit(loaded);
});
return () => {
live = false;
restoreFetch();
};
}, [direction]);
useEffect(() => {
const previous = document.documentElement.dataset.labAnim;
if (animation) document.documentElement.dataset.labAnim = "1";
else delete document.documentElement.dataset.labAnim;
return () => {
if (previous === undefined) delete document.documentElement.dataset.labAnim;
else document.documentElement.dataset.labAnim = previous;
};
}, [animation]);
if (!kit) return null;
return <SurfaceView kit={kit} surface={surface} />;
}
const meta = {
id: "design-<slug>-directions",
title: "Design proposals/<Group>/<Title> directions/Compare",
component: DirectionView,
argTypes: {
direction: { control: { type: "select" }, options: DIRECTION_IDS, labels: Object.fromEntries(DIRECTION_IDS.map((id) => [id, KIT_META[id].name])), description: "Which direction's kit renders." },
surface: { control: { type: "select" }, options: SURFACE_IDS, labels: Object.fromEntries(SURFACE_IDS.map((id) => [id, surfaceOf(id).label])), description: "Which product surface." },
animation: { control: { type: "boolean" }, description: "The lab's hover-motion toggle (html[data-lab-anim])." },
},
args: { direction: "today", surface: "<first surface id>", animation: true },
parameters: {
layout: "fullscreen",
docs: { description: { component: "Pick a direction and a surface. 0.Today mounts the shipped <Title> components on the lab's fixtures; 1–<n> are the explored directions. Page: https://paperclip-<slug>-directions.vercel.app." } },
paperclip: {
capabilities: ["<area>/<key>"],
status: "proposed",
home: "design-proposals",
implementation: "prototype",
boundary: "fixtures",
exception: "Design directory entry for <Title>: the shipped baseline and <n> proposed directions compared on static mocks of <surface labels>.",
notes: "Controls pick a direction kit and a surface. Today mounts the shipped <Title> components behind the lab's fetch stub with the <company> fixture world; the other kits are lab components. Frames are static mocks of the product chrome; nothing here establishes production behaviour or access checks.",
},
},
} satisfies Meta<typeof DirectionView>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Compare: Story = { name: "Any direction, any surface" };
export const Today: Story = { name: "0.Today", args: { direction: "today" } };
export const D1: Story = { name: "1.<Name>", args: { direction: "d1-<name>" } };
export const TodayProductionPalette: Story = { name: "0.Today · production palette", args: { direction: "today" }, parameters: { designProposalTheme: false } };
```
One `D<N>` export per direction, named `1.Refine`, `2.Rework`, `3.Rethink` unless `--names` says otherwise
(two directions: Refine, Rethink). No light variants are emitted; the visual suite shoots both themes.
Rules the file encodes:
- `capabilities`: `--capabilities <area>/<key>,...`, validated against the `## Sub-features` of
`maps/feature-map/<area>.md` through `scripts/feature-map.mjs` (`lib/capabilities.mjs:35-48`); the first
entry is primary and decides the folder on retirement (section 5, item 7). Omit the flag for cross-cutting work: the story
then carries `capabilities: []` and the exception sentence (the generator says so on stderr), the
Foundations precedent being `colors.stories.tsx:14-17` ("Shared color foundation: production light
palette and proposed Paper dark palette. This does not establish production feature coverage.").
`developer-labs/design` (`maps/feature-map/developer-labs.md:17`) is what `buttons.stories.tsx` cites
for a sandbox inventory.
- `exception` reads as a stand-alone sentence: it fills the Capabilities column of
`maps/generated/storybook.md` when `capabilities` is empty (`storybook-catalog.mjs:152`). The generated
one is "Design directory entry for <Title>: the shipped baseline and <n> proposed directions compared on
static mocks of <surface labels>." (`lib/context.mjs:162`). Other house phrasings: "Staged design-system
refresh awaiting visual review and app adoption; kept separate from production references."
(`task-row-design.stories.tsx:224`); "Shared desktop app-shell layouts staged with the design refresh
before adoption in production routes." (`app-shell.stories.tsx:21`); "Requested interactive
bulk-settings design for review before production implementation." (`bulk-agent-settings.stories.tsx:16`).
- `notes` hold durable limits only, never a change history (`README.md:43`), and disclaim production
behaviour. Story ids derive from export names (`Compare` -> `design-<slug>-directions--compare`, `D1` ->
`--d-1`, `TodayProductionPalette` -> `--today-production-palette`): change display text with `name`,
never by renaming an export. `scaffold.mjs check --slug <slug>` requires the meta id to equal
`registry.storyId`, the exports `Compare`, `Today`, `TodayProductionPalette` and one `D<N>` per registry
direction, `paperclip` last in `parameters`, and `status: "proposed"` while `storybook-catalog.mjs:99`
stands (`lib/check.mjs:28,116-132`; `lib/modes.mjs`). `add-direction` inserts the next `D<N>` export
before `TodayProductionPalette`.
- No comment lines: `lib/write.mjs:5-15,25-26` refuses any line starting `//`, `/*`, `{/*` or `<!--`, the
one exception being `// @vitest-environment jsdom` on a test's first line; files of 600 lines or more
and existing paths are refused too (27-29).
- Per-direction deep dives (surfaces plus slots) are written by hand, no subcommand emits them: a second
file `<slug>-directions-<digit>.stories.tsx` mirroring the hybrid story, id
`design-<slug>-directions-<digit>` (the digit key, never the name: a rename must never change an id;
the precedent's `-hybrid` suffix predates this rule), title
`Design proposals/<Group>/<Title> directions/<N>.<Name>`, same `paperclip` block with the exception
narrowed to that direction (`artifact-directions-hybrid.stories.tsx:44`), meta `satisfies Meta` without
`component`.
- Theme: the file is themed except `TodayProductionPalette`, which opts out with
`parameters: { designProposalTheme: false }` so Today is judged on the production palette; kit tokens
stay scoped by `data-direction` (section 3) and Today has no `tokens.css`. The lab page keeps its state
in the URL; a proposal that persists UI state uses `paperclip:design-proposals:<slug>:...`
(`ui/storybook/support/design-proposals/ToolbarsPreview.tsx:52`).
- Every new story adds two visual-regression screenshots (dark, light) generated from the built
`index.json` (`tests/storybook-visual/storybook-visual.spec.ts:6-26`); baselines are external and
Linux-only. Never publish baselines to pass a review (`README.md:119`).
- `new` itself runs `node scripts/storybook-catalog.mjs --check`, the lab's Vitest files and a Vite build
(`lib/verify.mjs:18-33`), and on failure deletes what it wrote and restores `.claude/launch.json` and
`.gitignore` (`scaffold.mjs:84-96`). Afterwards verify with `scaffold.mjs check --slug <slug>` (`--run`
adds lab vitest, catalog check, token gates, lab build and `cd ui && pnpm typecheck`,
`lib/check.mjs:148-154`), or by hand: `node scripts/storybook-catalog.mjs --check`, then
`cd ui && pnpm typecheck`; run `pnpm run check:token-gates` if you touched `ui/src/components` or
`ui/src/pages`.
## 5. Lifecycle: proposed, adopted, current, historical
1. Proposed. The lab enters, as `new` writes it, with `home: "design-proposals"`, `status: "proposed"` and
the exception as staging reason (`DESIGN.md:297-304`; `README.md:34-35,60-69`). The section is "a separate top-level
review section" (`README.md:60`), rendered first in the generated index as "Proposed design system"
("Staged design work for review. These examples retain capability references but do not count as
current product coverage.", `storybook-catalog.mjs:133`) and listed under "Design proposals" in the
Overview guide by `FeatureMapIndex` (`ui/storybook/support/feature-map/FeatureOverview.tsx:64,78-79,92-99`).
Capability links remain so the lab is reachable from each feature's overview, marked "(proposed)" (159).
2. Never coverage. `renderVerification` counts only entries whose implementation is not `guide` and
whose status is neither `historical` nor `proposed` (165-169: "Historical examples, proposals and
guides do not remove gaps."); tested at `storybook-catalog.test.mjs:75-99`. Approval is human and
visual: "It remains proposed until visual approval." (`DESIGN.md:150-151`).
3. Sandbox (a step between proposed and adopted, not a stage; `lifecycle.md` §8). `VITE_PAPERCLIP_DESIGN_SANDBOX=true` in the dev
app adds the same `.design-proposal-theme` marker to `<body>` and a bottom-right "Design proposals:
On/Off" toggle (`ui/src/context/DesignSandboxContext.tsx:14-37`; `DESIGN.md:724-739`). Use it to
inspect real pages on real data for missing token mappings and component variants; it is "an
integration experiment, not permission, scale or release acceptance" (`DESIGN.md:755`).
4. Adopted behind a flag. Product code for the chosen direction lands in `ui/src/components/...`
behind an experimental setting and gets a story in its FEATURE home with `status: "proposed"`,
`implementation: "production"`, no `home`, no `exception`:
`ui/storybook/stories/documents-artifacts/artifact-v1-primitives.stories.tsx:43-62` (id
`components-artifact-v1-primitives`, title under the feature prefix). `proposed` is legal anywhere;
only `home: "design-proposals"` forces it; seven feature-folder stories use this interim state. The
plan behind it: `doc/plans/2026-10-22-artifacts-mvp-v1.md:7-15` ("The lab stays the visual
reference") and 28-32 ("Reuse the lab's code, not its types ... The lab keeps its own copies; it is
a reference, not a dependency of product code"). Kit tokens became `--artifact-*` tokens in
`index.css:495-531`, with dark overrides at 594-600.
5. Current. When the flag is removed, set that feature-home story to `status: "current"`; it now
counts as coverage. The Compare entry stays in the directory as `proposed` while any direction is
unshipped and remains the visual reference (`lifecycle.md` §5).
6. Moving a file, on retirement only. Shipping never moves a lab story; only a retirement (item 7:
`historical` plus `replacement`) moves a file out of the proposals folder, by `README.md:73-75`: "move
its story into the usual feature or Foundations
home, update its classification and staging metadata, and preserve the explicit CSF ID and exports
so review links remain valid." Drop `home` and `exception` first (a `design-proposals` story cannot
change status, line 99); the first capability's area then dictates the folder
`ui/storybook/stories/<area>/` and the title prefix `<Section>/<Feature>/...` (`storyHome` 70-72;
106-108). Keep the meta `id` and every export name: Playwright specs address
`/iframe.html?id=<meta id>--<export>` (`tests/storybook-visual/buttons-design.spec.ts:5`) and 15 of
the 20 proposal ids are pinned that way (`grep -rqF -- "$id" tests/storybook-visual` for each `id:`
meta id in the git-tracked `design-proposals/*.stories.tsx`; unpinned: `design-agent-workload-trial`, both `design-artifact-directions-*`,
`design-bulk-agent-settings`, `design-project-health-trial`). Diff the built `ui/storybook-static/index.json` ids before
and after (`.agents/skills/maintain-storybook/SKILL.md:60-61`).
7. Historical. Set `status: "historical"` and `replacement: "<repo-relative .stories.tsx>"`
(must exist, line 110). Five files do this today, all outside the proposals folder, e.g.
`ui/storybook/stories/agent-chat/chat-entry.stories.tsx:5-22` (id `design-explorations-chat-entry`,
title `.../Design explorations/Chat entry`, description beginning "Historical"). `historical` is
rejected under `home: "design-proposals"` (99), so retiring a direction means moving its story to
its feature folder under a `Historical/` or `Design explorations/` subgroup with the id kept
(`lifecycle.md` §6; a whole lab moves Compare with it), never deleting it. "Do not delete a story merely because it is old, mocked, or duplicated by name."
(`maintain-storybook/SKILL.md:41-42`).
At every step run `node scripts/storybook-catalog.mjs --check`, `cd ui && pnpm typecheck` and
`node --test scripts/check-maps.test.mjs scripts/storybook-catalog.test.mjs scripts/storybook-feature-index.test.mjs`.
Record evidence in the PR, not in the annotation (`README.md:107-113`).
## 6. Source-of-truth note
Today `DESIGN.md:9` governs: "Agents and humans modifying `ui/` treat this file as the source of truth
for design decisions. Storybook is the verification surface - it documents the system; it does not
define it. If a change conflicts with this document, change this document first (with review) or
change the code." The section is framed as staging, not authority: stories are "review references"
(`DESIGN.md:304`), the exception explains "why it is staged separately from production references"
(`README.md:34-35`), and proposals never count "as current production examples" (`README.md:70-71`).
To make the design-proposals section the canonical directory of the design system:
- Reword `DESIGN.md:9` (with review) so the section defines design decisions and DESIGN.md records or
points to them; drop the "review references" framing at `DESIGN.md:304` for directory entries.
- Reframe `maps/storybook-map/README.md:34-35` (exception = the entry's purpose, not a reason for
separateness) and `60-75` (which entries stay in the directory, which move to a feature home).
`doc/DEVELOPING.md:257-277` needs no change unless the commands change.
Nothing changes in `scripts/storybook-catalog.mjs` for this: the section already has its own home
(`specialHomes`, line 17), folder and title rule (106-108), sort slot (`storybook-feature-index.mjs:11`),
index section (133) and theme scope (`preview-base.tsx:853`). Keep the coverage exclusion (165):
directory entries are design decisions, not product coverage. The one constraint to decide on is line
99: an entry cannot be marked other than `proposed` while it lives in `design-proposals`. The skill
ships every lab under "canonical means staged until shipped" (mode A: `lib/modes.mjs` detects line 99's
sentence and `check` expects `proposed`); the alternative, a status or home for approved-but-unshipped
directions (script, `storybook-catalog.test.mjs:101-115`, README), is written as paste-ready diffs in
`canonical-directory.md`, offered once as its own PR and opened only when Tonio says yes (`SKILL.md`).
@@ -0,0 +1,112 @@
# Drawing phases
What to do after `node .agents/skills/directions-lab/scripts/scaffold.mjs new ...` has written the lab, its built-in verification passed and the scaffold is committed. Three phases, in this order: frames, then fixtures and Today, then directions. Phase 2 comes before Phase 3 because it retypes `SurfaceData` in `kit.ts`, and every kit body (Today and each direction) takes its props from that type; a direction drawn against the placeholder `LabRecord` is drawn twice.
Paths below are under `ui/src/<slug>-directions/` unless they start with `ui/`, `.agents/` or `/`. "Precedent" means the artifact lab, `ui/src/artifact-directions/`; its line numbers were opened on this checkout at `5e26b733b`. Read the precedent for shape, never import from it or copy it mechanically.
The other references already cover the ground below; link to them, do not repeat them:
- `lab-anatomy.md`: files, URL state, toolbar, Slot scoping, frames and the widths table (section 8), the parked optional-surface branch (section 9).
- `today-fixtures-and-build.md`: the Today kit (section 1), adapters (2), the API stub (3), the fixture world (4), capture (10), deploy (11).
- `motion-spec.md`: the hover motion, the hooks a component carries (7), gating (4), the `tokens.css` checklist (11).
- `repo-gates.md`: catalog rules, token gates, PR rules (4), the hand-off list (8).
- `lifecycle.md`: every move after the lab is drawn (adopted, current, historical). No subcommand does these in this version; do them by hand.
## 0. Commit discipline
1. Commit the scaffold before drawing anything: `feat(ui): scaffold the <title> directions lab`. Stage exactly what `new` printed: the lab directory, `ui/<slug>-directions.html`, `ui/vite.<slug>-directions.config.mjs`, the story file, `.claude/launch.json`, `.gitignore`. Why: the scaffold is plumbing that is the same from lab to lab; mixed with the redraw it hides the design decisions, and a reviewer reads the PR commit by commit to find them.
2. After that, one commit per phase (`feat(ui): draw the <title> lab frames`, `... Today`, `... <N>.<Name>`), or at least one commit for the whole redraw. Never amend the scaffold commit. A later `add-direction` is its own commit, before the drawing that follows it.
3. The PR is scaffold plus redraw; the redraw commits are the design diff. PR title: `feat(ui): add the <title> directions lab` (`repo-gates.md` section 8).
## 1. Phase 1: frames
Goal: each `frames/<Pascal>Frame.tsx` is a static, behaviour-free mock of the product chrome around the one place that surface stands for, with the kit's slot inside. The scaffold left a region marked `data-lab-stub="frame-<surface>"` around `<Slot kit={kit}><kit.<Pascal>Body ... /></Slot>` inside `<AppShell>`. Replace the region; keep the `Slot` and the `AppShell`.
Rules:
- Copy the product's class strings. Open the host component of that place and take its `className` strings as they are. Cite the file and lines in the PR body, never in a code comment: CONTRIBUTING forbids comments, and `check` refuses them. The precedent frames carry doc comments because they pre-date that gate; do not copy them.
- Freeze the registry width. The generated frame fixes its column or pane at the surface's `width` from `registry.ts` (960 for a thread column, 434 for the pane, 1152 for a page; sources in `lab-anatomy.md` section 8). When the product constant disagrees with the brief, change the number in `registry.ts` and let the frame follow; never hard-code a second width in the frame. The precedent freezes by value: `PanelFrame.tsx:19` `TASK_DETAIL_PANE_WIDTH = 434`, applied at `:32` as `style={{ width }}`; `ThreadFrame.tsx:86` `max-w-(--tc-shell-max-w)`; `GalleryFrame.tsx:102` `max-w-6xl`.
- Reuse presentational primitives; hand-draw anything stateful. The precedent mounts `SidePanelFrame`, `SidePanelWindowControls`, `SidePanelTabs` and `Button` (`PanelFrame.tsx:1-5, 33-60`, with `presentation="docked" contentMode="full-bleed" headerSize="task-detail" className="flex-1 border-l-0"` at `:33-37`), `Breadcrumb*` and `StatusIcon` (`ThreadFrame.tsx:3-4, 26-42`), `Breadcrumb*`, `Input`, `Button`, `cn` (`GalleryFrame.tsx:2-5`). Anything that reads a store, routes or fetches is mirrored as markup with `noop` handlers (`PanelFrame.tsx:28, 43-44, 60`).
- Nothing scrolls internally. `AppShell.tsx:173` uses `min-h-225` where the product shell is `h-dvh`; `PanelFrame.tsx:63` renders the pane body as a plain `p-4` div; `thread-parts.tsx:93-117` `ComposerShell` is not sticky. The page grows, so a full-page capture and the Board's height measurement see everything. No `overflow-auto`, `h-screen` or `sticky` in a frame.
- Never import from `ui/src/artifact-directions/`. `check` fails on `artifact-directions`, `LabArtifact`, `GalleryFrame`, `KindsSheet` and the other precedent identifiers. Read, then re-type what fits your feature.
Reading pointers (read, do not copy mechanically):
| Surface kind | Precedent | What to take from it |
|---|---|---|
| A task thread | `frames/ThreadFrame.tsx` + `frames/thread-parts.tsx` | `TaskTopBar` (:21-50); `GAP` and `Item` spacing (:53-57); `Bubble` (:60-62); a slot mounted per row (:64-70) and once as a block (:74-78); the 960 column (:86). In `thread-parts`: `AgentIdentity` (:13-22), `Prose` (:25-31), `MessageFooter` (:62-79), `WorkedRow` (:82-90), `ComposerShell` (:93-117). |
| The right pane | `frames/PanelFrame.tsx` | The `aside` (:32); the `SidePanelFrame` props (:33-37); the tab set (:22-26, 40-57); the body padding around the slot (:63-67). |
| A company page | `frames/GalleryFrame.tsx` | `TopBar` in its single-crumb and trail variants (:20-45); `Toolbar` (:48-78); the opened-item header (:81-93); the page-width wrapper (:102); the optional whole-region override (:103-113). |
| Every surface | `frames/AppShell.tsx` | Nav lists (:41-59); `NavItem` (:63-79); `Section` (:82-95); `Sidebar` with the 240px `w-60` and the 60px top row (:97-152); `SupportButton` (:159-168); the shell and its main column (:170-181). |
The scaffold's `AppShell` takes company and tasks from `registry.shell` and the active nav from each surface's `nav` (`@nav` in `--surfaces`); change those in `registry.ts`, not in the frame.
Finding the product source: the precedent's doc comments name the product file and lines they mirrored (`ThreadFrame.tsx:20, 52, 59, 80, 83, 85`; `PanelFrame.tsx:14-15, 18, 21, 62`; `GalleryFrame.tsx:16, 19, 47, 80, 99, 101`; `AppShell.tsx:25, 62, 81, 99, 101, 139, 172, 176`). Use them as entry points, then re-open the product file; three of those citations have already drifted (`lab-anatomy.md` section 8). For a place the precedent never drew: `rg -n "<a distinctive class string from the element>" ui/src/components ui/src/pages` finds the host, then `rg -n "max-w-|DEFAULT_.*WIDTH|_PANE_WIDTH" <host file>` finds the width to freeze.
Done when:
- `rg -n 'data-lab-stub="frame-' ui/src/<slug>-directions` prints nothing AND `rg -n "<the product class string you froze>" ui/src/<slug>-directions/frames` prints the frame. An absence grep alone passes on an empty file.
- `/?d=today&s=<surface>&theme=dark&chrome=0` at 1440 wide matches a product screenshot of that place chrome for chrome, in both themes. The slot content is still the Today placeholder box; that is expected here.
- The page has one scrollbar, on `<html>`.
- `node .agents/skills/directions-lab/scripts/scaffold.mjs check --slug <slug>` passes.
## 2. Phase 2: fixtures and Today
Goal: `0.Today` renders the shipped component for every surface on the lab's fixtures, so each direction is judged against what ships.
1. Retype the data. Edit `SurfaceData` in `kit.ts` so each member is the props that place receives in the product (`{ thread: { items: LabRecord[] } }` becomes, say, `{ thread: { workProducts: LabWorkProduct[]; attachments: LabAttachment[] } }`). Define the record types beside it, as the precedent keeps `LabArtifact`, `LabGroup` and `LabRun` in `kit.ts:18-97`, and state each slot's fixed constraints in the PR body in the words of the precedent's `DirectionKit` JSDoc at `kit.ts:103-112` ("the pane is 434px wide; the body has 16px of padding"); a JSDoc in the lab's `kit.ts` is a comment line and `check` refuses it. `DirectionKit` and every `<Pascal>Body` prop type derive from `SurfaceData`, so `cd ui && npx tsc -b 2>&1 | rg "<slug>-directions"` now lists every body and frame to update (minutes; needs `@paperclipai/plugin-sdk` built). Fix the frames first (they pass the fixtures), then Today, then the direction stubs: rename their props only, the specimen stays until Phase 3.
2. Fill `fixtures.ts`. Keep `COMPANY`, `TASKS`, one export per surface and the pinned clock `LAB_NOW`; derive every timestamp from it as zone-less local ISO strings inside one week and precompute labels with the product's en-US formats (precedent `fixtures.ts:22, 27-34, 54-59`). Conventions and factories: `today-fixtures-and-build.md` section 4. Long bodies go in a sibling `fixture-documents.ts`; media are URL imports from `assets/`.
3. Mount the shipped component in `directions/today/<Pascal>Body.tsx`. Mount it when it is a standalone export: the precedent's `PanelBody.tsx:14-20` mounts `IssuePropertiesArtifactsTab` with `{ id, companyId } as Issue`, and `Gallery.tsx:10-34` mounts `ArtifactGroupCard` and `ArtifactCard` on the product's grid string (`:7`). When the product markup is inline JSX inside a larger component, copy that JSX minus handlers and import the real primitives (`ThreadAttachments.tsx:24-96`); say which branch you copied in the PR body. Feed it through `adapters.ts`: pure `LabRecord -> product type` functions with the base object pinned by `satisfies Partial<T>` (`adapters.ts:64`) so field drift against `@paperclipai/shared` fails typecheck. `guid`, `agentId` and `issueIdFor` (`adapters.ts:14-25`) are already in the scaffold; keep them verbatim. Direction of reuse: product reuses the lab's code, never its types; the lab keeps its copies (`today-fixtures-and-build.md` section 2).
4. Register exactly the routes it fetches, in `api-routes.ts`. Find them: `rg -n "useQuery|queryFn|queryKey|use[A-Z]\w+\(" ui/src/components/<feature>/<Component>.tsx`, note each `<module>Api.<fn>` (the precedent tab's are at `IssuePropertiesArtifactsTab.tsx:420-442`: `issuesApi.listAttachments`, `activityApi.runsForIssue`, `agentsApi.list`, imported at `:19-21`), then open `ui/src/api/<module>.ts` for the path string. Hooks hide queries too: follow every `use<Thing>s(` into `ui/src/hooks/`. Register one `registerApiRoute((pathname, method) => body | undefined)` per path family. The precedent answers five paths with one regex (`panel-api.ts:75-90`), mints an id per fixture list so several panels share one page (`:17-25`, used from `PanelBody.tsx:16` inside `useMemo`), and spaces creation times a minute apart so the product's sort keeps the fixture order (`:32`). Matching is pathname-only (`api-stub.ts:58`): `/companies?scope=accessible` reaches a route as `/api/companies`; never put a query string in a route. Registration runs at import time, so a test that renders a body without importing `api-routes.ts` sees `{}`.
5. Read the console. A path no route answers gets `{}` and `console.debug("[<slug>-directions] unhandled", method, pathname)` (`api-stub.ts:36-37`), never a 4xx, so a frame looks empty rather than broken. Start `preview_start <slug>-directions-dev`, open `/?d=today&s=<surface>`, `read_console_messages` with pattern `unhandled`, register what you see, reload, repeat until the list is empty for every surface. Two fallbacks stay built in (`api-stub.ts:30-33`): `*/auth/get-session` and `*/companies`.
6. Flags. A flag-gated component calls a `use<Feature>Enabled()` hook that reads `GET /instance/settings/experimental` (`ui/src/hooks/useArtifactsV1Enabled.ts:26-33`; the path at `ui/src/api/instanceSettings.ts:21`) and tests one key (`settings?.enableArtifactsV1 === true`, `:7-9`). The scaffold's `api-routes.ts` already answers that path from `registry.today.flags`, so Today shows the flag-off presentation by default. When the brief asks for the flagged presentation, set `today: { flags: { <key>: true } }` in `registry.ts`; find the key with `rg -n "export function use\w+Enabled" ui/src/hooks` and read that hook's resolver. Do not fork a second Today for the other state in this version; name the choice in the PR body.
Done when:
- `rg -n 'data-lab-stub="today-' ui/src/<slug>-directions` prints nothing AND `rg -n 'from "@/components/' ui/src/<slug>-directions/directions/today` prints at least one shipped import per surface.
- `cd ui && npx vitest run src/<slug>-directions/today.test.tsx` passes with the shipped components mounted. The test renders each `kit.<Pascal>Body` on the fixtures through the lab providers with the stub installed and asserts `[data-direction="today"]` has an element child and text. It passed on the placeholder too, so it proves something only once the placeholder is gone.
- Today matches the product on the same data: compare `/?d=today&s=<surface>&chrome=0` at 1440 with a product screenshot of that place (the brief's, or the dev app with equivalent records), both themes.
- No `unhandled` line for a route the component needs; `check --slug <slug>` passes.
## 3. Phase 3: directions
Goal: each `directions/d<N>-<name>/` is a drawing. One direction at a time, finished before the next starts. Each starts as a kit whose bodies render a `MotionSpecimen` per item so the Animation toggle is verifiable before anything is drawn.
Per direction:
1. Replace `MotionSpecimen` in each `<Pascal>Body.tsx` with the drawing; delete `MotionSpecimen.tsx` once nothing imports it. Keep the hook names the specimen carried, because `motion-contract.test.ts` asserts them across the direction's `.tsx` files taken together and the toggle is a no-op without them (`motion-spec.md` section 7):
- `data-front` on the element that rises, on every treatment branch. A branch that returns a bare print without it stands still while the backings turn; that is the bare-print lesson in `motion-spec.md` section 7.
- `data-rotate data-spread="left"|"right"` on each fanning backing, with its resting angle as inline `"--<p>-base": "<deg>deg"` cast `as CSSProperties`, and a `transform-origin` derived from the visible shape's centre, not the padded asset's. Precedent: `d5-v1-mvp/Stack.tsx:40-52` (origin `:41-42, 48`, base `:50`, hooks `:52`); the rule is `motion-spec.md` section 3.4. A direction with nothing to fan may leave the backings out, as long as the names still appear in the body (the test checks presence, the toggle then only lifts).
2. Tokens. Every raw value (hex, rgba, px, radii, shadows, grid templates) goes in that direction's `tokens.css`, scoped `[data-direction="<id>"]`, light values in the base block and dark values in `.dark [data-direction="<id>"]` after it, the `index.css` idiom (`d1-solid/tokens.css:40, 90`). Copy hazard: `d5-v1-mvp/tokens.css` is the other way round, dark as the base (`:5`) and light under `html:not(.dark) [data-direction]` (`:56`), because its values were measured from a dark mock. Pasting a d5 block into the scaffold's file swaps every fill; flip the pairs when you borrow a value (`motion-spec.md` section 4.3). Tailwind resolves `h-(--<p>-tile-h)`, `rounded-(--<p>-tile-radius)`, `bg-(--<p>-tile-hover)` only under the `Slot` wrapper; a kit rendered outside `[data-direction]` collapses.
3. The motion block stays untouched. The last rules of the generated `tokens.css` are the V1 MVP block (precedent `d5-v1-mvp/tokens.css:68-97`, under the comment at 67: the ungated rest rule (68-70), the one curve `--<p>-hover-ease: 300ms cubic-bezier(0.3, 0, 0.1, 1)` (80-82), the double-gated transition and the three hover transforms (84-97)). Do not change the curve, `-6px`, `+ 5deg`, `- 5deg`, the gates or the selectors: `motion-contract.test.ts` and `check` fail on any of it, and a direction that wants a different rise or fan is a decision for Tonio, not a lab edit. The one permitted edit: when the direction's tiles are anchors rather than buttons, re-key the three hover transforms from `button:hover` to `.<p>-tile:hover` and put that class on the anchor; the test accepts either trigger. Run the checklist in `motion-spec.md` section 11 before committing the direction.
4. Fills fade through Tailwind, never through the block. A colour change rides `transition-colors` in the markup with `hover:bg-(--<p>-tile-hover) focus-visible:bg-(--<p>-tile-hover)` (precedent `d5-v1-mvp/Tile.tsx:53`); movement is `:hover` only, fills also fire on `:focus-visible`, shadows and strokes are static tokens. `tokens.css` may hold exactly one `transition:` and it is the block's.
5. Name it. Replace the placeholder tagline (`Tokens and spacing, same structure`, `New components, same layout`, `New layout`) in `registry.ts` and in the kit's `index.tsx`; `registry.test.ts` requires them equal and `check` warns on a placeholder, with or without `--strict`. To rename the direction (`d2-rework` to `d2-bold`) do it by hand, there is no subcommand yet: the directory, the registry `id` and `name`, `[data-direction="<id>"]` in `tokens.css`, `kit.id` and `kit.name` in `index.tsx`, the `--<p>-` prefix (`motion-contract.test.ts` derives it from the id, so it always follows), and the `name` and `args.direction` of the story's `D<N>` export. Story exports are digit-keyed, so the export itself keeps its name. If a later `add-direction` refuses because a registry line is no longer in the generator's form, restore that line's shape or apply the manual edit it prints.
6. Verify hover by hand, in a single-surface view, with the Animation toggle on. Neither the Board nor a `shot.cjs` still shows motion: Board tiles are `pointer-events-none` iframes, and the still's context asks for `reducedMotion: "reduce"` (precedent `shot.cjs:121-125`), which the block's `@media` gate honours; `--hover` (section 4) records one hovered frame, not the movement. So: `preview_start <slug>-directions-dev`, open `/?d=<id>&s=<surface>&theme=dark` (Animation defaults on), check `document.documentElement.dataset.labAnim === "1"` with `javascript_tool`, `zoom` on the first tile at rest, `hover` it, wait 0.4 s, `zoom` again and see the rise and fan; press `a`, hover again: the fill still steps, nothing moves. Repeat in light. Keep the browser pane displayed while you do this (`tabs_context` says whether it is); a hidden pane runs as a background page and transitions may not advance.
Done when, per direction:
- `rg -n 'data-lab-stub="<id>-' ui/src/<slug>-directions` prints nothing AND `rg -n "data-front" ui/src/<slug>-directions/directions/<id>` prints every body.
- `cd ui && npx vitest run src/<slug>-directions` passes (registry, lab-state, api-stub, motion-contract, today). One full `ui` vitest at a time; concurrent runs fail spuriously.
- Hover verified by hand in both themes; the tagline is the direction's own; `check --slug <slug>` passes.
Adding a direction later: `node .agents/skills/directions-lab/scripts/scaffold.mjs add-direction --slug <slug> --name <Name>` appends the next digit with its motion block and specimen, its registry line and the story export `D<N>`, then runs `check` and rolls all of it back on a failure (it refuses a fourth without `--force-count`); commit it, then repeat this phase for it. `list` prints the labs on this checkout. A surface that only some directions draw is not scaffolded in this version; the pattern lives on the parked branch (`lab-anatomy.md` section 9), read it before adding one by hand.
## 4. Capture
With the dev server running:
```sh
npx playwright install chromium
node ui/src/<slug>-directions/shot.cjs --port <dev> --d all --out <scratchpad>/<slug>-shots
node ui/src/<slug>-directions/shot.cjs --port <dev> --d <id> --s <surface> --theme dark,light --out <scratchpad>/<slug>-shots --hover
```
Flags: `--port` (required; it never starts the server), `--d <kit|all>` (default `today`), `--s` comma-separated surfaces plus `board`, `--theme dark,light`, `--out` (required), `--hover`. Stills load `chrome=0` at 1440x900, DPR 2, reduced motion, and write `<kit>__<surface>__<theme>.png`. `--hover` opens a second context without reduced motion, hovers the first `button` inside `[data-direction]`, waits 350 ms and writes `<kit>__<surface>__<theme>__hover.png` beside the still; if your tiles are anchors, change that selector in the lab's `shot.cjs`. `board` is a contact sheet composed from the PNGs already in `--out` (precedent `shot.cjs:162-200`), because the in-page Board is one live iframe per cell and never settles on a dev server. Any console error, page error, failed request or HTTP status of 400 or more fails the run after writing everything (precedent `shot.cjs:130-141`); the generated script has no excuse list, so a warning from a shipped component is yours to report, not to excuse (the precedent's `shot.cjs:210-213` shows the one it excused and why). "page did not expose window.__LAB__" means `main.tsx` did not run; read the console. PNGs (rest and hover pairs, both themes) go into the PR description, never into the repo.
## 5. Hand-off
```sh
node .agents/skills/directions-lab/scripts/scaffold.mjs check --slug <slug> --strict --run
```
`--strict` fails on any remaining `data-lab-stub` (the placeholder-tagline warning comes from `check` itself); `--run` executes the hand-off list of `repo-gates.md` section 8 in that order up to the ui typecheck, with a lab Vite build inserted before it, and prints a ready-to-paste `## Verification` block whose `Not run:` lines name `pnpm build-storybook` and the repo-wide typecheck, which it never runs (fresh worktree: `pnpm install --frozen-lockfile` and a built `@paperclipai/plugin-sdk` first; repo-wide typecheck needs cargo). Then the PR per `repo-gates.md` sections 4 and 5: scaffold commit first, redraw commits after; every template section filled; path B labels or a linked issue; the story ids, the two launch entries and, if deployed, the alias listed; the DESIGN.md directory row offered as text and never applied. Lifecycle moves (adopted behind a flag, current, historical) are later PRs, by hand, per `lifecycle.md`.
@@ -0,0 +1,303 @@
# Generator: how `scaffold.mjs` renders a lab
For maintainers of the skill. The agent using the skill reads `SKILL.md`; this file explains the machinery
under `scripts/` and `assets/`, how it was derived from the artifact lab, and how to keep it in step. Every
rule the generator enforces is stated here and nowhere in code: the generator's own JavaScript carries no
comments, for the same repo rule it enforces on the files it writes (`CONTRIBUTING.md`: no code comments).
## Contents
1. Layout
2. Running it
3. The context object (`ctx`)
4. Templates
5. The generated lab, file by file, and the precedent each file came from
6. The motion asset and the drift test
7. Verification, rollback and the shared files
8. `check`, `add-direction`, `list`
9. Tests: `scaffold.test.mjs`
10. Adding or changing a template
11. Re-deriving after the precedent changes
12. Known limits of this version
## 1. Layout
```
.agents/skills/directions-lab/
assets/
motion/v1-mvp-motion.css the V1 MVP hover block with <id> and <p> placeholders (section 6)
templates/ JS modules, `export default (ctx, ...) => string` (section 4)
scripts/
scaffold.mjs CLI: new, check, add-direction, list
scaffold.test.mjs node --test, runs in under a second
lib/args.mjs flag grammar per subcommand (--k v, --k=v, booleans), UsageError
lib/context.mjs flags -> ctx: casings, ids, surfaces, directions, landing, company, provenance
lib/ports.mjs first free (dev, preview) pair from 5200/5210 in steps of 20
lib/capabilities.mjs validates --capabilities through <root>/scripts/feature-map.mjs
lib/render.mjs loads the templates and produces Map<repo-relative path, content>
lib/write.mjs refuses comments, 600-line files and existing paths; writes; rolls back
lib/shared-files.mjs idempotent .claude/launch.json and .gitignore edits with originals kept
lib/registry-edit.mjs reads and edits the generator-owned lines of registry.ts and the story
lib/modes.mjs mode A/B detection from scripts/storybook-catalog.mjs
lib/verify.mjs runs the verification commands and formats a failure
lib/check.mjs the `check` rules
references/generator.md this file
```
The skill directory is git-ignored (`/.agents/` in `.gitignore`), so every file is added with `git add -f`.
## 2. Running it
From the repo root (`node .agents/skills/directions-lab/scripts/scaffold.mjs ...`):
| Subcommand | Flags | What it does |
| --- | --- | --- |
| `new` | `--slug`, `--title`, `--surfaces id:Label:width@nav,...`, `--directions 2\|3`, `--names a,b[,c]`, `--group`, `--capabilities a/k,...`, `--company name:PREFIX`, `--landing auto\|all\|today\|<id>`, `--ports dev,preview`, `--force-count`, `--dry-run`, `--json`, `--skip-verify`, `--no-build`, `--keep-on-failure`, `--root <dir>` | renders in memory, refuses existing paths, writes, edits the two shared files, verifies, rolls back on failure |
| `check` | `--slug`, `--strict`, `--run`, `--json`, `--skip-verify`, `--root` | the lab's structural rules plus the motion-contract test; `--run` executes the hand-off list |
| `add-direction` | `--slug`, `--name`, `--force-count`, `--json`, `--skip-verify`, `--root` | next digit; one directory from the direction templates; one registry line; one story export; re-runs `check` |
| `list` | `--json`, `--root` | every `ui/src/*-directions/registry.ts`, with launch.json ports |
Exit codes: 0 on success, 1 on any failure (the failing command's output is printed); after a flag-grammar error
(unknown subcommand or flag, missing value) the help text is printed too, after any other usage error only the
message. `--root` points every path at another directory; it exists so the tests can
scaffold into a temporary tree. Verification needs the real repo (`ui/node_modules`), so a scaffold under a
temporary root uses `--skip-verify`.
Defaults: `--title` is the slug in sentence case (`agent-profile-card` -> `Agent profile card`); `--group Components`; `--names` are Refine, Rework, Rethink
(two directions: Refine, Rethink); `--company acmehats:ACM`; `--landing auto`; `--ports` from `ports.mjs`;
`--capabilities` empty, which makes the story carry `capabilities: []` and the exception sentence (the
generator says so on stderr). Four to nine directions need `--force-count` and explicit `--names`.
## 3. The context object (`ctx`)
`context.mjs` derives everything once; templates never compute names. Built by `buildContext(flags, { root,
ports, capabilities })` and, for `add-direction`, by `assembleContext` from a parsed registry.
| Field | Example (`--slug inbox-row`) | Used by |
| --- | --- | --- |
| `slug`, `SLUG` | `inbox-row`, `INBOX_ROW` | paths, env names, the vite define |
| `title`, `group` | `Inbox row`, `Components` | toolbar labels, the story title |
| `labDir`, `entry`, `configFile`, `storyFile` | `ui/src/inbox-row-directions`, `inbox-row-directions.html`, `vite.inbox-row-directions.config.mjs`, `ui/storybook/stories/design-proposals/inbox-row-directions.stories.tsx` | render targets |
| `storyId` | `design-inbox-row-directions` | story meta id, registry, toolbar tooltip |
| `envOutDir`, `envPort`, `buildDefine` | `INBOX_ROW_DIRECTIONS_OUT_DIR`, `INBOX_ROW_DIRECTIONS_PORT`, `__INBOX_ROW_DIRECTIONS_BUILD_COMMIT__` | vite config, launch entries, `lab-env.d.ts`, `build-info.ts` |
| `distDir`, `staticDir` | `ui/dist-inbox-row-directions/`, `node_modules/.inbox-row-directions-dist` | .gitignore line, the preview launch entry |
| `ports` | `{ dev: 5220, preview: 5230 }` | config default port, launch entries |
| `directions[]` | `{ id: "d1-refine", key: "1", digit: 1, name: "1.Refine", label: "Refine", tagline, prefix: "refine", stage: "proposed" }` | registry, kits, tokens, story exports |
| `today` | `{ id: "today", key: "0", name: "0.Today", tagline: "What ships now", baseline: true, stage: "shipped" }` | registry (last), Today kit |
| `kits` | `[...directions, today]` | registry order (Today last) |
| `surfaces[]` | `{ id: "inbox", label: "Inbox", width: 960, nav: "other", Pascal: "Inbox", UPPER: "INBOX", fixture: "INBOX_ITEMS", body: "InboxBody", frame: "InboxFrame" }` | kit contract, frames, fixtures, bodies |
| `multiSurface`, `cells` | `false`, `3` | toolbar row 2, landing rule |
| `landing` | `{ direction: "all", view: "board" }` | registry; `d=all&s=board` when `cells <= 8`, else `d=all&s=<first surface>` |
| `capabilities`, `exception`, `notes` | `[]`, the two house sentences | story paperclip block, registry |
| `company`, `tasks` | `{ id: "company-acmehats", name: "acmehats", issuePrefix: "ACM" }`, three tasks | registry shell, fixtures, stub session |
| `tag` | `[inbox-row-directions]` | the stub's `console.debug` line |
| `publicUrl`, `vercelProject` | `https://paperclip-inbox-row-directions.vercel.app` | registry, story description |
| `forceCount` | `false` | `allowMore: true` in the registry |
| `provenance` | `{ runtimeCommit: <git short sha or "unknown">, skillVersion: "1", createdAt: "YYYY-MM-DD" }` | registry |
`<p>` (the token prefix) is the direction's kebab-cased name, so `--names Bold` gives `d1-bold` and
`--bold-*`. The motion-contract test derives the prefix from the id (`id.replace(/^d\d+-/, "")`), so a
renamed directory keeps its tokens consistent only if the prefix is renamed with it.
## 4. Templates
Every file under `assets/templates/` is an ES module whose default export returns the file's text:
- lab-level: `(ctx) => string` (`main.tsx.mjs`, `kit.ts.mjs`, `registry.ts.mjs`, `stories/compare.stories.tsx.mjs`, ...)
- per surface: `(ctx, surface) => string` (`frames/SurfaceFrame.tsx.mjs`, `today/SlotBody.tsx.mjs`)
- per direction: `(ctx, direction) => string` (`direction/index.tsx.mjs`, `direction/tokens.css.mjs`, `direction/MotionSpecimen.tsx.mjs`)
- per direction and surface: `(ctx, direction, surface) => string` (`direction/SlotBody.tsx.mjs`)
- `launch-entries.json.mjs` returns the two launch objects and exports `launchNames(ctx)`
- `build-info.ts.mjs` also exports `envDts(ctx)` for `lab-env.d.ts`
- `direction/tokens.css.mjs` also exports `motionBlock(direction)`, the asset with placeholders substituted
`render.mjs` owns the mapping from template to target path (`LAB_FILES` for one-per-lab files, loops for
per-surface and per-direction files) and returns a `Map`. Templates are ordinary template literals, so a
backtick or `${` that must reach the output is written `\`` and `\${`; loops over surfaces and directions
are plain `.map().join("\n")`. A template never reads the repo: everything it needs is in `ctx`.
Conventions the templates follow, and `write.mjs` enforces on the result:
- no line starts with `//`, `/*`, `{/*` or `<!--`; the one exception is a `// @vitest-environment <env>` pragma
on line 1 of a test (`jsdom` in `today.test.tsx` and `api-stub.test.ts`), which vitest requires because
`ui/vitest.config.ts` sets `environment: "node"`
- no file reaches 600 lines (the repo's hard rule is 1,000; 600 is the house limit for a lab file)
- no `package.json` script or dependency; the lab is invoked through its own vite config
- strings that the catalog must read as literals (the story's `paperclip` block) are emitted with
`JSON.stringify`, never as template interpolation inside the generated file
## 5. The generated lab, file by file
Precedent: `ui/src/artifact-directions` (`lab-anatomy.md`, `today-fixtures-and-build.md`). Each generic
file was read from the precedent and parameterised; comments were removed; the registry replaced the
hand-maintained literals (`DirectionId`, `DIRECTION_IDS`, `KIT_KEYS`, `KIT_META`, `SURFACE_IDS`,
`VIEW_OPTIONS`, the Board's columns, `shot.cjs`'s `KITS`/`SURFACES`/`KIT_NAMES`).
| Generated | From | What changed |
| --- | --- | --- |
| `ui/<slug>-directions.html` | `ui/artifact-directions.html` | title; script path; the unlayered html/body style block kept verbatim, comment removed |
| `ui/vite.<slug>-directions.config.mjs` | `ui/vite.artifact-directions.config.mjs` | slug in ENTRY, env names, plugin names, cacheDir, watch paths; `define` with the per-lab build commit (`git rev-parse --short HEAD` in try/catch, `"unknown"` fallback) |
| `.claude/launch.json` entries | entries 27-44 | names, config file, ports, env var, static dir |
| `main.tsx` | `main.tsx` | KindsSheet removed; providers moved to `providers.tsx`; `window.__LAB__`; `proposal=1` wraps the page in `AvatarInitialProvider` + `.design-proposal-theme`; `d=all` always renders the Board |
| `providers.tsx` | `main.tsx:182-198` | the provider stack as a component so `today.test.tsx` can reuse it |
| `lab.css` | `lab.css` | verbatim rule |
| `define-lab.ts` | new | registry types and `defineLab()` validation (ids, digit keys, one baseline last, 2-3 explorations unless `allowMore`, `d<N>-<name>` ids keyed `N`, 1-6 surfaces, landing valid) |
| `registry.ts` | `kit.ts:16,130-150` + `SurfaceView.tsx:9-11` | pure data, the single source |
| `kit.ts` | `kit.ts:99-163` | `LabRecord` placeholder, `SurfaceData` (one member per surface, extends `Record<SurfaceId, unknown>` so a missing member fails tsc), `DirectionKit` with one `<Pascal>Body` per surface, ids and maps derived from the registry, the literal `import.meta.glob` |
| `lab-state.ts` | `lab-state.ts` | landing from the registry; `proposal` param; `landingSearch()` |
| `api-stub.ts` | `api-stub.ts` | verbatim minus comments; the debug tag and the stub session's e-mail follow the company |
| `api-routes.ts` | `today/panel-api.ts` pattern | the experimental-settings route answering `registry.today.flags`; an empty `registerFeatureRoutes()` for Phase 2 |
| `fixtures.ts` | `fixtures.ts` | `COMPANY`/`TASKS` from the registry shell, `LAB_NOW`, one `<UPPER>_ITEMS` export per surface with three placeholder records pinned to one week |
| `build-info.ts`, `lab-env.d.ts` | new | `LAB_BUILD` from the vite define (`"dev"` when undefined, so vitest never sees a ReferenceError) |
| `frames/Slot.tsx` | `frames/Slot.tsx` | verbatim |
| `frames/LabToolbar.tsx` | `frames/LabToolbar.tsx` | options from the registry; the label is a link to the landing with the `story <id> · build <sha>` tooltip; row 2 rendered only when `surfaces.length > 1`, otherwise the legend joins row 1; the `0–N` legend (`LEGEND`, the largest digit in `KIT_KEYS`), the Overview hint and the surface options are derived at run time, so `add-direction` and a hand-added surface need no toolbar edit; stage hint for adopted/current |
| `frames/Board.tsx` | `frames/Board.tsx` | `Tile` with the `proposal` prop passed through to its iframe links and `view` narrowed to `SurfaceId`; `KitBoard` columns = surfaces; `AllBoard` rows = directions and columns = every surface (`s=board`) or one (`s=<surface>`) |
| `frames/AppShell.tsx` | `frames/AppShell.tsx` | company and recent tasks from the registry; `active: ShellNav` with `other` |
| `frames/SurfaceView.tsx` | `frames/SurfaceView.tsx` | a `FRAMES` record, one entry per surface |
| `frames/<Pascal>Frame.tsx` | `ThreadFrame.tsx`/`PanelFrame.tsx` shape | a stub: shell, top bar, a dashed region frozen at the registry width marked `data-lab-stub="frame-<id>"` around `<Slot><kit.<Pascal>Body items={<UPPER>_ITEMS} /></Slot>` |
| `directions/today/*` | `directions/today/` | `index.tsx` with literal id/name/tagline; `adapters.ts` with `guid`, `agentId`, `issueIdFor` verbatim plus `issueHref`; one `<Pascal>Body.tsx` stub rendering the items in a dashed box `data-lab-stub="today-<id>"`; no tokens.css |
| `directions/d<N>-<name>/*` | `d5-v1-mvp/` | `index.tsx` importing `./tokens.css`; `tokens.css` (section 6); `MotionSpecimen.tsx` (a `<button>` with two `data-rotate data-spread` backings at inline `-14deg`/`-5deg`, geometry-derived `transform-origin`, static shadow filter, one `[data-front]` sheet, `transition-colors` fills); one `<Pascal>Body.tsx` per surface rendering a specimen per item inside `data-lab-stub="<id>-<surface>"` |
| `shot.cjs` | `shot.cjs` | reads `window.__LAB__` on a first `chrome=0` load instead of mirrored constants (fails with "page did not expose window.__LAB__; main.tsx did not run, read the console"); `--hover` writes `<kit>__<surface>__<theme>__hover.png` from a context without reduced motion; the board sheet is `150px repeat(<surfaces>, minmax(0,1fr))`; no excuse list |
| tests | `lab-state.test.ts`, `api-stub.test.ts`, `Thread.test.tsx` | `registry.test.ts`, `lab-state.test.ts`, `motion-contract.test.ts`, `today.test.tsx`, `api-stub.test.ts` (section 7 of `repo-gates.md` for the conventions) |
| the story | `artifact-directions-compare.stories.tsx` | `installApiStub` in a story-scoped effect with restore; `loadKit`; `SurfaceView`; the `animation` arg sets and restores `data-lab-anim`; `argTypes` and `args` precede `parameters` so `paperclip` is the last parameter; exports `Compare`, `Today`, `D1..DN`, `TodayProductionPalette` |
## 6. The motion asset and the drift test
`assets/motion/v1-mvp-motion.css` is `ui/src/artifact-directions/directions/d5-v1-mvp/tokens.css` lines 68-97 (67 is the comment above the block)
with the comments removed and `d5-v1-mvp` -> `<id>`, `--v1-` -> `--<p>-`. It is the skill's only copy of the
block. `direction/tokens.css.mjs` appends it, substituted, after the direction's light tokens
(`[data-direction="<id>"]`) and dark overrides (`.dark [data-direction="<id>"]`, the `index.css` idiom; d5's
`html:not(.dark)` is the copy hazard named in `lab-anatomy.md`). `add-direction` uses the same template, so
every exploration carries the block; Today never does.
Two tests pin it:
- `scaffold.test.mjs` "motion asset": reads the d5 file, strips `/* */` comments, cuts from the
`[data-rotate]` rest rule to the end of the `@media (prefers-reduced-motion: no-preference)` block,
normalises whitespace, substitutes, and asserts equality with the normalised asset. A tune of d5 turns
this red before any scaffold.
- the generated `motion-contract.test.ts` (node environment, `fs.readFileSync`, never `?raw`, which this
repo's vitest css pipeline swallows): per exploration, the curve declared once under its scope and no
other `cubic-bezier(` or `\d+ms`; exactly one `transition:` and it is `transform var(--<p>-hover-ease)` on
`:is([data-rotate][data-spread], [data-front])`; four gated rules, each starting
`html[data-lab-anim="1"] [data-direction="<id>"]`, no `data-lab-anim` outside the gate; hover targets
`[data-front]` (`translateY(-6px)`), `[data-spread="left"]` (`+ 5deg`), `[data-spread="right"]`
(`- 5deg`) keyed on `button:hover` or `.<p>-tile:hover`; the rest rule outside both gates; no transition
on filter, box-shadow, opacity or scale; the direction's `.tsx` sources carry `data-front`, `data-rotate`,
`data-spread` and `--<p>-base`. The hook assertion reads every `.tsx` in the direction directory together,
so a drawing may keep the hooks in a component rather than in `<Pascal>Body.tsx` itself.
## 7. Verification, rollback and the shared files
`new` is transactional:
1. `validateCapabilities` resolves `--capabilities` through `featureIndex(root)`; `pickPorts` chooses the
pair; `buildContext` validates every other flag.
2. `renderLab` renders everything in memory; `checkRendered` refuses comment lines, files of 600 lines or
more, and any target path that exists (so a half-written lab never overwrites a drawn one).
3. `writeFiles` creates directories and files, journaling what it created; `addLaunchEntries` and
`addGitignoreLine` edit the two shared files and keep their originals in memory. launch.json is parsed,
appended by name (an entry with the same name is replaced) and written back with
`JSON.stringify(..., null, 2)`, which reproduces the tracked file byte for byte; the gitignore line goes
after `dist-flow-preview/` when that line exists, else at the end.
4. Unless `--skip-verify`: `node scripts/storybook-catalog.mjs --check` from the root, then
`npx vitest run src/<slug>-directions` from `ui/`, then (unless `--no-build`)
`<SLUG>_DIRECTIONS_OUT_DIR=node_modules/.<slug>-directions-dist npx vite build --config
vite.<slug>-directions.config.mjs` from `ui/`. The first failure stops the sequence.
5. On failure the failing command's output is printed, then `rollback` unlinks every written file and
removes every directory it created, `restoreShared` writes the originals back, the build output and the
lab's vite cache directory are removed, and the exit code is 1. `--keep-on-failure` leaves everything in
place and prints the removal command instead.
`tsc` is not part of `new`'s verification: `ui/tsconfig.json` includes all of `src` and `storybook`, so a
lab-only typecheck needs a temporary tsconfig (`extends ./tsconfig.json`, `include` the lab directory, its
story and `src/vite-env.d.ts`); `check --run` runs the ui package's `pnpm typecheck` (from `ui/`) instead.
## 8. `check`, `add-direction`, `list`
`check --slug <slug>` reads the registry (`registry-edit.mjs` recognises the generator-owned lines by exact
text and fails loudly when a hand edit changed their shape) and verifies: `directions/` holds exactly the
registry ids and each `index.tsx` carries the registry's id and name; keys are unique digits with one
baseline; every exploration has a tokens.css and Today has none; no comment lines and no file of 600 lines;
none of the artifact lab's names (`DENY_LIST` in `check.mjs`: `artifact-directions`, `LabArtifact`,
`GalleryFrame`, `d5-v1-mvp`, `--v1-`, ...) appear unless listed in `registry.allow`; `<slug>-directions`
appears in no code file under `ui/src`, `ui/storybook` or at the top of `ui/` outside the lab, its html, its
config, its story, launch.json and .gitignore;
no `.css` or `.html` file outside `directions/<id>/tokens.css` carries a `data-lab-anim` rule;
the story's meta id equals `registry.storyId`, the stable exports exist, the `paperclip` parameter comes
last and its status matches the detected mode and the lab's stage (`modes.mjs`: `labStage` is `proposed`
while any exploration is `proposed` or `adopted`, `historical` when every exploration is `historical`,
else `current`; `expectedStoryStatus` maps it to `proposed` in mode A and to itself in mode B; a
`historical` story must also carry `replacement: "<repo-relative .stories.tsx>"` naming an existing
file, and a `replacement:` line in another story never counts as naming `<slug>-directions` outside the
lab); the launch entries and the gitignore line exist. It then
runs `npx vitest run src/<slug>-directions/motion-contract.test.ts`. `--strict` fails on any
`data-lab-stub`; placeholder taglines are warned about on every run. `--run` adds the hand-off list in order (lab vitest,
catalog check, token gates, lab build, the ui `pnpm typecheck`) and prints a `## Verification` block with `Not run:`
lines for `pnpm build-storybook` and the repo-wide typecheck (`NOT_RUN` in `check.mjs`); `--json` carries the
same list as `notRun` beside `ok`, `errors`, `warnings` and `verification`.
`add-direction --slug <slug> --name <Name>` computes the next digit, refuses a fourth without
`--force-count` (and sets `allowMore: true` in the registry when forced), renders the direction templates
through `assembleContext` built from the parsed registry, inserts the registry line before the Today entry
and the story export before `TodayProductionPalette`, then runs `check`; a failing check rolls the
direction back.
`list` prints every lab found with its directions, surfaces, launch ports and story id.
## 9. Tests: `scaffold.test.mjs`
`node --test .agents/skills/directions-lab/scripts/scaffold.test.mjs` (about half a second). It covers flag
parsing, context derivation (casings, landing rule, refusals of every invalid input, `--force-count`),
ports, the write guards (the pragma exception, the 600-line limit, existing paths), the shared-file edits
(idempotence, the anchor), mode detection, the motion asset (drift test, substitution), and then scaffolds
the three eval shapes (`composer` with two surfaces and three directions, `inbox-row` with one surface and
two, `agent-profile-card` with one surface and three) into a temporary `--root` with `--skip-verify` to
assert the file set, the surfaces-row rule, the motion block at the end of every exploration tokens.css,
the specimen hooks and stub markers, no comments and no 600-line file, the story's literal block and
exports, the launch entries and gitignore line, the refusal to overwrite, `list`, `check`, `add-direction`
(including the forced fourth), `--strict`, the `data-lab-anim` placement rule and, after switching the
temporary root to mode B, the Compare status for every lab stage (proposed, current, historical with and
without `replacement`) with `notRun` in the JSON. Two cases run against the real repo without writing:
capability validation through the feature map (`--dry-run`) and an in-memory render of every template.
## 10. Adding or changing a template
1. Read the precedent file the template mirrors (section 5) and the reference that cites it.
2. Edit or add the module under `assets/templates/`; register a new one-per-lab file in `LAB_FILES` in
`render.mjs`, or add the loop for a per-surface or per-direction file next to the existing ones.
3. Keep the output comment-free and under 600 lines; `checkRendered` refuses otherwise and the test
"writes no comments and no file of 600 lines or more" fails first.
4. Run `node --test .agents/skills/directions-lab/scripts/scaffold.test.mjs`, then prove a real scaffold:
`scaffold.mjs new --slug labproof --title Labproof --surfaces thread:Task-thread:960@tasks,panel:Right-panel:434@tasks --directions 3`
without `--skip-verify`, read any failure, fix the template, repeat until it exits 0; typecheck the lab
with a temporary tsconfig; then remove the proof lab (the lab directory, its html, its config, its story,
the two launch entries, the gitignore line, `ui/node_modules/.labproof-directions-dist` and
`ui/node_modules/.vite-labproof-directions-*`) and confirm with `git status --short`.
5. Update section 5 of this file when a template's relationship to the precedent changes.
## 11. Re-deriving after the precedent changes
The artifact lab stays on master as the frozen reference, so a change there is deliberate and rare. When one
lands: diff the precedent file against the template's output for a scaffolded lab (`git diff --no-index`),
carry the change into the template if it is generic (toolbar, Board, api-stub, main bootstrap, config
options, shot.cjs flags), leave it if it is artifact-specific, and update the line numbers cited in
`lab-anatomy.md` and `today-fixtures-and-build.md`. For the motion block, update `assets/motion/v1-mvp-motion.css`
only after the design decision is taken (the block is never tuned per lab), then run the drift test.
## 12. Known limits of this version
- No shared runtime under `ui/src/directions-lab`: every lab carries its own copy of the generic files, as
the precedent does. A fix to a generic file reaches existing labs only by hand or by re-scaffolding.
- Subcommands are `new`, `check`, `add-direction`, `list`. `add-surface`, `rename-direction`,
`direction-story`, `stage`, `diff`, `smoke`, `deploy.sh` and `--answers` are not implemented; a new surface,
a rename or a stage change is a hand edit of `registry.ts`, the `directions/` tree and the story, confirmed
with `check`.
- `add-direction` edits the registry and the story by exact-text recognition; a hand-reformatted registry
(for example by Prettier changing the one-line entries) makes it refuse, quoting the line it could not read;
only a missing baseline line or a missing `TodayProductionPalette` anchor prints the line to add by hand.
- `check` does not cross-check stage against story sentences beyond the status, and never reads `DESIGN.md`.
- The Board story (`InlineBoard`) is not emitted; the Storybook entry has `Compare`, `Today`, `D1..DN` and
`TodayProductionPalette`; a board export is a hand-written story in the same file.
- Ports are chosen from launch.json, the reserved list and the vite cache directories, not by probing
sockets.
@@ -0,0 +1,351 @@
# Lab anatomy
How a directions lab page is put together, with the artifact lab on master
(`ui/src/artifact-directions/`, merged as def4726be / PR #17213) as the worked
example. Every line number below was confirmed against this checkout.
Markers: **GENERIC** = copy and rename for any feature. **ARTIFACT-SPECIFIC** =
belongs to the artifact lab; redraw it per feature.
## Contents
1. Files and the page entry
2. State lives in the URL
3. Bootstrap: API stub, theme, animation
4. The DirectionKit contract
5. Kit discovery and the "how many directions" parameters
6. Toolbar
7. Slot scoping and theme idioms
8. Frames and surfaces
9. Parked-branch extras (not on master)
10. Capture flags
## 1. Files and the page entry
| Path (under `ui/` unless absolute) | Marker | Role |
|---|---|---|
| `artifact-directions.html` | GENERIC, rename | MPA entry, 38 lines |
| `vite.artifact-directions.config.mjs` | GENERIC, rename | lab-only Vite config, 129 lines |
| `src/artifact-directions/main.tsx` | GENERIC | bootstrap, `Lab`, providers |
| `lab-state.ts`, `lab-state.test.ts` | GENERIC; defaults are parameters | URL state |
| `kit.ts` | contract ARTIFACT-SPECIFIC; registry GENERIC in shape | `DirectionKit`, ids, loader |
| `lab.css` | GENERIC | `:root { --font-mono: var(--font-paper-mono); }` (8-10) |
| `api-stub.ts` | GENERIC; routes ARTIFACT-SPECIFIC | answers every `/api/` fetch |
| `fixtures.ts`, `fixture-documents.ts`, `assets/` | ARTIFACT-SPECIFIC | data the frames feed the slots |
| `frames/Slot.tsx`, `LabToolbar.tsx`, `Board.tsx` | GENERIC | scoping, lab chrome, overview |
| `frames/SurfaceView.tsx` | GENERIC shape, ARTIFACT-SPECIFIC content | `s=` to frame |
| `frames/AppShell.tsx` | GENERIC pattern with hard-coded data | sidebar + main column |
| `frames/ThreadFrame.tsx`, `PanelFrame.tsx`, `GalleryFrame.tsx`, `thread-parts.tsx`, `KindsSheet.tsx` | ARTIFACT-SPECIFIC | static mocks of product screens |
| `directions/<id>/index.tsx` | ARTIFACT-SPECIFIC | one kit per directory |
| `shot.cjs` | GENERIC; its `KITS`/`SURFACES` mirrors are per lab | Playwright capture |
| `/.claude/launch.json:27-44` | GENERIC, rename and pick free ports | dev :5180, build + preview :5190 |
Entry (`artifact-directions.html`): `<html>` is pre-set dark so the default theme never flashes (line 2). The inline `<style>` (9-32) is unlayered on purpose: `index.css` pins `body { height: 100%; overflow: hidden }` inside `@layer base` (index.css:717, 747-757), which would stop the page growing to the height of its frames, and only `<html>` may scroll, because a scrolling `<body>` becomes the sticky toolbar's scrollport. `#root` is line 35, the module script line 36. Line 2 is `<html lang="en" class="dark" style="color-scheme: dark">`.
```css
html {
height: auto;
min-height: 100%;
overflow-y: auto;
}
body {
height: auto;
min-height: 100%;
overflow: visible;
}
```
Vite config (`vite.artifact-directions.config.mjs`): `ENTRY` and `DEFAULT_PORT` (40-41); `PORT = portFromArgv() || Number(process.env.ARTIFACT_DIRECTIONS_PORT) || DEFAULT_PORT` (44-53; argv because a config cannot see resolved CLI options); `OUT_DIR` from `ARTIFACT_DIRECTIONS_OUT_DIR` else `dist-artifact-directions` (54-56, not git-ignored: build into `node_modules/` as launch.json:41 does, or add the directory to `.gitignore`); a polling watcher limited to the lab's paths (66-77, macOS FSEvents exhaustion otherwise kills HMR silently); `entryAsRoot`, a `configureServer` middleware that rewrites `/` and `/index.html` to `/${ENTRY}` keeping the query (79-90); `htmlAsIndex`, which renames the built entry to `index.html` in `closeBundle` (96-103). Three things are load-bearing: no `server.proxy`, `appType: "mpa"` plus the root rewrite, and a cache directory per port (28-37). Rename `ENTRY`, the env vars, the `cacheDir` prefix and the plugin names.
```js
export default defineConfig({
appType: "mpa",
plugins: [react(), tailwindcss(), entryAsRoot, htmlAsIndex],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
lexical: path.resolve(__dirname, "./node_modules/lexical/dist/Lexical.mjs"),
},
},
cacheDir: path.resolve(__dirname, `node_modules/.vite-artifact-directions-${PORT}`),
server: {
host: "127.0.0.1",
port: PORT,
strictPort: true,
watch: { usePolling: true, interval: 250, ignored: outsideLab },
},
build: {
outDir: OUT_DIR,
emptyOutDir: true,
minify: "esbuild",
rollupOptions: {
input: path.resolve(__dirname, ENTRY),
},
},
});
```
## 2. State lives in the URL
Everything the toolbar can change is in `window.location.search`; React state only mirrors it. Schema (lab-state.ts:8): `?d=<DirectionId|all>&s=<thread|panel|gallery|stack|kinds|board>&theme=<dark|light>&chrome=<1|0>&anim=<1|0>`.
| Param | Allowed | Default | Notes |
|---|---|---|---|
| `d` | any `DIRECTION_IDS` entry, `all` | `d5-v1-mvp` with no `s`; `today` when `s` is set | `all` is the every-kit contact sheet, URL-only since 2026-10-21 |
| `s` | `LAB_VIEWS` = `[...SURFACE_IDS, "kinds", "board"]` | `board` with no `d` or `d=all`; `thread` for any other `d` | `kinds`, the vocabulary sheet, is URL-only |
| `theme` | `dark`, `light` | `dark` | anything else reads as dark |
| `chrome` | `1`, `0` | `1` | `0` hides the LabToolbar and nothing else (main.tsx:168) |
| `anim` | `1`, `0` | `1` | written only when off |
GENERIC: the types (18-29), validators built from the id arrays (31-33), a reader with defaults, a short-form serializer, and `kitIdOf` (57: `all` renders as `today` in any non-board view). The landing kit `"d5-v1-mvp"` and the asymmetric defaults are this lab's decision; set yours at lines 41-42. A generated lab reads them from `registry.landing` (`--landing`) and `SURFACE_IDS[0]`. A generated lab also reads and writes a sixth param, `proposal` (written only when `1`): it wraps the page in `AvatarInitialProvider` + `.design-proposal-theme` and has no toolbar control; `today-fixtures-and-build.md` §12 and `generator.md` give the generated form.
```ts
export function readLabState(search: string): LabState {
const params = new URLSearchParams(search);
const d = params.get("d");
const s = params.get("s");
return {
// A bare URL opens V1 MVP's overview: it is what the shared link should land on.
direction: d === "all" ? "all" : isDirection(d) ? d : s ? "today" : "d5-v1-mvp",
view: isView(s) ? s : d && d !== "all" ? "thread" : "board",
theme: params.get("theme") === "light" ? "light" : "dark",
chrome: params.get("chrome") !== "0",
anim: params.get("anim") !== "0",
};
}
export function labSearch(state: LabState): string {
const params = new URLSearchParams({ d: state.direction, s: state.view, theme: state.theme });
if (!state.chrome) params.set("chrome", "0");
if (!state.anim) params.set("anim", "0");
return `?${params.toString()}`;
}
```
Write-back: `change(patch)` in `Lab` (main.tsx:138-148) merges the patch and replaces the URL, after `keepScroll()` so the viewport stays put across a toggle (`useScrollKeeper`, 108-130: restore now, next frame, and at +200 ms). No navigation, no reload.
```ts
setState((current) => {
const next = { ...current, ...patch };
window.history.replaceState(null, "", labSearch(next));
return next;
});
```
`lab-state.test.ts` (54 lines, 10 tests) pins the defaults, the fallbacks for unknown `d`/`s`, the short form and round-trips. `new` writes one per lab against the registry's landing; run it with `cd ui && npx vitest run src/<slug>-directions/lab-state.test.ts`.
## 3. Bootstrap: API stub, theme, animation (`main.tsx`)
Module-load order (25-41): install the fetch stub, read the URL, apply theme and animation to `<html>` before React mounts.
```ts
installApiStub();
const initial = readLabState(window.location.search);
function applyTheme(theme: LabTheme) {
document.documentElement.classList.toggle("dark", theme === "dark");
document.documentElement.style.colorScheme = theme;
}
applyTheme(initial.theme);
function applyAnim(anim: boolean) {
if (anim) document.documentElement.dataset.labAnim = "1";
else delete document.documentElement.dataset.labAnim;
}
applyAnim(initial.anim);
```
- Theme: `.dark` on `<html>` is what `index.css`'s `.dark {}` block (591, top-level and unlayered) and Tailwind's `@custom-variant dark (&:is(.dark *))` (134) key on; `style.colorScheme` keeps native controls and scrollbars in step. `ThemeSync` (44-50) pushes the URL theme into the app's `ThemeProvider` so `useTheme()` consumers agree. The comment at main.tsx:29 cites `index.css:393`; the block is at 591. `ThemeProvider.setTheme` persists to the app's `paperclip.theme` localStorage key, harmless on the lab's own origin.
- Animation: `data-lab-anim="1"` on `<html>` is the only signal; kits opt in with CSS (section 7). Both toggles are re-applied in effects on state change (150-151).
- API stub (`api-stub.ts`): every `/api/` fetch is answered locally and never reaches the network. `registerApiRoute((pathname, method) => body | undefined)` adds routes (18-20); built-ins answer `*/auth/get-session` and `*/companies` (30-33); anything else returns `{}` with a `console.debug` (34-37), so a frame can look empty without an error. `installApiStub()` is reference-counted and returns a restore (50-71) for Storybook. The routes a Today kit needs are ARTIFACT-SPECIFIC (`directions/today/panel-api.ts`).
- Containment: `useKit` loads a kit lazily and keeps the previous one mounted while the next loads (82-98); `KitBoundary`, keyed by `shownKitId`, shows a render error in place (56-73, 174). Providers (182-198): `StrictMode > MemoryRouter > QueryClientProvider({ retry: false, refetchOnWindowFocus: false }) > ThemeProvider > CompanyProvider > TooltipProvider > Lab`. CSS imports are `../index.css` then `./lab.css` (8-9); lab.css wins because it is unlayered, not because of order.
## 4. The DirectionKit contract (`kit.ts:99-127`)
A direction is one directory `directions/<id>/` whose `index.tsx` exports `kit: DirectionKit`. A kit never renders chrome and never imports a frame (kit.ts:6-9), so a direction can be rewritten wholesale without touching a shared file.
- Metadata, GENERIC: `id: DirectionId`, `name`, `tagline` (100-102); `name`/`tagline` repeat `KIT_META`.
- Required slots, ARTIFACT-SPECIFIC: `ThreadWorkProduct: FC<{ artifact: LabArtifact }>`, `ThreadAttachments: FC<{ artifacts: LabArtifact[] }>`, `PanelBody: FC<{ artifacts: LabArtifact[] }>`, `GalleryStacks: FC<{ groups: LabGroup[] }>`, `GalleryStack: FC<{ group: LabGroup }>` (104-112). Each JSDoc names the surface and what the frame fixes around the slot ("the pane is 434px wide; the body has 16px of padding").
- Optional overrides, pattern GENERIC and names ARTIFACT-SPECIFIC: `GalleryView?: FC<{ groups; open }>` when a kit owns a whole region including chrome the frame normally mocks (119); `threadAttachmentsAfterFooter?: boolean` when a kit only moves where a slot renders (126). A frame checks `kit.X ? <kit.X /> : fallback` (GalleryFrame.tsx:103-113; ThreadFrame.tsx:119, 123).
Slots are typed per surface because a slot's props are the data that place receives in the product and the frame fixes that place's width and position. The frame mounts `<kit.PanelBody artifacts={…} />` without knowing the kit, TypeScript flags a kit that misses a surface, and the constraints of each place live in one JSDoc. For a new lab: one slot per place the feature appears, props = the fixture type that place receives, fixture types beside the contract in `kit.ts` (`LabArtifact`, `LabGroup`, `LabRun`, `LabKind` at 18-97 are ARTIFACT-SPECIFIC).
Kit module shape: `directions/today/index.tsx:1-17` is the production baseline (shipped components, no tokens.css); an exploration adds `import "./tokens.css"` (d1-solid/index.tsx:6, d5-v1-mvp/index.tsx:5) and the optional members (d5-v1-mvp/index.tsx:44, 46).
## 5. Kit discovery and the "how many directions" parameters
Kits are found by a literal glob and loaded on demand, so a half-written kit cannot take the others down (kit.ts:152-163). Keep the glob string literal; Vite needs a static pattern.
```ts
const KIT_LOADERS = import.meta.glob<{ kit: DirectionKit }>("./directions/*/index.tsx");
export function loadKit(id: DirectionId): Promise<DirectionKit> {
const load = KIT_LOADERS[`./directions/${id}/index.tsx`];
if (!load) return Promise.reject(new Error(`No kit at directions/${id}/index.tsx`));
return load().then((module) => module.kit);
}
```
Id convention: `today` for the shipped baseline, `d<N>-<slug>` for exploration N (`d1-solid` … `d5-v1-mvp`). Directory name = id = `DirectionId` member (16); `DIRECTION_IDS` is `["d1-solid", "d2-tint", "d3-tactile", "d4-hybrid", "d5-v1-mvp", "today"]` (130).
Adding or removing a direction touches exactly these (in `kit.ts` unless noted):
1. The `DirectionId` union (16).
2. `DIRECTION_IDS` (130): toolbar order, explorations in numeric order, **Today last** (comment 129).
3. `KIT_KEYS` (133-140): the digit in the label, `today: "0"`. One digit per kit, so at most ten kits.
4. `KIT_META` (143-150): `name` in the form `"N.Name"` (`"0.Today"`, `"1.Solid"`, `"5.MVP V1"`) plus a tagline; the toolbar label and hint come from here.
5. `directions/<id>/index.tsx` exporting `kit` with the same `id`, `name` and `tagline`.
6. The landing kit in `readLabState` (lab-state.ts:41) and the test expectations (lab-state.test.ts:6, 10, 14, 23-24, 35, 43, 51-52).
7. The toolbar footer hint `0–5 kit · t theme · a animation` (LabToolbar.tsx:100) and the Animation "on" hint, which names a kit (70).
8. `shot.cjs` mirrors `KITS` (22) and `KIT_NAMES` (154); CJS cannot import kit.ts. `KITS` lists Today first while `DIRECTION_IDS` puts it last, so choose the order deliberately. An unknown kit fails with `Unknown --d` (224).
In a generated lab 1–4 are one `registry.ts` line (`kit.ts` derives the ids and maps from it) and 8 falls away (`shot.cjs` reads `window.__LAB__`); `scaffold.mjs add-direction --slug <slug> --name <Name>` writes that line, the kit directory and the `D<N>` story export, then runs `check`. Still by hand: 6 (`registry.landing` and the lab's tests); 7 is derived at run time (`LEGEND` in `frames/LabToolbar.tsx` takes the largest digit in `KIT_KEYS`). Renaming a direction has no subcommand: the directory, the registry `id`/`name`, `kit.id`, `[data-direction="<id>"]` and the `--<p>-` prefix in `tokens.css`.
## 6. Toolbar (`frames/LabToolbar.tsx`)
GENERIC apart from the two row titles and the option labels. Deliberately not the product's look (monospace, `text-(length:--text-micro)`, sticky), one grid with `max-content 1fr` columns so both row labels sit flush left and both option sets start on the same column (75-79).
- Row 1: label `Artifact directions`; the Kit segmented control, options from `DIRECTION_IDS` with label `KIT_META[id].name` and hint `${tagline} (${KIT_KEYS[id]})` (51-55); then, right-aligned by `ml-auto`, Theme (`dark` | `light`, hint "Toggle with t", 64-67) and the word `animation` with the Animation control (`off` | `on`, 68-71).
- Row 2: label `Artifact surfaces`; the Surface segmented control, `VIEW_OPTIONS` in display order Overview (`board`) first, then `Task-thread`, `Right-panel`, `Tab-gallery`, `Tab-closeup` (57-63); right-aligned hint `0–5 kit · t theme · a animation`, hidden below `lg` (100).
- `Segmented` (16-48): `role="radiogroup"` with `role="radio"` buttons, `aria-checked`, `title={hint}`; selected = `bg-background text-foreground shadow-xs`.
- `VIEW_OPTIONS` is a hand-maintained literal. `LAB_VIEWS` is imported (3) but unused; it only validates `s=` in lab-state.ts:33. `KIT_OPTIONS` is typed `Option<DirectionId | "all">[]` with no `all` entry, so under `?d=all` no radio is checked.
```tsx
export function LabToolbar({ state, onChange }: { state: LabState; onChange: (patch: Partial<LabState>) => void }) {
return (
<div
className="sticky top-0 z-50 grid items-center gap-x-4 gap-y-2 border-b border-border bg-background px-4 py-2 font-mono text-(length:--text-micro) text-muted-foreground"
style={{ gridTemplateColumns: "max-content 1fr" }}
>
<span className={TITLE}>Artifact directions</span>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<Segmented label="Kit" options={KIT_OPTIONS} value={state.direction} onChange={(direction) => onChange({ direction })} />
<div className="ml-auto flex items-center gap-4">
<Segmented label="Theme" options={THEME_OPTIONS} value={state.theme} onChange={(theme) => onChange({ theme })} />
<span className="flex items-center gap-1.5">
<span>animation</span>
<Segmented label="Animation" options={ANIM_OPTIONS} value={state.anim ? "on" : "off"} onChange={(value) => onChange({ anim: value === "on" })} />
</span>
</div>
</div>
<span className={TITLE}>Artifact surfaces</span>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<Segmented label="Surface" options={VIEW_OPTIONS} value={state.view} onChange={(view) => onChange({ view })} />
<span className="ml-auto hidden lg:inline">0–5 kit · t theme · a animation</span>
</div>
</div>
);
}
```
Keyboard shortcuts (main.tsx:153-163): `t` toggles theme, `a` toggles animation, a digit selects the kit whose `KIT_KEYS` entry matches; ignored with meta/ctrl/alt or while typing (`isTyping`, 52-54). `event.key` is compared case-sensitively (Shift+T does nothing); the numpad works.
```ts
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.metaKey || event.ctrlKey || event.altKey || isTyping(event.target)) return;
if (event.key === "t") change({ theme: state.theme === "dark" ? "light" : "dark" });
if (event.key === "a") change({ anim: !state.anim });
const direction = DIRECTION_IDS.find((id) => KIT_KEYS[id] === event.key);
if (direction) change({ direction });
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [change, state.theme, state.anim]);
```
## 7. Slot scoping and theme idioms
Every kit slot renders inside `Slot` (frames/Slot.tsx:8-14), which stamps `data-direction={kit.id}`; a kit's `tokens.css` scopes everything to `[data-direction="<id>"]`, so two kits' tokens never collide and a kit never leaks into the frame. The only `Slot` given a `className` is the thread attachments block, `flex w-full flex-col gap-1` (ThreadFrame.tsx:75, KindsSheet.tsx:71).
```tsx
export function Slot({ kit, className, children }: { kit: DirectionKit; className?: string; children: ReactNode }) {
return (
<div data-direction={kit.id} className={className}>
{children}
</div>
);
}
```
Two light/dark idioms coexist on master:
- `.dark [data-direction="<id>"] { … }`: light values in the base block, dark overrides after (d1-solid/tokens.css:90, d2-tint:95, d3-tactile:184, d4-hybrid:105).
- `html:not(.dark) [data-direction="<id>"] { … }`: dark values in the base block, light overrides after (d5-v1-mvp/tokens.css:56; its values were measured from a dark mock).
Use the first. It matches `index.css` (light at `:root`, dark in `.dark {}` at 591), four of five kits use it, it reads as an override, and the generator's `tokens.css` template writes it. Both work because `.dark {}` is top-level and unlayered, so a kit rule in the same layer wins on specificity (`@layer base` only opens at 717).
Animation opt-in, the V1 MVP values to carry over (d5-v1-mvp/tokens.css:68-97, under the comment at 67; the skill keeps them as `assets/motion/v1-mvp-motion.css`, which `new` and `add-direction` append to every exploration's `tokens.css`): a resting transform `[data-direction="<id>"] [data-rotate] { transform: rotate(var(--v1-base, 0deg)); }` (68-70); one shared curve on the scope, `--v1-hover-ease: 300ms cubic-bezier(0.3, 0, 0.1, 1)` (80-82); every moving rule under `@media (prefers-reduced-motion: no-preference) { html[data-lab-anim="1"] [data-direction="<id>"] … }` (84-97): `button:hover [data-front] { transform: translateY(-6px); }`, `[data-spread="left"]` turns `+5deg` and `"right"` `-5deg` from `--v1-base`. Colour hovers stay outside the gate so captures are stable. On master only d5 reacts to the toggle; the other kits ignore it.
## 8. Frames and surfaces
A frame is a static, behaviour-free mock of the product chrome around one place the feature appears; the kit supplies only the slot. Frames copy the product's class strings and cite the source file and line inline. One deliberate difference from the product: nothing scrolls internally. `AppShell` uses `min-h-225` (900px) where `Layout.tsx:583` uses `h-dvh`, `PanelFrame` renders the pane body as a plain `p-4` div (63-67), `ComposerShell` is not sticky, so the page grows and a full-page capture (and the Board's height measurement) sees everything.
Fixed widths and where they come from. Re-derive these; three frame comments have already drifted.
| Frozen in the frame | Value | Product source |
|---|---|---|
| `AppShell.tsx:100` `w-60` | 240px | `components/SidebarShell.tsx:16` `DEFAULT_SIDEBAR_WIDTH = 240` (resizable 208–420 at 17-19; the frame freezes the default; the frame comment says :15) |
| top bars `h-(--sz-60px)` (ThreadFrame.tsx:24, GalleryFrame.tsx:22, AppShell.tsx:102) | 60px | `index.css:2866` |
| `ThreadFrame.tsx:86` `max-w-(--tc-shell-max-w)` | 960px | `index.css:575` `--tc-shell-max-w: 60rem` |
| `PanelFrame.tsx:19` `TASK_DETAIL_PANE_WIDTH = 434` | 434px | `components/PropertiesPanel.tsx:88` `TASK_DETAIL_DEFAULT_PANE_WIDTH` (the frame comment says :85) |
| `GalleryFrame.tsx:102` `max-w-6xl` | 1152px | `pages/Artifacts.tsx:248` (the frame comment says :249) |
| `KindsSheet.tsx:14` `THREAD_COLUMN_WIDTH = 835` | 835px | measured from reference screenshots, beside an open pane |
| `Board.tsx:12-13` `FRAME_WIDTH`, `FRAME_MIN_HEIGHT` | 1440 × 900 | the viewport every surface is designed against |
Shipped components the frames reuse (everything else is hand-drawn): `AppShell` reuses `Avatar`, `AvatarFallback`, `cn` and lucide icons; `ThreadFrame` reuses `StatusIcon` and `Breadcrumb*`, `thread-parts` only `StatusIcon`; `PanelFrame` reuses `SidePanelFrame`, `SidePanelWindowControls`, `SidePanelTabs`, `SidePanelTabItem` and `Button` (`presentation="docked" contentMode="full-bleed" headerSize="task-detail" className="flex-1 border-l-0"`, 33-37; `border-l-0` because docked adds its own left border, `side-panel/SidePanelFrame.tsx:53`); `GalleryFrame` reuses `Breadcrumb*`, `Button`, `Input`, `cn`. `AppShell` is a GENERIC pattern with ARTIFACT-SPECIFIC data baked in: `COMPANY`/`TASKS` (22, 60, 111), the `A` badge (109), the `TO` avatar and e-mail (144, 146), and `ShellNav = "tasks" | "artifacts"` (33) tied to the WORK entries (48, 51).
`SurfaceView` (frames/SurfaceView.tsx) maps `s=` to a frame. `SurfaceId = "thread" | "panel" | "gallery" | "stack"` (9), `SURFACE_IDS` (11). `gallery`/`stack` render `<AppShell nav="artifacts"><GalleryFrame kit groups={GROUPS} open={surface === "stack" ? OPEN_GROUP : null} /></AppShell>` (14-20); `thread`/`panel` render `<AppShell nav="tasks">` with a `streamlined-task-detail-surface` row (`Layout.tsx:676`) holding `ThreadFrame` and, for `panel`, `PanelFrame` (21-29). Fixtures are imported by the frame, not passed from main (2). `main.tsx:169-177` routes `board` to `Board`, `kinds` to `KindsSheet`, everything else to `SurfaceView`.
`chrome=0` removes the toolbar and nothing else (main.tsx:168); theme and animation come from the URL regardless, so an iframe or a capture shows the exact view.
Board (frames/Board.tsx, GENERIC mechanism): the overview is not a second implementation. Each `Tile` (26-75) is this same page in a same-origin `<iframe src={labSearch({ …, chrome: false })}>` (54), laid out at 1440px and CSS-scaled to its column (ResizeObserver, 32-38); the height is measured from the framed `#root` (44-52) because the framed document's height follows the iframe's own; the wrapping `<a href={labSearch({ …, chrome: true })}>` (59) opens the full view. The iframe is `pointer-events-none`, `tabIndex={-1}`, `scrolling="no"` (67-70), so a tile never receives hover: hover motion cannot be judged on the Board, open the tile instead. `KitBoard` hard-codes the four views and `grid grid-cols-4` (83, 85) with `maxHeight` 1800 (89); `AllBoard` hard-codes `7rem repeat(3, minmax(0, 1fr))`, three column heads and the gallery + stack pairing (99-115). `d=all` is 6 kits × 4 = 24 live iframes; view it from the static build (launch entry `artifact-directions`, :5190) or let `shot.cjs` compose it from PNGs (`captureBoard`, 162-200).
A kit flag honoured by a frame (ThreadFrame.tsx:119-123): the attachments block is built once (74-78) and placed inside the bubble or 12px under the footer.
```tsx
{kit.threadAttachmentsAfterFooter ? null : attachments}
<MessageFooter timestamp="11:08 AM" />
</Bubble>
{/* A kit may hang the block under the footer instead, 12px below it (the V1 mock does). */}
{kit.threadAttachmentsAfterFooter ? <div className="mt-3">{attachments}</div> : null}
```
`KindsSheet` honours neither override: it always calls `GalleryStack`/`GalleryStacks` and renders `ThreadAttachments` standalone (71-73, 89-98), so a kit with `GalleryView` must still provide both.
Adding or removing a surface. Nothing derives automatically except `LabView`/`LAB_VIEWS` (lab-state.ts:18, 21):
1. `SurfaceId` and `SURFACE_IDS` (SurfaceView.tsx:9, 11) and the switch (13-31), with a frame and its fixtures.
2. The `VIEW_OPTIONS` label (LabToolbar.tsx:57-63) and the Overview hint (58).
3. `Board.tsx`: the `KitBoard` views literal and `grid-cols-N` (83, 85); the `AllBoard` template columns, heads and pairing (99-115).
4. `shot.cjs`: `SURFACES` (23), `BOARD_SURFACES` (153), the sheet's columns and crops (177-179, 184, 189).
5. A `KindsSheet` unit, if the lab keeps a vocabulary sheet.
6. A kit slot per new surface (section 4) in every kit, Today included.
A generated lab has no `add-surface`: add the `registry.surfaces` line, then by hand `frames/<Pascal>Frame.tsx` and its `FRAMES` entry (SurfaceView.tsx), the `SurfaceData` and `DirectionKit` members (kit.ts), `<UPPER>_ITEMS` (fixtures.ts), for a lab scaffolded with one surface row 2 (LabToolbar.tsx), a `<Pascal>Body.tsx` in every kit and a `today.test.tsx` case; `SURFACE_IDS`, `LAB_VIEWS`, the toolbar's `VIEW_OPTIONS`, Overview hint and legend, the Board and `shot.cjs` derive from the registry.
Single-surface lab: drop row 2, the `Artifact surfaces` label and the Surface `Segmented` (LabToolbar.tsx:97-101), and move the shortcut hint into row 1. Keep the `s=` parameter with two values, `board` and the surface, so Board tiles and `shot.cjs` can still address the surface by URL. With one view `KitBoard` is a single column; the every-direction grid (`AllBoard`'s shape, one row per kit) is the more useful overview.
## 9. Parked-branch extras (not on master)
Branch `claude/artifact-lab-new-surfaces` (one commit, ec6bab4c1) adds surfaces that only some kits draw. Read it with `git show claude/artifact-lab-new-surfaces:ui/src/artifact-directions/<path>`. The optional pattern:
- Two optional slots, `MediaPreview?: FC<{ items; index; onClose }>` and `PanelCloseup?: FC<{ document }>` (parked kit.ts:134, 139), with `KITS_WITH_EXTRAS = ["d5-v1-mvp", "today"]` and `kitHasExtras(id)` (143-144); both kits' `index.tsx` add the two members.
- `SurfaceId` gains `media` | `closeup`, with `EXTRA_SURFACE_IDS`/`isExtraSurface` (parked SurfaceView.tsx:15-19); `viewFor(direction, view)` redirects an extra surface to `board` for a kit that does not draw it, applied in both `readLabState` and `change()` (parked lab-state.ts:37-48, main.tsx:139-150).
- Toolbar options gain `disabled` and an `EXTRAS_HINT`; `frames/NotDrawn.tsx:1-15` renders `{kit.name} does not draw the {surface}; {who} do.` in place of a missing slot (`floating` for the overlay); `frames/LabNavigation.ts:1-10` is a `LabNavigationContext` so an overlay's close can call the toolbar's `change`.
- `KitBoard` lists six views for a kit with extras while the grid stays `grid-cols-4`, so the extras wrap.
Take `api-stub.ts`, `lab.css`, `d5-v1-mvp/kinds.ts` and `tokens.css` from master, not from this branch; the parked copies are pre-merge.
## 10. Capture flags (`shot.cjs`)
Captures load the lab with `chrome=0` and never pass `anim`, so the lab's default (on) applies; stills are motion-free because the Playwright context asks for reduced motion, which the kit's `@media` gate honours (121-125, 143). Any console error, page error, failed request or HTTP ≥ 400 fails the capture (130-141). Defaults: `--d today` (223), every `SURFACES` entry (23, 33), both themes (24, 34). The page URL is `` `${base}/?d=${shot.kit}&s=${shot.surface}&theme=${shot.theme}&chrome=0` `` (143). A generated `shot.cjs` takes its kits and surfaces from `window.__LAB__` and adds `--hover`, a rest/hover pair per surface from a second context without reduced motion.
```js
const context = await browser.newContext({
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 2,
reducedMotion: "reduce",
});
```
@@ -0,0 +1,253 @@
# Lifecycle: proposed, adopted, current, historical
How a direction moves from exploration to canonical, and what you edit by hand at each move. This
version of the skill scaffolds (`new`, `add-direction`), verifies (`check`) and lists (`list`); every
move below is a hand edit. Mechanics already written elsewhere are linked, not repeated:
`design-proposals-storybook.md` §1 (catalog rules) and §5 (the long-form lifecycle), `repo-gates.md`
§8 (hand-off commands), `motion-spec.md` §4.2 (product motion), `canonical-directory.md` (the
governance diff that enables mode B). Line numbers were opened at `5e26b733b`; re-check after the
cited files change. Working directory: repo root.
## 1. Where the stage lives
The catalog annotation is a closed field set with no stage field (`scripts/storybook-catalog.mjs:16`),
and under `home: "design-proposals"` it accepts only `status: "proposed"` (`:98-101`). So a direction's
stage is carried outside the annotation, in three places you keep in step by hand:
1. `ui/src/<slug>-directions/registry.ts`: each exploration's `stage` (`proposed | adopted | current |
historical`); Today is `shipped`. The lab toolbar appends ` · <stage>` to a direction's hint when it
is adopted or current, so the page says what shipped without a new control.
2. The per-direction story `ui/storybook/stories/design-proposals/<slug>-directions-<digit>.stories.tsx`:
the first sentence of `docs.description.component` states the stage (sections 4 to 6). The Compare
story's description does not change; it describes every direction.
3. The feature-home story's `status`, once product code exists (sections 4 and 5).
`node .agents/skills/directions-lab/scripts/scaffold.mjs check --slug <slug>` reads 1 and checks only
the Compare story's `status` against it under the detected mode (section 2): `proposed` in mode A; in
mode B the lab's stage: `proposed` while any exploration is `proposed` or `adopted`, `historical` when
every exploration is `historical` (then the story must also carry `replacement`, naming an existing story
file), else `current` (every exploration shipped, or the ones that did not were retired). It never opens 2 or 3; compare their
sentences with 1 by hand. Run it after every move. Run `scaffold.mjs list` first to see the labs in the
repo with their direction ids, surfaces, ports and story id; the stage is read in `registry.ts`.
| Stage | Registry entry | Directory stories (`design-proposals/`) | Feature-home story | Code |
| --- | --- | --- | --- | --- |
| proposed | `stage: "proposed"` | Compare (and per-direction, when written): `status: "proposed"`, `home: "design-proposals"`, `exception` | none | lab only |
| adopted | `stage: "adopted"` (flag and PR in the story sentence) | unchanged | `implementation: "production"`, `status: "proposed"`, no `home`, no `exception` | lab + `ui/src/components/<feature>/` behind `enable<Feature>` |
| current | `stage: "current"` | mode A unchanged; mode B per-direction `status: "current"`, Compare `"current"` once no exploration is still proposed or adopted | `status: "current"` | product; the lab stays the reference |
| historical | `stage: "historical"` (replacement in the story) | mode A: file moved out of the folder, `status: "historical"`, `replacement`; mode B: same fields, file in place, Compare too once every exploration is historical | unchanged | lab kept while its stories import it |
## 2. The two modes
Mode A, "staged until shipped", is what the repo runs today. `storybook-catalog.mjs:99` rejects any
status but `proposed` under the proposals home, and `scripts/storybook-catalog.test.mjs:101-108`
asserts that `current`, `mixed`, `unreviewed` and `historical` all fail there. Every directory entry is
therefore `proposed` for as long as it lives in the folder, whatever the direction's real stage; the
stage lives in the registry, the toolbar hint and the per-direction story sentence. The folder is framed
as a review section, not an authority (`DESIGN.md:297-304`; `maps/storybook-map/README.md:34-35`).
Mode B exists after `canonical-directory.md` is applied, as its own PR, only on Tonio's yes. Then line
99 accepts `proposed | current | historical` under the home, line 165 keeps excluding the home from
coverage whatever the status, and a directory entry may say `current` or `historical` without moving.
Detect the mode by reading the script, never by date: mode A while `scripts/storybook-catalog.mjs`
contains the text `design proposal home requires proposed status`, else mode B. Never apply the
governance diff inside a lab PR; offer it once, in one line of the PR body.
## 3. Proposed: what `new` writes
`new` writes the directory story `<slug>-directions.stories.tsx` with meta `id: "design-<slug>-directions"`,
`status: "proposed"`, `home: "design-proposals"`, `implementation: "prototype"`, `boundary: "fixtures"`
and the derived `exception`, and sets `stage: "proposed"` on every exploration in `registry.ts`
(`shipped` on Today). `add-direction` inserts another `proposed` entry before Today and a `D<digit>`
export in that story. No per-direction story exists yet; write one by hand only when a direction needs
surface and slot deep dives before adoption, following `design-proposals-storybook.md` §4
("Per-direction deep dives"): file `<slug>-directions-<digit>.stories.tsx`, digit-keyed id
`design-<slug>-directions-<digit>`. Why digits: a rename of the direction never changes a story id, and
story ids are what Playwright specs and review links pin. `check` admits the slug in the Compare story alone: a
per-direction story, in the folder or moved out, fails it with `names <slug>-directions outside the lab`
until the scan admits those files; read that line as expected and judge the rest of the report.
Nothing else to edit at this stage. Approval is visual and human: "It remains proposed until visual
approval." (`DESIGN.md:150-151`).
## 4. Adopted: product code lands behind an experimental setting
Precondition: Tonio chose the direction. Then, in one product PR plus one lab PR, or together:
1. Product code under `ui/src/components/<feature>/` by the rules in section 9, gated by a new setting
`enable<Feature>` built end to end like `enableArtifactsV1`: type `packages/shared/src/types/instance.ts:93`,
validator `packages/shared/src/validators/instance.ts:67`, catalog entry
`packages/shared/src/feature-catalog.ts:202-209`, hook `ui/src/hooks/useArtifactsV1Enabled.ts:24-41`
(`resolveArtifactsV1Enabled`, `:7-9`), toggle `ui/src/pages/InstanceExperimentalSettings.tsx:310`.
Why a setting: the old presentation stays the fallback until the setting is removed in a later
cleanup (`doc/plans/2026-10-22-artifacts-mvp-v1.md:19-22`).
2. A feature-home story modelled on `ui/storybook/stories/documents-artifacts/artifact-v1-primitives.stories.tsx:43-62`:
meta id under the feature (`:44`, `components-artifact-v1-primitives`), title under the feature prefix
(`:45`), a description that names the setting (`:51`, "behind Settings > Experimental > Artifacts V1"),
and `paperclip` with `capabilities` (`:55`), `status: "proposed"` (`:56`), `implementation: "production"`
(`:57`), `boundary: "fixtures"` (`:58`), `notes` (`:59`); no `home`, no `exception`. Why `proposed`
there: the status is legal anywhere (only the proposals home forces it, `storybook-catalog.mjs:99`) and it
keeps a flagged presentation out of coverage counts (`:165`) until the flag goes.
3. The lab. In `registry.ts` set the entry's `stage` to `"adopted"` and nothing else: `LabDirection`
(`define-lab.ts`) has no `flag`, `pr` or `product` field, and the exact-line parser behind `check`,
`list` and `add-direction` refuses an entry that carries one (`direction line not in generator form`).
The flag and PR go in the per-direction story's first sentence and the product path in its `notes`:
write the story if it is absent (section 3) and make the sentence exactly:
``Stage: adopted behind `enable<Feature>` (#<pr>).``
Keep `status: "proposed"` and the home in both modes: adopted maps to `proposed`.
4. Optional, when Today should show the shipped flag-on presentation: set
`today: { flags: { enable<Feature>: true } }` in `registry.ts`; the generated `api-routes.ts` already
answers `/instance/settings/experimental` with those flags, and `setLabFlags()` switches them at run
time (`today-fixtures-and-build.md` §1 explains the flag-OFF default). A second Today kit beside the
flag-off one is not registrable: `defineLab` admits one baseline, `today` keyed `0`, and every other
entry must be `d<N>-<name>` with a digit key.
The lab stays the visual reference (`doc/plans/2026-10-22-artifacts-mvp-v1.md:14`): when the port and
the drawing differ, fix the port. Do not redraw the adopted direction to match product code. `check`
does not compare tokens: after adoption diff each `--<p>-<name>` value in that direction's `tokens.css`
against `--<feature>-<name>` in `ui/src/index.css` by hand (section 9) and record drift in the PR.
## 5. Current: the flag is removed
1. Remove the setting end to end (the files in section 4, item 1) and the fallback presentation.
2. Feature-home story: `status: "current"`. It now counts as coverage (`storybook-catalog.mjs:165`).
3. Lab: `stage: "current"` in `registry.ts`; per-direction story first sentence exactly:
`Current: shipped as <feature-home meta id>.`
Mode A: every directory entry stays `status: "proposed"`; the Compare story stays in the proposals
folder as the visual reference while any direction is unshipped, and only a retirement (section 6)
moves a file out of it. Mode B: set the per-direction story to `status: "current"`. Compare stays
`proposed` while another exploration is still `proposed` or `adopted`, and becomes `"current"` once
none is (the others retired per section 6): `check` derives this from the registry stages
(`lib/modes.mjs`, `labStage` and `expectedStoryStatus`).
## 6. Historical: retiring a direction or a whole lab
Two catalog rules shape this move. `status: "historical"` may carry `replacement`, which must name an
existing story file (`storybook-catalog.mjs:110`, "replacement story is missing"). Under the proposals
home `historical` is rejected in mode A (`:99`), so the story must leave the folder: the home is then
derived from `capabilities[0]` (`storyHome`, `:70-72`), the file must live under
`ui/storybook/stories/<area>/` (`:107`) and the title must start with that feature's sidebar prefix
(`:108`). The meta `id` and every export name stay, so `<id>--<export>` links and specs keep working
(`maps/storybook-map/README.md:68-69, 73-75`).
Precedents, all outside the proposals folder, all `status: "historical"` with `replacement`:
`ui/storybook/stories/agent-chat/chat-entry.stories.tsx:6-7` (id `design-explorations-chat-entry`, title
`.../Design explorations/Chat entry`), `:17`, `:21`; `chat-channels/github-chat.stories.tsx:22, 26`;
`agents/new-agent-onboarding.stories.tsx:16, 20`; `steering/paused-composer.stories.tsx:20, 24`;
`onboarding/onboarding-connect-model.stories.tsx:29, 33`.
One direction, mode A:
1. `registry.ts`: `stage: "historical"` only; `replacement` is a story field, not a registry one (section
4, item 3). In the story it names `<repo-relative .stories.tsx>` (the feature-home story of the
direction that shipped, or the story that superseded it).
2. `git mv` the per-direction story to
`ui/storybook/stories/<area>/design-explorations/<slug>-directions-<digit>.stories.tsx`. Drop `home`
and `exception`; set `status: "historical"` and `replacement`; retitle to
`<Section>/<Feature>/Design explorations/<Title> directions/<N>.<Name>` (section and feature labels
are the ones the catalog derives for that area, `:86, :108`; copy them from a neighbouring story in
the folder). First sentence: `Historical: superseded by <replacement meta id>.` Keep the id and the
exports. Leave the kit directory in place: Compare still renders it and `registry.test.ts` still
expects it.
3. Mode B instead: the file stays in the folder; change only `status` and `replacement`; keep `home`
and `exception`.
A whole lab: every exploration goes `historical` with one `replacement`. Mode B: Compare stays in the
folder with `status: "historical"` and that `replacement`; `check` expects both once every exploration
is `historical` and fails without the field or when the named file is missing (another lab's story may
name this lab's story as its `replacement`; `check` skips `replacement:` lines when it scans for
`<slug>-directions` outside the lab). Mode A: move Compare and every
per-direction file together, keeping every id (Compare's title becomes
`.../Design explorations/<Title> directions/Compare`). With `capabilities: []` there is no area and
`storyHome` falls back to `foundations` (`:71`): add a real capability first rather than filing design
history under Foundations. `check` then reports the Compare story missing at its proposals path and the
moved files naming `<slug>-directions`; both are expected for a retired lab. The lab code stays until the
moved stories stop importing `@/<slug>-directions/*` (`kit`, `api-stub`, `api-routes`,
`frames/SurfaceView`); removing `ui/src/<slug>-directions/`, `ui/<slug>-directions.html`,
`ui/vite.<slug>-directions.config.mjs`, the two `.claude/launch.json` entries and the `.gitignore`
line is a later PR. Never delete a story because it is old or duplicated by name
(`design-proposals-storybook.md` §5, item 7). After any move run `pnpm build-storybook` before and
after and diff the ids in `ui/storybook-static/index.json`: the set must be unchanged.
## 7. Theme pairing: Storybook themes by default, the page is production by default
The proposals decorator at `ui/storybook/.storybook/preview-base.tsx:853` wraps a story whose
`parameters.paperclip.home` is `"design-proposals"` in `<AvatarInitialProvider><div className="design-proposal-theme">`
unless `parameters.designProposalTheme === false`. The marker's CSS is scoped `html:has(...)`, so the
whole iframe is themed, Today included, and Radix portals with it (`design-proposals-storybook.md` §2).
Why that is right for Compare: every kit sits under one palette, so a difference between two cells is a
difference between directions, never between palettes. The `TodayProductionPalette` export is the
opt-out, `parameters: { designProposalTheme: false }`, so the shipped baseline can also be judged on
the production palette from the same story file.
The standalone page inverts the default: production palette unless `?proposal=1`, which wraps the page
in the same two elements; there is no toolbar control and no key for it. Both hosts key dark/light on
`html.dark` and motion on `html[data-lab-anim]`, so a kit's `.dark [data-direction="<id>"]` tokens and
the motion block resolve identically in Storybook, on the page and in the Board iframes. Do not copy
d5's `html:not(.dark)` idiom (`motion-spec.md` §4.3); it inverts the pairs on the way to product.
## 8. Sandbox: the step between Storybook and production
`VITE_PAPERCLIP_DESIGN_SANDBOX=true` in a dev app (`ui/src/context/DesignSandboxContext.tsx:14`) adds
the same `design-proposal-theme` marker to `<body>` (`:23-25`) and a bottom-right "Design proposals:
On/Off" button (`:31-37`); `DESIGN.md:724-727` names the switch and `:755` its status: "an integration
experiment, not permission, scale or release acceptance". The sandbox mounts no lab kit. It themes the
real app on real data, so use it twice: before adoption, to check that the proposal-theme values a
direction leans on resolve on the real pages the lab's frames mock (missing token mappings show up
here, not on fixtures); after adoption, with `enable<Feature>` on in Settings > Experimental, to see
the ported component under both palettes with real records. The documented launch is at
`DESIGN.md:791-799`. Nothing seen in the sandbox changes a stage; record findings in the PR.
## 9. Graduation of code
Rules for the product PR of section 4, item 1. `pnpm run check:token-gates` applies to everything under
`ui/src/components/**` and `ui/src/pages/**` (`repo-gates.md` §3) and would fail the lab code as it
stands; that is the point of the port.
- Reuse the lab's code, not its types. Product components take product types through small adapters
(`ui/src/components/artifacts/v1/adapters.ts:1-8, 27`, `subjectFromIssueDocument(doc: IssueDocument)`);
the lab keeps its own copies and is a reference, not a dependency
(`doc/plans/2026-10-22-artifacts-mvp-v1.md:28-32`). Never import `@/<slug>-directions/*` from product.
- Tokens: every value in the adopted direction's `tokens.css` becomes `--<feature>-<name>` in
`ui/src/index.css`, light values in `:root` beside the feature's other tokens (precedent
`--artifact-*` at `:495-531`) and dark overrides in `.dark` (`:591-600`). The kit's dark/light pairs
map one to one; a value that exists only in one theme in the kit is a drawing bug to fix first.
`DESIGN.md:17` forbids a second token source, `DESIGN.md:30` any raw value in components.
- Motion in product form (`motion-spec.md` §4.2; precedent `index.css:4808-4824`): drop the
`html[data-lab-anim="1"]` gate, key on `.<feature>-tile`, rename the hooks to `data-<feature>-front`,
`data-<feature>-rotate`, `data-<feature>-spread`, inline `--<feature>-base`, and declare
`--<feature>-hover-ease: 300ms cubic-bezier(0.3, 0, 0.1, 1)` in `:root` beside the feature tokens
(`:531`); the reduced-motion gate stays (`doc/plans/2026-10-22-artifacts-mvp-v1.md:37-38`). The curve,
the 6px rise and the 5 degree fan do not change in the port.
- A component test asserts the hooks on every treatment branch, as
`ui/src/components/artifacts/v1/ArtifactTile.test.tsx:50` (two `[data-artifact-spread]`) and `:97`
(`[data-artifact-front]` present) do. Why every branch: a front rendered without its hook stands
still while the backings turn (`motion-spec.md` §7, the bare-print lesson).
- Feature-home story as in section 4, item 2; `status: "current"` only when the flag is removed.
## 10. The DESIGN.md directory row
`DESIGN.md` has no `## Design directory` section today; `canonical-directory.md` adds one with the table
`| Subject | Entry | Stage |`. Until that PR lands, and in any lab PR after it, offer the row as text in
the PR body and never write it:
```
| <Subject> | design-<slug>-directions | <stage> |
```
Why offered, not applied: `DESIGN.md:9` makes the document the source of truth and asks for review
before it changes, so a lab or product PR that edits it mixes a design decision into an implementation
diff. The row's second column is the stable meta id, not a URL, because ids survive renames and the
Vercel alias does not. Update the stage in the offered row at every move of sections 4 to 6.
## 11. Verify after every move
```sh
node .agents/skills/directions-lab/scripts/scaffold.mjs check --slug <slug>
node scripts/storybook-catalog.mjs --check
cd ui && pnpm typecheck
```
Then the hand-off list in `repo-gates.md` §8. A move that touched product code also runs
`pnpm run check:token-gates` and the component tests; a move that moved a story file also diffs the
built `index.json` ids (section 6). Evidence goes in the PR, never in the annotation.
@@ -0,0 +1,351 @@
# Motion spec - the V1 MVP hover motion every lab carries over
Single source for the hover motion a directions lab ships: curve, timing, what moves, what fades, how it
is gated, and the hooks a component must carry. Worked example: the artifact lab (`ui/src/artifact-directions/`)
and its product port (`ui/src/components/artifacts/v1/`, `ui/src/index.css`). Line numbers were opened and
confirmed on branch `claude/directions-lab-skill` on 2026-10-24. Short paths (`tokens.css`, `Stack.tsx`,
`Tile.tsx`, `Front.tsx`, `MediaPrint.tsx`, `PanelRow.tsx`) are under `ui/src/artifact-directions/directions/d5-v1-mvp/`;
`Artifact*.tsx` and `geometry.ts` are under `ui/src/components/artifacts/v1/`.
Legend: **GENERIC** = reuse for any feature, verbatim. **ARTIFACT-SPECIFIC** = belongs to the
artifact lab; redraw per feature (targets, magnitudes, angles, colours).
## Contents
1. The one curve and its token names
2. What transitions (transform) and what fades (Tailwind `transition-colors`)
3. The movement: 6px rise, 5° fan, resting angles, transform-origin
4. Gating: lab double gate vs product single gate (paste-ready CSS)
5. Hover fills: tile, pill, row per theme
6. What does not animate
7. Hooks a component must carry, and the bare-print lesson
8. Lab plumbing for the Animation toggle
9. `ui/src/motion-tokens.css` and how the lab curve relates
10. Operational notes: the board and `shot.cjs` never show motion
11. Checklist for a new direction's `tokens.css`
## 1. The one curve and its token names
**GENERIC.** One curve for every hover movement: `300ms cubic-bezier(0.3, 0, 0.1, 1)`. Brisk start,
long settle (more ease-out than ease-in). Never add a second curve or duration for movement.
| Form | Token | Declared at |
| --- | --- | --- |
| Lab | `--v1-hover-ease` | `tokens.css:80-82`, in a second `[data-direction="d5-v1-mvp"]` block |
| Product | `--artifact-hover-ease` | `ui/src/index.css:531`, in `:root`; not re-declared in `.dark` |
Name it `--<p>-hover-ease` in a lab (`<p>` = the direction's token prefix) and
`--<feature>-hover-ease` in product.
## 2. What transitions and what fades
**GENERIC.** Two timings on one hover, by design:
- Movement: exactly `transition: transform var(--…-hover-ease);` on the fanning backings and the
front (`tokens.css:85-87`; `index.css:4812-4814`). Nothing else (`filter`, `box-shadow`,
`opacity`, `scale`) transitions with this curve.
- Colour: Tailwind `transition-colors` in the markup, never in the token CSS (`Tile.tsx:30,53`;
`PanelRow.tsx:91`; product `ArtifactTile.tsx:27,45`, `ArtifactRow.tsx:93`). Tailwind 4.3.3
(`ui/node_modules/tailwindcss` -> `.pnpm/tailwindcss@4.3.3`) sets `transition-property: color,
background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from,
--tw-gradient-via, --tw-gradient-to` at the theme defaults `--default-transition-duration: 150ms;
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1)`
(`ui/node_modules/tailwindcss/theme.css:492-493`). Nothing under `ui/src` overrides them.
Colour fades are never gated (not on the lab toggle, not on reduced motion). A scaffold that moves
everything into CSS must add `transition-colors` (or an explicit `background-color` transition) to
the tile, pill and row, or the fills snap.
## 3. The movement
**ARTIFACT-SPECIFIC magnitudes, GENERIC pattern.**
### 3.1 The 6px rise
While the tile is hovered the artwork root `[data-front]` gets `transform: translateY(-6px)`
(`tokens.css:88-90`; product `[data-artifact-front]`, `index.css:4815-4817`). There is no rest rule
for the front: rest is the identity transform. The front only translates, so its `transform-origin`
is irrelevant.
### 3.2 The 5° fan of the two backing pages
Each backing carries `data-rotate` (rest) and `data-spread="left"|"right"` (hover side). The rest
rule is ungated: `[data-direction="d5-v1-mvp"] [data-rotate] { transform: rotate(var(--v1-base, 0deg)); }`
(`tokens.css:68-70`, under the comment at 67; product `[data-artifact-rotate]`, `index.css:4808-4810`). On hover: left
`rotate(calc(var(--v1-base, 0deg) + 5deg))`, right `rotate(calc(var(--v1-base, 0deg) - 5deg))`
(`tokens.css:91-96`; `index.css:4818-4823`). The `0deg` fallback means an element with `data-rotate`
but no inline base rests flat and still turns 5°.
State the directions as the code does: **left page +5deg, right page −5deg** (`Stack.tsx:36`). CSS positive
rotate is clockwise, so `tokens.css:76-77` reads "the left page clockwise and the right page counter-clockwise".
Do not describe this as the pages fanning "apart": a left page turning clockwise about its centre moves its top
edge right and its bottom edge left; a right page turning counter-clockwise moves its top edge left and its
bottom edge right, so the tops converge and the bottoms diverge. The media right page crosses upright (4° -> −1°).
### 3.3 Resting angles (lab `Stack.tsx`, product `geometry.ts`)
| Backing | Element | Rest | `spread` | Hover | Source |
| --- | --- | --- | --- | --- | --- |
| Sheet (text, documents) | `backing-short.svg` img, file 240.409×247.814 | −14° | left | −9° | `Stack.tsx:58-61`; `geometry.ts:53` |
| Sheet | plain landscape div 239.509×179.632, 12px radius | −5° | right | −10° | `Stack.tsx:63, 102-117`; `geometry.ts:56` |
| Media (image, video, audio) | `backing-tall-a.svg` img, 240.409×276.151, painted first (behind), centre y +2 | 4° | right | −1° | `Stack.tsx:69-71`; `geometry.ts:61` |
| Media | `backing-tall-b.svg` img, painted second | −7.64° | left | −2.64° | `Stack.tsx:72`; `geometry.ts:62` |
Sheet composition 254.253×241.044, front at left 37.13, top 25.04 (`Stack.tsx:98, 118`; `geometry.ts:51, 57`).
Media composition `max(55.73 + 195.048, left + print width)` × 240.337, front anchored by `MEDIA_ANCHOR`
(`Stack.tsx:74-79, 87-89`). The angle reaches CSS as an inline custom property per element, cast
`as CSSProperties`: `` "--v1-base": `${rotate}deg` `` (`Stack.tsx:50, 115`; product `ArtifactStack.tsx:33, 85`).
### 3.4 transform-origin rule
**GENERIC** for any rotated padded asset. Rotate about the visible shape's own centre
(`Stack.tsx:41-51`; product `ArtifactStack.tsx:25-34`, filter moved to `.artifact-backing`,
`index.css:4797-4799`):
```tsx
const originX = sheet.left + sheet.width / 2;
const originY = sheet.top + sheet.height / 2;
const style = {
left: centre.x - originX,
top: centre.y - originY,
width,
height,
transformOrigin: `${originX}px ${originY}px`,
filter: "var(--v1-shadow-sheet)",
"--v1-base": `${rotate}deg`,
} as CSSProperties;
```
Short sheet origin (120.2045, 120.507); tall sheet origin (120.2045, 134.6755). Measure `sheet.top`
from the file: the sheets sit 30px from the left and right edges but 26.6px from the top and 33.4px
from the bottom (`Stack.tsx:58, 69`), the shadow's 3.406px y-offset. The comment at `Stack.tsx:17-18`
("30px larger than the sheet on every side") is inexact; deriving the origin from it puts it 3.4px
low. The landscape div sets no origin: it is sized to the sheet, so the default `50% 50%` is its
centre (`Stack.tsx:102-117`).
## 4. Gating
**GENERIC.** Keep three rule groups apart:
1. Rest rules (`[data-rotate]` angle) and every colour rule: outside both gates.
2. The `transition: transform` rule: inside the reduced-motion query, beside the hover transforms
(hygiene: under `reduce` nothing changes `transform`, so an ungated transition has nothing to animate).
3. The hover transforms: inside the query and, in the lab, behind the Animation toggle.
Movement is `:hover` only. Fills and the pill step also fire on `:focus-visible` (`tokens.css:104`;
`index.css:4805`), so keyboard focus colours but never moves. By design in both forms.
### 4.1 Lab form: double gate (`html[data-lab-anim="1"]` AND reduced motion)
Paste into `ui/src/<lab>/directions/<id>/tokens.css`; replace `<id>` and `<p>`; keep the curve, the
gates, `-6px` and `±5deg` verbatim. Source: `tokens.css:68-97`; 67 is the comment above the block.
```css
[data-direction="<id>"] [data-rotate] {
transform: rotate(var(--<p>-base, 0deg));
}
[data-direction="<id>"] {
--<p>-hover-ease: 300ms cubic-bezier(0.3, 0, 0.1, 1);
}
@media (prefers-reduced-motion: no-preference) {
html[data-lab-anim="1"] [data-direction="<id>"] :is([data-rotate][data-spread], [data-front]) {
transition: transform var(--<p>-hover-ease);
}
html[data-lab-anim="1"] [data-direction="<id>"] button:hover [data-front] {
transform: translateY(-6px);
}
html[data-lab-anim="1"] [data-direction="<id>"] button:hover [data-spread="left"] {
transform: rotate(calc(var(--<p>-base, 0deg) + 5deg));
}
html[data-lab-anim="1"] [data-direction="<id>"] button:hover [data-spread="right"] {
transform: rotate(calc(var(--<p>-base, 0deg) - 5deg));
}
}
```
`button:hover` assumes the lab tile is a `<button>` (`Tile.tsx:49-54`). If a lab's tiles are anchors,
key on a class instead, as product does. Only `d5-v1-mvp/tokens.css` has rules keyed on `data-lab-anim`
(repo grep; `main.tsx:36` and `lab-state.ts:15` name it only in comments); a direction that wants the
toolbar toggle to do anything must prefix its movement rules this way, or the toggle is a no-op for it.
### 4.2 Product form: single gate (reduced motion only)
Paste into `ui/src/index.css` after the feature's component rules; replace `<feature>`. Source:
`index.css:4808-4824` (the file's last block).
```css
[data-<feature>-rotate] {
transform: rotate(var(--<feature>-base, 0deg));
}
@media (prefers-reduced-motion: no-preference) {
.<feature>-tile :is([data-<feature>-rotate][data-<feature>-spread], [data-<feature>-front]) {
transition: transform var(--<feature>-hover-ease);
}
.<feature>-tile:hover [data-<feature>-front] {
transform: translateY(-6px);
}
.<feature>-tile:hover [data-<feature>-spread="left"] {
transform: rotate(calc(var(--<feature>-base, 0deg) + 5deg));
}
.<feature>-tile:hover [data-<feature>-spread="right"] {
transform: rotate(calc(var(--<feature>-base, 0deg) - 5deg));
}
}
```
Product drops the lab gate and keys on the class `artifact-tile`, which sits on a `<Link>` or a
`<button>` (`ArtifactTile.tsx:44-45` TILE; `59-61` Link; `63-65` button). The product contract is
written at `doc/plans/2026-10-22-artifacts-mvp-v1.md:37-38`.
### 4.3 Theme scoping (both forms)
Lab: dark values are the base inside `[data-direction="d5-v1-mvp"] { … }` (`tokens.css:5-52`); light overrides
live under `html:not(.dark) [data-direction="d5-v1-mvp"]` (`tokens.css:56-65`), which matches only because the
lab toggles `dark` on `document.documentElement` (`main.tsx:30-34`); the kit contract also allows
`.dark [data-direction="<id>"]` (`ui/src/artifact-directions/kit.ts:11-13`). Product inverts it: light in `:root`
(`index.css:495-531`), dark in `.dark` (`index.css:591-600`, right after `--background`/`--foreground`). Copying
a block between forms without flipping swaps every fill; keep the dark/light value pairs identical.
## 5. Hover fills
**ARTIFACT-SPECIFIC values, GENERIC shape** (rest token + hover token per surface, applied through
Tailwind arbitrary-variable utilities that resolve only under the `[data-direction]` wrapper).
| Surface | Theme | Rest | Hover | Lab | Product |
| --- | --- | --- | --- | --- | --- |
| Tile (370px cell, 12px radius) | dark | transparent | `#202020` (mock, on the `#171717` canvas) | `tokens.css:38-41` | `index.css:516-519, 596` |
| Tile | light | transparent | `var(--accent)` | `tokens.css:60` | `index.css:519` |
| Pill | dark | `#333333` | `#333333` (unchanged) | `tokens.css:16-18` | `index.css:597-598` |
| Pill | light | `var(--muted)` | `var(--border)` | `tokens.css:61-62` | `index.css:520-522` |
| Panel row (6.4px radius, 12px gap) | dark | `#262626` | `#2c2c2c` | `tokens.css:46-49` | `index.css:599-600` |
| Panel row | light | `var(--muted)` | `#eeeeee` | `tokens.css:63-64` | `index.css:523-526` |
Markup: tile `rounded-(--v1-tile-radius) … transition-colors hover:bg-(--v1-tile-hover)
focus-visible:bg-(--v1-tile-hover)` (`Tile.tsx:53`); pill `<span data-pill className="v1-pill rounded-full px-3
py-1 text-xs font-normal leading-4 transition-colors">` (`Tile.tsx:30`), fills from `tokens.css:99-106`; row
`rounded-(--v1-row-radius) bg-(--v1-row) … transition-colors hover:bg-(--v1-row-hover)` (`PanelRow.tsx:91`).
Product: `ArtifactTile.tsx:27, 45`; `index.css:4800-4807`; `ArtifactRow.tsx:93` (fill on an outer
`<div data-artifact-row>`, `:177`; focus ring on the inner control, `:94`).
Why light needs an explicit pill step: the light tile hover fill `--accent` has `--muted`'s value, so
a `--muted` pill would vanish into the hovered tile; `--v1-pill-hover: var(--border)` is one token
shade under it (`tokens.css:62`; `index.css:521`). Dark keeps the pill unchanged. If a new feature's
hover fill differs from the pill's rest colour, drop the step. The row hover step is not in the mock
(`tokens.css:43-47`); light `#eeeeee` is one step under `#f5f5f5` as dark steps `#262626` up to
`#2c2c2c` (`tokens.css:64`).
## 6. What does not animate
**GENERIC rule, ARTIFACT-SPECIFIC values.**
- Shadows: static `filter: var(--v1-shadow-sheet)` on `.v1-sheet` (`tokens.css:116-121`) and inline on
backing imgs (`Stack.tsx:49`); static `box-shadow: var(--v1-shadow-print)` on `.v1-print` (`tokens.css:108-114`)
and, with the inset stroke prepended, on the landscape div (`Stack.tsx:114`). No transition, no hover rule;
they ride with the transformed element. Dark alpha 0.18, light 0.0924 (`tokens.css:24-25, 58-59`;
`index.css:507-508, 594-595`). Porting: box-shadow blur 20px = drop-shadow blur 10px = SVG stdDeviation 10.
- Stroke: the 1px inner hairline is a `::after` overlay on sheets (`tokens.css:123-131`;
`index.css:4782-4789`) and is baked into the three backing SVGs (`stroke="#1A1A1A"
stroke-opacity="0.1" stroke-width="2"` under a clip path; none contains a `<filter>`). The
`tokens.css:22-23` comment about a baked-in filter is stale.
- Scale and opacity: nothing scales or fades on hover in either form. Nothing moves on `:focus-visible`.
## 7. Hooks a component must carry
**GENERIC names-with-prefix, ARTIFACT-SPECIFIC placement.**
| Role | Lab | Product |
| --- | --- | --- |
| Hover trigger | `<button>` (`Tile.tsx:49-54`) | class `artifact-tile` on Link or button (`ArtifactTile.tsx:44-45, 59, 63`) |
| Artwork root (rises) | `data-front` on the wrapper div (`Front.tsx:17, 25, 31`); the media print carries it itself via `data-front={front ? "" : undefined}` (`MediaPrint.tsx:41`, signature `:38`) | `data-artifact-front` on the wrapper div (`ArtifactFront.tsx:26, 34, 40`) |
| Backing (rests, fans) | `data-rotate data-spread={spread}` on the img (`Stack.tsx:52`) and the landscape div (`Stack.tsx:103-104`) | `data-artifact-rotate data-artifact-spread={spread}` plus class `artifact-backing` (`ArtifactStack.tsx:37-38, 44`) |
| Resting angle | inline `--v1-base` (`Stack.tsx:50, 115`) | inline `--artifact-base` (`ArtifactStack.tsx:33, 85`) |
| Pill | `data-pill` + class `v1-pill` (`Tile.tsx:30`) | `data-artifact-pill` + class `artifact-pill` (`ArtifactTile.tsx:27`) |
The bare-print lesson: a print rendered without the front hook stands still while the backings
turn. In this checkout and on `master`, `ArtifactFront.tsx:30-31` returns a bare `<ArtifactPrint …>`
for the media treatment, so a picture or poster-less video front does not rise. Commit `95fb3e219`
on branch `claude/artifacts-v1-picture-front-rise` wraps it in `<div data-artifact-front
className="shrink-0" style={style}>` and adds the test "gives a single picture the same hover-lift
hook as a sheet". Put the hook on every treatment branch and assert it: here
`ArtifactTile.test.tsx:50` checks `querySelectorAll("[data-artifact-spread]")` has length 2 and `:97`
that `[data-artifact-front]` exists; on the fix branch, `[data-artifact-front]` contains the print.
Tailwind `h-(--v1-tile-h)`, `rounded-(--v1-tile-radius)`, `bg-(--v1-row)` resolve only under an ancestor
that defines them; outside `Slot.tsx` (`ui/src/artifact-directions/frames/Slot.tsx:8-14`) a kit collapses.
## 8. Lab plumbing for the Animation toggle
**GENERIC.** Lab-level, not direction-level. Copy unchanged; only the toolbar hint names the direction.
`ui/src/artifact-directions/main.tsx:36-41`, then `useEffect(() => applyAnim(state.anim), [state.anim]);`
at `:151`:
```tsx
function applyAnim(anim: boolean) {
if (anim) document.documentElement.dataset.labAnim = "1";
else delete document.documentElement.dataset.labAnim;
}
applyAnim(initial.anim);
```
Keyboard: bare `a` toggles (`main.tsx:157`), ignored with meta/ctrl/alt or while typing in
INPUT/TEXTAREA/SELECT/contentEditable (`main.tsx:52-54, 155`). URL state in
`ui/src/artifact-directions/lab-state.ts`: default on, read as `anim: params.get("anim") !== "0",`
(`:45`); written only when off, `if (!state.anim) params.set("anim", "0");` (`:52`).
Toolbar (`ui/src/artifact-directions/frames/LabToolbar.tsx:68-71`): `ANIM_OPTIONS` Segmented `off`/`on`,
hints "Hover animations off (toggle with a)" / "Hover animations on: MVP V1 artwork rises, stack pages
turn (toggle with a)"; rendered at `:91-94` with an inline `<span>animation</span>` label to the LEFT of
the Segmented, in the right-aligned `ml-auto flex items-center gap-4` group with Theme (`:89`); legend
"0–5 kit · t theme · a animation" (`:100`). The entry `ui/artifact-directions.html:2` is `<html lang="en"
class="dark" style="color-scheme: dark">` with no `data-lab-anim`; the attribute exists only after `main.tsx:41`.
## 9. `ui/src/motion-tokens.css` and the lab curve
`ui/src/motion-tokens.css` (imported at `index.css:1`) defines `--motion-ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1)`,
`--motion-ease-standard: cubic-bezier(0.4, 0, 0.2, 1)`, `--motion-duration-instant: 80ms`,
`--motion-duration-fast: 160ms` (`:4-7`); the rest are `--onboarding-motion-*`. None equals the hover curve.
`--motion-ease-standard` is Tailwind's default and so is the colour-fade curve; `0.3, 0, 0.1, 1` starts brisker
and settles longer than it, and is gentler than out-expo. No `--motion-duration-*` token is 300ms (only onboarding's
`--onboarding-motion-card-enter`, `:17`). The artifact port kept
`--artifact-hover-ease` in `index.css:531` beside its feature tokens and added nothing to `motion-tokens.css`.
A new feature either does the same or promotes the curve to a named motion token; decide once and record it.
## 10. Operational notes
**GENERIC.** Neither capture path shows the motion; verify hover by hand.
- The overview board (`ui/src/artifact-directions/frames/Board.tsx`) renders every tile as this same page in a
same-origin `<iframe src={labSearch({ direction, view, theme, chrome: false, anim })}>` (`:54, 64-72`) at
`FRAME_WIDTH = 1440`, `FRAME_MIN_HEIGHT = 900` (`:12-13`) scaled down, with `pointer-events-none` on the
iframe (`:70`): a pointer never reaches a tile's `button:hover`. `anim` reaches each iframe only through the
URL param, which that document's own `main.tsx:27, 41` applies to its own `<html>`; keep it in `labSearch`.
- `ui/src/artifact-directions/shot.cjs:121-125` captures with `browser.newContext({ viewport: { width:
1440, height: 900 }, deviceScaleFactor: 2, reducedMotion: "reduce" })`, so the `no-preference` block
does not exist in a capture, and nothing hovers anyway (`lab-state.ts:13-15`). Captures show the
rest state in every kit.
- To verify: open a single surface, e.g. `?d=d5-v1-mvp&s=gallery&theme=dark` (Animation defaults on),
confirm `document.documentElement.dataset.labAnim === "1"`, hover a tile and watch the rise and fan;
press `a`: the fill still steps, nothing moves. Same under the OS reduced-motion preference.
## 11. Checklist for a new direction's `tokens.css`
- [ ] Declares `--<p>-hover-ease: 300ms cubic-bezier(0.3, 0, 0.1, 1)` under `[data-direction="<id>"]`; no other curve.
- [ ] `transition: transform var(--<p>-hover-ease)` is the file's only transition; target `:is([data-rotate][data-spread], [data-front])`.
- [ ] Rest rule `[data-rotate] { transform: rotate(var(--<p>-base, 0deg)); }` sits outside both gates.
- [ ] Transition rule and the three hover transforms sit inside `@media (prefers-reduced-motion:
no-preference)`, each prefixed `html[data-lab-anim="1"] [data-direction="<id>"]`.
- [ ] Front rises `translateY(-6px)`; left page `+ 5deg`; right page `- 5deg`, in `calc()` on the inline base.
- [ ] Hover selectors key on the real trigger element (`button:hover`, or a class when tiles are
anchors); movement is `:hover` only; fills and the pill step use `:is(:hover, :focus-visible)`.
- [ ] Every colour change rides Tailwind `transition-colors` in the markup, outside both gates.
- [ ] Rest and hover tokens for tile, pill and row in both themes; a pill step only where the hover fill equals the pill's rest.
- [ ] Shadows and strokes are static tokens with no transition and no hover rule; no scale or opacity change.
- [ ] Light values sit in the base `[data-direction="<id>"]` block and dark overrides under
`.dark [data-direction="<id>"]` (the `index.css` idiom; d5's `html:not(.dark)` form is the copy
hazard in 4.3); dark/light pairs match the product form's `:root`/`.dark` pairs exactly.
- [ ] Components carry `data-front`, `data-rotate`, `data-spread`, inline `--<p>-base` and a
geometry-derived `transform-origin`; every front treatment branch carries `data-front`, with a
test that asserts it.
- [ ] Verified by hovering in a single-surface view with Animation on, not from the board or a `shot.cjs` capture.
@@ -0,0 +1,221 @@
# Questions and defaults - the intake before `scaffold.mjs new`
Read this after SKILL.md and before touching the generator. It covers only the intake: what to pull out
of the brief, how to ground it in the code, the one `AskUserQuestion` call, and how every flag of
`node .agents/skills/directions-lab/scripts/scaffold.mjs new` is derived. What the generated files look
like is in `generator.md` §5, their precedent in `lab-anatomy.md` and `today-fixtures-and-build.md`; the
Storybook entry and its ids are in `design-proposals-storybook.md` and `repo-gates.md` §1; later stage
moves are hand edits per `lifecycle.md`.
## Contents
1. Extract from the brief
2. Ground the subject in the code
3. The one question call
4. When no question can be asked
5. What is never asked
6. Flag derivation table
7. The three eval briefs as flag lines
8. State every derived value before `new`
## 1. Extract from the brief
Read the brief once and fill these four slots. Each slot either comes from the brief or from a step below;
nothing else is invented.
| Slot | Look for | Becomes |
|---|---|---|
| Subject | the feature noun ("the task composer", "the inbox row", "the agent profile card") | `--slug` (kebab-case of the noun, no "directions", no "lab") and `--title` (sentence case) |
| Places | "shows up in", "appears on", "in the right panel", "on the inbox page", a list of locations | `--surfaces`, one entry per place; one place means no surfaces row |
| Count or names | "3 new directions", "2 directions", or named directions ("Solid, Tint") | `--directions` and, only when named, `--names` |
| Flags and components | `enable...`, `use...Enabled`, a component or file name | the Today kit's `flags`, and a head start for the grep in §2 |
A brief that states a count skips Q1; a brief that names the places skips Q2 (§3). Everything the brief
leaves open is either asked (count, places) or derived (everything else, §6). Keep the brief's own words
for the surface labels when it gives them ("Task thread", "Right panel"); the generator only needs them
to be short.
## 2. Ground the subject in the code
Do this before asking anything. Why: Q2's options must be real usage sites, and Phase 2 of the drawing
(`drawing-phases.md`) needs the flag hook and the API routes that the Today kit must answer. Run from the
repo root; drop test files so the hits are product code.
```sh
rg -l -i "<subject word>" ui/src/components ui/src/pages --glob '!*.test.*'
rg -ln "<ComponentName>\b" ui/src/components ui/src/pages --glob '!*.test.*'
rg -n "use[A-Za-z]*Enabled\(" <component files>
rg -n "useQuery\(|queryFn|queryKeys\." <component files>
rg -n -i "<subject word>" maps/feature-map/*.md
```
- **Component files.** The first grep finds the files that define and render the subject. The second,
run on each defining component, lists the places it is used; those usage sites are the Q2 options.
Record whether the hits are under `ui/src/components` or only under `ui/src/pages`; `--group` follows
from that (§6).
- **Flag hook.** Every experimental flag is read through a `use<Feature>Enabled()` hook in `ui/src/hooks/`
that queries `queryKeys.instance.experimentalSettings` (`ui/src/lib/queryKeys.ts:614`) through
`instanceSettingsApi.getExperimental()`, for example `ui/src/hooks/useStreamlinedUiEnabled.ts:27-32`.
The generated `api-routes.ts` answers that one route from `registry.today.flags`, so the hook name and
the flag key are all you need to write down.
- **API routes.** Follow each `useQuery`/`queryFn` into `ui/src/api/<module>.ts`. Every path string there
is appended to `BASE = "/api"` (`ui/src/api/client.ts:5`, used at `:54`), which is why the lab's fetch
stub can answer them by pathname (`today-fixtures-and-build.md` §3). List the paths; Phase 2 registers
exactly those.
- **Capability ids.** `scripts/feature-map.mjs:6-10` takes the area from the file stem of each
`maps/feature-map/<area>.md`; lines `24-31` read every row of the form `` - `key`: ... `` under
`## Sub-features` and build the id `<area>/<key>`; line `34` enforces `/^[a-z0-9-]+\/[a-z0-9-]+$/`.
`--capabilities` is validated against that same `featureIndex(root)`, so an id you type must exist as a
row, not merely read well. Open the area file's `## Sub-features` block and pick the row whose
description names what the subject does; when no row does, the answer is `[]` (§6).
## 3. The one question call
One `AskUserQuestion` call, at most two questions, placed after §2 and before any file is written. Each
question is dropped when §1 already answered it; when both are answered, no call is made. Headers stay
within 12 characters and each question carries 2-4 options, which is the tool's limit.
**Q1** (skipped when the brief states a count or names the directions)
- `header`: `Directions`
- `question`: "How many directions should the lab explore next to Today? Each direction is a level of
exploration. Unnamed directions scaffold as 1.Refine (tokens and spacing, same structure), 2.Rework
(new components, same layout), 3.Rethink (new layout) and are renamed later by hand
(`lab-anatomy.md` §5 lists the touch points)."
- options: `2` with description "Today + 1.Refine + 2.Rethink"; `3` with description
"Today + 1.Refine + 2.Rework + 3.Rethink".
**Q2** (skipped when the brief names the places)
- `header`: `Surfaces`
- `question`: "Which product surfaces does <subject> appear on? One tab per place."
- `multiSelect: true`
- options: up to three usage sites from §2, each labelled by the product place and described by the file
that renders it there (for example "Task list row" / `ui/src/components/IssuesList.tsx`), plus a last
option "Only one place" described as "<subject> on its primary surface only; no surfaces row". With more
than three sites, choose the three most distinct product places and name the rest in the closest
option's description; a surface left out is added by hand later (`lab-anatomy.md` §8).
Two or more selections produce the surfaces row; one selection or "Only one place" produces a
single-surface lab with the legend joined into row 1 (`lab-anatomy.md` §8, last paragraph).
## 4. When no question can be asked
`AskUserQuestion` is not in every harness. When the call is unavailable or the run is non-interactive,
write this sentence in the reply before running anything, then proceed with the §6 defaults:
> Defaulting to three directions and one surface (<surface>) because I cannot ask; change later with
> `scaffold.mjs add-direction`.
`<surface>` is the place the brief names, else the subject's primary surface from §2: the first usage site
under `ui/src/pages` in grep order, else the first under `ui/src/components`. Say which one and why in the
same reply. `add-direction` is the only lifecycle subcommand this version has; removing a direction or
adding a surface is a hand edit (`lab-anatomy.md` §5 and §8), which is why the sentence names only the
move the CLI can make.
## 5. What is never asked
Theme (both palettes ship behind the `t` toggle), animation (always on behind `a`), Today (always present,
always the shipped components on fixtures), ports, slug, landing, title, group, company and capabilities.
Why: each is fixed by the lab contract or derivable from the brief and the grep, and the question call is
the one chance to ask; spending it on a derivable value costs an answer that only Tonio can give.
## 6. Flag derivation table
Run-mode flags (`--dry-run`, `--json`, `--skip-verify`, `--no-build`, `--keep-on-failure`, `--root`) are not
derived; always run `--dry-run` first.
| Flag | Derivation | Default |
|---|---|---|
| `--slug` | kebab-case of the subject noun; the generator adds `-directions` to the directory, html, config, story and launch entries, refuses a slug that already ends in `-directions`, and refuses when any path it would write exists, so run `scaffold.mjs list` first | required |
| `--title` | sentence case of the subject ("Inbox row"); the page title becomes `<Title> directions` | the slug in sentence case (`Agent profile card`) |
| `--surfaces` | `id:Label:width@nav,...`, one per place, 1-6 entries. `id` is a short lowercase word (never `board`), `Label` the brief's own name for the place, rendered as typed (the precedent hyphenates, `Task-thread`; quote the flag for spaces), `width` from the product constant (table below), `nav` one of `tasks`, `artifacts`, `other` (below) | required |
| `--directions` | the stated count or the Q1 answer; `2` or `3`; 4-9 only with `--force-count`, which WARNs | required |
| `--names` | only when the brief names the directions, comma-separated, in order | `Refine,Rework,Rethink`; `Refine,Rethink` for 2; required with `--force-count` |
| `--group` | `Components` when any §2 hit is under `ui/src/components`, `Pages` when every hit is under `ui/src/pages`; `Layouts` and `Patterns` only when the brief says so. The vocabulary is the existing Storybook section order at `scripts/storybook-feature-index.mjs:11` | `Components` |
| `--capabilities` | the validated `<area>/<key>` ids from §2 whose description names what the subject does; when none fits, `[]`, and the story carries the exception sentence `repo-gates.md` §1.4 describes | `[]` |
| `--company` | `name:PREFIX`, fixed so the fixtures and the stub's `/companies` fallback match the precedent (`today-fixtures-and-build.md` §3) | `acmehats:ACM` |
| `--landing` | `auto` picks `d=all&s=board` when cells = (directions + 1) × surfaces ≤ 8, else `d=all&s=<first surface>`; `all`, `today` or a direction id override it | `auto` |
| `--ports` | chosen by the generator: the first `dev,dev+10` pair from `5200/5210` upward in steps of 20 that avoids every port in `.claude/launch.json` (today `3121`, `6016`, `4173`, `5180`, `5190`), `RESERVED_PORTS` in `scripts/lib/ports.mjs:5` and any `ui/node_modules/.vite-*-<port>` cache directory; pass `dev,preview` only to override, and a taken pair is refused | generator |
Widths are product constants, never measured from a screenshot. Re-check the line numbers when you cite
them; three frame comments in the precedent had drifted (`lab-anatomy.md` §8).
| Place | Width | Source |
|---|---|---|
| Task-chat column (thread, composer dock) | `960` | `ui/src/index.css:575` `--tc-shell-max-w: 60rem`, used by `ui/src/components/task-chat/TaskChatComposerDock.tsx:19` and `ui/src/components/TaskChatThread.tsx:3072` |
| Right pane (task detail properties panel) | `434` | `ui/src/components/PropertiesPanel.tsx:88` `TASK_DETAIL_DEFAULT_PANE_WIDTH = 434` |
| Company page body | `1152` | `max-w-6xl` (72rem) at `ui/src/pages/Artifacts.tsx:248`; the same class caps `ui/src/pages/PluginManager.tsx:192` and `ui/src/pages/Secrets.tsx:1808` |
| A place with no `max-w-*` on its wrapper (the inbox list fills the main column; `ui/src/pages/Inbox.tsx` sets none) | `960` | the default; say in the reply that the width is the default, not a product constant |
`nav` takes `tasks`, `artifacts` or `other` (`scripts/lib/context.mjs:5`); the generated `AppShell` highlights only
Tasks (`ui/src/components/Sidebar.production.tsx:182`, route `/issues`) or Artifacts (`:196`). A place under
Inbox (`:156`) or Agents (`ui/src/components/Sidebar.tsx:273`) takes `other` and gets its highlight by hand in
Phase 1 (`drawing-phases.md` §1).
## 7. The three eval briefs as flag lines
Each case under `.agents/skills/directions-lab/evals/<name>/prompt.md` exercises one intake path. Read the
prompt, then compare with the line below.
**multi-surface-three-directions** - the brief states 3 and names two places, so no question is asked.
Subject "task composer" → `composer`. The grep finds `ui/src/components/task-chat/TaskChatComposer.tsx`,
rendered at the bottom of the thread by `ui/src/components/TaskChatThread.tsx:3305` inside the dock
(`TaskChatComposerDock.tsx:19`, width 960); the thread is `IssueChatThread` or `TaskChatThread`, chosen at
`ui/src/pages/IssueDetail.tsx:1473-1474` and mounted at `:2519`. The product has no chat tab in the right
panel: the 434px pane (`PropertiesPanel.tsx:88`) carries `Properties | Plan | Artifacts` (`:72`), so the
panel surface comes from the brief alone and its frame follows the precedent's `PanelFrame`.
The flag hook is `useStreamlinedUiEnabled` (`TaskChatThread.tsx:495`). `maps/feature-map/tasks.md:21`
`tasks/discussion` ("persist comments, references, and files on the task") is the row that names what a
composer does. Group `Components`. Cells 4 × 2 = 8, so the landing is the board.
```sh
node .agents/skills/directions-lab/scripts/scaffold.mjs new --slug composer --title Composer \
--surfaces thread:Task-thread:960@tasks,panel:Right-panel:434@tasks --directions 3 \
--capabilities tasks/discussion --dry-run
```
**single-surface-two-directions** - the brief states 2 and one place ("on the inbox page"), so no question
is asked. Subject "inbox row" → `inbox-row`, title `Inbox row`. The row is `IssueRow` from
`ui/src/components/IssueRow.tsx`, imported at `ui/src/pages/Inbox.tsx:87` and rendered at `:2851`; the
inbox list has no width cap, so 960 is the stated default; the shell highlights neither Tasks nor Artifacts
there, so nav is `other`. Group `Components` (the row lives under `ui/src/components`).
`maps/feature-map/inbox-search.md:17` `inbox-search/views` names the views the row lists; add
`inbox-search/triage` (`:18`) only when the brief includes the archive affordance. Cells 3 × 1 = 3.
```sh
node .agents/skills/directions-lab/scripts/scaffold.mjs new --slug inbox-row --title "Inbox row" \
--surfaces inbox:Inbox:960@other --directions 2 --capabilities inbox-search/views --dry-run
```
**asks-how-many-directions** - "make me a directions lab for the agent profile card" states neither a count
nor a place, so Q1 and Q2 are asked in one call. No file is named `*ProfileCard*`; the grep lands on
`ui/src/components/AgentIdentity.tsx:3`, `AgentCapsule.tsx:88` and `AgentAvatar.tsx`, used from
`ui/src/components/IssuesList.tsx`, `KanbanBoard.tsx`, `task-chat/TaskChatBubble.tsx`,
`issue-properties/IssueProperties.tsx`, `ActiveAgentsPanel.tsx` and more, so Q2 offers three of those
places plus "Only one place" and names the rest in the descriptions. No row under
`maps/feature-map/agents.md:15-27` describes an identity card, so capabilities are `[]` with the exception
sentence. Without the question tool, the §4 sentence is written and the lab is 3 directions × 1 surface:
```sh
node .agents/skills/directions-lab/scripts/scaffold.mjs new --slug agent-profile-card \
--title "Agent profile card" --surfaces list:Task-list:960@tasks --directions 3 --dry-run
```
With answers, `--directions` takes the Q1 value and `--surfaces` gains one entry per selected place.
## 8. State every derived value before `new`
Before the real `new`, the reply lists, in this order: slug and title; each surface as `id:Label:width@nav`
with the width's source line; the direction count and names; the group; the capability ids with the row
each came from, or `[]` and the exception; the landing and cell count; the flag hook and API paths the
Today kit will need; the `--dry-run` output. Then run `new` and quote its `--json` output.
Why this is not optional: this version of the generator has `new`, `check`, `add-direction` and `list`
only. There is no rename, no add-surface and no stage, so a wrong slug, width, surface list or capability
after `new` means removing the lab directory, its html, its vite config, the two launch entries, the
`.gitignore` line and the story by hand before trying again. The story meta id `design-<slug>-directions`
is permanent once catalogued (`design-proposals-storybook.md` §4). An unknown capability is refused before
any file is written (`scripts/lib/capabilities.mjs`); the catalog check, the lab's Vitest files and the Vite
build run after the write and roll everything back on failure. A derived value stated in prose costs Tonio
one line to correct; the same value baked into files costs a rollback.
@@ -0,0 +1,355 @@
# Repo gates a lab must pass
Nothing in CI knows about labs: a lab PR merges through the same gates as any `ui/` change. Each gate
below names the rule it checks and the local command that proves it. Every rule is GENERIC unless marked
ARTIFACT-SPECIFIC; artifact-specific values come from the worked example (the artifact directions lab,
PR #17213) and must be redrawn per feature. Line numbers were confirmed on `claude/directions-lab-skill`
at `5e26b733b`.
## Contents
1. Storybook catalog rules for design-proposal stories
2. Validate the catalog; generated outputs are git-ignored
3. Token gates and the lab's `tokens.css`
4. PR rules from CONTRIBUTING and the template
5. commitperclip gates (blocking)
6. Greptile and the `rules/*.mdc` files
7. Vitest conventions and the lab-only test command
8. Hand-off checklist
## 1. Storybook catalog rules for design-proposal stories
`scripts/storybook-catalog.mjs` parses every story file without executing it (`loadCsf`, line 65) and
validates the result (lines 83-116). Line numbers in 1.1-1.5 refer to that script unless a path is given.
### 1.1 Location, title, meta id
- File under `ui/storybook/stories/design-proposals/` (`storyRoot` line 12; folder rule line 107).
Storybook's glob `../stories/**/*.stories.@(ts|tsx|mdx)` (`ui/storybook/.storybook/main.ts:23`) finds it.
- Title starts with `Design proposals/` (`specialHomes` line 17; rule line 108). Convention:
`Design proposals/<Lab display name>/<Suite>`.
- Explicit meta `id` (line 109). Convention: `design-<lab>-<suite>`. Taken ids:
`grep -n "^ id:" ui/storybook/stories/design-proposals/*.stories.tsx` (20 files, 18 prefixed `design-`).
- ARTIFACT-SPECIFIC: `design-artifact-directions-compare`, `design-artifact-directions-hybrid`, group
`Artifact directions` (`ui/storybook/stories/design-proposals/artifact-directions-compare.stories.tsx:39-40`,
`artifact-directions-hybrid.stories.tsx:28-29`).
- The sub-order under Design proposals is hardcoded in `scripts/storybook-feature-index.mjs:11`
(`Foundations, Components, Layouts, Pages, AI Generated Test Pages, Patterns, Guidelines`); a lab group
outside the list sorts after them; the catalog does not enforce group names. Never edit the generated
`preview.tsx` (`maps/storybook-map/README.md:90`).
- `ui/storybook/.storybook/preview-base.tsx:853` wraps a story whose `parameters.paperclip.home` is
`"design-proposals"` in `<AvatarInitialProvider><div className="design-proposal-theme">` unless
`parameters.designProposalTheme === false`; `html:has(.design-proposal-theme)` switches `--font-mono` to
Paper Mono (`ui/src/index.css:3916-3918`). Theme toolbar defaults to dark (line 861); a11y is `test: "error"` (line 876).
### 1.2 The `parameters.paperclip` block: fields and allowed values
Allowed keys (line 16): `capabilities`, `status`, `implementation`, `boundary`, `notes`, `home`,
`exception`, `replacement`; any other key throws `unknown paperclip field` (line 57).
| Field | Allowed values | Lab value |
| --- | --- | --- |
| `home` | `foundations`, `design-proposals` (line 95) | `"design-proposals"` |
| `status` | `current`, `proposed`, `historical`, `mixed`, `unreviewed` (line 13) | `"proposed"`; forced by the home (line 99) |
| `exception` | nonempty string (line 96) | required by the home (line 100) and by empty `capabilities` (line 103) |
| `capabilities` | array of strings (line 94); each an existing `<area>/<key>` (line 102); no duplicates (line 104) | see 1.4, or `[]` with an `exception` |
| `implementation` | `production`, `prototype`, `mixed`, `guide`, `unreviewed` (line 14) | `"prototype"`; `"production"` when the story mounts shipped components |
| `boundary` | `fixtures`, `fixture-server`, `static`, `unreviewed` (line 15) | `"fixtures"` |
| `notes` | string (line 93) | what is simulated; say nothing proves production behaviour |
| `replacement` | repo-relative path of an existing story file (line 110) | omit |
Defaults when omitted: `status`, `implementation`, `boundary` are `"unreviewed"`; `notes` is `""` (line 77).
### 1.3 Placement and the literal-only rule
- `parameters` is an object literal with exactly one non-computed `paperclip` property (lines 47-51).
- Every value inside the block is a string, boolean, number, array or object literal; `as` and
`satisfies` are unwrapped (lines 26-42). An identifier, call, spread or template string inside the block
throws `metadata must be literal; expressions are not executed` (line 42). Expressions in `argTypes`,
`args` and `component` are fine.
- No `...spread` or computed property may come after `paperclip` (lines 52-54). Literal properties may
follow it; placing it last is convention, not rule.
- `annotation()` throws at scan time (one error per run); `validateCatalog` errors are joined (lines 189-190).
### 1.4 Capabilities
Ids come from `maps/feature-map/<area>.md`: the file stem is the area, each row `- \`key\`: ...` under
`## Sub-features` is a key, and the id is `<area>/<key>` matching `/^[a-z0-9-]+\/[a-z0-9-]+$/`
(`scripts/feature-map.mjs:24-37`).
- `ls maps/feature-map/` lists the areas; read the area's `## Sub-features`.
- No fitting area: `capabilities: []` plus an `exception` (`ui/storybook/stories/design-proposals/colors.stories.tsx:13-21`).
- Do not add a feature-map area without its agent-access companion: `checkMaps` validates both
(`scripts/check-maps.mjs:31-34`, `maps/README.md:10-11`) and runs before the catalog (line 184).
- Proposals never count as coverage: `renderVerification` skips `proposed` and `historical` entries
(line 165) and the index files them under "Proposed design system" (line 133).
- ARTIFACT-SPECIFIC: `documents-artifacts/library`, `documents-artifacts/work-products`,
`documents-artifacts/attachments` (`maps/feature-map/documents-artifacts.md:17-21`).
### 1.5 Uniqueness
Meta ids are unique among meta ids (lines 112, 114). Story ids (per export) are unique and must not
collide with the generated `feature-map-<featureId>--overview` ids (line 113): never a meta id
`feature-map-<area>` with an export named `Overview`.
### 1.6 Worked example
`ui/storybook/stories/design-proposals/artifact-directions-compare.stories.tsx:38-59`. Reproduce the
shape; redraw every string.
```tsx
const meta = {
id: "design-artifact-directions-compare",
title: "Design proposals/Artifact directions/Compare",
component: DirectionView,
parameters: {
layout: "fullscreen",
docs: {
description: {
component:
"Pick a direction and a surface. Today is the shipped components fed with the lab's fixtures; 1–5 are the explored directions.",
},
},
paperclip: {
capabilities: ["documents-artifacts/library", "documents-artifacts/work-products", "documents-artifacts/attachments"],
status: "proposed",
home: "design-proposals",
implementation: "prototype",
boundary: "fixtures",
exception: "Design exploration staged in the artifact directions lab: the shipped baseline and five proposed directions compared on static mocks of the task thread, the side panel and the Artifacts page.",
notes: "Controls pick a direction kit and a surface. Today mounts the shipped artifact components behind the lab's fetch stub with the acmehats fixture world; the other kits are lab components. Frames are static mocks of the product chrome; nothing here establishes production behaviour or access checks.",
},
},
```
Lines 60-74 add `argTypes` built from runtime constants (`DIRECTION_IDS`, `KIT_META`, `SURFACE_IDS`),
default `args`, and `} satisfies Meta<typeof DirectionView>;`. GENERIC: `layout: "fullscreen"`,
`docs.description.component`, the six `paperclip` fields, runtime constants in `argTypes`.
ARTIFACT-SPECIFIC: every string, the constants, the default args. The per-direction story
(`artifact-directions-hybrid.stories.tsx:27-48`) has the same block with no `component` and one `render`
export per surface and per slot (lines 56-155).
## 2. Validate the catalog; generated outputs are git-ignored
```sh
node scripts/storybook-catalog.mjs --check
```
- `--check` validates without writing; no flag or `--write` also writes the generated files (lines
202-206); any other argument throws the usage error. Success prints
`Storybook map: N files, M stories. Structure only; no runtime proof.` (316 files, 2973 stories here).
- `cd ui && pnpm typecheck` runs the catalog in write mode first:
`"typecheck": "node ../scripts/storybook-catalog.mjs && tsc -b"` (`ui/package.json:23`).
`ui/tsconfig.json:19` includes `src`, `storybook`, `vitest.setup.ts`, so lab code, tests and stories are typechecked.
- CI runs it on every PR: the Typecheck lane runs `pnpm run typecheck:build-gaps`
(`.github/workflows/pr-trusted.yml:333`; `package.json:25`), and `scripts/run-typecheck-build-gaps.mjs:143-150,178-183`
runs `pnpm --filter <name> typecheck` for each workspace whose `build` script has no `tsc`, which
includes `ui` (`"build": "vite build"`, `ui/package.json:18`).
- `pnpm storybook` and `pnpm build-storybook` call `generateStorybook()` at config load
(`ui/storybook/.storybook/main.ts:10`), so a bad annotation also stops Storybook. `pnpm build-storybook`
is not on the default PR path, but `DESIGN.md:814` lists it as done-when: run it or report it as not run.
- The visual suite runs only with the `storybook-visual` label (`.github/workflows/storybook-visual.yml:42`)
and screenshots every story in both themes with `maxDiffPixels: 0`
(`tests/storybook-visual/playwright.config.ts:42`, `storybook-visual.spec.ts:108-118`); new stories have
no baseline until a maintainer runs `pnpm test:storybook-visual:update`.
- `ui/storybook-static/`, `ui/storybook/generated/`, `ui/storybook/.storybook/preview.tsx` and
`maps/generated/` are ignored (`.gitignore:11-14`). Never commit or hand-edit them (`maps/storybook-map/README.md:86-90`).
## 3. Token gates and the lab's `tokens.css`
`scripts/check-token-gates.mjs` scans only `ui/src/components/**` and `ui/src/pages/**` (`SCAN_DIRS`,
line 80), only `.ts/.tsx/.js/.jsx` files (line 111); Gate 4 reads `ui/src/index.css` alone (lines 81,
296-303). No other CSS file is read.
- GENERIC: a lab under `ui/src/<lab>/` is outside gates 1-3. Its per-direction `tokens.css` may hold raw
hex, px, ms and `cubic-bezier` values and its components may use Tailwind bracket values; `DESIGN.md:30`
permits this for demo/UX-lab surfaces (`doc/design/DECISION-SHEET.md:20`, ruling B1). Keep every raw
value in a `tokens.css` scoped to `[data-direction="<id>"]`, light values in the base block and dark
overrides under `.dark [data-direction="<id>"]`.
- Anything that graduates to product moves under `components/` or `pages/` and must become tokens in
`ui/src/index.css` (`DESIGN.md:17,30`; a `tokens.css` is allowed there only when `index.css` imports it).
The gates would fail the lab code as it stands.
- A lab page that must live under `ui/src/pages/` needs an `* allow ui/src/pages/<File>.tsx — <reason>`
line in `index.css` (`check-token-gates.mjs:89-104`; precedent `ui/src/index.css:3195-3198`).
- ARTIFACT-SPECIFIC evidence: `ui/src/artifact-directions/directions/d5-v1-mvp/PanelRow.tsx:30,32,65`
(`gap-[3px]`, `h-[2px]`, `rounded-[4px]`), `d5-v1-mvp/tokens.css:9-12,24-25,56-65` (hex, rgba, px),
`d4-hybrid/tokens.css:97` (`--hy-stagger: 28ms`).
```sh
pnpm run check:token-gates
```
`package.json:47` chains `check-token-gates.mjs`, `sync-agent-palette-tokens.mjs --check` and
`sync-cliplab-character.mjs --check`. Use `pnpm run`; the bare shorthand has failed with
`Command not found` under pnpm 9.15.4 in a worktree. Expect four gates `CLEAN`.
## 4. PR rules from CONTRIBUTING and the template
- Fill every section of `.github/PULL_REQUEST_TEMPLATE.md`: `## Thinking Path` (line 3),
`## Linked Issues or Issue Description` (19), `## What Changed` (52), `## Verification` (58), `## Risks`
(67), `## Model Used` (78), `## Checklist` (93-108). Delete the template's HTML comments.
- `CONTRIBUTING.md:49`: no code file moves from under 1,000 lines to over. `CONTRIBUTING.md:50`: "Do not
submit a PR that includes code comments." Neither is checked by commitperclip; Greptile enforces the
1,000-line rule through `rules/pull-request-review.mdc:22-25`. PR #17213 merged with doc comments, which
are still in the lab's stories, `tokens.css` files and Vite config; the Artifacts V1 feature (PR #17228)
needed a follow-up commit (`e1433f508`) to strip its own: scaffold comment-free.
- Public references only: `#NNN`, `Fixes #NNN`, `github.com/paperclipai/paperclip` URLs; no `PAP-123`
ids, instance or localhost links (`CONTRIBUTING.md:68-80`).
- Descriptive kebab-case branch name before pushing (`CONTRIBUTING.md:82-98`). No screenshots in the
repo; put them in the PR (`CONTRIBUTING.md:100-106`).
- `Model Used`: provider, exact model id, context window, reasoning mode, tools (`CONTRIBUTING.md:108-110`).
Simplified Technical English (`CONTRIBUTING.md:173`). Verification names what was not run and why
(`AGENTS.md` section 7).
## 5. commitperclip gates (blocking)
`.github/workflows/commitperclip-review.yml` runs on `pull_request_target` (lines 4-5) from `master`
(lines 20-23) and fails when a blocking gate fails (lines 57-63). Blocking (`.github/scripts/run-quality-gates.mjs:139-145`;
the check scripts below sit in the same directory):
template, linked issue, dedup search, test coverage, lockfile. Dependencies, release bootstrap and
co-authors are informational.
### Template (`check-pr-template.mjs`)
Lines 9-15: `## Thinking Path` needs 3 or more items; `## What Changed`, `## Verification`, `## Risks`,
`## Model Used` need 1. Content runs to the next `\n## ` (lines 23-29); items split on terminal
punctuation, bullet or quote line starts, or blank lines, keeping items over 5 characters (line 35).
Empty content, `_No response_` or content starting with `<!--` fails (line 56). `Model Used` fails on
`provider, model id`, `your model`, `<model>` (lines 17-21).
### Linked issue (`check-pr-linked-issue.mjs`)
Skipped for `docs`, `chore`, `build`, `ci`, `style`, `test`, `revert` (line 21). A `feat:` PR passes by
path A or B:
- A: `(fixes|closes|resolves|refs) #N`, a `github.com/paperclipai/paperclip/issues/N` URL, or a bare `#N`
(lines 14-18); HTML comments are stripped first (145-147, 178).
- B: at least 3 filled fields from ONE template's field set (line 25; loop 153-157); labels from different
templates do not add up. A label stands alone on its line as `## Label`, `**Label**` or `Label:`; the
content under it must be real: a lone `-` or `[...]` does not count (109-118, 122-132), and a label
followed directly by another label is empty.
- Feature-request labels the gate counts (lines 39-44): `Problem or motivation` (`Problem`, `Motivation`),
`Proposed solution` (`Solution`), `Alternatives considered` (`Alternatives`), `Roadmap alignment`
(`Roadmap`). `.github/ISSUE_TEMPLATE/feature_request.yml` also has `Subsystem affected` (line 12) and
`Additional context` (line 52); they do not count toward the 3.
- PR #17213 used path B with all six as `**Label**` lines. ARTIFACT-SPECIFIC: its roadmap line
("Artifacts & Work Products").
### Dedup search (`check-pr-dedup-search.mjs`)
A checked checkbox line matching
`/^\s*[-*]\s*\[\s*([ xX])\s*\][^\n]*search(?:ed)?[^\n]*(?:similar|duplicate|prior)[^\n]*\bprs?\b/im`
(lines 20-21). Check template line 98:
`- [x] I have searched GitHub for duplicate or related PRs and linked them above`. The other 13 checklist
items are not machine-checked.
### Test coverage (`check-pr-test-coverage.mjs`)
Skipped for `docs`, `chore`, `build`, `ci`, `style`, `refactor`, `revert` (line 21). Otherwise at least
one non-removed file matches `/\.test\.(ts|js|tsx|jsx|mjs|cjs)$/`, `/\.spec\....$/`, `/(?:^|\/)tests?\//`
or `/\/__tests__\//` (lines 11-16, 64-66). `.stories.tsx` files and fixture factories like
`test-artifact.ts` do not count. A `docs:`/`chore:`/`build:`/`ci:` title with source changes fails
(lines 25, 43-57). Title a lab PR `feat(ui): ...` and ship at least one `.test.ts(x)`.
### Lockfile (`check-pr-lockfile.mjs`)
A diff touching `pnpm-lock.yaml` fails unless the author is `github-actions[bot]` on
`chore/refresh-lockfile` (lines 9-14). A lab adds no dependencies and commits no lockfile change.
## 6. Greptile and the `rules/*.mdc` files
- Greptile must score 5/5 with no open P2+, recommendations or follow-ups (`CONTRIBUTING.md:126-134`);
it reads rules through `.greptile/files.json` and `.greptile/rules.md` (`rules/README.md:15-19`).
- No rule's `globs` covers `ui/` or `ui/storybook/` (scoped rules cover `server/`, `packages/db`,
adapters, adapter-utils, plugins, runner). Only the three `alwaysApply: true` rules apply to a lab PR:
`rules/pull-request-review.mdc` (no screenshot requests, lines 17-21; 1,000-line crossing, lines 22-25),
`rules/module-boundaries.mdc`, `rules/product-harness-responsibility.mdc`.
- `.greptile/rules.md` last line: "Do not add package.json scripts for new utilities, checks, benchmarks,
or specialized tests." Invoke the lab's Vite config directly and record the command in
`.claude/launch.json` (ARTIFACT-SPECIFIC: `.claude/launch.json:27-44`, ports 5180 and 5190;
`ui/vite.artifact-directions.config.mjs:10-26`).
## 7. Vitest conventions and the lab-only test command
The `ui` project (`vitest.config.ts:28`) uses `environment: "node"`, processes no CSS except
`motion-tokens.css`, and loads `./vitest.setup.ts` (`ui/vitest.config.ts:11-14`), which mocks
`localStorage` and stubs `Element.prototype.scrollIntoView` (`ui/vitest.setup.ts:3-40`). Kit
`import "./tokens.css"` lines are inert; assert on class names, attributes and inline styles, not
computed styles.
- Pure modules: no pragma, `import { describe, expect, it } from "vitest"`, relative import of the module
(`ui/src/artifact-directions/lab-state.test.ts:1-7`, `directions/d5-v1-mvp/when.test.ts:1-4`,
`kinds.test.ts`, `MediaPrint.test.ts`).
- DOM tests (`ui/src/artifact-directions/directions/d5-v1-mvp/Thread.test.tsx:1-27`; the hooks sit inside
`describe` after `let container: HTMLDivElement; let root: Root;`):
```tsx
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
```
Render with `act(() => root.render(<X />))`. A test that stubs `window.fetch` restores it in `afterEach`
(`ui/src/artifact-directions/api-stub.test.ts:7-14`). A fixture factory `test-<entity>.ts` takes
`Partial<T> & Pick<T, "kind">` (`d5-v1-mvp/test-artifact.ts`) and is not a test file for the coverage gate.
When a tested component renders the app's `Link`, mock `CompanyContext` and wrap in `MemoryRouter`:
`ui/src/lib/router.tsx:67` exports a `Link` that reads `useCompany()` (lines 5, 31). Pattern
(`ui/src/plugins/outlet-errors.test.tsx:4,13,35`):
```tsx
import { MemoryRouter } from "react-router-dom";
vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompany: null }) }));
```
The artifact lab's own tests render no `Link` and do not use this mock.
Run only the lab's tests (`ui/package.json` has no `test` script):
```sh
cd ui && npx vitest run src/<lab>
```
ARTIFACT-SPECIFIC: `cd ui && npx vitest run src/artifact-directions` runs 6 files, 31 tests. Run one
full `ui` vitest at a time; concurrent full runs produce spurious failures.
## 8. Hand-off checklist
Run each from the repo root, in this order; paste results under `## Verification` with a `Not run:` line
for anything skipped and why. The first five are what
`node .agents/skills/directions-lab/scripts/scaffold.mjs check --slug <slug> --run` runs, stopping at the
first failure (`scripts/lib/check.mjs:148-154`); `pnpm build-storybook` stays a manual step that `--run`
reports as `Not run:`.
```sh
cd ui && npx vitest run src/<slug>-directions
node scripts/storybook-catalog.mjs --check
pnpm run check:token-gates
cd ui && <SLUG>_DIRECTIONS_OUT_DIR=node_modules/.<slug>-directions-dist npx vite build --config vite.<slug>-directions.config.mjs
cd ui && pnpm typecheck
pnpm build-storybook
```
- Fresh worktree: `pnpm install --frozen-lockfile` and build `@paperclipai/plugin-sdk` first, or `tsc`
reports `Cannot find module '@paperclipai/plugin-sdk/ui'` in `PluginOrganizationSwitcher(.test).tsx`.
Repo-wide `pnpm -r typecheck` needs the Rust toolchain for `paperclip-runner`; report it as not run when absent.
- PR title `feat(ui): ...`; one or more `.test.ts(x)` files in the diff; no `pnpm-lock.yaml` change; no new
`package.json` script; no file crossing 1,000 lines; no code comments; no committed screenshots or
generated files.
- Body: five `##` sections with content, path A link or path B with 3 filled feature-request labels,
checked dedup checkbox, concrete `Model Used`. Then Greptile 5/5 and maintainer approval.
@@ -0,0 +1,327 @@
# Today, fixtures, build, run, capture, deploy
Reference for scaffolding a new directions lab. Worked example: the artifact lab at `ui/src/artifact-directions/` with entry `ui/artifact-directions.html` and config `ui/vite.artifact-directions.config.mjs`. Line numbers were confirmed against this checkout on 2026-10-24; cite symbols, not lines, in generated code. Tags: **GENERIC** = copy for any feature; **ARTIFACT-SPECIFIC** = redraw per feature.
## Contents
1. Today kit: the production control
2. Adapters: lab fixtures to product types
3. API stub
4. Fixture world
5. HTML entry and what main.tsx mounts
6. Lab Vite config, option by option
7. launch.json: two entries
8. Product build, tsc, vitest, token gates
9. Tests the lab carries
10. shot.cjs: capture
11. Vercel deploy
12. What a generator parameterizes
## 1. Today kit: the production control
**GENERIC.** The Today kit renders the shipped product components on the lab's fixtures, so every other direction is judged against what ships. Rules:
- One directory `directions/today/` whose `index.tsx` exports `kit: DirectionKit` (`ui/src/artifact-directions/directions/today/index.tsx:8-17`). A kit never renders chrome and never imports a frame (`ui/src/artifact-directions/kit.ts:3-14`).
- One component file per slot. Mount the shipped component when it is a standalone export. When the product markup is inline JSX inside a larger component, copy that JSX minus handlers, import the real primitives and helpers, and say so in the file header, naming the branch copied (`directions/today/ThreadAttachments.tsx:17-23` mirrors the `!artifactsV1` blocks at `ui/src/components/task-chat/TaskChatBubble.tsx:277,320`; V1 renders a different combined block at `:436`).
- Reproduce product layout constants by value and cite the string, not a line (`directions/today/Gallery.tsx:7` carries `grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3`, found once at `ui/src/pages/Artifacts.tsx:317`; the file's own comment cites a stale line).
- Register the id in `DirectionId`, `DIRECTION_IDS`, `KIT_KEYS` and `KIT_META` (`kit.ts:16,130,133-140,143-150`). The toolbar and board read `KIT_META`; the kit's own `name`/`tagline` are read only by the kinds sheet (`frames/KindsSheet.tsx:47-48`). Keeping them equal is a convention (`kit.ts:142`), not a check. A generated lab registers once, as one line in `registry.ts`: its `kit.ts` derives `DirectionId`, `DIRECTION_IDS`, `KIT_KEYS` and `KIT_META` from the registry, and `registry.test.ts` fails when a kit's `id`, `name` or `tagline` differs from the registry's (`scaffold.mjs check` compares `id` and `name`).
Kit discovery (`kit.ts:157-163`), **GENERIC** verbatim:
```ts
const KIT_LOADERS = import.meta.glob<{ kit: DirectionKit }>("./directions/*/index.tsx");
export function loadKit(id: DirectionId): Promise<DirectionKit> {
const load = KIT_LOADERS[`./directions/${id}/index.tsx`];
if (!load) return Promise.reject(new Error(`No kit at directions/${id}/index.tsx`));
return load().then((module) => module.kit);
}
```
**ARTIFACT-SPECIFIC**, what the artifact Today kit mounts:
- `GalleryStacks` / `GalleryStack`: `ArtifactGroupCard` and `ArtifactCard` on the product grid (`Gallery.tsx:10-34`).
- `PanelBody`: the real `IssuePropertiesArtifactsTab`, given `{ id: registerPanel(artifacts), companyId: COMPANY.id } as Issue` and `onOpenDocument={noop}` (`PanelBody.tsx:14-19`). The tab reads only `issue.id` and `issue.companyId`; the product also passes `uploadState` (`ui/src/components/task-side-panel/TaskSidePanel.tsx:770`), which Today omits.
- `ThreadWorkProduct`: a task document becomes a `TaskChatProtocolCard` resource item with ``subtitle: `Document · rev ${artifact.revision}` ``; anything else becomes `RichWorkProductCard` with `workProductHref(workProduct)` (`ThreadWorkProduct.tsx:13-31`; the product split is `ui/src/components/TaskChatThread.tsx:1290-1312`).
- `ThreadAttachments`: mirrored; uses `@/components/ui/attachment`, `ArtifactPreview`, `fileKindForAttachment`, `formatFileSize`, `isImageAttachment`, `isVideoLikeOutput` (`ThreadAttachments.tsx:1-15`).
**GENERIC**, flag-gated components. Before mounting, grep the feature's flag hook under `ui/src/components` and `ui/src/pages`. For artifacts, `useArtifactsV1Enabled()` is called at `IssuePropertiesArtifactsTab.tsx:414`, `TaskChatBubble.tsx:149`, `TaskChatProtocolCard.tsx:1045` and in `pages/Artifacts.tsx`; it resolves `settings?.enableArtifactsV1 === true` (`ui/src/hooks/useArtifactsV1Enabled.ts:7-9`) from `GET /api/instance/settings/experimental` (`ui/src/api/instanceSettings.ts:20-21`). The artifact stub has no route for it, answers `{}`, and the flag is false: Today's panel and its task-document thread row render the flag-OFF presentation (the generic card from `TaskChatProtocolCard.tsx:1072`), while `ArtifactCard`, `ArtifactGroupCard` and `RichWorkProductCard` are flag-independent. To show a flagged presentation under Today, register a route returning `{ enableArtifactsV1: true }` for that pathname. A generated lab already has that route: `api-routes.ts` answers any pathname ending `/instance/settings/experimental` with `registry.today.flags` (`{}` as scaffolded), so set the flag in `registry.ts` (`today: { flags: { enableArtifactsV1: true } }`) or at run time with `setLabFlags`.
## 2. Adapters: lab fixtures to product types
**GENERIC pattern** (`directions/today/adapters.ts`): pure functions `LabRecord -> product type`, one per prop type the mounted components take, each mirroring what the server would produce. Pin the base object with `} satisfies Partial<IssueWorkProduct>;` (`adapters.ts:64`) so field drift against `@paperclipai/shared` fails typecheck. Deterministic ids, verbatim (`adapters.ts:14-25`):
```ts
export function guid(seed: string): string {
let h = 0x811c9dc5;
let hex = "";
for (let round = 0; hex.length < 32; round += 1) {
for (const char of `${seed}:${round}`) h = Math.imul(h ^ char.charCodeAt(0), 0x01000193);
hex += (h >>> 0).toString(16).padStart(8, "0");
}
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
}
export const agentId = (name: string) => guid(`agent:${name}`);
export const issueIdFor = (taskIdentifier: string) => guid(`issue:${taskIdentifier}`);
```
Seed conventions: `agent:<name>`, `issue:<identifier>`, `<entity>:<fixture id>` (`attachment:`, `work-product:`, `document:`, `revision:` at `adapters.ts:39,48,89,95`), `panel:<comma-joined ids>` (`panel-api.ts:18`). Product hrefs use `COMPANY.issuePrefix` (`adapters.ts:172,187,202`). The scaffold's `adapters.ts` carries `guid`, `agentId` and `issueIdFor` verbatim plus `issueHref(taskIdentifier)`; every other adapter is drawn by hand.
**ARTIFACT-SPECIFIC:** `isTaskDocument` (`:28`); `attachmentPaths`, which points media at bundled asset URLs (`:38-44`; the product's canonical-path test at `ui/src/components/task-chat/RichWorkProductCard.tsx:231` then only hides "Open in tab"); `toWorkProduct` (`:46-84`); `toIssueDocument` (`:86-108`); `toAttachmentRef` (`:113-124`); the hand-copied `previewText` (280 chars, `:127-137`) and `mediaKind` (`:143-150`) mirrors of `server/src/services/company-artifacts.ts:89-121`; `toCompanyArtifact` (`:152-189`); `toCompanyArtifactGroup` (`:191-204`). Mirrors drift (the server returns `null` for an empty preview, strips backticks differently and lowercases content types); re-read the server before relying on exact text. The plan doc fixes the direction of reuse: product code reuses the lab's code, not its types; the lab keeps its own copies (`doc/plans/2026-10-22-artifacts-mvp-v1.md:28-32`).
## 3. API stub
**GENERIC verbatim, except the two fallbacks.** `ui/src/artifact-directions/api-stub.ts` replaces `window.fetch`. No module mocks are needed because `ui/src/api/client.ts:54` calls the global ``fetch(`${BASE}${path}`, ...)`` at call time with `const BASE = "/api"` (`:5`), so every react-query `queryFn` is intercepted.
- `registerApiRoute(route)` pushes an `ApiRoute = (pathname, method) => unknown | undefined` (`:14-20`). `respond()` tries routes in registration order; the first body `!== undefined` is returned as JSON 200 (`:25-29`).
- Built-in fallbacks (`:30-33`): a pathname ending `/auth/get-session` answers a lab session and user; one ending `/companies` answers `[COMPANY]`. They satisfy `sessionQueryOptions` and `companyListQueryOptions` (`ui/src/api/companies-query.ts:55-59,19-40`); `CompanyProvider` then selects `selectableCompanies[0]?.id` (`ui/src/context/CompanyContext.tsx:81`). Keep both; swap `COMPANY`.
- Any other `/api/` path answers `{}` with status 200 after `console.debug("[artifact-directions] unhandled", method, pathname)` (`:36-37`; a generated lab logs `[<slug>-directions] unhandled`). Never a 4xx, so a missing route never breaks a frame and never trips the HTTP gate in shot.cjs. Read that debug line to find the routes a new Today kit must register.
- Matching is pathname-only: `new URL(url, window.location.origin).pathname` (`:58`). `/companies?scope=accessible` (`ui/src/api/companies.ts:28`) collapses to `/api/companies`; routes must not include query strings. A URL without `/api/` passes through (fonts, assets).
Installation, verbatim (`api-stub.ts:40-42,50-71`):
```ts
let holders = 0;
let realFetch: typeof window.fetch | null = null;
let stubFetch: typeof window.fetch | null = null;
export function installApiStub(): () => void {
if (holders === 0) {
const original = window.fetch;
const real = original.bind(window);
realFetch = original;
stubFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
if (!url.includes("/api/")) return real(input as RequestInfo, init);
const { pathname } = new URL(url, window.location.origin);
return respond(pathname, (init?.method ?? "GET").toUpperCase());
}) as typeof window.fetch;
window.fetch = stubFetch;
}
holders += 1;
let released = false;
return () => {
if (released) return;
released = true;
holders -= 1;
if (holders === 0 && realFetch && window.fetch === stubFetch) window.fetch = realFetch;
};
}
```
Installs are refcounted; each restore is idempotent; the last release restores only if nothing else replaced `fetch` meanwhile. The page installs once at module top, before `createRoot` (`main.tsx:25`); a Storybook story installs on mount and calls the restore on unmount.
**GENERIC pattern with ARTIFACT-SPECIFIC routes**: the per-kit `<thing>-api.ts` (`directions/today/panel-api.ts`); in a generated lab the same code goes in the lab-root `api-routes.ts`, inside its empty `registerFeatureRoutes()`, next to the experimental-settings route it already registers, and `main.tsx`, the story and `today.test.tsx` import that module. Module-level `Map` keyed by a minted id (`:13-14`); `registerPanel(list): id`, called inside the slot's `useMemo` and passed as the product entity id (`:17-25`, `PanelBody.tsx:16`); `registerApiRoute(...)` at module import time so routes go live when the kit loads (`:75-90`). Find the routes to answer by grepping the mounted component for `useQuery`/`queryFn` and following each api module to its path string. The artifact tab issues (`IssuePropertiesArtifactsTab.tsx:414-443`): `GET /api/issues/:id/attachments`; `/work-products` through `useIssueWorkProducts` (`refetchOnMount: "always"`; its second `?refreshPullRequests=true` query runs only when a row has `type === "pull_request"`, `ui/src/hooks/useIssueWorkProducts.ts:10-25`); `/documents` with no `includeSystem` (`ui/src/hooks/useIssueDocuments.ts:13-19`); `/runs`; `GET /api/companies/:companyId/agents`; `/documents/:key/annotations` per expanded document (`ui/src/api/document-annotations.ts:30`); and the experimental-settings flag above. Canned bodies: creation times a minute apart from one anchor so list order survives the tab's sort (`panel-api.ts:32`); a review document per Markdown file (`:40-50`); runs deduped by id (`:52-67`); annotation placeholders sized by `commentCount` (`:69-73`). Gotchas: import-time registration means a test importing `PanelBody` without `panel-api` (or, generated, without `./api-routes`) gets `{}`; `registerPanel` ids are hashes of the list, so equal lists share an entry and the `agents` set is global across panels.
## 4. Fixture world
**GENERIC conventions** (`ui/src/artifact-directions/fixtures.ts`):
- Record types live with the slot contract in `kit.ts`, not with the data (`kit.ts:18-97`; `fixtures.ts:1` imports them).
- `COMPANY = { id, name, issuePrefix }`, a partial `Company` sufficient for the stub and hrefs (`fixtures.ts:22`); a product component reading other `Company` fields sees `undefined`.
- People as string constants plus an `AVATARS` map of imported SVGs (`:23-25`); `TASKS` declared `as const` with `type TaskId = keyof typeof TASKS` (`:27-35`).
- A `MIME` map keyed by extension (`:37`); en-US label helpers `dateTimeLabel`, `dateLabel`, `dayLabel`; a 1024-based `sizeLabel`; `plain(markdown)` for excerpts (`:37-76`).
- Factories: `interface Seed extends Partial<Omit<LabArtifact, "updatedAt" | "kind" | "title" | "id">> { at: string; task: TaskId }`; `artifact(id, kind, title, seed)` derives `ext`, `contentType`, `sizeLabel`, `excerpt`, the labels and `taskTitle` (`:85-109`); `taskDocument` (`:111-112`); `run` (`:78-83`); `group` (`:246-255`, where `artifacts[0]` drives the label, so lists are newest-first).
- Timestamps are zone-less local ISO strings pinned to one week (2026-09-24 to 09-28) so labels do not depend on the viewer's zone (`kit.ts:77`). Labels are precomputed strings, so a kit needs no clock unless it computes relative time.
- Long bodies in a sibling `fixture-documents.ts`, imported as `import * as docs` (`fixtures.ts:2`); media as URL imports from `assets/`, typed by `ui/src/vite-env.d.ts:1`.
- One export per slot or surface the frames render (`PANEL_ARTIFACTS`, `THREAD_ATTACHMENTS`, `THREAD_WORK_PRODUCTS` at `:162-173`; `GROUPS`, `OPEN_GROUP` at `:265-266`), plus `KIND_SPECIMENS` (one per kind in `LAB_KINDS` order, then a Markdown file and a `changes_requested` specimen: 12 entries, `:269-284`) and `STACK_SPECIMENS` (small, medium, large, `:295`).
**Clock.** `fixtures.ts` defines no `LAB_NOW`. `export const LAB_NOW = new Date("2026-09-28T18:00:00")` lives in `directions/d5-v1-mvp/when.ts:6`, and `panel-api.ts:32` has its own anchor `2026-09-28T12:00:00`. A generated lab's `fixtures.ts` exports `LAB_NOW = new Date("2026-09-28T18:00:00")`; derive every other anchor from it.
**ARTIFACT-SPECIFIC, and positional contracts to keep or edit:** the ten `LabKind`s, the acmehats world (44 records, 23 bodies, 12 assets); `frames/ThreadFrame.tsx:73` destructures `const [critique, guide, pptx, pdf, image, source] = THREAD_WORK_PRODUCTS;`; `frames/AppShell.tsx:60` and `ThreadFrame.tsx:37` index `TASKS["ACM-1"]`, `["ACM-2"]`, `["ACM-3"]`; `frames/SurfaceView.tsx:17,27` pass `GROUPS`/`OPEN_GROUP`/`PANEL_ARTIFACTS`; `frames/KindsSheet.tsx:16-23,55,72,81,96` feed `KIND_SPECIMENS`/`STACK_SPECIMENS` to every slot, so `registerPanel` mints a second id for a list holding a `link` and run-less records, which adapters must tolerate.
**A new feature's lab must define, and what the scaffold already gives it:** (1) its record types in `kit.ts`: the scaffold's `LabRecord` (`id`, `title`, `author`, `updatedAt`, `updatedLabel`), `SurfaceData` (one member per surface) and a `DirectionKit` with one `<Pascal>Body` slot per surface, to retype to the props each place receives; (2) `fixtures.ts`: the scaffold exports `COMPANY` and `TASKS` from `registry.shell`, `LAB_NOW`, and one `<UPPER>_ITEMS` array per surface holding three placeholder records; specimen arrays only if it keeps a kinds sheet, which the scaffold does not emit; (3) `directions/today/`: the scaffold's `adapters.ts` and one `<Pascal>Body.tsx` stub per surface (`data-lab-stub="today-<surface>"`), plus, by hand, exactly the routes its mounted components issue, in `api-routes.ts`; (4) registration: one `directions` line in `registry.ts`, from which `kit.ts` derives the rest.
## 5. HTML entry and what main.tsx mounts
**GENERIC verbatim** (`ui/artifact-directions.html`, 38 lines): `<html lang="en" class="dark" style="color-scheme: dark">` for a dark first paint (`:2`); `<title>`, `theme-color #18181b`, `/favicon.svg` from the default publicDir (`:6-8`); `<div id="root">` and `<script type="module" src="/src/artifact-directions/main.tsx">` (`:35-36`; the absolute `/src/...` path requires Vite root `ui/`). The only style block, unlayered (`:22-31`):
```html
html {
height: auto;
min-height: 100%;
overflow-y: auto;
}
body {
height: auto;
min-height: 100%;
overflow: visible;
}
```
It undoes `ui/src/index.css:747-757` inside `@layer base` (`html { height: 100% }`, `body { height: 100%; overflow: hidden }`), which would stop a full-page capture at the viewport. Only `<html>` scrolls: a scrolling body becomes the scrollport of the `sticky top-0 z-50` toolbar (`frames/LabToolbar.tsx:83`). Do not copy `ui/wizard-preview.html:16-21`, which applies the rule to `html, body` together and breaks the sticky toolbar.
**main.tsx** (`ui/src/artifact-directions/main.tsx`): `import "../index.css"` before `"./lab.css"` so tokens exist (`:8-9`); `installApiStub()` (`:25`); `readLabState(window.location.search)` (`:27`); theme and animation applied to `<html>` (`:30-41`), verbatim:
```ts
function applyTheme(theme: LabTheme) {
document.documentElement.classList.toggle("dark", theme === "dark");
document.documentElement.style.colorScheme = theme;
}
function applyAnim(anim: boolean) {
if (anim) document.documentElement.dataset.labAnim = "1";
else delete document.documentElement.dataset.labAnim;
}
```
Provider stack (`:182-198`): `StrictMode > MemoryRouter > QueryClientProvider` with `new QueryClient({ defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } } })` `> ThemeProvider > CompanyProvider > TooltipProvider > Lab`. A generated lab keeps the same stack in `providers.tsx` as `LabProviders` with `createLabQueryClient()`, so `today.test.tsx` renders Today under it; its `main.tsx` also imports `./api-routes` and sets `window.__LAB__` (directions, surfaces, keys, names, labels, story id, build) for `shot.cjs`. Frames wrap every slot in `<div data-direction={kit.id}>` (`frames/Slot.tsx:10`), which is what a kit's `tokens.css` scopes to. **GENERIC as emitted:** `lab.css` is only `:root { --font-mono: var(--font-paper-mono); }` (`lab.css:8-10`), and the scaffold writes the same rule for every lab.
## 6. Lab Vite config, option by option
`ui/vite.artifact-directions.config.mjs` (129 lines) is **GENERIC** except the slug-bearing identifiers and the default port. Never serve the lab through `ui/vite.config.ts`: it proxies `/api` to :3100 and boots the app at `/`.
- `root`: unset, so Vite's root is cwd. Run every lab command from `ui/`. `base`: unset (`/`), so the built page links `/assets/...` absolutely and the rename below cannot break them.
- Port and out dir (`:40-56`), verbatim:
```js
const ENTRY = "artifact-directions.html";
const DEFAULT_PORT = 5180;
function portFromArgv() {
const argv = process.argv;
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === "--port" && argv[i + 1]) return Number(argv[i + 1]);
if (argv[i].startsWith("--port=")) return Number(argv[i].slice("--port=".length));
}
return null;
}
const PORT = portFromArgv() || Number(process.env.ARTIFACT_DIRECTIONS_PORT) || DEFAULT_PORT;
const OUT_DIR = process.env.ARTIFACT_DIRECTIONS_OUT_DIR
? path.resolve(process.env.ARTIFACT_DIRECTIONS_OUT_DIR)
: path.resolve(__dirname, "dist-artifact-directions");
```
A config cannot see resolved CLI options, hence argv. A relative env value resolves against cwd (`ui/`); the default resolves against the config's directory. `ui/dist-artifact-directions/` is not gitignored (`.gitignore:5-7` covers only `dist/`, `dist-preview/`, `dist-flow-preview/`): always set the env var; `scaffold.mjs new` adds `ui/dist-<slug>-directions/` to `.gitignore` after `dist-flow-preview/`, and `check` fails when that line is missing.
- Watcher (`:66-77`): `LAB_WATCH_PATHS = [src/<slug>, src/index.css, ENTRY]`; `outsideLab(p)` returns false for anything inside those paths and for their ancestor directories (`lab.startsWith(target + path.sep)`), true otherwise. Reason: macOS FSEvents exhaustion makes `fs.watch` fail with EMFILE and Vite's watcher goes silent; polling a few hundred lab files is cheap. Edits to product components, `ui/public` or other `src/` files do not reach the page; restart the server.
- Plugins (`:79-103`), verbatim:
```js
const entryAsRoot = {
name: "artifact-directions-entry-as-root",
configureServer(server) {
server.middlewares.use((req, _res, next) => {
const [pathname, query] = (req.url || "").split("?");
if (pathname === "/" || pathname === "/index.html") {
req.url = `/${ENTRY}${query ? `?${query}` : ""}`;
}
next();
});
},
};
const htmlAsIndex = {
name: "artifact-directions-html-as-index",
closeBundle() {
const built = path.resolve(OUT_DIR, ENTRY);
if (!fs.existsSync(built)) return;
fs.renameSync(built, path.resolve(OUT_DIR, "index.html"));
},
};
```
`entryAsRoot` hooks `configureServer` only (dev). Under `vite preview` and on the deploy the root resolves because `htmlAsIndex` renamed the file; `/<slug>.html` 404s there, so link with `/?d=...`, never the html filename. The rename runs in `closeBundle`, not `generateBundle`, because Vite's HTML plugin emits the document after user plugins' turn.
- `defineConfig` (`:105-129`), verbatim:
```js
export default defineConfig({
appType: "mpa",
plugins: [react(), tailwindcss(), entryAsRoot, htmlAsIndex],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
lexical: path.resolve(__dirname, "./node_modules/lexical/dist/Lexical.mjs"),
},
},
cacheDir: path.resolve(__dirname, `node_modules/.vite-artifact-directions-${PORT}`),
server: {
host: "127.0.0.1",
port: PORT,
strictPort: true,
watch: { usePolling: true, interval: 250, ignored: outsideLab },
},
build: {
outDir: OUT_DIR,
emptyOutDir: true,
minify: "esbuild",
rollupOptions: {
input: path.resolve(__dirname, ENTRY),
},
},
});
```
- `appType: "mpa"` disables the SPA fallback. With it, `/` would serve the shared root's `index.html` and boot the real app, which calls `/api` and registers the service worker on this origin. Unknown paths 404.
- No `server.proxy`: an HTTP request to `/api/*` 404s; only in-page fetches are answered, by the stub.
- Aliases must equal `ui/vite.config.ts` and `ui/vitest.config.ts:5-10`, or product components do not resolve.
- `cacheDir` per port: the default `node_modules/.vite` is shared and concurrent dev servers race on dependency metadata. Each new port pays dependency optimisation once.
- `emptyOutDir: true` (`:123`) is explicit so Vite also wipes an out dir outside the project root (the Vercel recipe) without a warning. A wrong env value deletes that directory's contents.
- `minify: "esbuild"` with no `esbuild.drop`: the lab keeps `console` so shot.cjs can fail on console errors. The sibling `vite.preview.config.mjs` and `vite.flow-preview.config.mjs` drop console; copying them silences the gate.
- Deliberately absent, though `ui/vite.config.ts` has them: `serviceWorkerBuildIdPlugin()`, `preloadIssueDetail()`, `define.__PAPERCLIP_BUILD_COMMIT__`, `rolldownOptions.output.codeSplitting`. Vite is 8.2.2 (rolldown); `rollupOptions.input` still builds without a deprecation warning. A generated config adds its own `define`, `__<SLUG>_DIRECTIONS_BUILD_COMMIT__` from `git rev-parse --short HEAD` (`"unknown"` when git fails), which `build-info.ts` exposes as `LAB_BUILD` (`"dev"` when undefined) for the toolbar tooltip.
- `publicDir` unset, so `ui/public` is copied verbatim into `OUT_DIR`, including `sw.js`. Do not set `publicDir: false`: `ui/src/index.css:110-128` loads `/fonts/InterVariable*.woff2` and `/fonts/PaperMono-Variable.woff2` from it, and the entry loads `/favicon.svg`.
- Extra html files inside the lab dir (such as `directions/d2-tint/specimen.html`) are inert unless added to `rollupOptions.input`.
## 7. launch.json: two entries
`.claude/launch.json:27-44` is tracked, so new entries ship in the PR. **GENERIC** shape, parameterized names and ports; the two `runtimeArgs` commands:
```sh
cd ui && npx vite --config vite.artifact-directions.config.mjs --port 5180 --strictPort
cd ui && export ARTIFACT_DIRECTIONS_OUT_DIR=node_modules/.artifact-directions-dist && npx vite build --config vite.artifact-directions.config.mjs && npx vite preview --config vite.artifact-directions.config.mjs --port 5190 --strictPort
```
`artifact-directions-dev` (5180) is for editing with HMR. `artifact-directions` (5190) builds into `ui/node_modules/.artifact-directions-dist` (gitignored through `**/node_modules/`) and serves it statically, never live-reloading. `scaffold.mjs new` writes the same two entries as `<slug>-directions-dev` and `<slug>-directions` (slug, config file, env var and ports substituted); `check` fails when either is missing. Both exist because the in-page board (`s=board`) is 4 or 24 live iframes of the lab at `FRAME_WIDTH` 1440 (`frames/Board.tsx:12`; a generated board is kits × surfaces iframes, and `--landing auto` lands on it only when that is 8 or fewer) and never settles on an unbundled dev server. The build step in the second command carries no `--port`, so its cacheDir is the 5180 one. `preview.host` falls back to `server.host`, so preview binds 127.0.0.1. `ui/package.json` has no lab scripts on purpose: CODEOWNERS owns `package.json` at every depth (`.github/CODEOWNERS:19-20`), so the lab is invoked with `npx vite --config ...` directly.
## 8. Product build, tsc, vitest, token gates
- The product build excludes the lab structurally, not by configuration: `ui/vite.config.ts` has no exclusion; the lab is simply unreachable from `ui/index.html` and `src/main.tsx`. Before each PR, grep the slug outside the lab dir (today: the config, the html, launch.json, three Storybook stories, a plan doc). `scaffold.mjs check` is that guard for a generated lab: it fails when `<slug>-directions` appears in a `.ts`, `.tsx`, `.css`, `.cjs`, `.mjs` or `.html` file under `ui/` outside the lab, its html, its config, its story, `launch.json` and `.gitignore`.
- `tsc -b` covers the lab (`ui/tsconfig.json:19` includes `src`; `.cjs` is not typechecked). Vitest covers it (`ui/vitest.config.ts` has no include filter), so a half-finished lab breaks `pnpm test`, which is why `new` runs the lab's Vitest files and rolls back on failure.
- `scripts/check-token-gates.mjs:80` scans only `components` and `pages`; lab CSS is not gated.
## 9. Tests the lab carries
Six files: `lab-state.test.ts`, `api-stub.test.ts`, and `directions/d5-v1-mvp/Thread.test.tsx`, `MediaPrint.test.ts`, `kinds.test.ts`, `when.test.ts`. **GENERIC:** `api-stub.test.ts` (reuse verbatim; three cases: the `/api/companies` fallback answers `[COMPANY]` and restore puts `fetch` back; a registered route answers and an unregistered POST gets `{}`; nested installs share one stub, a repeated restore is a no-op, the last release restores) and `lab-state.test.ts`. `ui/vitest.config.ts:12` sets `environment: "node"`, so any test touching `window` or `fetch` starts with `// @vitest-environment jsdom` (`api-stub.test.ts:1`); it is the one comment line the generator's no-comment rule allows, on line 1 only (`api-stub.test.ts`, `today.test.tsx`). Run one file with `cd ui && npx vitest run src/artifact-directions/api-stub.test.ts` (3 pass; durations vary, do not record them). Run full ui suites one at a time. `scaffold.mjs new` ships five tests: `registry.test.ts`, `lab-state.test.ts`, `motion-contract.test.ts`, `today.test.tsx` and this `api-stub.test.ts` (same three cases, comment removed); `check` runs `motion-contract.test.ts` and `check --run` the whole lab directory.
## 10. shot.cjs: capture
`ui/src/artifact-directions/shot.cjs` (263 lines, CommonJS, no TS imports). Usage: `node ui/src/artifact-directions/shot.cjs --port <p> --d <kit|all> --out <dir> [--s thread,panel,gallery,stack,kinds,board] [--theme dark,light]`. It does not start a server. **GENERIC:** `parseArgs` (`--key value` or `--key=value`), `list()` validation, `loadChromium()` trying `@playwright/test` then `playwright` (`:67-78`; it resolves the root-hoisted `@playwright/test` from `package.json:100`), `settleFrame` (`:85-118`: set lazy images eager, await `document.fonts.ready`, a 400 ms MutationObserver quiet window, every image complete, every video `readyState >= 2` or 3.5 s, quiet again; network-idle says nothing about React under a fetch stub), the problem collectors (console errors, `pageerror`, `requestfailed`, any response `>= 400`), exit codes (2 usage, 1 any failure, after writing everything). Capture flags, verbatim (`:121-125,143-148`):
```js
const context = await browser.newContext({
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 2,
reducedMotion: "reduce",
});
const url = `${base}/?d=${shot.kit}&s=${shot.surface}&theme=${shot.theme}&chrome=0`;
await page.goto(url, { waitUntil: "networkidle", timeout: 120000 });
for (const frame of page.frames()) await frame.evaluate(settleFrame);
const file = path.join(outDir, `${shot.kit}__${shot.surface}__${shot.theme}.png`);
await page.screenshot({ path: file, fullPage: true });
```
Per-surface PNGs are `<kit>__<surface>__<theme>.png`. `reducedMotion: "reduce"` means every capture shows a resting state: the lab's only motion rules sit under `@media (prefers-reduced-motion: no-preference)` keyed on `html[data-lab-anim="1"]` (`directions/d5-v1-mvp/tokens.css:84-97`), and the URL never passes `anim`. A hover or animation frame needs a context without `reducedMotion` plus `page.hover` before the shot; a generated `shot.cjs` has this as `--hover`, which hovers the first `[data-direction] button`, waits 350 ms and writes `<kit>__<surface>__<theme>__hover.png`. Board (`:153-200`): non-board shots run first; `captureBoard` captures any missing `BOARD_SURFACES` PNG, writes a temporary HTML contact sheet (grid `150px repeat(3,minmax(0,1fr))`, tiles cropped to 1100/1100/542/542 px), screenshots it from `file://` at 2880x1200 with `deviceScaleFactor: 1` as `<kit>__board__<theme>.png`, then unlinks the sheet. **ARTIFACT-SPECIFIC:** `KITS`, `SURFACES`, `BOARD_SURFACES`, `KIT_NAMES` (`:22-24,153-154`; hand-duplicated from `kit.ts` and `lab-state.ts`, edit both sides), the tile crops, the stale "sixteen" in the comment at `:159`, and `isShippedNestingWarning` (`:210-213`), which excuses the product `ArtifactCard` nested-anchor warning. Start a new lab with no excuses. A generated `shot.cjs` has none of them: it reads `window.__LAB__` (directions, surfaces, names, labels) from a first `chrome=0` load and fails with "page did not expose window.__LAB__" when `main.tsx` did not run; `--d` defaults to `today` and `--s` to every surface plus `board`; every tile is cropped to 1100 px on a `150px repeat(<surfaces>, minmax(0,1fr))` grid; there is no excuse list.
## 11. Vercel deploy
**GENERIC recipe.** The deploy steps come from a memory note, not the repo; no Vercel deploy config (`vercel.json` at the root or `ui/`, `.vercel/`) is tracked, and the project link lives in the out-of-repo deploy directory.
```sh
cd ui
ARTIFACT_DIRECTIONS_OUT_DIR=<dir>/paperclip-artifact-directions npx vite build --config vite.artifact-directions.config.mjs
rm <dir>/paperclip-artifact-directions/sw.js
cd <dir>
vercel deploy paperclip-artifact-directions --prod --yes
```
The directory basename names the Vercel project; a generated lab's registry pins `publicUrl` to `https://paperclip-<slug>-directions.vercel.app`, so build into `<dir>/paperclip-<slug>-directions` with `<SLUG>_DIRECTIONS_OUT_DIR`. The alias `https://<project>.vercel.app` is public; the per-deployment `*-<hash>.vercel.app` URL is behind Vercel Authentication. Build the lab alone so the app's main chunk never lands in the deployed directory (every file there is publicly fetchable), and expect the whole `ui/public` tree in the output. `sw.js` is the product's worker copied unstamped (`__PAPERCLIP_BUILD_ID__` literal at `ui/public/sw.js:7`): its fetch handler returns at once (`:105`), but if registered it would `skipWaiting()`, delete every cache and `clients.claim()` (`:88-99`); removing it is hygiene. The lab never registers a worker (registration is the load listener at `ui/src/main.tsx:45`, which the lab does not import). A bare URL lands on `readLabState`'s default (`lab-state.ts:41-42`); in a generated lab that default is `registry.landing`, written from `--landing` (`auto`: `d=all&s=board` when kits × surfaces is 8 or fewer, else `d=all&s=<first surface>`), so edit `registry.ts` to change the view the shared link opens.
## 12. What a generator parameterizes
- **slug** (`--slug`, kebab-case, never ending in `-directions`; the suffix is added everywhere): `ui/<slug>-directions.html`, `ui/vite.<slug>-directions.config.mjs`, `ui/src/<slug>-directions/`, the story `ui/storybook/stories/design-proposals/<slug>-directions.stories.tsx` with meta id `design-<slug>-directions`, plugin names `<slug>-directions-entry-as-root` and `<slug>-directions-html-as-index`, `cacheDir node_modules/.vite-<slug>-directions-${PORT}`, default out dir `dist-<slug>-directions` plus the `.gitignore` line `ui/dist-<slug>-directions/`, launch entries `<slug>-directions-dev` and `<slug>-directions`, static dir `node_modules/.<slug>-directions-dist`, the `console.debug("[<slug>-directions] unhandled", ...)` tag, Vercel project `paperclip-<slug>-directions`.
- **env var names**: `<SLUG>_DIRECTIONS_OUT_DIR`, `<SLUG>_DIRECTIONS_PORT` and the define `__<SLUG>_DIRECTIONS_BUILD_COMMIT__` (upper snake case of the slug).
- **two free ports**: dev and static preview (5180/5190 here). `--ports dev,preview`, or the first pair from 5200/5210 in steps of 20 that avoids the reserved list (3100, 3101, 3121, 4173, 5173, 5180, 5190, 6006, 6016), every port in `.claude/launch.json` and any `ui/node_modules/.vite-*-<port>` already on disk; sockets are not probed.
- **page title** (`<Title> directions`, from `--title`, default the slug in sentence case) and **COMPANY** from `--company name:PREFIX` (default `acmehats:ACM`): `{ id: "company-<name>", name, issuePrefix }` in `registry.shell`, three shell tasks `<PREFIX>-1..3`, and the stub user `lab@<name>.example`. **LAB_NOW** and the pinned week are not flags: every lab gets `2026-09-28T18:00:00` and placeholder records on 09-24, 09-26 and 09-28.
- **kit ids, digit keys, KIT_META** from `--directions 2|3` and `--names` (`d<N>-<kebab name>`, key `N`, name `N.Name`, placeholder taglines for Refine, Rework and Rethink, else `Direction N`), the **surface list** from `--surfaces id:Label:width@nav` (1 to 6, `nav` one of `tasks|artifacts|other`, never `board`), and the **bare-URL default** from `--landing auto|all|today|<id>`. The URL shape is fixed: `d/s/theme/chrome/anim` plus `proposal`.
- **shot.cjs**: nothing; the generated script reads `window.__LAB__` at run time and carries no excuse list.
- **routes**: none; `api-routes.ts` registers only the experimental-settings route, and the routes the Today kit needs are added by hand in `registerFeatureRoutes()`, found by grepping the mounted components and from the `unhandled` debug lines.
@@ -0,0 +1,47 @@
export class UsageError extends Error {}
export const COMMANDS = {
new: {
strings: ["slug", "title", "surfaces", "directions", "names", "group", "capabilities", "company", "landing", "ports", "root"],
booleans: ["force-count", "dry-run", "json", "skip-verify", "no-build", "keep-on-failure"],
},
check: { strings: ["slug", "root"], booleans: ["strict", "run", "json", "skip-verify"] },
"add-direction": { strings: ["slug", "name", "root"], booleans: ["force-count", "json", "skip-verify"] },
list: { strings: ["root"], booleans: ["json"] },
};
export function parseArgs(argv) {
const [command, ...rest] = argv;
if (!command || command === "--help" || command === "-h") return { command: "help", flags: {} };
const spec = COMMANDS[command];
if (!spec) throw new UsageError(`unknown subcommand "${command}"; expected one of ${Object.keys(COMMANDS).join(", ")}`);
const flags = {};
for (let i = 0; i < rest.length; i += 1) {
const token = rest[i];
if (!token.startsWith("--")) throw new UsageError(`unexpected argument "${token}"`);
const [rawKey, inline] = token.slice(2).split(/=(.*)/s);
if (spec.booleans.includes(rawKey)) {
if (inline !== undefined && inline !== "true" && inline !== "false") throw new UsageError(`--${rawKey} takes no value`);
flags[rawKey] = inline !== "false";
continue;
}
if (!spec.strings.includes(rawKey)) throw new UsageError(`unknown flag --${rawKey} for ${command}`);
const value = inline !== undefined ? inline : rest[(i += 1)];
if (value === undefined || value.startsWith("--")) throw new UsageError(`--${rawKey} needs a value`);
flags[rawKey] = value;
}
return { command, flags };
}
export function helpText() {
return `Usage:
scaffold.mjs new --slug <slug> --title <Title> --surfaces id:Label:width@nav,... --directions 2|3
[--names a,b[,c]] [--group Components|Pages|Layouts|Patterns] [--capabilities a/k,...]
[--company name:PREFIX] [--landing auto|all|today|<id>] [--ports dev,preview]
[--force-count] [--dry-run] [--json] [--skip-verify] [--no-build] [--keep-on-failure] [--root <dir>]
scaffold.mjs check --slug <slug> [--strict] [--run] [--json] [--skip-verify] [--root <dir>]
scaffold.mjs add-direction --slug <slug> --name <Name> [--force-count] [--json] [--skip-verify] [--root <dir>]
scaffold.mjs list [--json] [--root <dir>]
Exit 0 on success, 1 on any failure (the failing command's output is printed).`;
}
@@ -0,0 +1,49 @@
import { existsSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { UsageError } from "./args.mjs";
export const CAPABILITY_PATTERN = /^[a-z0-9-]+\/[a-z0-9-]+$/;
export async function loadFeatureIndex(root) {
const script = path.join(root, "scripts/feature-map.mjs");
if (!existsSync(script)) throw new UsageError(`cannot validate capabilities: ${script} is missing (run from the repo root, or pass no --capabilities)`);
const { featureIndex } = await import(pathToFileURL(script).href);
return featureIndex(root);
}
export function allCapabilities(index) {
return index.sections.flatMap((section) => section.features.flatMap((feature) => feature.capabilities));
}
export function parseCapabilities(text) {
if (!text) return [];
const ids = text.split(",").map((id) => id.trim()).filter(Boolean);
const malformed = ids.filter((id) => !CAPABILITY_PATTERN.test(id));
if (malformed.length) throw new UsageError(`capabilities must be <area>/<key>: ${malformed.join(", ")}`);
if (new Set(ids).size !== ids.length) throw new UsageError("duplicate capability ids");
return ids;
}
export function candidates(index, term) {
const needle = term.toLowerCase();
return allCapabilities(index)
.filter((capability) => capability.id.includes(needle) || capability.description.toLowerCase().includes(needle))
.map((capability) => capability.id);
}
export async function validateCapabilities(root, text) {
const ids = parseCapabilities(text);
if (!ids.length) return ids;
const index = await loadFeatureIndex(root);
const known = new Set(allCapabilities(index).map((capability) => capability.id));
const unknown = ids.filter((id) => !known.has(id));
if (unknown.length) {
const hints = unknown.map((id) => {
const near = candidates(index, id.split("/")[1] ?? id).slice(0, 5);
return ` ${id}${near.length ? ` (did you mean ${near.join(", ")}?)` : ""}`;
});
throw new UsageError(`unknown capabilities (maps/feature-map/<area>.md ## Sub-features):\n${hints.join("\n")}`);
}
return ids;
}
@@ -0,0 +1,182 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import { PLACEHOLDER_TAGLINES } from "./context.mjs";
import { detectMode, expectedStoryStatus, labStage } from "./modes.mjs";
import { readRegistry } from "./registry-edit.mjs";
import { describeFailure, runStep } from "./verify.mjs";
import { commentLines, lineCount, MAX_LINES } from "./write.mjs";
export const DENY_LIST = [
"artifact-directions",
"LabArtifact",
"LabGroup",
"LabKind",
"LabRun",
"GalleryFrame",
"KindsSheet",
"KIND_SPECIMENS",
"STACK_SPECIMENS",
"d5-v1-mvp",
"--v1-",
"ArtifactCard",
"isShippedNestingWarning",
"Interface Craft",
"2096:",
"2129:",
];
export const STORY_EXPORTS = ["Compare", "Today", "TodayProductionPalette"];
export const NOT_RUN = ["pnpm build-storybook (not part of check --run; run it or report why not)", "pnpm -r typecheck (needs the Rust toolchain for paperclip-runner)"];
function walk(dir, filter = () => true) {
if (!existsSync(dir)) return [];
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const file = path.join(dir, entry.name);
if (entry.isDirectory()) return entry.name === "node_modules" ? [] : walk(file, filter);
return filter(file) ? [file] : [];
});
}
const CODE = /\.(ts|tsx|css|cjs|mjs|html)$/;
const DIRECTION_TOKENS = /\/directions\/[^/]+\/tokens\.css$/;
const REPLACEMENT_LINE = /^\s*replacement: "[^"]*",?$/;
const namesOutsideReplacement = (text, term) => text.split("\n").some((line) => !REPLACEMENT_LINE.test(line) && line.includes(term));
export function labPaths(root, slug) {
return {
labDir: path.join(root, `ui/src/${slug}-directions`),
entry: path.join(root, `ui/${slug}-directions.html`),
config: path.join(root, `ui/vite.${slug}-directions.config.mjs`),
story: path.join(root, `ui/storybook/stories/design-proposals/${slug}-directions.stories.tsx`),
launch: path.join(root, ".claude/launch.json"),
gitignore: path.join(root, ".gitignore"),
};
}
export function checkLab(root, slug, { strict = false, run = false, skipVerify = false } = {}) {
const errors = [];
const warnings = [];
const paths = labPaths(root, slug);
const registryFile = path.join(paths.labDir, "registry.ts");
if (!existsSync(registryFile)) return { errors: [`no lab at ui/src/${slug}-directions (registry.ts missing)`], warnings, verification: [], notRun: NOT_RUN };
const registry = readRegistry(readFileSync(registryFile, "utf8"), path.relative(root, registryFile));
const relative = (file) => path.relative(root, file);
const kitDirs = readdirSync(path.join(paths.labDir, "directions"), { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
const ids = registry.directions.map((direction) => direction.id).sort();
if (JSON.stringify(kitDirs) !== JSON.stringify(ids)) errors.push(`directions/ holds [${kitDirs.join(", ")}] but the registry lists [${ids.join(", ")}]`);
for (const direction of registry.directions) {
const index = path.join(paths.labDir, "directions", direction.id, "index.tsx");
if (!existsSync(index)) continue;
const text = readFileSync(index, "utf8");
if (!text.includes(`id: ${JSON.stringify(direction.id)}`)) errors.push(`${relative(index)}: kit.id is not ${JSON.stringify(direction.id)}`);
if (!text.includes(`name: ${JSON.stringify(direction.name)}`)) errors.push(`${relative(index)}: kit.name is not ${JSON.stringify(direction.name)}`);
const tokens = path.join(paths.labDir, "directions", direction.id, "tokens.css");
if (!direction.baseline && !existsSync(tokens)) errors.push(`${relative(tokens)}: every exploration carries a tokens.css`);
if (direction.baseline && existsSync(tokens)) errors.push(`${relative(tokens)}: Today carries no tokens.css`);
if (!direction.baseline && Object.values(PLACEHOLDER_TAGLINES).includes(direction.tagline)) warnings.push(`${direction.id}: tagline "${direction.tagline}" is the scaffold placeholder`);
if (!direction.baseline && /^Direction \d$/.test(direction.tagline)) warnings.push(`${direction.id}: tagline "${direction.tagline}" is the scaffold placeholder`);
}
const keys = registry.directions.map((direction) => direction.key);
if (new Set(keys).size !== keys.length || keys.some((key) => !/^\d$/.test(key))) errors.push("direction keys must be unique single digits");
const baselines = registry.directions.filter((direction) => direction.baseline);
if (baselines.length !== 1 || baselines[0].id !== "today") errors.push("exactly one baseline direction, today");
const labFiles = [...walk(paths.labDir, (file) => CODE.test(file)), paths.entry, paths.config, paths.story].filter(existsSync);
const allow = new Set(registry.allow);
for (const file of labFiles) {
const text = readFileSync(file, "utf8");
const comments = commentLines(text);
if (comments.length) errors.push(`${relative(file)}: comment line(s) at ${comments.slice(0, 5).join(", ")}`);
const lines = lineCount(text);
if (lines >= MAX_LINES) errors.push(`${relative(file)}: ${lines} lines reaches the ${MAX_LINES}-line limit`);
for (const term of DENY_LIST) if (!allow.has(term) && text.includes(term)) errors.push(`${relative(file)}: carries "${term}" from the artifact lab`);
if (strict) {
const stubs = [...text.matchAll(/data-lab-stub="([^"]+)"/g)].map((match) => match[1]);
if (stubs.length) errors.push(`${relative(file)}: scaffold stub(s) still present: ${[...new Set(stubs)].join(", ")}`);
}
if (/\.(css|html)$/.test(file) && !DIRECTION_TOKENS.test(file) && text.includes("data-lab-anim")) errors.push(`${relative(file)}: data-lab-anim rules belong in directions/<id>/tokens.css`);
}
const allowedSlugFiles = new Set([...labFiles, paths.launch, paths.gitignore].map(relative));
const slugTerm = `${slug}-directions`;
const scanRoots = [path.join(root, "ui/src"), path.join(root, "ui/storybook"), path.join(root, "ui")];
const seen = new Set();
for (const scanRoot of scanRoots) {
const files = scanRoot === path.join(root, "ui") ? readdirSync(scanRoot).map((name) => path.join(scanRoot, name)).filter((file) => statSync(file).isFile()) : walk(scanRoot, (file) => CODE.test(file) || file.endsWith(".json"));
for (const file of files) {
if (seen.has(file) || !CODE.test(file)) continue;
seen.add(file);
if (allowedSlugFiles.has(relative(file))) continue;
if (namesOutsideReplacement(readFileSync(file, "utf8"), slugTerm)) errors.push(`${relative(file)}: names ${slugTerm} outside the lab`);
}
}
if (existsSync(paths.story)) {
const story = readFileSync(paths.story, "utf8");
if (!story.includes(`id: ${JSON.stringify(registry.storyId)}`)) errors.push(`${relative(paths.story)}: meta id is not ${registry.storyId}`);
for (const name of STORY_EXPORTS) if (!new RegExp(`^export const ${name}: Story`, "m").test(story)) errors.push(`${relative(paths.story)}: export ${name} is missing`);
for (const direction of registry.directions.filter((entry) => !entry.baseline)) {
if (!new RegExp(`^export const D${direction.key}: Story`, "m").test(story)) errors.push(`${relative(paths.story)}: export D${direction.key} is missing`);
}
const parameters = /parameters: \{([\s\S]*?)\n \},\n\} satisfies Meta/.exec(story);
if (!parameters) errors.push(`${relative(paths.story)}: cannot find the meta parameters block`);
else {
const keys = [...parameters[1].matchAll(/^ ([a-zA-Z]+): /gm)].map((match) => match[1]);
if (keys[keys.length - 1] !== "paperclip") errors.push(`${relative(paths.story)}: paperclip must be the last parameter (found ${keys.join(", ")})`);
const mode = detectMode(root);
const stage = labStage(registry.directions);
const expected = expectedStoryStatus(mode, stage);
if (!parameters[1].includes(`status: "${expected}"`)) errors.push(`${relative(paths.story)}: status should be "${expected}" (mode ${mode}, lab stage ${stage})`);
if (expected === "historical") {
const replacement = /\breplacement: "([^"]+)"/.exec(parameters[1]);
if (!replacement) errors.push(`${relative(paths.story)}: status "historical" needs replacement: "<repo-relative .stories.tsx>" naming the successor story`);
else if (!existsSync(path.join(root, replacement[1]))) errors.push(`${relative(paths.story)}: replacement ${replacement[1]} does not exist`);
}
}
} else errors.push(`${relative(paths.story)}: story file is missing`);
if (existsSync(paths.launch)) {
const names = JSON.parse(readFileSync(paths.launch, "utf8")).configurations.map((entry) => entry.name);
for (const name of [`${slug}-directions-dev`, `${slug}-directions`]) if (!names.includes(name)) errors.push(`.claude/launch.json: entry ${name} is missing`);
} else errors.push(".claude/launch.json is missing");
if (!existsSync(paths.gitignore) || !readFileSync(paths.gitignore, "utf8").split("\n").includes(`ui/dist-${slug}-directions/`)) errors.push(`.gitignore: ui/dist-${slug}-directions/ is missing`);
const verification = [];
if (!skipVerify && errors.length === 0) {
const motion = runStep("motion contract", "npx", ["vitest", "run", `src/${slug}-directions/motion-contract.test.ts`], { cwd: path.join(root, "ui") });
verification.push(motion);
if (!motion.ok) errors.push(describeFailure(motion));
if (run && motion.ok) {
const SLUG = slug.replace(/-/g, "_").toUpperCase();
const steps = [
["lab vitest", "npx", ["vitest", "run", `src/${slug}-directions`], "ui", {}],
["storybook catalog", "node", ["scripts/storybook-catalog.mjs", "--check"], ".", {}],
["token gates", "pnpm", ["run", "check:token-gates"], ".", {}],
["lab vite build", "npx", ["vite", "build", "--config", `vite.${slug}-directions.config.mjs`], "ui", { [`${SLUG}_DIRECTIONS_OUT_DIR`]: `node_modules/.${slug}-directions-dist` }],
["ui typecheck", "pnpm", ["typecheck"], "ui", {}],
];
for (const [label, command, args, cwd, env] of steps) {
const result = runStep(label, command, args, { cwd: path.join(root, cwd), env });
verification.push(result);
if (!result.ok) {
errors.push(describeFailure(result));
break;
}
}
}
}
return { errors, warnings, verification, registry, notRun: NOT_RUN };
}
export function verificationBlock(verification, { skipped = NOT_RUN } = {}) {
const lines = ["## Verification", ""];
for (const result of verification) lines.push(`- ${result.ok ? "Passed" : "Failed"}: \`cd ${path.basename(result.cwd) === "ui" ? "ui" : "."} && ${result.command}\``);
for (const reason of skipped) lines.push(`- Not run: ${reason}`);
return lines.join("\n");
}
@@ -0,0 +1,174 @@
import { execFileSync } from "node:child_process";
import { UsageError } from "./args.mjs";
export const SLUG_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
export const NAVS = ["tasks", "artifacts", "other"];
export const GROUPS = ["Components", "Pages", "Layouts", "Patterns"];
export const DEFAULT_NAMES = { 2: ["Refine", "Rethink"], 3: ["Refine", "Rework", "Rethink"] };
export const PLACEHOLDER_TAGLINES = {
Refine: "Tokens and spacing, same structure",
Rework: "New components, same layout",
Rethink: "New layout",
};
export const TODAY = { id: "today", key: "0", digit: 0, name: "0.Today", label: "Today", tagline: "What ships now", baseline: true, stage: "shipped" };
export const SKILL_VERSION = "1";
export const MAX_BOARD_CELLS = 8;
export const pascal = (value) => value.split(/[-_\s]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
export const upperSnake = (value) => value.replace(/-/g, "_").toUpperCase();
export const kebab = (value) =>
value
.trim()
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
.replace(/[^a-zA-Z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.toLowerCase();
export const sentenceCase = (slug) => {
const words = slug.split("-").join(" ");
return words.charAt(0).toUpperCase() + words.slice(1);
};
export function parseSurfaces(text) {
if (!text) throw new UsageError("--surfaces is required (id:Label:width@nav,...)");
const surfaces = text.split(",").map((entry) => entry.trim()).filter(Boolean).map((entry) => {
const match = /^([a-z][a-z0-9-]*):([^:@]+):(\d+)@([a-z]+)$/.exec(entry);
if (!match) throw new UsageError(`surface "${entry}" must look like id:Label:width@nav`);
const [, id, label, width, nav] = match;
if (!NAVS.includes(nav)) throw new UsageError(`surface "${id}" nav "${nav}" must be one of ${NAVS.join("|")}`);
if (Number(width) <= 0) throw new UsageError(`surface "${id}" width must be positive`);
return deriveSurface({ id, label: label.trim(), width: Number(width), nav });
});
if (surfaces.length < 1 || surfaces.length > 6) throw new UsageError("1 to 6 surfaces");
const ids = surfaces.map((surface) => surface.id);
if (new Set(ids).size !== ids.length) throw new UsageError("surface ids must be unique");
if (ids.includes("board")) throw new UsageError('"board" is the overview view and cannot be a surface id');
return surfaces;
}
export function deriveSurface({ id, label, width, nav }) {
const Pascal = pascal(id);
return { id, label, width, nav, Pascal, UPPER: upperSnake(id), fixture: `${upperSnake(id)}_ITEMS`, body: `${Pascal}Body`, frame: `${Pascal}Frame` };
}
export function deriveDirection(digit, name) {
const label = name.trim();
if (!/^[A-Za-z][A-Za-z0-9 ]*$/.test(label)) throw new UsageError(`direction name "${label}" must be letters, digits and spaces, starting with a letter`);
const prefix = kebab(label);
return {
id: `d${digit}-${prefix}`,
key: String(digit),
digit,
name: `${digit}.${label}`,
label,
tagline: PLACEHOLDER_TAGLINES[label] ?? `Direction ${digit}`,
prefix,
stage: "proposed",
};
}
export function parseDirections(flags) {
const count = Number(flags.directions);
if (!Number.isInteger(count) || count < 2 || count > 9) throw new UsageError("--directions must be 2 or 3 (4-9 with --force-count)");
if (count > 3 && !flags["force-count"]) throw new UsageError(`--directions ${count} needs --force-count (2 or 3 is the house rule)`);
const names = flags.names ? flags.names.split(",").map((name) => name.trim()).filter(Boolean) : DEFAULT_NAMES[count];
if (!names) throw new UsageError(`--names is required for ${count} directions`);
if (names.length !== count) throw new UsageError(`--names lists ${names.length} names for ${count} directions`);
const directions = names.map((name, index) => deriveDirection(index + 1, name));
const prefixes = directions.map((direction) => direction.prefix);
if (new Set(prefixes).size !== prefixes.length) throw new UsageError("direction names must be distinct");
return directions;
}
export function parseCompany(text = "acmehats:ACM") {
const match = /^([a-z][a-z0-9-]*):([A-Z][A-Z0-9]{0,5})$/.exec(text);
if (!match) throw new UsageError('--company must look like name:PREFIX (lowercase name, upper-case prefix), e.g. acmehats:ACM');
const [, name, issuePrefix] = match;
return { id: `company-${name}`, name, issuePrefix };
}
export function resolveLanding(flag = "auto", directions, surfaces) {
const cells = (directions.length + 1) * surfaces.length;
if (flag === "auto") return { direction: "all", view: cells <= MAX_BOARD_CELLS ? "board" : surfaces[0].id };
if (flag === "all") return { direction: "all", view: "board" };
if (flag === "today") return { direction: "today", view: surfaces[0].id };
const direction = directions.find((entry) => entry.id === flag);
if (!direction) throw new UsageError(`--landing "${flag}" is not auto, all, today or a direction id (${directions.map((entry) => entry.id).join(", ")})`);
return { direction: direction.id, view: surfaces[0].id };
}
export function runtimeCommit(root) {
try {
return execFileSync("git", ["rev-parse", "--short", "HEAD"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || "unknown";
} catch {
return "unknown";
}
}
export function shellTasks(company) {
return [
{ identifier: `${company.issuePrefix}-1`, title: "Paperclip onboarding" },
{ identifier: `${company.issuePrefix}-2`, title: `Develop ${company.name} brand strategy and designer brief` },
{ identifier: `${company.issuePrefix}-3`, title: `Create visual directions and first ${company.name} brand deck` },
];
}
export function joinLabels(labels) {
if (labels.length === 1) return labels[0];
return `${labels.slice(0, -1).join(", ")} and ${labels[labels.length - 1]}`;
}
export function buildContext(flags, { root, ports, capabilities = [], today = new Date() }) {
const slug = flags.slug;
if (!slug || !SLUG_PATTERN.test(slug)) throw new UsageError("--slug must be kebab-case: lower-case letters, digits and single hyphens");
if (slug.endsWith("-directions")) throw new UsageError('--slug must not end with "-directions"; the suffix is added');
const title = (flags.title ?? sentenceCase(slug)).trim();
if (!title) throw new UsageError("--title must not be empty");
const group = flags.group ?? "Components";
if (!GROUPS.includes(group)) throw new UsageError(`--group must be one of ${GROUPS.join("|")}`);
const surfaces = parseSurfaces(flags.surfaces);
const directions = parseDirections(flags);
const company = parseCompany(flags.company);
const landing = resolveLanding(flags.landing, directions, surfaces);
return assembleContext({ root, slug, title, group, surfaces, directions, company, landing, ports, capabilities, forceCount: Boolean(flags["force-count"]), today });
}
export function assembleContext({ root, slug, title, group, surfaces, directions, company, landing, ports, capabilities, forceCount, today = new Date(), provenance }) {
const SLUG = upperSnake(slug);
const kits = [...directions, TODAY];
const cells = kits.length * surfaces.length;
const labels = surfaces.map((surface) => surface.label);
return {
slug,
SLUG,
title,
group,
labDir: `ui/src/${slug}-directions`,
entry: `${slug}-directions.html`,
configFile: `vite.${slug}-directions.config.mjs`,
storyId: `design-${slug}-directions`,
storyFile: `ui/storybook/stories/design-proposals/${slug}-directions.stories.tsx`,
envOutDir: `${SLUG}_DIRECTIONS_OUT_DIR`,
envPort: `${SLUG}_DIRECTIONS_PORT`,
buildDefine: `__${SLUG}_DIRECTIONS_BUILD_COMMIT__`,
distDir: `ui/dist-${slug}-directions/`,
staticDir: `node_modules/.${slug}-directions-dist`,
ports,
directions,
today: TODAY,
kits,
surfaces,
multiSurface: surfaces.length > 1,
cells,
landing,
capabilities,
exception: `Design directory entry for ${title}: the shipped baseline and ${directions.length} proposed directions compared on static mocks of ${joinLabels(labels)}.`,
notes: `Controls pick a direction kit and a surface. Today mounts the shipped ${title} components behind the lab's fetch stub with the ${company.name} fixture world; the other kits are lab components. Frames are static mocks of the product chrome; nothing here establishes production behaviour or access checks.`,
company,
tasks: shellTasks(company),
tag: `[${slug}-directions]`,
publicUrl: `https://paperclip-${slug}-directions.vercel.app`,
vercelProject: `paperclip-${slug}-directions`,
forceCount: Boolean(forceCount),
provenance: provenance ?? { runtimeCommit: runtimeCommit(root), skillVersion: SKILL_VERSION, createdAt: today.toISOString().slice(0, 10) },
};
}
@@ -0,0 +1,25 @@
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
export const MODE_A_SENTENCE = "design proposal home requires proposed status";
export function detectMode(root) {
const script = path.join(root, "scripts/storybook-catalog.mjs");
if (!existsSync(script)) return "A";
return readFileSync(script, "utf8").includes(MODE_A_SENTENCE) ? "A" : "B";
}
export function labStage(directions) {
const explorations = directions.filter((direction) => !direction.baseline);
if (!explorations.length) return "proposed";
if (explorations.some((direction) => direction.stage === "proposed" || direction.stage === "adopted")) return "proposed";
if (explorations.every((direction) => direction.stage === "historical")) return "historical";
return "current";
}
export function expectedStoryStatus(mode, stage) {
if (mode === "A") return "proposed";
if (stage === "current") return "current";
if (stage === "historical") return "historical";
return "proposed";
}
@@ -0,0 +1,46 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { UsageError } from "./args.mjs";
export const RESERVED_PORTS = [3100, 3101, 3121, 4173, 5173, 5180, 5190, 6006, 6016];
export const FIRST_DEV_PORT = 5200;
export const PORT_STEP = 20;
export const PREVIEW_OFFSET = 10;
export function launchPorts(root) {
const file = path.join(root, ".claude/launch.json");
if (!existsSync(file)) return [];
const parsed = JSON.parse(readFileSync(file, "utf8"));
return (parsed.configurations ?? []).map((entry) => Number(entry.port)).filter((port) => Number.isFinite(port));
}
export function cacheDirPorts(root) {
const dir = path.join(root, "ui/node_modules");
if (!existsSync(dir)) return [];
return readdirSync(dir)
.map((name) => /^\.vite-.+-(\d+)$/.exec(name))
.filter(Boolean)
.map((match) => Number(match[1]));
}
export function takenPorts(root) {
return new Set([...RESERVED_PORTS, ...launchPorts(root), ...cacheDirPorts(root)]);
}
export function pickPorts(root, explicit) {
const taken = takenPorts(root);
if (explicit) {
const parts = explicit.split(",").map((part) => Number(part.trim()));
if (parts.length !== 2 || parts.some((port) => !Number.isInteger(port) || port < 1024 || port > 65535)) throw new UsageError("--ports must be dev,preview (two ports between 1024 and 65535)");
const [dev, preview] = parts;
if (dev === preview) throw new UsageError("--ports dev and preview must differ");
const clashes = parts.filter((port) => taken.has(port));
if (clashes.length) throw new UsageError(`--ports ${clashes.join(", ")} already taken (launch.json, reserved list or a .vite-*-<port> cache dir)`);
return { dev, preview };
}
for (let dev = FIRST_DEV_PORT; dev < 65000; dev += PORT_STEP) {
const preview = dev + PREVIEW_OFFSET;
if (!taken.has(dev) && !taken.has(preview)) return { dev, preview };
}
throw new UsageError("no free port pair found");
}
@@ -0,0 +1,89 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import path from "node:path";
export class RegistryShapeError extends Error {}
const DIRECTION_LINE = /^ {4}\{ id: "([^"]+)", key: "(\d)", name: "([^"]+)", tagline: "((?:[^"\\]|\\.)*)"(, baseline: true)?, stage: "([a-z]+)" \},$/;
const SURFACE_LINE = /^ {4}\{ id: "([^"]+)", label: "((?:[^"\\]|\\.)*)", width: (\d+), nav: "([a-z]+)" \},$/;
function field(text, name, pattern, file) {
const match = new RegExp(`^ ${name}: ${pattern},$`, "m").exec(text);
if (!match) throw new RegistryShapeError(`${file}: cannot find the generator-owned line " ${name}: ..."; the registry was hand-edited past what the generator recognises`);
return match[1];
}
function block(text, name, file) {
const start = text.indexOf(` ${name}: [\n`);
if (start === -1) throw new RegistryShapeError(`${file}: cannot find the generator-owned " ${name}: [" array`);
const end = text.indexOf("\n ],\n", start);
if (end === -1) throw new RegistryShapeError(`${file}: the "${name}" array has no " ]," close on its own line`);
const inner = text.slice(start + ` ${name}: [\n`.length, end);
return { start, end: end + "\n ],\n".length, lines: inner.split("\n") };
}
export function readRegistry(text, file = "registry.ts") {
const slug = field(text, "slug", '"([^"]+)"', file);
const title = field(text, "title", '"((?:[^"\\\\]|\\\\.)*)"', file);
const storyId = field(text, "storyId", '"([^"]+)"', file);
const group = field(text, "group", '"([^"]+)"', file);
const landingRaw = field(text, "landing", '\\{ direction: "([^"]+)", view: "[^"]+" \\}', file);
const landingView = /^ landing: \{ direction: "[^"]+", view: "([^"]+)" \},$/m.exec(text)[1];
const directions = block(text, "directions", file).lines.map((line) => {
const match = DIRECTION_LINE.exec(line);
if (!match) throw new RegistryShapeError(`${file}: direction line not in generator form:\n${line}`);
const [, id, key, name, tagline, baseline, stage] = match;
return { id, key, name, tagline, baseline: Boolean(baseline), stage };
});
const surfaces = block(text, "surfaces", file).lines.map((line) => {
const match = SURFACE_LINE.exec(line);
if (!match) throw new RegistryShapeError(`${file}: surface line not in generator form:\n${line}`);
const [, id, label, width, nav] = match;
return { id, label, width: Number(width), nav };
});
const allowMatch = /^ allow: \[(.*)\],$/m.exec(text);
const allow = allowMatch ? allowMatch[1].split(",").map((item) => item.trim().replace(/^"|"$/g, "")).filter(Boolean) : [];
return { slug, title, storyId, group, landing: { direction: landingRaw, view: landingView }, directions, surfaces, allow, allowMore: /^ allowMore: true,$/m.test(text) };
}
export function directionLine(direction) {
return ` { id: "${direction.id}", key: "${direction.key}", name: "${direction.name}", tagline: "${direction.tagline}", stage: "${direction.stage}" },`;
}
export function insertDirection(text, direction, { file = "registry.ts", allowMore = false } = {}) {
const { lines, start, end } = block(text, "directions", file);
const todayIndex = lines.findIndex((line) => DIRECTION_LINE.exec(line)?.[5]);
if (todayIndex === -1) {
throw new RegistryShapeError(`${file}: no baseline line in the directions array. Add this line by hand before the today entry:\n${directionLine(direction)}`);
}
const next = [...lines.slice(0, todayIndex), directionLine(direction), ...lines.slice(todayIndex)];
let result = `${text.slice(0, start)} directions: [\n${next.join("\n")}\n ],\n${text.slice(end)}`;
if (allowMore && !/^ allowMore: true,$/m.test(result)) {
const allowLine = /^ allow: \[.*\],$/m.exec(result);
if (!allowLine) throw new RegistryShapeError(`${file}: cannot find " allow: [...]," to place allowMore before`);
result = `${result.slice(0, allowLine.index)} allowMore: true,\n${result.slice(allowLine.index)}`;
}
return result;
}
export const STORY_ANCHOR = "export const TodayProductionPalette: Story = {";
export const directionStoryLine = (direction) => `export const D${direction.key}: Story = { name: ${JSON.stringify(direction.name)}, args: { direction: ${JSON.stringify(direction.id)} } };`;
export function insertDirectionStory(text, direction, file = "stories.tsx") {
const line = directionStoryLine(direction);
if (text.includes(`export const D${direction.key}: Story`)) throw new RegistryShapeError(`${file}: export D${direction.key} already exists`);
const anchor = text.indexOf(STORY_ANCHOR);
if (anchor === -1) throw new RegistryShapeError(`${file}: cannot find "${STORY_ANCHOR}" to insert before. Add this line by hand after the last D<N> export:\n${line}`);
return `${text.slice(0, anchor)}${line}\n${text.slice(anchor)}`;
}
export function findLabs(root) {
const dir = path.join(root, "ui/src");
if (!existsSync(dir)) return [];
return readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.endsWith("-directions") && existsSync(path.join(dir, entry.name, "registry.ts")))
.map((entry) => {
const file = path.join(dir, entry.name, "registry.ts");
return { dir: `ui/src/${entry.name}`, file, registry: readRegistry(readFileSync(file, "utf8"), `ui/src/${entry.name}/registry.ts`) };
});
}
@@ -0,0 +1,83 @@
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
export const TEMPLATE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../assets/templates");
async function template(name) {
return import(pathToFileURL(path.join(TEMPLATE_ROOT, name)).href);
}
const LAB_FILES = [
["main.tsx", "main.tsx.mjs"],
["providers.tsx", "providers.tsx.mjs"],
["lab.css", "lab.css.mjs"],
["build-info.ts", "build-info.ts.mjs"],
["define-lab.ts", "define-lab.ts.mjs"],
["registry.ts", "registry.ts.mjs"],
["kit.ts", "kit.ts.mjs"],
["lab-state.ts", "lab-state.ts.mjs"],
["api-stub.ts", "api-stub.ts.mjs"],
["api-routes.ts", "api-routes.ts.mjs"],
["fixtures.ts", "fixtures.ts.mjs"],
["shot.cjs", "shot.cjs.mjs"],
["frames/Slot.tsx", "frames/Slot.tsx.mjs"],
["frames/LabToolbar.tsx", "frames/LabToolbar.tsx.mjs"],
["frames/Board.tsx", "frames/Board.tsx.mjs"],
["frames/AppShell.tsx", "frames/AppShell.tsx.mjs"],
["frames/SurfaceView.tsx", "frames/SurfaceView.tsx.mjs"],
["directions/today/index.tsx", "today/index.tsx.mjs"],
["directions/today/adapters.ts", "today/adapters.ts.mjs"],
["registry.test.ts", "tests/registry.test.ts.mjs"],
["lab-state.test.ts", "tests/lab-state.test.ts.mjs"],
["motion-contract.test.ts", "tests/motion-contract.test.ts.mjs"],
["today.test.tsx", "tests/today.test.tsx.mjs"],
["api-stub.test.ts", "tests/api-stub.test.ts.mjs"],
];
export async function renderDirection(ctx, direction) {
const files = new Map();
const dir = `${ctx.labDir}/directions/${direction.id}`;
const index = await template("direction/index.tsx.mjs");
const tokens = await template("direction/tokens.css.mjs");
const specimen = await template("direction/MotionSpecimen.tsx.mjs");
const body = await template("direction/SlotBody.tsx.mjs");
files.set(`${dir}/index.tsx`, index.default(ctx, direction));
files.set(`${dir}/tokens.css`, tokens.default(ctx, direction));
files.set(`${dir}/MotionSpecimen.tsx`, specimen.default(ctx, direction));
for (const surface of ctx.surfaces) files.set(`${dir}/${surface.body}.tsx`, body.default(ctx, direction, surface));
return files;
}
export async function renderLab(ctx) {
const files = new Map();
const entry = await template("entry.html.mjs");
const config = await template("vite.config.mjs.mjs");
const story = await template("stories/compare.stories.tsx.mjs");
const buildInfo = await template("build-info.ts.mjs");
files.set(`ui/${ctx.entry}`, entry.default(ctx));
files.set(`ui/${ctx.configFile}`, config.default(ctx));
files.set(ctx.storyFile, story.default(ctx));
files.set(`${ctx.labDir}/lab-env.d.ts`, buildInfo.envDts(ctx));
for (const [target, name] of LAB_FILES) {
const module = await template(name);
files.set(`${ctx.labDir}/${target}`, module.default(ctx));
}
const frame = await template("frames/SurfaceFrame.tsx.mjs");
const todayBody = await template("today/SlotBody.tsx.mjs");
for (const surface of ctx.surfaces) {
files.set(`${ctx.labDir}/frames/${surface.frame}.tsx`, frame.default(ctx, surface));
files.set(`${ctx.labDir}/directions/today/${surface.body}.tsx`, todayBody.default(ctx, surface));
}
for (const direction of ctx.directions) for (const [target, content] of await renderDirection(ctx, direction)) files.set(target, content);
return files;
}
export async function launchEntries(ctx) {
const module = await template("launch-entries.json.mjs");
return { entries: module.default(ctx), names: module.launchNames(ctx) };
}
export async function motionBlockFor(direction) {
const module = await template("direction/tokens.css.mjs");
return module.motionBlock(direction);
}
@@ -0,0 +1,84 @@
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import path from "node:path";
export const GITIGNORE_ANCHOR = "dist-flow-preview/";
function readOriginal(file) {
return existsSync(file) ? readFileSync(file, "utf8") : null;
}
export function launchJsonWithEntries(original, entries) {
const parsed = original ? JSON.parse(original) : { version: "0.0.1", configurations: [] };
const configurations = Array.isArray(parsed.configurations) ? parsed.configurations : [];
for (const entry of entries) {
const index = configurations.findIndex((existing) => existing.name === entry.name);
if (index === -1) configurations.push(entry);
else configurations[index] = entry;
}
return `${JSON.stringify({ ...parsed, configurations }, null, 2)}\n`;
}
export function launchJsonWithoutEntries(original, names) {
const parsed = JSON.parse(original);
const configurations = (parsed.configurations ?? []).filter((entry) => !names.includes(entry.name));
return `${JSON.stringify({ ...parsed, configurations }, null, 2)}\n`;
}
export function gitignoreWithLine(original, line) {
const text = original ?? "";
const lines = text.split("\n");
if (lines.includes(line)) return text;
const anchor = lines.indexOf(GITIGNORE_ANCHOR);
if (anchor !== -1) {
lines.splice(anchor + 1, 0, line);
return lines.join("\n");
}
const trimmed = text.endsWith("\n") || text === "" ? text : `${text}\n`;
return `${trimmed}${line}\n`;
}
export function gitignoreWithoutLine(original, line) {
return original
.split("\n")
.filter((existing) => existing !== line)
.join("\n");
}
export function addLaunchEntries(root, entries) {
const file = path.join(root, ".claude/launch.json");
const original = readOriginal(file);
mkdirSync(path.dirname(file), { recursive: true });
writeFileSync(file, launchJsonWithEntries(original, entries));
return { file, original };
}
export function addGitignoreLine(root, line) {
const file = path.join(root, ".gitignore");
const original = readOriginal(file);
writeFileSync(file, gitignoreWithLine(original, line));
return { file, original };
}
export function removeLaunchEntries(root, names) {
const file = path.join(root, ".claude/launch.json");
const original = readOriginal(file);
if (original === null) return { file, original };
writeFileSync(file, launchJsonWithoutEntries(original, names));
return { file, original };
}
export function removeGitignoreLine(root, line) {
const file = path.join(root, ".gitignore");
const original = readOriginal(file);
if (original === null) return { file, original };
writeFileSync(file, gitignoreWithoutLine(original, line));
return { file, original };
}
export function restoreShared(records) {
for (const { file, original } of records) {
if (original === null) {
if (existsSync(file)) unlinkSync(file);
} else writeFileSync(file, original);
}
}
@@ -0,0 +1,49 @@
import { spawnSync } from "node:child_process";
import path from "node:path";
export function runStep(label, command, args, { cwd, env = {} }) {
const result = spawnSync(command, args, {
cwd,
env: { ...process.env, ...env, CI: process.env.CI ?? "1" },
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
const display = `${Object.entries(env)
.map(([key, value]) => `${key}=${value} `)
.join("")}${command} ${args.join(" ")}`;
return { label, command: display, cwd, ok: result.status === 0, status: result.status, output, error: result.error?.message };
}
export function verificationSteps(ctx, { noBuild = false } = {}) {
const steps = [
{ label: "storybook catalog", command: "node", args: ["scripts/storybook-catalog.mjs", "--check"], cwd: "." },
{ label: "lab vitest", command: "npx", args: ["vitest", "run", `src/${ctx.slug}-directions`], cwd: "ui" },
];
if (!noBuild) {
steps.push({
label: "lab vite build",
command: "npx",
args: ["vite", "build", "--config", ctx.configFile],
cwd: "ui",
env: { [ctx.envOutDir]: ctx.staticDir },
});
}
return steps;
}
export function runVerification(root, steps, { onStep } = {}) {
const results = [];
for (const step of steps) {
const result = runStep(step.label, step.command, step.args, { cwd: path.join(root, step.cwd), env: step.env });
results.push(result);
onStep?.(result);
if (!result.ok) break;
}
return { ok: results.every((result) => result.ok), results };
}
export function describeFailure(result) {
const header = `FAILED ${result.label}: (cd ${result.cwd} && ${result.command}) exited ${result.status ?? "with no status"}`;
return `${header}\n${result.error ? `${result.error}\n` : ""}${result.output}`;
}
@@ -0,0 +1,63 @@
import { existsSync, mkdirSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import path from "node:path";
export const MAX_LINES = 600;
export const COMMENT_LINE = /^\s*(\/\/|\/\*|\{\/\*|<!--)/;
export const VITEST_PRAGMA = /^\/\/ @vitest-environment [a-z-]+$/;
export function commentLines(content) {
const hits = [];
content.split("\n").forEach((line, index) => {
if (index === 0 && VITEST_PRAGMA.test(line)) return;
if (COMMENT_LINE.test(line)) hits.push(index + 1);
});
return hits;
}
export function lineCount(content) {
return content.endsWith("\n") ? content.split("\n").length - 1 : content.split("\n").length;
}
export function checkRendered(files, { root }) {
const errors = [];
for (const [relative, content] of files) {
if (typeof content !== "string" || !content.trim()) errors.push(`${relative}: rendered empty`);
const comments = commentLines(content ?? "");
if (comments.length) errors.push(`${relative}: comment line(s) at ${comments.slice(0, 5).join(", ")}; generated code carries no comments`);
const lines = lineCount(content ?? "");
if (lines >= MAX_LINES) errors.push(`${relative}: ${lines} lines reaches the ${MAX_LINES}-line limit`);
if (existsSync(path.join(root, relative))) errors.push(`${relative}: already exists; refusing to overwrite`);
}
return errors;
}
export function newJournal() {
return { files: [], dirs: [] };
}
export function writeFiles(files, { root, journal = newJournal() }) {
for (const [relative] of files) {
let dir = path.dirname(path.join(root, relative));
const missing = [];
while (!existsSync(dir)) {
missing.unshift(dir);
dir = path.dirname(dir);
}
for (const created of missing) {
if (!journal.dirs.includes(created)) journal.dirs.push(created);
mkdirSync(created);
}
}
for (const [relative, content] of files) {
const target = path.join(root, relative);
writeFileSync(target, content);
journal.files.push(target);
}
return journal;
}
export function rollback(journal) {
for (const file of journal.files) if (existsSync(file)) unlinkSync(file);
const dirs = [...journal.dirs].sort((a, b) => b.length - a.length);
for (const dir of dirs) if (existsSync(dir) && statSync(dir).isDirectory()) rmSync(dir, { recursive: true, force: true });
}
@@ -0,0 +1,282 @@
#!/usr/bin/env node
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { COMMANDS, helpText, parseArgs, UsageError } from "./lib/args.mjs";
import { validateCapabilities } from "./lib/capabilities.mjs";
import { checkLab, verificationBlock } from "./lib/check.mjs";
import { assembleContext, buildContext, deriveDirection, deriveSurface, upperSnake } from "./lib/context.mjs";
import { pickPorts } from "./lib/ports.mjs";
import { findLabs, insertDirection, insertDirectionStory, readRegistry, RegistryShapeError } from "./lib/registry-edit.mjs";
import { launchEntries, renderDirection, renderLab } from "./lib/render.mjs";
import { addGitignoreLine, addLaunchEntries, restoreShared } from "./lib/shared-files.mjs";
import { describeFailure, runVerification, verificationSteps } from "./lib/verify.mjs";
import { checkRendered, newJournal, rollback, writeFiles } from "./lib/write.mjs";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
export const DEFAULT_ROOT = path.resolve(SCRIPT_DIR, "../../../..");
const log = (...parts) => console.log(...parts);
function resolveRoot(flags) {
const root = path.resolve(flags.root ?? DEFAULT_ROOT);
if (!existsSync(root)) throw new UsageError(`--root ${root} does not exist`);
return root;
}
function nextCommands(ctx) {
return [
`preview_start ${ctx.slug}-directions-dev (or: cd ui && npx vite --config ${ctx.configFile} --port ${ctx.ports.dev} --strictPort)`,
`open http://127.0.0.1:${ctx.ports.dev}/ · /?d=today&s=${ctx.surfaces[0].id} · /?d=${ctx.directions[0].id}&s=${ctx.surfaces[0].id}`,
`cd ui && npx vitest run src/${ctx.slug}-directions`,
`node .agents/skills/directions-lab/scripts/scaffold.mjs check --slug ${ctx.slug}`,
`node ${ctx.labDir}/shot.cjs --port ${ctx.ports.dev} --d all --out <scratch> [--hover]`,
];
}
function plan(ctx, files, launch) {
return {
lab: { slug: ctx.slug, title: ctx.title, storyId: ctx.storyId, group: ctx.group, capabilities: ctx.capabilities, exception: ctx.capabilities.length ? null : ctx.exception },
directions: ctx.kits.map(({ id, key, name, tagline }) => ({ id, key, name, tagline })),
surfaces: ctx.surfaces.map(({ id, label, width, nav }) => ({ id, label, width, nav })),
toolbar: { surfacesRow: ctx.multiSurface, views: ctx.multiSurface ? ["board", ...ctx.surfaces.map((surface) => surface.id)] : ["board", ctx.surfaces[0].id] },
cells: ctx.cells,
landing: `?d=${ctx.landing.direction}&s=${ctx.landing.view}`,
ports: ctx.ports,
paths: [...files.keys()],
shared: { launch: launch.names, gitignore: ctx.distDir },
commands: nextCommands(ctx),
};
}
async function commandNew(flags) {
const root = resolveRoot(flags);
const capabilities = await validateCapabilities(root, flags.capabilities);
const ports = pickPorts(root, flags.ports);
const ctx = buildContext(flags, { root, ports, capabilities });
if (ctx.forceCount) console.warn(`WARN: ${ctx.directions.length} directions; the house rule is 2 or 3 (forced with --force-count)`);
if (!capabilities.length) console.warn(`NOTE: no --capabilities; the story carries capabilities: [] with the exception sentence: ${ctx.exception}`);
const files = await renderLab(ctx);
const launch = await launchEntries(ctx);
const problems = checkRendered(files, { root });
if (problems.length) throw new UsageError(`refusing to write:\n ${problems.join("\n ")}`);
const summary = plan(ctx, files, launch);
if (flags["dry-run"]) {
if (flags.json) log(JSON.stringify(summary, null, 2));
else {
log(`Dry run for ${ctx.labDir} (${ctx.cells} cells, landing ${summary.landing}, ports ${ports.dev}/${ports.preview}, surfaces row ${ctx.multiSurface ? "yes" : "no"})`);
for (const file of summary.paths) log(` ${file}`);
log(` .claude/launch.json (+${launch.names.join(", ")})`);
log(` .gitignore (+${ctx.distDir})`);
}
return 0;
}
const progress = flags.json ? (...parts) => console.error(...parts) : log;
const journal = newJournal();
const shared = [];
const undo = () => {
rollback(journal);
restoreShared(shared);
for (const dir of [path.join(root, "ui", ctx.staticDir), path.join(root, "ui/node_modules", `.vite-${ctx.slug}-directions-${ctx.ports.dev}`)]) rmSync(dir, { recursive: true, force: true });
};
let verification = { ok: true, results: [] };
try {
writeFiles(files, { root, journal });
shared.push(addLaunchEntries(root, launch.entries));
shared.push(addGitignoreLine(root, ctx.distDir));
progress(`Wrote ${files.size} files under ${root} for ${ctx.labDir}; edited .claude/launch.json and .gitignore`);
if (!flags["skip-verify"]) {
verification = runVerification(root, verificationSteps(ctx, { noBuild: flags["no-build"] }), {
onStep: (result) => progress(`${result.ok ? "ok " : "FAIL"} ${result.label}: ${result.command}`),
});
}
} catch (error) {
undo();
console.error(`scaffold: ${error.message}`);
console.error("Rolled back every written path and restored .claude/launch.json and .gitignore.");
return 1;
}
if (!verification.ok) {
const failed = verification.results.find((result) => !result.ok);
console.error(describeFailure(failed));
if (flags["keep-on-failure"]) {
console.error(`Kept the written files for inspection (--keep-on-failure). Remove them with: rm -rf ${ctx.labDir} ui/${ctx.entry} ui/${ctx.configFile} ${ctx.storyFile}`);
} else {
undo();
console.error("Rolled back every written path and restored .claude/launch.json and .gitignore.");
}
return 1;
}
if (flags.json) log(JSON.stringify({ ...summary, verification: verification.results.map(({ label, command, ok }) => ({ label, command, ok })) }, null, 2));
else {
log(`\n${ctx.title} directions lab: ${ctx.kits.length} kits × ${ctx.surfaces.length} surface(s) = ${ctx.cells} cells; landing ${summary.landing}; story ${ctx.storyId}`);
log(`Ports: dev ${ports.dev}, preview ${ports.preview}. Surfaces row: ${ctx.multiSurface ? "yes" : "no (legend joins row 1)"}.`);
log(`Verification: ${verification.results.length ? verification.results.map((result) => `${result.label} ok`).join(", ") : "skipped"}`);
log("Next:");
for (const command of summary.commands) log(` ${command}`);
}
return 0;
}
function commandList(flags) {
const root = resolveRoot(flags);
const labs = findLabs(root);
const launch = existsSync(path.join(root, ".claude/launch.json")) ? JSON.parse(readFileSync(path.join(root, ".claude/launch.json"), "utf8")).configurations : [];
const rows = labs.map(({ dir, registry }) => ({
slug: registry.slug,
title: registry.title,
dir,
storyId: registry.storyId,
directions: registry.directions.map((direction) => direction.id),
surfaces: registry.surfaces.map((surface) => surface.id),
ports: {
dev: launch.find((entry) => entry.name === `${registry.slug}-directions-dev`)?.port ?? null,
preview: launch.find((entry) => entry.name === `${registry.slug}-directions`)?.port ?? null,
},
}));
if (flags.json) log(JSON.stringify(rows, null, 2));
else if (!rows.length) log("No labs found (ui/src/*-directions/registry.ts).");
else for (const row of rows) log(`${row.slug}\t${row.title}\t${row.dir}\tdirections: ${row.directions.join(", ")}\tsurfaces: ${row.surfaces.join(", ")}\tports: ${row.ports.dev ?? "?"}/${row.ports.preview ?? "?"}\tstory: ${row.storyId}`);
return 0;
}
function commandCheck(flags) {
const root = resolveRoot(flags);
if (!flags.slug) throw new UsageError("--slug is required");
const result = checkLab(root, flags.slug, { strict: Boolean(flags.strict), run: Boolean(flags.run), skipVerify: Boolean(flags["skip-verify"]) });
if (flags.json) log(JSON.stringify({ ok: result.errors.length === 0, errors: result.errors, warnings: result.warnings, verification: result.verification.map(({ label, command, ok }) => ({ label, command, ok })), notRun: result.notRun }, null, 2));
else {
for (const warning of result.warnings) log(`WARN ${warning}`);
for (const error of result.errors) console.error(`FAIL ${error}`);
if (flags.run) log(`\n${verificationBlock(result.verification, { skipped: result.notRun })}`);
log(result.errors.length ? `\ncheck: ${result.errors.length} problem(s)` : `check: ok (${result.warnings.length} warning(s))`);
}
return result.errors.length ? 1 : 0;
}
async function commandAddDirection(flags) {
const root = resolveRoot(flags);
if (!flags.slug) throw new UsageError("--slug is required");
if (!flags.name) throw new UsageError("--name is required");
const labDir = path.join(root, `ui/src/${flags.slug}-directions`);
const registryFile = path.join(labDir, "registry.ts");
if (!existsSync(registryFile)) throw new UsageError(`no lab at ui/src/${flags.slug}-directions`);
const text = readFileSync(registryFile, "utf8");
const registry = readRegistry(text, path.relative(root, registryFile));
const explorations = registry.directions.filter((direction) => !direction.baseline);
const digit = explorations.length + 1;
if (digit > 9) throw new UsageError("a lab holds at most nine explorations (one digit each)");
if (digit > 3 && !flags["force-count"]) throw new UsageError(`this lab already has ${explorations.length} explorations; a ${digit}th needs --force-count`);
const direction = deriveDirection(digit, flags.name);
if (registry.directions.some((entry) => entry.id === direction.id)) throw new UsageError(`direction ${direction.id} already exists`);
const ctx = assembleContext({
root,
slug: registry.slug,
title: registry.title,
group: registry.group,
surfaces: registry.surfaces.map(deriveSurface),
directions: [...explorations.map((entry) => ({ ...entry, digit: Number(entry.key), prefix: entry.id.replace(/^d\d+-/, "") })), direction],
company: { id: `company-${registry.slug}`, name: registry.slug, issuePrefix: upperSnake(registry.slug).slice(0, 3) },
landing: registry.landing,
ports: { dev: 0, preview: 0 },
capabilities: [],
forceCount: digit > 3,
});
const files = await renderDirection(ctx, direction);
const problems = checkRendered(files, { root });
if (problems.length) throw new UsageError(`refusing to write:\n ${problems.join("\n ")}`);
const storyFile = path.join(root, `ui/storybook/stories/design-proposals/${registry.slug}-directions.stories.tsx`);
if (!existsSync(storyFile)) throw new UsageError(`${path.relative(root, storyFile)} is missing`);
const storyText = readFileSync(storyFile, "utf8");
const kitFile = path.join(labDir, "kit.ts");
const changed = changedSlotContracts(existsSync(kitFile) ? readFileSync(kitFile, "utf8") : "", ctx.surfaces);
if (changed.length) {
throw new UsageError(
`the slot props in ui/src/${registry.slug}-directions/kit.ts no longer match the scaffold's placeholder (${changed.join(", ")}), ` +
"so a generated kit would not fit them. Add the direction by hand: copy an existing exploration's directory to " +
`directions/${direction.id}, rename its id, name, tagline and --<prefix>- tokens, then add its registry entry and the D${digit} story export.`,
);
}
const next = insertDirection(text, direction, { file: path.relative(root, registryFile), allowMore: digit > 3 });
const nextStory = insertDirectionStory(storyText, direction, path.relative(root, storyFile));
const journal = newJournal();
const undo = () => {
rollback(journal);
writeFileSync(registryFile, text);
writeFileSync(storyFile, storyText);
};
let errors = [];
try {
writeFiles(files, { root, journal });
writeFileSync(registryFile, next);
writeFileSync(storyFile, nextStory);
errors = checkLab(root, registry.slug, { skipVerify: Boolean(flags["skip-verify"]) }).errors;
if (!errors.length && !flags["skip-verify"]) {
const steps = verificationSteps(ctx, { noBuild: true }).filter((step) => step.label === "lab vitest");
const verification = runVerification(root, steps);
if (!verification.ok) errors = [describeFailure(verification.results.find((result) => !result.ok))];
}
} catch (error) {
errors = [error.message];
}
if (errors.length) {
undo();
for (const error of errors) console.error(`FAIL ${error}`);
console.error("Rolled the new direction back.");
return 1;
}
if (flags.json) log(JSON.stringify({ direction: { id: direction.id, key: direction.key, name: direction.name, tagline: direction.tagline }, paths: [...files.keys(), path.relative(root, registryFile), path.relative(root, storyFile)] }, null, 2));
else {
log(`Added ${direction.id} (${direction.name}): ${files.size} files, a registry entry and the story export D${digit}.`);
log(`Next: cd ui && npx vitest run src/${registry.slug}-directions`);
}
return 0;
}
export function changedSlotContracts(kitText, surfaces) {
if (!/export interface LabRecord \{/.test(kitText)) return ["LabRecord"];
return surfaces
.filter((surface) => ![` ${JSON.stringify(surface.id)}: { items: LabRecord[] };`, ` ${surface.id}: { items: LabRecord[] };`].some((line) => kitText.includes(line)))
.map((surface) => surface.id);
}
export async function main(argv) {
let parsed;
try {
parsed = parseArgs(argv);
} catch (error) {
if (error instanceof UsageError) {
console.error(`scaffold: ${error.message}\n`);
console.error(helpText());
return 1;
}
throw error;
}
if (parsed.command === "help") {
log(helpText());
return 0;
}
try {
if (parsed.command === "new") return await commandNew(parsed.flags);
if (parsed.command === "list") return commandList(parsed.flags);
if (parsed.command === "check") return commandCheck(parsed.flags);
if (parsed.command === "add-direction") return await commandAddDirection(parsed.flags);
throw new UsageError(`unknown subcommand ${parsed.command}; expected ${Object.keys(COMMANDS).join(", ")}`);
} catch (error) {
if (error instanceof UsageError || error instanceof RegistryShapeError) {
console.error(`scaffold ${parsed.command}: ${error.message}`);
return 1;
}
throw error;
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main(process.argv.slice(2)).then(
(code) => process.exit(code),
(error) => {
console.error(`scaffold: ${error.stack ?? error.message}`);
process.exit(1);
},
);
}
@@ -0,0 +1,554 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { createRequire } from "node:module";
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { after, before, describe, it } from "node:test";
import { parseArgs, UsageError } from "./lib/args.mjs";
import { NOT_RUN } from "./lib/check.mjs";
import { buildContext, deriveDirection, parseSurfaces, sentenceCase } from "./lib/context.mjs";
import { detectMode, expectedStoryStatus, labStage, MODE_A_SENTENCE } from "./lib/modes.mjs";
import { pickPorts } from "./lib/ports.mjs";
import { directionLine, insertDirection, readRegistry } from "./lib/registry-edit.mjs";
import { motionBlockFor, renderLab } from "./lib/render.mjs";
import { gitignoreWithLine, launchJsonWithEntries } from "./lib/shared-files.mjs";
import { checkRendered, commentLines, lineCount, MAX_LINES } from "./lib/write.mjs";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const SCAFFOLD = path.join(SCRIPT_DIR, "scaffold.mjs");
const REPO_ROOT = path.resolve(SCRIPT_DIR, "../../../..");
const MOTION_ASSET = path.join(SCRIPT_DIR, "../assets/motion/v1-mvp-motion.css");
const D5_TOKENS = path.join(REPO_ROOT, "ui/src/artifact-directions/directions/d5-v1-mvp/tokens.css");
const SHAPES = {
composer: ["--slug", "composer", "--title", "Composer", "--surfaces", "thread:Task-thread:960@tasks,panel:Right-panel:434@tasks", "--directions", "3"],
"inbox-row": ["--slug", "inbox-row", "--title", "Inbox row", "--surfaces", "inbox:Inbox:960@other", "--directions", "2"],
"agent-profile-card": ["--slug", "agent-profile-card", "--title", "Agent profile card", "--surfaces", "card:Agents-page:1152@other", "--directions", "3"],
};
function makeRoot() {
const root = mkdtempSync(path.join(tmpdir(), "directions-lab-"));
mkdirSync(path.join(root, ".claude"), { recursive: true });
mkdirSync(path.join(root, "ui/src"), { recursive: true });
mkdirSync(path.join(root, "ui/storybook/stories/design-proposals"), { recursive: true });
mkdirSync(path.join(root, "scripts"), { recursive: true });
writeFileSync(path.join(root, ".claude/launch.json"), `${JSON.stringify({ version: "0.0.1", configurations: [{ name: "paperclip", port: 3121 }] }, null, 2)}\n`);
writeFileSync(path.join(root, ".gitignore"), "node_modules\ndist/\ndist-flow-preview/\nserver/ui-dist/\n");
writeFileSync(path.join(root, "scripts/storybook-catalog.mjs"), `const sentence = "${MODE_A_SENTENCE}";\n`);
return root;
}
function run(args, { root }) {
const result = spawnSync(process.execPath, [SCAFFOLD, ...args, ...(root ? ["--root", root] : [])], { encoding: "utf8" });
return { code: result.status, out: `${result.stdout}${result.stderr}`, json: () => JSON.parse(result.stdout) };
}
function walk(dir) {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const file = path.join(dir, entry.name);
return entry.isDirectory() ? walk(file) : [file];
});
}
const normalise = (css) =>
css
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/\s+/g, " ")
.trim();
function d5MotionBlock() {
const css = readFileSync(D5_TOKENS, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
const start = css.indexOf('[data-direction="d5-v1-mvp"] [data-rotate]');
assert.ok(start > -1, "d5 tokens.css has the [data-rotate] rest rule");
const media = css.indexOf("@media (prefers-reduced-motion: no-preference)", start);
let depth = 0;
let end = -1;
for (let i = css.indexOf("{", media); i < css.length; i += 1) {
if (css[i] === "{") depth += 1;
if (css[i] === "}") {
depth -= 1;
if (depth === 0) {
end = i + 1;
break;
}
}
}
return css.slice(start, end);
}
describe("flag parsing", () => {
it("reads --k v and --k=v and boolean flags", () => {
const { command, flags } = parseArgs(["new", "--slug", "x", "--directions=3", "--dry-run", "--json"]);
assert.equal(command, "new");
assert.deepEqual(flags, { slug: "x", directions: "3", "dry-run": true, json: true });
});
it("refuses unknown subcommands and flags", () => {
assert.throws(() => parseArgs(["frobnicate"]), UsageError);
assert.throws(() => parseArgs(["new", "--nope", "1"]), UsageError);
assert.throws(() => parseArgs(["check", "--slug"]), UsageError);
});
});
describe("context derivation", () => {
const ports = { dev: 5200, preview: 5210 };
it("derives casings, ids, cells and the landing rule", () => {
const ctx = buildContext({ slug: "inbox-row", surfaces: "inbox:Inbox:960@other", directions: "2" }, { root: REPO_ROOT, ports });
assert.equal(ctx.title, "Inbox row");
assert.equal(sentenceCase("agent-profile-card"), "Agent profile card");
assert.equal(buildContext({ slug: "inbox-row", title: "Inbox Row", surfaces: "inbox:Inbox:960@other", directions: "2" }, { root: REPO_ROOT, ports }).title, "Inbox Row");
assert.equal(ctx.SLUG, "INBOX_ROW");
assert.ok(!("Slug" in ctx));
assert.equal(ctx.envOutDir, "INBOX_ROW_DIRECTIONS_OUT_DIR");
assert.equal(ctx.storyId, "design-inbox-row-directions");
assert.deepEqual(ctx.directions.map((d) => d.id), ["d1-refine", "d2-rethink"]);
assert.deepEqual(ctx.kits.map((d) => d.key), ["1", "2", "0"]);
assert.equal(ctx.cells, 3);
assert.equal(ctx.multiSurface, false);
assert.deepEqual(ctx.landing, { direction: "all", view: "board" });
assert.equal(ctx.surfaces[0].body, "InboxBody");
assert.equal(ctx.surfaces[0].fixture, "INBOX_ITEMS");
});
it("lands on the first surface when the board would exceed eight cells", () => {
const ctx = buildContext({ slug: "wide", surfaces: "a:A:960@tasks,b:B:960@tasks,c:C:960@tasks", directions: "3" }, { root: REPO_ROOT, ports });
assert.equal(ctx.cells, 12);
assert.deepEqual(ctx.landing, { direction: "all", view: "a" });
});
it("refuses invalid inputs", () => {
const base = { surfaces: "thread:Task-thread:960@tasks", directions: "3" };
assert.throws(() => buildContext({ ...base, slug: "Bad Slug" }, { root: REPO_ROOT, ports }), /kebab-case/);
assert.throws(() => buildContext({ ...base, slug: "x-directions" }, { root: REPO_ROOT, ports }), /-directions/);
assert.throws(() => buildContext({ ...base, slug: "x", directions: "4" }, { root: REPO_ROOT, ports }), /--force-count/);
assert.throws(() => buildContext({ ...base, slug: "x", directions: "1" }, { root: REPO_ROOT, ports }), /2 or 3/);
assert.throws(() => buildContext({ ...base, slug: "x", surfaces: "thread:Task-thread:960@nowhere" }, { root: REPO_ROOT, ports }), /nav/);
assert.throws(() => buildContext({ ...base, slug: "x", surfaces: "thread" }, { root: REPO_ROOT, ports }), /id:Label:width@nav/);
assert.throws(() => buildContext({ ...base, slug: "x", group: "Widgets" }, { root: REPO_ROOT, ports }), /--group/);
assert.throws(() => buildContext({ ...base, slug: "x", company: "ACME" }, { root: REPO_ROOT, ports }), /--company/);
assert.throws(() => buildContext({ ...base, slug: "x", names: "A,B" }, { root: REPO_ROOT, ports }), /names/);
assert.throws(() => buildContext({ ...base, slug: "x", landing: "d9-nope" }, { root: REPO_ROOT, ports }), /--landing/);
assert.throws(() => parseSurfaces("board:Board:960@tasks"), /board/);
});
it("allows four to nine directions only with --force-count and explicit names", () => {
const ctx = buildContext({ slug: "x", surfaces: "a:A:960@tasks", directions: "4", names: "One,Two,Three,Four", "force-count": true }, { root: REPO_ROOT, ports });
assert.equal(ctx.directions.length, 4);
assert.equal(ctx.forceCount, true);
assert.equal(ctx.directions[3].id, "d4-four");
assert.equal(deriveDirection(2, "Bold Paper").id, "d2-bold-paper");
assert.equal(deriveDirection(2, "Bold Paper").prefix, "bold-paper");
});
});
describe("ports", () => {
it("skips launch.json, reserved and cache-dir ports and steps by 20", () => {
const root = makeRoot();
try {
mkdirSync(path.join(root, "ui/node_modules/.vite-foo-directions-5200"), { recursive: true });
assert.deepEqual(pickPorts(root), { dev: 5220, preview: 5230 });
assert.throws(() => pickPorts(root, "3121,9000"), /already taken/);
assert.throws(() => pickPorts(root, "9000"), /dev,preview/);
assert.deepEqual(pickPorts(root, "9000,9001"), { dev: 9000, preview: 9001 });
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
describe("write guards", () => {
it("flags comment lines except the vitest environment pragma on line one", () => {
assert.deepEqual(commentLines("a\n// b\n /* c */\n {/* d */}\n<!-- e -->\n"), [2, 3, 4, 5]);
assert.deepEqual(commentLines("// @vitest-environment jsdom\n\nimport x from 'y';\n"), []);
assert.deepEqual(commentLines("import x from 'y';\n// @vitest-environment jsdom\n"), [2]);
});
it("counts lines and refuses the limit and existing paths", () => {
assert.equal(lineCount("a\nb\n"), 2);
const root = makeRoot();
try {
const long = `${"x\n".repeat(MAX_LINES)}`;
const errors = checkRendered(new Map([["ui/long.ts", long], [".gitignore", "ok\n"], ["ui/c.ts", "// nope\n"]]), { root });
assert.ok(errors.some((error) => error.includes("600-line limit")));
assert.ok(errors.some((error) => error.includes("already exists")));
assert.ok(errors.some((error) => error.includes("comment line")));
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
describe("shared files", () => {
it("appends launch entries by name, idempotently, with two-space JSON", () => {
const original = `${JSON.stringify({ version: "0.0.1", configurations: [{ name: "a", port: 1 }] }, null, 2)}\n`;
const once = launchJsonWithEntries(original, [{ name: "b", port: 2 }]);
const twice = launchJsonWithEntries(once, [{ name: "b", port: 2 }]);
assert.equal(once, twice);
assert.deepEqual(JSON.parse(twice).configurations.map((entry) => entry.name), ["a", "b"]);
});
it("inserts the gitignore line after the dist-flow-preview anchor, else at the end, once", () => {
assert.equal(gitignoreWithLine("dist/\ndist-flow-preview/\nx/\n", "ui/dist-a-directions/"), "dist/\ndist-flow-preview/\nui/dist-a-directions/\nx/\n");
assert.equal(gitignoreWithLine("dist/\n", "ui/dist-a-directions/"), "dist/\nui/dist-a-directions/\n");
assert.equal(gitignoreWithLine("dist/", "ui/dist-a-directions/"), "dist/\nui/dist-a-directions/\n");
const withLine = gitignoreWithLine("dist/\n", "ui/dist-a-directions/");
assert.equal(gitignoreWithLine(withLine, "ui/dist-a-directions/"), withLine);
});
});
describe("modes", () => {
it("detects mode A from the catalog sentence and B otherwise", () => {
const root = makeRoot();
try {
assert.equal(detectMode(root), "A");
writeFileSync(path.join(root, "scripts/storybook-catalog.mjs"), "nothing here\n");
assert.equal(detectMode(root), "B");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it("derives the lab stage from the explorations and maps it to the Compare status per mode", () => {
const today = { id: "today", baseline: true, stage: "shipped" };
const lab = (...stages) => [...stages.map((stage, index) => ({ id: `d${index + 1}-x`, stage })), today];
assert.equal(labStage(lab("proposed", "proposed")), "proposed");
assert.equal(labStage(lab("current", "proposed", "proposed")), "proposed");
assert.equal(labStage(lab("current", "adopted")), "proposed");
assert.equal(labStage(lab("historical", "proposed")), "proposed");
assert.equal(labStage(lab("current", "current", "current")), "current");
assert.equal(labStage(lab("current", "historical", "historical")), "current");
assert.equal(labStage(lab("historical", "historical")), "historical");
assert.equal(labStage([today]), "proposed");
for (const stage of ["proposed", "current", "historical"]) assert.equal(expectedStoryStatus("A", stage), "proposed");
assert.equal(expectedStoryStatus("B", "proposed"), "proposed");
assert.equal(expectedStoryStatus("B", "current"), "current");
assert.equal(expectedStoryStatus("B", "historical"), "historical");
});
});
describe("motion asset", () => {
it("equals the d5 block after comment stripping, whitespace normalisation and substitution", () => {
const expected = normalise(d5MotionBlock()).replaceAll("d5-v1-mvp", "<id>").replaceAll("--v1-", "--<p>-");
assert.equal(normalise(readFileSync(MOTION_ASSET, "utf8")), expected);
});
it("renders with the direction's id and prefix substituted", async () => {
const block = await motionBlockFor({ id: "d2-bold", prefix: "bold" });
assert.ok(block.includes('[data-direction="d2-bold"] [data-rotate]'));
assert.ok(block.includes("--bold-hover-ease: 300ms cubic-bezier(0.3, 0, 0.1, 1)"));
assert.ok(!block.includes("<id>") && !block.includes("<p>"));
});
});
describe("rendering the three eval shapes", () => {
const root = makeRoot();
const results = {};
before(() => {
for (const [slug, args] of Object.entries(SHAPES)) {
results[slug] = run(["new", ...args, "--skip-verify", "--json"], { root });
assert.equal(results[slug].code, 0, results[slug].out);
}
});
after(() => rmSync(root, { recursive: true, force: true }));
it("writes the lab, entry, config and story and reports the plan as JSON", () => {
const plan = results.composer.json();
assert.equal(plan.lab.storyId, "design-composer-directions");
assert.equal(plan.cells, 8);
assert.equal(plan.landing, "?d=all&s=board");
assert.deepEqual(plan.ports, { dev: 5200, preview: 5210 });
for (const file of ["ui/composer-directions.html", "ui/vite.composer-directions.config.mjs", "ui/storybook/stories/design-proposals/composer-directions.stories.tsx", "ui/src/composer-directions/registry.ts", "ui/src/composer-directions/kit.ts", "ui/src/composer-directions/lab-state.ts", "ui/src/composer-directions/main.tsx", "ui/src/composer-directions/shot.cjs", "ui/src/composer-directions/motion-contract.test.ts", "ui/src/composer-directions/today.test.tsx", "ui/src/composer-directions/frames/ThreadFrame.tsx", "ui/src/composer-directions/frames/PanelFrame.tsx", "ui/src/composer-directions/directions/today/ThreadBody.tsx", "ui/src/composer-directions/directions/d3-rethink/MotionSpecimen.tsx", "ui/src/composer-directions/directions/d3-rethink/PanelBody.tsx", "ui/src/composer-directions/directions/d3-rethink/tokens.css"]) {
assert.ok(plan.paths.includes(file), `plan lists ${file}`);
assert.ok(existsSync(path.join(root, file)), `${file} exists`);
}
assert.ok(!existsSync(path.join(root, "ui/src/composer-directions/directions/today/tokens.css")), "Today has no tokens.css");
});
it("renders the surfaces row only for more than one surface, with the legend moving into row 1 otherwise", () => {
const multi = readFileSync(path.join(root, "ui/src/composer-directions/frames/LabToolbar.tsx"), "utf8");
assert.ok(multi.includes("Composer surfaces"));
assert.ok(multi.includes('label="Surface"'));
assert.ok(multi.includes('<span className="ml-auto hidden lg:inline">{LEGEND}</span>'));
assert.ok(multi.includes("hint: `The kit's ${registry.surfaces.length} surfaces on one sheet`"));
assert.ok(multi.includes("...registry.surfaces.map((surface) => ({ value: surface.id, label: surface.label }))"));
for (const slug of ["inbox-row", "agent-profile-card"]) {
const single = readFileSync(path.join(root, `ui/src/${slug}-directions/frames/LabToolbar.tsx`), "utf8");
assert.ok(!single.includes("surfaces</"), `${slug}: no surfaces row`);
assert.ok(!single.includes('label="Surface"'), `${slug}: no Surface control`);
assert.ok(!single.includes("VIEW_OPTIONS"), `${slug}: no VIEW_OPTIONS`);
assert.ok(single.includes('<span className="hidden lg:inline">{LEGEND}</span>'), `${slug}: legend in row 1`);
}
for (const slug of Object.keys(SHAPES)) {
const toolbar = readFileSync(path.join(root, `ui/src/${slug}-directions/frames/LabToolbar.tsx`), "utf8");
assert.ok(toolbar.includes("const MAX_KIT_DIGIT = Math.max(...Object.values(KIT_KEYS).map(Number));"), `${slug}: digit range from KIT_KEYS`);
assert.ok(toolbar.includes("const LEGEND = `0–${MAX_KIT_DIGIT} kit · t theme · a animation`;"), `${slug}: legend derived`);
assert.ok(!/0–\d/.test(toolbar), `${slug}: no baked digit range`);
}
const plan = results["inbox-row"].json();
assert.deepEqual(plan.toolbar, { surfacesRow: false, views: ["board", "inbox"] });
});
it("puts the motion asset, rendered for its id, at the end of every exploration tokens.css", async () => {
for (const slug of Object.keys(SHAPES)) {
const registry = readRegistry(readFileSync(path.join(root, `ui/src/${slug}-directions/registry.ts`), "utf8"));
for (const direction of registry.directions.filter((entry) => !entry.baseline)) {
const css = readFileSync(path.join(root, `ui/src/${slug}-directions/directions/${direction.id}/tokens.css`), "utf8");
const block = await motionBlockFor({ id: direction.id, prefix: direction.id.replace(/^d\d+-/, "") });
assert.ok(css.endsWith(block), `${slug}/${direction.id} ends with the motion block`);
assert.equal(css.match(/cubic-bezier\(/g).length, 1);
}
}
});
it("gives every exploration a MotionSpecimen with the hooks and resting angles, rendered in every surface body", () => {
for (const slug of Object.keys(SHAPES)) {
const registry = readRegistry(readFileSync(path.join(root, `ui/src/${slug}-directions/registry.ts`), "utf8"));
for (const direction of registry.directions.filter((entry) => !entry.baseline)) {
const dir = path.join(root, `ui/src/${slug}-directions/directions/${direction.id}`);
const specimen = readFileSync(path.join(dir, "MotionSpecimen.tsx"), "utf8");
for (const hook of ["data-front", "data-rotate", 'data-spread={spread}', "rotate: -14", "rotate: -5", "transformOrigin"]) assert.ok(specimen.includes(hook), `${direction.id}: ${hook}`);
for (const surface of registry.surfaces) {
const body = readFileSync(path.join(dir, `${surface.id.charAt(0).toUpperCase()}${surface.id.slice(1).replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase())}Body.tsx`), "utf8");
assert.ok(body.includes("<MotionSpecimen item={item} />"));
assert.ok(body.includes(`data-lab-stub="${direction.id}-${surface.id}"`));
}
}
for (const surface of registry.surfaces) {
const frame = readFileSync(path.join(root, `ui/src/${slug}-directions/frames/${surface.id.charAt(0).toUpperCase()}${surface.id.slice(1).replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase())}Frame.tsx`), "utf8");
assert.ok(frame.includes(`data-lab-stub="frame-${surface.id}"`));
const today = readFileSync(path.join(root, `ui/src/${slug}-directions/directions/today/${surface.id.charAt(0).toUpperCase()}${surface.id.slice(1).replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase())}Body.tsx`), "utf8");
assert.ok(today.includes(`data-lab-stub="today-${surface.id}"`));
}
}
});
it("writes no comments and no file of 600 lines or more", () => {
const files = [...walk(path.join(root, "ui/src")), ...walk(path.join(root, "ui/storybook")), ...readdirSync(path.join(root, "ui")).map((name) => path.join(root, "ui", name)).filter((file) => /\.(html|mjs)$/.test(file))];
assert.ok(files.length > 100);
for (const file of files) {
const text = readFileSync(file, "utf8");
assert.deepEqual(commentLines(text), [], `${path.relative(root, file)} has comment lines`);
assert.ok(lineCount(text) < MAX_LINES, `${path.relative(root, file)} is under ${MAX_LINES} lines`);
}
});
it("registers the story with the literal paperclip block last and the stable exports", () => {
const story = readFileSync(path.join(root, "ui/storybook/stories/design-proposals/composer-directions.stories.tsx"), "utf8");
assert.ok(story.includes('id: "design-composer-directions"'));
assert.ok(story.includes('title: "Design proposals/Components/Composer directions/Compare"'));
assert.ok(story.includes('status: "proposed"') && story.includes('home: "design-proposals"') && story.includes('implementation: "prototype"') && story.includes('boundary: "fixtures"'));
assert.ok(story.includes("exception: \"Design directory entry for Composer: the shipped baseline and 3 proposed directions compared on static mocks of Task-thread and Right-panel.\""));
assert.ok(/paperclip: \{[\s\S]*?\n \},\n \},\n\} satisfies Meta/.test(story), "paperclip is the last parameter");
for (const name of ["Compare", "Today", "D1", "D2", "D3", "TodayProductionPalette"]) assert.ok(new RegExp(`^export const ${name}: Story`, "m").test(story), name);
assert.ok(story.includes("parameters: { designProposalTheme: false }"));
const two = readFileSync(path.join(root, "ui/storybook/stories/design-proposals/inbox-row-directions.stories.tsx"), "utf8");
assert.ok(!two.includes("export const D3"));
});
it("adds two launch entries per lab and one gitignore line after the anchor", () => {
const launch = JSON.parse(readFileSync(path.join(root, ".claude/launch.json"), "utf8"));
const names = launch.configurations.map((entry) => entry.name);
assert.deepEqual(names, ["paperclip", "composer-directions-dev", "composer-directions", "inbox-row-directions-dev", "inbox-row-directions", "agent-profile-card-directions-dev", "agent-profile-card-directions"]);
const dev = launch.configurations.find((entry) => entry.name === "composer-directions-dev");
assert.deepEqual(dev, { name: "composer-directions-dev", runtimeExecutable: "/bin/sh", runtimeArgs: ["-c", "cd ui && npx vite --config vite.composer-directions.config.mjs --port 5200 --strictPort"], port: 5200 });
const preview = launch.configurations.find((entry) => entry.name === "composer-directions");
assert.ok(preview.runtimeArgs[1].startsWith("cd ui && export COMPOSER_DIRECTIONS_OUT_DIR=node_modules/.composer-directions-dist && npx vite build"));
assert.equal(preview.port, 5210);
const gitignore = readFileSync(path.join(root, ".gitignore"), "utf8").split("\n");
assert.equal(gitignore.indexOf("dist-flow-preview/") + 1, gitignore.indexOf("ui/dist-agent-profile-card-directions/"));
assert.ok(gitignore.includes("ui/dist-composer-directions/"));
});
it("refuses to scaffold over an existing lab and rolls nothing back", () => {
const launchBefore = readFileSync(path.join(root, ".claude/launch.json"), "utf8");
const again = run(["new", ...SHAPES.composer, "--skip-verify"], { root });
assert.equal(again.code, 1);
assert.match(again.out, /already exists/);
assert.equal(readFileSync(path.join(root, ".claude/launch.json"), "utf8"), launchBefore);
});
it("lists the labs it finds", () => {
const listed = run(["list", "--json"], { root });
assert.equal(listed.code, 0, listed.out);
const rows = listed.json();
assert.deepEqual(rows.map((row) => row.slug).sort(), ["agent-profile-card", "composer", "inbox-row"]);
assert.deepEqual(rows.find((row) => row.slug === "composer").ports, { dev: 5200, preview: 5210 });
});
it("passes check without verification and adds a direction through the registry", () => {
const checked = run(["check", "--slug", "inbox-row", "--skip-verify"], { root });
assert.equal(checked.code, 0, checked.out);
assert.match(checked.out, /check: ok/);
const added = run(["add-direction", "--slug", "inbox-row", "--name", "Bold", "--skip-verify"], { root });
assert.equal(added.code, 0, added.out);
const registry = readRegistry(readFileSync(path.join(root, "ui/src/inbox-row-directions/registry.ts"), "utf8"));
assert.deepEqual(registry.directions.map((direction) => direction.id), ["d1-refine", "d2-rethink", "d3-bold", "today"]);
assert.ok(existsSync(path.join(root, "ui/src/inbox-row-directions/directions/d3-bold/tokens.css")));
assert.ok(existsSync(path.join(root, "ui/src/inbox-row-directions/directions/d3-bold/InboxBody.tsx")));
const story = readFileSync(path.join(root, "ui/storybook/stories/design-proposals/inbox-row-directions.stories.tsx"), "utf8");
assert.ok(story.includes('export const D3: Story = { name: "3.Bold", args: { direction: "d3-bold" } };\nexport const TodayProductionPalette'));
const fourth = run(["add-direction", "--slug", "inbox-row", "--name", "Fourth", "--skip-verify"], { root });
assert.equal(fourth.code, 1);
assert.match(fourth.out, /--force-count/);
const forced = run(["add-direction", "--slug", "inbox-row", "--name", "Fourth", "--skip-verify", "--force-count"], { root });
assert.equal(forced.code, 0, forced.out);
assert.ok(readFileSync(path.join(root, "ui/src/inbox-row-directions/registry.ts"), "utf8").includes(" allowMore: true,\n allow: [],"));
const strict = run(["check", "--slug", "inbox-row", "--strict", "--skip-verify"], { root });
assert.equal(strict.code, 1);
assert.match(strict.out, /scaffold stub/);
});
it("flags a data-lab-anim rule outside a direction's tokens.css", () => {
const labCss = path.join(root, "ui/src/inbox-row-directions/lab.css");
const original = readFileSync(labCss, "utf8");
try {
writeFileSync(labCss, `${original}html[data-lab-anim="1"] .x { transform: none; }\n`);
const checked = run(["check", "--slug", "inbox-row", "--skip-verify", "--json"], { root });
assert.equal(checked.code, 1);
assert.deepEqual(checked.json().errors, ["ui/src/inbox-row-directions/lab.css: data-lab-anim rules belong in directions/<id>/tokens.css"]);
} finally {
writeFileSync(labCss, original);
}
});
it("in mode B derives the Compare status from the registry stages and reports notRun as JSON", () => {
const catalog = path.join(root, "scripts/storybook-catalog.mjs");
const registryFile = path.join(root, "ui/src/agent-profile-card-directions/registry.ts");
const storyFile = path.join(root, "ui/storybook/stories/design-proposals/agent-profile-card-directions.stories.tsx");
const originals = Object.fromEntries([catalog, registryFile, storyFile].map((file) => [file, readFileSync(file, "utf8")]));
const setStages = (stages) => {
let text = originals[registryFile];
for (const [id, stage] of Object.entries(stages)) text = text.replace(new RegExp(`(\\{ id: "${id}", [^\\n]*stage: ")proposed(" \\},)`), `$1${stage}$2`);
writeFileSync(registryFile, text);
};
const setStory = (status, replacement) => {
let text = originals[storyFile].replace('status: "proposed",', `status: "${status}",`);
if (replacement) text = text.replace(" notes: ", ` replacement: ${JSON.stringify(replacement)},\n notes: `);
writeFileSync(storyFile, text);
};
const check = (slug = "agent-profile-card") => {
const result = run(["check", "--slug", slug, "--skip-verify", "--json"], { root });
return { code: result.code, ...result.json() };
};
try {
writeFileSync(catalog, "nothing here\n");
assert.equal(check().code, 0, "all proposed: proposed");
assert.deepEqual(check().notRun, NOT_RUN);
setStages({ "d1-refine": "current" });
assert.equal(check().code, 0, "one current, others proposed: still proposed");
setStages({ "d1-refine": "current", "d2-rework": "adopted", "d3-rethink": "historical" });
assert.equal(check().code, 0, "an adopted exploration keeps the story proposed");
setStages({ "d1-refine": "current", "d2-rework": "current", "d3-rethink": "current" });
let result = check();
assert.equal(result.code, 1);
assert.deepEqual(result.errors, [`ui/storybook/stories/design-proposals/agent-profile-card-directions.stories.tsx: status should be "current" (mode B, lab stage current)`]);
setStory("current");
assert.equal(check().code, 0, "every exploration current: current");
setStages({ "d1-refine": "current", "d2-rework": "historical", "d3-rethink": "historical" });
assert.equal(check().code, 0, "the shipped one current, the rest retired: current");
setStages({ "d1-refine": "historical", "d2-rework": "historical", "d3-rethink": "historical" });
result = check();
assert.equal(result.code, 1);
assert.match(result.errors[0], /status should be "historical" \(mode B, lab stage historical\)/);
setStory("historical");
result = check();
assert.equal(result.code, 1);
assert.match(result.errors[0], /status "historical" needs replacement/);
setStory("historical", "ui/storybook/stories/design-proposals/nowhere.stories.tsx");
result = check();
assert.equal(result.code, 1);
assert.match(result.errors[0], /replacement ui\/storybook\/stories\/design-proposals\/nowhere\.stories\.tsx does not exist/);
setStory("historical", "ui/storybook/stories/design-proposals/composer-directions.stories.tsx");
assert.equal(check().code, 0, "every exploration historical with a replacement: historical");
assert.equal(check("composer").code, 0, "a replacement naming another lab's story is not that lab's slug leaking");
writeFileSync(catalog, originals[catalog]);
result = check();
assert.equal(result.code, 1);
assert.match(result.errors[0], /status should be "proposed" \(mode A, lab stage historical\)/);
} finally {
for (const [file, text] of Object.entries(originals)) writeFileSync(file, text);
}
});
});
describe("review fixes", () => {
it("quotes hyphenated surface ids so kit.ts and SurfaceView.tsx parse", () => {
const root = makeRoot();
try {
const result = run(["new", "--slug", "hyphen", "--surfaces", "right-panel:Right-panel:434@tasks,thread:Task-thread:960@tasks", "--directions", "2", "--skip-verify"], { root });
assert.equal(result.code, 0, result.out);
const kit = readFileSync(path.join(root, "ui/src/hyphen-directions/kit.ts"), "utf8");
const view = readFileSync(path.join(root, "ui/src/hyphen-directions/frames/SurfaceView.tsx"), "utf8");
assert.match(kit, / "right-panel": \{ items: LabRecord\[\] \};/);
assert.match(view, / "right-panel": RightPanelFrame,/);
const esbuild = createRequire(path.join(REPO_ROOT, "ui/package.json"))("esbuild");
for (const [file, source] of [["kit.ts", kit], ["SurfaceView.tsx", view]]) {
assert.doesNotThrow(() => esbuild.transformSync(source, { loader: file.endsWith("x") ? "tsx" : "ts", jsx: "automatic" }), file);
}
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it("rolls back written files and launch entries when a shared-file edit throws", () => {
const root = makeRoot();
try {
const launchBefore = readFileSync(path.join(root, ".claude/launch.json"), "utf8");
rmSync(path.join(root, ".gitignore"));
mkdirSync(path.join(root, ".gitignore"));
const result = run(["new", ...SHAPES.composer.slice(0), "--skip-verify"], { root });
assert.equal(result.code, 1, result.out);
assert.match(result.out, /Rolled back every written path/);
assert.equal(existsSync(path.join(root, "ui/src/composer-directions")), false);
assert.equal(existsSync(path.join(root, "ui/composer-directions.html")), false);
assert.equal(readFileSync(path.join(root, ".claude/launch.json"), "utf8"), launchBefore);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it("refuses add-direction once the slot props no longer match the placeholder", () => {
const root = makeRoot();
try {
assert.equal(run(["new", ...SHAPES.composer, "--skip-verify"], { root }).code, 0);
const kitFile = path.join(root, "ui/src/composer-directions/kit.ts");
writeFileSync(kitFile, readFileSync(kitFile, "utf8").replace('"thread": { items: LabRecord[] };', '"thread": { workProducts: string[] };'));
const result = run(["add-direction", "--slug", "composer", "--name", "Bold", "--force-count", "--skip-verify"], { root });
assert.equal(result.code, 1, result.out);
assert.match(result.out, /no longer match the scaffold's placeholder \(thread\)/);
assert.equal(existsSync(path.join(root, "ui/src/composer-directions/directions/d4-bold")), false);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
describe("dry runs against the real repo", () => {
it("validates capabilities through the feature map and refuses unknown ids", () => {
const ok = run(["new", "--slug", "zz-dry", "--title", "Dry", "--surfaces", "a:A:960@tasks", "--directions", "2", "--capabilities", "tasks/create", "--dry-run", "--json"], { root: REPO_ROOT });
assert.equal(ok.code, 0, ok.out);
const plan = ok.json();
assert.deepEqual(plan.lab.capabilities, ["tasks/create"]);
assert.equal(plan.lab.exception, null);
assert.ok(!existsSync(path.join(REPO_ROOT, "ui/src/zz-dry-directions")));
const bad = run(["new", "--slug", "zz-dry", "--title", "Dry", "--surfaces", "a:A:960@tasks", "--directions", "2", "--capabilities", "tasks/nope", "--dry-run"], { root: REPO_ROOT });
assert.equal(bad.code, 1);
assert.match(bad.out, /unknown capabilities/);
});
it("renders every template in memory for a repo-rooted context", async () => {
const ctx = buildContext({ slug: "zz-mem", surfaces: "a:A:960@tasks,b:B:434@artifacts", directions: "3" }, { root: REPO_ROOT, ports: { dev: 5200, preview: 5210 } });
const files = await renderLab(ctx);
assert.equal(files.size, 47);
assert.ok(files.get("ui/src/zz-mem-directions/registry.ts").includes(directionLine(ctx.directions[0])));
const text = files.get("ui/src/zz-mem-directions/registry.ts");
const next = insertDirection(text, deriveDirection(4, "Four"), { allowMore: true });
assert.deepEqual(readRegistry(next).directions.map((direction) => direction.id), ["d1-refine", "d2-rework", "d3-rethink", "d4-four", "today"]);
assert.throws(() => insertDirection(text.replace("baseline: true, ", ""), deriveDirection(4, "Four")), /baseline/);
});
});
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/directions-lab