trykeyline 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/.claude-plugin/marketplace.json +18 -0
  2. package/LICENSE +21 -0
  3. package/README.md +117 -2
  4. package/bin/keyline.mjs +280 -0
  5. package/package.json +38 -4
  6. package/plugins/keyline/.claude-plugin/plugin.json +14 -0
  7. package/plugins/keyline/plugin.json +22 -0
  8. package/plugins/keyline/skills/keyline/SKILL.md +47 -0
  9. package/plugins/keyline/skills/keyline/commands/audit.md +30 -0
  10. package/plugins/keyline/skills/keyline/commands/color.md +7 -0
  11. package/plugins/keyline/skills/keyline/commands/critique.md +20 -0
  12. package/plugins/keyline/skills/keyline/commands/depth.md +7 -0
  13. package/plugins/keyline/skills/keyline/commands/init.md +16 -0
  14. package/plugins/keyline/skills/keyline/commands/layout.md +7 -0
  15. package/plugins/keyline/skills/keyline/commands/motion.md +7 -0
  16. package/plugins/keyline/skills/keyline/commands/patterns.md +7 -0
  17. package/plugins/keyline/skills/keyline/commands/polish.md +19 -0
  18. package/plugins/keyline/skills/keyline/commands/refine.md +19 -0
  19. package/plugins/keyline/skills/keyline/commands/sync.md +15 -0
  20. package/plugins/keyline/skills/keyline/commands/type.md +7 -0
  21. package/plugins/keyline/skills/keyline/commands/ux-laws.md +26 -0
  22. package/plugins/keyline/skills/keyline/domains/color/deriving-a-ramp.md +144 -0
  23. package/plugins/keyline/skills/keyline/domains/color/guide.md +207 -0
  24. package/plugins/keyline/skills/keyline/domains/color/status-and-exceptions.md +174 -0
  25. package/plugins/keyline/skills/keyline/domains/color/theming-mechanics.md +121 -0
  26. package/plugins/keyline/skills/keyline/domains/depth/elevation-recipes.md +185 -0
  27. package/plugins/keyline/skills/keyline/domains/depth/fallbacks-and-performance.md +132 -0
  28. package/plugins/keyline/skills/keyline/domains/depth/guide.md +226 -0
  29. package/plugins/keyline/skills/keyline/domains/depth/translucent-materials.md +159 -0
  30. package/plugins/keyline/skills/keyline/domains/layout/forks-gating-and-disclosures.md +201 -0
  31. package/plugins/keyline/skills/keyline/domains/layout/guide.md +259 -0
  32. package/plugins/keyline/skills/keyline/domains/layout/measure-and-rhythm.md +193 -0
  33. package/plugins/keyline/skills/keyline/domains/layout/radius-lines-and-layers.md +122 -0
  34. package/plugins/keyline/skills/keyline/domains/motion/guards.md +162 -0
  35. package/plugins/keyline/skills/keyline/domains/motion/guide.md +198 -0
  36. package/plugins/keyline/skills/keyline/domains/motion/ladder-and-curves.md +139 -0
  37. package/plugins/keyline/skills/keyline/domains/motion/patterns.md +196 -0
  38. package/plugins/keyline/skills/keyline/domains/motion/performance.md +121 -0
  39. package/plugins/keyline/skills/keyline/domains/patterns/actions-and-forms.md +204 -0
  40. package/plugins/keyline/skills/keyline/domains/patterns/agents-and-live-states.md +111 -0
  41. package/plugins/keyline/skills/keyline/domains/patterns/collections-and-dashboards.md +131 -0
  42. package/plugins/keyline/skills/keyline/domains/patterns/copy.md +123 -0
  43. package/plugins/keyline/skills/keyline/domains/patterns/guide.md +174 -0
  44. package/plugins/keyline/skills/keyline/domains/patterns/iteration.md +70 -0
  45. package/plugins/keyline/skills/keyline/domains/type/display-tier.md +108 -0
  46. package/plugins/keyline/skills/keyline/domains/type/guide.md +227 -0
  47. package/plugins/keyline/skills/keyline/domains/type/ladders.md +169 -0
  48. package/plugins/keyline/skills/keyline/domains/type/numerals-and-loading.md +107 -0
  49. package/plugins/keyline/skills/keyline/domains/type/prose-and-emphasis.md +125 -0
  50. package/plugins/keyline/skills/keyline/domains/type/type-audit.md +87 -0
  51. package/plugins/keyline/skills/keyline/glass/color/assets/color-tokens.css +299 -0
  52. package/plugins/keyline/skills/keyline/glass/color/assets/theme.ts +119 -0
  53. package/plugins/keyline/skills/keyline/glass/color/guide.md +195 -0
  54. package/plugins/keyline/skills/keyline/glass/color/intent-ramp.md +136 -0
  55. package/plugins/keyline/skills/keyline/glass/color/theming.md +106 -0
  56. package/plugins/keyline/skills/keyline/glass/depth/assets/GlassPanel.tsx +171 -0
  57. package/plugins/keyline/skills/keyline/glass/depth/assets/glass.css +322 -0
  58. package/plugins/keyline/skills/keyline/glass/depth/blur-and-tint.md +50 -0
  59. package/plugins/keyline/skills/keyline/glass/depth/depth-cues.md +87 -0
  60. package/plugins/keyline/skills/keyline/glass/depth/guide.md +147 -0
  61. package/plugins/keyline/skills/keyline/glass/depth/performance-and-fallbacks.md +37 -0
  62. package/plugins/keyline/skills/keyline/glass/layout/adaptive-presentations.md +231 -0
  63. package/plugins/keyline/skills/keyline/glass/layout/assets/SectionNav.tsx +244 -0
  64. package/plugins/keyline/skills/keyline/glass/layout/assets/layout-tokens.css +201 -0
  65. package/plugins/keyline/skills/keyline/glass/layout/guide.md +259 -0
  66. package/plugins/keyline/skills/keyline/glass/layout/measure-and-rhythm.md +134 -0
  67. package/plugins/keyline/skills/keyline/glass/layout/radius-and-elevation.md +103 -0
  68. package/plugins/keyline/skills/keyline/glass/motion/assets/DropMenu.tsx +106 -0
  69. package/plugins/keyline/skills/keyline/glass/motion/assets/motion-presets.ts +210 -0
  70. package/plugins/keyline/skills/keyline/glass/motion/assets/motion-tokens.css +176 -0
  71. package/plugins/keyline/skills/keyline/glass/motion/assets/useReveal.ts +65 -0
  72. package/plugins/keyline/skills/keyline/glass/motion/assets/useScrollTick.ts +183 -0
  73. package/plugins/keyline/skills/keyline/glass/motion/easing-and-duration.md +108 -0
  74. package/plugins/keyline/skills/keyline/glass/motion/guide.md +255 -0
  75. package/plugins/keyline/skills/keyline/glass/motion/reduced-motion.md +81 -0
  76. package/plugins/keyline/skills/keyline/glass/motion/scroll-performance.md +58 -0
  77. package/plugins/keyline/skills/keyline/glass/motion/shared-element.md +161 -0
  78. package/plugins/keyline/skills/keyline/glass/motion/sound.md +195 -0
  79. package/plugins/keyline/skills/keyline/glass/motion/springs.md +51 -0
  80. package/plugins/keyline/skills/keyline/glass/type/assets/prose.tsx +268 -0
  81. package/plugins/keyline/skills/keyline/glass/type/assets/type-tokens.css +416 -0
  82. package/plugins/keyline/skills/keyline/glass/type/guide.md +220 -0
  83. package/plugins/keyline/skills/keyline/glass/type/long-form-prose.md +151 -0
  84. package/plugins/keyline/skills/keyline/glass/type/scale.md +69 -0
  85. package/plugins/keyline/skills/keyline/glass/type/weight-and-tracking.md +61 -0
  86. package/plugins/keyline/skills/keyline/scripts/check/css.mjs +112 -0
  87. package/plugins/keyline/skills/keyline/scripts/check/design.mjs +67 -0
  88. package/plugins/keyline/skills/keyline/scripts/check/files.mjs +44 -0
  89. package/plugins/keyline/skills/keyline/scripts/check/markup.mjs +149 -0
  90. package/plugins/keyline/skills/keyline/scripts/check/rules.mjs +584 -0
  91. package/plugins/keyline/skills/keyline/scripts/check.mjs +297 -0
  92. package/plugins/keyline/skills/keyline/scripts/contrast.mjs +153 -0
  93. package/plugins/keyline/skills/keyline/scripts/scan.mjs +340 -0
  94. package/plugins/keyline/skills/keyline/system/design-md.md +136 -0
  95. package/plugins/keyline/skills/keyline/system/detection.md +53 -0
  96. package/plugins/keyline/skills/keyline/system/guide.md +97 -0
  97. package/plugins/keyline/skills/keyline/system/presets.md +58 -0
  98. package/plugins/keyline/skills/keyline/system/roles.md +150 -0
