mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
Merge master and regenerate Coordinator migration and contracts
This commit is contained in:
commit
d62c929069
1288 files changed
+159603
-74018
No files matched your search
@@ -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.
|
||||
+7
@@ -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.
|
||||
+7
@@ -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.
|
||||
+7
@@ -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/);
|
||||
});
|
||||
});
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/directions-lab
|
||||
@@ -89,6 +89,7 @@ packages/grok-acp/bin
|
||||
|
||||
!packages/paperclip-runner/test-fixtures/pi-acp/profile-v28-identity.json
|
||||
!packages/paperclip-runner/test-fixtures/pi-acp/profile-v29-identity.json
|
||||
!packages/paperclip-runner/test-fixtures/pi-acp/profile-v30-identity.json
|
||||
|
||||
!packages/paperclip-runner/test/fixtures/copilot-profile-v23-identity.json
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import test from 'node:test';
|
||||
import { verifyControllerRuntime } from '../../packages/paperclip-runner/scripts/controlled-evals/verify-runtime.mjs';
|
||||
|
||||
test('the uploaded controller preserves every inventoried runtime file', {
|
||||
skip: !process.env.PAPERCLIP_CONTROLLER_TRANSPORT_DIRECTORY,
|
||||
}, async () => {
|
||||
await verifyControllerRuntime({
|
||||
directory: process.env.PAPERCLIP_CONTROLLER_TRANSPORT_DIRECTORY,
|
||||
sourceRevision: process.env.GITHUB_SHA,
|
||||
receiptSha256: process.env.PAPERCLIP_CONTROLLER_RECEIPT_SHA256,
|
||||
});
|
||||
});
|
||||
@@ -164,6 +164,16 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Apply reviewed qualification lock corrections
|
||||
if: inputs.release_run_id == '' && (inputs.build_eval_image || inputs.publish_eval_image)
|
||||
env:
|
||||
TARGET_SHA: ${{ needs.authorize_manual.outputs.target_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$TARGET_SHA" = "$GITHUB_SHA" ]; then
|
||||
node tests/runner-e2e/prepare-report-lock.mjs
|
||||
fi
|
||||
|
||||
- name: Resolve source dependencies without lifecycle scripts
|
||||
if: inputs.release_run_id == ''
|
||||
run: |
|
||||
|
||||
@@ -109,6 +109,13 @@ jobs:
|
||||
- name: Test feature module boundary check
|
||||
run: node "$CI_NODE_TEST" ./scripts/check-module-boundaries.test.mjs
|
||||
|
||||
# Report-only for now (always exits 0); --enforce turns findings into failures.
|
||||
- name: Report raw query-error rendering
|
||||
run: node scripts/check-query-error-rendering.mjs --summary
|
||||
|
||||
- name: Test query-error rendering check
|
||||
run: node --test ./scripts/check-query-error-rendering.test.mjs
|
||||
|
||||
- name: Test PR quality-gate scripts
|
||||
run: node "$CI_NODE_TEST" '.github/scripts/tests/*.test.mjs'
|
||||
|
||||
|
||||
@@ -187,6 +187,18 @@ jobs:
|
||||
echo '::notice title=Paid runner routing::Using a standard GitHub-hosted runner'
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Preflight reviewed report dependencies before paid admission
|
||||
run: node tests/runner-e2e/prepare-report-lock.mjs
|
||||
|
||||
target_lock:
|
||||
name: Resolve target pnpm lockfile
|
||||
needs: authorize
|
||||
@@ -1184,6 +1196,7 @@ jobs:
|
||||
OPENROUTER_API_KEY: ${{ matrix.credentialName == 'OPENROUTER_API_KEY' && secrets.OPENROUTER_API_KEY || '' }}
|
||||
XAI_API_KEY: ${{ matrix.credentialName == 'XAI_API_KEY' && secrets.XAI_API_KEY || '' }}
|
||||
GROK_AUTH_JSON: ${{ matrix.credentialName == 'GROK_AUTH_JSON' && secrets.GROK_AUTH_JSON || '' }}
|
||||
COPILOT_GITHUB_TOKEN: ${{ matrix.credentialName == 'COPILOT_GITHUB_TOKEN' && secrets.COPILOT_GITHUB_TOKEN || '' }}
|
||||
DAYTONA_API_KEY: ${{ matrix.environmentId == 'daytona' && secrets.DAYTONA_API_KEY || '' }}
|
||||
PAPERCLIP_E2E_DAYTONA_IMAGE: ${{ needs.daytona_image.outputs.image }}
|
||||
PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH: ${{ github.workspace }}/packages/paperclip-runner/provider-pack
|
||||
@@ -1272,6 +1285,9 @@ jobs:
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Apply reviewed reporting lock corrections
|
||||
run: node tests/runner-e2e/prepare-report-lock.mjs
|
||||
|
||||
- name: Install pinned reporting dependencies
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts --store-dir "${{ runner.temp }}/eval-report-store"
|
||||
|
||||
@@ -1499,6 +1515,9 @@ jobs:
|
||||
|
||||
# Use a job-local store and disable lifecycle scripts throughout install.
|
||||
# OIDC is available to every step, even before the AWS credential exchange.
|
||||
- name: Apply reviewed publication lock corrections
|
||||
run: node tests/runner-e2e/prepare-report-lock.mjs
|
||||
|
||||
- name: Install pinned publication dependencies
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts --store-dir "${{ runner.temp }}/eval-publisher-store"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Runner Direct Live Protocol Evals
|
||||
name: Runner Live Protocol Evals
|
||||
|
||||
on:
|
||||
schedule:
|
||||
@@ -30,10 +30,17 @@ on:
|
||||
- subscription
|
||||
default: api_key
|
||||
required: false
|
||||
accounting_mode:
|
||||
description: "Controlled Codex/Claude costs and traces, or explicit legacy provider qualification"
|
||||
type: choice
|
||||
options:
|
||||
- controlled
|
||||
- legacy
|
||||
default: controlled
|
||||
max_infrastructure_retries:
|
||||
description: "Automatic retries only for explicitly retryable infrastructure failures (0-3)"
|
||||
type: number
|
||||
default: 1
|
||||
default: 0
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -51,6 +58,9 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
accounting_mode: ${{ steps.mode.outputs.mode }}
|
||||
retry_count: ${{ steps.mode.outputs.retries }}
|
||||
controller_lock_sha256: ${{ steps.mode.outputs.controller_lock_sha256 }}
|
||||
test_runner: ${{ steps.runner.outputs.runner }}
|
||||
max_parallel_default: ${{ steps.runner.outputs.max_parallel_default }}
|
||||
max_parallel_limit: ${{ steps.runner.outputs.max_parallel_limit }}
|
||||
@@ -139,12 +149,35 @@ jobs:
|
||||
test "$resolved" = "$EVALS_SHA"
|
||||
echo "sha=$resolved" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate retry envelope
|
||||
- name: Validate accounting mode and retry envelope
|
||||
id: mode
|
||||
env:
|
||||
RETRIES: ${{ github.event_name == 'schedule' && 1 || inputs.max_infrastructure_retries }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
ACCOUNTING_MODE: ${{ inputs.accounting_mode || 'controlled' }}
|
||||
RETRIES: ${{ inputs.max_infrastructure_retries || 0 }}
|
||||
CONTROLLER_POLICY_JSON: ${{ vars.RUNNER_PROTOCOL_EVAL_CONTROLLER_POLICY_JSON }}
|
||||
TARGET_SHA: ${{ steps.target.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$EVENT_NAME" = schedule ]; then ACCOUNTING_MODE=controlled; RETRIES=0; fi
|
||||
[[ "$RETRIES" =~ ^[0-3]$ ]]
|
||||
case "$ACCOUNTING_MODE" in
|
||||
controlled)
|
||||
test "$TARGET_SHA" = "$GITHUB_SHA"
|
||||
test "$GITHUB_RUN_ATTEMPT" = 1
|
||||
test "$RETRIES" = 0
|
||||
if ! jq -e '.schema == "paperclip.runner-eval.hosted-policy/v1"' <<< "${CONTROLLER_POLICY_JSON:-}" >/dev/null; then
|
||||
echo 'Controlled runs require a reviewed RUNNER_PROTOCOL_EVAL_CONTROLLER_POLICY_JSON; no provider work started.' >&2
|
||||
exit 1
|
||||
fi
|
||||
lock_sha="$(jq -er '.controllerLockSha256 | select(type == "string" and test("^[a-f0-9]{64}$"))' <<< "$CONTROLLER_POLICY_JSON")"
|
||||
echo "controller_lock_sha256=$lock_sha" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
legacy) test "$EVENT_NAME" = workflow_dispatch ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
echo "mode=$ACCOUNTING_MODE" >> "$GITHUB_OUTPUT"
|
||||
echo "retries=$RETRIES" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Select paid test runner
|
||||
id: runner
|
||||
@@ -265,6 +298,8 @@ jobs:
|
||||
REQUESTED_MAX_PARALLEL: ${{ inputs.max_parallel }}
|
||||
ROSTERS: ${{ inputs.rosters || 'all' }}
|
||||
GROK_AUTHENTICATION: ${{ inputs.grok_authentication || 'api_key' }}
|
||||
ACCOUNTING_MODE: ${{ needs.authorize.outputs.accounting_mode }}
|
||||
CONTROLLER_POLICY_JSON: ${{ vars.RUNNER_PROTOCOL_EVAL_CONTROLLER_POLICY_JSON }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! [[ "$MAX_PARALLEL" =~ ^[1-9][0-9]*$ ]] || [ "$MAX_PARALLEL" -lt 2 ] || [ "$MAX_PARALLEL" -gt "$MAX_PARALLEL_LIMIT" ]; then
|
||||
@@ -285,6 +320,13 @@ jobs:
|
||||
--campaign-id "gha-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \
|
||||
--max-parallel "$MAX_PARALLEL" \
|
||||
--output runner-protocol-eval-catalog.json
|
||||
if [ "$ACCOUNTING_MODE" = controlled ]; then
|
||||
jq -e 'all(.cells[]; (.provider == "codex" and (.acpxAgent // "") == "" and .driver == "codex_app_server") or (.provider == "acpx" and .acpxAgent == "claude" and .driver == "acpx_runtime"))' runner-protocol-eval-catalog.json >/dev/null || {
|
||||
echo 'Controlled selection supports native Codex and ACPX Claude only; select their rosters explicitly.' >&2; exit 1;
|
||||
}
|
||||
maximum="$(jq -er '.maxCells | select(type == "number" and floor == . and . > 0 and . <= 512)' <<< "$CONTROLLER_POLICY_JSON")"
|
||||
jq -e --argjson maximum "$maximum" '.cells | length <= $maximum' runner-protocol-eval-catalog.json >/dev/null
|
||||
fi
|
||||
|
||||
- name: Require the chat-report renderer before paid execution
|
||||
run: |
|
||||
@@ -408,16 +450,234 @@ jobs:
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
|
||||
build_controller:
|
||||
name: Build trusted controller without private archive access
|
||||
needs: [authorize, target_lock]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
artifact_id: ${{ steps.upload.outputs.artifact-id }}
|
||||
runtime_receipt_sha256: ${{ steps.build.outputs.runtime_receipt_sha256 }}
|
||||
steps:
|
||||
- name: Select accounting mode
|
||||
run: test '${{ needs.authorize.outputs.accounting_mode }}' = controlled || test '${{ needs.authorize.outputs.accounting_mode }}' = legacy
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: needs.authorize.outputs.accounting_mode == 'controlled'
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
if: needs.authorize.outputs.accounting_mode == 'controlled'
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
if: needs.authorize.outputs.accounting_mode == 'controlled'
|
||||
env:
|
||||
NPM_CONFIG_AUDIT: "false"
|
||||
NPM_CONFIG_FUND: "false"
|
||||
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Download resolved lockfile for review binding
|
||||
if: needs.authorize.outputs.accounting_mode == 'controlled'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
artifact-ids: ${{ needs.target_lock.outputs.artifact_id }}
|
||||
path: ${{ runner.temp }}/runner-controller-lock
|
||||
|
||||
- name: Build portable controller in an unprivileged job
|
||||
if: needs.authorize.outputs.accounting_mode == 'controlled'
|
||||
id: build
|
||||
env:
|
||||
EXPECTED_LOCK_SHA256: ${{ needs.authorize.outputs.controller_lock_sha256 }}
|
||||
RESOLVED_LOCK_SHA256: ${{ needs.target_lock.outputs.lock_sha256 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
|
||||
test "$RESOLVED_LOCK_SHA256" = "$EXPECTED_LOCK_SHA256"
|
||||
lock="$RUNNER_TEMP/runner-controller-lock/pnpm-lock.yaml"
|
||||
test "$(sha256sum "$lock" | cut -d ' ' -f 1)" = "$EXPECTED_LOCK_SHA256"
|
||||
cp "$lock" pnpm-lock.yaml
|
||||
pnpm install --frozen-lockfile --ignore-scripts --store-dir "$RUNNER_TEMP/runner-controller-pnpm-store"
|
||||
node packages/paperclip-runner/scripts/controlled-evals/build-runtime.mjs \
|
||||
"$RUNNER_TEMP/runner-protocol-runtime" "$GITHUB_SHA" > "$RUNNER_TEMP/controller-runtime-receipt.json"
|
||||
echo "runtime_receipt_sha256=$(jq -r .receiptSha256 "$RUNNER_TEMP/controller-runtime-receipt.json")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Retain the trusted controller code for every worker
|
||||
if: needs.authorize.outputs.accounting_mode == 'controlled'
|
||||
id: upload
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: runner-protocol-runtime-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
include-hidden-files: true
|
||||
path: ${{ runner.temp }}/runner-protocol-runtime/
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Download controller artifact for a credential-free transport test
|
||||
if: needs.authorize.outputs.accounting_mode == 'controlled'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
artifact-ids: ${{ steps.upload.outputs.artifact-id }}
|
||||
path: ${{ runner.temp }}/runner-controller-roundtrip
|
||||
|
||||
- name: Verify the real uploaded controller inventory before private preparation
|
||||
if: needs.authorize.outputs.accounting_mode == 'controlled'
|
||||
env:
|
||||
PAPERCLIP_CONTROLLER_TRANSPORT_DIRECTORY: ${{ runner.temp }}/runner-controller-roundtrip
|
||||
PAPERCLIP_CONTROLLER_RECEIPT_SHA256: ${{ steps.build.outputs.runtime_receipt_sha256 }}
|
||||
run: node --test .github/tests/runner-controller-artifact-transport.test.mjs
|
||||
|
||||
prepare_controller:
|
||||
name: Prepare one shared cost and trace manifest
|
||||
needs: [authorize, catalog, build_runner, build_controller]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
environment: ${{ needs.authorize.outputs.accounting_mode == 'controlled' && 'runner-e2e-private-history' || '' }}
|
||||
outputs:
|
||||
artifact_id: ${{ needs.build_controller.outputs.artifact_id }}
|
||||
manifest_sha256: ${{ steps.prepare.outputs.manifest_sha256 }}
|
||||
runtime_receipt_sha256: ${{ needs.build_controller.outputs.runtime_receipt_sha256 }}
|
||||
steps:
|
||||
- name: Select accounting mode
|
||||
run: test '${{ needs.authorize.outputs.accounting_mode }}' = controlled || test '${{ needs.authorize.outputs.accounting_mode }}' = legacy
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: needs.authorize.outputs.accounting_mode == 'controlled'
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Generate private eval-repository token
|
||||
if: needs.authorize.outputs.accounting_mode == 'controlled'
|
||||
id: evals_token
|
||||
env:
|
||||
COMMITPERCLIP_KEY: ${{ secrets.COMMITPERCLIP_KEY }}
|
||||
GH_REPO: paperclipai/paperclip-evals
|
||||
run: |
|
||||
set -euo pipefail
|
||||
token="$(node .github/scripts/get-bot-token.mjs)"
|
||||
echo "::add-mask::$token"
|
||||
echo "value=$token" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: needs.authorize.outputs.accounting_mode == 'controlled'
|
||||
with:
|
||||
repository: paperclipai/paperclip-evals
|
||||
ref: ${{ needs.authorize.outputs.evals_sha }}
|
||||
path: .paperclip-evals
|
||||
token: ${{ steps.evals_token.outputs.value }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
if: needs.authorize.outputs.accounting_mode == 'controlled'
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Download the complete campaign catalog
|
||||
if: needs.authorize.outputs.accounting_mode == 'controlled'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: runner-protocol-eval-catalog-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: runner-protocol-catalog
|
||||
|
||||
- name: Download the immutable provider runtime
|
||||
if: needs.authorize.outputs.accounting_mode == 'controlled'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: runner-protocol-build-${{ needs.authorize.outputs.target_sha }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: runner-protocol-build
|
||||
|
||||
- name: Download trusted controller built without archive permissions
|
||||
if: needs.authorize.outputs.accounting_mode == 'controlled'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
artifact-ids: ${{ needs.build_controller.outputs.artifact_id }}
|
||||
path: ${{ runner.temp }}/runner-protocol-runtime
|
||||
|
||||
- name: Verify copied controller before private preparation
|
||||
if: needs.authorize.outputs.accounting_mode == 'controlled'
|
||||
env:
|
||||
EXPECTED_LOCK_SHA256: ${{ needs.authorize.outputs.controller_lock_sha256 }}
|
||||
RUNTIME_RECEIPT_SHA256: ${{ needs.build_controller.outputs.runtime_receipt_sha256 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node packages/paperclip-runner/scripts/controlled-evals/verify-runtime.mjs "$RUNNER_TEMP/runner-protocol-runtime" "$GITHUB_SHA" "$RUNTIME_RECEIPT_SHA256"
|
||||
test "$(jq -r '.inputs["pnpm-lock.yaml"]' "$RUNNER_TEMP/runner-protocol-runtime/runtime.json")" = "$EXPECTED_LOCK_SHA256"
|
||||
|
||||
- name: Compile requests and bind the reviewed operating policy
|
||||
if: needs.authorize.outputs.accounting_mode == 'controlled'
|
||||
id: prepare
|
||||
env:
|
||||
PAPERCLIP_PROTOCOL_EVAL_SOURCE_SHA: ${{ needs.authorize.outputs.target_sha }}
|
||||
PAPERCLIP_PROTOCOL_EVALS_SHA: ${{ needs.authorize.outputs.evals_sha }}
|
||||
CONTROLLER_POLICY_JSON: ${{ vars.RUNNER_PROTOCOL_EVAL_CONTROLLER_POLICY_JSON }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
|
||||
test "$(git -C .paperclip-evals rev-parse HEAD)" = "$PAPERCLIP_PROTOCOL_EVALS_SHA"
|
||||
python3 .paperclip-evals/evals/paperclip-runner/tools/eval_program.py report --help | grep -q -- --controller-manifest
|
||||
(cd runner-protocol-build && sha256sum --check runner-protocol-build.tar.gz.sha256)
|
||||
mkdir runner-protocol-build/extracted
|
||||
tar -xzf runner-protocol-build/runner-protocol-build.tar.gz -C runner-protocol-build/extracted
|
||||
policy="$RUNNER_TEMP/runner-controller-policy.json"
|
||||
POLICY_PATH="$policy" python3 - <<'PYTHON'
|
||||
import os
|
||||
with os.fdopen(os.open(os.environ["POLICY_PATH"], os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w") as target:
|
||||
target.write(os.environ["CONTROLLER_POLICY_JSON"])
|
||||
PYTHON
|
||||
worker="/tmp/paperclip-runner-controlled-${GITHUB_RUN_ID}"
|
||||
python3 .paperclip-evals/evals/paperclip-runner/tools/prepare_controlled_campaign.py \
|
||||
--catalog runner-protocol-catalog/runner-protocol-eval-catalog.json \
|
||||
--runnerd runner-protocol-build/extracted/paperclip-runnerd \
|
||||
--worker-runnerd "$worker/runner/paperclip-runnerd" \
|
||||
--github-run-id "$GITHUB_RUN_ID" --definitions-revision "$PAPERCLIP_PROTOCOL_EVALS_SHA" \
|
||||
--output "$RUNNER_TEMP/prepared-requests.json"
|
||||
control="$RUNNER_TEMP/runner-protocol-control"
|
||||
node "$RUNNER_TEMP/runner-protocol-runtime/controlled-evals/hosted.mjs" prepare \
|
||||
runner-protocol-catalog/runner-protocol-eval-catalog.json "$RUNNER_TEMP/prepared-requests.json" \
|
||||
"$policy" runner-protocol-build/extracted "$control" > "$RUNNER_TEMP/control-preparation.json"
|
||||
cp "$policy" "$control/hosted-policy.json"
|
||||
echo "manifest_sha256=$(jq -r .manifestSha256 "$RUNNER_TEMP/control-preparation.json")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Obtain private archive credentials for transfer steps only
|
||||
if: needs.authorize.outputs.accounting_mode == 'controlled'
|
||||
id: private_history_credentials
|
||||
uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6
|
||||
with:
|
||||
role-to-assume: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_AWS_ROLE_ARN }}
|
||||
aws-region: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_AWS_REGION }}
|
||||
output-env-credentials: false
|
||||
output-credentials: true
|
||||
disable-retry: true
|
||||
action-timeout-s: 60
|
||||
|
||||
- name: Retain the identical manifest and policy in private storage
|
||||
if: needs.authorize.outputs.accounting_mode == 'controlled'
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ steps.private_history_credentials.outputs.aws-access-key-id }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ steps.private_history_credentials.outputs.aws-secret-access-key }}
|
||||
AWS_SESSION_TOKEN: ${{ steps.private_history_credentials.outputs.aws-session-token }}
|
||||
AWS_REGION: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_AWS_REGION }}
|
||||
RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET }}
|
||||
run: node "$RUNNER_TEMP/runner-protocol-runtime/private-history.mjs" upload protocol-control "$GITHUB_RUN_ID" "$RUNNER_TEMP/runner-protocol-control"
|
||||
|
||||
eval_shard_0:
|
||||
name: Direct eval ${{ matrix.rosterId }} / ${{ matrix.caseId }}
|
||||
needs: [authorize, catalog, build_runner]
|
||||
needs: [authorize, catalog, build_runner, prepare_controller]
|
||||
runs-on: ${{ needs.authorize.outputs.test_runner }}
|
||||
timeout-minutes: 18
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
environment:
|
||||
name: runner-e2e-paid
|
||||
name: ${{ needs.authorize.outputs.accounting_mode == 'controlled' && 'runner-e2e-private-paid' || 'runner-e2e-paid' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: ${{ fromJSON(needs.catalog.outputs.max_parallel_per_shard) }}
|
||||
@@ -435,6 +695,10 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$REF" = "refs/heads/$DEFAULT_BRANCH"
|
||||
if [ '${{ needs.authorize.outputs.accounting_mode }}' = controlled ]; then
|
||||
test '${{ needs.authorize.outputs.target_sha }}' = "$GITHUB_SHA"
|
||||
test "$GITHUB_RUN_ATTEMPT" = 1
|
||||
fi
|
||||
triggering_actor_id="$(gh api "users/$TRIGGERING_ACTOR" --jq .id)"
|
||||
test "$triggering_actor_id" = "$ACTOR_ID" || test "$TRIGGERING_ACTOR" != "$ACTOR"
|
||||
for candidate in "$triggering_actor_id" "$ACTOR_ID"; do
|
||||
@@ -484,10 +748,30 @@ jobs:
|
||||
tar --extract --gzip --file runner-protocol-build.tar.gz --directory extracted
|
||||
test -x extracted/paperclip-runnerd
|
||||
|
||||
- name: Download the trusted portable controller
|
||||
if: needs.authorize.outputs.accounting_mode == 'controlled'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
artifact-ids: ${{ needs.prepare_controller.outputs.artifact_id }}
|
||||
path: /tmp/paperclip-runner-controlled-${{ github.run_id }}/runtime
|
||||
|
||||
- name: Verify controller code before archive access
|
||||
if: needs.authorize.outputs.accounting_mode == 'controlled'
|
||||
id: verify_controller
|
||||
env:
|
||||
RUNTIME_RECEIPT_SHA256: ${{ needs.prepare_controller.outputs.runtime_receipt_sha256 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
root="/tmp/paperclip-runner-controlled-${GITHUB_RUN_ID}"
|
||||
node packages/paperclip-runner/scripts/controlled-evals/verify-runtime.mjs "$root/runtime" "$GITHUB_SHA" "$RUNTIME_RECEIPT_SHA256"
|
||||
cp -a runner-protocol-build/extracted "$root/runner"
|
||||
|
||||
# Keep the host policy aligned with runner-full-stack-e2e.yml. This is
|
||||
# deliberately before any provider credential or web-identity step.
|
||||
- name: Provision Codex sandbox on the disposable trusted runner
|
||||
if: matrix.provider == 'codex' || matrix.rosterId == 'protocol-live-acpx-codex-control'
|
||||
env:
|
||||
CONTROLLED_RUNNER_ROOT: ${{ needs.authorize.outputs.accounting_mode == 'controlled' && format('/tmp/paperclip-runner-controlled-{0}/runner', github.run_id) || '' }}
|
||||
run: |
|
||||
node --input-type=module <<'NODE'
|
||||
import { execFileSync } from "node:child_process";
|
||||
@@ -499,7 +783,7 @@ jobs:
|
||||
let restricted = "0";
|
||||
try { restricted = readFileSync("/proc/sys/kernel/apparmor_restrict_unprivileged_userns", "utf8").trim(); } catch {}
|
||||
if (restricted !== "1") process.exit(0);
|
||||
const root = realpathSync(path.join(process.env.GITHUB_WORKSPACE, "runner-protocol-build/extracted/portable"));
|
||||
const root = realpathSync(process.env.CONTROLLED_RUNNER_ROOT ? path.join(process.env.CONTROLLED_RUNNER_ROOT, "portable") : path.join(process.env.GITHUB_WORKSPACE, "runner-protocol-build/extracted/portable"));
|
||||
const runnerRequire = createRequire(path.join(root, "package.json"));
|
||||
const acpRequire = createRequire(runnerRequire.resolve("@agentclientprotocol/codex-acp/package.json"));
|
||||
const codexRequire = createRequire(acpRequire.resolve("@openai/codex/package.json"));
|
||||
@@ -518,6 +802,44 @@ jobs:
|
||||
execFileSync("sudo", ["-n", "apparmor_parser", "-r", profilePath], {timeout:15000, stdio:"pipe"});
|
||||
NODE
|
||||
|
||||
- name: Obtain private archive credentials for transfer steps only
|
||||
if: needs.authorize.outputs.accounting_mode == 'controlled'
|
||||
id: private_history_credentials
|
||||
uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6
|
||||
with:
|
||||
role-to-assume: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_AWS_ROLE_ARN }}
|
||||
aws-region: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_AWS_REGION }}
|
||||
output-env-credentials: false
|
||||
output-credentials: true
|
||||
disable-retry: true
|
||||
action-timeout-s: 60
|
||||
|
||||
- name: Download the identical private campaign manifest
|
||||
if: needs.authorize.outputs.accounting_mode == 'controlled'
|
||||
env:
|
||||
CELL_ID: ${{ matrix.cellId }}
|
||||
AWS_ACCESS_KEY_ID: ${{ steps.private_history_credentials.outputs.aws-access-key-id }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ steps.private_history_credentials.outputs.aws-secret-access-key }}
|
||||
AWS_SESSION_TOKEN: ${{ steps.private_history_credentials.outputs.aws-session-token }}
|
||||
AWS_REGION: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_AWS_REGION }}
|
||||
RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET }}
|
||||
run: node "/tmp/paperclip-runner-controlled-${GITHUB_RUN_ID}/runtime/private-history.mjs" download protocol-control "$GITHUB_RUN_ID" "/tmp/paperclip-runner-controlled-${GITHUB_RUN_ID}/control" "$CELL_ID"
|
||||
|
||||
- name: Verify current worker admission before provider credentials
|
||||
if: needs.authorize.outputs.accounting_mode == 'controlled'
|
||||
env:
|
||||
MANIFEST_SHA256: ${{ needs.prepare_controller.outputs.manifest_sha256 }}
|
||||
PAPERCLIP_RUNNER_EVAL_EXECUTION_ID: ${{ matrix.cellId }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
root="/tmp/paperclip-runner-controlled-${GITHUB_RUN_ID}"
|
||||
node "$root/runtime/controlled-evals/hosted.mjs" verify "$root/control/controller-manifest.json" "$MANIFEST_SHA256" "$PAPERCLIP_RUNNER_EVAL_EXECUTION_ID"
|
||||
{
|
||||
echo "PAPERCLIP_RUNNER_EVAL_CONTROL_MANIFEST=$root/control/controller-manifest.json"
|
||||
echo "PAPERCLIP_RUNNER_EVAL_ARCHIVE_CLI=$root/runtime/controlled-evals/archive.mjs"
|
||||
echo "PAPERCLIP_RUNNER_EVAL_EXECUTION_ID=$PAPERCLIP_RUNNER_EVAL_EXECUTION_ID"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Prepare short-lived AgentCore web identity
|
||||
if: matrix.credentialName == 'AWS_AGENTCORE_OIDC'
|
||||
env:
|
||||
@@ -547,12 +869,15 @@ jobs:
|
||||
CASE_ID: ${{ matrix.caseId }}
|
||||
CREDENTIAL_NAME: ${{ matrix.credentialName }}
|
||||
PROVIDER: ${{ matrix.provider }}
|
||||
MAX_INFRASTRUCTURE_RETRIES: ${{ github.event_name == 'schedule' && 1 || inputs.max_infrastructure_retries }}
|
||||
MAX_INFRASTRUCTURE_RETRIES: ${{ needs.authorize.outputs.retry_count }}
|
||||
ACCOUNTING_MODE: ${{ needs.authorize.outputs.accounting_mode }}
|
||||
BRAINTRUST_API_KEY: ${{ needs.authorize.outputs.accounting_mode == 'controlled' && secrets.BRAINTRUST_API_KEY || '' }}
|
||||
OPENAI_API_KEY: ${{ matrix.credentialName == 'OPENAI_API_KEY' && secrets.OPENAI_API_KEY || '' }}
|
||||
ANTHROPIC_API_KEY: ${{ matrix.credentialName == 'ANTHROPIC_API_KEY' && secrets.ANTHROPIC_API_KEY || '' }}
|
||||
OPENROUTER_API_KEY: ${{ matrix.credentialName == 'OPENROUTER_API_KEY' && secrets.OPENROUTER_API_KEY || '' }}
|
||||
XAI_API_KEY: ${{ matrix.credentialName == 'XAI_API_KEY' && secrets.XAI_API_KEY || '' }}
|
||||
PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET: ${{ matrix.credentialName == 'PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET' && secrets.GROK_AUTH_JSON || '' }}
|
||||
COPILOT_GITHUB_TOKEN: ${{ matrix.credentialName == 'COPILOT_GITHUB_TOKEN' && secrets.COPILOT_GITHUB_TOKEN || '' }}
|
||||
PAPERCLIP_CLAUDE_MANAGED_PROFILE_ID: ${{ vars.PAPERCLIP_CLAUDE_MANAGED_PROFILE_ID }}
|
||||
PAPERCLIP_CLAUDE_MANAGED_AGENT_ID: ${{ vars.PAPERCLIP_CLAUDE_MANAGED_AGENT_ID }}
|
||||
PAPERCLIP_CLAUDE_MANAGED_AGENT_VERSION: ${{ vars.PAPERCLIP_CLAUDE_MANAGED_AGENT_VERSION }}
|
||||
@@ -576,7 +901,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p cell-output/runs
|
||||
if [ "$CREDENTIAL_NAME" != "AWS_AGENTCORE_OIDC" ]; then
|
||||
if [ "$ACCOUNTING_MODE" != controlled ] && [ "$CREDENTIAL_NAME" != "AWS_AGENTCORE_OIDC" ]; then
|
||||
test -n "${!CREDENTIAL_NAME:-}"
|
||||
fi
|
||||
if [ "$PROVIDER" = "claude_managed" ]; then
|
||||
@@ -585,13 +910,23 @@ jobs:
|
||||
test -n "$PAPERCLIP_CLAUDE_MANAGED_AGENT_VERSION"
|
||||
test -n "$PAPERCLIP_CLAUDE_MANAGED_ENVIRONMENT_ID"
|
||||
fi
|
||||
runner_cli=runner-protocol-build/extracted/portable/dist/cli/eval-session.js
|
||||
runner_package=runner-protocol-build/extracted/paperclip-runner.tgz
|
||||
runnerd=runner-protocol-build/extracted/paperclip-runnerd
|
||||
if [ "$ACCOUNTING_MODE" = controlled ]; then
|
||||
test -n "$BRAINTRUST_API_KEY"
|
||||
root="/tmp/paperclip-runner-controlled-${GITHUB_RUN_ID}"
|
||||
runner_cli="$root/runtime/controlled-evals/run.mjs"
|
||||
runner_package="$root/runner/paperclip-runner.tgz"
|
||||
runnerd="$root/runner/paperclip-runnerd"
|
||||
fi
|
||||
set +e
|
||||
python3 .paperclip-evals/evals/paperclip-runner/tools/run_live_roster.py run \
|
||||
--roster ".paperclip-evals/evals/paperclip-runner/rosters/$ROSTER_FILE" \
|
||||
--case "$CASE_ID" \
|
||||
--runner-cli runner-protocol-build/extracted/portable/dist/cli/eval-session.js \
|
||||
--runner-package runner-protocol-build/extracted/paperclip-runner.tgz \
|
||||
--runnerd runner-protocol-build/extracted/paperclip-runnerd \
|
||||
--runner-cli "$runner_cli" \
|
||||
--runner-package "$runner_package" \
|
||||
--runnerd "$runnerd" \
|
||||
--runs-root cell-output/runs \
|
||||
--summary-path cell-output/roster-summary.json \
|
||||
--max-infrastructure-retries "$MAX_INFRASTRUCTURE_RETRIES" \
|
||||
@@ -620,14 +955,28 @@ jobs:
|
||||
caseId: process.env.CASE_ID,
|
||||
...(authenticationMode ? { authenticationMode } : {}),
|
||||
...(authenticationEvidenceFailure ? { authenticationEvidenceFailure } : {}),
|
||||
accountingMode: process.env.ACCOUNTING_MODE,
|
||||
exitCode: Number(process.env.CELL_EXIT_CODE),
|
||||
}, null, 2)}\n`, { mode: 0o600 });
|
||||
if (authenticationEvidenceFailure) throw new Error(authenticationEvidenceFailure);
|
||||
NODE
|
||||
exit "$status"
|
||||
|
||||
- name: Upload access-controlled cell attempt
|
||||
if: always()
|
||||
- name: Retain controlled cell evidence in private storage
|
||||
if: always() && needs.authorize.outputs.accounting_mode == 'controlled' && steps.private_history_credentials.outcome == 'success' && steps.verify_controller.outcome == 'success'
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ steps.private_history_credentials.outputs.aws-access-key-id }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ steps.private_history_credentials.outputs.aws-secret-access-key }}
|
||||
AWS_SESSION_TOKEN: ${{ steps.private_history_credentials.outputs.aws-session-token }}
|
||||
AWS_REGION: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_AWS_REGION }}
|
||||
RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET }}
|
||||
CELL_ID: ${{ matrix.cellId }}
|
||||
run: |
|
||||
test -d cell-output
|
||||
node "/tmp/paperclip-runner-controlled-${GITHUB_RUN_ID}/runtime/private-history.mjs" upload "protocol-cell-$CELL_ID" "$GITHUB_RUN_ID" cell-output
|
||||
|
||||
- name: Upload legacy cell attempt
|
||||
if: always() && needs.authorize.outputs.accounting_mode == 'legacy'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: runner-protocol-eval-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.cellId }}
|
||||
@@ -638,14 +987,14 @@ jobs:
|
||||
eval_shard_1:
|
||||
name: Direct eval ${{ matrix.rosterId }} / ${{ matrix.caseId }}
|
||||
if: needs.catalog.outputs.matrix_1_present == 'true'
|
||||
needs: [authorize, catalog, build_runner]
|
||||
needs: [authorize, catalog, build_runner, prepare_controller]
|
||||
runs-on: ${{ needs.authorize.outputs.test_runner }}
|
||||
timeout-minutes: 18
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
environment:
|
||||
name: runner-e2e-paid
|
||||
name: ${{ needs.authorize.outputs.accounting_mode == 'controlled' && 'runner-e2e-private-paid' || 'runner-e2e-paid' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: ${{ fromJSON(needs.catalog.outputs.max_parallel_per_shard) }}
|
||||
@@ -654,13 +1003,15 @@ jobs:
|
||||
|
||||
report:
|
||||
name: Merge attempts and render canonical Evalbook
|
||||
if: always() && !cancelled() && needs.catalog.result == 'success' && needs.build_runner.result == 'success'
|
||||
needs: [authorize, catalog, build_runner, eval_shard_0, eval_shard_1]
|
||||
if: always() && !cancelled() && needs.catalog.result == 'success' && needs.build_runner.result == 'success' && needs.prepare_controller.result == 'success'
|
||||
needs: [authorize, catalog, build_runner, prepare_controller, eval_shard_0, eval_shard_1]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
id-token: write
|
||||
environment: ${{ needs.authorize.outputs.accounting_mode == 'controlled' && 'runner-e2e-private-history' || '' }}
|
||||
outputs:
|
||||
public_report_ready: ${{ steps.public_report.outputs.ready }}
|
||||
steps:
|
||||
@@ -700,17 +1051,6 @@ jobs:
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Resolve trusted report lockfile without lifecycle scripts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pnpm install --ignore-scripts --no-frozen-lockfile --lockfile-only
|
||||
test -s pnpm-lock.yaml
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Download immutable campaign catalog
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
@@ -723,7 +1063,59 @@ jobs:
|
||||
name: runner-protocol-build-${{ needs.authorize.outputs.target_sha }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: runner-protocol-build
|
||||
|
||||
- name: Download every access-controlled cell
|
||||
- name: Download the retained controller code
|
||||
if: needs.authorize.outputs.accounting_mode == 'controlled'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
artifact-ids: ${{ needs.prepare_controller.outputs.artifact_id }}
|
||||
path: ${{ runner.temp }}/runner-protocol-runtime
|
||||
|
||||
- name: Verify retained controller bytes
|
||||
if: needs.authorize.outputs.accounting_mode == 'controlled'
|
||||
env:
|
||||
RUNTIME_RECEIPT_SHA256: ${{ needs.prepare_controller.outputs.runtime_receipt_sha256 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node packages/paperclip-runner/scripts/controlled-evals/verify-runtime.mjs "$RUNNER_TEMP/runner-protocol-runtime" "$GITHUB_SHA" "$RUNTIME_RECEIPT_SHA256"
|
||||
|
||||
- name: Obtain private archive credentials for transfer steps only
|
||||
if: needs.authorize.outputs.accounting_mode == 'controlled'
|
||||
id: private_history_credentials
|
||||
uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6
|
||||
with:
|
||||
role-to-assume: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_AWS_ROLE_ARN }}
|
||||
aws-region: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_AWS_REGION }}
|
||||
output-env-credentials: false
|
||||
output-credentials: true
|
||||
disable-retry: true
|
||||
action-timeout-s: 60
|
||||
|
||||
- name: Restore private manifest and every available controlled cell
|
||||
if: needs.authorize.outputs.accounting_mode == 'controlled'
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ steps.private_history_credentials.outputs.aws-access-key-id }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ steps.private_history_credentials.outputs.aws-secret-access-key }}
|
||||
AWS_SESSION_TOKEN: ${{ steps.private_history_credentials.outputs.aws-session-token }}
|
||||
AWS_REGION: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_AWS_REGION }}
|
||||
RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET }}
|
||||
MANIFEST_SHA256: ${{ needs.prepare_controller.outputs.manifest_sha256 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
archive="$RUNNER_TEMP/runner-protocol-runtime/private-history.mjs"
|
||||
control="$RUNNER_TEMP/runner-protocol-control"
|
||||
node "$archive" download protocol-control "$GITHUB_RUN_ID" "$control" report
|
||||
test "$(sha256sum "$control/controller-manifest.json" | cut -d ' ' -f 1)" = "$MANIFEST_SHA256"
|
||||
mkdir -p downloaded-runner-protocol-evals
|
||||
while IFS= read -r cell; do
|
||||
node "$archive" download "protocol-cell-$cell" "$GITHUB_RUN_ID" "downloaded-runner-protocol-evals/runner-protocol-eval-$GITHUB_RUN_ID-1-$cell" || echo 'A private cell is unavailable; its allocation remains held.'
|
||||
done < <(jq -r '.attempts[].executionId' "$control/controller-manifest.json")
|
||||
{
|
||||
echo "PAPERCLIP_RUNNER_EVAL_CONTROL_MANIFEST=$control/controller-manifest.json"
|
||||
echo "PAPERCLIP_RUNNER_EVAL_ARCHIVE_CLI=$RUNNER_TEMP/runner-protocol-runtime/controlled-evals/archive.mjs"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download every legacy cell
|
||||
if: needs.authorize.outputs.accounting_mode == 'legacy'
|
||||
id: download_cells
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
@@ -733,7 +1125,7 @@ jobs:
|
||||
merge-multiple: false
|
||||
|
||||
- name: Retry cell download after artifact transport failure
|
||||
if: steps.download_cells.outcome == 'failure'
|
||||
if: needs.authorize.outputs.accounting_mode == 'legacy' && steps.download_cells.outcome == 'failure'
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
@@ -769,15 +1161,27 @@ jobs:
|
||||
|
||||
- name: Render the access-controlled canonical Evalbook report
|
||||
run: |
|
||||
python3 .paperclip-evals/evals/paperclip-runner/tools/eval_program.py report \
|
||||
control_args=()
|
||||
if [ -n "${PAPERCLIP_RUNNER_EVAL_CONTROL_MANIFEST:-}" ]; then
|
||||
control_args=(--controller-manifest "$PAPERCLIP_RUNNER_EVAL_CONTROL_MANIFEST" --controller-archive-cli "$PAPERCLIP_RUNNER_EVAL_ARCHIVE_CLI")
|
||||
fi
|
||||
set +e
|
||||
python3 .paperclip-evals/evals/paperclip-runner/tools/eval_program.py report "${control_args[@]}" \
|
||||
--runs-root runner-protocol-merged/runs \
|
||||
--output runner-protocol-merged/report \
|
||||
--viewer-root runner-protocol-build/extracted/dist-issue-thread \
|
||||
--inventory .paperclip-evals/evals/paperclip-runner/inventory.json \
|
||||
--coverage-matrix .paperclip-evals/evals/paperclip-runner/coverage-matrix.json
|
||||
--coverage-matrix .paperclip-evals/evals/paperclip-runner/coverage-matrix.json > runner-protocol-merged/accounting-report.json
|
||||
status=$?
|
||||
set -e
|
||||
cp runner-protocol-merged/campaign.json runner-protocol-merged/report/campaign.json
|
||||
if [ -n "${PAPERCLIP_RUNNER_EVAL_CONTROL_MANIFEST:-}" ]; then
|
||||
cp "$PAPERCLIP_RUNNER_EVAL_CONTROL_MANIFEST" runner-protocol-merged/controller-manifest.json
|
||||
fi
|
||||
exit "$status"
|
||||
|
||||
- name: Render the same canonical grid from a public-safe evidence projection
|
||||
if: needs.authorize.outputs.accounting_mode == 'legacy'
|
||||
run: |
|
||||
node packages/paperclip-runner/scripts/runner-protocol-eval-campaign.mjs sanitize \
|
||||
--runs-root runner-protocol-merged/runs \
|
||||
@@ -792,24 +1196,28 @@ jobs:
|
||||
cp runner-protocol-merged/campaign.json runner-protocol-merged/public-report/campaign.json
|
||||
|
||||
- name: Set up report browser verification
|
||||
if: needs.authorize.outputs.accounting_mode == 'legacy'
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Verify the actual chat viewer before publication
|
||||
if: needs.authorize.outputs.accounting_mode == 'legacy'
|
||||
run: |
|
||||
pnpm install --frozen-lockfile --ignore-scripts
|
||||
pnpm install --frozen-lockfile --ignore-scripts --store-dir "$RUNNER_TEMP/runner-report-pnpm-store"
|
||||
pnpm --filter @paperclipai/paperclip-runner exec playwright install --with-deps chromium
|
||||
node packages/paperclip-runner/scripts/verify-runner-evalbook-viewer.mjs --report-root runner-protocol-merged/public-report --screenshots runner-protocol-merged/viewer-proof
|
||||
node packages/paperclip-runner/scripts/verify-runner-evalbook-viewer.mjs --report-root runner-protocol-merged/report
|
||||
|
||||
- name: Enforce the static public allowlist
|
||||
if: needs.authorize.outputs.accounting_mode == 'legacy'
|
||||
id: public_report
|
||||
run: |
|
||||
node --input-type=module -e 'import { validatePublicProtocolEvalReport } from "./packages/paperclip-runner/scripts/publish-runner-protocol-eval-history.mjs"; await validatePublicProtocolEvalReport("runner-protocol-merged/public-report", { viewerRoot: "runner-protocol-build/extracted/dist-issue-thread" });'
|
||||
echo "ready=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Add campaign result to the workflow summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo '## Runner direct live protocol evals'
|
||||
@@ -817,8 +1225,20 @@ jobs:
|
||||
jq -r '"- Cells: \(.totals.passed)/\(.totals.selected) passed\n- Behavior failures: \(.totals.behaviorFailures)\n- Infrastructure failures: \(.totals.infrastructureFailures)\n- Paperclip: `\(.source.paperclip.sha)`\n- Evals: `\(.source.evals.sha)`"' runner-protocol-merged/campaign.json
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload access-controlled canonical Evalbook and raw attempts
|
||||
if: always()
|
||||
- name: Retain controlled Evalbook and accounting in private storage
|
||||
if: always() && needs.authorize.outputs.accounting_mode == 'controlled' && steps.private_history_credentials.outcome == 'success'
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ steps.private_history_credentials.outputs.aws-access-key-id }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ steps.private_history_credentials.outputs.aws-secret-access-key }}
|
||||
AWS_SESSION_TOKEN: ${{ steps.private_history_credentials.outputs.aws-session-token }}
|
||||
AWS_REGION: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_AWS_REGION }}
|
||||
RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET }}
|
||||
run: |
|
||||
test -d runner-protocol-merged
|
||||
node "$RUNNER_TEMP/runner-protocol-runtime/private-history.mjs" upload protocol-report "$GITHUB_RUN_ID" runner-protocol-merged
|
||||
|
||||
- name: Upload legacy canonical Evalbook and raw attempts
|
||||
if: always() && needs.authorize.outputs.accounting_mode == 'legacy'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: runner-protocol-eval-report-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
@@ -827,7 +1247,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload publisher-only sanitized Evalbook
|
||||
if: steps.public_report.outputs.ready == 'true'
|
||||
if: needs.authorize.outputs.accounting_mode == 'legacy' && steps.public_report.outputs.ready == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: runner-protocol-eval-public-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
@@ -60,16 +60,23 @@ title sizes remain larger.
|
||||
|
||||
The bottom navigation responds to accumulated scrolling, ignoring small
|
||||
reversals and Safari's edge bounce. It glides out and eases back in with shared
|
||||
motion tokens; the task composer follows the same motion. Keep page padding
|
||||
stable while the navigation moves, and honor reduced-motion preferences.
|
||||
motion tokens. Keep page padding stable while the navigation moves, and honor
|
||||
reduced-motion preferences.
|
||||
|
||||
Task conversations reveal their initial comments, interaction cards, plan, and
|
||||
relevant run history together after positioning the latest message. Keep the
|
||||
mobile loading surface at a stable viewport height while that history loads;
|
||||
concealed content must not stretch the document. Background refreshes keep an
|
||||
already revealed conversation and composer mounted and visible.
|
||||
Bound the initial wait to 15 seconds. If a request stalls, reveal the available
|
||||
conversation and composer with a notice that some history is still loading.
|
||||
Mobile task conversations size their shell to the visible viewport, with one
|
||||
message scroller and a normal flex footer for the composer. Navigation stays at
|
||||
the layout viewport bottom, behind the keyboard.
|
||||
|
||||
Cold task opens reveal the instruction and recent conversation independently of
|
||||
plans, related metadata, and historical run activity. Show the latest human
|
||||
follow-up, recent saved replies, and text or pending requests from the latest run.
|
||||
Put older messages and diagnostic activity behind **More messages**. Fetch those
|
||||
only when expanded; never download all history just to calculate a count.
|
||||
Retain messages and runs observed while the task stays open, preserve expansion
|
||||
on return within the session, and load linked older comments when requested.
|
||||
Background refreshes and history expansion keep the conversation and composer
|
||||
mounted. A failed history request shows an inline retry without hiding readable
|
||||
content. Full-history and classic views retain the bounded 15-second initial wait.
|
||||
|
||||
Only the viewer's own human comments sit on the right in blue bubbles. Agents
|
||||
and other humans sit on the left on the page surface, with their own name and
|
||||
@@ -80,11 +87,19 @@ that relayed a message from another channel.
|
||||
|
||||
## Contextual feedback
|
||||
|
||||
Keep the runner turn's avatar, name, Working header and current-activity slot
|
||||
Keep the runner turn's avatar, name, status header and current-activity slot
|
||||
in place from message submission through queued admission and native startup.
|
||||
Saving the human comment does not end startup feedback. Keep the identity mounted
|
||||
until its run appears or a newer execution outcome ends admission.
|
||||
Runtime discovery must not replace the turn with a generic spinner or a
|
||||
"run started" receipt. Queued and running headers use the same spacing.
|
||||
|
||||
A queued turn shows `Queued`, or `Waiting for maintenance` while the server holds
|
||||
admission. Reserve `Working` and `Thinking` for execution. A saved message that
|
||||
starts this queued turn stays in history, including when it reopens an older
|
||||
task. This placement is not a consumption receipt. Later follow-ups remain in
|
||||
the composer queue with their existing edit, order and discard controls.
|
||||
|
||||
Stream the provider's final reply as the conversational answer. Completion-tool
|
||||
reports remain inspectable run data while work continues. Use their summary as
|
||||
an answer fallback only after the run ends and its final transcript fetch
|
||||
@@ -860,6 +875,8 @@ tokenize motion. Principles — reasoning only; values live in `ui/src/index.css
|
||||
|
||||
Agent Chat and regular task chats keep pending questions and requests as compact, reopenable entries at their original position in history. Dismissing any composer request—including completion reviews, confirmations, choices, suggested tasks, item verdicts, and runtime input—removes its form and pending reminder. Saved interactions record human dismissal on the server; raw runtime requests retain a browser-local preference. Dismissal is not an answer, approval, or rejection. Sending a newer user message also clears an ordinary question from the composer without resolving it. Questions never contribute to composer pending counts or navigation. Opening the history entry restores the original form and its draft; submitting later uses the same durable question response path. Actual permission reviews retain their permission checks.
|
||||
|
||||
Desktop properties panels keep a zero-width, noninteractive host while their content is unknown. Late content expands the panel and moves the thread in the same width transition. Removing content on navigation clears the pane immediately, including maximized geometry. The motion uses `--motion-pane-reveal` and `--motion-ease-pane-reveal`; reduced motion disables it. A saved hidden preference stays hidden, and pointer resizing stays immediate.
|
||||
|
||||
## Canonical proposal toolbars
|
||||
|
||||
`Design proposals / Components / Toolbars` is the canonical toolbar review and
|
||||
|
||||
+1
-1
@@ -194,7 +194,7 @@ RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
COPY --chown=node:node --from=build /app /app
|
||||
|
||||
COPY --from=runner-provider-pack /provider-pack /opt/paperclip-runner/provider-pack
|
||||
COPY --chown=0:0 --from=runner-provider-pack /provider-pack /opt/paperclip-runner/provider-pack
|
||||
COPY --from=runner-provider-pack /provider-pack-transport/ /opt/paperclip-runner/
|
||||
# Managed deployments can remap node's UID at startup. This immutable pack
|
||||
# contains public code and integrity metadata, never credentials; it must remain
|
||||
|
||||
@@ -49,6 +49,18 @@ and leaves all existing cases passive. Source identities are not grants.
|
||||
|
||||
### Task query performance
|
||||
|
||||
Native session resume selects its newest progress barrier within the existing
|
||||
32-run task/session window in one SQL snapshot. Only the chosen checkpoint source
|
||||
returns its saved native input, checkpoint, tool fingerprint, wake envelope, and
|
||||
canonical task brief. Unused bootstrap candidates do not return full context or
|
||||
profiles. Wrong runtime, nonterminal status, process identity, provider events,
|
||||
invalid but non-null checkpoints, and future checkpoint barriers retain their
|
||||
existing selection rules. Stored run payloads and history reads stay complete.
|
||||
Run the synthetic PostgreSQL comparison with a pinned pre-change revision:
|
||||
`pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-native-session-resume-reads.ts --baseline-ref=<commit>`.
|
||||
An optional `PAPERCLIP_SESSION_RESUME_BENCHMARK_DATABASE_URL` must name a dedicated
|
||||
test database ending in `_test`; otherwise the harness starts disposable PostgreSQL.
|
||||
|
||||
Agent request authentication uses a compact run-identity read. It omits saved
|
||||
execution payloads, results, and log excerpts from the query response. The read
|
||||
still locks the task before the run and reconciles pending steering from durable
|
||||
@@ -580,6 +592,16 @@ PostgreSQL backends and checks rejection, pool recovery, and transaction isolati
|
||||
Remove the patch when an upstream release passes these tests. Installs of the
|
||||
unmodified `postgres` package outside this workspace do not include the patch.
|
||||
|
||||
The patch also preserves transaction connection reservation when pipelining is
|
||||
disabled with `PGMAX_PIPELINE=0`. Without this fix, the driver's pipeline capacity
|
||||
check skips the transaction reservation callback and `BEGIN` fails with
|
||||
`UNSAFE_TRANSACTION`. Disabling pipelining makes a queued query wait for the next
|
||||
available pool connection instead of sending it behind unrelated work on a busy
|
||||
connection. This can help interactive requests that share a prepared-query pool
|
||||
with slow background work. It does not make the SQL itself faster. The default
|
||||
pipeline limit remains unchanged. Test transaction behavior before using this
|
||||
setting with a driver build that does not include the patch.
|
||||
|
||||
Trusted-header actor synchronization retries transient connection failures,
|
||||
including `CONNECT_TIMEOUT`, at most twice. This retry applies only to the
|
||||
idempotent actor synchronization operations, not arbitrary transactions. A
|
||||
@@ -1408,3 +1430,23 @@ human's decision to leave a pending request in history. They are independent of
|
||||
The board-only dismissal path locks the task then interaction, checks audience,
|
||||
and never enqueues a continuation. Ordinary deferred requests are excluded from
|
||||
completion arbitration; protected authorization gates remain active.
|
||||
|
||||
### GitHub inherited review-wait reads
|
||||
|
||||
Passive GitHub review waits select only the assessment's `reviewedCommit` JSON
|
||||
value. The read preserves its JSON type for strict comparison with the review
|
||||
head. It still checks current requester provenance, company and agent bindings,
|
||||
connection state, repository identity, latest review, and processed publication.
|
||||
The complete assessment remains stored for review and publication.
|
||||
|
||||
The synthetic PostgreSQL benchmark captures the service-generated baseline and
|
||||
treatment queries, compares complete outputs, and measures raw DataRow bytes:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-github-review-wait.ts --baseline-ref=663fe667d66d3863d0919a8724f1d99500feef0b
|
||||
```
|
||||
|
||||
It uses disposable embedded PostgreSQL by default. A disposable local database
|
||||
named `review_wait_benchmark...` can be supplied through
|
||||
`PAPERCLIP_TEST_DATABASE_URL` to compare another PostgreSQL major version.
|
||||
DataRow bytes exclude response metadata and transport overhead.
|
||||
+218
-15
@@ -563,16 +563,59 @@ owns committed updates.
|
||||
|
||||
## Hot-Restart Deploys
|
||||
|
||||
During a restart, the board's health, session, and access checks retry temporary
|
||||
network/gateway failures and non-JSON API responses every five seconds. A new
|
||||
page shows **Reconnecting to Paperclip** with a **Try again** action and waits
|
||||
for startup health to become ready. Valid startup metadata remains available to
|
||||
sign-in and invitation pages. An already
|
||||
open page stays mounted during temporary background failures so unsaved edits
|
||||
survive. Successful checks resume the same route and refresh other failed reads;
|
||||
this recovery does not reload the browser or replay mutations. Authorization
|
||||
failures still require sign-in or an explicit retry. Storybook **App / Connection
|
||||
recovery** shows the startup recovery states.
|
||||
During a restart, the UI's connectivity store (`ui/src/lib/connectivity.ts`)
|
||||
confirms an outage with a `/api/health` probe, then polls health with backoff
|
||||
(1s, 2s, 5s, 10s, then every 15s; a longer `Retry-After` wins). While the server
|
||||
is unreachable, React Query pauses reads and `meta.replay = "idempotent"`
|
||||
mutations instead of failing them; on recovery they resume and live queries
|
||||
refresh once. Other mutations fail fast with readable copy and are not replayed.
|
||||
Transient errors (network drops, non-JSON responses, 408/425/429/502/503/504,
|
||||
and gateway codes such as `tenant_app_unavailable` in any status) are classified
|
||||
in `ui/src/api/errors.ts`.
|
||||
|
||||
A new page shows **Reconnecting to Paperclip** with a **Try again** action and
|
||||
waits for startup health to become ready. Valid startup metadata remains
|
||||
available to sign-in and invitation pages. An already open page stays mounted
|
||||
during temporary background failures so unsaved edits survive; after about two
|
||||
seconds of continuous trouble, one app-level **Connection interrupted** banner
|
||||
appears, followed briefly by **Back online**. Recovery does not reload the
|
||||
browser. Authorization failures still require sign-in or an explicit retry.
|
||||
Storybook **App / Connection recovery** shows the startup screens and banner.
|
||||
|
||||
To reproduce a deploy blip in a dev or QA build, set the outage simulator in the
|
||||
browser console and remove it to end the outage:
|
||||
|
||||
```js
|
||||
localStorage.paperclipSimulateOutage = "503:tenant_app_unavailable" // until removed
|
||||
localStorage.paperclipSimulateOutage = "503:tenant_app_unavailable@20" // for 20 seconds
|
||||
localStorage.paperclipSimulateOutage = "network" // dropped connection
|
||||
localStorage.removeItem("paperclipSimulateOutage")
|
||||
```
|
||||
|
||||
Reads render through `useQueryView(query)` or `<QueryView>`
|
||||
(`ui/src/components/QueryView.tsx`). The view state is one of `loading`,
|
||||
`ready`, `stale` (data plus a transient failure: render the data, no red
|
||||
text), `reconnecting` (no data yet while the app-wide connectivity state is
|
||||
not `online`: a quiet placeholder that fills in by itself when the probe loop
|
||||
recovers), or `error` (a real failure: `describeError` copy with a Retry
|
||||
button). A transient failure with no data while the server is reachable (a
|
||||
429, one plugin worker restarting, a 504 on one slow route) is also `error`
|
||||
once the retry policy gives up, because no recovery loop would refetch it.
|
||||
"Not found" renders only when `classifyError` says `not_found`, never for an
|
||||
outage. `<QueryErrorState>` is the error presentation on its own, in `page`,
|
||||
`panel`, and `inline` sizes. Plugin chrome (`usePluginSlots`,
|
||||
`usePluginLaunchers`) is the one exception: it never reports a transient
|
||||
failure and stays collapsed instead.
|
||||
|
||||
`node scripts/check-query-error-rendering.mjs --summary` reports UI code that renders raw query errors
|
||||
(`{error.message}`, `isError ?`) or sets `retry: false`. It is report-only for
|
||||
the long tail; pass `--enforce` to fail on every finding. Files listed in
|
||||
`ENFORCED_FILES` in the script have already migrated, and a finding in one of
|
||||
them fails the check in every mode. Add a file to that list when you migrate
|
||||
it. For an intentional exception (a mutation result rendered inline, a
|
||||
prop that is already outage-aware), end the line with `// query-error-ok:
|
||||
<reason>`. Reads that must fail fast use `retry: retryTransientOnly(0)`
|
||||
instead of `retry: false`.
|
||||
|
||||
Primary-instance rebuilds that restart `paperclip.service` can request one-shot live-run adoption instead of using the normal graceful shutdown drain. Before restarting the service, write the marker from the newly staged app with the current service PID:
|
||||
|
||||
@@ -838,6 +881,41 @@ Set `SEARCH_EVAL_SCALE=1` to additionally measure a disposable 10,000-task,
|
||||
30,000-comment dataset. `SEARCH_EVAL_REPORT=/tmp/search-quality.json` saves
|
||||
per-query results and latency measurements; scale measurements are opt-in.
|
||||
|
||||
### Native skill preparation benchmark
|
||||
|
||||
Measure native skill preparation through the production service, real PostgreSQL,
|
||||
and an in-memory OpenTelemetry exporter:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/server exec tsx scripts/benchmark-native-skill-preparation.ts
|
||||
```
|
||||
|
||||
The default fixture has a 208-file repository of about 10.8 MB. The report includes
|
||||
cold and warm resolution, a fresh service instance, detailed phase timings, and
|
||||
regular-pool SQL statement counts (excluding the separate cache-lock connection).
|
||||
Every delivered file is checked outside the timed section. No provider is started.
|
||||
`--snapshot-file=/absolute/path/snapshot.json --entrypoint=skills/example/SKILL.md`
|
||||
uses a captured repository file inventory instead. `--network-delay-ms=25` adds
|
||||
25 ms in each direction through a local TCP relay; this is simulated transport
|
||||
latency, not a measurement of a hosted database.
|
||||
|
||||
`--actor=agent` adds a real agent, run, and responsible user, then records version
|
||||
pins through the same callback boundary as execution. `--staging-shape=true`
|
||||
seeds 14 projects, 10 sources, 8 bindings, and 156 other skills. These match one
|
||||
observed staging inventory's counts; their content is synthetic. Combine these
|
||||
options to include authorization and unselected library rows in the measurement.
|
||||
|
||||
By default, spans stay in memory. `--export-otlp=true` also exports them using
|
||||
the operator's `OTEL_EXPORTER_OTLP_ENDPOINT`, protocol, and headers. Keep header
|
||||
values out of command arguments and reports. The report records source hashes,
|
||||
the export mode, and trace IDs. Exported traces identify this local diagnostic
|
||||
in their service version; they are not evidence of staging performance.
|
||||
|
||||
If creating another embedded cluster is impractical, `--postgres-port=55448
|
||||
--postgres-user=paperclip` uses an existing **local** server. It creates and migrates
|
||||
a uniquely named disposable database and drops that database afterward; it does
|
||||
not reuse an application database.
|
||||
|
||||
### Repository workspace admission benchmark
|
||||
|
||||
Measure a fresh checkout with and without the repository object cache:
|
||||
@@ -939,8 +1017,11 @@ as the source Runner package.
|
||||
|
||||
For native OpenCode and Cursor on Daytona, a complete installed Paperclip release
|
||||
also selects its packaged Linux daemon and provider-pack identity automatically.
|
||||
Use the matching Paperclip Daytona image: the controller verifies the image's
|
||||
pack manifest, executable hashes, bridge tree, and pinned versions before launch.
|
||||
Use a qualified Paperclip Daytona image. For an OS-protected pack, startup
|
||||
checks the selected provider interface and platform from its small manifest in
|
||||
the existing runner metadata query. It does not rescan the pack or require the
|
||||
same app source revision. Writable or legacy installations retain full content
|
||||
verification.
|
||||
The controller's operating system does not determine the remote daemon target.
|
||||
|
||||
Native Claude remote execution also selects the Linux x64 or ARM64 daemon and
|
||||
@@ -1766,6 +1847,13 @@ overrides, scoped credentials, managed AI identity, and runtime skill delivery.
|
||||
The executor receives credential ownership and failure-redaction updates as soon
|
||||
as they are available, so later setup failures still clean up acquired resources.
|
||||
Busy-account deferral returns explicitly before session/workspace preparation.
|
||||
|
||||
On Cloud-managed sandbox connections, an ACPX platform check can defer runtime
|
||||
preparation to the normal Runner launch. After the selected account completes
|
||||
its provider hello probe or live API-key check, that specific deferred check is
|
||||
informational. Authentication failures, unsupported platforms, other warnings,
|
||||
and self-hosted or SSH runtime checks retain their status. A passed connection
|
||||
test does not replace Runner package verification before execution.
|
||||
Public-path tests are in `server/src/__tests__/heartbeat-execution-configuration.test.ts`.
|
||||
|
||||
Concurrent skill preparations join the company inventory refresh before taking
|
||||
@@ -1835,6 +1923,32 @@ binding, then applies the existing run and operation permission checks.
|
||||
existing public helpers. Keep session policy changes separate from run
|
||||
orchestration changes.
|
||||
|
||||
Explicit continuation admission reads only task/comment identity and dispatch
|
||||
evidence from the saved context. Its preflight reads the historical adapter type
|
||||
and workspace sync receipt. The coordinator and run locks retain their order.
|
||||
The locked reread retains the complete Runner profile because suspended-session
|
||||
and quarantined-cleanup proofs parse the exact saved execution input. Other run
|
||||
and dispatch paths retain their existing reads.
|
||||
|
||||
Measure these two admission queries with
|
||||
`pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-continuation-run-reads.ts --baseline-ref=<pre-fix-commit>`.
|
||||
The synthetic PostgreSQL fixture compares the admission result and reports exact
|
||||
text-format DataRow frame bytes, SQL samples, and execution plans. It excludes
|
||||
TLS and protocol metadata. To use an isolated external database, set
|
||||
`PAPERCLIP_CONTINUATION_BENCHMARK_DATABASE_URL` to a disposable database ending
|
||||
in `_test`. The harness applies migrations there and adds synthetic fixtures.
|
||||
|
||||
Continuation history reads select completed tool receipts and the typed
|
||||
conversation/workspace recovery fields used to choose the prior terminal run.
|
||||
Every completed receipt keeps its full result. Receipt order, company/agent/task
|
||||
scope, and the complete retained history stay unchanged. Saved results remain
|
||||
available to detail, recovery, and execution paths.
|
||||
Use `pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-continuation-history.ts --baseline-ref=<pre-fix-commit>`
|
||||
to compare generated SQL, complete continuation envelopes, and DataRow bytes.
|
||||
The benchmark includes a large required-receipt control. Its external database
|
||||
option is `PAPERCLIP_CONTINUATION_HISTORY_BENCHMARK_DATABASE_URL`, with the same
|
||||
disposable `_test` database restriction.
|
||||
|
||||
Retry scheduling is in `server/src/services/heartbeat/retries.ts`. It owns bounded
|
||||
retry schedules, connection and workspace contention deferrals, shared-workspace
|
||||
holder checks, due retry promotion, and retry-now requests. `createHeartbeatRetries`
|
||||
@@ -1966,7 +2080,7 @@ agent conversion are outside this playbook.
|
||||
|
||||
## Paperclip Runner Adapter Conversion
|
||||
|
||||
Paperclip Runner offers native Codex, OpenCode, **ACPX Claude**, and **ACPX Pi**. New
|
||||
Paperclip Runner offers native Codex, OpenCode, **ACPX Claude**, **ACPX Grok Build**, and **ACPX Pi**. New
|
||||
Claude Code agents default to ACPX Claude on Apple Silicon, Intel Mac, Linux x64
|
||||
and Linux ARM64 with supported glibc. New OpenCode and Pi agents default to their native
|
||||
runtimes on Apple Silicon, Intel Mac, and Linux x64 with supported glibc;
|
||||
@@ -1977,11 +2091,23 @@ requires matching remote assets. The runner override is in Advanced. Ordinary Cl
|
||||
and provider routing remain authoritative. Existing agents keep their saved runner. Converting an existing Claude, Codex, OpenCode, or Pi agent selects its
|
||||
corresponding provider, preserves compatible models, credentials, workspace,
|
||||
and instructions, and resets execution sessions while retaining run history.
|
||||
Grok Build uses its qualified macOS ARM64 and Linux x64 targets. Its conversion
|
||||
preserves the selected model, account, and explicit reasoning effort through ACP.
|
||||
Custom commands, extra arguments, legacy permission modes, search flags, and
|
||||
nonzero turn limits require Legacy; conversion rejects them before saving.
|
||||
Other adapters require an explicit provider choice. Legacy ACPX Codex agent
|
||||
settings normalize to native Codex on configuration updates and before fresh
|
||||
runs; immutable run descriptors remain readable. The **Paperclip Runner**
|
||||
experimental setting applies to other native providers. Native Codex, OpenCode,
|
||||
ACPX Claude, and ACPX Pi do not require it. Company access checks still apply.
|
||||
ACPX Claude, ACPX Grok Build, and ACPX Pi do not require it. Company access checks still apply.
|
||||
|
||||
Native Codex preserves an explicit `adapterConfig.search: false` when converting
|
||||
or saving an agent. The disabled hosted-search policy is recorded in the native
|
||||
execution input and launch requirements, so prepared launches and recovery keep
|
||||
it. An absent setting retains the provider default; this does not add support
|
||||
for converting a Legacy agent with `search: true`. Authentication hello probes
|
||||
always disable hosted web search because they only verify the selected account
|
||||
and model.
|
||||
|
||||
OpenCode requires an explicit `provider/model` before testing or finishing setup.
|
||||
An inactive setup draft can be saved before choosing a model. Automatic native
|
||||
@@ -1991,6 +2117,18 @@ extra arguments, model variants, and inherited permission rules require the
|
||||
Legacy runner; incompatible settings return remediation instead of being ignored.
|
||||
Saved OpenCode agents keep their runner and runtime policies.
|
||||
|
||||
Plugin environment drivers can declare `requiresAgent: true` when their leases
|
||||
need an agent-owned resource. Setup saves the draft and environment, runs its
|
||||
existing lifecycle preparation, and waits in `verifying` with the `setup` hold.
|
||||
The worker becomes idle there until the user finishes hiring. Connection tests
|
||||
use that draft's identity and a bounded Core-issued probe; ordinary tasks remain
|
||||
blocked. Environment changes save immediately, preserve the selected account,
|
||||
and invalidate the previous test. Tests without an agent report that prerequisite
|
||||
without allocating a lease. Finishing setup still verifies the saved configuration.
|
||||
Companies that require hire approval keep drafts paused and defer allocation and
|
||||
verification until that approval. The wizard saves the account with a pending
|
||||
verification warning instead of requiring a pre-approval computer.
|
||||
|
||||
Pi also requires an explicit `provider/model` before testing or hiring; inactive
|
||||
setup drafts may precede model selection. Install its pinned runtime on the
|
||||
execution host with `paperclipai runtime setup pi` before testing the connection.
|
||||
@@ -2184,7 +2322,15 @@ Historical v1 `provider-pack.tar.zst` receipts remain readable. Missing sidecars
|
||||
remote zstd, compatible GNU tar/checksum tools, or the Node 18+ helper retain
|
||||
directory staging; invalid sidecars fail
|
||||
closed. Keep transport files outside the pack so they cannot include themselves.
|
||||
Matching staged/preinstalled packs remain preferred and never transfer archives.
|
||||
Compatible protected image packs remain preferred and never transfer archives.
|
||||
The Runner's `src/live/provider-pack-contract.json` declares interface revisions;
|
||||
bump only the affected interface when its runner/pack contract breaks. Qualify
|
||||
and seal packs at image publication. The fast path requires root-owned,
|
||||
non-writable ancestors and manifest, and rejects writable aliases. The selected
|
||||
pack's own artifact identities feed launch and the recovery receipt. Reattachment
|
||||
keeps that exact identity; compatibility is a fresh-launch selection rule.
|
||||
Older packs without the declaration retain content verification until the image
|
||||
is updated. Selected ACPX agent profile checks remain in force.
|
||||
Daytona detects gzip/zstd headers and skips redundant transport compression.
|
||||
|
||||
### Remote GitHub launcher staging
|
||||
@@ -2344,6 +2490,34 @@ identity mismatches.
|
||||
|
||||
## App-Shipped Skills Catalog
|
||||
|
||||
Plugin environment drivers can declare `skillKeys` that reference their own
|
||||
manifest-managed skills. Selecting that environment adds those skills to the
|
||||
normal authorized skill selection, including an explicitly empty optional
|
||||
selection. Core installs a missing managed skill in the current company's
|
||||
library during lifecycle reconciliation or new-run admission and preserves existing
|
||||
skill edits and explicit version pins. New managed skills use
|
||||
`plugin-<short-plugin-id>-<skill-slug>` for both the library slug and SKILL.md name.
|
||||
The short ID is the final dot-separated plugin ID component, without a leading
|
||||
`plugin-`. An unrelated skill can keep the same slug in a separate canonical
|
||||
record. The required environment skill wins during delivery: a selected company
|
||||
skill with the same slug or name is omitted and reported as shadowed. Stored agent
|
||||
assignments and both library records remain unchanged. Run warnings and snapshots,
|
||||
and the plugin runtime response, identify the omitted key and the winning key. Existing company copies keep their edits and names
|
||||
until reset. Lifecycle callers request installation with
|
||||
`agents.getRuntime(agentId, companyId, undefined, true)`; ordinary runtime reads and
|
||||
recovery do not install newly declared skills. Run
|
||||
preparation captures the same immutable versions as ordinary assignments;
|
||||
recovery uses the saved selection. The lifecycle runtime reader includes the
|
||||
effective default environment's skills before synchronizing the persistent home.
|
||||
A task provider can pass its selected environment ID to `agents.getRuntime` when
|
||||
a task overrides that default. This
|
||||
contract currently applies to `driver: "plugin"` environments. Other drivers
|
||||
and plugin drivers without `skillKeys` keep their existing behavior. No agent
|
||||
configuration is rewritten, and no background scan is added. Changes to implicitly
|
||||
selected skills, the effective default environment, or plugin requirements enqueue
|
||||
normal durable runtime events, so an idle ready computer receives updates without
|
||||
a new run. Failed synchronization retains the event for the existing retry path.
|
||||
|
||||
The Paperclip app ships a curated catalog of company skills out of the box. The
|
||||
catalog is a workspace package at `packages/skills-catalog`:
|
||||
|
||||
@@ -3258,6 +3432,35 @@ pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-workspace-prepar
|
||||
It compares actual preparation guards with synthetic history. Local timings are
|
||||
separate from Git materialization, environment acquisition, and provider startup.
|
||||
|
||||
### Sidebar badge run-query benchmark
|
||||
|
||||
The badge query keeps the newest 200 runs of nonterminated same-company agents.
|
||||
It selects each agent's first observed run before status or Mine filters. An
|
||||
observation ordinal preserves equal-time input order. Per-agent ranking orders
|
||||
by full creation time first, then that ordinal for exact ties. SQL returns only failure
|
||||
IDs, issue bindings, and creation times. Dismissals and fresh issue authorization
|
||||
still run in the service.
|
||||
|
||||
Run the synthetic PostgreSQL comparison with a pinned pre-change revision:
|
||||
|
||||
```sh
|
||||
PAPERCLIP_LOG_LEVEL=error SIDEBAR_REQUIRE_BYTE_REDUCTION=1 pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-sidebar-badges.ts --baseline-ref=bf3ef12f3a78d0b15a32ad57f7e16520fbf6cd5e
|
||||
```
|
||||
|
||||
The harness starts disposable embedded PostgreSQL by default. To use an existing
|
||||
isolated test database with current migrations, set
|
||||
`PAPERCLIP_SIDEBAR_TEST_DATABASE_URL` to a dedicated database whose name ends in
|
||||
`_test`. The harness removes only its own synthetic company fixtures. Do not use
|
||||
a development or hosted company database.
|
||||
|
||||
The report includes service-generated SQL, complete badge equality, candidate
|
||||
bindings, alternating warm samples, plans, and UTF-8 response-value bytes before
|
||||
Drizzle decoding. This byte count excludes protocol framing. The mixed fixture
|
||||
covers shared agents, superseding successes, timestamp ties, Mine users,
|
||||
dismissal equality, private and deleted issues, and more than 200 runs. The
|
||||
all-failed, all-succeeded, and empty controls expose neutral or slower paths.
|
||||
Timings describe local SQL work and do not establish hosted request latency.
|
||||
|
||||
### Budget accounting performance
|
||||
|
||||
Budget admission and settlement share the company accounting lock. They evaluate
|
||||
|
||||
+11
-1
@@ -49,7 +49,7 @@ start execution. This preference does not change the org chart or permissions.
|
||||
|
||||
### Agent Execution
|
||||
|
||||
Existing Claude and Codex agents can upgrade from the legacy runner on their
|
||||
Existing Claude, Codex, and Grok Build agents can upgrade from the legacy runner on their
|
||||
Harness / Runtime page when Paperclip Runner is the qualified default for their
|
||||
environment. One action saves pending edits and translates compatible settings
|
||||
while keeping the harness, selected model, account bindings, instructions, and
|
||||
@@ -391,3 +391,13 @@ operation logs retain their app authorization. Workspace `_issuePrivacySources`
|
||||
operation privacy metadata protect app-record disclosure; they do not grant or deny
|
||||
filesystem execution or task-bound file browsing. Current trust and sandbox policy,
|
||||
publication receipts, binding checks, and lifecycle locks remain authoritative.
|
||||
|
||||
## Agent connection administration (2026-10-13)
|
||||
|
||||
Standard agents can configure connections through the same domain APIs used by the app, bounded by their grants and their responsible user's current authority. Fresh standard agents for default Operators can manage app/tool connections, chat/email channels, AI accounts and pools, profiles and gateways without additional grants. Existing grants are not backfilled; explicit restrictions persist. Gateway administration retains the responsible human's administrative permission and does not grant agents blanket `tools:admin`.
|
||||
|
||||
The agent performs authorized setup, reuses saved accounts and drafts, and hands off only required human authentication, consent, credential entry or identity confirmation. Saved handoffs resume through existing task continuation after current authority is rechecked. Management, tool execution and approval remain separate. Private accounts, host commands and stored credentials retain their boundaries; agent-issued gateway clients preserve issuer and responsible-user restrictions at use time.
|
||||
|
||||
Authorized policy administration includes user-requested changes that loosen the acting agent’s own tool controls. These writes remain bounded by management grants, resource scope and the responsible user’s current authority; they do not need an additional approval solely because the agent is a target. Subsequent tool calls obey the resulting policy. Agents cannot approve their own governed action requests or edit their own principal permission grants.
|
||||
|
||||
The implementation, operation inventory, permission matrix and fixture qualification are recorded in [Agent API parity for connection configuration](plans/2026-10-13-connection-api-parity.md). Provider fixtures establish API behavior; they do not constitute live provider setup or an empirical agent-success claim.
|
||||
@@ -897,8 +897,14 @@ Permissions / Trust settings. Board operators with `agents:create` can add
|
||||
same-company projects through `POST /api/agents/:id/project-access`; the operation
|
||||
locks the agent, preserves existing permissions and audits added projects.
|
||||
Saving does not retry work. The task offers explicit Retry after the required
|
||||
project is allowed, and dispatch rechecks authorization. Existing setup failures
|
||||
with the exact project-boundary denial also receive the explanation on reload.
|
||||
projects are allowed, and dispatch rechecks authorization. Readiness checks the
|
||||
task's current organizational project and selected workspace source, including
|
||||
a valid pending selection. Moving the task to an allowed project does not clear
|
||||
a retained source's access requirement. Existing setup failures with the exact
|
||||
project-boundary denial also receive the explanation on reload, without assuming
|
||||
that the task's current project caused the historical failure. A stale or
|
||||
unavailable workspace selection stays in the non-retryable failure state; it
|
||||
does not ask the user to grant project access that cannot repair the selection.
|
||||
|
||||
Cross-issue writes are contained per heartbeat run. An agent-authored comment
|
||||
may wake the target assignee, including an explicit `resume: true` comment on a
|
||||
@@ -1350,7 +1356,7 @@ Invites tab does not hide this Cloud action.
|
||||
## 10.3 Agents
|
||||
|
||||
The Harness / Runtime configuration page offers **Upgrade runner** for existing
|
||||
legacy Claude and Codex agents when the target environment selects Paperclip
|
||||
legacy Claude, Codex, and Grok Build agents when the target environment selects Paperclip
|
||||
Runner by default. The action uses the normal authorized agent PATCH with
|
||||
`runner: "paperclip"`, includes pending configuration edits, and retains the
|
||||
model and compatible settings. A rejected conversion leaves saved configuration
|
||||
@@ -1896,6 +1902,13 @@ Required UX behaviors:
|
||||
- conflict toasts on atomic checkout failure
|
||||
- no silent background failures; every failed run visible in UI
|
||||
|
||||
Cold opens of ordinary tasks show the instruction and recent conversation before
|
||||
related metadata and historical run activity. Earlier messages and diagnostics
|
||||
load through **More messages**. Messages observed while the task stays open remain
|
||||
visible; deep links still load their target. Compact reads retain the full reads'
|
||||
authorization and redaction rules. Agent Chat and the classic task interface keep
|
||||
their existing history behavior.
|
||||
|
||||
Regular task chats and Agent Chat keep requests as compact, reopenable entries
|
||||
at their original position in the feed. Dismissing any composer request clears
|
||||
its form and pending reminder, including completion reviews, confirmations,
|
||||
@@ -2623,3 +2636,12 @@ company audit context; active viewers may update their own presentation. The pre
|
||||
and sparse audit event commit atomically. Client caches are keyed by human identity,
|
||||
and switching waits for successful persistence; unavailable preferences fall back to
|
||||
the classic shell. This preference does not replace `enableClassicTaskInterface`.
|
||||
## Agent connection administration (2026-10-13)
|
||||
|
||||
Standard agents can configure connections through the same domain APIs used by the app, bounded by their grants and their responsible user's current authority. Fresh standard agents for default Operators can manage app/tool connections, chat/email channels, AI accounts and pools, profiles and gateways without additional grants. Existing grants are not backfilled; explicit restrictions persist. Gateway administration retains the responsible human's administrative permission and does not grant agents blanket `tools:admin`.
|
||||
|
||||
The agent performs authorized setup, reuses saved accounts and drafts, and hands off only required human authentication, consent, credential entry or identity confirmation. Saved handoffs resume through existing task continuation after current authority is rechecked. Management, tool execution and approval remain separate. Private accounts, host commands and stored credentials retain their boundaries; agent-issued gateway clients preserve issuer and responsible-user restrictions at use time.
|
||||
|
||||
Authorized policy administration includes user-requested changes that loosen the acting agent’s own tool controls. These writes remain bounded by management grants, resource scope and the responsible user’s current authority; they do not need an additional approval solely because the agent is a target. Subsequent tool calls obey the resulting policy. Agents cannot approve their own governed action requests or edit their own principal permission grants.
|
||||
|
||||
The implementation, operation inventory, permission matrix and fixture qualification are recorded in [Agent API parity for connection configuration](plans/2026-10-13-connection-api-parity.md). Provider fixtures establish API behavior; they do not constitute live provider setup or an empirical agent-success claim.
|
||||
@@ -873,3 +873,12 @@ classic workspace. The presentation choice belongs to the person across companie
|
||||
on an instance; it does not grant execution capabilities or change shared work.
|
||||
The concrete current contract is in `SPEC-implementation.md` under Personal
|
||||
experimental workspace preference.
|
||||
## Agent connection administration (2026-10-13)
|
||||
|
||||
Standard agents can configure connections through the same domain APIs used by the app, bounded by their grants and their responsible user's current authority. Fresh standard agents for default Operators can manage app/tool connections, chat/email channels, AI accounts and pools, profiles and gateways without additional grants. Existing grants are not backfilled; explicit restrictions persist. Gateway administration retains the responsible human's administrative permission and does not grant agents blanket `tools:admin`.
|
||||
|
||||
The agent performs authorized setup, reuses saved accounts and drafts, and hands off only required human authentication, consent, credential entry or identity confirmation. Saved handoffs resume through existing task continuation after current authority is rechecked. Management, tool execution and approval remain separate. Private accounts, host commands and stored credentials retain their boundaries; agent-issued gateway clients preserve issuer and responsible-user restrictions at use time.
|
||||
|
||||
Authorized policy administration includes user-requested changes that loosen the acting agent’s own tool controls. These writes remain bounded by management grants, resource scope and the responsible user’s current authority; they do not need an additional approval solely because the agent is a target. Subsequent tool calls obey the resulting policy. Agents cannot approve their own governed action requests or edit their own principal permission grants.
|
||||
|
||||
The implementation, operation inventory, permission matrix and fixture qualification are recorded in [Agent API parity for connection configuration](plans/2026-10-13-connection-api-parity.md). Provider fixtures establish API behavior; they do not constitute live provider setup or an empirical agent-success claim.
|
||||
@@ -84,7 +84,7 @@ Paperclip runs a watchdog reconciliation tick at server startup, at the end of e
|
||||
|
||||
For each active watchdog the tick:
|
||||
|
||||
1. **Walks the watched subtree.** Starts at the configured issue and follows `parent_id` downward, excluding every issue whose `originKind = 'task_watchdog'` and everything below it. This excludes the watchdog's own review tasks so it cannot trigger itself.
|
||||
1. **Walks the watched subtree.** Starts at the configured issue and follows `parent_id` downward, excluding every issue whose `originKind = 'task_watchdog'` and everything below it. This excludes the watchdog's own review tasks so it cannot trigger itself. The scan includes at most 100 levels, with the watched issue at level 1. If an eligible child remains beyond that limit, the result is **incomplete**. Paperclip skips review dispatch and rejects watchdog mutation revalidation until it can read the full subtree. A leaf at level 100 does not by itself make the scan incomplete. Reconciliation reports these cases separately through `incomplete` and `incompleteIssueIds`. Startup and periodic reconciliation warn when a watched issue first becomes incomplete. The warning contains the newly affected issue IDs. An unchanged incomplete issue does not produce repeated warnings. Once a successful scan no longer reports that issue as incomplete, a later incomplete scan can warn again.
|
||||
2. **Checks for live paths.** If any included issue has a live run (`queued`, `running`, `scheduled_retry`), a queued wake request, or a scheduled retry, the subtree is **live** and the watchdog does not fire.
|
||||
3. **Computes a stop fingerprint.** A SHA-256 hash over the stopped leaves' identifiers, statuses, blockers, pending interactions, and the current watchdog configuration. The configuration is part of the fingerprint, so changing the agent or instructions invalidates the previously reviewed state.
|
||||
4. **Compares against `lastReviewedFingerprint`.** Match → suppress (the watchdog already saw this exact stopped state). New → proceed.
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# Adapter model and harness audit: October 23, 2026
|
||||
|
||||
This audit follows the [October 2, 2026 audit](adapter-model-audit-2026-10-02.md).
|
||||
It covers model selection in the coding-agent adapters and the CLI pins in the
|
||||
evaluation sandbox image. Catalog entries identify models; provider accounts
|
||||
and installed CLIs determine access. No agent defaults or saved model
|
||||
selections are migrated.
|
||||
|
||||
## Model changes
|
||||
|
||||
| Adapter | Changes from the audit |
|
||||
| --- | --- |
|
||||
| Codex | No new selectable model IDs. Codex 0.160.0 through 0.164.0 ship the same bundled model set, which the picker already lists (including GPT-6 Astra, GPT-5.6 Terra, and GPT-5.6 Luna). OpenAI retired `gpt-5.5` (October 14, 2026) and `gpt-5.4` / `gpt-5.4-mini` (August 31, 2026) from Codex with ChatGPT sign-in. The API and API-key Codex sessions still serve them, so they stay in the picker. When the backend rejects one of them for a ChatGPT account, the environment Test (legacy CLI and Paperclip Runner) now names the retirement and the documented replacement instead of suggesting a stale CLI, a plan restriction, or generic account checks. |
|
||||
| OpenCode | Add `anthropic/claude-haiku-5-5` and `google/gemini-4-argon` to the static fallback used by remote environments and the Paperclip Runner picker. Both IDs are in the OpenCode model registry (models.dev). |
|
||||
| Claude Code (direct and Bedrock) | Claude Haiku 5.5 is covered by [#16708](https://github.com/paperclipai/paperclip/pull/16708); this audit does not duplicate it. Haiku 5.5 needs Claude Code 2.1.293 or later and supports all five effort levels. |
|
||||
| Gemini CLI | Gemini 4 Argon (`gemini-4-argon`) is generally available on the Gemini API, but Gemini CLI 0.65.0 does not register it. Deferred. |
|
||||
| Grok Build, Kimi Code, Cursor | No new verified model IDs. Grok 4.7 and Kimi K3 remain the newest documented models. |
|
||||
|
||||
## Harness changes
|
||||
|
||||
| Harness | Before | After |
|
||||
| --- | --- | --- |
|
||||
| Grok CLI (`@xai-official/grok`) | 1.0.46 | 1.0.70 |
|
||||
| Gemini CLI | 0.62.0 | 0.65.0 |
|
||||
| Kimi Code CLI | 2.1.1 | 2.3.3 |
|
||||
| Cursor CLI (sandbox layer) | 2026.10.01-e373342 | 2026.10.22-27dcf5f |
|
||||
| GitHub CLI | 2.102.0 | 2.103.3 |
|
||||
| Codex CLI (shared provider pack) | 0.160.0 | Unchanged here; [#17245](https://github.com/paperclipai/paperclip/pull/17245) moves it to 0.163.0 |
|
||||
| Claude Agent SDK / Claude Code | 0.3.286 / 2.1.286 | Unchanged; see "Deferred items" |
|
||||
| OpenCode (shared provider pack) | 1.18.34 | Unchanged; see "Deferred items" |
|
||||
| Hermes | 0.19.0 | 0.19.0 (newest on PyPI) |
|
||||
|
||||
These pins change only the evaluation image's sandbox layer. They must stay
|
||||
aligned with the fleet sandbox image definition.
|
||||
|
||||
## Sources and verification
|
||||
|
||||
Research date: October 23, 2026. Harness pins rechecked October 26, 2026.
|
||||
|
||||
- [Codex models](https://learn.chatgpt.com/docs/models) states the GPT-5.5
|
||||
retirement ("Effective October 14, 2026 ... GPT-5.5 retires from Codex for
|
||||
users signed in with ChatGPT"), its replacements (GPT-6.1 Sol on paid plans,
|
||||
GPT-6 Luna on Free and Go), the GPT-5.4 retirement and its replacements
|
||||
(`gpt-6-sol`, `gpt-6-luna`), and that API-key sessions are not affected.
|
||||
- The bundled `codex-rs/models-manager/models.json` is unchanged in model set
|
||||
from [rust-v0.160.0](https://github.com/openai/codex/releases/tag/rust-v0.160.0)
|
||||
to [rust-v0.164.0](https://github.com/openai/codex/releases/tag/rust-v0.164.0),
|
||||
the npm `latest` release on October 26, 2026.
|
||||
- [Claude Haiku 5.5](https://platform.claude.com/docs/en/models/haiku-5-5/overview)
|
||||
was released October 7, 2026. The
|
||||
[Claude Code changelog](https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md)
|
||||
entry for 2.1.293 adds `claude-haiku-5-5`.
|
||||
- [models.dev](https://models.dev/api.json) lists `anthropic/claude-haiku-5-5`
|
||||
and `google/gemini-4-argon`. The OpenCode 1.18.34 executable's bundled
|
||||
snapshot predates both. Paperclip Runner binds the selected model into the
|
||||
OpenCode provider configuration, so the snapshot does not limit it.
|
||||
- [Gemini 4 Argon](https://ai.google.dev/gemini-api/docs/models/gemini-4-argon)
|
||||
is generally available since October 13, 2026. Gemini CLI 0.65.0
|
||||
(`packages/core/src/config/models.ts`) has no entry for it, and pull requests
|
||||
adding it to the CLI are still open.
|
||||
- Grok CLI 1.0.70, Gemini CLI 0.65.0, and Kimi Code 2.3.3 are the npm `latest`
|
||||
tags on October 26, 2026. xAI publishes no Grok CLI changelog; `-m`,
|
||||
`--effort`, and `grok models` are unchanged in the
|
||||
[CLI reference](https://docs.x.ai/build/cli/reference.md) and in
|
||||
`grok --help` from 1.0.70.
|
||||
[Kimi Code 2.2.0 and 2.3.0](https://github.com/MoonshotAI/kimi-code/releases)
|
||||
remove the `kaos` MCP executor and require qualified plugin skill names;
|
||||
Paperclip uses neither. Kimi Code 2.3.2 stops the agent from launching
|
||||
swarms by itself, so `kimi -p` and ACP sessions no longer start swarms;
|
||||
2.3.1 and 2.3.3 are fixes and prompt changes. `-m`, `-p`,
|
||||
`--output-format`, and `acp` are unchanged in `kimi --help` from 2.3.3.
|
||||
Gemini CLI 0.65.0 makes workspace settings read-only in untrusted folders;
|
||||
Paperclip writes only home settings and sets `GEMINI_CLI_TRUST_WORKSPACE`
|
||||
for remote runs.
|
||||
- Cursor CLI 2026.10.22-27dcf5f is the version `https://cursor.com/install`
|
||||
resolves. The archive SHA-256 was computed from the versioned Linux x64
|
||||
tarball, and the extracted executable reports that version.
|
||||
- [GitHub CLI 2.103.3](https://github.com/cli/cli/releases/tag/v2.103.3): the
|
||||
pinned digest matches the release `checksums.txt`. 2.103.1 through 2.103.3
|
||||
are fixes on top of 2.103.0.
|
||||
- [Hermes on PyPI](https://pypi.org/project/hermes-agent/) remains at 0.19.0.
|
||||
Newer Hermes releases (v0.22.4) are published only through GitHub, the
|
||||
installer, and Docker.
|
||||
|
||||
## Deferred items
|
||||
|
||||
- **Claude runtime 2.1.286 → 2.1.308.** Claude Code's `stable` channel is
|
||||
2.1.308 (`latest` is 2.1.309), with Agent SDK 0.3.308. The range changes
|
||||
headless defaults that Paperclip runs depend on: fork subagents are on by
|
||||
default in `-p` and the Agent SDK (2.1.302), sessions with no permission
|
||||
mode start in `default` mode on third-party providers (2.1.302), and MCP
|
||||
allow rules match whole server names (2.1.299). This needs a separate runner
|
||||
qualification together with the patched `claude-agent-acp` bridge. Until then,
|
||||
Haiku 5.5 on the CLI lane needs a 2.1.293+ CLI. Once #16708 records that
|
||||
minimum, sandbox runs on an older image prepare a private CLI at the model
|
||||
minimum before launch.
|
||||
- **OpenCode 1.19.x and 2.x.** OpenCode 1.19.3 generates reasoning variants
|
||||
from model options instead of catalog variants, which affects the variants
|
||||
Paperclip Runner binds. OpenCode 2 changes the SDK, event, and plugin
|
||||
contracts. Both need qualification.
|
||||
- **ACP bridges.** ACPX 0.20.0, `claude-agent-acp` 0.94.0 (ACP SDK 2.0), and
|
||||
`codex-acp` 2.2.2 (breaking tool-call contract in 2.0.0) need a
|
||||
qualification campaign rather than a pin bump.
|
||||
- **GitHub Copilot CLI 1.0.103.** The qualified Copilot profile binds
|
||||
Copilot 1.0.88 by immutable identity; a new profile version needs its own
|
||||
qualification. Copilot discovers models from the ACP session, so new Copilot
|
||||
models need no catalog change.
|
||||
- **Pi 1.4.0** and **Hermes 0.22.x** need runtime qualification; Hermes no
|
||||
longer publishes to PyPI.
|
||||
- **Gemini 4 Argon** in the Gemini CLI and Cursor catalogs: the CLI does not
|
||||
register it yet, and Cursor documents display names only.
|
||||
- **Codex default model.** Codex 0.161.0 makes `gpt-6.1-sol` its default.
|
||||
Paperclip keeps `gpt-5.6-sol` so saved agents and spending do not change.
|
||||
- **Hidden Codex aliases** (`gpt-daybreak-*`) are restricted to eligible
|
||||
accounts.
|
||||
- **Claude Sonnet 4.5** is deprecated and retires November 30, 2026. It stays
|
||||
in the picker until then.
|
||||
- **Claude Mythos 5.1** remains restricted to verified organizations.
|
||||
|
||||
## Host-environment baseline failures
|
||||
|
||||
Recorded once for this audit on the unchanged base (`c5ca29d`), Node 25.9.0:
|
||||
|
||||
- `server/src/__tests__/agent-hire-ai-connections.test.ts` fails at import
|
||||
with `AI_InvalidArgumentError: baseURL must be a non-empty string.` because
|
||||
the host injects an empty AI provider URL. Unrelated to this change.
|
||||
@@ -29,3 +29,13 @@ Agent-authenticated callers cannot set process adapter configuration or switch a
|
||||
The new direct grants are withheld from low trust agents and managed built-in agents. Low trust run or project policies can also deny privileged actions even if an agent has a grant. There is no permission backfill or upgrade migration for existing agents. A standard pending new hire receives the new default set when approved and activated. Invitation approval retains the set when it replaces a new agent's grants and preserves any explicitly scoped default grant. Existing scoped grants stay unchanged. A grant removed by an operator stays removed; ordinary updates and startup do not reapply it.
|
||||
|
||||
The standard agent configuration default supports the agent setup in the linked runner task. Enabling a warm runtime can still depend on the target agent's adapter and runtime configuration, provider availability, and any responsible-user or approval checks.
|
||||
|
||||
## Connection administration (2026-10-13)
|
||||
|
||||
The five default tool grants are usable through the existing connection APIs and default native `search_api`/`call_api`. A fresh standard agent for a normally provisioned Operator can configure app/tool, chat/email and AI connections, profiles and gateways without additional agent grants. Gateway management uses the agent's `tools:manage_connections` grant and still requires `tools:admin` on the responsible human. It does not grant blanket agent administration.
|
||||
|
||||
Connection management always intersects the current human's authority, including in responsible-user shadow mode. Personal self-service remains available where the human setup surface permits it; sharing and assignments require authority over their targets. Current run/work-mode/trust restrictions, scoped grants and explicit grant removal remain effective. No existing-agent permissions are backfilled. Agent-issued gateway clients preserve this delegation at every use, even after the setup run ends.
|
||||
|
||||
See [the authorization and qualification record](plans/2026-10-13-connection-api-parity.md) and the operation-reviewed [agent access maps](../maps/agent-access-map/connection-setup.md). Human consent, credential entry and identity confirmation remain bounded handoffs to saved setup.
|
||||
|
||||
Authorized policy administration includes user-requested changes that loosen the acting agent’s own tool controls. These writes remain bounded by management grants, resource scope and the responsible user’s current authority; they do not need an additional approval solely because the agent is a target. Subsequent tool calls obey the resulting policy. Agents cannot approve their own governed action requests or edit their own principal permission grants.
|
||||
@@ -1,6 +1,24 @@
|
||||
# Copilot 1.0.88 rich ACP capability audit
|
||||
|
||||
Current integration (2026-10-17): **Copilot profile v21 remains pending** with
|
||||
Current source declaration (2026-10-26): **Copilot profile v61** binds controller
|
||||
journal retirement and the historical profile decoder. Its digest is
|
||||
`sha256:956276ebee59f880249fa6955adc224af9ca7b6a9da59bcc5da4a8667550c5b3`.
|
||||
The v60 declaration remains unchanged and readable as history. This source
|
||||
identity update changes no Copilot executable, arguments, permission policy,
|
||||
tool receipts, or qualification flags. Offline compatibility checks supply no
|
||||
new live qualification; prior live outcomes retain their original identities.
|
||||
|
||||
Historical source declaration (2026-10-26): **Copilot profile v60** binds the shared
|
||||
sources that carry Codex's explicit hosted-search restriction. Its digest is
|
||||
`sha256:ca3c2b38ed5f6e25cf1c96c1f73781a86cc90c1c0add93f64716c66f28b3f3aa`.
|
||||
The v59 declaration remains unchanged and readable as history. Exact-current
|
||||
launch admission still applies. The Copilot executable, arguments, permissions,
|
||||
receipts, and qualification flags are unchanged. Copilot authentication probes
|
||||
do not carry the Codex-only setting. Offline compatibility checks do not supply
|
||||
new live qualification; prior live outcomes remain attached to their original
|
||||
source identities.
|
||||
|
||||
Historical integration (2026-10-17): **Copilot profile v21 remained pending** with
|
||||
digest `sha256:1dad3d26d91e66c6f911dbfa43e7963f36c0bbf0d7709f4bbb568029a8b4b46a`.
|
||||
It binds the shared provider completion contract, direct driver, production sidecar, and controller
|
||||
continuation resolver. Installation verification requires this generated v21
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# Chat milestone query performance
|
||||
|
||||
The milestone selector first materializes runs that match an active or waiting
|
||||
conversation in the same company. It then applies the existing publication
|
||||
origin, continuation, status and final-answer checks. This prevents PostgreSQL
|
||||
from evaluating expensive JSON lineage predicates across unrelated
|
||||
run/conversation pairs.
|
||||
|
||||
The change preserves `boundRunIssueIdSql()` and all publication authorization,
|
||||
idempotency, ordering and pagination logic. It adds no migration or index. The
|
||||
one-second recovery cadence, startup reconciliation, commit signals, shutdown
|
||||
and native progress selector are unchanged. The separate lifecycle scheduling
|
||||
work in [PR #17606](https://github.com/paperclipai/paperclip/pull/17606) is outside
|
||||
this change.
|
||||
|
||||
## Reproduce the measurements
|
||||
|
||||
Run from the repository root with Docker available:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-chat-milestones.ts --baseline-ref=db70c5acd92f50fa37fb4f0dea3da0e7685089a2 --docker-postgres=17 --report=/tmp/chat-milestones-report.json
|
||||
pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-chat-milestones.ts --baseline-ref=db70c5acd92f50fa37fb4f0dea3da0e7685089a2 --docker-postgres=17 --dense-chat --report=/tmp/chat-milestones-dense-report.json
|
||||
pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-chat-milestones.ts --baseline-ref=db70c5acd92f50fa37fb4f0dea3da0e7685089a2 --docker-postgres=17 --cursor-window --report=/tmp/chat-milestones-large-window-report.json
|
||||
```
|
||||
|
||||
Without `--docker-postgres=17`, the harness uses disposable embedded PostgreSQL.
|
||||
Neither mode uses an existing application database. The Docker mode starts a
|
||||
container on an ephemeral localhost port, applies the repository migrations and
|
||||
removes the container after the run.
|
||||
|
||||
The harness imports the baseline service from the pinned commit and captures
|
||||
the complete SQL and parameters generated by both real service implementations.
|
||||
It stops before publication writes. The report includes full positional result
|
||||
equality, all samples, plans, database version/settings, source hashes and the
|
||||
treatment diff. A detached, uncommitted treatment is identified by its file
|
||||
hashes and diff in addition to its HEAD.
|
||||
|
||||
## Local results
|
||||
|
||||
Measured on PostgreSQL 17.11 with 128 MB shared buffers and 4 MB work memory.
|
||||
The synthetic fixture contains 2,615 runs, 55 conversation issues and contexts
|
||||
containing a base64 string made from 144 KiB of pseudorandom bytes, approximately
|
||||
192 KiB of text. Each query has two warmup passes and five measured passes;
|
||||
baseline and treatment order alternates. These are local SQL measurements,
|
||||
excluding provider delivery and application processing.
|
||||
|
||||
| Fixture and query | Baseline median, range | Treatment median, range | Complete ordered rows |
|
||||
| --- | --- | --- | --- |
|
||||
| Selective bindings: milestones | 1,496 ms, 1,483–1,716 | 131 ms, 125.59–135.85 | 60 equal |
|
||||
| Selective bindings: native progress control | 17.15 ms, 16.35–18.38 | 17.43 ms, 16.77–19.78 | 41 equal |
|
||||
| Dense bindings: milestones | 737 ms, 629–1,291 | 809 ms, 463–1,288 | 200 equal |
|
||||
| Dense bindings: native progress control | 23.93 ms, 22.72–25.47 | 24.33 ms, 20.33–29.37 | 41 equal |
|
||||
|
||||
The selective fixture improved by **11.42×**. Its original merge join discarded
|
||||
60,962 run/conversation pairs through a complex lineage filter. The treatment
|
||||
materialized 101 matching runs first and discarded no pairs through that join
|
||||
filter. Shared-hit blocks fell from 3,276,955 to 64,903. Neither plan spilled to
|
||||
temporary files.
|
||||
|
||||
The dense control binds about 2,500 formerly unrelated history rows to active
|
||||
conversations through the first-class issue column, preserving existing native
|
||||
progress receipts. It materializes 1,768 runs. Its treatment median was about
|
||||
10% slower, with heavily overlapping sample ranges. A subsequent standalone
|
||||
EXPLAIN also varied substantially. **Dense performance is inconclusive, with
|
||||
possible materialization overhead; it does not establish a speedup.** Both
|
||||
native progress controls are neutral.
|
||||
|
||||
The gain depends on binding selectivity and the database plan. These
|
||||
measurements do not establish a universal speedup, improved request latency or
|
||||
deployed database cost. Compare fresh production query-statistics windows after
|
||||
any separately authorized rollout.
|
||||
|
||||
## Large-window cursor control
|
||||
|
||||
The `--cursor-window` control uses the same service implementation with 12,215
|
||||
runs, including 12,000 recent native runs with first-class task bindings and
|
||||
approximately 8 KiB encoded contexts. It requests a limit of one; the service's
|
||||
minimum candidate page size is 25. The fixture adds a second conversation for
|
||||
the same task so both late nonempty pages must preserve the other conversation
|
||||
on the cursor's own run.
|
||||
|
||||
Every statement is captured from `enqueueChatRunMilestones`, including its
|
||||
actual second-page cursor. Capture advances the real keyset loop with a
|
||||
non-publishable placeholder page and stops before any publication write. The
|
||||
finished cursor comes from the last actual candidate on the short final page.
|
||||
All statements then run against the real PostgreSQL fixture, with two warmups
|
||||
and five alternating measurements.
|
||||
|
||||
| Service page | Baseline median, range | Treatment median, range | Complete ordered rows |
|
||||
| --- | --- | --- | --- |
|
||||
| First page | 762.65 ms, 678.43–807.93 | 800.63 ms, 597.38–838.01 | 25 equal |
|
||||
| Cursor at 90% of history | 234.84 ms, 226.42–241.19 | 54.36 ms, 53.39–63.62 | 25 equal |
|
||||
| Cursor at 99.99% of history | 352.10 ms, 321.27–377.10 | 12.88 ms, 12.13–29.92 | 7 equal |
|
||||
| Cursor after the final candidate | 340.00 ms, 324.40–381.30 | 58.58 ms, 56.55–78.92 | 0 equal |
|
||||
|
||||
The first page is neutral/inconclusive; its sample ranges overlap. Later pages
|
||||
improved by 4.32×, 27.33× and 5.80× respectively. The unchanged native progress
|
||||
control was neutral at 22.79 versus 22.72 ms, with 25 equal rows.
|
||||
|
||||
The treatment still materializes 12,101 matching runs before its outer cursor
|
||||
filter. The materialization node took 4.6–10.0 ms in these plans, and no measured
|
||||
page spilled temporary data. These observations do not measure peak memory or
|
||||
prove how every JSON value is stored or decompressed. They do show that this
|
||||
bounded, native-heavy fixture did not reproduce the feared large-window cursor
|
||||
regression. No identity-only rewrite or additional cursor predicate was needed
|
||||
for this change. The dense and first-page performance limits remain disclosed.
|
||||
|
||||
## Correctness and blast radius
|
||||
|
||||
The safety condition is that the new prefilter repeats a predicate already
|
||||
required by the existing conversation join. It uses the same company,
|
||||
conversation-state and native/legacy binding expression within the same SQL
|
||||
statement. All authoritative checks still run afterward.
|
||||
|
||||
The selective benchmark compares every returned column in exact order and
|
||||
checks 15 explicit historical binding cases. These include whitespace trimming,
|
||||
task-ID fallback, numeric/object/array JSON values, uppercase and malformed
|
||||
identifiers, native and first-class issue precedence, cross-company values and
|
||||
null/scalar/array contexts. Dense results compare the complete first 200 ordered
|
||||
rows; they do not claim that all history fits on that page.
|
||||
|
||||
Migration 0313's insert trigger normally normalizes recognized JSON identifiers
|
||||
into the first-class issue binding. The fixture disables that trigger only
|
||||
while inserting historical row shapes in its disposable database and restores
|
||||
it in `finally`. Runtime triggers are unchanged.
|
||||
|
||||
Run the dedicated regression and existing coordination suites:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/server exec vitest run src/__tests__/chat-run-milestone-candidates.test.ts src/services/chat-publication-reconciliation.test.ts src/__tests__/app-private-hostname-gate.test.ts
|
||||
```
|
||||
|
||||
These three suites passed **27 tests** during validation, with a disposable
|
||||
PostgreSQL 17 constructor override for the database test.
|
||||
|
||||
The service integration checks cover bounded draining past rejected
|
||||
candidates, exact continuation origins, private summaries, explicit final-answer
|
||||
precedence, idempotent lifecycle updates, ownership-attention messages and native
|
||||
progress reauthorization after a question, final answer or revoked reach wins.
|
||||
The relevant cases live in `server/src/__tests__/chat-channels.integration.test.ts`.
|
||||
These checks prove service behavior using synthetic provider runtimes; they do
|
||||
not prove live provider delivery or deployed sleep/wake behavior.
|
||||
|
||||
The following selection passed **37 tests** with disposable PostgreSQL 17;
|
||||
1,529 unrelated cases were skipped:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/server exec vitest run src/__tests__/chat-channels.integration.test.ts \
|
||||
-t "drains bounded Slack chat-origin milestones|coalesces one Slack run's lifecycle|edits a Slack working reply|publishes a Telegram failure for an exact confirmation continuation|replaces working with one safe ownership-attention|does not let a late|keeps an internal Telegram run summary private|publishes only a resolver-selected Telegram reply|settles an interrupted native run once|preserves an interrupted run's selected final|skips a locked native progress|reauthorizes skipped native progress|coalesces closed native progress|publishes native progress with a typed issue binding|enforces the native progress cadence|does not replay stale native progress|keeps native progress behind question|reuses a pre-run FIFO notice|link-only interaction continuation|successful GitHub link-question continuation|publishes a closed-choice question, settles its Slack card"
|
||||
```
|
||||
@@ -0,0 +1,70 @@
|
||||
# Cloud membership synchronization
|
||||
|
||||
Trusted Cloud requests synchronize the user's profile, company membership and
|
||||
default grants before they resolve authority. Synchronization writes are shared
|
||||
only within one database client and process. Requests with different contexts
|
||||
for the same user execute in order. An A, B, A sequence still applies all three
|
||||
contexts. Other users can proceed independently.
|
||||
|
||||
Successful identical contexts retain the existing five-second write debounce.
|
||||
Failed or partial synchronization invalidates the prior success, so a retry can
|
||||
repair it. Membership reads, owner-elevation settings and stale administrator
|
||||
removal remain fresh on each request. This cache does not store actors.
|
||||
|
||||
Guarded upserts preserve `updated_at` when profile fields, role and status do not
|
||||
change. They still repair missing default grants. Access lists now sort these
|
||||
rows by their last actual change. Profiles remain reachable beyond the first
|
||||
200 memberships: lookup uses the existing company/principal index for an exact
|
||||
principal ID, then at most one company-scoped alias query returning one row.
|
||||
An exact ID takes precedence over another member's name, matching the UI's
|
||||
stable-ID links. Name, email and normalized non-UUID ID aliases retain their
|
||||
newest-membership-first ordering. A missing slug performs two queries, with no
|
||||
membership paging. The fallback still scans company candidates; it does not
|
||||
add an expression index or claim constant database CPU at every company size.
|
||||
|
||||
## Reproduce the local comparison
|
||||
|
||||
From the repository root, use a disposable PostgreSQL 17 container:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-cloud-membership-sync.ts \
|
||||
--baseline-ref=db70c5acd92f50fa37fb4f0dea3da0e7685089a2 --docker-postgres=17 \
|
||||
> /tmp/cloud-membership-sync.json
|
||||
```
|
||||
|
||||
The harness imports the actual resolvers, applies normal migrations, seeds
|
||||
synthetic data and removes its container. It does not use `DATABASE_URL`.
|
||||
Two warmup pairs precede five alternating measured pairs. Each pair uses a
|
||||
fresh synchronization scope and 20 simultaneous requests for one unchanged
|
||||
user. Row triggers count updates, including updates that preserve values.
|
||||
|
||||
PostgreSQL 17.11 measured a median 51.07 ms before and 25.71 ms after. The ranges
|
||||
were 47.21–63.25 ms and 24.17–29.32 ms. Membership statements fell from 20 to one,
|
||||
and profile plus membership tuple updates fell from 40 to zero. All 20 complete
|
||||
actor results matched on every sample. This measures local service work and
|
||||
authority reads. It does not establish production request latency or remove
|
||||
locks across replicas.
|
||||
|
||||
## Safety checks
|
||||
|
||||
Run the Cloud actor, Cloud transient database retry, company provisioning,
|
||||
Cloud synchronization PostgreSQL and user profile route suites. They cover
|
||||
coalescing, A/B/A transitions, failed partial writes, expiry, distinct users and
|
||||
databases, grant repair, real role-change invalidation, imported-membership
|
||||
revocation, owner-flag changes and cross-company denial. The profile regression
|
||||
first returned 404 with the former 200-row lookup. The fixture now contains
|
||||
601 memberships with timestamps differing within one millisecond. It checks
|
||||
raw and normalized non-UUID IDs, a missing slug's two-statement bound, tenant
|
||||
isolation, exact-ID priority and fallback ordering. Real PostgreSQL requests
|
||||
compare all four alias fields against the route's own JavaScript canonical
|
||||
slug output, including quotes, whitespace, punctuation, Unicode and email edge
|
||||
cases. An exhaustive Unicode lowercase check verifies the two non-ASCII
|
||||
characters that need explicit ASCII mappings in the SQL predicate, avoiding
|
||||
database-locale differences. Whole-character replacements precede an
|
||||
ASCII-only `translate`, preserving UTF-8 byte sequences on legacy `SQL_ASCII`
|
||||
databases. A disposable `SQL_ASCII` database applies normal migrations and
|
||||
exercises all four alias fields with en/em dashes, euro signs, neighboring
|
||||
multibyte characters, dotted I and Kelvin signs. It also verifies that a euro
|
||||
sign cannot incorrectly match the profile alias `k`.
|
||||
|
||||
No schema migration, startup work or deployment is required by this change.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Company deletion with chat endpoints
|
||||
|
||||
Company deletion must remove chat endpoints before deleting their referenced issues or assigned agents.
|
||||
The endpoint-to-agent foreign key uses RESTRICT, so waiting for the final company
|
||||
cascade fails while deleting agents. This was reproduced by the Discord scheduling
|
||||
producer test and by the owning company-removal suite.
|
||||
|
||||
The chat owner participates in the existing company-deletion transaction. It
|
||||
removes only the requested company's endpoint rows before issue deletion and before the agent lifecycle
|
||||
participant runs. Existing endpoint foreign keys remove their dependent records.
|
||||
The lifecycle owner still requires completed agent termination. The transaction
|
||||
still rolls back all SQL cleanup if a later participant fails. No external calls,
|
||||
new scheduler, schema change, or relaxed constraint is introduced.
|
||||
|
||||
Discord command ownership tombstones have no cascading foreign keys and remain
|
||||
unchanged. Deletion does not authorize a new endpoint to adopt an old application.
|
||||
Other retained-history constraints, including voice-session restrictions, still
|
||||
apply; this fix does not bypass them or change external provider teardown policy.
|
||||
|
||||
Both new PostgreSQL regressions failed before the change. All six cleanup-removal
|
||||
cases pass afterward, including deletion ordering, another company's untouched
|
||||
endpoint, preserved namespace ownership, and rollback after a later failure.
|
||||
The suite accepts a validated dedicated external PostgreSQL test database because
|
||||
embedded PostgreSQL cannot start on this host. Server typecheck/build and remote
|
||||
CI/review are tracked separately; this is not evidence of deployed host sleep.
|
||||
|
||||
|
||||
Review expanded the fixture to include a conversation and publication linked to
|
||||
an issue. Both regressions then failed because cleanup ran after issue deletion.
|
||||
The chat participant now runs immediately after the company row is locked, before
|
||||
any referenced issue or agent is removed. The same transaction preserves history
|
||||
on rollback. This supplements the original endpoint-only proof; it does not claim
|
||||
to remove existing voice-session restrictions.
|
||||
|
||||
## Current-parent acceptance, October 27
|
||||
|
||||
This slice normally integrates health parent #17554 at
|
||||
`8c1d293d1ae2b7db753ab20f5a2a2edec1fb0a62`. Only the four company-cleanup
|
||||
files differ from that parent; no database, workflow, or provider change is added.
|
||||
The parent must land before this prerequisite can merge into master.
|
||||
|
||||
The remaining Ponytail finding reproduced on an initially empty dedicated
|
||||
PostgreSQL 17 database: the real endpoint-deletion case and its cleanup failed
|
||||
with missing-table errors. The external fixture now applies pending migrations
|
||||
before creating its client and uses the shared 90-second embedded-bootstrap setup budget. The six complete
|
||||
cleanup-removal cases pass on that same initially empty database. Test teardown
|
||||
also deletes the ownership tombstones it creates; an independent query after
|
||||
the complete suite finds zero rows. This fixture cleanup does not change the
|
||||
production rule that company deletion preserves those tombstones.
|
||||
|
||||
The failed fresh-database run remains part of the evidence. Server typecheck and
|
||||
build pass with this repair. Fresh current-head CI/reviews remain separate gates. No full local
|
||||
repository-suite or deployed suspension claim is made.
|
||||
|
||||
The final review identified that the previously suggested 30-second setup limit
|
||||
was below the repository's documented bootstrap budget under runner contention.
|
||||
The fixture now imports and uses EMBEDDED_POSTGRES_TEST_TIMEOUT_MS. It changes no
|
||||
assertion or production behavior. The earlier fresh-database failure and six-case
|
||||
acceptance remain recorded; the shared timeout addresses documented setup cost,
|
||||
not a reproduced production defect or a claim of measured CI contention here.
|
||||
@@ -59,6 +59,12 @@ the selected connection owns its provider routing.
|
||||
Changing those separately may make a binding incompatible; saving then requires
|
||||
a compatible choice. Agent configuration cannot grant access to another account.
|
||||
|
||||
Agent account choices and legacy-adoption confirmations show the sign-in method,
|
||||
personal or company-shared scope, and available account or owner identity beside
|
||||
the connection name. Accounts with the same provider name can be distinguished
|
||||
before selection. These labels use existing authorized account metadata, never
|
||||
credentials or authentication paths.
|
||||
|
||||
Personal defaults are unique per company, user, and provider. A Claude bot can use one user’s subscription and another user’s API key without changing its harness or model. Explicit shared selections remain pinned to the selected account and method.
|
||||
The first successful personal connection sets a default only when none exists.
|
||||
The additive `ai_provider_defaults` table preserves the legacy per-method preferences. Migration selects each user’s most recently updated provider preference (including unavailable accounts), and rerunning it never overwrites a provider default. New writes maintain the legacy table for older servers. A database trigger propagates older servers’ explicit default updates to the provider default. Inserting an additional method default does not replace an existing provider default.
|
||||
|
||||
@@ -764,6 +764,17 @@ IDs to an auditable profile, and leaves **Ask first** available for any action.
|
||||
The open default changes the initial policy; it does not create a route around a
|
||||
policy the operator has applied.
|
||||
|
||||
Saving a connection's installation list grants its app profile only to newly
|
||||
added targets. Targets that remain installed keep their existing profile
|
||||
assignments. If an operator removes an app profile from an installed agent or
|
||||
company, a later installation-list save must preserve that removal. This also
|
||||
applies when the list is unchanged or another agent is removed. Removing and
|
||||
then reinstalling a target is a new installation and uses the current setup
|
||||
defaults.
|
||||
Every non-empty installation-list save also removes legacy connection-wide
|
||||
include entries from the managed app profile. Keep this cleanup when no targets
|
||||
are added, so the selected action entries remain authoritative.
|
||||
|
||||
If a destructive provider cannot be safe with those defaults, add a narrowly
|
||||
reviewed provider policy with tests. Do not hide a dangerous tool by
|
||||
misclassifying it as read, and do not silently change global defaults in a
|
||||
|
||||
@@ -134,6 +134,13 @@ choose an active sponsor, and use the restricted guest profile. Guests receive n
|
||||
sponsor credentials. Authority is checked again before tool calls and
|
||||
publication, so revocation also affects queued or ongoing work.
|
||||
|
||||
The username lookup uses the bot's own GitHub App installation with a short-lived,
|
||||
metadata-only token. It does not use a personal account or the shared unauthenticated
|
||||
IP quota. An active connection with a verified installation is required. GitHub
|
||||
supports installation tokens for [Get a user](https://docs.github.com/en/rest/users/users#get-a-user)
|
||||
without additional permissions. Lookup errors distinguish unavailable accounts,
|
||||
rate limits, and installation authentication or access failures.
|
||||
|
||||
Choose automatic events and author filters in **Settings** to control which
|
||||
authored PRs and issues start work. Authorized mentions remain available with
|
||||
automatic events off and bypass automatic author filters. The shared external-
|
||||
@@ -169,6 +176,19 @@ permissions. Ordinary discussion does not change a review score. Repeat review
|
||||
mentions and pushes continue the existing task; inline replies return to the
|
||||
task owning that thread.
|
||||
|
||||
A successful native execution can still be resolving its visible response.
|
||||
Without a confirmed reply for that run, maintenance waits for its committed
|
||||
presentation decision before choosing a completion fallback. It then uses the same saved request to settle the wait,
|
||||
publish a genuine missing-response notice, or report a failed turn.
|
||||
|
||||
A queued or running follow-up acknowledgement preserves an existing App-owned
|
||||
final PR summary. A new validated assessment can replace it through the review
|
||||
publication path. When an authorized native turn commits a passive response
|
||||
wait with no new comment, maintenance settles that request's acknowledgement
|
||||
without replacing the assessment with a generic completion notice. This does
|
||||
not count the old summary as a reply from the new run. Missing or failed
|
||||
responses still receive their normal fallback; uncertain writes remain held.
|
||||
|
||||
Event prompts supplement the agent's instructions. Repository content and PR
|
||||
prose are untrusted input and cannot change tool authority or publication policy.
|
||||
The execution records the configuration revision and event context used.
|
||||
@@ -249,9 +269,15 @@ the receipt.
|
||||
|
||||
Receipt recovery compares the complete saved publication request, including long
|
||||
text and nested coverage entries. Shortened audit previews do not establish
|
||||
request identity. Changed content or a missing durable publication returns a
|
||||
conflict without dispatching another write. Exact retries read the current
|
||||
receipt; the durable worker owns pending publication retries.
|
||||
request identity. Changed content or a missing completed durable publication
|
||||
returns a conflict without dispatching another write. Exact retries read the
|
||||
current receipt; the durable worker owns pending publication retries.
|
||||
|
||||
A timeout during metadata or file reads can occur before publication staging.
|
||||
The same request can repeat this preflight with its complete request identity.
|
||||
The saved assessment, unique publication key, and provider lease still prevent
|
||||
repeat external writes. `begin_review` retries return the run's current review
|
||||
result. They do not return a shortened audit preview.
|
||||
|
||||
For a call that required human approval, the durable publication keeps that
|
||||
verified approval for its exact operation. An exact caller retry only reads the
|
||||
|
||||
@@ -329,6 +329,36 @@ another recipient is named. The routing rule does not change automatic issue or
|
||||
event policies. Archived bots and identities from other companies or providers
|
||||
are excluded from the routing lookup.
|
||||
|
||||
## Retrying a saved request
|
||||
|
||||
A normal task Retry can continue a newer saved GitHub message after an earlier
|
||||
attempt failed. Execution revalidates the exact admitted successor, its current
|
||||
linked principal and repository access, and the durable continuation receipt;
|
||||
it does not repeat the admission that already consumed the recovery hold.
|
||||
A failed native predecessor still requires its committed failed result and
|
||||
exact executor termination proof. Successful results and uncertain delivery do
|
||||
not grant a replay. A certified failed-chat Retry retains the exact accepted
|
||||
message identity when its predecessor stopped before identity initialization,
|
||||
so a published review can expose its authorized passive response wait.
|
||||
If an inherited task-resume attempt already recorded a result, ordinary Retry
|
||||
explains that exact replay is unavailable and directs the user to send a new
|
||||
message to continue from saved work. That new turn still passes normal current
|
||||
requester, access and executor-containment checks.
|
||||
|
||||
If that successor failed at Retry authorization before runtime or provider
|
||||
startup, the next normal Retry follows its canonical source receipt. The exact
|
||||
system diagnostic, settlement events, empty execution evidence and absence of
|
||||
allocated resources establish that narrow recovery path. A generic setup
|
||||
failure or a claimed `providerWorkStarted: false` does not establish it.
|
||||
|
||||
The canonical task Retry also recognizes an isolated-directory binding failure
|
||||
at the workspace preparation boundary before allocation. Its structured reason
|
||||
and task binding, trusted failed settlement, dispatch-only profile and absence
|
||||
of execution resources must all agree. Other workspace validation failures do
|
||||
not inherit this permission. Current request authorization still runs at both
|
||||
admission and execution; a failed unadmitted Retry intent does not replace the
|
||||
saved request or prevent a later deliberate Retry.
|
||||
|
||||
## Run projection
|
||||
|
||||
The resolved token is leased at run start as an audited class-3 secret and is
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Paperclip evaluation guide
|
||||
|
||||
The explicit-only [chat feature evals](../tests/runner-e2e/CHAT-FEATURES.md) promote
|
||||
two natural-language task scenarios into local native Product E2E cells. They
|
||||
verify saving an unstarted task and delegating a saved announcement back to chat,
|
||||
using ordinary production instructions, real browser messages and independent
|
||||
state/provenance checks. The broader YAML catalog remains planning inventory.
|
||||
|
||||
The explicit-only [Daytona restart continuity journey](../tests/runner-e2e/README.md)
|
||||
extends the existing native restart oracle to a 125-second controller outage,
|
||||
using the recorded Claude and Codex models in disposable development fixtures.
|
||||
@@ -84,6 +90,12 @@ behavior.
|
||||
grading oracle. See [`tests/runner-e2e`](../tests/runner-e2e/README.md) and
|
||||
[Everyday Workflows](../tests/runner-e2e/EVERYDAY-WORKFLOWS.md).
|
||||
|
||||
The [chat scenario catalog](../tests/runner-e2e/chat-scenarios/README.md) is a
|
||||
planning inventory within Product E2E: natural user requests, assumed context and
|
||||
expected outcomes linked to the feature and agent-access maps. It includes
|
||||
conditional setup, permission, approval and recovery journeys. Its YAML files
|
||||
are not registered runnable cells and contribute no passing coverage or scores.
|
||||
|
||||
Runner Evals answer whether a real runner/provider can perform a bounded
|
||||
protocol operation against the expected control-plane contract. Product E2E
|
||||
Evals answer whether a person can complete a product workflow through the real
|
||||
|
||||
@@ -160,3 +160,55 @@ these company-scoped records when their company is deleted. Run-scoped adapters
|
||||
Native warm sessions retain their token-free launchers and inactive broker until
|
||||
session retirement; environment deletion and orderly controller shutdown close
|
||||
idle owners first. Cleanup failures are logged and do not change the run result.
|
||||
|
||||
## Project authorization for background repository work
|
||||
|
||||
Background provisioning and workspace maintenance have no responsible task user.
|
||||
They must not retain a task's identity. A signed-in project workspace writer can
|
||||
select their own active, company-installed GitHub Connector grant in project
|
||||
settings and explicitly authorize the project. The binding stores the exact grant
|
||||
ID and consenting user, never a credential. Revocation records a project lifecycle
|
||||
event so subscribed providers can reconcile the change.
|
||||
|
||||
`GET /api/projects/:id/git-authorization` returns the current binding and only the
|
||||
viewer's eligible personal grants. `PUT` accepts `{ grantId }` or `{ grantId: null }`.
|
||||
Agents cannot create this standing consent. Project workspace writers can remove it.
|
||||
Core rechecks membership, Connector installation/enablement, grant status, and
|
||||
repository metadata on each background use. Public repositories never require
|
||||
a connection or credential. Private repositories wait for authorization before
|
||||
provisioning starts, so missing consent cannot leave a failed anonymous clone.
|
||||
Repository selections retain the same visibility metadata used by the UI lock.
|
||||
Legacy workspaces use existing Connector catalog metadata, with anonymous
|
||||
GitHub discovery only when visibility is unknown. Unknown visibility defers
|
||||
provisioning; it is never assumed public. It never substitutes another grant
|
||||
when the selected one fails. Replacing a grant requires explicit reauthorization.
|
||||
Project consent follows the selected connection's current GitHub repository access,
|
||||
including repositories added to the project later. It does not create a second
|
||||
repository approval list. Project repository editors may add sources within that
|
||||
connection's access; manage permitted repositories in the GitHub connection.
|
||||
Background export also rechecks the consenting user's project read access and the
|
||||
project's existing trust policy. Low-trust or unverified projects cannot export a
|
||||
raw credential through standing consent.
|
||||
Cloud owner access is rechecked through current Cloud authority and the managed
|
||||
owner-elevation setting on every private background export; saved local admin
|
||||
roles cannot replace that authority. An unavailable authority check cannot confer
|
||||
owner access, though explicit project membership still applies.
|
||||
|
||||
The trusted plugin capability `projects.git-credentials` enables
|
||||
`ctx.projects.resolveGitCredential(projectId, workspaceId, companyId)`. The host
|
||||
binds requests to the active project lifecycle or workspace operation; data, UI,
|
||||
tool, and proactive calls do not carry this authority. Repository URLs come from
|
||||
stored project workspaces. Foreground preparation uses the live run's accepted
|
||||
identity and standard trust checks, even when background project consent exists.
|
||||
Maintenance ignores originating run/agent context and uses project consent only.
|
||||
After archival, existing consent remains usable only for host-issued workspace
|
||||
inspection and removal. New provisioning and new consent remain blocked.
|
||||
|
||||
The provider receives a transient GitHub token with its existing provider
|
||||
permissions. This is not a repository-scoped or read-only token minting API.
|
||||
Only grant-listed repositories can trigger background export. Plugins must never
|
||||
save tokens in entities, state, configuration, operation receipts, or logs, and
|
||||
must send them only to trusted execution infrastructure over HTTPS. Revocation
|
||||
prevents subsequent credential acquisition; it cannot recall a token already
|
||||
sent to an in-flight operation. Existing tasks and Git credential fallback rules
|
||||
outside this plugin API are unchanged.
|
||||
+390
-13
@@ -19,6 +19,27 @@ Paperclip separates four concepts that are easy to blur together:
|
||||
|
||||
The system works best when those are kept separate.
|
||||
|
||||
### Onboarding first-task creation
|
||||
|
||||
When an authenticated agent run serves the server-created `onboarding_first_task`,
|
||||
new task creation requires acceptance of its latest ordinary proposal. The same
|
||||
transactional admission applies to native `create_task` and REST issue/child
|
||||
creation. Choosing a different parent, including a standalone task, does not
|
||||
change the run's source task.
|
||||
|
||||
Acceptance comes from a saved human card decision or a saved conversational
|
||||
resolution linked to a genuine later user comment on that task. Opening answers
|
||||
and clarification responses alone, agent-only decisions, and tool/credential
|
||||
approvals do not authorize the proposal. A newer pending or rejected proposal supersedes an
|
||||
older acceptance. A saved plan requires approval of its current document revision;
|
||||
checkbox decisions retain the user's actual selected scope.
|
||||
|
||||
Planning documents, questions and proposal cards remain available before approval.
|
||||
Ordinary task delegation and previously committed task-creation replay retain
|
||||
their existing behavior, including dispatch recovery for already admitted work.
|
||||
This boundary governs new task creation; other execution and resource authorities
|
||||
continue to apply independently.
|
||||
|
||||
## 2. Assignee Semantics
|
||||
|
||||
An issue has at most one assignee.
|
||||
@@ -305,6 +326,16 @@ continues automatically when the workspace is available. Internal scheduling
|
||||
attempts remain in the run log without conversation cancellation markers,
|
||||
cancellation toasts, or manual Retry controls. Users can keep sending instructions.
|
||||
|
||||
An instance admission drain keeps accepted new turns queued and displays
|
||||
**Waiting for maintenance**. Ending the audited drain requests admission for
|
||||
these existing runs immediately. The shared delivery coordinator owns the drain's
|
||||
actual expiry deadline and cancels it on shutdown; expiry also wakes admission.
|
||||
Neither path waits for a general recovery scan or creates a new wake
|
||||
request. The ordinary claim and dispatch checks still enforce company status,
|
||||
identity, budgets, pauses, ownership, and concurrency. A restarted server restores
|
||||
queued work through startup recovery. A failed admission request leaves its
|
||||
durable rows available for existing recovery.
|
||||
|
||||
The legacy remote ACP process-session relay runs on the control-plane host. Its
|
||||
launch command uses the host's absolute Node executable even when the adapter's
|
||||
launch environment is sanitized for a remote sandbox; the sandbox PATH remains
|
||||
@@ -521,13 +552,25 @@ of the invalid call. Unauthorized tools still receive a generic rejection, and
|
||||
corrected calls retain the same permission and approval checks.
|
||||
|
||||
A native provider may omit `continuation` (or send `null`) when it reports
|
||||
`yielded` after saving a monitor or requesting repository preparation. The controller reads current authorized task
|
||||
`yielded` after saving a monitor, requesting repository preparation, or publishing
|
||||
an authorized current-head GitHub review. The controller reads current authorized task
|
||||
context and fills the canonical continuation only if that same task,
|
||||
run, assignee, and work mode remain eligible. An eligible monitor takes precedence.
|
||||
A pending repository requested by that task in its current workspace offers
|
||||
`same_agent`; the existing status arbiter and wake queue own the next admission.
|
||||
Prepared repositories and requests belonging to another task cannot offer it.
|
||||
An explicit `done` result does not acquire a continuation. Completion admission
|
||||
A completed GitHub review with its processed publication may offer `response_wake`
|
||||
for that exact head and accepted requester under current conversation and
|
||||
connection authority. A normal tree resume may inherit the published review
|
||||
through its accepted parent identity. An ordinary GitHub follow-up may inherit it
|
||||
only after this run has a processed useful reply bound to its exact current
|
||||
inbound comment; acknowledgements, pending or ambiguous writes and partial
|
||||
assessment receipts do not suffice. Completion admission, finalization and
|
||||
status commit recheck the server-issued run/review/head binding; a pending
|
||||
publication, newer unreviewed head or revoked access cannot fall through to an
|
||||
immediate continuation. An explicit malformed continuation remains invalid.
|
||||
An explicit `done` result does not acquire a continuation or change its completion
|
||||
policy; review scores are not interpreted as task-completion instructions. Completion admission
|
||||
checks that persisted monitor again. This default does not create or renew a
|
||||
schedule. A consumed, cleared, exhausted, or ineligible monitor cannot authorize
|
||||
a passive wait. An explicit `response_wake` still identifies a real response
|
||||
@@ -589,7 +632,7 @@ A board comment can be an interrupt, an ownership change, both, or neither. Pape
|
||||
|
||||
An interrupt stops the current live execution path for the issue. It does not, by itself, select the next owner. If an active run is interrupted by the board, the run may still terminate with the underlying `cancelled` status, but the issue activity and wake context should make the operator intent visible as an interruption rather than an unexplained runtime failure.
|
||||
|
||||
For legacy runners, **Interrupt** on a queued message stops the active run and explicitly continues the pending queue after execution cleanup. It validates the queue revision and target run, then dispatches the requested queue’s current message bodies in their saved order. Other actors’ queues cannot consume that interrupt. The persisted interrupt intent is retried by the scheduler after a promotion error or server restart until that queue is dispatched or discarded. Edits and discards remain authoritative until dispatch; deleting the final message must not create an empty continuation. Pending messages remain visible after a run stops. Cancelling only the run preserves the queue for a later explicit wake; pausing the task retains its separate queue-cancellation behavior. Native same-turn steering keeps its separate acknowledgement protocol. After an acknowledged native Stop, a follow-up saved before the stopped run finished remains pending. It cannot authorize an automatic new turn. A later user message can authorize one continuation that retains the earlier undelivered input. Closing the stopped execution coordinator also wakes the existing accounting worker in the same transaction. Complete usage can settle once; missing or partial receipts retain their accounting hold. Legacy Codex uses Ctrl-C to stop its tool sessions and cannot retry a missing-session fallback after the provider has confirmed that the session started.
|
||||
For legacy runners, **Interrupt** on a queued message stops the active run and explicitly continues the pending queue after execution cleanup. It validates the queue revision and target run, then dispatches the requested queue’s current message bodies in their saved order. Other actors’ queues cannot consume that interrupt. The persisted interrupt intent is retried by the scheduler after a promotion error or server restart until that queue is dispatched or discarded. Edits and discards remain authoritative until dispatch; deleting the final message must not create an empty continuation. Pending messages remain visible after a run stops. Cancelling only the run preserves the queue for a later explicit wake; pausing the task retains its separate queue-cancellation behavior. Native same-turn steering keeps its separate acknowledgement protocol. After an acknowledged native Stop, a follow-up saved before the stopped run finished remains pending. It cannot authorize an automatic new turn. A later user message can authorize one continuation that retains the earlier undelivered input. A stopped queue also offers **Send**, which records fresh board intent for the existing messages without copying them or changing their authors. Native Send intent must be later than the stopped execution; it preserves all provider-stop, workspace, approval, pause and budget gates. It does not interrupt a live native turn. Closing the stopped execution coordinator also wakes the existing accounting worker in the same transaction. Complete usage can settle once; missing or partial receipts retain their accounting hold. Legacy Codex uses Ctrl-C to stop its tool sessions and cannot retry a missing-session fallback after the provider has confirmed that the session started.
|
||||
|
||||
An ownership change selects who owns the issue after the comment is committed:
|
||||
|
||||
@@ -730,7 +773,16 @@ recovery path with the specific scan cause and an operator action. Generic
|
||||
stranded-work recovery must not grant another budget for these errors. This
|
||||
does not automatically replay historical generic `setup_failed` runs.
|
||||
|
||||
### ACP startup handshake bound
|
||||
### Runner authentication and ACP startup handshake bounds
|
||||
|
||||
The native Runner waits up to the ten-second connection setup budget for the
|
||||
authenticated welcome. The current execution or reconnect deadline can shorten
|
||||
that wait. Controller admission and durable lease persistence happen before the
|
||||
welcome, so a short internal response limit must not discard an otherwise valid
|
||||
bootstrap ticket during that work. A failed welcome still makes the one-use
|
||||
ticket unsafe to replay. Authentication proofs, credential expiry, and identity
|
||||
checks remain required. Steady-state reads return to their 250 ms polling bound
|
||||
after the welcome.
|
||||
|
||||
An adapter-backed live path also requires that the ACP startup handshake itself cannot hang forever. The engine bounds the handshake with a fixed startup deadline and a poll of the duplex control-channel disposition. Either condition ends the handshake and reports a closed, typed code, so the issue can reach a settled disposition instead of staying `in_progress` with no observable next action.
|
||||
|
||||
@@ -740,7 +792,7 @@ The handshake failure code is distinct from a session-identity mismatch. A timeo
|
||||
|
||||
An explicit recovery action is a typed liveness repair path for a source issue. It is the recovery primitive; the action can be rendered directly on the source issue or backed by a separate recovery issue when the repair needs its own work item.
|
||||
|
||||
A terminal native failure can retain a result accepted before checkpoint or cleanup failed. That result is historical evidence, not a live controller. New user input may start a fresh turn after the controller and execution environment have stopped and ordinary admission checks pass. Preserve the failed run, its result, and its recovery budget. Do not commit the old result, infer action outcomes, or replay the failed turn. A message saved while cleanup is pending must be reconsidered after verified cleanup and delivered once. Admission must consume its deferred receipt in the same transaction that creates the new run, including in agent chat, where later messages keep their separate turns. Completing that run must not promote the consumed receipt again. Workspace-export repair retains its separate saved-result recovery path.
|
||||
A terminal native failure can retain a result accepted before checkpoint or cleanup failed. That result is historical evidence, not a live controller. New user input may start a fresh turn after the controller and execution environment have stopped and ordinary admission checks pass. Preserve the failed run, its result, and its recovery budget. Do not commit the old result, infer action outcomes, or replay the failed turn. A message saved while cleanup is pending must be reconsidered after verified cleanup and delivered once. External chat messages retain their original inbound action, wake receipt and actor. Cleanup delegates reconsideration to the live inbound owner, which revalidates the endpoint, conversation generation, principal and exact saved source before normal admission. The existing stranded-queue sweep uses the same owner after a lost callback or restart; it cannot substitute a generic assignment wake. A rejected source must not prevent other saved messages from being reconsidered. Admission must consume its deferred receipt in the same transaction that creates the new run, including in agent chat, where later messages keep their separate turns. Completing that run must not promote the consumed receipt again. Workspace-export repair retains its separate saved-result recovery path.
|
||||
|
||||
When a legacy turn admitted by an explicit user message fails or times out, a bounded transient retry needs its own durable continuation authorization. Initial admission binds the message's exact body digest and revision. Scheduling requires that original binding and revalidates the human author, source task, assignee, and execution ownership, then records the successor's receipt in the same transaction as its run. Historical receipts without a message binding cannot authorize automatic retries and are not backfilled. The parent receipt remains intact. Dispatch checks the exact successor, retry parent, and unchanged message binding again. Deleted or edited input, changed actors or ownership, superseded work, and duplicate successors cannot renew authority. One-run Retry and Interrupt intents retain their separate admission contracts; they are not renewable user-message authority. Other retry policies cannot borrow this receipt: their scheduling fails closed. Successful explicit turns do not create a prose-only missing-comment follow-up with spent authority.
|
||||
|
||||
@@ -748,7 +800,7 @@ Explicit retry admission checks the local parent adapter controller and verified
|
||||
|
||||
A new user message can continue a terminal native run whose process fields were cleared before local stop receipts existed. Admission must verify the exact run, runner, workspace, and provider session in the retained suspended state, with no active provider turn, pending tool call, or undelivered output. Missing or mismatched state keeps the hold. A later recorded process launch also keeps the hold until its stop is verified. Normal assignment, decision, controller, environment cleanup, and active-run gates still apply. The message starts one fresh conversation turn; it does not replay the failed run, reset its recovery budget, or certify unknown action outcomes.
|
||||
|
||||
The task thread exposes the guarded Retry action for failed or timed-out conversation runs and native preparation cancelled before provider startup when the never-started proof is verified. The server projects this eligibility on the recovery notice and rechecks it on Retry. Cleanup quarantine retains its inspection path, and non-conversation reconciliation gates remain enforced. Pending decisions, active execution, pause, budget, dependency, and ownership gates remain in force. A refused Retry reports its reason inline and remains available for another attempt.
|
||||
The task thread exposes the guarded Retry action for failed or timed-out conversation runs and native preparation cancelled before provider startup when the never-started proof is verified. The server projects this eligibility on the recovery notice and rechecks it on Retry. Cleanup quarantine retains its inspection path, and non-conversation reconciliation gates remain enforced. Pending decisions, active execution, pause, budget, dependency, and ownership gates remain in force. A refused Retry reports its reason inline and remains available for another attempt. When a newer linked-human GitHub message was saved but its admission was skipped by a budget or agent-availability gate, ordinary task Retry selects that unstarted message before considering the older failed run. The existing chat task-Retry owner snapshots the complete pending batch, including eligible skipped input, into a new admission generation bound to the canonical processed deliveries, comments, principal, session generation, admitted instruction snapshots, and task Retry intent. It retains the original skipped receipt and failed result unchanged. A terminal failed publication of that old result also remains history for a distinct selected request; pending or uncertain publication still holds work, and the old output is never replayed. Board Retry refusals expose only stable allowlisted authority categories, never source payloads, identifiers, or credential fingerprints. Admission rechecks source integrity and current access, then uses the normal new-user-turn continuation proof: a committed failed result can remain history only after controller, successor, process, workspace, and exact remote-termination guards pass. Unknown ownership stays deferred; Retry cannot certify it. The existing chat retry queue owns delivery and bounded infrastructure retries; a committed receipt settles a lost acknowledgment without another execution. An accepted saved-input receipt discharges only its matching unadmitted-input gate, allowing later genuine conversation or tree-resume wakes without replaying the skipped request.
|
||||
|
||||
A continuation cancelled by the queued-run gate while waiting for review did not start a provider. Its matching dispatch-gate receipt and unclaimed execution fields keep this deliberate wait out of unknown-action reconciliation. Pending review and decision gates still apply. If an older server already created a recovery hold for this exact state, Retry or a new user message can request one fresh turn after the server verifies there is no provider, coordinator, or unfinished cleanup. Saved user input is reconsidered through the same admission path; the cancelled turn is not replayed.
|
||||
|
||||
@@ -829,6 +881,7 @@ A healthy `in_review` issue has at least one valid action path:
|
||||
- a human owner via `assigneeUserId`
|
||||
- an active run or queued wake that is expected to process the review state
|
||||
- an active one-shot monitor for an external service or async review loop that the assignee owns
|
||||
- an authorized GitHub conversation waiting for its next provider event after a published review; a tree resume or same-agent continuation retains this path only through its accepted requester lineage and the latest saved review publication
|
||||
- an open explicit recovery action for an ambiguous review handoff
|
||||
|
||||
Agent-assigned `in_review` with no typed participant is only healthy when one of the other paths exists. Assignment to the same agent that produced the handoff is not, by itself, a review path.
|
||||
@@ -1236,6 +1289,22 @@ apply. Stream closure without a turn terminal is not proof of success. Event
|
||||
replay uses the existing source receipts and never repeats provider work merely
|
||||
to recover recorded output.
|
||||
|
||||
Native execution success can precede its durable presentation decision. GitHub
|
||||
response maintenance without a confirmed current-run reply holds that request
|
||||
until the native phase is committed and a valid presentation decision exists.
|
||||
A confirmed current-run receipt can retire its placeholder. The same maintenance owner
|
||||
then rechecks the settled outcome; elapsed time cannot turn undecided output
|
||||
into a generic failure notice. Failed execution retains normal error fallback.
|
||||
|
||||
GitHub main-PR response maintenance honors a committed native passive response wait
|
||||
whose server presentation decision intentionally selects no comment. It settles
|
||||
the current source's acknowledgement without a provider write or a fabricated
|
||||
current-run reply receipt. Queued/running acknowledgements preserve an App-owned
|
||||
final shared PR summary; a new canonical assessment may replace it. A generic
|
||||
completion fallback must not overwrite that summary after an intentional wait.
|
||||
Failure, genuinely missing response, and uncertain-publication handling retain
|
||||
their existing independent checks.
|
||||
|
||||
If runnerd synthesizes a result when the provider stops, it publishes that result
|
||||
before the provider-turn terminal and publishes the run terminal last. The
|
||||
adapter can therefore retain the result while the matching turn still has
|
||||
@@ -1284,6 +1353,13 @@ future execution until Resume, and missing stop proof still blocks continuation.
|
||||
|
||||
A permanently unusable native runner session may be replaced only with evidence that its predecessor is stopped and fenced, completed results and workspace state are preserved, required task history is available, and pending effects have been reconciled. A provider-native shell command or external write without a reliable outcome receipt is unknown. Unknown effects, integrity failures, and unverified process ownership never authorize speculative replay. Once automatic recovery is ruled out, Paperclip selects a conservative default: preserve recorded work, stop the affected task, and retain a durable no-replay hold. Unknown action outcomes remain unknown. No reconciliation form or user diagnosis is required.
|
||||
|
||||
An ACPX sidecar transport failure carries its redacted cause and code in the
|
||||
terminal event's structured `error` field. The session facade must retain that
|
||||
cause instead of reporting a failed turn with `error: null`. A transport failure
|
||||
does not prove that the provider stopped or that a retry is safe; it remains
|
||||
non-recoverable until the existing ownership and recovery checks establish the
|
||||
next permitted action.
|
||||
|
||||
Local Codex crash replacement can use a complete interrupted-turn inventory,
|
||||
authenticated process-stop evidence, and unchanged retained-state fingerprints.
|
||||
Only text and an exactly receipted task-completion call qualify for this path;
|
||||
@@ -1293,6 +1369,14 @@ recording the proof and successor lineage. Retained provider files are not edite
|
||||
|
||||
Bootstrap retries, exact-checkpoint resumes, and fresh replacement sessions share three total provider attempts, including the original attempt. Linked run IDs, controller restarts, and duplicate wakes do not reset this budget. Automatic attempts retain the 30-second delay. Replacement scheduling and predecessor lineage commit together, with one successor per predecessor and admission through the normal task locks, authorization, pause, approval, and budget gates.
|
||||
|
||||
When a same-run retry acquires a replacement plugin task lease, the executor
|
||||
reloads the prior task's durable Runner checkpoint before checking local
|
||||
lifecycle evidence. This includes retries after a provider turn omitted its
|
||||
completion result. The existing lease-transfer policy must verify the company,
|
||||
agent, workspace, retained session, and termination of prior execution. The
|
||||
checkpoint must match the exact Runner, lease, run, session, turn, and item.
|
||||
A missing restart claim does not remove these checks or authorize a fresh session.
|
||||
|
||||
A prepared remote workspace interrupted before the executor's first durable
|
||||
claim may resume its untouched bootstrap on the same run. This requires attempt
|
||||
and controller generation zero, no prior owner, and no process, provider session,
|
||||
@@ -1306,6 +1390,13 @@ fresh native run identities. Unmarked preparations from older servers remain
|
||||
held during rolling upgrades, because those servers cannot safely yield to a
|
||||
recovery owner. Revisiting an existing preparation cannot add that marker.
|
||||
|
||||
Restart recovery checks the heartbeat process's live execution registry before
|
||||
claiming an observed run. Native input can exist before workspace synchronization
|
||||
has saved its descriptor or the executor has claimed its lease. A live preparation
|
||||
keeps ownership throughout that interval. Recovery leaves its coordinator unchanged
|
||||
and does not dispatch a second executor. An inactive run still requires the normal
|
||||
ownership, workspace integrity, and provider evidence checks.
|
||||
|
||||
A preparation that resumes after another execution acquired the coordinator
|
||||
lease yields through the controller-detachment path. It does not fail the shared
|
||||
run, release its environment, or start another provider. Lease expiry alone does
|
||||
@@ -1327,8 +1418,61 @@ does not reset the provider-attempt budget.
|
||||
|
||||
Provider execution and control-plane finalization have different clocks. A healthy provider can think or execute a long tool without output. Once execution settles, recovery and finalization control steps have a 60-second deadline, checked on startup and every 15 seconds. With a healthy database and scheduler, an abandoned transition must be repaired or surfaced within 90 seconds. Terminal persistence must not wait on provider cleanup or publication; a late finalizer cannot change a reassigned or closed task or release another run's locks. Historical ambiguous runs are never automatically replayed after an upgrade.
|
||||
|
||||
When that deadline fails a native run with a prepared workspace, the same
|
||||
transaction records the exact source's file-preservation obligation. Existing
|
||||
stop-and-retain recovery contains the provider and saves the files before
|
||||
ordinary Retry can continue. Finalized workspaces, mismatched sources and
|
||||
accepted results keep their existing settlement paths. Reassignment does not
|
||||
discard the stopped run's files or grant authority over the new assignee.
|
||||
|
||||
For older failed native runs that lack this bookkeeping, a deliberate Retry
|
||||
registers preservation from the exact prepared workspace descriptor and assigns
|
||||
the existing bounded maintenance owner. A live finalization owner or mismatched
|
||||
source cannot be adopted. Registration does not prove termination or authorize
|
||||
replay: the provider stop receipt, saved-file receipt, current execution authority
|
||||
and effect fences still control successor admission. Repeated clicks retain the
|
||||
same recovery generation and request.
|
||||
|
||||
An authenticated new message also registers that missing obligation before it
|
||||
waits for cleanup. Orphan cleanup reconstructs it for a failed native run that
|
||||
still owns an exact prepared remote workspace, including historical saved input.
|
||||
Both paths wake the existing preservation worker at commit instead of waiting
|
||||
indefinitely for an export that has no owner. A live executor or finalizer, accepted result, or mismatched source
|
||||
remains protected. Cleanup never interprets this registration as a stop receipt
|
||||
or permission to replay provider work. Repeated cleanup visits register the source
|
||||
once; settled sources add no continuing discovery or new maintenance timer.
|
||||
|
||||
Every continuation carries the triggering request, ordered user direction, interaction outcomes, completed work, and explicit history coverage. A delivered message remains part of the task's request after its connection or approval resolves. The original title is background; a completed Notion read does not satisfy a later Gmail request. Author and source-trust boundaries survive rendering into both native and legacy prompts. Missing required history must be fetched before dispatch rather than described as complete.
|
||||
|
||||
### Remote runner process evidence
|
||||
|
||||
A failed remote health request does not prove that runnerd stopped. Read-only
|
||||
process checks retry transient transport failures up to three times within one
|
||||
30-second budget. Missing or changed identity, an unknown process generation,
|
||||
and exhausted health reads retain uncertainty. They do not authorize a signal,
|
||||
replacement launch, or provider replay. Confirmed predecessor exit permits the
|
||||
transport to attempt recovery within its existing reconnect deadline. Repeated
|
||||
listener conflicts use that same deadline and port; they do not grant ownership
|
||||
of another listener.
|
||||
|
||||
The remote launcher retains a stop signal received while artifact staging is
|
||||
pending. It checks cancellation again immediately before dispatch. A cancelled
|
||||
launch that has not been dispatched settles without starting a process. If a
|
||||
launch RPC is already in flight, signalling waits for its exact identity and
|
||||
uses the existing marker, nonce, PID and runner identity checks. The signal
|
||||
request is not a termination receipt. Monitor evidence still decides whether
|
||||
an already dispatched process stopped.
|
||||
|
||||
A runner receipt that its reconnect grace expired ends that incident. The
|
||||
transport recognizes both the coded error and the runner's plain-text message.
|
||||
It must not open another recovery window after that receipt.
|
||||
|
||||
A bounded transport close can return before artifact staging settles. That
|
||||
return does not prove cleanup completed or release the retained-state obligation.
|
||||
Launch attempts, authenticated runner connections, provider process entries,
|
||||
provider turns, and external effects are different counts. Transport recovery
|
||||
alone does not prove the shared provider-attempt budget or authorize a new turn.
|
||||
|
||||
### Interrupted conversation continuation
|
||||
|
||||
Before provider dispatch, chat-control admission retries transient database lock
|
||||
@@ -1376,14 +1520,25 @@ accepted native result. A successful `workspace_recovery` operation and matching
|
||||
recovery record prove persistence; adapter output or a user repair note cannot.
|
||||
The failed run and unknown external effects remain historical facts.
|
||||
|
||||
Startup and periodic recovery discover these obligations through the retained
|
||||
workspace index. Two background exports per server keep unrelated recovery
|
||||
responsive. Automatic file recovery has three total attempts with a 30-second
|
||||
delay. An idempotent board request through
|
||||
Startup and the retained-workspace sweep reconstruct these obligations through
|
||||
the retained workspace index. Committed Retry, recovery admission, and recovery
|
||||
progress writes wake a separate preservation worker. It follows the persisted
|
||||
retry deadline and becomes idle when no eligible recovery remains. Two background
|
||||
exports per server keep unrelated execution finalization responsive. A resolved
|
||||
exact-source files-only obligation remains eligible; unrelated resolved or
|
||||
cancelled recovery history does not keep this worker active. The persisted
|
||||
attempt budget applies across restarts. An idempotent board request through
|
||||
`POST /api/issues/:id/recovery-actions/recover-workspace` admits one additional
|
||||
attempt after a transient failure. It requires task mutation access, accepts no
|
||||
infrastructure certification, and grants no agent or provider execution authority.
|
||||
Restarts do not reset the attempt limit. Missing stop or descriptor proof keeps
|
||||
Each completed export frees its own slot and wakes the next scan. A slow export
|
||||
does not hold a free slot. Completed cleanup and saved-input obligations receive
|
||||
one bounded pass through unvisited sources. The pass stops at its tail so paused
|
||||
input does not keep the worker active; a committed change starts a fresh pass.
|
||||
Shutdown joins this worker's exports before closing the database. Repeated Retry
|
||||
during physical preservation binds the canonical intent
|
||||
without changing the attempt's generation or revision. Progress writes preserve
|
||||
that newer intent so its fenced completion can still commit. Missing stop or descriptor proof keeps
|
||||
the source allocated and exposes the specific service blocker.
|
||||
|
||||
File recovery alone never requests another agent turn. Existing saved human
|
||||
@@ -1401,7 +1556,7 @@ Real gates still apply: company and task ownership, active provider ownership, b
|
||||
|
||||
A source that already stopped and was released remains fenced until its export settles; restart reconciliation scans these released sources too. Both provider reuse and destruction check the physical allocation for a shutdown fence or file-repair pin, including historical lease rows.
|
||||
|
||||
An operator Stop waits for provider termination. Remote sandbox providers may return a stopped/deleted receipt after their control-plane operation completes. Paperclip binds that receipt to the company, run, and exact lease; successful file cleanup, a terminal run row, or an in-sandbox shutdown event is not sufficient. Legacy conversational runs receive their cancellation acknowledgement after all remote leases have confirmed termination. Stop alone never creates a continuation. A user message queued during remote cleanup is reconsidered when the provider confirms termination; it still passes normal admission and adopts pending comment IDs in order. Once stopped, the next explicit wake uses the same queue. A compatible saved ACP session can resume, and an unavailable or incompatible session can start fresh with the full task context. Run credentials and scratch paths remain scoped to the new run. A subtree pause requires Resume; a message does not bypass it.
|
||||
An operator Stop waits for provider termination. Remote sandbox providers may return a stopped/deleted receipt after their control-plane operation completes. Paperclip binds that receipt to the company, run, and exact lease; successful file cleanup, a terminal run row, or an in-sandbox shutdown event is not sufficient. Legacy conversational runs and native runs with an audited run-only Stop receive their shared cancellation acknowledgement after all remote leases have confirmed termination. A native interrupt acknowledgement alone does not establish containment. Workspace preservation remains a separate obligation and its recovery hold survives this acknowledgement. Stop alone never creates a continuation. A user message queued during remote cleanup is reconsidered when the provider confirms termination; it still passes normal admission and adopts pending comment IDs in order. Once stopped, the next explicit wake uses the same queue. A compatible saved ACP session can resume, and an unavailable or incompatible session can start fresh with the full task context. Run credentials and scratch paths remain scoped to the new run. A subtree pause requires Resume; a message does not bypass it.
|
||||
|
||||
Cancelled runs and runs with a recorded pending stop lose run-scoped API write authority, while reads remain available for diagnostics. This applies to ordinary tasks as well as conversations. Task and interaction mutations recheck that authority in the write transaction, so a cancelled run cannot overwrite a recovery disposition with a late Done or resolve an interaction after revocation. A task handoff that intentionally stops its own run can commit only with a server receipt tied to that exact request. Direct sandbox CLI adapters such as Grok register cancellation before preparation and keep ownership until host-owned termination is verified, including a sandbox acquired before adapter registration. A failed stop does not acknowledge cancellation or abandon an outstanding remote command. Interrupted workspace restore failures remain recorded for recovery; a stop receipt proves termination, not successful file restoration.
|
||||
|
||||
@@ -1415,6 +1570,27 @@ Historical legacy interruption holds for conversational adapters no longer block
|
||||
|
||||
The server projection remains available for diagnostics. Normal working, finishing, and interaction waits add no badges or cards to task lists or feeds. Active transcript headers keep saying Working during automatic retry and execution confirmation; attempts, causes, and recovery decisions belong in the run log. Recovery uses the existing transcript and run log rather than adding a reconciliation form. A cancelled run that never started says “Couldn't start” instead of implying that the agent answered.
|
||||
|
||||
Native workspace export failures offer **Inspect recovery** on the run page.
|
||||
The explicit read lists recovery actions for that exact task and source run;
|
||||
selecting an action observes its current recorded-source identity. It includes
|
||||
resolved actions whose retained work may still need attention. This observation
|
||||
does not prove provider termination, local descriptor integrity, or successful
|
||||
file preservation, and it does not explain an earlier owner state retroactively.
|
||||
Recorded system recovery failures retain their original attempt and intent.
|
||||
New source-binding failures record a closed reason and, for a rejected lease
|
||||
policy, an allowlisted policy value. Neither detail includes paths, provider
|
||||
allocation identifiers, credentials, or raw provider errors.
|
||||
|
||||
The company/task-authorized `GET /api/issues/{id}/recovery-actions` accepts
|
||||
`inspectSourceRunId` to list at most 20 matching actions and an optional
|
||||
`inspectSourceActionId` to inspect one exact action. This opt-in path bypasses
|
||||
read revalidation and performs no recovery writes, provider calls, filesystem
|
||||
inspection, or retries. It reads the existing source verifier without changing
|
||||
its guards. Inspection of more than eight recorded leases returns
|
||||
`not_inspected/source_limit` before per-lease lookup; physical recovery retains
|
||||
its existing acceptance rules. The observation spans multiple database reads
|
||||
and grants no execution or preservation authority.
|
||||
|
||||
### Codex startup and provider state
|
||||
|
||||
Paperclip trusts the server-selected startup execution root in the isolated
|
||||
@@ -1437,6 +1613,21 @@ delta when attaching a new run. Other stale-event and authority checks remain.
|
||||
|
||||
### Explicit user continuation after execution failure
|
||||
|
||||
A deferred chat Retry retains its authorized source batch when it resumes.
|
||||
Separate board queues do not join that batch. For a historical mixed batch
|
||||
refused before provider startup, a new task Retry can restore the undelivered
|
||||
receipts only after certifying the exact bootstrap failure, source authority,
|
||||
unchanged board comments and absence of execution, allocation or prior delivery.
|
||||
The restoration is audited. Historical runs and budget-skipped receipts remain
|
||||
unchanged; each input still passes its ordinary admission and dispatch checks.
|
||||
|
||||
When a user's comment also assigns the task back to its agent, the assignment
|
||||
wake retains that comment's continuation intent. Admission validates the saved
|
||||
comment, its author, task, timing and delivery receipt through the same checks as
|
||||
an ordinary comment wake. A bare assignment, agent/system input or mention wake
|
||||
does not authorize continuation. Already deferred assignment-with-comment
|
||||
receipts use this same path after recovery; they do not require another comment.
|
||||
|
||||
An execution recovery hold blocks automatic replay. A new authenticated user
|
||||
comment or exact failed-run Retry can authorize a fresh native or legacy
|
||||
conversation turn after the predecessor's
|
||||
@@ -1457,6 +1648,23 @@ ownership, active controllers, cleanup leases, pause, approval, budget, and norm
|
||||
execution gates still apply. Dependency-blocked interaction mode remains limited
|
||||
to its existing answer/triage contract.
|
||||
|
||||
Snapshot preservation and native copyback are distinct receipts. A historical
|
||||
workspace recovered as an immutable snapshot does not make its old native sync
|
||||
reference finalized. Once recovery settles, the current inbound owner can
|
||||
reauthorize its same unconsumed durable human request and select a fresh isolated
|
||||
workspace. The saved snapshot attaches to that successor through the existing
|
||||
verified attachment path. A saved board-message queue uses the same fresh
|
||||
strategy. Historical holds for the same failed run share that recovery strategy;
|
||||
each still requires its own execution and termination proofs. Neither path
|
||||
replays the stopped turn, adopts a chat request through the generic board queue,
|
||||
nor invents a copyback hash or resets the predecessor's attempt count.
|
||||
A later ordinary turn keeps the current source-free task directory, including a
|
||||
fresh attempt directory. Inherited agent Git defaults do not turn that saved
|
||||
directory into a Git base. Recognition requires the current same-task binding,
|
||||
active local directory realization, and verified canonical path. Explicit new
|
||||
source or Git requirements and immutable recovery bindings keep their existing
|
||||
selection rules. Preparation rechecks the binding and path under the source lock.
|
||||
|
||||
The hold retirement, audit record, and new run commit together under the task
|
||||
lock. The new turn uses a fresh provider session and retains the latest user
|
||||
request, task history, completed work, and the interruption notice. It receives
|
||||
@@ -1616,6 +1824,14 @@ comments already delivered to the failed run. A plain operator Stop still
|
||||
requires a new user action. The successor guard is scoped to the same agent so
|
||||
another agent's review participation keeps its independent recovery path.
|
||||
|
||||
Agent Chat `/new` can clear provider context without launching a provider. It
|
||||
cannot certify that an older native provider stopped. Native execution recovery
|
||||
records remain eligible across that boundary, so the next message must still
|
||||
pass the existing process, controller, workspace and lease cleanup checks. A
|
||||
verified stop can retire the hold for new input; prior outcomes stay recorded.
|
||||
Send also checks the native predecessor when no recovery record exists. A later
|
||||
context-reset record cannot hide that predecessor or its stop requirements.
|
||||
|
||||
An explicit queued-message Interrupt also grants one scoped cleanup retry for
|
||||
the stopped run. Old ephemeral leases whose cleanup predates provider stop
|
||||
receipts are rechecked through the recorded provider teardown path. Retained
|
||||
@@ -1624,6 +1840,23 @@ Delivery still requires the provider's verified stop receipt. Periodic queue
|
||||
retries do not gain extra cleanup attempts, and the queue displays the server's
|
||||
waiting reason while cleanup remains unresolved.
|
||||
|
||||
A connected task can also fail while preparing an accepted board-authored
|
||||
message, before the provider starts. Its Retry belongs to the canonical task
|
||||
intent rather than the external inbound-message ledger. The server certifies
|
||||
its unchanged, undeleted comment, original user wake and accepted identity in
|
||||
the same company, task, agent and user scope. A settled bootstrap diagnostic
|
||||
must agree with the absence of a native result, coordinator, environment lease,
|
||||
controller, process or provider output. A previous session display hint does
|
||||
not establish that the failed attempt started a provider.
|
||||
|
||||
That intent pins the saved comment and accepted identity. Queue admission
|
||||
rechecks the source; dispatch rechecks the coalesced intent and its exact claimed
|
||||
successor receipt before inheriting the accepted identity. Current permissions,
|
||||
assignment, pauses, budgets and containment remain normal queue requirements.
|
||||
This path neither fabricates external inbound provenance nor replays a recorded
|
||||
terminal result. If this source cannot be certified, Retry directs the user to
|
||||
send a new task message to continue from saved work.
|
||||
|
||||
The legacy task recovery notice shows “Automatic recovery of this task stopped.” in
|
||||
a bordered container with Retry for a failed or timed-out run. A failed Retry
|
||||
shows its error in the same container. New user messages and saved undelivered
|
||||
@@ -1682,12 +1915,25 @@ limits its own attempt; it must not terminate the remote owner while another
|
||||
controller is starting or classifying runs. Lease expiry still shuts down the
|
||||
provider, and exact identity, authentication, Stop and revocation checks remain
|
||||
required before any recovery or new execution.
|
||||
A remote restart claim with a persisted admitted Runner artifact can reconnect
|
||||
without the previous controller's launch binary, provider pack or local pack
|
||||
receipt. The existing sandbox marker, process identity and authenticated PRP
|
||||
checkpoint still establish the original execution. Adoption uses that admitted
|
||||
artifact identity and does not stage or launch provider assets. Fresh launches
|
||||
and recovery without persisted admission retain their release and pack checks.
|
||||
Native ACPX semantic tool calls wait on their durable PRP invocation across a
|
||||
controller outage. Their provider-side MCP bridge does not impose a separate
|
||||
request deadline that could make the provider retry an already queued write.
|
||||
Runner lease expiry, turn cancellation and bridge closure still end those waits.
|
||||
Ordinary bridge callers retain their request deadline. Repeating the same call
|
||||
identity returns its one retained result; it does not execute the operation again.
|
||||
Completed `update_case`, `create_workflow` and `update_workflow` results recover
|
||||
through their existing tool adapters, request conversion and API receipt keys.
|
||||
Recovery repeats current feature, task and access checks but does not dispatch an
|
||||
HTTP mutation or create another receipt. Missing, changed, pending or unknown
|
||||
outcomes remain unanswered for authoritative reconciliation; a stopped run or
|
||||
revoked authority cannot recover a result.
|
||||
|
||||
The native Claude sidecar also sets its HTTP MCP client budget to one hour,
|
||||
matching the Runner connection lease budget. This prevents Claude's default
|
||||
60-second HTTP deadline from aborting an otherwise pending durable request.
|
||||
@@ -1797,6 +2043,24 @@ replacement. Missing or conflicting identity evidence, failed reads, and an
|
||||
exhausted probe budget retain the existing ownership and recovery safeguards.
|
||||
They must not launch a competing listener or overwrite the original marker.
|
||||
|
||||
Remote process verification uses one Node probe for liveness, command identity,
|
||||
and the saved Linux boot identity and start ticks. If metadata disappears during
|
||||
the probe, only a fresh `ESRCH` response confirms exit. A zombie also confirms
|
||||
exit when its generation matches the saved marker. Permission errors, unreadable
|
||||
metadata, and changed generations remain uncertain. Adopted-runner checks reject
|
||||
that uncertainty rather than returning the same `false` value as a confirmed
|
||||
exit. Launch checks that Node can run the probe before creating launch state or
|
||||
detaching the process. These receipts do not release historical recovery holds
|
||||
or prove that workspace files and provider state have been saved.
|
||||
The transport propagates rejected adopted-process checks through close and
|
||||
command waits. It must not turn a rejected check into a positive exit observation.
|
||||
When a process check rejects close, the transport retires its local controller,
|
||||
timers and notification queue without signalling the remote Runner or replacing
|
||||
its checkpoint. Repeated close retains the same failed ownership result.
|
||||
Copilot profile 62 binds this shared transport implementation. Profile 61 remains
|
||||
decodable for recovery, but exact launch admission uses the current profile and
|
||||
digest. Earlier live qualification results keep their original profile identity.
|
||||
|
||||
Healthy native warm turns retain their runner and provider processes. After a
|
||||
controller crash, continuation can recover a completed remote session from its
|
||||
saved checkpoint and exact sandbox process identity. The prior controller must
|
||||
@@ -1875,6 +2139,16 @@ re-read dependency readiness, since new dependencies need not change the
|
||||
displayed task status. Old blocked rows without a receipt remain held; no
|
||||
historical status backfill is performed.
|
||||
|
||||
For legacy GitHub input without a saved App ID or its own response receipt,
|
||||
Task Retry may prove the endpoint's earlier non-null App claim from a processed
|
||||
response ledger created before that input. The proof stays within the same
|
||||
company, endpoint, conversation and agent, with a processed prior delivery and
|
||||
its inbound source link. Supported setup and reconnect writers preserve an
|
||||
existing non-null App claim. This historical identity proof grants no publication
|
||||
replay or access: the saved input and current principal, repository, conversation,
|
||||
runtime fence and execution containment still pass normal admission. Explicit
|
||||
conflicting App bindings remain denials.
|
||||
|
||||
### Queued input after a native Stop
|
||||
|
||||
A run-only Stop ends the current response. It does not discard queued user
|
||||
@@ -1897,13 +2171,33 @@ message posted after the run finished can authorize a fresh turn through the
|
||||
same continuation checks as a new message. A message from before the Stop cannot
|
||||
authorize that continuation by itself. Previously delivered messages stay excluded.
|
||||
|
||||
The Send click also covers saved question answers and confirmation responses.
|
||||
Admission checks the last native Stop's process, workspace and cleanup receipts
|
||||
even when no recovery action exists. Its continuation authority is recorded on
|
||||
the exact queue receipt and verified again at dispatch. A deferred response keeps
|
||||
its messages and controls visible and explains the current wait. Historical
|
||||
recovery actions cannot stand in for the newest failed or cancelled native run:
|
||||
Send checks that run's controller, process, lease and cleanup proofs as well.
|
||||
|
||||
A live plan Interrupt binds the click to its target run and the exact operator
|
||||
cancellation receipt. Its click may precede that run's stop time, but cannot
|
||||
bypass process or workspace cleanup. Other Send clicks must follow the Stop.
|
||||
Only a persisted Agent Chat `/new` authored by the requesting user retains the
|
||||
conversation-reset exception. Ordinary follow-ups still wait for an accepted
|
||||
result to finish committing, even after workspace export recovery clears its
|
||||
execution blocker. Committed successful results no longer create continuation
|
||||
waits from recovered-export history. Applied conversation resets retire older
|
||||
non-workspace holds; active workspace holds remain authoritative across the
|
||||
boundary. Workspace recovery holds still block the reset. Queue authorization
|
||||
is read again at dispatch for both cancelled and failed native predecessors.
|
||||
|
||||
The active session advertises steering only when its driver supports it. A
|
||||
transport method that rejects steering does not grant that capability. The
|
||||
queued-message controller remains mounted across an empty queue, preserving
|
||||
pending delivery and inline errors. Steer immediately moves the selected message
|
||||
into the conversation; Interrupt immediately moves all submitted queued messages.
|
||||
Stale queue polls do not restore those rows while delivery is pending or accepted.
|
||||
Rejected delivery restores the latest queue and shows an inline error without a
|
||||
Rejected or still-deferred delivery restores the latest queue and shows an inline error without a
|
||||
toast. The provider acknowledgement remains the authoritative delivery receipt;
|
||||
optimistic display never grants permission or records successful delivery. A preparing
|
||||
Paperclip Runner retains the Steer label while steering is unavailable; the
|
||||
@@ -1995,6 +2289,25 @@ not grant execution authority: calls still use the existing gateway policy,
|
||||
approval, revocation, and audit paths.
|
||||
|
||||
|
||||
Native OpenCode sessions use the server-owned assigned-tool relay for local and
|
||||
remote execution. The provider receives only bounded search and call schemas,
|
||||
even when its protocol can fit the full catalog. This bounds provider grammar
|
||||
compilation independently of the transport's tool-count and byte limits. Search
|
||||
returns pinned tool aliases and schemas, intersects fresh grants, and respects
|
||||
planning/ask restrictions. OpenCode discovery pages use a 32 KiB budget; larger
|
||||
schemas use 16K-character chunks so JSON escaping and the result envelope fit
|
||||
its HTTP MCP bridge. Other harnesses keep the existing PRP discovery budgets.
|
||||
Calls retain the original gateway binding and its
|
||||
argument validation, approvals, revocation, and audit rules; prompt arguments
|
||||
cannot choose a gateway or credential. Assigned gateway launch credentials stay
|
||||
on the controller. The MCP bridge preserves exact declared operation names,
|
||||
including names that start with `paperclip_`; only a catalog-bound transport
|
||||
prefix may resolve to that declared name. Shortened or unknown names do not
|
||||
gain authority. OpenCode sessions with the former direct catalog rotate their
|
||||
provider checkpoint before reuse; other providers retain their existing tool
|
||||
contract fingerprints.
|
||||
|
||||
|
||||
### Use real connection requests (2026-09-14)
|
||||
|
||||
When a user asks to connect a known service, the agent searches for that service
|
||||
@@ -2139,3 +2452,67 @@ use the same activity projection. Board-owned repairs stay actionable until a
|
||||
verified native retry actually starts. Once an explicit board retry is running,
|
||||
its live activity takes precedence over the prior owner and exhausted budget.
|
||||
Resolved and cancelled actions remain resolved.
|
||||
|
||||
|
||||
### Task directories after a fresh retry
|
||||
|
||||
A fresh repository-free retry receives its own private attempt directory. Once
|
||||
that attempt succeeds, later ordinary input reuses the task's currently bound
|
||||
directory, including an attempt directory, and preserves its saved files. The
|
||||
binding is re-read before preparation. Only the exact company's task namespace
|
||||
is eligible; closed or changed bindings, foreign paths, symlinks and missing
|
||||
retained directories fail validation. A later explicitly fresh retry still
|
||||
receives a separate clean sibling and does not copy or replay the old turn.
|
||||
|
||||
### Stopped execution workspace recovery instructions
|
||||
|
||||
A stopped run can retain a historical workspace repair instruction after machine
|
||||
recovery has saved the files. Its execution projection uses the workspace
|
||||
recovery state bound to that exact run for the next instruction, as the task
|
||||
blocker does. Recovery in progress, a failed repair, and recovered files must
|
||||
remain distinct. This read-only instruction does not release the replay hold,
|
||||
change the stopped run, or authorize another provider turn. A recorded explicit
|
||||
successor still takes precedence over the old recovery instruction.
|
||||
|
||||
|
||||
A terminal native run can leave an older exact-session retry action behind when
|
||||
workspace recovery takes ownership. The existing execution disposition worker
|
||||
retires that action only after the exact run and coordinator are terminal, no
|
||||
controller lease, result, successor or retry remains, and a separate source-bound
|
||||
workspace hold records that uncertain actions cannot replay. The worker keeps
|
||||
that hold and all saved work. It does not change the task or start a provider.
|
||||
The same bounded reconciliation pass handles historical actions after restart.
|
||||
|
||||
Checkpoint encryption resolves the local master key once per checkpoint load or
|
||||
changed-record write, then encrypts or decrypts the batch in memory. It does not
|
||||
retain a process-wide key cache. Each later operation reads the configured key
|
||||
again, and a recovery read never creates a missing key. Ciphertext format and
|
||||
company/run/part binding stay the same. Migration 0391 follows current master and
|
||||
remains idempotent for test databases that already installed an earlier preview.
|
||||
|
||||
Expired remote recovery considers the complete active-run inventory supplied by
|
||||
the orphan sweep. It does not truncate that inventory at 100 rows: blocked issue
|
||||
locks can retain expired owners across sweeps, and must not starve later valid
|
||||
runners. This remains one scoped query and performs no checkpoint query when
|
||||
that inventory is empty. It does not scan retained completed history.
|
||||
|
||||
Fresh execution claims start their lease from the database wall clock in the
|
||||
final claim update, after run-row locks and replacement-history reads. Lock
|
||||
contention cannot consume the new remote controller's 60-second lease before
|
||||
renewal begins at commit. Recovery claims use the same clock rule.
|
||||
|
||||
Checkpoint-pool shutdown fences lazy creation even if the owner has never
|
||||
requested that pool. Later checkpoint access fails before allocating a client.
|
||||
An already-created pool remains cached and ended; unrelated owners and database
|
||||
test doubles keep their existing behavior. Closing an unused pool makes no SQL.
|
||||
|
||||
|
||||
Ordinary remote warm admission also restores external controller state before
|
||||
reading the durable identity or checking reuse. It uses the retained owner's
|
||||
last run and issue, or the validated saved native-session identity after owner
|
||||
loss. The marker may describe the first turn, so it cannot identify every later
|
||||
warm checkpoint. Legacy file-backed roots retain their existing checks. A
|
||||
restored checkpoint must match the normalized session before writing its local
|
||||
projection. This adds a scoped checkpoint read and local reconstruction only
|
||||
when warm admission encounters an external marker or an older projection; it
|
||||
adds no streaming checkpoint writes or recurring idle work.
|
||||
@@ -2,14 +2,48 @@
|
||||
|
||||
Select **Grok Build** in the native runner provider selector. The stored contract is
|
||||
`adapterType: "paperclip_runner"` with `provider: "acpx"`, `acpxAgent: "grok"`,
|
||||
and `model: "grok-4.7"`. Existing `grok_local` agents keep their legacy adapter.
|
||||
and `model: "grok-4.7"`. Existing `grok_local` agents keep their legacy adapter
|
||||
until an explicit upgrade. On a qualified macOS ARM64 or Linux x64 computer,
|
||||
**Harness / Runtime → Upgrade runner** preserves the agent, model, account,
|
||||
instructions, and history. It starts fresh execution sessions. Advanced also
|
||||
offers an explicit Runner choice. Unsupported legacy settings are rejected
|
||||
before saving; select compatible settings or keep Legacy.
|
||||
New Grok runner agents default to **Full auto (approve all)**
|
||||
(`acpxPermissionMode: "approve-all"`) in setup and the configuration form.
|
||||
API configurations that omit the permission mode use the same default. No
|
||||
additional permission setting is needed for unattended execution. Explicitly
|
||||
saved restrictions remain unchanged.
|
||||
On Cloud, an operator must enable `enableNativeRunner` for the instance before
|
||||
the new-agent picker or direct setup page offers the native runner.
|
||||
Grok Build's qualified harness does not require the `enableNativeRunner` flag.
|
||||
|
||||
Explicit thinking effort uses `reasoningEffort` (`minimal`, `low`, `medium`,
|
||||
`high`, or `xhigh`). The immutable v10 execution input, provider descriptor,
|
||||
and recovery identity bind this value as `grokEffort`. The Grok provider boundary
|
||||
adds native `_meta.modelId` and `_meta.reasoningEffort` to session create, load,
|
||||
and resume requests before transport writes and observation. Grok 1.0.13 reports
|
||||
the selected effort in `_meta["x.ai/sessionConfig"].options`; it does not support
|
||||
the standard `session/set_config_option` effort API. The Runner checks that native
|
||||
acknowledgement and the effective model before a prompt. A lazy restored handle
|
||||
must connect through read-only native session inspection first. Reapplying the
|
||||
bound metadata on restore prevents Grok from resetting effort to its default.
|
||||
Missing configuration, a rejected value, or effort drift stops admission.
|
||||
Admission failures keep a closed Grok reason code through the sidecar boundary.
|
||||
Other startup failures retain their closed admission stage when no more specific
|
||||
known error code is available. These diagnostics contain no native payload,
|
||||
credential, arbitrary provider message, or workspace path. They do not change
|
||||
admission or retry policy.
|
||||
Omitting effort retains Grok's provider default. This setting remains outside
|
||||
the model-visible task prompt. Historical execution inputs retain their schemas.
|
||||
|
||||
The upgrade resolves an unset model or the legacy `grok-build` default to the
|
||||
native `grok-4.7` default. Task-level model and effort overrides use the Grok
|
||||
catalog and keep the account and tool policy unchanged.
|
||||
|
||||
Migration also preserves the effective `disableWebSearch` setting, whose legacy
|
||||
default is `true`. Native input v10 carries it as `grokDisableWebSearch` through
|
||||
startup and recovery. Grok's private `requirements.toml` enforces disabled search
|
||||
above compatible project settings. Explicit `false` retains enabled search;
|
||||
unset native configurations retain their provider default. See Grok's
|
||||
[configuration reference](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-pager/docs/user-guide/26-config-reference.md).
|
||||
|
||||
Grok Build speaks [ACP over stdio](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-pager/docs/user-guide/15-agent-mode.md).
|
||||
The runner owns `grok agent --no-leader stdio` through ACPX, including session
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
# Approval queue seeding without retention polling
|
||||
|
||||
## Obligation and stack dependency
|
||||
|
||||
The attention maintenance scan does two different jobs: retention and automatic
|
||||
queue membership. Its call to `materializeSeededQueues` adds pending approvals to
|
||||
the PR queue when the first linked issue has a pull-request work product. Queue
|
||||
API readers use saved membership; they do not first build the attention feed.
|
||||
Removing approvals from maintenance therefore lost automatic queue population.
|
||||
A real PostgreSQL regression reproduced an empty queue with a pending linked
|
||||
approval and PR work product, without an attention reader.
|
||||
|
||||
This slice owns automatic PR-queue membership for approvals. It depends on the
|
||||
approval producer boundaries introduced in #17497 and is required with that
|
||||
retention migration before treating the combined behavior as complete. Join
|
||||
requests have no issue association or matching seed rule. Other attention-source
|
||||
seed discovery remains in the existing feed scan and must migrate with an
|
||||
explicit owner before those sources leave that scan.
|
||||
|
||||
## Source of truth and ownership
|
||||
|
||||
Existing approvals, issue approvals, issue work products, queues, and queue items
|
||||
are the only durable state. No new table, outbox, external API, or provider branch
|
||||
is introduced. `approvalQueueSeedingService` selects missing memberships for:
|
||||
|
||||
- A pending approval in an active company.
|
||||
- Its first linked issue, ordered by issue ID, in the same company and eligible
|
||||
under the attention feed's execution-issue visibility predicate.
|
||||
- A pull-request work product belonging to that issue and company.
|
||||
- A missing PR queue or an enabled seed rule without this approval's membership.
|
||||
|
||||
Disabled rules, nonmatching approvals, completed approvals, and existing seeded
|
||||
history are passive. A pending approval itself prevents its linked Slack/Speko
|
||||
conversation from projecting as idle, so this source does not depend on every
|
||||
other conversation-state producer. Hidden/harness/conversation-container issue
|
||||
changes retain their explicit signals.
|
||||
|
||||
The queue service owns seed matching, creation, membership uniqueness, and audit
|
||||
writes. The feed and worker use the same writer through a small normalized source
|
||||
contract. The candidate lookup and writer share the pull-request product SQL
|
||||
predicate, so the matching policy has one owner. A worker does not construct a presentation-layer AttentionItem. Queue
|
||||
reads keep their existing source authorization checks. A queue-rule update and
|
||||
seeding serialize on the existing queue row. Existing memberships are not pruned
|
||||
when an approval resolves. This preserves current behavior; pruning is a separate
|
||||
change, including the proposal in #13364.
|
||||
|
||||
## Producer audit
|
||||
|
||||
| Producer | Commit boundary and effect |
|
||||
| --- | --- |
|
||||
| Approval create, cancel, revision, resubmit, resolve | `writeApprovalChange` notifies retention and company-scoped queue seeding before writes in the owning transaction. Approve/reject/cancel retain their explicit tool-review notification; ordinary mutations do not wake those queues. |
|
||||
| Shared approval records, budget approvals, formal tool approvals | The same approval notification covers their existing transactions. |
|
||||
| Issue approval link, unlink, link-many, tool-created links | Existing approval transaction helper. Link removal can expose a different first issue. |
|
||||
| Issue visibility changes and issue deletion | Existing approval notification before the update or cascading delete. Routine failure cleanup retains this signal. |
|
||||
| Company activation, pause, archive, deletion and budget pause/resume | Existing company notification wakes both approval consumers. |
|
||||
| Work-product create, type/issue update, import batch, delete | The owning work-product transaction notifies seeding when a pull-request row is involved. |
|
||||
| PR queue creation, rule update and manual approval membership removal | The queue mutation transaction notifies seeding. An enabled rule can re-add a still-matching manually removed item, as the old scan did. |
|
||||
|
||||
Other direct work-product writes only create artifacts or update title, metadata,
|
||||
or trust. They cannot create a pull-request match. Supported writes use these
|
||||
in-process service boundaries. Independent SQL writers and multiple server
|
||||
replicas are not covered by local transaction signals.
|
||||
|
||||
Retention-only keep/archive/revive changes do not create queue projection debt.
|
||||
The old seed writer ran before retention filtering, so archived or kept approvals
|
||||
still follow the same seeding rule. User-specific dismissals do not filter the
|
||||
unscoped maintenance feed. No new deadline is inferred from those display states.
|
||||
|
||||
## Worker lifecycle
|
||||
|
||||
Startup reconstructs missing memberships across the instance. Each later committed
|
||||
producer carries its company ID and wakes only that company's reconciliation.
|
||||
Pending scopes coalesce in memory; one company's ordinary mutations cannot restart
|
||||
a global scan. Scope rotation prevents one failing company from starving another. Each sweep processes at most 100 approvals, with an ID cursor and a fixed upper ID per scan cycle. The cursor advances past
|
||||
failures and finishes that finite cycle before revisiting them; new arrivals cannot
|
||||
extend it indefinitely. Each row is rechecked under
|
||||
an approval lock before the queue writer runs. One bad row cannot permanently
|
||||
hide later candidates. Atomic existing membership uniqueness prevents duplicate
|
||||
rows and duplicate insertion audit events.
|
||||
|
||||
After a sweep, another lookup decides whether actual projection debt remains.
|
||||
Unprocessed pages and newer committed signals retain a one-second continuation.
|
||||
Failed scopes back off exponentially from one second to one minute. A successful
|
||||
nonempty batch resets consecutive failures; an empty cursor rollover while debt
|
||||
remains preserves outage backoff. New signals
|
||||
do not reset a failing scope's backoff; other companies can proceed independently.
|
||||
The next retry is registered with the shared scheduler, with no SQL during the
|
||||
wait. A failed scope remains tracked until its owed memberships are reconciled. The generation check follows the final awaited lookup, so a commit arriving
|
||||
during an empty read cannot be erased. Once reconciled, there is no deadline and
|
||||
no recurring worker SQL. The shared coordinator preserves requested work during
|
||||
drain. Passive pending checks inspect memory only; startup reconstructs from rows. Shutdown aborts between rows
|
||||
and waits for an admitted transaction; unprocessed rows remain recoverable.
|
||||
Registration follows the existing heartbeat-maintenance enablement switch.
|
||||
|
||||
This slice does not extend Cloud alarm coverage or loosen host sleep admission.
|
||||
It proves a worker's quiet/reactivation behavior, not deployed host or database
|
||||
suspension. The remaining attention maintenance interval is still present for
|
||||
other source owners.
|
||||
|
||||
## Verification
|
||||
|
||||
- Before the fix, the PostgreSQL regression found no PR queue after the normal
|
||||
maintenance path excluded approvals. After the fix, the queue API finds it
|
||||
without a full attention read.
|
||||
- Twenty-one real-database worker tests cover empty-to-active-to-quiet behavior,
|
||||
startup and completed history, actual approval/link/work-product producers,
|
||||
batch import, first-link selection, company isolation, hidden targets, queue
|
||||
rule enablement, company activation, resubmission, outer commit and rollback,
|
||||
first write during drain, database failure through drain, multiple pages,
|
||||
failed-page progress and retry, concurrent resolution, archived/kept history, shutdown/restart, and an admitted transaction at shutdown.
|
||||
- 115 existing tests pass across queue routes, attention, approval retention,
|
||||
approval service, work products, and startup wiring. The shared writer still
|
||||
passes existing plan/question/PR seed tests and authorization tests.
|
||||
- Workspace typechecks and the production build pass. Fresh PR checks are
|
||||
tracked in the PR and must pass before readiness is claimed.
|
||||
- The actual pending lookup on 40,000 completed approvals plus one pending PR
|
||||
approval uses the existing partial pending-retention index. The probe runs in
|
||||
a rolled-back transaction; execution took 0.094 ms on this local fixture. No new index or migration is needed for this case.
|
||||
|
||||
The first expanded test run caught two fixture errors: a hand-inserted queue
|
||||
omitted required creator attribution, and a failure test expected no idle hold
|
||||
while committed projection work was still unresolved. The corrected test waits
|
||||
for the active attempt to finish and checks passive SQL directly while preserving
|
||||
that required hold. Neither required weakening the worker's recovery contract.
|
||||
|
||||
Review found a queue/item lock inversion between seeded insertion and manual
|
||||
membership changes. A two-connection PostgreSQL regression reproduced `deadlock
|
||||
detected`. Manual add/remove now lock the queue before touching its items, matching
|
||||
the seed writer. The regression and the existing queue route suite pass (27 cases).
|
||||
Server typecheck and build pass after the repair. Fresh-head CI/review is required.
|
||||
|
||||
Security review found that normal producer wakes could restart a cross-company
|
||||
scan. The regression reproduced another company's queue being populated by an
|
||||
unrelated company write. Producers now include company hints, including batch
|
||||
imports and nested transactions. A separate controlled PostgreSQL test holds the
|
||||
final empty read while a real producer commits and verifies the retained wake.
|
||||
|
||||
After company scoping, all 169 selected cases pass across approval queue seeding,
|
||||
approval retention, queue routes, approval services, work products, and issue
|
||||
comment routes. Server typechecking passes. The preceding run suffered timeouts
|
||||
and a consequent cleanup foreign-key error; it is not counted as passing. The
|
||||
route suite separately exposed cold imports inside timed tests. Moving those
|
||||
imports to suite loading preserves assertions and prevents timed-out requests
|
||||
from leaking into later mock state. The watchdog fixture collision from CI uses
|
||||
monotonic identifiers instead; its 19 real-PostgreSQL cases pass.
|
||||
|
||||
The next CI run passed the security gate and the two originally failing shards.
|
||||
Its remaining server shard caught a historical cleanup test checking 33 provider
|
||||
inspections after only 32 had completed. The test advanced fake time before the
|
||||
previous asynchronous page had necessarily published its next deadline. It now
|
||||
waits for all 33 inspections, then retains the zero-further-work assertions. All
|
||||
14 environment cleanup cases pass on real PostgreSQL; production code is unchanged.
|
||||
|
||||
|
||||
Further review reproduced 120 failed transactions in two simulated minutes for a
|
||||
persistently failing projection. Per-scope backoff now bounds that test to at most
|
||||
eight attempts, even with new signals every second. Removing the fault recovers
|
||||
the membership. A real coordinator/producer case proves thirty seconds without
|
||||
SQL during backoff, another company's successful queue seeding during that wait,
|
||||
and eventual recovery without losing the original work. All 25 worker cases pass.
|
||||
The first consumer command omitted the external DB setting for queue routes and
|
||||
skipped those six cases; the corrected command executes and passes all six.
|
||||
The shared PR predicate is exercised by both those writer tests and worker tests.
|
||||
Server typecheck and build pass; fresh remote review/checks remain required.
|
||||
|
||||
The final startup, approval-retention, and attention consumer run passes all 93
|
||||
cases. With the 25 worker and six queue cases, 124 affected tests pass.
|
||||
|
||||
|
||||
## Current retention-parent integration
|
||||
|
||||
The seeding worker reuses `firstApprovalIssueId` from the approval-visibility
|
||||
owner rather than retaining its own first-link SQL. Company IDs are part of the
|
||||
approval notification contract because projection reconciliation is scoped.
|
||||
Generic approval writes notify retention and seeding only; the existing terminal
|
||||
approve/reject/cancel owners alone notify tool-review consumers. Current precise
|
||||
issue-deletion target notifications, unseen retention controls, finite retention
|
||||
cycles and the real source-row lock regressions are preserved.
|
||||
|
||||
All 522 cases in fifteen complete suites pass at `4e725e2055`, with server
|
||||
typecheck and production build. This includes seeding, queue routes, work products,
|
||||
issue comments, both retention workers, approvals, attention, retention controls,
|
||||
budgets, tool gateway, startup, scoped deadlines, environment cleanup and task
|
||||
watchdogs. Four new disposable fixtures were cloned from the already migrated
|
||||
parent fixture; this is not a fresh-empty-schema claim. The other suites used
|
||||
separate existing disposable databases. No database was shared with another
|
||||
concurrent local test run. The subsequent parent merge changes only two documents;
|
||||
all tested runtime and acceptance sources remain byte-identical.
|
||||
|
||||
The first integration typecheck caught two incorrect gateway company references.
|
||||
They now use the existing session company, and typecheck plus the complete gateway
|
||||
suite pass. Earlier deadlock, cross-company wake, backoff, skipped-fixture and
|
||||
teardown failures remain in the historical sections above. The parent's attention
|
||||
cleanup timeout and expiry-test CI assertion are recorded in its document; this
|
||||
larger current run passes without changing their assertions or timeouts. Fresh
|
||||
exact-head reviews and CI remain required before this dependent PR can merge.
|
||||
|
||||
|
||||
## Final master and review repairs
|
||||
|
||||
Join retention merged as `8406047b4e`. The normal master reconciliation first
|
||||
proved that landed tree equals the existing parent. Conflict resolution preserved
|
||||
the complete tested seeding tree; it also removed a duplicate helper introduced
|
||||
by automatic merge. The PR again differs from master in only its original 25
|
||||
seeding paths. Database schema and migration metadata are identical to master.
|
||||
|
||||
Three retry regressions failed before repair: ongoing arrivals skipped a recovered
|
||||
membership, a partial successful batch retained the old sixty-second failure
|
||||
penalty, and resetting that counter on an empty cursor rollover weakened outage
|
||||
backoff. The worker now fixes the upper ID per cycle and resets consecutive
|
||||
failures only on real successful progress or complete reconciliation. Exact
|
||||
one-second recovery and two-second consecutive-failure assertions pass; repeated
|
||||
signals still cannot reset a failing scope's delay. No new table or retry wrapper
|
||||
is introduced.
|
||||
|
||||
The external seeding, queue-route and watchdog fixtures now apply pending
|
||||
migrations before creating their client. Each of three empty-database regressions
|
||||
failed with missing tables before this setup repair. All 55 cases then passed on
|
||||
a second set of genuinely empty databases, with the suites applying their own
|
||||
migrations. The final additional rollover guard brings the three complete suites
|
||||
to 56 passing cases (28 seeding, 6 queue-route, 22 watchdog), using those now
|
||||
migrated fixtures. Server types/build pass at `7948136ef8`. The earlier 522-case
|
||||
integration run remains evidence for its stated revision and unchanged consumers;
|
||||
it is not relabeled as a current full-suite run.
|
||||
|
||||
The first expanded fresh run passed 54 cases and failed the new backoff test's
|
||||
cleanup expectation: after an entirely failed full page, a separate empty pass
|
||||
ends the cycle before the next pass revisits rows. The corrected fixture permits
|
||||
that explicit continuation and still checks complete recovery and the exact retry
|
||||
deadline. Earlier failures remain recorded. Fresh final-head CI and reviews are
|
||||
required before merge.
|
||||
Loaded 100 of 1288 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user