## 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]>
21 KiB
Motion spec - the V1 MVP hover motion every lab carries over
Single source for the hover motion a directions lab ships: curve, timing, what moves, what fades, how it
is gated, and the hooks a component must carry. Worked example: the artifact lab (ui/src/artifact-directions/)
and its product port (ui/src/components/artifacts/v1/, ui/src/index.css). Line numbers were opened and
confirmed on branch claude/directions-lab-skill on 2026-10-24. Short paths (tokens.css, Stack.tsx,
Tile.tsx, Front.tsx, MediaPrint.tsx, PanelRow.tsx) are under ui/src/artifact-directions/directions/d5-v1-mvp/;
Artifact*.tsx and geometry.ts are under ui/src/components/artifacts/v1/.
Legend: GENERIC = reuse for any feature, verbatim. ARTIFACT-SPECIFIC = belongs to the artifact lab; redraw per feature (targets, magnitudes, angles, colours).
Contents
- The one curve and its token names
- What transitions (transform) and what fades (Tailwind
transition-colors) - The movement: 6px rise, 5° fan, resting angles, transform-origin
- Gating: lab double gate vs product single gate (paste-ready CSS)
- Hover fills: tile, pill, row per theme
- What does not animate
- Hooks a component must carry, and the bare-print lesson
- Lab plumbing for the Animation toggle
ui/src/motion-tokens.cssand how the lab curve relates- Operational notes: the board and
shot.cjsnever show motion - Checklist for a new direction's
tokens.css
1. The one curve and its token names
GENERIC. One curve for every hover movement: 300ms cubic-bezier(0.3, 0, 0.1, 1). Brisk start,
long settle (more ease-out than ease-in). Never add a second curve or duration for movement.
| Form | Token | Declared at |
|---|---|---|
| Lab | --v1-hover-ease |
tokens.css:80-82, in a second [data-direction="d5-v1-mvp"] block |
| Product | --artifact-hover-ease |
ui/src/index.css:531, in :root; not re-declared in .dark |
Name it --<p>-hover-ease in a lab (<p> = the direction's token prefix) and
--<feature>-hover-ease in product.
2. What transitions and what fades
GENERIC. Two timings on one hover, by design:
- Movement: exactly
transition: transform var(--…-hover-ease);on the fanning backings and the front (tokens.css:85-87;index.css:4812-4814). Nothing else (filter,box-shadow,opacity,scale) transitions with this curve. - Colour: Tailwind
transition-colorsin the markup, never in the token CSS (Tile.tsx:30,53;PanelRow.tsx:91; productArtifactTile.tsx:27,45,ArtifactRow.tsx:93). Tailwind 4.3.3 (ui/node_modules/tailwindcss->.pnpm/[email protected]) setstransition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-toat the theme defaults--default-transition-duration: 150ms; --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1)(ui/node_modules/tailwindcss/theme.css:492-493). Nothing underui/srcoverrides them.
Colour fades are never gated (not on the lab toggle, not on reduced motion). A scaffold that moves
everything into CSS must add transition-colors (or an explicit background-color transition) to
the tile, pill and row, or the fills snap.
3. The movement
ARTIFACT-SPECIFIC magnitudes, GENERIC pattern.
3.1 The 6px rise
While the tile is hovered the artwork root [data-front] gets transform: translateY(-6px)
(tokens.css:88-90; product [data-artifact-front], index.css:4815-4817). There is no rest rule
for the front: rest is the identity transform. The front only translates, so its transform-origin
is irrelevant.
3.2 The 5° fan of the two backing pages
Each backing carries data-rotate (rest) and data-spread="left"|"right" (hover side). The rest
rule is ungated: [data-direction="d5-v1-mvp"] [data-rotate] { transform: rotate(var(--v1-base, 0deg)); }
(tokens.css:68-70, under the comment at 67; product [data-artifact-rotate], index.css:4808-4810). On hover: left
rotate(calc(var(--v1-base, 0deg) + 5deg)), right rotate(calc(var(--v1-base, 0deg) - 5deg))
(tokens.css:91-96; index.css:4818-4823). The 0deg fallback means an element with data-rotate
but no inline base rests flat and still turns 5°.
State the directions as the code does: left page +5deg, right page −5deg (Stack.tsx:36). CSS positive
rotate is clockwise, so tokens.css:76-77 reads "the left page clockwise and the right page counter-clockwise".
Do not describe this as the pages fanning "apart": a left page turning clockwise about its centre moves its top
edge right and its bottom edge left; a right page turning counter-clockwise moves its top edge left and its
bottom edge right, so the tops converge and the bottoms diverge. The media right page crosses upright (4° -> −1°).
3.3 Resting angles (lab Stack.tsx, product geometry.ts)
| Backing | Element | Rest | spread |
Hover | Source |
|---|---|---|---|---|---|
| Sheet (text, documents) | backing-short.svg img, file 240.409×247.814 |
−14° | left | −9° | Stack.tsx:58-61; geometry.ts:53 |
| Sheet | plain landscape div 239.509×179.632, 12px radius | −5° | right | −10° | Stack.tsx:63, 102-117; geometry.ts:56 |
| Media (image, video, audio) | backing-tall-a.svg img, 240.409×276.151, painted first (behind), centre y +2 |
4° | right | −1° | Stack.tsx:69-71; geometry.ts:61 |
| Media | backing-tall-b.svg img, painted second |
−7.64° | left | −2.64° | Stack.tsx:72; geometry.ts:62 |
Sheet composition 254.253×241.044, front at left 37.13, top 25.04 (Stack.tsx:98, 118; geometry.ts:51, 57).
Media composition max(55.73 + 195.048, left + print width) × 240.337, front anchored by MEDIA_ANCHOR
(Stack.tsx:74-79, 87-89). The angle reaches CSS as an inline custom property per element, cast
as CSSProperties: "--v1-base": `${rotate}deg` (Stack.tsx:50, 115; product ArtifactStack.tsx:33, 85).
3.4 transform-origin rule
GENERIC for any rotated padded asset. Rotate about the visible shape's own centre
(Stack.tsx:41-51; product ArtifactStack.tsx:25-34, filter moved to .artifact-backing,
index.css:4797-4799):
const originX = sheet.left + sheet.width / 2;
const originY = sheet.top + sheet.height / 2;
const style = {
left: centre.x - originX,
top: centre.y - originY,
width,
height,
transformOrigin: `${originX}px ${originY}px`,
filter: "var(--v1-shadow-sheet)",
"--v1-base": `${rotate}deg`,
} as CSSProperties;
Short sheet origin (120.2045, 120.507); tall sheet origin (120.2045, 134.6755). Measure sheet.top
from the file: the sheets sit 30px from the left and right edges but 26.6px from the top and 33.4px
from the bottom (Stack.tsx:58, 69), the shadow's 3.406px y-offset. The comment at Stack.tsx:17-18
("30px larger than the sheet on every side") is inexact; deriving the origin from it puts it 3.4px
low. The landscape div sets no origin: it is sized to the sheet, so the default 50% 50% is its
centre (Stack.tsx:102-117).
4. Gating
GENERIC. Keep three rule groups apart:
- Rest rules (
[data-rotate]angle) and every colour rule: outside both gates. - The
transition: transformrule: inside the reduced-motion query, beside the hover transforms (hygiene: underreducenothing changestransform, so an ungated transition has nothing to animate). - The hover transforms: inside the query and, in the lab, behind the Animation toggle.
Movement is :hover only. Fills and the pill step also fire on :focus-visible (tokens.css:104;
index.css:4805), so keyboard focus colours but never moves. By design in both forms.
4.1 Lab form: double gate (html[data-lab-anim="1"] AND reduced motion)
Paste into ui/src/<lab>/directions/<id>/tokens.css; replace <id> and <p>; keep the curve, the
gates, -6px and ±5deg verbatim. Source: tokens.css:68-97; 67 is the comment above the block.
[data-direction="<id>"] [data-rotate] {
transform: rotate(var(--<p>-base, 0deg));
}
[data-direction="<id>"] {
--<p>-hover-ease: 300ms cubic-bezier(0.3, 0, 0.1, 1);
}
@media (prefers-reduced-motion: no-preference) {
html[data-lab-anim="1"] [data-direction="<id>"] :is([data-rotate][data-spread], [data-front]) {
transition: transform var(--<p>-hover-ease);
}
html[data-lab-anim="1"] [data-direction="<id>"] button:hover [data-front] {
transform: translateY(-6px);
}
html[data-lab-anim="1"] [data-direction="<id>"] button:hover [data-spread="left"] {
transform: rotate(calc(var(--<p>-base, 0deg) + 5deg));
}
html[data-lab-anim="1"] [data-direction="<id>"] button:hover [data-spread="right"] {
transform: rotate(calc(var(--<p>-base, 0deg) - 5deg));
}
}
button:hover assumes the lab tile is a <button> (Tile.tsx:49-54). If a lab's tiles are anchors,
key on a class instead, as product does. Only d5-v1-mvp/tokens.css has rules keyed on data-lab-anim
(repo grep; main.tsx:36 and lab-state.ts:15 name it only in comments); a direction that wants the
toolbar toggle to do anything must prefix its movement rules this way, or the toggle is a no-op for it.
4.2 Product form: single gate (reduced motion only)
Paste into ui/src/index.css after the feature's component rules; replace <feature>. Source:
index.css:4808-4824 (the file's last block).
[data-<feature>-rotate] {
transform: rotate(var(--<feature>-base, 0deg));
}
@media (prefers-reduced-motion: no-preference) {
.<feature>-tile :is([data-<feature>-rotate][data-<feature>-spread], [data-<feature>-front]) {
transition: transform var(--<feature>-hover-ease);
}
.<feature>-tile:hover [data-<feature>-front] {
transform: translateY(-6px);
}
.<feature>-tile:hover [data-<feature>-spread="left"] {
transform: rotate(calc(var(--<feature>-base, 0deg) + 5deg));
}
.<feature>-tile:hover [data-<feature>-spread="right"] {
transform: rotate(calc(var(--<feature>-base, 0deg) - 5deg));
}
}
Product drops the lab gate and keys on the class artifact-tile, which sits on a <Link> or a
<button> (ArtifactTile.tsx:44-45 TILE; 59-61 Link; 63-65 button). The product contract is
written at doc/plans/2026-10-22-artifacts-mvp-v1.md:37-38.
4.3 Theme scoping (both forms)
Lab: dark values are the base inside [data-direction="d5-v1-mvp"] { … } (tokens.css:5-52); light overrides
live under html:not(.dark) [data-direction="d5-v1-mvp"] (tokens.css:56-65), which matches only because the
lab toggles dark on document.documentElement (main.tsx:30-34); the kit contract also allows
.dark [data-direction="<id>"] (ui/src/artifact-directions/kit.ts:11-13). Product inverts it: light in :root
(index.css:495-531), dark in .dark (index.css:591-600, right after --background/--foreground). Copying
a block between forms without flipping swaps every fill; keep the dark/light value pairs identical.
5. Hover fills
ARTIFACT-SPECIFIC values, GENERIC shape (rest token + hover token per surface, applied through
Tailwind arbitrary-variable utilities that resolve only under the [data-direction] wrapper).
| Surface | Theme | Rest | Hover | Lab | Product |
|---|---|---|---|---|---|
| Tile (370px cell, 12px radius) | dark | transparent | #202020 (mock, on the #171717 canvas) |
tokens.css:38-41 |
index.css:516-519, 596 |
| Tile | light | transparent | var(--accent) |
tokens.css:60 |
index.css:519 |
| Pill | dark | #333333 |
#333333 (unchanged) |
tokens.css:16-18 |
index.css:597-598 |
| Pill | light | var(--muted) |
var(--border) |
tokens.css:61-62 |
index.css:520-522 |
| Panel row (6.4px radius, 12px gap) | dark | #262626 |
#2c2c2c |
tokens.css:46-49 |
index.css:599-600 |
| Panel row | light | var(--muted) |
#eeeeee |
tokens.css:63-64 |
index.css:523-526 |
Markup: tile rounded-(--v1-tile-radius) … transition-colors hover:bg-(--v1-tile-hover) focus-visible:bg-(--v1-tile-hover) (Tile.tsx:53); pill <span data-pill className="v1-pill rounded-full px-3 py-1 text-xs font-normal leading-4 transition-colors"> (Tile.tsx:30), fills from tokens.css:99-106; row
rounded-(--v1-row-radius) bg-(--v1-row) … transition-colors hover:bg-(--v1-row-hover) (PanelRow.tsx:91).
Product: ArtifactTile.tsx:27, 45; index.css:4800-4807; ArtifactRow.tsx:93 (fill on an outer
<div data-artifact-row>, :177; focus ring on the inner control, :94).
Why light needs an explicit pill step: the light tile hover fill --accent has --muted's value, so
a --muted pill would vanish into the hovered tile; --v1-pill-hover: var(--border) is one token
shade under it (tokens.css:62; index.css:521). Dark keeps the pill unchanged. If a new feature's
hover fill differs from the pill's rest colour, drop the step. The row hover step is not in the mock
(tokens.css:43-47); light #eeeeee is one step under #f5f5f5 as dark steps #262626 up to
#2c2c2c (tokens.css:64).
6. What does not animate
GENERIC rule, ARTIFACT-SPECIFIC values.
- Shadows: static
filter: var(--v1-shadow-sheet)on.v1-sheet(tokens.css:116-121) and inline on backing imgs (Stack.tsx:49); staticbox-shadow: var(--v1-shadow-print)on.v1-print(tokens.css:108-114) and, with the inset stroke prepended, on the landscape div (Stack.tsx:114). No transition, no hover rule; they ride with the transformed element. Dark alpha 0.18, light 0.0924 (tokens.css:24-25, 58-59;index.css:507-508, 594-595). Porting: box-shadow blur 20px = drop-shadow blur 10px = SVG stdDeviation 10. - Stroke: the 1px inner hairline is a
::afteroverlay on sheets (tokens.css:123-131;index.css:4782-4789) and is baked into the three backing SVGs (stroke="#1A1A1A" stroke-opacity="0.1" stroke-width="2"under a clip path; none contains a<filter>). Thetokens.css:22-23comment about a baked-in filter is stale. - Scale and opacity: nothing scales or fades on hover in either form. Nothing moves on
:focus-visible.
7. Hooks a component must carry
GENERIC names-with-prefix, ARTIFACT-SPECIFIC placement.
| Role | Lab | Product |
|---|---|---|
| Hover trigger | <button> (Tile.tsx:49-54) |
class artifact-tile on Link or button (ArtifactTile.tsx:44-45, 59, 63) |
| Artwork root (rises) | data-front on the wrapper div (Front.tsx:17, 25, 31); the media print carries it itself via data-front={front ? "" : undefined} (MediaPrint.tsx:41, signature :38) |
data-artifact-front on the wrapper div (ArtifactFront.tsx:26, 34, 40) |
| Backing (rests, fans) | data-rotate data-spread={spread} on the img (Stack.tsx:52) and the landscape div (Stack.tsx:103-104) |
data-artifact-rotate data-artifact-spread={spread} plus class artifact-backing (ArtifactStack.tsx:37-38, 44) |
| Resting angle | inline --v1-base (Stack.tsx:50, 115) |
inline --artifact-base (ArtifactStack.tsx:33, 85) |
| Pill | data-pill + class v1-pill (Tile.tsx:30) |
data-artifact-pill + class artifact-pill (ArtifactTile.tsx:27) |
The bare-print lesson: a print rendered without the front hook stands still while the backings
turn. In this checkout and on master, ArtifactFront.tsx:30-31 returns a bare <ArtifactPrint …>
for the media treatment, so a picture or poster-less video front does not rise. Commit 95fb3e219
on branch claude/artifacts-v1-picture-front-rise wraps it in <div data-artifact-front className="shrink-0" style={style}> and adds the test "gives a single picture the same hover-lift
hook as a sheet". Put the hook on every treatment branch and assert it: here
ArtifactTile.test.tsx:50 checks querySelectorAll("[data-artifact-spread]") has length 2 and :97
that [data-artifact-front] exists; on the fix branch, [data-artifact-front] contains the print.
Tailwind h-(--v1-tile-h), rounded-(--v1-tile-radius), bg-(--v1-row) resolve only under an ancestor
that defines them; outside Slot.tsx (ui/src/artifact-directions/frames/Slot.tsx:8-14) a kit collapses.
8. Lab plumbing for the Animation toggle
GENERIC. Lab-level, not direction-level. Copy unchanged; only the toolbar hint names the direction.
ui/src/artifact-directions/main.tsx:36-41, then useEffect(() => applyAnim(state.anim), [state.anim]);
at :151:
function applyAnim(anim: boolean) {
if (anim) document.documentElement.dataset.labAnim = "1";
else delete document.documentElement.dataset.labAnim;
}
applyAnim(initial.anim);
Keyboard: bare a toggles (main.tsx:157), ignored with meta/ctrl/alt or while typing in
INPUT/TEXTAREA/SELECT/contentEditable (main.tsx:52-54, 155). URL state in
ui/src/artifact-directions/lab-state.ts: default on, read as anim: params.get("anim") !== "0",
(:45); written only when off, if (!state.anim) params.set("anim", "0"); (:52).
Toolbar (ui/src/artifact-directions/frames/LabToolbar.tsx:68-71): ANIM_OPTIONS Segmented off/on,
hints "Hover animations off (toggle with a)" / "Hover animations on: MVP V1 artwork rises, stack pages
turn (toggle with a)"; rendered at :91-94 with an inline <span>animation</span> label to the LEFT of
the Segmented, in the right-aligned ml-auto flex items-center gap-4 group with Theme (:89); legend
"0–5 kit · t theme · a animation" (:100). The entry ui/artifact-directions.html:2 is <html lang="en" class="dark" style="color-scheme: dark"> with no data-lab-anim; the attribute exists only after main.tsx:41.
9. ui/src/motion-tokens.css and the lab curve
ui/src/motion-tokens.css (imported at index.css:1) defines --motion-ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1),
--motion-ease-standard: cubic-bezier(0.4, 0, 0.2, 1), --motion-duration-instant: 80ms,
--motion-duration-fast: 160ms (:4-7); the rest are --onboarding-motion-*. None equals the hover curve.
--motion-ease-standard is Tailwind's default and so is the colour-fade curve; 0.3, 0, 0.1, 1 starts brisker
and settles longer than it, and is gentler than out-expo. No --motion-duration-* token is 300ms (only onboarding's
--onboarding-motion-card-enter, :17). The artifact port kept
--artifact-hover-ease in index.css:531 beside its feature tokens and added nothing to motion-tokens.css.
A new feature either does the same or promotes the curve to a named motion token; decide once and record it.
10. Operational notes
GENERIC. Neither capture path shows the motion; verify hover by hand.
- The overview board (
ui/src/artifact-directions/frames/Board.tsx) renders every tile as this same page in a same-origin<iframe src={labSearch({ direction, view, theme, chrome: false, anim })}>(:54, 64-72) atFRAME_WIDTH = 1440,FRAME_MIN_HEIGHT = 900(:12-13) scaled down, withpointer-events-noneon the iframe (:70): a pointer never reaches a tile'sbutton:hover.animreaches each iframe only through the URL param, which that document's ownmain.tsx:27, 41applies to its own<html>; keep it inlabSearch. ui/src/artifact-directions/shot.cjs:121-125captures withbrowser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2, reducedMotion: "reduce" }), so theno-preferenceblock does not exist in a capture, and nothing hovers anyway (lab-state.ts:13-15). Captures show the rest state in every kit.- To verify: open a single surface, e.g.
?d=d5-v1-mvp&s=gallery&theme=dark(Animation defaults on), confirmdocument.documentElement.dataset.labAnim === "1", hover a tile and watch the rise and fan; pressa: the fill still steps, nothing moves. Same under the OS reduced-motion preference.
11. Checklist for a new direction's tokens.css
- Declares
--<p>-hover-ease: 300ms cubic-bezier(0.3, 0, 0.1, 1)under[data-direction="<id>"]; no other curve. transition: transform var(--<p>-hover-ease)is the file's only transition; target:is([data-rotate][data-spread], [data-front]).- Rest rule
[data-rotate] { transform: rotate(var(--<p>-base, 0deg)); }sits outside both gates. - Transition rule and the three hover transforms sit inside
@media (prefers-reduced-motion: no-preference), each prefixedhtml[data-lab-anim="1"] [data-direction="<id>"]. - Front rises
translateY(-6px); left page+ 5deg; right page- 5deg, incalc()on the inline base. - Hover selectors key on the real trigger element (
button:hover, or a class when tiles are anchors); movement is:hoveronly; fills and the pill step use:is(:hover, :focus-visible). - Every colour change rides Tailwind
transition-colorsin the markup, outside both gates. - Rest and hover tokens for tile, pill and row in both themes; a pill step only where the hover fill equals the pill's rest.
- Shadows and strokes are static tokens with no transition and no hover rule; no scale or opacity change.
- Light values sit in the base
[data-direction="<id>"]block and dark overrides under.dark [data-direction="<id>"](theindex.cssidiom; d5'shtml:not(.dark)form is the copy hazard in 4.3); dark/light pairs match the product form's:root/.darkpairs exactly. - Components carry
data-front,data-rotate,data-spread, inline--<p>-baseand a geometry-derivedtransform-origin; every front treatment branch carriesdata-front, with a test that asserts it. - Verified by hovering in a single-surface view with Animation on, not from the board or a
shot.cjscapture.