trykeyline 0.0.0-stage → 0.1.0
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.
- package/.claude-plugin/marketplace.json +18 -0
- package/LICENSE +37 -0
- package/README.md +117 -2
- package/bin/keyline.mjs +280 -0
- package/package.json +38 -4
- package/plugins/keyline/.claude-plugin/plugin.json +14 -0
- package/plugins/keyline/skills/keyline/SKILL.md +57 -0
- package/plugins/keyline/skills/keyline/commands/audit.md +25 -0
- package/plugins/keyline/skills/keyline/commands/color.md +7 -0
- package/plugins/keyline/skills/keyline/commands/critique.md +43 -0
- package/plugins/keyline/skills/keyline/commands/depth.md +7 -0
- package/plugins/keyline/skills/keyline/commands/init.md +15 -0
- package/plugins/keyline/skills/keyline/commands/layout.md +7 -0
- package/plugins/keyline/skills/keyline/commands/motion.md +7 -0
- package/plugins/keyline/skills/keyline/commands/patterns.md +7 -0
- package/plugins/keyline/skills/keyline/commands/polish.md +19 -0
- package/plugins/keyline/skills/keyline/commands/refine.md +29 -0
- package/plugins/keyline/skills/keyline/commands/sync.md +17 -0
- package/plugins/keyline/skills/keyline/commands/type.md +7 -0
- package/plugins/keyline/skills/keyline/domains/color/deriving-a-ramp.md +144 -0
- package/plugins/keyline/skills/keyline/domains/color/guide.md +243 -0
- package/plugins/keyline/skills/keyline/domains/color/status-and-exceptions.md +174 -0
- package/plugins/keyline/skills/keyline/domains/color/theming-mechanics.md +121 -0
- package/plugins/keyline/skills/keyline/domains/depth/elevation-recipes.md +191 -0
- package/plugins/keyline/skills/keyline/domains/depth/fallbacks-and-performance.md +132 -0
- package/plugins/keyline/skills/keyline/domains/depth/guide.md +255 -0
- package/plugins/keyline/skills/keyline/domains/depth/translucent-materials.md +159 -0
- package/plugins/keyline/skills/keyline/domains/layout/forks-gating-and-disclosures.md +201 -0
- package/plugins/keyline/skills/keyline/domains/layout/guide.md +284 -0
- package/plugins/keyline/skills/keyline/domains/layout/measure-and-rhythm.md +193 -0
- package/plugins/keyline/skills/keyline/domains/layout/radius-lines-and-layers.md +122 -0
- package/plugins/keyline/skills/keyline/domains/motion/guards.md +162 -0
- package/plugins/keyline/skills/keyline/domains/motion/guide.md +234 -0
- package/plugins/keyline/skills/keyline/domains/motion/ladder-and-curves.md +139 -0
- package/plugins/keyline/skills/keyline/domains/motion/patterns.md +196 -0
- package/plugins/keyline/skills/keyline/domains/motion/performance.md +121 -0
- package/plugins/keyline/skills/keyline/domains/patterns/actions-and-forms.md +204 -0
- package/plugins/keyline/skills/keyline/domains/patterns/agents-and-live-states.md +111 -0
- package/plugins/keyline/skills/keyline/domains/patterns/collections-and-dashboards.md +131 -0
- package/plugins/keyline/skills/keyline/domains/patterns/copy.md +123 -0
- package/plugins/keyline/skills/keyline/domains/patterns/guide.md +216 -0
- package/plugins/keyline/skills/keyline/domains/patterns/iteration.md +78 -0
- package/plugins/keyline/skills/keyline/domains/type/display-tier.md +108 -0
- package/plugins/keyline/skills/keyline/domains/type/guide.md +251 -0
- package/plugins/keyline/skills/keyline/domains/type/ladders.md +169 -0
- package/plugins/keyline/skills/keyline/domains/type/numerals-and-loading.md +107 -0
- package/plugins/keyline/skills/keyline/domains/type/prose-and-emphasis.md +125 -0
- package/plugins/keyline/skills/keyline/domains/type/type-audit.md +90 -0
- package/plugins/keyline/skills/keyline/glass/color/assets/color-tokens.css +319 -0
- package/plugins/keyline/skills/keyline/glass/color/assets/theme.ts +119 -0
- package/plugins/keyline/skills/keyline/glass/color/guide.md +222 -0
- package/plugins/keyline/skills/keyline/glass/color/intent-ramp.md +136 -0
- package/plugins/keyline/skills/keyline/glass/color/theming.md +112 -0
- package/plugins/keyline/skills/keyline/glass/depth/assets/GlassPanel.tsx +171 -0
- package/plugins/keyline/skills/keyline/glass/depth/assets/glass.css +322 -0
- package/plugins/keyline/skills/keyline/glass/depth/blur-and-tint.md +50 -0
- package/plugins/keyline/skills/keyline/glass/depth/depth-cues.md +87 -0
- package/plugins/keyline/skills/keyline/glass/depth/guide.md +216 -0
- package/plugins/keyline/skills/keyline/glass/depth/performance-and-fallbacks.md +37 -0
- package/plugins/keyline/skills/keyline/glass/layout/adaptive-presentations.md +231 -0
- package/plugins/keyline/skills/keyline/glass/layout/assets/SectionNav.tsx +244 -0
- package/plugins/keyline/skills/keyline/glass/layout/assets/layout-tokens.css +199 -0
- package/plugins/keyline/skills/keyline/glass/layout/guide.md +258 -0
- package/plugins/keyline/skills/keyline/glass/layout/measure-and-rhythm.md +134 -0
- package/plugins/keyline/skills/keyline/glass/layout/radius-and-elevation.md +102 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/DropMenu.tsx +106 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/motion-presets.ts +210 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/motion-tokens.css +176 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/useReveal.ts +65 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/useScrollTick.ts +183 -0
- package/plugins/keyline/skills/keyline/glass/motion/easing-and-duration.md +108 -0
- package/plugins/keyline/skills/keyline/glass/motion/guide.md +286 -0
- package/plugins/keyline/skills/keyline/glass/motion/reduced-motion.md +81 -0
- package/plugins/keyline/skills/keyline/glass/motion/scroll-performance.md +58 -0
- package/plugins/keyline/skills/keyline/glass/motion/shared-element.md +161 -0
- package/plugins/keyline/skills/keyline/glass/motion/sound.md +192 -0
- package/plugins/keyline/skills/keyline/glass/motion/springs.md +51 -0
- package/plugins/keyline/skills/keyline/glass/type/assets/prose.tsx +268 -0
- package/plugins/keyline/skills/keyline/glass/type/assets/type-tokens.css +414 -0
- package/plugins/keyline/skills/keyline/glass/type/guide.md +222 -0
- package/plugins/keyline/skills/keyline/glass/type/long-form-prose.md +151 -0
- package/plugins/keyline/skills/keyline/glass/type/scale.md +69 -0
- package/plugins/keyline/skills/keyline/glass/type/weight-and-tracking.md +61 -0
- package/plugins/keyline/skills/keyline/scripts/check/css.mjs +112 -0
- package/plugins/keyline/skills/keyline/scripts/check/design.mjs +67 -0
- package/plugins/keyline/skills/keyline/scripts/check/files.mjs +44 -0
- package/plugins/keyline/skills/keyline/scripts/check/markup.mjs +149 -0
- package/plugins/keyline/skills/keyline/scripts/check/rules.mjs +584 -0
- package/plugins/keyline/skills/keyline/scripts/check.mjs +294 -0
- package/plugins/keyline/skills/keyline/scripts/contrast.mjs +153 -0
- package/plugins/keyline/skills/keyline/scripts/scan.mjs +340 -0
- package/plugins/keyline/skills/keyline/system/design-md.md +136 -0
- package/plugins/keyline/skills/keyline/system/detection.md +53 -0
- package/plugins/keyline/skills/keyline/system/guide.md +101 -0
- package/plugins/keyline/skills/keyline/system/presets.md +58 -0
- package/plugins/keyline/skills/keyline/system/roles.md +150 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Radius, lines and layers
|
|
2
|
+
|
|
3
|
+
## The nesting rule, worked through
|
|
4
|
+
|
|
5
|
+
**Inner radius = outer radius − the gap between their edges, at concentric corners.** Only then do the two curves share a centre; any other value makes the corners visibly disagree. The rule binds only an inner corner tucked inside the outer one's curve, inset by less than the outer radius. Inset that far or further, or set away from the corners, an element shares no centre with its container and takes the ladder's radius for its own size — never a square corner produced by the subtraction.
|
|
6
|
+
|
|
7
|
+
| Nesting | Outer | Gap | Inner | Note |
|
|
8
|
+
|---|---|---|---|---|
|
|
9
|
+
| A container holding icon buttons | 16px | 4px | 12px | |
|
|
10
|
+
| An icon button holding an avatar | 12px | 2px inset | 10px | |
|
|
11
|
+
| A sheet holding text rows | 16px | 6px | 10px → snap to 12px | 2px on a 44px row is invisible |
|
|
12
|
+
| A search input holding a chip | 8px | 3px | 5px → snap to 4px | |
|
|
13
|
+
| A card with a flush image | 12px | 0 | 12px, or clip with `overflow: hidden` | Zero gap, same radius |
|
|
14
|
+
| A padded card holding an icon tile | 12px | 16px | `radius.control` for a 32px tile | Inset past the outer radius: not concentric, sized by the ladder |
|
|
15
|
+
| A search field holding a `kbd` hint | 8px | 8px | `radius.small` | Not concentric; square would read as a different system |
|
|
16
|
+
|
|
17
|
+
**Snap within 2px, follow exactly above that.** The ladder is worth more than 1–2px of exactness, which below about 8px the eye barely resolves; a mismatch of 4px or more is what people notice without naming.
|
|
18
|
+
|
|
19
|
+
Derive the ladder from the nestings, and write the subtraction into the token so a later padding change — the usual way a correct nesting quietly breaks — can't break it:
|
|
20
|
+
|
|
21
|
+
```css
|
|
22
|
+
:root {
|
|
23
|
+
--radius-container: 16px;
|
|
24
|
+
--container-pad: 4px;
|
|
25
|
+
--radius-control: calc(var(--radius-container) - var(--container-pad)); /* 12px */
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Ladders per preset
|
|
30
|
+
|
|
31
|
+
| Role | editorial | product | expressive (round) | expressive (sharp) |
|
|
32
|
+
|---|---|---|---|---|
|
|
33
|
+
| `radius.pill` | full | full | full | full, or 0 |
|
|
34
|
+
| `radius.small` | 4px | 4–6px | 8px | 0 |
|
|
35
|
+
| `radius.control` | 8px | 8px | 12px | 0 |
|
|
36
|
+
| `radius.card` | 12px | 12px | 20px | 0 |
|
|
37
|
+
| `radius.container` | 16px | 16px | 28px | 0 |
|
|
38
|
+
|
|
39
|
+
Product used to run smaller (6px controls, 8px cards), and in review that read as boxy and dated beside current apps. It now matches editorial: with content unboxed (layout law 3), the few cards don't tile edge to edge, which is where large radii leave triangular gaps (one more reason not to build a mosaic of panels). Both draw the corner as a light superellipse (below). An existing design's radii are the floor when improving it: a pass never sharpens corners, not even for the nesting law — it raises the outer radius or trims the inset instead (`commands/refine.md`, the limits). Expressive may go either way, but never mixes round and square components or keeps four near-identical radii that are really one.
|
|
40
|
+
|
|
41
|
+
**Radius scales with size, not importance**, and sub-proportionally: a 400px panel gets a larger radius than a 40px button because it is larger, but a proportional one reads as a lozenge. **`radius.pill` is for things with no top and bottom** — tags, dots, toggles, progress tracks. A paragraph in a pill-ended card collides with the curve.
|
|
42
|
+
|
|
43
|
+
## Corner shape
|
|
44
|
+
|
|
45
|
+
A superellipse eases curvature into the straight edge where a quarter-circle `border-radius` stops dead, drawing a better corner at the same radius.
|
|
46
|
+
|
|
47
|
+
```css
|
|
48
|
+
*, *::before, *::after { corner-shape: superellipse(1.5); }
|
|
49
|
+
.pill, .avatar, .dot { corner-shape: round; } /* full radii: a squircle here just swells */
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`superellipse(k)` uses the exponent 2^k: 1 is an ordinary round corner, 2 a full squircle, 1.5 about halfway — craft rather than effect. Editorial and product use 1.5 by default, which keeps a round system feeling current; expressive may use any shape if it's applied globally. Pills and circles keep `round`. It is progressive enhancement: unsupported browsers draw normal rounding at the same radius, so nothing may depend on it.
|
|
53
|
+
|
|
54
|
+
## Line weights by job
|
|
55
|
+
|
|
56
|
+
| Role | Job | editorial | product | expressive |
|
|
57
|
+
|---|---|---|---|---|
|
|
58
|
+
| `hairline.chrome` | A divider inside one object — toolbar groups, menu rows, table rows | 0.5px | 0.5px, `border.divider` | 1px |
|
|
59
|
+
| `hairline.object` | An object's edge — a card, an input, a disclosure, a table frame | 1px | 0.5px, `border.default` | 1–2px |
|
|
60
|
+
| `rule.emphasis` | A deliberate mark — a quote bar, a focus ring, an active tab | 2px | 2px | 3–4px |
|
|
61
|
+
|
|
62
|
+
Where chrome and object share a weight, the divider must step down in colour so it never outranks the edge.
|
|
63
|
+
|
|
64
|
+
### Half a pixel, drawn as a border
|
|
65
|
+
|
|
66
|
+
A `0.5px` border is one device pixel on a 2× screen and rounds up to a full pixel on 1×: browsers snap any border width between 0 and 1 device pixel up to one. A box half a pixel tall with a background gets no such snapping — on 1× it antialiases to a faint smear or nothing. So every hairline is a border, including a divider between rows:
|
|
67
|
+
|
|
68
|
+
```css
|
|
69
|
+
:root { --hairline: 0.5px; }
|
|
70
|
+
@media (prefers-contrast: more) { :root { --hairline: 1px; } }
|
|
71
|
+
|
|
72
|
+
.row + .row { border-block-start: var(--hairline) solid var(--border-divider); }
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Three things never take the half pixel, on purpose:
|
|
76
|
+
|
|
77
|
+
- **Focus rings**, on `rule.emphasis`: a half-pixel ring is too faint to find.
|
|
78
|
+
- **`prefers-contrast: more`**, whose whole job is a real edge, so the token swaps to 1px rather than each component guessing.
|
|
79
|
+
- **Deliberate thick marks** — the current tab's underline, a quote or note bar.
|
|
80
|
+
|
|
81
|
+
A vertical divider is a border too, and spans the content, not the padding: a 24px line in a 40px row, centred.
|
|
82
|
+
|
|
83
|
+
```css
|
|
84
|
+
.divider-v { block-size: 24px; align-self: center; border-inline-start: var(--hairline-chrome) solid var(--border-divider); }
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### A frame matches what it frames
|
|
88
|
+
|
|
89
|
+
Where a frame holds dividers — a product table, a grouped list, a settings panel — the outer edge and the inner lines are read as one grid, so they share a weight and an inset and meet without a step. A 1px frame round 0.5px row rules looks like a border drawn on afterwards; a header rule inset 12px under a frame drawn edge to edge looks misaligned. The divider may step down in colour, never in weight.
|
|
90
|
+
|
|
91
|
+
Two resets that cause visible bugs:
|
|
92
|
+
|
|
93
|
+
- An `<hr>` carries a UA border: give it `border: 0` before a `background` and `block-size`, or it draws the browser's line and yours a few pixels apart.
|
|
94
|
+
- A divider that is rhythm rather than structure gets `aria-hidden`; an `<hr>` announces itself as a separator.
|
|
95
|
+
|
|
96
|
+
On translucent or unknown backdrops every line uses `border.layered` (colour law 4).
|
|
97
|
+
|
|
98
|
+
## The z-index ladder
|
|
99
|
+
|
|
100
|
+
The rungs and their defaults are layout law 7's table; every preset shares them. What follows is how they hold up in code.
|
|
101
|
+
|
|
102
|
+
Ordered by permanence, gaps of ten. A stacked modal (a confirm over a dialog) needs no new rung: rendered later in the same portal, it stacks by document order. **Portals make the ladder global**: inside a card, a menu is trapped by every stacking context above it whatever its number, so overlays, menus escaping a scroll container, and toasts render into a root-level portal.
|
|
103
|
+
|
|
104
|
+
### The two pairs
|
|
105
|
+
|
|
106
|
+
```html
|
|
107
|
+
<!-- Catcher above the page, below the menu it dismisses. -->
|
|
108
|
+
<div class="catcher" style="position: fixed; inset: 0; z-index: var(--z-dismiss)" aria-hidden="true"></div>
|
|
109
|
+
<div class="menu" style="position: relative; z-index: var(--z-nav)">…</div>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
A catcher on `z.nav` or above eats the menu's own clicks, so the menu seems to ignore every tap. A content popover on `z.popover` sits below `z.dismiss` and can't borrow the navigation's catcher: render its own immediately before it in the same portal, and the two stack by document order.
|
|
113
|
+
|
|
114
|
+
## Stacking-context techniques
|
|
115
|
+
|
|
116
|
+
**Negative z needs an isolated parent.** At `z-index: -10` alone, a highlight meant to slide behind a row of items but in front of their container's background drops behind the container too; `isolation: isolate` on the container scopes it.
|
|
117
|
+
|
|
118
|
+
**A lift on hover needs one on focus.** An item raised to `z.raised` on hover, so its neighbour doesn't clip its shadow, needs the same on `:focus-within`, or keyboard focus shows it clipped.
|
|
119
|
+
|
|
120
|
+
**A full-width strip passes clicks through.** A fixed bar spanning the viewport for centring takes `pointer-events: none`, its content `pointer-events: auto`, or the invisible strip swallows every click along the bottom of the page.
|
|
121
|
+
|
|
122
|
+
**Containing blocks.** A `transform`, `filter`, `backdrop-filter`, `perspective`, `contain: paint` or `will-change: transform` on an ancestor makes it the containing block for `position: fixed` descendants: a full-screen overlay renders the size of its parent strip, or a fixed header scrolls away. Check every ancestor for those properties before changing a z-index.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# Guards
|
|
2
|
+
|
|
3
|
+
Reduced motion, content that starts hidden, and set pieces: where motion can take something away. Each section's fallbacks must all exist, because each covers a different reader.
|
|
4
|
+
|
|
5
|
+
## Reduced motion: substitute, don't subtract
|
|
6
|
+
|
|
7
|
+
For every animation, ask what it communicates, then find the cheapest thing that still communicates it.
|
|
8
|
+
|
|
9
|
+
| Original | Reduced path | Why |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| Spring slide, re-targeting highlight | Flat tween at `duration.state` | The move is the message; the physics is decoration |
|
|
12
|
+
| Scale, squash or slide entrance | Plain fade at the same tier | Presence still shown; nothing travels |
|
|
13
|
+
| Blur cross-fade | Cross-fade with `blur(0px)` at both ends | Blur is the trigger; the fade isn't |
|
|
14
|
+
| Reveal-on-scroll | Off; content in its final visible state | The animation is the whole effect |
|
|
15
|
+
| Route fade, ambient loop | Off | Ditto |
|
|
16
|
+
| Live-state loop (level bars, a travelling stroke) | Held still on a representative frame | Shape and colour already say "live"; the movement only restates it |
|
|
17
|
+
| Hover lift whose motion is the whole interaction | No hover variant at all | Static is correct |
|
|
18
|
+
| Theme cross-fade | Instant | Whole-viewport interpolation |
|
|
19
|
+
| Programmatic smooth scroll | `behavior: "auto"` | Whole-viewport motion is among the strongest triggers |
|
|
20
|
+
| Icon fill cross-fade | Instant swap | The state is the message |
|
|
21
|
+
| Press scale at `duration.instant` | Keep it | Small, short, below any threshold of concern |
|
|
22
|
+
| Close tween (`ease.exit`) | Keep it | Already flat and short |
|
|
23
|
+
| Set piece | Never plays | A flourish by definition; don't substitute a quieter one |
|
|
24
|
+
|
|
25
|
+
### CSS
|
|
26
|
+
|
|
27
|
+
```css
|
|
28
|
+
/* Branch the travel, keep the fade */
|
|
29
|
+
:root { --motion-travel: 12px; }
|
|
30
|
+
@media (prefers-reduced-motion: reduce) { :root { --motion-travel: 0px; } }
|
|
31
|
+
|
|
32
|
+
/* Reveal: off entirely — resolving to the FINAL state, not the initial one */
|
|
33
|
+
@media (prefers-reduced-motion: reduce) {
|
|
34
|
+
.reveal { opacity: 1; transform: none; transition: none; }
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The theme cross-fade inverts the pattern: its whole rule sits inside `prefers-reduced-motion: no-preference`, so it's opt-in.
|
|
39
|
+
|
|
40
|
+
### Libraries
|
|
41
|
+
|
|
42
|
+
```tsx
|
|
43
|
+
// Motion
|
|
44
|
+
const reduce = useReducedMotion();
|
|
45
|
+
transition={reduce ? { duration: <duration.state> } : <spring.snappy>}
|
|
46
|
+
whileHover={reduce ? undefined : "hover"} // undefined registers no listener; "rest" still does
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
// GSAP
|
|
51
|
+
const mm = gsap.matchMedia();
|
|
52
|
+
mm.add({ reduce: "(prefers-reduced-motion: reduce)", ok: "(prefers-reduced-motion: no-preference)" }, (ctx) => {
|
|
53
|
+
const { reduce } = ctx.conditions!;
|
|
54
|
+
gsap.from(".sheet", { opacity: 0, y: reduce ? 0 : 12, duration: 0.2, ease: "power3.out" });
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
// React Spring
|
|
60
|
+
const reduce = useReducedMotion();
|
|
61
|
+
const styles = useSpring({ opacity: open ? 1 : 0, y: open || reduce ? 0 : 12, config: snappy });
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`MotionConfig reducedMotion="user"` (Motion) drops transforms and keeps opacity, a reasonable floor; `Globals.assign({ skipAnimation: true })` (React Spring) drops everything, the regression this section prevents. Neither replaces the per-component branch.
|
|
65
|
+
|
|
66
|
+
### Imperative APIs
|
|
67
|
+
|
|
68
|
+
Nothing called from JavaScript reads the media query: `scrollIntoView`, `scrollTo`, `Element.animate`, a GSAP timeline built outside `matchMedia`, a canvas or WebGL loop. Pass the preference in, and listen for its `change` event if the component lives long.
|
|
69
|
+
|
|
70
|
+
### Testing
|
|
71
|
+
|
|
72
|
+
macOS: Accessibility → Display → Reduce motion. iOS: Accessibility → Motion. Windows: Accessibility → Visual effects → Animation effects. Chrome DevTools: Rendering → Emulate `prefers-reduced-motion`. Walk the whole interface with it on, looking not for jank but for a menu that appears with no sign it opened, or a section that never appears.
|
|
73
|
+
|
|
74
|
+
Related preferences take the same shape elsewhere: `prefers-reduced-transparency` (translucent surfaces become opaque) in the depth guide, `prefers-contrast: more` in the colour guide.
|
|
75
|
+
|
|
76
|
+
## Content that starts hidden
|
|
77
|
+
|
|
78
|
+
A reveal every reader sees:
|
|
79
|
+
|
|
80
|
+
```css
|
|
81
|
+
.reveal { opacity: 0; translate: 0 var(--reveal-travel);
|
|
82
|
+
transition: opacity <duration.overlay> <ease.colour>, translate <duration.overlay> <ease.standard>; }
|
|
83
|
+
.reveal[data-in] { opacity: 1; translate: 0 0; }
|
|
84
|
+
@media (prefers-reduced-motion: reduce) { .reveal { opacity: 1; translate: none; transition: none; } }
|
|
85
|
+
@media print { .reveal { opacity: 1; translate: none; } }
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
```html
|
|
89
|
+
<noscript><style>.reveal { opacity: 1 !important; translate: none !important; }</style></noscript>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
export function observeReveals(): () => void {
|
|
94
|
+
const els = document.querySelectorAll<HTMLElement>(".reveal:not([data-in])");
|
|
95
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
96
|
+
els.forEach((el) => el.setAttribute("data-in", "")); // never leave content hidden behind a feature check
|
|
97
|
+
return () => {};
|
|
98
|
+
}
|
|
99
|
+
const io = new IntersectionObserver((entries) => {
|
|
100
|
+
for (const e of entries) {
|
|
101
|
+
if (!e.isIntersecting) continue;
|
|
102
|
+
e.target.setAttribute("data-in", "");
|
|
103
|
+
io.unobserve(e.target); // one-shot: re-animating on scroll-back is nauseating
|
|
104
|
+
}
|
|
105
|
+
}, { threshold: 0, rootMargin: "0px 0px -40px 0px" });
|
|
106
|
+
els.forEach((el) => io.observe(el));
|
|
107
|
+
return () => io.disconnect();
|
|
108
|
+
}
|
|
109
|
+
// Call again on every client-side navigation, keyed on the path.
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Each guard saves a reader who would otherwise see nothing:
|
|
113
|
+
|
|
114
|
+
| Guard | The reader it saves |
|
|
115
|
+
|---|---|
|
|
116
|
+
| `threshold: 0` with a root margin | Anyone reading an element taller than the viewport. A threshold is a fraction of the element's *own* area, and an element can never exceed viewport ÷ its height, so `0.1` is unreachable for anything over ten screens tall — a long article, exactly. A root margin works at any height |
|
|
117
|
+
| `data-in`, not a class | Anyone whose element re-renders. A framework rewrites `class` from its own value on every render, dropping a class added from outside; attributes it doesn't render, it leaves alone |
|
|
118
|
+
| No-observer fallback | Old or stripped-down browsers |
|
|
119
|
+
| `<noscript>` reset | Readers with JavaScript off, and crawlers; a feature check can't catch "the script never ran" |
|
|
120
|
+
| Print rule | Anyone printing; scroll-triggered content prints blank without it |
|
|
121
|
+
| Re-observe on navigation | Anyone arriving on a second page client-side |
|
|
122
|
+
| Reduced-motion rule | Readers who asked for less motion |
|
|
123
|
+
|
|
124
|
+
The re-render failure hides well: a utility applied during a hover can outrank the reveal's opacity, so the page breaks only when the pointer *leaves*. Test the state after the interaction, not during it.
|
|
125
|
+
|
|
126
|
+
**Any CSS that hides content pending JavaScript needs a `<noscript>` reset**, whether a reveal, a hydration class or a skeleton.
|
|
127
|
+
|
|
128
|
+
### Collapsibles
|
|
129
|
+
|
|
130
|
+
Animate the height (`grid-template-rows: 0fr` ↔ `1fr`) and keep the content mounted, `inert` and `aria-hidden` while shut, so a tab press or screen reader meets the trigger, not hidden prose, and search still finds the content in the served HTML.
|
|
131
|
+
|
|
132
|
+
## Set pieces
|
|
133
|
+
|
|
134
|
+
Decide before the first pixel, in a blocking inline script in the document head:
|
|
135
|
+
|
|
136
|
+
```html
|
|
137
|
+
<script>
|
|
138
|
+
(function () {
|
|
139
|
+
try {
|
|
140
|
+
if (matchMedia("(prefers-reduced-motion: reduce)").matches) return; // beats any replay override
|
|
141
|
+
if (location.hash) return; // a deep link is someone asking for that section
|
|
142
|
+
if (sessionStorage.getItem("intro-seen")) return; // once a session
|
|
143
|
+
document.documentElement.dataset.intro = "on"; // CSS shows the overlay at paint
|
|
144
|
+
} catch (e) {} // storage can throw; failing means no set piece
|
|
145
|
+
})();
|
|
146
|
+
</script>
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The component only *ends* it — flips the attribute — and records "seen" in its completion callback; recording on start lands in the same render as the play and cancels the animation it records. Write "seen" under reduced motion too, so the reader's next tab doesn't surprise them.
|
|
150
|
+
|
|
151
|
+
**Branch client-only flags on the transition, never on a rendered style.** "Has this played?" is false on the server for everyone and true on the client for most: a style branched on it paints differently on each, while a branched transition paints the same and differs only in how long the change takes:
|
|
152
|
+
|
|
153
|
+
```tsx
|
|
154
|
+
variants={{
|
|
155
|
+
shut: { opacity: 0, y: 6 }, // constant on both renders
|
|
156
|
+
open: { opacity: 1, y: 0, transition: { duration: reduce ? 0 : firstTime ? <set-piece> : <duration.state> } },
|
|
157
|
+
}}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
A filling CSS animation (`animation-fill-mode: both`) can't be transitioned out of with a plain declaration, because the animation's end state wins; replace the animation to end it.
|
|
161
|
+
|
|
162
|
+
**Nothing waits behind it.** The page underneath is rendered and interactive; the skip link is the first focusable element, and focusing it ends the piece; any key or click ends it at once.
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# Interface Motion
|
|
2
|
+
|
|
3
|
+
Motion in an interface has one job: **make a change legible.** Not to delight, not to demonstrate craft. Something opened, moved or was chosen, and the animation lets the user follow it. If removing an animation leaves the user no less clear about what happened, remove it.
|
|
4
|
+
|
|
5
|
+
The constraint also makes the few places motion *is* spent land hard, because nothing around them competes. The laws hold under every preset; the dials are where an essay, a dashboard and a launch page legitimately differ.
|
|
6
|
+
|
|
7
|
+
## Before you start
|
|
8
|
+
|
|
9
|
+
Read `DESIGN.md` at the project root: `## Profile` for the preset and any areas on another one, `## Motion` for the project's name for each role and the decisions already made. Reason in roles; write code in the project's names.
|
|
10
|
+
|
|
11
|
+
- **No `DESIGN.md`:** run `/keyline init` for this domain only, do the task, then recommend the full setup.
|
|
12
|
+
- **A role you need has no row:** reuse a value already doing that job; otherwise add one in the project's naming style and record it in `DESIGN.md`.
|
|
13
|
+
- **The task would break a law or move a dial off its preset:** say so and ask; if the user confirms, record it with its reason under `## Decisions`.
|
|
14
|
+
- **Preset `glass`:** the values are in `DESIGN.md`; `glass/motion/guide.md` has the exact recipes behind them.
|
|
15
|
+
|
|
16
|
+
## Laws
|
|
17
|
+
|
|
18
|
+
### 1. Motion makes a change legible, or it goes
|
|
19
|
+
|
|
20
|
+
**Every animation must answer "what just changed?" — and one that answers nothing is removed, not shortened.** Motion is the strongest attention signal an interface has; spent on nothing, it trains the eye to ignore it, and the animation that matters — a save confirming, a row arriving — goes unseen. A pulsing "New" badge beside a live sync indicator is the classic: the user tunes out both. Nothing animates to attract attention, and nothing loops except to report a state still in progress, like a spinner or a progress bar; any other loop is decoration, under law 4.
|
|
21
|
+
|
|
22
|
+
**Paused means still.** A loop reporting a live state — level bars on a recorder, a stroke travelling round a card while something works — stops or settles low when that state pauses; still moving, it claims the thing is still listening. Only a progress indicator loops through a wait, because waiting is its state. On hover or focus a live indicator morphs in place into the action that ends it — bars into a stop square — so the control's next move appears where the eye already is.
|
|
23
|
+
|
|
24
|
+
Hover travel answers nothing either: a card rising under the pointer claims it moved, or can be picked up, and a grid of them rising in turn reads as a marketing page. Hover is a wash; how far, if at all, a preset lets a hovered thing travel is a dial.
|
|
25
|
+
|
|
26
|
+
→ Live indicators, pausing and the morph to their action: `domains/motion/patterns.md`
|
|
27
|
+
|
|
28
|
+
### 2. Duration grows with how much of the screen changes
|
|
29
|
+
|
|
30
|
+
**Duration is set by how much the eye has to re-read, never by how important the element feels, and every tier is longer than the one below it.** Tie it to importance and the primary button gets a 500ms flourish, the navigation 150ms: the most-used control is the slowest, and nothing feels like one system.
|
|
31
|
+
|
|
32
|
+
| Role | What changes |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `duration.instant` | A pressed element's own response; a focus ring |
|
|
35
|
+
| `duration.micro` | One property with no position — opacity on hover |
|
|
36
|
+
| `duration.state` | One small element — colour, background, icon swap, small transform |
|
|
37
|
+
| `duration.popover` | A small element *and* its surroundings — a menu, a hover card |
|
|
38
|
+
| `duration.overlay` | A large region entering or collapsing — a sheet, a panel, a reveal |
|
|
39
|
+
| `duration.theme` | Everything on the page at once |
|
|
40
|
+
|
|
41
|
+
An element tied to a larger change takes its tier: a disclosure chevron runs at `duration.popover` and lands with the panel opening below it. Hover is no exception: a wash across a large card at `duration.micro`'s 80ms looks instant, not smooth, where 200ms in and 250ms out reads as a fade; rows swept in bulk keep the short tier (Dials). `duration.route` is the one tier size doesn't set; law 3 caps it. The numbers are dials; the ordering is not.
|
|
42
|
+
|
|
43
|
+
**`duration.theme` runs only when the page itself switches theme**, under a class held on the root for the length of the switch. Everyday colour transitions leave out the page background and are suspended during a theme change: otherwise an OS switch by media query, which has no class to hold, cross-fades every control at its own duration while the page around it snaps.
|
|
44
|
+
|
|
45
|
+
### 3. Nothing repeated waits on an animation
|
|
46
|
+
|
|
47
|
+
**Anything a user does many times a session must never wait on motion: feedback starts on the input, every transition can be interrupted, and anything on the path to content is capped.** An animation is watched the first time and endured the fortieth: a tab switch that slides for 300ms is charming once and a tax by lunchtime, and a dialog that ignores clicks until its exit completes turns a double-click into a missed one.
|
|
48
|
+
|
|
49
|
+
- Press feedback lands on `pointerdown` at `duration.instant`; waiting for `click` feels dead.
|
|
50
|
+
- Controls stay live while animating: a new target re-targets the motion in flight, never queues.
|
|
51
|
+
- A route transition sits between "I clicked" and "I can read": opacity only, never longer than `duration.overlay`, the incoming page interactive from its first frame.
|
|
52
|
+
|
|
53
|
+
### 4. Off-ladder motion names its exemption
|
|
54
|
+
|
|
55
|
+
**The ladder is for state. Anything longer must be classed as not-state — the subject of the moment, a set piece, or ambient — with the class written down beside it.** Forced onto the ladder these get worse; unclassed, a 900ms flourish ends up on a menu.
|
|
56
|
+
|
|
57
|
+
| Class | What it is | What it must earn |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| Subject | The motion *is* the content being looked at — a dissolve between photographs, a chart drawing itself once | It isn't chrome reporting a change; the same component's *state* changes stay on the ladder |
|
|
60
|
+
| Set piece | A greeting, a first-run sequence, a launch moment | Law 15: once a session, blocks nothing |
|
|
61
|
+
| Ambient | A background loop in a hero | Never behind text being read, paused off screen, absent under reduced motion; `expressive` only. Where nothing can pause it off screen, it is finite — a few iterations, then rest — or removed |
|
|
62
|
+
|
|
63
|
+
The test for all three: **would a user see this more than a handful of times a session?** If yes, it's state, and on the ladder.
|
|
64
|
+
|
|
65
|
+
### 5. One standard curve; colour never overshoots
|
|
66
|
+
|
|
67
|
+
**Everything that moves shares `ease.standard`; colour and opacity use `ease.colour`; and no curve may pass its target on a property with a floor.** One shared curve makes unrelated elements moving together feel like one mechanism; the urge for a second bezier is the urge for a spring, where character belongs (law 6).
|
|
68
|
+
|
|
69
|
+
Colour has no mass, so overshoot on a background is a wobble in the light; opacity clamps at 1, so overshoot is a wasted tail that mistimes the fade. On a property with a hard floor — `blur()` or `brightness()` toward 0, a radius toward 0, a scale that mustn't invert — browsers clamp rather than error, and the track *stalls* for its last few frames, which is hard to trace. **Check what else rides the curve:** transform with opacity is fine; add a filter and the whole track needs a monotone curve. Whether `ease.standard` overshoots is a dial; this law bites only where it does.
|
|
70
|
+
|
|
71
|
+
### 6. Springs are named by character, and move only things with position
|
|
72
|
+
|
|
73
|
+
**A spring is named for how it feels (`spring.snappy`, `spring.soft`), used wherever something can be re-targeted mid-flight or grabbed, and never drives opacity, colour or filter.** A bezier restarts from a standstill at each new target, so a highlight chasing the pointer across five items stutters five times; a spring keeps its velocity. **If the user could grab it, or it could change target mid-flight, it's a spring.** A number isn't a decision: `spring-400-30` says nothing about when to reach for it. Opacity splits out as its own short tween, because a spring's overshoot on opacity hitches at the tail of every fade.
|
|
74
|
+
|
|
75
|
+
Springs are wrong for discrete flips with no travel, anything that must land in step with a bezier, and long distances at low stiffness: a soft spring across 800px takes over a second and feels broken.
|
|
76
|
+
|
|
77
|
+
→ Curves and springs per preset in four libraries, the opacity split in code, squash and stretch, and tuning by symptom: `domains/motion/ladder-and-curves.md`
|
|
78
|
+
|
|
79
|
+
### 7. Exits get out of the way
|
|
80
|
+
|
|
81
|
+
**Closing is faster than opening, flat, and eases in — no spring, no overshoot, and it travels less far.** Opening answers the user's intent and can afford character; closing comes after they've moved on, so a bounce reads as reluctance and stands in their way, and a disappearing element needn't travel the full distance. In the Glass preset a menu opens on a spring of about 400ms and closes on a 130ms `ease-in` tween. The law is for things leaving; a hover wash fading behind a pointer that has already moved on stands in nobody's way, so its leave may run a little longer than its arrival, and a sweep across a board fades rather than strobes.
|
|
82
|
+
|
|
83
|
+
### 8. Where identity persists, the element travels
|
|
84
|
+
|
|
85
|
+
**If the same thing is on screen before and after a change, it moves between the two positions rather than fading out in one place and in at another.** Per-item hover backgrounds across a row of icons give overlapping fades, one brightening as its neighbour dims: a stutter, not an object. One shared highlight that re-targets its position and size can't flicker, because there is only one; it skips its mount animation, or it flies in from the corner on first paint.
|
|
86
|
+
|
|
87
|
+
A view switch keeps the reader's place (layout law 9), so **measure the reading position before the outgoing layout unmounts** and open the incoming one there, neither end stretched to the other's aspect. A shared element is the wrong tool for a lone item, items that reflow while hovered, items in different scroll containers, and a grid crossed diagonally.
|
|
88
|
+
|
|
89
|
+
→ Highlight, tooltip and layout-switch patterns in View Transitions, Motion `layoutId` and GSAP Flip: `domains/motion/patterns.md`
|
|
90
|
+
|
|
91
|
+
### 9. One highlight travels between hovered and current
|
|
92
|
+
|
|
93
|
+
**Where a group shows both a current item and the one under the pointer — a nav, a segmented control — one highlight travels with the pointer and comes to rest on the current item, rather than a second treatment appearing beside the first.** What current looks like is colour law 7's, never re-picked here, and weight never changes on hover (type law 5). Previewing a choice and reporting one are the same gesture a beat apart, so they share one moving shape (law 8): two signals each doing half the job, never one doing both. Outside such a group, hover is a wash (the dials). Nothing is filled because it looks better.
|
|
94
|
+
|
|
95
|
+
A filled icon is the same outline with its interior painted, cross-faded in place at `duration.popover` on `ease.colour`, so the silhouette never moves. Marks that can't take it — a brand's official solid mark, a chevron with no interior, a lone toggle with nothing to be picked out from — signal another way. **Fill that distinguishes nothing is just weight.** Nor is a step from `text.secondary` to `text.primary` a signal; the colour guide covers why.
|
|
96
|
+
|
|
97
|
+
### 10. Reduced motion is a branch with a substitute
|
|
98
|
+
|
|
99
|
+
**Every animated component reads `prefers-reduced-motion` and takes its own reduced path that still communicates the change; a global off switch is a regression.** The preference exists because large-area and accelerating motion causes nausea and dizziness, not to remove visible state: `* { animation: none !important }` strips the signals with the decoration, and a menu appears with no sign it opened.
|
|
100
|
+
|
|
101
|
+
| Original | Reduced path |
|
|
102
|
+
|---|---|
|
|
103
|
+
| Spring slide, re-targeting highlight | A flat tween at `duration.state`; the move stays legible |
|
|
104
|
+
| Scale or squash entrance | A plain fade |
|
|
105
|
+
| Blur cross-fade | A cross-fade; the blur is the trigger, not the fade |
|
|
106
|
+
| Reveal-on-scroll, route fade, ambient loop | Off, with content in its final visible state |
|
|
107
|
+
| Theme cross-fade, programmatic smooth scroll | Instant |
|
|
108
|
+
| Press scale at `duration.instant` | Kept: too small and short to matter |
|
|
109
|
+
| Set piece | Skipped entirely, not softened |
|
|
110
|
+
|
|
111
|
+
Imperative APIs — `scrollIntoView`, `scrollTo`, `Element.animate`, a GSAP timeline, a canvas loop — don't read the preference; pass it in. Motion's `MotionConfig reducedMotion="user"` and React Spring's `skipAnimation` are a floor, not the plan.
|
|
112
|
+
|
|
113
|
+
→ Every substitute in CSS, Motion, GSAP and React Spring, and how to test: `domains/motion/guards.md`
|
|
114
|
+
|
|
115
|
+
### 11. Motion never owns whether content is visible
|
|
116
|
+
|
|
117
|
+
**Any style that hides content until an animation runs must resolve to visible whenever that animation can't run** — no JavaScript, no observer, reduced motion, print, or an element too tall to trip its trigger. The failure is not a plainer page but a correct document the reader can't see. Two such bugs on the Glass preset's reference site left content at opacity 0: an observer threshold of 0.1 that no section over ten viewports tall could reach, and an externally added `in` class the framework rewrote out of a state-dependent `className`.
|
|
118
|
+
|
|
119
|
+
So reveals are one-shot, with a zero threshold and a root margin, re-observed on client navigation, marked by a data attribute the framework doesn't own, and reset by a `<noscript>` style and a print rule. A collapsible animates its height but stays mounted (layout law 11).
|
|
120
|
+
|
|
121
|
+
### 12. Scroll work runs once a frame, and never hides what it can't bring back
|
|
122
|
+
|
|
123
|
+
**Each scroll-driven behaviour is one passive listener collapsed to one `requestAnimationFrame`, with a dead zone on anything that toggles on direction — and nothing collapses on a device that can't expand it again.** A rAF flag gives one measurement per painted frame, which no time-based throttle can. A dead zone of a few pixels stops trackpad jitter toggling a collapsing header forever. Whether it may collapse at all on touch is layout law 8.
|
|
124
|
+
|
|
125
|
+
### 13. Animate what the compositor can; escalate one element, never a view
|
|
126
|
+
|
|
127
|
+
**Animate `transform` and `opacity`, list every transitioned property by name, justify any layout property you animate, and when the DOM can't carry a transition, move one decorative element to the GPU — never the view.** `transition: all` catches properties you never meant, including layout ones a parent adds later. Animating `width` is defensible only where nothing reflows, as on one absolutely positioned element outside the flow; otherwise use a transform, or the `0fr`↔`1fr` grid track to "animate to auto". A transform on an ancestor weakens `backdrop-filter` beneath it, so the moving wrapper and the translucent surface are separate elements (the depth guide has the material side).
|
|
128
|
+
|
|
129
|
+
GPU escalation is for one travelling element with no semantics of its own, never a view of tiles, whose semantics a canvas can't carry and whose textures cost hundreds of megabytes; its setup returns `null` rather than throwing, the path reduced motion and no-WebGL already take.
|
|
130
|
+
|
|
131
|
+
→ The scroll shape with hysteresis, scroll spies, the grid technique, GPU rules and bundle discipline: `domains/motion/performance.md`
|
|
132
|
+
|
|
133
|
+
### 14. Pointer gaps get intent, not luck
|
|
134
|
+
|
|
135
|
+
**A hover-opened element gets a close grace period of about 100ms, or a hover bridge across the gap; the timer is cleared on unmount and on navigation; and whatever hover reveals, focus reveals too, with a separate path for touch.** Without it, the menu closes in the gap; where trigger and menu touch, a bridge of padding is cheaper, because the pointer never leaves. A timer left running across a navigation reopens a stale menu on the next page. Delay the *close*, never the open (law 3).
|
|
136
|
+
|
|
137
|
+
### 15. Set pieces play once and block nothing
|
|
138
|
+
|
|
139
|
+
**A greeting, a first-run sequence or a launch flourish plays at most once a session, is decided before the first paint, skips for deep links and reduced motion, and never covers content or the skip link.** Its long budget is defensible only because it never recurs. Gate it in a blocking inline script that sets a root attribute for CSS to answer at paint: the server can't see session storage, and a component decides a frame too late. Record "seen" on *completion*, or the write cancels the animation it records; branch client-only flags on the *transition*, never a rendered style, or server and client paint differently. Focusing the skip link, pressing a key or following a link ends it at once.
|
|
140
|
+
|
|
141
|
+
→ The inline gate, the hydration-safe variant and the "seen" bookkeeping: `domains/motion/guards.md`
|
|
142
|
+
|
|
143
|
+
## Dials
|
|
144
|
+
|
|
145
|
+
| Dial | editorial | product | expressive |
|
|
146
|
+
|---|---|---|---|
|
|
147
|
+
| `duration.micro` | 100ms | 80ms | 120ms |
|
|
148
|
+
| `duration.state` | 150ms | 100ms | 200ms |
|
|
149
|
+
| `duration.popover` | 200ms | 150ms | 280ms |
|
|
150
|
+
| `duration.overlay` | 300ms | 200ms | 450ms |
|
|
151
|
+
| `duration.route` | 250ms opacity fade | none | 400ms fade; directional only where routes have a real order |
|
|
152
|
+
| `duration.theme` | 400ms | 250ms | 600ms |
|
|
153
|
+
| Exit duration | ≈ ⅔ of the entrance (popover exit 130ms) | 100ms | 180ms |
|
|
154
|
+
| `ease.standard` | `cubic-bezier(0.22, 1, 0.36, 1)` — no overshoot | `cubic-bezier(0.2, 0, 0, 1)` — no overshoot | `cubic-bezier(0.34, 1.2, 0.64, 1)` — slight overshoot |
|
|
155
|
+
| `ease.colour` · `ease.exit` | `ease` · `ease-in` | `ease-out` · `ease-in` | `ease` · `ease-in` |
|
|
156
|
+
| Springs | `snappy` 0.15 / 0.35s, `soft` 0.2 / 0.45s, one `playful` 0.3 / 0.5s | `snappy` 0 / 0.25s, for drag, throw and a re-targeting indicator only | `snappy` 0.2 / 0.4s, `soft` 0.3 / 0.6s, `playful` 0.45 / 0.7s |
|
|
157
|
+
| Reveal-on-scroll | 8px travel, `duration.overlay`, no stagger | none | up to 24px, stagger ≤ 80ms, whole group ≤ 600ms |
|
|
158
|
+
| Press feedback | scale 0.96 on small controls | `surface.pressed` step, no scale | scale 0.92–0.95; wider than ≈ 200px, `scale(0.98)` or a 1px translate |
|
|
159
|
+
| Hover on a card or large surface | a wash, 200ms in · 250ms out; no travel | a wash, 200ms in · 250ms out; no travel, no shadow change | a wash, 280ms in · 350ms out; a lift ≤ 4px on primary navigation only |
|
|
160
|
+
| Set pieces | at most one on the site | none | a few, each named |
|
|
161
|
+
| Ambient loops | none | progress indicators, and live-state indicators while the state is live | a hero only, paused off screen; where nothing can pause it, one finite motion — a few iterations, then rest |
|
|
162
|
+
| Sound | absent, or opt-in behind a visible toggle | absent | opt-in, default off, visible toggle |
|
|
163
|
+
|
|
164
|
+
Springs are Motion's `bounce / duration`.
|
|
165
|
+
|
|
166
|
+
The presets move the tiers together, so law 2's ordering survives any row. Each set piece and ambient loop is a named entry in `DESIGN.md` with its law 4 class; exceeding the preset's allowance is a decision with a reason, not a drift.
|
|
167
|
+
|
|
168
|
+
→ Why each dial sits where it does: `domains/motion/ladder-and-curves.md`. Sound's rules: `domains/motion/patterns.md`
|
|
169
|
+
|
|
170
|
+
## Building it for a project
|
|
171
|
+
|
|
172
|
+
1. **Read the preset and its areas.** A `product` app with an `expressive` marketing area gets two ladders; the area's paths say which a file uses.
|
|
173
|
+
2. **Inventory what moves**, built or planned, with what each changes and how much screen it covers. The ladder comes from this list, not a token file.
|
|
174
|
+
3. **Assign tiers by area** (law 2), promoting anything tied to a larger change. What doesn't fit is a stray to snap or an exemption to class under law 4; nothing is left unclassed.
|
|
175
|
+
4. **Take the preset's numbers** and check them against the project: a consistent existing value within ~20ms of a dial is kept and recorded; scattered strays snap to the ladder. Confirm the tiers strictly ascend.
|
|
176
|
+
5. **Fix the curves.** One `ease.standard`, one `ease.colour`, `ease.exit` only if exits differ. If the standard curve overshoots, find every track carrying a filter or floored property.
|
|
177
|
+
6. **Name only the springs the inventory needs**, by character; with no drag and no re-targeting indicator, maybe none.
|
|
178
|
+
7. **Write the reduced path for every inventory row** (law 10), and the visible end state for everything that starts hidden (law 11).
|
|
179
|
+
8. **Emit tokens in the project's mechanism**: custom properties, a Tailwind theme or a JS theme object.
|
|
180
|
+
|
|
181
|
+
→ Per-preset tables, the *Ledgerline* token file with Tailwind mappings, and a derivation from a messy codebase: `domains/motion/ladder-and-curves.md`
|
|
182
|
+
|
|
183
|
+
## Auditing existing code
|
|
184
|
+
|
|
185
|
+
Collect evidence, then report:
|
|
186
|
+
|
|
187
|
+
- **Off-ladder durations:** `rg -n "\d+ms|duration[-:]\s*\d|duration-\[" src`; each value is a tier, a stray or an unclassed exemption.
|
|
188
|
+
- **Curves:** `rg -n "cubic-bezier|ease-in-out|ease:" src`; more distinct beziers than the preset's two or three is a finding, as is an overshooting one on a track with a filter.
|
|
189
|
+
- **Entrances on `ease-in`**, exits on springs: `rg -n "exit=|ease-in" src`.
|
|
190
|
+
- **Opacity inside a spring:** `type: "spring"` with no per-property opacity override.
|
|
191
|
+
- **`transition: all`**, `transition-all`, animated `width|height|top|left|margin`; a colour transition on the page background outside the theme switch's class.
|
|
192
|
+
- **Reduced motion:** animated components against `useReducedMotion|prefers-reduced-motion|matchMedia` branches; global `animation: none !important`; `scrollIntoView|scrollTo|\.animate\(` with no branch.
|
|
193
|
+
- **Hidden until animated:** `opacity: 0` initial states, reveal classes, `threshold:` above 0, `classList.add` on framework-rendered elements; a missing `<noscript>` reset or print rule.
|
|
194
|
+
- **Scroll:** `addEventListener\("scroll"` without `passive` or rAF, or several per behaviour.
|
|
195
|
+
- **Loops:** `infinite`, `repeat: Infinity`, `setInterval` driving UI; an ambient `infinite` that nothing pauses off screen.
|
|
196
|
+
- **Hover:** timers never cleared on unmount; any hover style, decoration included, outside a `(hover: hover) and (pointer: fine)` gate; `rg -n "hover:-?translate|:hover[^{]*\{[^}]*transform" src` for cards that lift where the preset says wash.
|
|
197
|
+
- **Live indicators:** loops with no paused branch.
|
|
198
|
+
|
|
199
|
+
Then tab through with reduced motion on, and with JavaScript off, for anything that never appears or appears with no sign it opened. Check hover states on *leave* as well as enter; the leave state is where transition-property mistakes live.
|
|
200
|
+
|
|
201
|
+
Report each breach as one line under `## Open issues`, with the law, the evidence and the reach: *"Law 11 — `.fade-up` has no `<noscript>` reset; content blank with JS off. 23 components."*
|
|
202
|
+
|
|
203
|
+
## Writing back to DESIGN.md
|
|
204
|
+
|
|
205
|
+
Under `## Motion`: ladder rows (role, name here, value, used for); curves; springs by character; the reduced-motion policy in a sentence; each off-ladder entry as a row in the **Named exemptions** table (exemption, values, why — its law 4 class and where it plays), which the detector reads; the sound policy, if any.
|
|
206
|
+
|
|
207
|
+
Under `## Decisions`, dated, with a reason: any dial moved off the preset (*"`duration.state` 150ms, not 100ms: the ledger's hover rows felt abrupt in testing"*), a set piece added, sound turned on, a law knowingly broken.
|
|
208
|
+
|
|
209
|
+
Under `## Open issues`: each audit breach, one line with its evidence, removed when fixed; don't fix beyond the task unasked.
|
|
210
|
+
|
|
211
|
+
## Checklist
|
|
212
|
+
|
|
213
|
+
- [ ] Every animation answers "what changed?"; only an ongoing state loops, and it holds still when that state pauses.
|
|
214
|
+
- [ ] Live indicators morph in place to their action on hover and focus.
|
|
215
|
+
- [ ] `duration.*` tiers strictly ascend; the area changed sets each animation's tier, hover included.
|
|
216
|
+
- [ ] Hover on cards is a wash at the dial's in · out, with no travel unless the preset allows it.
|
|
217
|
+
- [ ] Press lands on `pointerdown` at `duration.instant`; anything wider than about 200px presses with `scale(0.98)` or a 1px translate; nothing repeated waits; `duration.route` ≤ `duration.overlay`.
|
|
218
|
+
- [ ] `duration.theme` runs only under the page's own switch class; everyday colour transitions leave out the page background and are suspended while the theme changes.
|
|
219
|
+
- [ ] Each off-ladder duration is classed subject, set piece or ambient in `DESIGN.md`.
|
|
220
|
+
- [ ] One `ease.standard`; colour and opacity on `ease.colour`; no overshoot on a filter or floored property.
|
|
221
|
+
- [ ] Springs are `spring.<character>`, only where things re-target or are grabbed, opacity split out.
|
|
222
|
+
- [ ] Exits are shorter than entrances, flat, on `ease.exit`, no spring.
|
|
223
|
+
- [ ] Shared elements re-target, never remount, and skip their mount animation; layout switches keep the reading position.
|
|
224
|
+
- [ ] In a group with a current item, one highlight travels between hovered and current; current looks as colour law 7 says; no weight change on hover; marks that can't take a fill signal another way.
|
|
225
|
+
- [ ] Each animated component has a reduced path that still shows the change; imperative APIs get the preference.
|
|
226
|
+
- [ ] Hidden-until-animated content survives no JS, no observer, reduced motion, print, tall elements and re-renders.
|
|
227
|
+
- [ ] Scroll work is passive, rAF-collapsed and hysteretic, and never hides what touch can't restore.
|
|
228
|
+
- [ ] Only `transform` and `opacity` animate, each property named; GPU escalation is one element and fails to `null`.
|
|
229
|
+
- [ ] Hover-opened elements get a close grace or bridge and cleared timers; focus reveals what hover does.
|
|
230
|
+
- [ ] Set pieces play once a session, gate before first paint, record "seen" on completion, never block the skip link.
|
|
231
|
+
- [ ] Durations, curves and springs match the preset, or a `## Decisions` entry says why not.
|
|
232
|
+
- [ ] Reveal travel, press feedback and route transitions match the preset.
|
|
233
|
+
- [ ] Set pieces and ambient loops are within the preset's allowance; a loop nothing can pause off screen is finite or gone.
|
|
234
|
+
- [ ] Sound only if the preset allows it, off by default behind a visible, persisted toggle.
|