Files
paperclip/.agents/skills/directions-lab/assets/templates/shot.cjs.mjs
T
TonioandClaude Opus 5.5 dfedd73f56 feat(skills): add the directions-lab skill and its lab generator (#17437)
## Thinking Path

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

## Linked Issues or Issue Description

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

**Subsystem affected**

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

**Problem or motivation**

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

**Proposed solution**

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

**Alternatives considered**

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

**Roadmap alignment**

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

## What Changed

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

## Verification

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

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

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

## Risks

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

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-26 12:26:44 -07:00

241 lines
10 KiB
JavaScript

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);
});
`;