## 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]>
35 KiB
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
- Today kit: the production control
- Adapters: lab fixtures to product types
- API stub
- Fixture world
- HTML entry and what main.tsx mounts
- Lab Vite config, option by option
- launch.json: two entries
- Product build, tsc, vitest, token gates
- Tests the lab carries
- shot.cjs: capture
- Vercel deploy
- 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/whoseindex.tsxexportskit: 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-23mirrors the!artifactsV1blocks atui/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:7carriesgrid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3, found once atui/src/pages/Artifacts.tsx:317; the file's own comment cites a stale line). - Register the id in
DirectionId,DIRECTION_IDS,KIT_KEYSandKIT_META(kit.ts:16,130,133-140,143-150). The toolbar and board readKIT_META; the kit's ownname/taglineare 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 inregistry.ts: itskit.tsderivesDirectionId,DIRECTION_IDS,KIT_KEYSandKIT_METAfrom the registry, andregistry.test.tsfails when a kit'sid,nameortaglinediffers from the registry's (scaffold.mjs checkcomparesidandname).
Kit discovery (kit.ts:157-163), GENERIC verbatim:
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:ArtifactGroupCardandArtifactCardon the product grid (Gallery.tsx:10-34).PanelBody: the realIssuePropertiesArtifactsTab, given{ id: registerPanel(artifacts), companyId: COMPANY.id } as IssueandonOpenDocument={noop}(PanelBody.tsx:14-19). The tab reads onlyissue.idandissue.companyId; the product also passesuploadState(ui/src/components/task-side-panel/TaskSidePanel.tsx:770), which Today omits.ThreadWorkProduct: a task document becomes aTaskChatProtocolCardresource item withsubtitle: `Document · rev ${artifact.revision}`; anything else becomesRichWorkProductCardwithworkProductHref(workProduct)(ThreadWorkProduct.tsx:13-31; the product split isui/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):
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 anApiRoute = (pathname, method) => unknown | undefined(:14-20).respond()tries routes in registration order; the first body!== undefinedis returned as JSON 200 (:25-29).- Built-in fallbacks (
:30-33): a pathname ending/auth/get-sessionanswers a lab session and user; one ending/companiesanswers[COMPANY]. They satisfysessionQueryOptionsandcompanyListQueryOptions(ui/src/api/companies-query.ts:55-59,19-40);CompanyProviderthen selectsselectableCompanies[0]?.id(ui/src/context/CompanyContext.tsx:81). Keep both; swapCOMPANY. - Any other
/api/path answers{}with status 200 afterconsole.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):
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:1imports them). COMPANY = { id, name, issuePrefix }, a partialCompanysufficient for the stub and hrefs (fixtures.ts:22); a product component reading otherCompanyfields seesundefined.- People as string constants plus an
AVATARSmap of imported SVGs (:23-25);TASKSdeclaredas constwithtype TaskId = keyof typeof TASKS(:27-35). - A
MIMEmap keyed by extension (:37); en-US label helpersdateTimeLabel,dateLabel,dayLabel; a 1024-basedsizeLabel;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)derivesext,contentType,sizeLabel,excerpt, the labels andtaskTitle(:85-109);taskDocument(:111-112);run(:78-83);group(:246-255, whereartifacts[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 asimport * as docs(fixtures.ts:2); media as URL imports fromassets/, typed byui/src/vite-env.d.ts:1. - One export per slot or surface the frames render (
PANEL_ARTIFACTS,THREAD_ATTACHMENTS,THREAD_WORK_PRODUCTSat:162-173;GROUPS,OPEN_GROUPat:265-266), plusKIND_SPECIMENS(one per kind inLAB_KINDSorder, then a Markdown file and achanges_requestedspecimen: 12 entries,:269-284) andSTACK_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 LabKinds, 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 {
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:
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 fromui/.base: unset (/), so the built page links/assets/...absolutely and the rename below cannot break them.- Port and out dir (
:40-56), verbatim:
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 makesfs.watchfail with EMFILE and Vite's watcher goes silent; polling a few hundred lab files is cheap. Edits to product components,ui/publicor othersrc/files do not reach the page; restart the server. - Plugins (
:79-103), verbatim:
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:
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'sindex.htmland boot the real app, which calls/apiand 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.tsandui/vitest.config.ts:5-10, or product components do not resolve. cacheDirper port: the defaultnode_modules/.viteis 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 noesbuild.drop: the lab keepsconsoleso shot.cjs can fail on console errors. The siblingvite.preview.config.mjsandvite.flow-preview.config.mjsdrop console; copying them silences the gate.- Deliberately absent, though
ui/vite.config.tshas them:serviceWorkerBuildIdPlugin(),preloadIssueDetail(),define.__PAPERCLIP_BUILD_COMMIT__,rolldownOptions.output.codeSplitting. Vite is 8.2.2 (rolldown);rollupOptions.inputstill builds without a deprecation warning. A generated config adds its owndefine,__<SLUG>_DIRECTIONS_BUILD_COMMIT__fromgit rev-parse --short HEAD("unknown"when git fails), whichbuild-info.tsexposes asLAB_BUILD("dev"when undefined) for the toolbar tooltip. publicDirunset, soui/publicis copied verbatim intoOUT_DIR, includingsw.js. Do not setpublicDir: false:ui/src/index.css:110-128loads/fonts/InterVariable*.woff2and/fonts/PaperMono-Variable.woff2from 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 torollupOptions.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:
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.tshas no exclusion; the lab is simply unreachable fromui/index.htmlandsrc/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 checkis that guard for a generated lab: it fails when<slug>-directionsappears in a.ts,.tsx,.css,.cjs,.mjsor.htmlfile underui/outside the lab, its html, its config, its story,launch.jsonand.gitignore. tsc -bcovers the lab (ui/tsconfig.json:19includessrc;.cjsis not typechecked). Vitest covers it (ui/vitest.config.tshas no include filter), so a half-finished lab breakspnpm test, which is whynewruns the lab's Vitest files and rolls back on failure.scripts/check-token-gates.mjs:80scans onlycomponentsandpages; 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):
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.
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 storyui/storybook/stories/design-proposals/<slug>-directions.stories.tsxwith meta iddesign-<slug>-directions, plugin names<slug>-directions-entry-as-rootand<slug>-directions-html-as-index,cacheDir node_modules/.vite-<slug>-directions-${PORT}, default out dirdist-<slug>-directionsplus the.gitignorelineui/dist-<slug>-directions/, launch entries<slug>-directions-devand<slug>-directions, static dirnode_modules/.<slug>-directions-dist, theconsole.debug("[<slug>-directions] unhandled", ...)tag, Vercel projectpaperclip-<slug>-directions. - env var names:
<SLUG>_DIRECTIONS_OUT_DIR,<SLUG>_DIRECTIONS_PORTand 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.jsonand anyui/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(defaultacmehats:ACM):{ id: "company-<name>", name, issuePrefix }inregistry.shell, three shell tasks<PREFIX>-1..3, and the stub userlab@<name>.example. LAB_NOW and the pinned week are not flags: every lab gets2026-09-28T18:00:00and placeholder records on 09-24, 09-26 and 09-28. - kit ids, digit keys, KIT_META from
--directions 2|3and--names(d<N>-<kebab name>, keyN, nameN.Name, placeholder taglines for Refine, Rework and Rethink, elseDirection N), the surface list from--surfaces id:Label:width@nav(1 to 6,navone oftasks|artifacts|other, neverboard), and the bare-URL default from--landing auto|all|today|<id>. The URL shape is fixed:d/s/theme/chrome/animplusproposal. - shot.cjs: nothing; the generated script reads
window.__LAB__at run time and carries no excuse list. - routes: none;
api-routes.tsregisters only the experimental-settings route, and the routes the Today kit needs are added by hand inregisterFeatureRoutes(), found by grepping the mounted components and from theunhandleddebug lines.