@@ -0,0 +1,196 @@
1
+ # Patterns
2
+
3
+ Code uses role placeholders (`<duration.state>`) and generic custom properties; swap in the names `DESIGN.md` maps them to.
4
+
5
+ ## The shared highlight
6
+
7
+ One highlight per group, animating its position and size to whichever item is hovered or focused. It never remounts.
8
+
9
+ ```tsx
10
+ // Motion. The same shape works with GSAP quickTo or React Spring's useSpring.
11
+ const [hover, setHover] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
12
+ const enter = (el: HTMLElement) =>
13
+ setHover({ x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight });
14
+
15
+ <ul className="group-shell" onPointerLeave={() => setHover(null)}> {/* position: relative; isolation: isolate */}
16
+ <m.div
17
+ aria-hidden
18
+ className="highlight" /* absolute; z-index: -1; pointer-events: none */
19
+ initial={false}
20
+ animate={hover ? { x: hover.x, y: hover.y, width: hover.w, height: hover.h, opacity: 1 } : { opacity: 0 }}
21
+ transition={reduce ? { duration: <duration.state> } : { ...<spring.snappy>, opacity: { duration: <duration.micro> } }}
22
+ />
23
+ {items.map((it) => (
24
+ <a key={it.id} onPointerEnter={(e) => enter(e.currentTarget)} onFocus={(e) => enter(e.currentTarget)} … />
25
+ ))}
26
+ </ul>
27
+ ```
28
+
29
+ Six details, each a bug without it:
30
+
31
+ - **`initial={false}`** (outside Motion, set the first position with no transition), or the highlight flies in from the corner on first paint.
32
+ - **Offsets, not `getBoundingClientRect()`.** `offsetLeft` / `offsetTop` are relative to the positioned parent the highlight lives in; viewport rectangles need subtraction and break if the page scrolls between measure and render.
33
+ - **`isolation: isolate` on the group, `z-index: -1` on the highlight**: behind the items but inside the group's stacking context, so it can't drop behind the group's own surface.
34
+ - **`pointer-events: none`**: the highlight sits under the pointer by definition, and would otherwise steal the enter event and oscillate.
35
+ - **Focus alongside hover**, so keyboard users get the same feedback.
36
+ - **Leave on the group, not the items**: per-item leave fires in the gaps and blanks the highlight between items.
37
+
38
+ A tooltip rides the same state on one axis: an outer element animates `x` to the item's centre; an inner one is statically `translate: -50% 0`. A plain coordinate animates cleanly; `calc(centre - 50%)` asks the library to interpolate mixed units.
39
+
40
+ **Current versus hovered.** The current item's static surface drops while the group is hovered, so the travelling highlight owns the surface; two at once make the selection ambiguous. The current item keeps its label colour (colour law 7) throughout, so its place is never lost.
41
+
42
+ ## Layout switches and page-level shared elements
43
+
44
+ For an element that persists across a DOM change — a card becoming a detail view, a thumbnail becoming the open image:
45
+
46
+ ```css
47
+ /* CSS View Transitions — same-document via document.startViewTransition(update),
48
+ cross-document via @view-transition { navigation: auto; } */
49
+ .card-image { view-transition-name: var(--vt-name); } /* unique per element, e.g. set inline per card */
50
+ ::view-transition-group(*) { animation-duration: <duration.overlay>; animation-timing-function: <ease.standard>; }
51
+ @media (prefers-reduced-motion: reduce) {
52
+ ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none; }
53
+ }
54
+ ```
55
+
56
+ - **Motion:** give both ends the same `layoutId`; the library measures and travels between them.
57
+ - **GSAP:** `const state = Flip.getState(els); /* change the DOM */ Flip.from(state, { duration, ease });`
58
+
59
+ Whichever tool, **measure the reading position while the outgoing layout is still rendered** — the item nearest the viewport's centre — and open the incoming layout on it. Where the ends differ in aspect ratio, cross-fade two elements, each at its own aspect and sharing a centre, rather than stretching one into the other's box.
60
+
61
+ ## Presence: open and close
62
+
63
+ ```css
64
+ /* CSS only: a popover or dialog entering from display: none */
65
+ .menu {
66
+ transition: opacity <duration.popover> <ease.colour>, translate <duration.popover> <ease.standard>,
67
+ display <duration.popover> allow-discrete, overlay <duration.popover> allow-discrete;
68
+ opacity: 1; translate: 0 0;
69
+ }
70
+ @starting-style { .menu:popover-open { opacity: 0; translate: 0 calc(var(--motion-travel) * -1); } }
71
+ .menu:not(:popover-open) {
72
+ opacity: 0; translate: 0 calc(var(--motion-travel) * -0.5); /* the exit travels half as far */
73
+ transition-duration: var(--motion-exit); transition-timing-function: <ease.exit>;
74
+ }
75
+ ```
76
+
77
+ ```tsx
78
+ // Motion: AnimatePresence keeps the element mounted for its exit.
79
+ <AnimatePresence>
80
+ {open && (
81
+ <m.div
82
+ initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: -4 }}
83
+ animate={{ opacity: 1, scale: 1, y: 0, transition: reduce ? { duration: <duration.state> } : <spring.soft> }}
84
+ exit={{ opacity: 0, ...(reduce ? {} : { scale: 0.98, y: -2 }), transition: { duration: <exit>, ease: <ease.exit> } }}
85
+ />
86
+ )}
87
+ </AnimatePresence>
88
+ ```
89
+
90
+ Scale from the edge the element is anchored to (`transform-origin` at the trigger): a menu growing from its own middle seems to come from nowhere. The wrapper moves; a child carries the translucent material.
91
+
92
+ ## Fill means chosen, in code
93
+
94
+ The filled state is a second layer over the outline, at the same coordinates, cross-fading in place:
95
+
96
+ ```css
97
+ .icon .fill { opacity: 0; transition: opacity <duration.popover> <ease.colour>; }
98
+ .icon .detail { opacity: 1; transition: opacity <duration.popover> <ease.colour>; } /* interior lines a solid shape swallows */
99
+ .is-current .icon .fill, .item:hover .icon .fill { opacity: 1; }
100
+ .is-current .icon .detail, .item:hover .icon .detail { opacity: 0; }
101
+ @media (prefers-reduced-motion: reduce) { .icon .fill, .icon .detail { transition: none; } }
102
+ ```
103
+
104
+ Where the solid shape would swallow an interior line, knock it out with a mask rather than deleting it: the silhouette in white (fill and stroke, matching the outline exactly), the interior lines in black at the outline's stroke width. A solid chip chosen instead of fill follows the same rule: one treatment, one meaning.
105
+
106
+ ## Hover intent
107
+
108
+ ```ts
109
+ const closeSoon = () => {
110
+ clearTimeout(timer.current);
111
+ timer.current = window.setTimeout(() => setOpen(false), 100);
112
+ };
113
+ const cancelClose = () => clearTimeout(timer.current);
114
+ useEffect(() => () => clearTimeout(timer.current), []); // unmount
115
+ useEffect(() => setOpen(false), [pathname]); // navigation
116
+ ```
117
+
118
+ Bind `closeSoon` to leaving the trigger *and* the panel, `cancelClose` to entering either. Any `setTimeout` driving UI state is cleared on unmount and reset on navigation, or a menu opened on one page reappears 100ms into the next. Where they're adjacent, a hover bridge is cheaper: padding on the wrapper spanning the visual gap, so the pointer never leaves the hover region.
119
+
120
+ ## Hover on a card
121
+
122
+ A wash, with the leave on the resting rule and the arrival on the hovered one, so each direction takes its own time:
123
+
124
+ ```css
125
+ .card { transition: background-color <hover out> <ease.colour>; } /* the leave */
126
+ .card:hover { background-color: var(--surface-hover); transition-duration: <hover in>; }
127
+ ```
128
+
129
+ Why a wash, and the timings: `domains/motion/ladder-and-curves.md`.
130
+
131
+ ## Press
132
+
133
+ `transform: scale(<press>)` on `:active`, `transition-duration: 0` in and `duration.state` back out, on `pointerdown`, not `click`. In `product`, prefer the `surface.pressed` step: a toolbar of twenty shrinking buttons is noise. Anything wider than about 200px presses with `scale(0.98)` or a 1px `translate` instead: at 0.92–0.95 a full-width button loses about 15px and reads as collapsing, not pressed.
134
+
135
+ ## Live indicators
136
+
137
+ An indicator that reports a live state — level bars while recording, a stroke travelling round a card while something is generated — moves only while the state does, and turns into its action on demand.
138
+
139
+ ```css
140
+ .live .bars span { animation: level 900ms <ease.standard> infinite alternate; }
141
+ .live[data-paused] .bars span { animation: none; transform: scaleY(0.25); } /* settle low, don't freeze mid-peak */
142
+ .live .stop, .live .bars { transition: opacity <duration.state> <ease.colour>; }
143
+ .live .stop { opacity: 0; }
144
+ .live:is(:hover, :focus-visible) .stop { opacity: 1; }
145
+ .live:is(:hover, :focus-visible) .bars { opacity: 0; }
146
+ @media (prefers-reduced-motion: reduce) { .live .bars span { animation: none; } }
147
+ ```
148
+
149
+ - **Paused settles, it doesn't freeze.** A loop stopped mid-cycle leaves a peak that looks like a glitch; hold a low resting frame, or stop a travelling stroke and dim its glow. Bars driven by a real input level fall on their own; a decorative loop has to be told.
150
+ - **The morph is in place.** Bars and stop square share one box and cross-fade, so the control never moves under the pointer.
151
+ - **The name is the action throughout** — a button named "Stop recording" — because on touch nothing hovers and the tap is the stop.
152
+
153
+ ## Theme cross-fade
154
+
155
+ A universal colour transition left in the stylesheet makes every hover on the site `duration.theme` and sluggish. Apply it only for the toggle:
156
+
157
+ ```ts
158
+ root.classList.add("theme-transition");
159
+ applyTheme(choice);
160
+ setTimeout(() => root.classList.remove("theme-transition"), THEME_MS + 40);
161
+ ```
162
+
163
+ ```css
164
+ @media (prefers-reduced-motion: no-preference) {
165
+ :root.theme-transition, :root.theme-transition *, :root.theme-transition *::before, :root.theme-transition *::after {
166
+ transition: background-color <duration.theme> <ease.colour>, color <duration.theme> <ease.colour>,
167
+ border-color <duration.theme> <ease.colour>, fill <duration.theme> <ease.colour>,
168
+ stroke <duration.theme> <ease.colour> !important;
169
+ }
170
+ }
171
+ ```
172
+
173
+ The `!important` beats each component's own transition for that window; removing the class stops it poisoning everything after. Pseudo-elements are listed because `*` doesn't match them, and decoration built with them would snap. The curve is `ease.colour`, never one that overshoots: an overshooting colour passes its target and returns. `duration.theme` runs only under this class, when the page itself switches.
174
+
175
+ A theme that follows the OS by media query changes with no toggle to hook, and every control carrying its own colour transition fades at its own duration while the page around it snaps. So everyday colour transitions never include the page background, and a theme change suspends them: listen for the query's `change` and hold a class for one frame.
176
+
177
+ ```ts
178
+ matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
179
+ if (readChoice() !== "system") return; // the reader chose a theme: the OS doesn't override it
180
+ root.classList.add("theme-instant"); /* :root.theme-instant * { transition: none !important; } */
181
+ applyTheme(e.matches ? "dark" : "light");
182
+ requestAnimationFrame(() => requestAnimationFrame(() => root.classList.remove("theme-instant")));
183
+ });
184
+ ```
185
+
186
+ The theme mechanism and flash-free first paint belong to the colour guide.
187
+
188
+ ## Sound, if the preset allows it
189
+
190
+ Sound is off wherever the user didn't choose to be there. Where it exists, synthesise it with Web Audio rather than shipping files — no request, no decode latency, so it lands with the input — and, non-negotiably:
191
+
192
+ - **Arm the context on every activation gesture:** `pointerdown`, `keydown` and `touchstart`. Hover is activation in no engine, and a keyboard Enter dispatches `click`, not `pointerdown`, so a pointer-only unlock leaves keyboard users in silence for ever. `wheel` activates nothing; leave it out.
193
+ - **Hover sounds for `pointerType === "mouse"` only**, deduplicated per element: a touch tap fires `pointerover` just before `click`, and plays both.
194
+ - **Dense surfaces opt out of hover sound** with an attribute on the container; above about three ticks a second it stops being feedback.
195
+ - **A visible, persisted mute**, checked at the top of playback rather than at the listener, so it applies to in-flight input; storage access wrapped so it can't throw.
196
+ - **Two sounds is a texture; five is a soundtrack.** Press and, at most, hover.
@@ -0,0 +1,121 @@
1
+ # Performance
2
+
3
+ ## The one scroll shape
4
+
5
+ Every scroll-driven behaviour uses one structure. At its smallest, with `update` clearing the flag:
6
+
7
+ ```ts
8
+ let ticking = false;
9
+ const onScroll = () => { if (!ticking) { ticking = true; requestAnimationFrame(update); } };
10
+ window.addEventListener("scroll", onScroll, { passive: true });
11
+ ```
12
+
13
+ In full, with teardown:
14
+
15
+ ```ts
16
+ function onScrollFrame(update: () => void): () => void {
17
+ let ticking = false;
18
+ const run = () => { ticking = false; update(); };
19
+ const onScroll = () => { if (!ticking) { ticking = true; requestAnimationFrame(run); } };
20
+ update(); // correct on a page loaded already scrolled
21
+ window.addEventListener("scroll", onScroll, { passive: true });
22
+ window.addEventListener("resize", onScroll); // a resize invalidates the same measurements
23
+ return () => {
24
+ window.removeEventListener("scroll", onScroll);
25
+ window.removeEventListener("resize", onScroll);
26
+ };
27
+ }
28
+ ```
29
+
30
+ - **`passive: true`**, as the handler never calls `preventDefault()`; without it every scroll event blocks on your JavaScript, the commonest cause of scroll jank.
31
+ - **The `ticking` flag** makes a burst of events (a high-resolution trackpad fires many per frame) one measurement per painted frame.
32
+ - **No `throttle` or `debounce`.** A time-based throttle is faster than the frame rate, wasting work, or slower, dropping frames.
33
+ - **One listener per behaviour**, torn down on unmount. In a component, make the effect's dependency a value, not a fresh array — `ids.join("|")` — or the listener rebuilds every render.
34
+
35
+ A purely visual effect can use CSS scroll-driven animations (`animation-timeline: scroll()` or `view()`), off the main thread with no listener, behind `@supports (animation-timeline: scroll())` with a static fallback and inside `prefers-reduced-motion: no-preference`.
36
+
37
+ ## Hysteresis
38
+
39
+ Anything toggled by scroll *direction* needs a dead zone, or a pixel of jitter — trackpad noise, a rubber-band bounce, your own layout change — flips it for ever.
40
+
41
+ ```ts
42
+ const update = () => {
43
+ const y = window.scrollY;
44
+ const atBottom = y + window.innerHeight >= document.documentElement.scrollHeight - 2;
45
+ if (y < 24 || atBottom) setCollapsed(false); // always expanded at either end — checked first
46
+ else if (y > last + 6) setCollapsed(true); // 6px of downward intent
47
+ else if (y < last - 6) setCollapsed(false); // 6px of upward intent
48
+ last = y;
49
+ };
50
+ ```
51
+
52
+ The end zones come first, or a small upward scroll near the foot leaves a collapsed bar over the footer. The `- 2` absorbs sub-pixel rounding on fractional device pixel ratios.
53
+
54
+ ## Never hide an affordance where it can't come back
55
+
56
+ Gate the effect before attaching anything — layout law 8, with the code in `domains/layout/forks-gating-and-disclosures.md`.
57
+
58
+ ## Scroll spies
59
+
60
+ Tracking the section being read, for a contents rail:
61
+
62
+ ```ts
63
+ const READING_LINE = 120; // px below the viewport top
64
+ const update = () => {
65
+ let current = ids[0];
66
+ for (const id of ids) {
67
+ const el = document.getElementById(id);
68
+ if (!el) continue;
69
+ if (el.getBoundingClientRect().top > READING_LINE) break; // document order: stop at the first not yet passed
70
+ current = id;
71
+ }
72
+ if (window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2) current = ids.at(-1)!;
73
+ setActive((prev) => (prev === current ? prev : current));
74
+ };
75
+ ```
76
+
77
+ - **A reading line, not an `IntersectionObserver`**, because several sections are visible at once and the question is *which heading did I last pass?*
78
+ - **The bottom clamp.** A short final section never crosses the line before the page ends, so the rail would mark the one before it.
79
+ - **Match `scroll-margin-top` to the line**, a little smaller, within ~10px. A contents link that scrolls its heading to y = 0 puts it above the line, and the rail highlights the previous section.
80
+ - **`history.replaceState` for the hash**, not router navigation, which runs the app's own scroll handling and cuts the smooth scroll short.
81
+
82
+ ## What to animate
83
+
84
+ `width`, `height`, `top`, `left`, `margin` and `padding` lay out on every frame, for the element and possibly its siblings; `transform` and `opacity` don't.
85
+
86
+ **Name every property.** `transition: all` picks up properties you never intended, including layout ones a parent adds later. The explicit list — `transition: color, background-color, translate` — also makes a hover's *leave* predictable: exactly the listed properties transition out.
87
+
88
+ **A justified exception** looks like the shared highlight: one absolutely positioned, `pointer-events: none` element outside the flow, whose size change reflows nothing. If you can't say that about an element, scale it instead.
89
+
90
+ **Animating to auto** without measuring:
91
+
92
+ ```css
93
+ .region { display: grid; grid-template-rows: 0fr; transition: grid-template-rows <duration.overlay> <ease.standard>; }
94
+ .region[data-open] { grid-template-rows: 1fr; }
95
+ .region > * { min-height: 0; overflow: hidden; }
96
+ ```
97
+
98
+ The same works on columns with `min-width: 0`. Where supported, `interpolate-size: allow-keywords` lets `height: auto` transition directly.
99
+
100
+ **Transforms and `backdrop-filter`.** The moving wrapper and a translucent surface are different elements (depth law 9).
101
+
102
+ **`will-change`** only on an element about to animate, removed after; left on everything, it pins memory for layers that never move.
103
+
104
+ ## Escalating to the GPU
105
+
106
+ Some shared-element trips can't be done in the DOM: the ends differ in aspect ratio, so one image distorts all the way, or the view is too large to re-lay-out per frame. Then draw one element on a canvas above the view and animate four numbers — a rectangle — in a uniform.
107
+
108
+ - **One decorative element, never the view.** A hundred tiles as textures costs hundreds of megabytes, and the tiles carry keyboard and screen-reader semantics a canvas can't. The drawn element has none of its own (`alt=""`, labels elsewhere).
109
+ - **Setup returns `null`, never throws.** Inside an effect, an exception takes the view down instead of falling back. Check for the artefact that proves setup completed — a linked program — rather than trusting the constructor.
110
+ - **`null` is the ordinary path:** the caller keeps its `<img>`, which also serves reduced motion, no WebGL, and a context lost later.
111
+ - **Mipmaps, trilinear filtering**, or a large image drawn small shimmers as it moves sub-pixel.
112
+ - **Soften the last pixel of the edge**, or a rectangle on fractional pixels crawls column by column. Fade alpha only, on a non-premultiplied context.
113
+ - **Don't force-lose the context on teardown.** Frameworks reuse the canvas node across remounts, and a force-lost context silently poisons it.
114
+
115
+ ## Keeping the motion code small
116
+
117
+ - **Motion:** `LazyMotion` with `domAnimation` and the `m` component; `strict` makes a stray full `motion.*` import a build error. Scope a `domMax` island to the one component that needs layout animations.
118
+ - **GSAP:** register only the plugins in use; import `Flip` or `ScrollTrigger` where they're used, not in a global setup file.
119
+ - **React Spring:** import from `@react-spring/web` only.
120
+ - **CSS first.** A hover, a colour change, a reveal and most presence animations need no library; reach for one when something re-targets, is dragged, or must exit after unmounting.
121
+
@@ -0,0 +1,204 @@
1
+ # Actions and forms
2
+
3
+ ## Destructive items in a menu
4
+
5
+ Every destroying or session-ending item takes the same place and treatment: last, after a divider, no icon, the danger treatment from the colour guide.
6
+
7
+ ```tsx
8
+ <Menu>
9
+ <MenuItem onSelect={rename}>Rename</MenuItem>
10
+ <MenuItem onSelect={duplicate}>Duplicate</MenuItem>
11
+ <MenuItem onSelect={openMoveTo}>Move to…</MenuItem>
12
+ <MenuSeparator />
13
+ <MenuItem tone="danger" onSelect={() => openConfirm(row, triggerRef)}>Delete</MenuItem>
14
+ </Menu>
15
+ ```
16
+
17
+ The same holds in the profile menu (Sign out last, alone), in settings (a "Delete workspace" section last on the page, its button in the same danger treatment), and in a bulk-action bar (Delete at the end, set apart).
18
+
19
+ | Item | Destructive or session-ending? |
20
+ |---|---|
21
+ | Delete, Remove from project, Clear history, Revoke access, Leave workspace, Sign out | Yes — one treatment |
22
+ | Archive (restorable), Mark as done, Hide | No — ordinary items, ideally with Undo |
23
+ | Cancel (an unsaved form) | No — a ghost button, not danger |
24
+
25
+ ## Proof in proportion to the loss
26
+
27
+ | Loss | Ask for | Example |
28
+ |---|---|---|
29
+ | Reversible | Nothing; Undo in a toast for 5–8s | Archive a task, move a card, remove a tag |
30
+ | One item, irreversible | A dialog; the button repeats the verb | Delete a note |
31
+ | Many items, or someone else's work | A dialog that counts what goes ("48 tasks, 12 notes") | Delete a folder |
32
+ | An account, a project, a workspace | Type a word to confirm | Delete project, delete account |
33
+
34
+ Escalate on what is lost, not on how serious the screen looks: a bulk delete of 200 rows deserves more than a delete of one project with nothing in it.
35
+
36
+ ## The type-to-confirm dialog
37
+
38
+ One component, used for every large delete. It is the system's dialog with different contents, never a bespoke surface (depth law 1).
39
+
40
+ ```tsx
41
+ export function ConfirmDestroy({ open, onOpenChange, title, consequence, word = "DELETE",
42
+ action, pendingLabel, onConfirm, returnFocusTo }) {
43
+ const [typed, setTyped] = useState("");
44
+ const [state, setState] = useState<"idle" | "pending" | "error">("idle");
45
+ const [error, setError] = useState("");
46
+ const inputRef = useRef<HTMLInputElement>(null);
47
+ const matches = typed.trim().toLowerCase() === word.toLowerCase();
48
+
49
+ async function confirm(e: React.FormEvent) {
50
+ e.preventDefault();
51
+ if (!matches || state === "pending") return;
52
+ setState("pending");
53
+ try {
54
+ await onConfirm();
55
+ onOpenChange(false);
56
+ } catch (err) {
57
+ setState("error");
58
+ setError(messageFor(err)); // words, not a code
59
+ }
60
+ }
61
+
62
+ return (
63
+ <Dialog
64
+ open={open}
65
+ onOpenChange={(next) => state !== "pending" && onOpenChange(next)}
66
+ initialFocus={inputRef}
67
+ finalFocus={returnFocusTo} /* the row or menu item that opened it */
68
+ >
69
+ <form onSubmit={confirm}>
70
+ <DialogTitle>{title}</DialogTitle>
71
+ <p>{consequence}</p>
72
+ <label htmlFor="confirm-word">Type {word} to confirm</label>
73
+ <input id="confirm-word" ref={inputRef} value={typed} autoComplete="off"
74
+ onChange={(e) => setTyped(e.target.value)}
75
+ aria-describedby={state === "error" ? "confirm-error" : undefined} />
76
+ {state === "error" && <p id="confirm-error" role="alert">{error}</p>}
77
+ <DialogFooter>
78
+ <Button variant="ghost" type="button" onClick={() => onOpenChange(false)}
79
+ disabled={state === "pending"}>Cancel</Button>
80
+ <Button tone="danger" type="submit" disabled={!matches || state === "pending"}>
81
+ {state === "pending" ? pendingLabel : action}
82
+ </Button>
83
+ </DialogFooter>
84
+ </form>
85
+ </Dialog>
86
+ );
87
+ }
88
+ ```
89
+
90
+ - **The input takes focus, not Cancel.** The typed word is the guard; focusing it saves a click without weakening it.
91
+ - **Case-insensitive**, trimmed: the reader proves intent, not typing accuracy.
92
+ - **Held open while pending**: Escape and the scrim do nothing until it settles, so a failure has somewhere to be reported.
93
+ - **Focus returns to what opened it**, the row's neighbour if the row is gone — depth law 12, with the code in `domains/depth/elevation-recipes.md`.
94
+
95
+ ## Undo instead of confirm
96
+
97
+ For anything reversible, act at once and offer Undo:
98
+
99
+ ```ts
100
+ async function archive(task: Task) {
101
+ const before = store.snapshot(task.id);
102
+ store.archive(task.id);
103
+ toast("Task archived", { action: { label: "Undo", onClick: () => store.restore(before) } });
104
+ try { await api.archive(task.id); }
105
+ catch { store.restore(before); toast.error("Couldn't archive task", { action: retry(() => archive(task)) }); }
106
+ }
107
+ ```
108
+
109
+ A confirm dialog in front of every reversible action trains the reader to click through dialogs, which is how the dangerous one gets clicked through too.
110
+
111
+ ## Field anatomy
112
+
113
+ Label above, control, hint below; an error takes the hint's place rather than adding a line.
114
+
115
+ ```tsx
116
+ <div className="field">
117
+ <label htmlFor={id}>{label}</label>
118
+ <Control id={id} aria-invalid={!!error || undefined}
119
+ aria-describedby={error || hint ? `${id}-note` : undefined} />
120
+ {(error || hint) && (
121
+ <p id={`${id}-note`} className={error ? "field-error" : "field-hint"}>
122
+ {error ?? hint}
123
+ </p>
124
+ )}
125
+ </div>
126
+ ```
127
+
128
+ Reserve the note's line height when a field has a hint, so the swap never moves the field below. Validate on blur and on submit, not on every keystroke; clear a field's error as soon as its value changes.
129
+
130
+ **A form-level failure** — the server refused, the network dropped — is one line above the primary button, with an icon and `role="alert"`, saying what to do next. The fields keep what the reader typed.
131
+
132
+ ## The deciding field first
133
+
134
+ Order a form by dependency, not by importance:
135
+
136
+ | Instead of | Do |
137
+ |---|---|
138
+ | Description, then Category | Category (full width), then the fields it shapes, then Description |
139
+ | Free text, then "Is this private?" | Private or shared first, if it changes who can be mentioned |
140
+ | Every field shown, half disabled | Fields appear once the deciding answer allows them, in place, without reflowing what's above |
141
+
142
+ The deciding field is full width because it is the one question every reader answers; the rest can sit in pairs.
143
+
144
+ ## The sign-in screen
145
+
146
+ | Region | Holds |
147
+ |---|---|
148
+ | Form column, about 360px, unboxed | Title ("Sign in"), one muted line, fields, the one brand button, the secondary path as a link ("Create an account") |
149
+ | Art panel, from a wide breakpoint | The depth guide's art panel on the other half (`domains/depth/elevation-recipes.md`); moving only if the auth routes are an `expressive` area in `## Profile`, otherwise one still frame |
150
+ | Phone | The form alone; the art is decoration and goes first |
151
+
152
+ - **No card around the form.** The page is the surface; a card on an otherwise empty page is a box around nothing.
153
+ - **One primary button**, reading the verb: "Sign in", "Create account", "Send link".
154
+ - **Errors**: field errors replace hints; "Wrong email or password" is a form-level line, never a field error that tells an attacker which half was right.
155
+ - **Password managers** need `autocomplete="email"` / `"current-password"` / `"new-password"` and real `<form>` submission.
156
+ - The panel's build and theme scope are the depth guide's art-panel recipe (`domains/depth/elevation-recipes.md`); its motion is the motion guide's ambient class, with a still frame under reduced motion; inputs at 16px on phones, so the browser doesn't zoom, are the type guide.
157
+
158
+ ## Pending states by how long the wait is
159
+
160
+ | Wait | The reader sees | Where |
161
+ |---|---|---|
162
+ | Under 100ms | The control's own press state | On the control |
163
+ | ≈ 400ms – 1s | The label turns to its progressive form ("Save" → "Saving…"), width held, the control disabled against a second press | In the button |
164
+ | 1s – 10s | A skeleton of what's coming: the rows, the panel's shape, never a centred spinner over a blank region | In the region that will change |
165
+ | Over 10s | Determinate progress — "Importing 340 of 1,200" — with Cancel, and a notice when it's done if the reader has left | In the region, then a toast off screen |
166
+
167
+ Show the pending state only once the threshold passes, so a fast response never flashes it:
168
+
169
+ ```tsx
170
+ function usePending(pending: boolean, after = 400) {
171
+ const [shown, setShown] = useState(false);
172
+ useEffect(() => {
173
+ if (!pending) return setShown(false);
174
+ const t = setTimeout(() => setShown(true), after);
175
+ return () => clearTimeout(t);
176
+ }, [pending, after]);
177
+ return shown;
178
+ }
179
+
180
+ // <button disabled={saving} style={{ minWidth }}>{usePending(saving) ? "Saving…" : "Save"}</button>
181
+ ```
182
+
183
+ A skeleton, once shown, stays for at least ≈ 300ms, or a response that lands just after the threshold makes the region flicker twice.
184
+
185
+ ## Forgiving inputs
186
+
187
+ Normalise on blur and on submit, and keep what was typed visible until then:
188
+
189
+ | Field | Accept | Store |
190
+ |---|---|---|
191
+ | Email | Capitals, leading and trailing spaces | Trimmed, domain lower-cased |
192
+ | Phone | Spaces, dashes, brackets, a leading `+` or `00` | E.164 (`+447700900123`) |
193
+ | Card, IBAN, codes | Spaces and dashes, pasted in any grouping | Digits only; displayed back in groups of four |
194
+ | Date | "12 Mar", "12/3", "tomorrow", a picker | ISO date; displayed in the reader's locale |
195
+ | Amount | "1,200", "1200.00", a currency symbol | A number in minor units |
196
+
197
+ `inputmode` and `autocomplete` do half the work — `inputmode="numeric"` for codes, `"email"`, `"tel"` — so the right keyboard arrives and the browser fills what it knows. An error names the fix ("Add the year"), not the rule broken ("Invalid format").
198
+
199
+ ## Progress through a flow, and its end
200
+
201
+ - **Show position and what's left**: "Step 2 of 3" or a short list of named steps, the current one marked, earlier ones ticked and revisitable.
202
+ - **Start with credit**: a setup checklist opens with the step the reader just finished already ticked.
203
+ - **No step counter for one screen**, and none that changes length mid-flow; if a branch adds steps, show the new count when the branch is chosen.
204
+ - **The end state** names what happened in the product's nouns ("Riverside project created"), shows the thing itself or a link to it, and offers the next action ("Invite your team"). It is a screen, not a toast that leaves the reader on the last form.
@@ -0,0 +1,111 @@
1
+ # Agents and live states
2
+
3
+ Two patterns for output the reader didn't write and can't fully predict: an agent taking actions, and text arriving as it's produced. Both fail the same way — they ask for trust instead of showing their work.
4
+
5
+ ## The tool-call card
6
+
7
+ Every tool call is one card in the conversation, in the order it happened.
8
+
9
+ | Part | Holds |
10
+ |---|---|
11
+ | Header row | Status glyph · tool name in sentence case ("Search tasks", not `search_tasks`) · a one-line summary of the arguments ("Overdue, Riverside") · duration once done |
12
+ | Arguments | Key–value pairs with labels from the glossary; long values truncated with "Show all" |
13
+ | Result | A summary first ("12 tasks"), the raw result behind a disclosure |
14
+ | Approval | Only when the step needs one — inline, under the card |
15
+
16
+ **Open or closed.** A running step is open, so the reader can watch it; a finished one collapses to its header; a failed one stays open with the error in words and a Retry where retrying is safe. The reader's own open or closed choice wins over all three until the conversation reloads.
17
+
18
+ **Status shape, not just colour.** The glyph changes shape between running, done, failed and waiting for approval (shape carries state, colour confirms it: the colour guide); a spinner inside the glyph is fine, a spinner instead of the card is not.
19
+
20
+ ```tsx
21
+ <details open={state !== "done"} className="tool-call">
22
+ <summary>
23
+ <StatusGlyph state={state} />
24
+ <span className="tool-name">{label(tool.name)}</span>
25
+ <span className="tool-args">{summarise(tool.args)}</span>
26
+ {state === "done" && <time>{formatDuration(ms)}</time>}
27
+ </summary>
28
+ <ArgsList args={tool.args} />
29
+ <ToolResult result={result} />
30
+ </details>
31
+ ```
32
+
33
+ A native `<details>` keeps the content in the page for find-in-page and copy (layout law 11).
34
+
35
+ ## Inline approvals
36
+
37
+ A step that changes something outside the conversation — sends a message, edits records, spends money — waits for approval under its own card:
38
+
39
+ ```
40
+ ┌ ◌ Send email · to 3 members ─────────────────────┐
41
+ │ Subject Site walk moved to Thursday │
42
+ │ To Sam, Priya, Jo │
43
+ └───────────────────────────────────────────────────┘
44
+ [Send email] [Don't send]
45
+ ```
46
+
47
+ - **The approve button names the action** and takes the brand action colour; reject is a muted ghost button. Not "Approve" and "Reject": the reader should be able to read the button alone.
48
+ - **Never a modal.** The card above it, and the turns above that, are the evidence for the decision; a modal covers them.
49
+ - **Never focused by default**, and never on Enter from the composer: an approval is a decision, not a keystroke the reader was already making.
50
+ - **After the answer**, the buttons give way to a line saying what was decided ("Sent", "Not sent"), so the record stays readable.
51
+ - **Several pending steps** each keep their own buttons; an "Approve all" may sit above them only when every step is the same kind of action.
52
+
53
+ ## Conversation layout
54
+
55
+ - **No avatar per message.** Two speakers don't need faces to be told apart.
56
+ - **The reader's turns** sit right-aligned, on a light tint, at most about two-thirds of the panel's width.
57
+ - **The agent's turns** run full width with no fill — they are the content, and they hold tables, cards and code that need the width.
58
+ - **AI-made content elsewhere** in the product — a summary on a record, a suggested title — carries one small mark in brand beside it, not a robot face or a coloured block (the colour guide).
59
+ - **The panel is non-modal**: a side panel or sheet the reader can work beside, keyboard-reachable, closed by Escape when focus is inside it.
60
+ - **The composer**: Enter sends, Shift+Enter adds a line; the send button is disabled while empty and becomes Stop while the agent is working.
61
+
62
+ ## Live text
63
+
64
+ A live transcript or a streaming answer has two kinds of text, and they must look different:
65
+
66
+ | Part | Treatment |
67
+ |---|---|
68
+ | Committed segments | Ink, final; never rewritten |
69
+ | The phrase still forming | Muted, with a brand caret at its end |
70
+ | Speaker or time markers | Meta tier, at each change, not on every line |
71
+
72
+ **Following the bottom.** Follow while the reader is at the bottom; stop the moment they scroll up; show "Jump to latest" until they return:
73
+
74
+ ```ts
75
+ function useFollowBottom(el: HTMLElement | null, deps: unknown[]) {
76
+ const atBottom = useRef(true);
77
+ useEffect(() => {
78
+ if (!el) return;
79
+ const onScroll = () => {
80
+ atBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
81
+ };
82
+ el.addEventListener("scroll", onScroll, { passive: true });
83
+ return () => el.removeEventListener("scroll", onScroll);
84
+ }, [el]);
85
+ useLayoutEffect(() => {
86
+ if (el && atBottom.current) el.scrollTop = el.scrollHeight;
87
+ }, deps); // eslint-disable-line react-hooks/exhaustive-deps
88
+ return atBottom;
89
+ }
90
+ ```
91
+
92
+ **The top fade** says more is above, and costs nothing to layout:
93
+
94
+ ```css
95
+ .live-scroll {
96
+ mask-image: linear-gradient(to bottom, transparent 0, #000 32px);
97
+ }
98
+ ```
99
+
100
+ **Announcing.** Put committed segments in a polite live region, one segment at a time; keep the forming phrase out of it, or a screen reader re-reads every word as it changes. A visually hidden region beside the visible text is often simpler than making the visible list live.
101
+
102
+ ## Paused, stopped, failed
103
+
104
+ | State | Shows | Offers |
105
+ |---|---|---|
106
+ | Live | Moving level bars or the one "working" signal | Pause; on hover and focus the live control becomes Stop |
107
+ | Paused | Everything still; the forming phrase committed or dropped | View (the text so far), Copy, Resume |
108
+ | Stopped | The full text, final | Copy, and whatever the product does next with it |
109
+ | Failed | What was captured, kept | Retry, and the captured text never lost |
110
+
111
+ What moves while live and stops while paused is motion law 1; what colour a live state takes is the colour guide's *Live is not danger*.