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,102 @@
|
|
|
1
|
+
# Radius, corners, and stacking
|
|
2
|
+
|
|
3
|
+
Glass's values for layout laws 5–7; the rules themselves, and the nesting worked through in general, are in `domains/layout/radius-lines-and-layers.md`.
|
|
4
|
+
|
|
5
|
+
## The nesting rule, through the site
|
|
6
|
+
|
|
7
|
+
Layout law 5, in the reference implementation's dock:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
Dock: rounded-2xl (16px) p-1 (4px padding)
|
|
11
|
+
Icon button: rounded-[12px] (12px) 16 − 4 = 12 ✓
|
|
12
|
+
Avatar: rounded-[10px] (10px) inset ~2px 12 − 2 = 10 ✓
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Three levels, each derived. The avatar is `h-9 w-9` inside an `h-10 w-10` button — a 2px inset all round, so 10px.
|
|
16
|
+
|
|
17
|
+
The mobile sheet snaps to the ladder:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
Sheet: rounded-2xl (16px) p-1.5 (6px)
|
|
21
|
+
Row: rounded-xl (12px) 16 − 6 = 10, rounded up to 12
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## The ladder
|
|
25
|
+
|
|
26
|
+
| Radius | Class | For |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| full | `rounded-full` | Pills, tags, dots, markers |
|
|
29
|
+
| 2px | — | The focus ring's own radius |
|
|
30
|
+
| 5px | `rounded-[5px]` | A lettermark box (tiny, so a small radius) |
|
|
31
|
+
| 6px | `rounded-md` | Tooltips, the skip link |
|
|
32
|
+
| 9px | `rounded-[9px]` | Small cards in a stack |
|
|
33
|
+
| 10px | `rounded-[10px]` | Avatar inside a button; a list highlight bar |
|
|
34
|
+
| 12px | `rounded-xl` / `rounded-[12px]` | Cards, hover panels, disclosures, icon buttons |
|
|
35
|
+
| 16px | `rounded-2xl` / `rounded-[16px]` | Outer containers — a dock, a sheet, a pocket |
|
|
36
|
+
|
|
37
|
+
Radius scales with size, not importance (layout law 5): a 40px icon button gets 12px; a 300px panel gets 16px.
|
|
38
|
+
|
|
39
|
+
## Continuous corners
|
|
40
|
+
|
|
41
|
+
The rule is in §4 of the guide: `superellipse(1.5)` on every element and pseudo-element. **1.5 rather than 2** because a full squircle is noticeable — at small sizes it looks like a slightly swollen rectangle. Halfway reads as a well-drawn corner rather than an effect.
|
|
42
|
+
|
|
43
|
+
## Hairline weights
|
|
44
|
+
|
|
45
|
+
| Weight | Class | For |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| 0.5px | `border-[0.5px]`, `h-[0.5px]` | Chrome borders, dividers, glass edges |
|
|
48
|
+
| 1px | `border`, `ring-1`, `h-px` | Card borders, TOC markers |
|
|
49
|
+
| 2px | `border-l-2` | Quote rules, focus rings |
|
|
50
|
+
|
|
51
|
+
By job (layout law 6): two groups of icons within one dock, 0.5px; where a card ends, 1px; a blockquote's left rule, 2px.
|
|
52
|
+
|
|
53
|
+
Vertical dividers use `w-[0.5px]` with an explicit height and `self-center`:
|
|
54
|
+
|
|
55
|
+
```tsx
|
|
56
|
+
<span aria-hidden className="mx-0.5 h-6 w-[0.5px] self-center bg-gray-alpha-300" />
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
A 24px line in a 40px-tall row, one step lighter than the dock's `alpha-400` edge, because it's an internal articulation, not a boundary.
|
|
60
|
+
|
|
61
|
+
**The box form is a gap the reference implementation still has.** `h-[0.5px]` and `w-[0.5px]` with a background are boxes, which layout law 6 says a 1x screen can drop. Draw every hairline as a border instead: `border-t-[0.5px]` on the element, or an empty span carrying `border-l-[0.5px]` for a vertical divider, the same weight and colour by the more reliable route.
|
|
62
|
+
|
|
63
|
+
## Elevation tiers
|
|
64
|
+
|
|
65
|
+
Glass's shadow tiers — chrome, pocket, panel, card, transient — are depth's: `glass/depth/depth-cues.md`.
|
|
66
|
+
|
|
67
|
+
## The z-index ladder
|
|
68
|
+
|
|
69
|
+
The ladder is in §8 of the guide. **The 40/50 pair** in code:
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
{open && (
|
|
73
|
+
<div aria-hidden onClick={() => setOpen(false)}
|
|
74
|
+
className="pointer-events-auto fixed inset-0 z-40 sm:hidden" />
|
|
75
|
+
)}
|
|
76
|
+
<div className="pointer-events-auto relative z-50 sm:hidden">
|
|
77
|
+
{/* the menu */}
|
|
78
|
+
</div>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**`z-index: 10` for a hover lift:**
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
<Link className="group relative block transition-[z-index] hover:z-10 focus-within:z-10">
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Folder cards rising out of a pocket would otherwise be clipped by the next folder; `focus-within:z-10` gives keyboard focus the same lift.
|
|
88
|
+
|
|
89
|
+
## Stacking-context techniques
|
|
90
|
+
|
|
91
|
+
The general techniques are in `domains/layout/radius-lines-and-layers.md`. Glass's cases:
|
|
92
|
+
|
|
93
|
+
- **`isolate` plus negative z** scopes the dock's sliding highlight behind its icons but in front of the glass: the code is in `glass/depth/depth-cues.md`.
|
|
94
|
+
- **The nav strip** passes clicks through and carries no transform (§8 of the guide); a transform would trap the mobile sheet's full-bleed `w-screen` overlay inside the strip.
|
|
95
|
+
|
|
96
|
+
### `transform-origin` for menus
|
|
97
|
+
|
|
98
|
+
```tsx
|
|
99
|
+
const anchor = placement === "up" ? "origin-bottom" : "origin-top";
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
A menu scales **from its trigger's edge**, or it seems to detach and drift; off one side, use the corner: `origin-top-left`.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The squash-and-stretch floating menu — the shared open/close motion for
|
|
3
|
+
* every menu in the system (a phone nav sheet, a settings drop-up, a socials
|
|
4
|
+
* pill). All of them grow out of a trigger sitting against one edge, so all
|
|
5
|
+
* of them stretch from that edge.
|
|
6
|
+
*
|
|
7
|
+
* The effect comes from letting two springs DISAGREE. See motion-presets.ts
|
|
8
|
+
* for the numbers and the reasoning.
|
|
9
|
+
*
|
|
10
|
+
* Two structural decisions worth keeping:
|
|
11
|
+
*
|
|
12
|
+
* 1. The panel mounts only while open, so a closed menu is out of the tab
|
|
13
|
+
* order and off the paint. That matters here because the panel carries
|
|
14
|
+
* backdrop-filter, which is expensive even when invisible.
|
|
15
|
+
*
|
|
16
|
+
* 2. The wrapper is ALWAYS present, so a trigger's aria-controls still
|
|
17
|
+
* resolves to a real element when the menu is closed.
|
|
18
|
+
*
|
|
19
|
+
* Note this component animates a bare m.div and expects its child to carry
|
|
20
|
+
* the glass. A transform on an ancestor weakens backdrop-filter in its
|
|
21
|
+
* subtree — the wrapper scales, the child blurs, and they must be different
|
|
22
|
+
* elements.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { AnimatePresence, LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
|
|
26
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
27
|
+
import { GROW, CROSS, CLOSE } from "./motion-presets";
|
|
28
|
+
|
|
29
|
+
export type DropPlacement = "up" | "down";
|
|
30
|
+
|
|
31
|
+
export function DropMenu({
|
|
32
|
+
open,
|
|
33
|
+
placement = "up",
|
|
34
|
+
origin,
|
|
35
|
+
id,
|
|
36
|
+
className = "",
|
|
37
|
+
style,
|
|
38
|
+
onMouseEnter,
|
|
39
|
+
onMouseLeave,
|
|
40
|
+
children,
|
|
41
|
+
}: {
|
|
42
|
+
open: boolean;
|
|
43
|
+
placement?: DropPlacement;
|
|
44
|
+
/** transform-origin class — defaults to the edge the menu grows from. */
|
|
45
|
+
origin?: string;
|
|
46
|
+
id?: string;
|
|
47
|
+
className?: string;
|
|
48
|
+
style?: CSSProperties;
|
|
49
|
+
onMouseEnter?: () => void;
|
|
50
|
+
onMouseLeave?: () => void;
|
|
51
|
+
children: ReactNode;
|
|
52
|
+
}) {
|
|
53
|
+
const reduce = useReducedMotion();
|
|
54
|
+
|
|
55
|
+
// Start nudged back toward the trigger, so it reads as coming out of it.
|
|
56
|
+
const away = placement === "up" ? 4 : -4;
|
|
57
|
+
|
|
58
|
+
// Scale from the anchored edge, or the caller's corner for a menu that
|
|
59
|
+
// hangs off one side of its trigger (e.g. origin-top-left).
|
|
60
|
+
const anchor = origin ?? (placement === "up" ? "origin-bottom" : "origin-top");
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div
|
|
64
|
+
id={id}
|
|
65
|
+
style={style}
|
|
66
|
+
onMouseEnter={onMouseEnter}
|
|
67
|
+
onMouseLeave={onMouseLeave}
|
|
68
|
+
// Empty and inert when closed, so it can't sit over the page as a trap.
|
|
69
|
+
className={`${className} ${open ? "" : "pointer-events-none"}`}
|
|
70
|
+
>
|
|
71
|
+
<LazyMotion features={domAnimation} strict>
|
|
72
|
+
<AnimatePresence>
|
|
73
|
+
{open && (
|
|
74
|
+
<m.div
|
|
75
|
+
className={anchor}
|
|
76
|
+
/* Open squashes from a real offset; exit only travels part of
|
|
77
|
+
the way back, because it's disappearing anyway. */
|
|
78
|
+
initial={
|
|
79
|
+
reduce ? { opacity: 0 } : { opacity: 0, scaleY: 0.82, scaleX: 0.94, y: away }
|
|
80
|
+
}
|
|
81
|
+
animate={reduce ? { opacity: 1 } : { opacity: 1, scaleY: 1, scaleX: 1, y: 0 }}
|
|
82
|
+
exit={
|
|
83
|
+
reduce
|
|
84
|
+
? { opacity: 0, transition: CLOSE }
|
|
85
|
+
: { opacity: 0, scaleY: 0.9, scaleX: 0.97, y: away, transition: CLOSE }
|
|
86
|
+
}
|
|
87
|
+
transition={
|
|
88
|
+
reduce
|
|
89
|
+
? { duration: 0.15 }
|
|
90
|
+
: {
|
|
91
|
+
scaleY: GROW, // the grow axis overshoots
|
|
92
|
+
y: GROW, // travel rides the same spring, or the
|
|
93
|
+
// anchored edge detaches mid-overshoot
|
|
94
|
+
scaleX: CROSS, // the cross axis arrives late
|
|
95
|
+
opacity: { duration: 0.14, ease: "easeOut" },
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
>
|
|
99
|
+
{children}
|
|
100
|
+
</m.div>
|
|
101
|
+
)}
|
|
102
|
+
</AnimatePresence>
|
|
103
|
+
</LazyMotion>
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Motion presets — every spring and tween in the system, with the
|
|
3
|
+
* reduced-motion substitutes.
|
|
4
|
+
*
|
|
5
|
+
* Two vocabularies appear here on purpose:
|
|
6
|
+
* bounce + duration — a designer's controls; predictable arrival
|
|
7
|
+
* stiffness + damping + mass — needed when the RELATIONSHIP between two
|
|
8
|
+
* springs is the effect (see GROW / CROSS)
|
|
9
|
+
*
|
|
10
|
+
* The one rule that applies to all of them: opacity never springs. Position
|
|
11
|
+
* wants physics, opacity wants a curve. A spring on opacity overshoots past
|
|
12
|
+
* its clamp and hitches on the way back — a visible flicker at the tail of
|
|
13
|
+
* every fade.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/* ── Shared element re-targeting ─────────────────────────────────────────
|
|
17
|
+
A highlight or tooltip sliding to a new item. bounce is near the floor of
|
|
18
|
+
"perceptibly springy" on purpose: this is a thing MOVING, not a thing
|
|
19
|
+
ARRIVING. Bounce implies a moment of settling, and a highlight that
|
|
20
|
+
settles at every stop draws attention to itself instead of to the item
|
|
21
|
+
it's marking. */
|
|
22
|
+
export const SLIDE = {
|
|
23
|
+
type: "spring",
|
|
24
|
+
bounce: 0.2,
|
|
25
|
+
duration: 0.4,
|
|
26
|
+
opacity: { duration: 0.16, ease: "easeOut" },
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
/* ── The one playful moment ──────────────────────────────────────────────
|
|
30
|
+
Cards rising out of a folder on hover. 40% more bounce and 25% more
|
|
31
|
+
duration than anything else in the system. That it's the ONLY such moment
|
|
32
|
+
is why it works: against a baseline of restraint, one exuberant
|
|
33
|
+
interaction reads as craft. Three of them read as a template. */
|
|
34
|
+
export const LIFT = { type: "spring", bounce: 0.28, duration: 0.5 } as const;
|
|
35
|
+
|
|
36
|
+
/* ── Squash and stretch, out of physics ──────────────────────────────────
|
|
37
|
+
Two springs of deliberately different character, applied to the two axes
|
|
38
|
+
of one panel. Every difference pushes the same direction — GROW is fast
|
|
39
|
+
and loose, CROSS is slow and controlled:
|
|
40
|
+
|
|
41
|
+
stiffness 620 vs 380 GROW pulls harder, moves sooner
|
|
42
|
+
damping 19 vs 30 GROW resists less, overshoots
|
|
43
|
+
mass 0.85 vs 1 GROW is lighter, accelerates faster
|
|
44
|
+
|
|
45
|
+
Through the overshoot the panel is briefly TALLER and NARROWER than it
|
|
46
|
+
ends up, then settles. Squash and stretch emerging from physics rather
|
|
47
|
+
than a hand-authored timeline — interruptible and velocity-aware for free.
|
|
48
|
+
|
|
49
|
+
Generalises: when two axes of one element move together, giving them
|
|
50
|
+
different springs creates deformation, and deformation reads as mass. */
|
|
51
|
+
export const GROW = { type: "spring", stiffness: 620, damping: 19, mass: 0.85 } as const;
|
|
52
|
+
export const CROSS = { type: "spring", stiffness: 380, damping: 30 } as const;
|
|
53
|
+
|
|
54
|
+
/* ── Closing ─────────────────────────────────────────────────────────────
|
|
55
|
+
A tween, not a spring. easeIn, so it accelerates away rather than
|
|
56
|
+
decelerating in. 130ms, well under the state tier.
|
|
57
|
+
|
|
58
|
+
A bounce on the way out reads as the interface being reluctant, and it
|
|
59
|
+
stands between the user and whatever they're reaching for next.
|
|
60
|
+
Entrances can have character; exits should get out of the way. */
|
|
61
|
+
export const CLOSE = { duration: 0.13, ease: "easeIn" } as const;
|
|
62
|
+
|
|
63
|
+
/* ── Reduced motion ──────────────────────────────────────────────────────
|
|
64
|
+
Substitute, don't subtract. The preference exists because large-area or
|
|
65
|
+
accelerating motion is physically unpleasant for some users — it is not a
|
|
66
|
+
preference for a static interface. A user who sets it still needs to know
|
|
67
|
+
that a menu opened.
|
|
68
|
+
|
|
69
|
+
Flat, state-tier, no overshoot. The state change stays legible; the
|
|
70
|
+
physics goes away. */
|
|
71
|
+
export const FLAT = { duration: 0.15 } as const;
|
|
72
|
+
|
|
73
|
+
/** Pick the spring or its flat substitute. `reduce` from useReducedMotion(). */
|
|
74
|
+
export function motionOr<T>(reduce: boolean | null, animated: T) {
|
|
75
|
+
return reduce ? FLAT : animated;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* ── Duration ladder, for inline JS use ──────────────────────────────────
|
|
79
|
+
Mirrors motion-tokens.css. Seconds, because that's what Motion takes. */
|
|
80
|
+
export const DURATION = {
|
|
81
|
+
micro: 0.1,
|
|
82
|
+
state: 0.15,
|
|
83
|
+
popover: 0.2,
|
|
84
|
+
route: 0.26,
|
|
85
|
+
overlay: 0.3,
|
|
86
|
+
theme: 0.4,
|
|
87
|
+
} as const;
|
|
88
|
+
|
|
89
|
+
/* ── Off the ladder, deliberately ────────────────────────────────────────
|
|
90
|
+
The ladder is for STATE: something changed and the user has to follow it.
|
|
91
|
+
Two categories aren't state, and squeezing them onto the ladder makes
|
|
92
|
+
them worse rather than tighter.
|
|
93
|
+
|
|
94
|
+
THE SUBJECT OF THE MOMENT. When the thing animating IS the content, the
|
|
95
|
+
user is looking directly at it and the motion has to read in its own
|
|
96
|
+
right. Note the discipline in the pair below: same component, two
|
|
97
|
+
durations, decided by whether the motion is the subject or the report. */
|
|
98
|
+
export const SUBJECT = {
|
|
99
|
+
/** Stepping between images — a noise-thresholded dissolve, not a cut. */
|
|
100
|
+
dissolve: 0.62,
|
|
101
|
+
/** The same image moving between two layouts. On screen at both ends, so
|
|
102
|
+
it only has to be followable — back inside the overlay tier. */
|
|
103
|
+
flight: 0.42,
|
|
104
|
+
} as const;
|
|
105
|
+
|
|
106
|
+
/* A SET PIECE gets its own budget by never appearing again: once per tab,
|
|
107
|
+
never blocking a deep link, never played under reduced motion. See §14 of
|
|
108
|
+
glass/motion/guide.md for the gating. */
|
|
109
|
+
export const SET_PIECE = {
|
|
110
|
+
/** The first open of a disclosure, per line. */
|
|
111
|
+
theatreLine: 0.34,
|
|
112
|
+
/** Every subsequent open of the same thing. */
|
|
113
|
+
quietLine: 0.16,
|
|
114
|
+
/** Between staggered children on the first open only. */
|
|
115
|
+
stagger: 0.09,
|
|
116
|
+
} as const;
|
|
117
|
+
|
|
118
|
+
/* ── The curve for a track that also drives a filter ─────────────────────
|
|
119
|
+
NOT --ease-geist. The house curve overshoots past 1, and a blur animating
|
|
120
|
+
to 0px would have to pass through a NEGATIVE radius to settle back.
|
|
121
|
+
Browsers clamp rather than crash, so the symptom isn't an error — it's a
|
|
122
|
+
value stuck at zero for the tail while everything else is still moving,
|
|
123
|
+
which reads as the animation stalling right at the end.
|
|
124
|
+
|
|
125
|
+
Applies to any property with a hard floor: blur, brightness, a radius
|
|
126
|
+
going to 0, a scale you've promised won't invert. Expo-out has the same
|
|
127
|
+
"arrives decisively" character without ever leaving [0, 1]. */
|
|
128
|
+
export const EASE_FILTER = [0.16, 1, 0.3, 1] as const;
|
|
129
|
+
|
|
130
|
+
/* ── Blur crossfade ──────────────────────────────────────────────────────
|
|
131
|
+
Text swapping in place — a wordmark revealing its tagline on hover, say.
|
|
132
|
+
Note the asymmetry (280 in / 180 out): same principle as the springs.
|
|
133
|
+
|
|
134
|
+
Blur is the vestibular trigger; the fade isn't. So reduced motion swaps
|
|
135
|
+
only the blur amount to 0px and keeps the crossfade. Animating
|
|
136
|
+
blur(0px) → blur(0px) is a no-op the browser optimises away, so this needs
|
|
137
|
+
no second code path. */
|
|
138
|
+
export function blurCrossfade(reduce: boolean | null) {
|
|
139
|
+
const blur = reduce ? "blur(0px)" : "blur(4px)";
|
|
140
|
+
return {
|
|
141
|
+
initial: { opacity: 0, filter: blur },
|
|
142
|
+
animate: { opacity: 1, filter: "blur(0px)", transition: { duration: 0.28, ease: "easeOut" } },
|
|
143
|
+
exit: { opacity: 0, filter: blur, transition: { duration: 0.18, ease: "easeIn" } },
|
|
144
|
+
} as const;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* ── First-open theatre ──────────────────────────────────────────────────
|
|
148
|
+
Staggered lines behind a blur, on the FIRST open only; every subsequent
|
|
149
|
+
open is a quick fade. `theatre` comes from sessionStorage, so it is false
|
|
150
|
+
on the server for everyone and true on the client for most.
|
|
151
|
+
|
|
152
|
+
Which is why the shut state below is a CONSTANT. Branch a rendered style
|
|
153
|
+
on a client-only flag and React hands you two different first paints and
|
|
154
|
+
says so in the console. Only the transition reads the flags — a transition
|
|
155
|
+
isn't a style, so there's nothing for hydration to disagree about, and a
|
|
156
|
+
reader who asked for less motion gets a zero-length transition to the same
|
|
157
|
+
end state rather than a different starting position.
|
|
158
|
+
|
|
159
|
+
Record "seen" on animation COMPLETE, not on start: setting it earlier
|
|
160
|
+
lands in the same render batch as the open and cancels the very animation
|
|
161
|
+
it's recording. */
|
|
162
|
+
export function theatreLine(reduce: boolean | null, theatre: boolean) {
|
|
163
|
+
return {
|
|
164
|
+
shut: { opacity: 0, y: 6, filter: "blur(4px)" },
|
|
165
|
+
open: {
|
|
166
|
+
opacity: 1,
|
|
167
|
+
y: 0,
|
|
168
|
+
filter: "blur(0px)",
|
|
169
|
+
transition: {
|
|
170
|
+
duration: reduce ? 0 : theatre ? SET_PIECE.theatreLine : SET_PIECE.quietLine,
|
|
171
|
+
// A blur is on this track — see EASE_FILTER above.
|
|
172
|
+
ease: EASE_FILTER,
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
} as const;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The parent's stagger. Zero after the first open, so a re-open is instant. */
|
|
179
|
+
export function theatreList(theatre: boolean) {
|
|
180
|
+
return {
|
|
181
|
+
open: {
|
|
182
|
+
transition: {
|
|
183
|
+
staggerChildren: theatre ? SET_PIECE.stagger : 0,
|
|
184
|
+
delayChildren: theatre ? 0.06 : 0,
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
} as const;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/* ── Icon fill ───────────────────────────────────────────────────────────
|
|
191
|
+
Fill means CHOSEN — the page you're on, or the one you're pointing at.
|
|
192
|
+
Nothing is filled because it looks better.
|
|
193
|
+
|
|
194
|
+
The filled variant is not a second drawing: it's the same outline with its
|
|
195
|
+
interior painted in, layered OVER the stroke at the same coordinates. The
|
|
196
|
+
silhouette never moves, so the two crossfade in place — no pop, no reflow.
|
|
197
|
+
|
|
198
|
+
ease-out, not the house curve: the overshoot is right for travel and wrong
|
|
199
|
+
for a fade. Same split the springs make between position and opacity. */
|
|
200
|
+
export const fillFade = (on: boolean) =>
|
|
201
|
+
`transition-opacity duration-200 ease-out motion-reduce:transition-none ${
|
|
202
|
+
on ? "opacity-100" : "opacity-0"
|
|
203
|
+
}`;
|
|
204
|
+
|
|
205
|
+
/** The inverse — interior lines a solid shape swallows (a briefcase's
|
|
206
|
+
divider, a document's rules) have to leave as the fill arrives. */
|
|
207
|
+
export const detailFade = (on: boolean) =>
|
|
208
|
+
`transition-opacity duration-200 ease-out motion-reduce:transition-none ${
|
|
209
|
+
on ? "opacity-0" : "opacity-100"
|
|
210
|
+
}`;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/* ─────────────────────────────────────────────────────────────────────────
|
|
2
|
+
Interface motion — tokens and CSS-driven effects
|
|
3
|
+
Drop-in for Tailwind v4 (@theme) or plain CSS (the :root fallback below).
|
|
4
|
+
───────────────────────────────────────────────────────────────────────── */
|
|
5
|
+
|
|
6
|
+
@theme {
|
|
7
|
+
/* ── One curve for the whole system ─────────────────────────────────────
|
|
8
|
+
The 1.1 end control point overshoots ~10% and settles back — enough to
|
|
9
|
+
feel physical, not enough to read as bounce. A single shared curve is
|
|
10
|
+
why unrelated elements moving at once still feel like one mechanism.
|
|
11
|
+
|
|
12
|
+
Do NOT add a second bezier "because this needs a different feel." If an
|
|
13
|
+
element needs distinct character it needs a spring — that's the axis
|
|
14
|
+
where character belongs without fragmenting the system. */
|
|
15
|
+
--ease-geist: cubic-bezier(0.175, 0.885, 0.32, 1.1);
|
|
16
|
+
|
|
17
|
+
/* ── Duration ladder ────────────────────────────────────────────────────
|
|
18
|
+
Set by how much of the screen changes, not by how important the element
|
|
19
|
+
feels. Three numbers carry almost everything: 150 / 200 / 300. */
|
|
20
|
+
--duration-micro: 100ms; /* one property, no position — link opacity */
|
|
21
|
+
--duration-state: 150ms; /* colour, background, small transform, icon */
|
|
22
|
+
--duration-popover: 200ms; /* chevron, hover card, compound glyph */
|
|
23
|
+
--duration-route: 260ms; /* whole viewport, on the critical path */
|
|
24
|
+
--duration-overlay: 300ms; /* large region entering or collapsing */
|
|
25
|
+
--duration-theme: 400ms; /* literally everything, at once */
|
|
26
|
+
|
|
27
|
+
/* ── Off the ladder, deliberately ───────────────────────────────────────
|
|
28
|
+
The ladder is for STATE. Two things aren't state, and squeezing them
|
|
29
|
+
onto it makes them worse rather than tighter.
|
|
30
|
+
|
|
31
|
+
THE SUBJECT OF THE MOMENT — the thing animating IS the content, so the
|
|
32
|
+
user is looking directly at it and the motion has to read in its own
|
|
33
|
+
right. Note the pair: the dissolve is the subject, the flight is a
|
|
34
|
+
report about it, so the flight stays inside the overlay tier. */
|
|
35
|
+
--duration-dissolve: 620ms; /* stepping between images */
|
|
36
|
+
--duration-flight: 420ms; /* one image moving between two layouts */
|
|
37
|
+
|
|
38
|
+
/* A SET PIECE earns its budget by never appearing again — once per tab,
|
|
39
|
+
never blocking a deep link, never played under reduced motion. */
|
|
40
|
+
--duration-setpiece-in: 560ms;
|
|
41
|
+
--duration-setpiece-out: 320ms;
|
|
42
|
+
|
|
43
|
+
/* ── The curve for a track that also drives a filter ────────────────────
|
|
44
|
+
NOT --ease-geist. The house curve overshoots past 1, and a blur
|
|
45
|
+
animating to 0px would have to pass through a NEGATIVE radius to settle
|
|
46
|
+
back. Browsers clamp rather than crash, so the symptom is a value stuck
|
|
47
|
+
at zero for the tail of the transition — which reads as the animation
|
|
48
|
+
stalling right at the end, and is very hard to attribute.
|
|
49
|
+
|
|
50
|
+
Applies to any property with a hard floor: blur, brightness, a radius
|
|
51
|
+
going to 0, a scale you've promised won't invert. Expo-out has the same
|
|
52
|
+
"arrives decisively" character without ever leaving [0, 1]. */
|
|
53
|
+
--ease-filter: cubic-bezier(0.16, 1, 0.3, 1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* Plain-CSS fallback if you're not on Tailwind v4. */
|
|
57
|
+
:root {
|
|
58
|
+
--ease-geist: cubic-bezier(0.175, 0.885, 0.32, 1.1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@layer components {
|
|
62
|
+
/* ── Reveal on scroll ────────────────────────────────────────────────────
|
|
63
|
+
CSS animates; JS only sets `data-in` (see useReveal.ts). 8px of travel —
|
|
64
|
+
enough to register, small enough that a page of revealing paragraphs
|
|
65
|
+
doesn't feel like it's sliding around. */
|
|
66
|
+
.reveal {
|
|
67
|
+
opacity: 0;
|
|
68
|
+
transform: translateY(8px);
|
|
69
|
+
transition: opacity var(--duration-overlay) var(--ease-geist),
|
|
70
|
+
transform var(--duration-overlay) var(--ease-geist);
|
|
71
|
+
}
|
|
72
|
+
/* An attribute, not a class — see useReveal.ts for why a class can't survive. */
|
|
73
|
+
.reveal[data-in] {
|
|
74
|
+
opacity: 1;
|
|
75
|
+
transform: translateY(0);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* Off entirely — the animation IS the effect, so there's nothing to
|
|
79
|
+
degrade to. Note it resolves to opacity: 1; the danger with any
|
|
80
|
+
reveal-on-scroll is leaving content stuck at opacity: 0. */
|
|
81
|
+
@media (prefers-reduced-motion: reduce) {
|
|
82
|
+
.reveal {
|
|
83
|
+
opacity: 1;
|
|
84
|
+
transform: none;
|
|
85
|
+
transition: none;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* Print is a real presentation, and scroll-triggered content prints blank
|
|
90
|
+
without this. The most-forgotten line in the file. */
|
|
91
|
+
@media print {
|
|
92
|
+
.reveal {
|
|
93
|
+
opacity: 1;
|
|
94
|
+
transform: none;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* THE THIRD GUARD IS NOT CSS. A feature check can't catch "JavaScript
|
|
99
|
+
never ran at all": the rules above ship, `.reveal` applies, and nothing
|
|
100
|
+
arrives to add `.in`. The reader gets a correct document they cannot
|
|
101
|
+
see — a blank page rather than a plain one. Put this in the head:
|
|
102
|
+
|
|
103
|
+
<noscript>
|
|
104
|
+
<style>.reveal { opacity: 1 !important; transform: none !important; }</style>
|
|
105
|
+
</noscript>
|
|
106
|
+
|
|
107
|
+
The general rule: any CSS that hides content pending JavaScript needs a
|
|
108
|
+
<noscript> reset. */
|
|
109
|
+
|
|
110
|
+
/* ── Route transition ───────────────────────────────────────────────────
|
|
111
|
+
Opacity only — no slide, no scale. Directional motion implies a spatial
|
|
112
|
+
relationship between routes; in a flat site map there isn't one.
|
|
113
|
+
|
|
114
|
+
260ms rather than 300: a page transition sits directly between "I
|
|
115
|
+
clicked" and "I can read," and that latency reads as the site being
|
|
116
|
+
slow. 260 is the shortest crossfade that isn't mistaken for a flicker.
|
|
117
|
+
|
|
118
|
+
`both` holds the from-state before the animation starts, preventing a
|
|
119
|
+
one-frame flash of opaque content. Key the wrapper on the pathname to
|
|
120
|
+
replay it on each navigation. */
|
|
121
|
+
.route-fade {
|
|
122
|
+
animation: routeFade var(--duration-route) var(--ease-geist) both;
|
|
123
|
+
}
|
|
124
|
+
@keyframes routeFade {
|
|
125
|
+
from { opacity: 0; }
|
|
126
|
+
to { opacity: 1; }
|
|
127
|
+
}
|
|
128
|
+
@media (prefers-reduced-motion: reduce) {
|
|
129
|
+
.route-fade { animation: none; }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* ── Theme crossfade ────────────────────────────────────────────────────
|
|
133
|
+
Added by the theme setter for ~440ms on an explicit toggle only, then
|
|
134
|
+
removed. Left in permanently it would make EVERY hover state on the site
|
|
135
|
+
400ms and sluggish — the !important is what lets it beat each
|
|
136
|
+
component's own duration-150 for those 440ms, and the removal is what
|
|
137
|
+
stops it poisoning everything else.
|
|
138
|
+
|
|
139
|
+
400ms because this is the largest possible change: every surface, text
|
|
140
|
+
colour, border, icon fill and shadow simultaneously.
|
|
141
|
+
|
|
142
|
+
Plain `ease`, not the house curve — overshoot on a position is momentum;
|
|
143
|
+
overshoot on a colour interpolation means the background travels past
|
|
144
|
+
its target and comes back, which reads as a wobble in the light.
|
|
145
|
+
|
|
146
|
+
The whole rule sits inside no-preference, so it's opt-in. Reduced-motion
|
|
147
|
+
users still get the theme change, just instantly. */
|
|
148
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
149
|
+
:root.theme-transition,
|
|
150
|
+
:root.theme-transition *,
|
|
151
|
+
:root.theme-transition *::before,
|
|
152
|
+
:root.theme-transition *::after {
|
|
153
|
+
transition: background-color var(--duration-theme) ease,
|
|
154
|
+
color var(--duration-theme) ease,
|
|
155
|
+
border-color var(--duration-theme) ease,
|
|
156
|
+
fill var(--duration-theme) ease,
|
|
157
|
+
stroke var(--duration-theme) ease,
|
|
158
|
+
box-shadow var(--duration-theme) ease !important;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/* ── Skip link ──────────────────────────────────────────────────────────
|
|
163
|
+
Parked off-screen with a transform (not display:none, which would take
|
|
164
|
+
it out of the tab order) and slid in on focus at the state tier. */
|
|
165
|
+
.skip {
|
|
166
|
+
position: absolute;
|
|
167
|
+
left: 16px;
|
|
168
|
+
top: 16px;
|
|
169
|
+
z-index: 100;
|
|
170
|
+
transform: translateY(-200%);
|
|
171
|
+
transition: transform var(--duration-state) var(--ease-geist);
|
|
172
|
+
}
|
|
173
|
+
.skip:focus {
|
|
174
|
+
transform: translateY(0);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Marks any `.reveal` element as arrived, with `data-in`, when it scrolls into
|
|
5
|
+
* view. The CSS does all the animating (see motion-tokens.css); this only sets
|
|
6
|
+
* an attribute.
|
|
7
|
+
*
|
|
8
|
+
* An attribute rather than a class, and that is not a style choice. `class` is a
|
|
9
|
+
* prop React owns: the moment any `.reveal` element gets a className that depends
|
|
10
|
+
* on state, React rewrites the whole attribute from its own value and a class
|
|
11
|
+
* added out here is gone. The element then has `.reveal` with nothing to say it
|
|
12
|
+
* arrived, so it fades to `opacity: 0` — and the observer has already unobserved
|
|
13
|
+
* it, so it never comes back. React doesn't render `data-in`, so it leaves it be.
|
|
14
|
+
*
|
|
15
|
+
* Pass a value that changes on navigation (e.g. the pathname) so the observer
|
|
16
|
+
* re-attaches to the new page's `.reveal` elements after a route change —
|
|
17
|
+
* without it, a client-side navigation leaves the incoming page's content
|
|
18
|
+
* permanently at opacity: 0.
|
|
19
|
+
*
|
|
20
|
+
* THE THIRD GUARD IS NOT IN THIS FILE, and it's the one people miss. A feature
|
|
21
|
+
* check can't catch "JavaScript never ran at all": the CSS ships, `.reveal`
|
|
22
|
+
* applies, and nothing arrives to set `data-in`. The reader gets a correct document
|
|
23
|
+
* they cannot see. Put this in the document head, beside where you load the app:
|
|
24
|
+
*
|
|
25
|
+
* <noscript>
|
|
26
|
+
* <style>.reveal { opacity: 1 !important; transform: none !important; }</style>
|
|
27
|
+
* </noscript>
|
|
28
|
+
*
|
|
29
|
+
* The general rule: any CSS that hides content pending JavaScript needs a
|
|
30
|
+
* <noscript> reset. This is a correctness bug, not a nicety — the failure mode
|
|
31
|
+
* is a blank page rather than a plain one.
|
|
32
|
+
*/
|
|
33
|
+
export function useReveal(dep?: unknown) {
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
// No IntersectionObserver → show everything immediately. Never leave
|
|
36
|
+
// content invisible behind a feature check.
|
|
37
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
38
|
+
document.querySelectorAll(".reveal").forEach((el) => el.setAttribute("data-in", ""));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const io = new IntersectionObserver(
|
|
43
|
+
(entries) => {
|
|
44
|
+
for (const entry of entries) {
|
|
45
|
+
if (entry.isIntersecting) {
|
|
46
|
+
entry.target.setAttribute("data-in", "");
|
|
47
|
+
// A reveal is one-shot. Re-animating on scroll-back is nauseating,
|
|
48
|
+
// and unobserving keeps the observer's element list shrinking.
|
|
49
|
+
io.unobserve(entry.target);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
// No fractional threshold, deliberately. An element's intersection ratio
|
|
54
|
+
// can never exceed the viewport's height divided by its own, so anything
|
|
55
|
+
// more than ten screens tall can't reach 0.1 however far you scroll, and
|
|
56
|
+
// it sits at opacity 0 for the whole page. The bottom margin does the job
|
|
57
|
+
// the threshold was doing: reveal once the element is 40px inside the
|
|
58
|
+
// viewport, at any height.
|
|
59
|
+
{ threshold: 0, rootMargin: "0px 0px -40px 0px" }
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
document.querySelectorAll(".reveal").forEach((el) => io.observe(el));
|
|
63
|
+
return () => io.disconnect();
|
|
64
|
+
}, [dep]);
|
|
65
|
+
}
|