diff --git a/.agents/skills/directions-lab/SKILL.md b/.agents/skills/directions-lab/SKILL.md new file mode 100644 index 00000000000..5d505c8b252 --- /dev/null +++ b/.agents/skills/directions-lab/SKILL.md @@ -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/-directions.html` plus `ui/src/-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/-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 "" ui/src/components ui/src/pages +rg -n "" maps/feature-map/*.md +``` + +The first grep gives the component files, the flag hook +(`useEnabled`), 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 () 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|`, +`--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: "-directions-dev"}`, and open three URLs: `/` (the landing), +`/?d=today&s=` and `/?d=&s=` +(`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 `[-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 +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 | diff --git a/.agents/skills/directions-lab/agents/openai.yaml b/.agents/skills/directions-lab/agents/openai.yaml new file mode 100644 index 00000000000..1ad24823d4c --- /dev/null +++ b/.agents/skills/directions-lab/agents/openai.yaml @@ -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." diff --git a/.agents/skills/directions-lab/assets/PRECEDENT b/.agents/skills/directions-lab/assets/PRECEDENT new file mode 100644 index 00000000000..127faa80685 --- /dev/null +++ b/.agents/skills/directions-lab/assets/PRECEDENT @@ -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 diff --git a/.agents/skills/directions-lab/assets/motion/v1-mvp-motion.css b/.agents/skills/directions-lab/assets/motion/v1-mvp-motion.css new file mode 100644 index 00000000000..7e8e16f6d97 --- /dev/null +++ b/.agents/skills/directions-lab/assets/motion/v1-mvp-motion.css @@ -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)); + } +} diff --git a/.agents/skills/directions-lab/assets/templates/api-routes.ts.mjs b/.agents/skills/directions-lab/assets/templates/api-routes.ts.mjs new file mode 100644 index 00000000000..4942af1f027 --- /dev/null +++ b/.agents/skills/directions-lab/assets/templates/api-routes.ts.mjs @@ -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(); +`; diff --git a/.agents/skills/directions-lab/assets/templates/api-stub.ts.mjs b/.agents/skills/directions-lab/assets/templates/api-stub.ts.mjs new file mode 100644 index 00000000000..6d1d28dd0f5 --- /dev/null +++ b/.agents/skills/directions-lab/assets/templates/api-stub.ts.mjs @@ -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; + }; +} +`; diff --git a/.agents/skills/directions-lab/assets/templates/build-info.ts.mjs b/.agents/skills/directions-lab/assets/templates/build-info.ts.mjs new file mode 100644 index 00000000000..2697fb3f7f4 --- /dev/null +++ b/.agents/skills/directions-lab/assets/templates/build-info.ts.mjs @@ -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"; +`; diff --git a/.agents/skills/directions-lab/assets/templates/define-lab.ts.mjs b/.agents/skills/directions-lab/assets/templates/define-lab.ts.mjs new file mode 100644 index 00000000000..f2e14d12cb8 --- /dev/null +++ b/.agents/skills/directions-lab/assets/templates/define-lab.ts.mjs @@ -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; +} +`; diff --git a/.agents/skills/directions-lab/assets/templates/direction/MotionSpecimen.tsx.mjs b/.agents/skills/directions-lab/assets/templates/direction/MotionSpecimen.tsx.mjs new file mode 100644 index 00000000000..b4381750568 --- /dev/null +++ b/.agents/skills/directions-lab/assets/templates/direction/MotionSpecimen.tsx.mjs @@ -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> + ); +} +`; +}; diff --git a/.agents/skills/directions-lab/assets/templates/direction/SlotBody.tsx.mjs b/.agents/skills/directions-lab/assets/templates/direction/SlotBody.tsx.mjs new file mode 100644 index 00000000000..81227e606d8 --- /dev/null +++ b/.agents/skills/directions-lab/assets/templates/direction/SlotBody.tsx.mjs @@ -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> + ); +} +`; diff --git a/.agents/skills/directions-lab/assets/templates/direction/index.tsx.mjs b/.agents/skills/directions-lab/assets/templates/direction/index.tsx.mjs new file mode 100644 index 00000000000..753bf42fb70 --- /dev/null +++ b/.agents/skills/directions-lab/assets/templates/direction/index.tsx.mjs @@ -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")} +}; +`; diff --git a/.agents/skills/directions-lab/assets/templates/direction/tokens.css.mjs b/.agents/skills/directions-lab/assets/templates/direction/tokens.css.mjs new file mode 100644 index 00000000000..78adda7feb4 --- /dev/null +++ b/.agents/skills/directions-lab/assets/templates/direction/tokens.css.mjs @@ -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)}`; +}; diff --git a/.agents/skills/directions-lab/assets/templates/entry.html.mjs b/.agents/skills/directions-lab/assets/templates/entry.html.mjs new file mode 100644 index 00000000000..28e35b0a932 --- /dev/null +++ b/.agents/skills/directions-lab/assets/templates/entry.html.mjs @@ -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 + + + + +
+ + + +`; diff --git a/.agents/skills/directions-lab/assets/templates/fixtures.ts.mjs b/.agents/skills/directions-lab/assets/templates/fixtures.ts.mjs new file mode 100644 index 00000000000..960261af477 --- /dev/null +++ b/.agents/skills/directions-lab/assets/templates/fixtures.ts.mjs @@ -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")} +`; diff --git a/.agents/skills/directions-lab/assets/templates/frames/AppShell.tsx.mjs b/.agents/skills/directions-lab/assets/templates/frames/AppShell.tsx.mjs new file mode 100644 index 00000000000..020e016a0ef --- /dev/null +++ b/.agents/skills/directions-lab/assets/templates/frames/AppShell.tsx.mjs @@ -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 ( + + {Icon ? ( + + + + ) : null} + {label} + + ); +} + +function Section({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
+
+
+ {label} +
+
+
+
{children}
+
+ ); +} + +function Sidebar({ active }: { active: ShellNav }) { + return ( + + ); +} + +export function SupportButton() { + return ( + + + + Support + + + ); +} + +export function AppShell({ active, children }: { active: ShellNav; children: ReactNode }) { + return ( +
+
+ +
{children}
+
+
+ ); +} +`; diff --git a/.agents/skills/directions-lab/assets/templates/frames/Board.tsx.mjs b/.agents/skills/directions-lab/assets/templates/frames/Board.tsx.mjs new file mode 100644 index 00000000000..11e150ffd53 --- /dev/null +++ b/.agents/skills/directions-lab/assets/templates/frames/Board.tsx.mjs @@ -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(null); + const contentObserver = useRef(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) => { + 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 ( + +