trykeyline 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/LICENSE +0 -16
  3. package/bin/keyline.mjs +2 -1
  4. package/package.json +1 -1
  5. package/plugins/keyline/.claude-plugin/plugin.json +1 -1
  6. package/plugins/keyline/plugin.json +22 -0
  7. package/plugins/keyline/skills/keyline/SKILL.md +22 -32
  8. package/plugins/keyline/skills/keyline/commands/audit.md +8 -3
  9. package/plugins/keyline/skills/keyline/commands/critique.md +3 -26
  10. package/plugins/keyline/skills/keyline/commands/init.md +5 -4
  11. package/plugins/keyline/skills/keyline/commands/polish.md +2 -2
  12. package/plugins/keyline/skills/keyline/commands/refine.md +6 -16
  13. package/plugins/keyline/skills/keyline/commands/sync.md +1 -3
  14. package/plugins/keyline/skills/keyline/commands/ux-laws.md +26 -0
  15. package/plugins/keyline/skills/keyline/domains/color/deriving-a-ramp.md +1 -1
  16. package/plugins/keyline/skills/keyline/domains/color/guide.md +10 -46
  17. package/plugins/keyline/skills/keyline/domains/color/status-and-exceptions.md +2 -2
  18. package/plugins/keyline/skills/keyline/domains/depth/elevation-recipes.md +2 -8
  19. package/plugins/keyline/skills/keyline/domains/depth/guide.md +12 -41
  20. package/plugins/keyline/skills/keyline/domains/layout/guide.md +31 -56
  21. package/plugins/keyline/skills/keyline/domains/layout/measure-and-rhythm.md +16 -16
  22. package/plugins/keyline/skills/keyline/domains/layout/radius-lines-and-layers.md +2 -2
  23. package/plugins/keyline/skills/keyline/domains/motion/guide.md +12 -47
  24. package/plugins/keyline/skills/keyline/domains/motion/patterns.md +1 -1
  25. package/plugins/keyline/skills/keyline/domains/motion/performance.md +1 -1
  26. package/plugins/keyline/skills/keyline/domains/patterns/guide.md +20 -62
  27. package/plugins/keyline/skills/keyline/domains/patterns/iteration.md +1 -9
  28. package/plugins/keyline/skills/keyline/domains/type/guide.md +13 -37
  29. package/plugins/keyline/skills/keyline/domains/type/ladders.md +1 -1
  30. package/plugins/keyline/skills/keyline/domains/type/type-audit.md +5 -8
  31. package/plugins/keyline/skills/keyline/glass/color/assets/color-tokens.css +34 -165
  32. package/plugins/keyline/skills/keyline/glass/color/assets/theme.ts +10 -70
  33. package/plugins/keyline/skills/keyline/glass/color/guide.md +3 -30
  34. package/plugins/keyline/skills/keyline/glass/color/intent-ramp.md +2 -34
  35. package/plugins/keyline/skills/keyline/glass/color/theming.md +5 -72
  36. package/plugins/keyline/skills/keyline/glass/depth/assets/GlassPanel.tsx +14 -121
  37. package/plugins/keyline/skills/keyline/glass/depth/assets/glass.css +29 -188
  38. package/plugins/keyline/skills/keyline/glass/depth/depth-cues.md +1 -16
  39. package/plugins/keyline/skills/keyline/glass/depth/guide.md +4 -73
  40. package/plugins/keyline/skills/keyline/glass/depth/performance-and-fallbacks.md +3 -11
  41. package/plugins/keyline/skills/keyline/glass/layout/adaptive-presentations.md +5 -91
  42. package/plugins/keyline/skills/keyline/glass/layout/assets/SectionNav.tsx +3 -6
  43. package/plugins/keyline/skills/keyline/glass/layout/assets/layout-tokens.css +11 -138
  44. package/plugins/keyline/skills/keyline/glass/layout/guide.md +10 -15
  45. package/plugins/keyline/skills/keyline/glass/layout/measure-and-rhythm.md +4 -6
  46. package/plugins/keyline/skills/keyline/glass/layout/radius-and-elevation.md +3 -2
  47. package/plugins/keyline/skills/keyline/glass/motion/assets/DropMenu.tsx +4 -6
  48. package/plugins/keyline/skills/keyline/glass/motion/assets/motion-presets.ts +10 -184
  49. package/plugins/keyline/skills/keyline/glass/motion/assets/motion-tokens.css +21 -105
  50. package/plugins/keyline/skills/keyline/glass/motion/easing-and-duration.md +2 -31
  51. package/plugins/keyline/skills/keyline/glass/motion/guide.md +10 -41
  52. package/plugins/keyline/skills/keyline/glass/motion/reduced-motion.md +1 -7
  53. package/plugins/keyline/skills/keyline/glass/motion/scroll-performance.md +3 -32
  54. package/plugins/keyline/skills/keyline/glass/motion/shared-element.md +1 -1
  55. package/plugins/keyline/skills/keyline/glass/motion/sound.md +10 -80
  56. package/plugins/keyline/skills/keyline/glass/type/assets/prose.tsx +8 -43
  57. package/plugins/keyline/skills/keyline/glass/type/assets/type-tokens.css +49 -86
  58. package/plugins/keyline/skills/keyline/glass/type/guide.md +5 -7
  59. package/plugins/keyline/skills/keyline/glass/type/long-form-prose.md +9 -74
  60. package/plugins/keyline/skills/keyline/glass/type/scale.md +2 -6
  61. package/plugins/keyline/skills/keyline/glass/type/weight-and-tracking.md +4 -20
  62. package/plugins/keyline/skills/keyline/scripts/check/css.mjs +5 -14
  63. package/plugins/keyline/skills/keyline/scripts/check/design.mjs +3 -1
  64. package/plugins/keyline/skills/keyline/scripts/check/files.mjs +70 -26
  65. package/plugins/keyline/skills/keyline/scripts/check/markup.mjs +3 -3
  66. package/plugins/keyline/skills/keyline/scripts/check/rules.mjs +9 -1
  67. package/plugins/keyline/skills/keyline/scripts/check.mjs +6 -3
  68. package/plugins/keyline/skills/keyline/scripts/contrast.mjs +2 -0
  69. package/plugins/keyline/skills/keyline/scripts/scan.mjs +29 -45
  70. package/plugins/keyline/skills/keyline/system/design-md.md +4 -4
  71. package/plugins/keyline/skills/keyline/system/guide.md +4 -8
  72. package/plugins/keyline/skills/keyline/system/presets.md +16 -16
  73. package/plugins/keyline/skills/keyline/system/roles.md +8 -8
@@ -5,39 +5,15 @@
5
5
 
6
6
  @import "tailwindcss";
7
7
 
8
- /* Drive the `dark:` variant off data-theme (set by theme.ts), not the OS —
9
- a theme toggle has to be able to override the system, and once a user has
10
- expressed a preference the OS shouldn't win.
11
-
12
- :where() keeps specificity at zero, so a dark: utility doesn't outrank an
13
- unprefixed one purely by virtue of the attribute selector. */
8
+ /* `dark:` keys off data-theme (set by theme.ts), not the OS; :where() keeps
9
+ specificity at zero. */
14
10
  @custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
15
11
 
16
12
  @theme {
17
- /* ── The solid ramp ─────────────────────────────────────────────────────
18
- Each step encodes an INTENT, not a lightness:
19
-
20
- 100 default surface for a raised element
21
- 200 that surface, hovered · inert placeholder fill
22
- 300 that surface, active/pressed
23
- 400 BORDER
24
- 500 border hovered · decorative marks (bullets, dots, rail markers)
25
- 600 border active
26
- 700 solid fill
27
- 800 solid fill hovered
28
- 900 SECONDARY TEXT
29
- 1000 PRIMARY TEXT · inverted-element fill
30
-
31
- NOTE 500-800 ARE NOT TEXT COLOURS. A border needs 3:1 against its
32
- surface; text needs 4.5:1. On a light theme these mid steps are light
33
- greys on white — 500 is 1.66:1, 600 is 2.38:1, 800 is 4.12:1 — so all
34
- of them fail as text. Only 900 and 1000 are readable. Dark mode hides
35
- this completely, because the same indices sit far lighter against a
36
- near-black page. Text tiers below.
37
-
38
- Note 400 is LIGHTER than 300 in this theme. Not a mistake — a border
39
- sits beside content and must stay quieter than a pressed surface, which
40
- sits under it. Intent beats monotonicity. */
13
+ /* ── The solid ramp, indexed by intent: 100–300 surface states, 400–600
14
+ border states, 700–800 solid fill, 900 secondary text, 1000 primary.
15
+ 500–800 are NOT text colours, and 400 is lighter than 300 on purpose:
16
+ glass/color/intent-ramp.md. */
41
17
  --color-gray-100: #f2f2f2;
42
18
  --color-gray-200: #ebebeb;
43
19
  --color-gray-300: #e6e6e6;
@@ -49,11 +25,7 @@
49
25
  --color-gray-900: #4d4d4d;
50
26
  --color-gray-1000: #171717;
51
27
 
52
- /* ── The translucent ramp ───────────────────────────────────────────────
53
- Same indices, alpha instead of solid. Use these for ANYTHING LAYERED:
54
- borders on glass, dividers, hairlines, overlays. A solid border on a
55
- translucent panel doesn't participate in what's behind it and reads as
56
- a pasted-on frame. */
28
+ /* ── The translucent ramp: anything layered (glass/color/guide.md §2). */
57
29
  --color-gray-alpha-100: #0000000d;
58
30
  --color-gray-alpha-200: #00000015;
59
31
  --color-gray-alpha-300: #0000001a;
@@ -61,41 +33,19 @@
61
33
  --color-gray-alpha-500: #00000036;
62
34
  --color-gray-alpha-600: #0000003d;
63
35
 
64
- /* ── Surfaces ───────────────────────────────────────────────────────────
65
- Held out of the ramp: a page background is categorically different from
66
- a component surface. background-100 is also the TEXT colour for anything
67
- inverted (a tooltip), which is why it's a token and not a literal white. */
36
+ /* ── Surfaces, held out of the ramp. background-100 is also the text of
37
+ anything inverted. */
68
38
  --color-background-100: #ffffff;
69
39
  --color-background-200: #fafafa;
70
40
 
71
- /* ── Text tiers ─────────────────────────────────────────────────────────
72
- Held apart from the ramp, because a border and a piece of text have
73
- different contrast requirements (3:1 vs 4.5:1) and one step can't serve
74
- both. Ratios against background-100:
75
-
76
- gray-1000 primary 17.93:1 body copy, headings, active state
77
- gray-900 secondary 8.45:1 emphasised, ancestor rows
78
- muted 6.48:1 deks, supporting copy, <em>
79
- quiet 5.49:1 resting icons, state labels, <cite>
80
- faint 4.61:1 dates, read times, meta
81
-
82
- Worst real pairing is muted on gray-100 (a subtle surface) at 5.79:1.
83
-
84
- The ladder is STRICTLY DESCENDING on purpose. Once every tier has to
85
- clear 4.5:1 they crowd together and the hierarchy collapses — check the
86
- ordering, not just the thresholds, or you pass WCAG and lose the design.
87
-
88
- Icons ride `quiet` rather than getting their own token: they only need
89
- 3:1, but they usually sit beside a label and two tokens that must stay
90
- visually matched is more coupling than it saves. */
41
+ /* ── Text tiers, apart from the ramp (glass/color/guide.md §4). Against
42
+ background-100: gray-1000 17.93 · gray-900 8.45 · muted 6.48 · quiet
43
+ 5.49 · faint 4.61. Strictly descending; icons ride `quiet`. */
91
44
  --color-muted: #5e5e5e;
92
45
  --color-quiet: #696969;
93
46
  --color-faint: #757575;
94
47
 
95
- /* ── The one accent ─────────────────────────────────────────────────────
96
- Focus ring only. Links stay monochrome — in a monochrome interface
97
- colour IS attention, so spend it on the one thing that must be
98
- unmissable: where the keyboard is. */
48
+ /* ── The one accent: the focus ring only. */
99
49
  --color-blue-700: #006bff;
100
50
 
101
51
  /* Elevated surface for sliding highlights and active pills — white here,
@@ -103,30 +53,18 @@
103
53
  --color-highlight: #ffffff;
104
54
  }
105
55
 
106
- /* ── Runtime surfaces ─────────────────────────────────────────────────────
107
- These are plain custom properties, NOT @theme tokens, because they must be
108
- re-declarable per theme AND replaceable by the reduced-transparency
109
- fallback at runtime — and because one of them is a gradient, which no
110
- colour token system accepts.
111
-
112
- Rule: a value that changes by CONTEXT belongs in :root; a value that's
113
- part of the vocabulary belongs in @theme. */
56
+ /* ── Runtime surfaces: plain custom properties, not @theme tokens, because
57
+ they re-declare per theme and under reduced transparency, and one is a
58
+ gradient (glass/color/guide.md §6). */
114
59
  :root {
115
60
  color-scheme: light;
116
61
  --glass-nav-bg: rgba(242, 242, 242, 0.72);
117
62
  --glass-panel-bg: rgba(243, 243, 243, 0.97);
118
63
  --folder-glass-bg: linear-gradient(to bottom, rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0.34));
119
64
 
120
- /* ── The one chromatic exception ────────────────────────────────────────
121
- Beyond the focus ring, a monochrome system can afford at most one place
122
- colour goes — and the justification has to be structural. Here it's a
123
- collapsed summary panel: it's an OFFER rather than content ("do you want
124
- the three-line version?"), which makes it the one element allowed to
125
- announce itself, and means the exception can't spread, because nothing
126
- else on the site is an offer.
127
-
128
- Delete this block if you don't have such an element. An exception with a
129
- weak justification becomes a palette within two components. */
65
+ /* ── The one chromatic exception: the collapsed summary panel, an offer
66
+ rather than content (glass/color/guide.md §3). Delete this block if you
67
+ don't have such an element. */
130
68
  --sv-edge-top: #16276b; /* deep navy at the top of the 1px stroke */
131
69
  --sv-edge-mid: #2f5ac4;
132
70
  --sv-edge-bottom: #6d97ea; /* running to a lighter blue at the bottom */
@@ -135,9 +73,7 @@
135
73
  --sv-accent: #2f5ac4; /* the MARK, and only the mark */
136
74
  --sv-sheen-hi: #8fb2f2;
137
75
 
138
- /* Four shadows, not one. A single centred blur reads as a drop shadow
139
- that happens to be blue; weighting it to the two SIDES is what makes it
140
- read as light coming off the panel. See the Glass depth guide (`glass/depth/guide.md`). */
76
+ /* Weighted to the sides so it reads as light (glass/depth/guide.md §6). */
141
77
  --sv-glow:
142
78
  0 1px 2px rgba(22, 39, 107, 0.05),
143
79
  -10px 0 20px -18px rgba(47, 90, 196, 0.38),
@@ -150,15 +86,8 @@
150
86
  0 14px 34px -16px rgba(47, 90, 196, 0.6);
151
87
  }
152
88
 
153
- /* ── Dark theme ───────────────────────────────────────────────────────────
154
- One block re-tints the same token names, so most of the site adapts with
155
- no per-component dark: classes at all. Semantics hold: low steps are
156
- surfaces, high steps are text, "border" still means 400.
157
-
158
- Not a mechanical inversion. The bottom is compressed (100→400 spans only
159
- twenty units) because the eye separates near-black surfaces easily, while
160
- the middle jumps forty units at once (400→500) to pull "border" clear of
161
- "text". 700 is the one step identical in both themes. */
89
+ /* ── Dark theme: the same names re-tinted, not inverted
90
+ (glass/color/intent-ramp.md, dark-mode re-tinting). */
162
91
  :root[data-theme="dark"] {
163
92
  color-scheme: dark;
164
93
 
@@ -173,10 +102,7 @@
173
102
  --color-gray-900: #c4c4c4;
174
103
  --color-gray-1000: #ededed;
175
104
 
176
- /* The crux of the whole system: the alpha ramp inverts to WHITE-based.
177
- A dark-mode border is lighter than its surface, so a black overlay would
178
- make it disappear. "A little more contrast than the surface" means black
179
- in light mode and white in dark. */
105
+ /* The alpha ramp inverts to white-based. */
180
106
  --color-gray-alpha-100: #ffffff0d;
181
107
  --color-gray-alpha-200: #ffffff15;
182
108
  --color-gray-alpha-300: #ffffff1f;
@@ -184,43 +110,27 @@
184
110
  --color-gray-alpha-500: #ffffff36;
185
111
  --color-gray-alpha-600: #ffffff45;
186
112
 
187
- /* Near-black, not #000: pure black gives shadows nothing to sit on and
188
- produces harsh contrast with light text. */
113
+ /* Near-black, not #000. */
189
114
  --color-background-100: #0b0b0b;
190
115
  --color-background-200: #161616;
191
116
 
192
- /* Text tiers, re-picked for the dark surface. Note how little `muted` had
193
- to change — it keeps a value that already passed at 7.53:1. The contrast
194
- failure this ladder exists to fix was overwhelmingly a LIGHT-theme
195
- failure; only `faint` was genuinely broken in dark too (3.97:1).
196
- Ratios against background-100: muted 7.53 · quiet 5.48 · faint 4.59. */
117
+ /* Re-picked for the dark surface: muted 7.53 · quiet 5.48 · faint 4.59. */
197
118
  --color-muted: #a0a0a0;
198
119
  --color-quiet: #878787;
199
120
  --color-faint: #7a7a7a;
200
121
 
201
- /* Re-picked, not reused. The same blue that clears 4.5:1 on white has far
202
- less on near-black — any accent needs a per-theme value. */
122
+ /* Every accent takes a per-theme value. */
203
123
  --color-blue-700: #3b82f6;
204
124
 
205
125
  --color-highlight: #2c2c2c;
206
126
 
207
127
  --glass-nav-bg: rgba(26, 26, 26, 0.72);
208
128
  --glass-panel-bg: rgba(22, 22, 22, 0.97);
209
- /* Grey-based, not white — a white gradient over a dark page reads as fog
210
- rather than glass. */
129
+ /* Grey-based: a white gradient over a dark page reads as fog. */
211
130
  --folder-glass-bg: linear-gradient(to bottom, rgba(48, 48, 48, 0.5), rgba(30, 30, 30, 0.34));
212
131
 
213
- /* The same edge, LIFTED — every stop moved by roughly the same amount.
214
- A #16276b top stop is within a few points of a #0b0b0b page, so the
215
- stroke would simply go missing down its darker half. The instinct is to
216
- lighten only the dark end, and that's the mistake: it pinches the ramp
217
- and it stops reading as one gradient. A gradient is a relationship
218
- between its stops, so re-picking it for a theme means re-picking all of
219
- them — exactly as the accent rule says.
220
-
221
- The fill is TINTED, not darkened. It sits at gray-100's weight so the
222
- panel doesn't punch a hole in the page: "raised" means "more contrast
223
- against the page", and the direction flips between themes. */
132
+ /* Every stop lifted by about the same amount, so it stays one gradient;
133
+ the fill is tinted to gray-100's weight, not darkened. */
224
134
  --sv-edge-top: #24407f;
225
135
  --sv-edge-mid: #3f6ed0;
226
136
  --sv-edge-bottom: #86adf7;
@@ -240,9 +150,8 @@
240
150
  0 14px 36px -16px rgba(90, 140, 240, 0.7);
241
151
  }
242
152
 
243
- /* Reduced transparency → solid surfaces in both themes. MUST come after both
244
- theme blocks: it re-declares the same properties at the same specificity,
245
- so source order decides. */
153
+ /* Reduced transparency: the tint half (glass.css drops the filter). MUST
154
+ come after both theme blocks — same specificity, so source order decides. */
246
155
  @media (prefers-reduced-transparency: reduce) {
247
156
  :root,
248
157
  :root[data-theme="dark"] {
@@ -264,10 +173,7 @@
264
173
  color: var(--color-background-100);
265
174
  }
266
175
 
267
- /* :focus-visible, never :focus — a mouse click shouldn't leave a ring.
268
- outline-offset puts a gap of page colour between element and ring, so
269
- the ring reads as a distinct object rather than a border. Links get more
270
- because inline text has ragged edges the ring shouldn't crowd. */
176
+ /* :focus-visible, never :focus; the offset leaves a gap of page colour. */
271
177
  :focus-visible {
272
178
  outline: 2px solid var(--color-blue-700);
273
179
  outline-offset: 2px;
@@ -278,42 +184,5 @@
278
184
  }
279
185
  }
280
186
 
281
- /* ─────────────────────────────────────────────────────────────────────────
282
- The .glass-* MATERIAL CLASSES that consume the tints above live in
283
- glass/depth/assets/glass.css — the blur and saturation values, the
284
- @supports guard with an opaque baseline, and the other half of the
285
- reduced-transparency fallback (dropping backdrop-filter, which this file's
286
- tint swap does not do on its own).
287
-
288
- Both files are needed for working glass. They're split because the tints
289
- are colour tokens that re-tint per theme, while the materials are surface
290
- craft — different guides own them, and neither should duplicate the other.
291
- ───────────────────────────────────────────────────────────────────────── */
292
-
293
- /* ─────────────────────────────────────────────────────────────────────────
294
- Elevation, for reference — apply in components, not here. A shadow that
295
- changes STRUCTURE between themes can't be a single token, so it needs a
296
- dark: variant. Extract it once to a shared constant and import it.
297
-
298
- Four layers, one light source:
299
- inset 1px white top-edge catch light — does more for "floating" than
300
- any of the shadows
301
- 1px/1px @ 2% contact shadow: resting on something, not pasted over
302
- 8/16 @ 4% near ambient
303
- 24/32 @ 6% far ambient — two layers, because one large shadow
304
- reads as fog while two fall off like real penumbra
305
-
306
- Negative spread (-4px, -8px) contracts before blurring, keeping the shadow
307
- tucked under the element instead of haloing past its edges. This is the
308
- detail most hand-written shadows miss.
309
-
310
- Opacities are 2–6%. Elevation should be felt, not seen.
311
-
312
- const SHADOW =
313
- "shadow-[inset_0_1px_0_rgba(255,255,255,0.6),0_1px_1px_rgba(0,0,0,0.02),0_8px_16px_-4px_rgba(0,0,0,0.04),0_24px_32px_-8px_rgba(0,0,0,0.06)] " +
314
- "dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.10),0_1px_1px_rgba(0,0,0,0.3),0_8px_16px_-4px_rgba(0,0,0,0.5),0_24px_32px_-8px_rgba(0,0,0,0.6)]";
315
-
316
- Dark inverts BOTH ways: shadows ~10x stronger (4% black on #0b0b0b is
317
- mathematically invisible), inset highlight ~6x weaker (0.6 white on a dark
318
- surface reads as a glowing edge, not a catch light).
319
- ───────────────────────────────────────────────────────────────────────── */
187
+ /* The .glass-* material classes and .dock-shadow live in
188
+ glass/depth/assets/glass.css; both files are needed for working glass. */
@@ -1,33 +1,17 @@
1
1
  /**
2
2
  * Theme persistence. Two explicit modes, Light or Dark; Light is the default.
3
3
  * The choice sticks in localStorage and is reflected on <html> via data-theme,
4
- * which the dark token overrides key off (see color-tokens.css).
5
- *
6
- * Three separate functions because they have different callers:
7
- * getThemeChoice for initial React state, applyTheme for the DOM write,
8
- * setThemeChoice for the user action. Keeping them apart is what lets the
9
- * toggle animate while the initial paint doesn't.
10
- *
11
- * ─────────────────────────────────────────────────────────────────────────
12
- * PAIR THIS WITH THE HEAD SCRIPT AT THE BOTTOM OF THIS FILE. Without it,
13
- * every load flashes white before React mounts — the single most noticeable
14
- * bug a themed site can have.
15
- * ─────────────────────────────────────────────────────────────────────────
4
+ * which the dark token overrides key off (color-tokens.css). Three functions
5
+ * for three callers: initial React state, the DOM write, the user action.
6
+ * Pair it with the head script at the bottom of this file.
16
7
  */
17
8
 
18
9
  export type ThemeChoice = "light" | "dark";
19
10
 
20
11
  const KEY = "theme";
21
12
 
22
- /**
23
- * The comparison is `=== "dark"`, not a parse: anything unrecognised — a
24
- * stale value, a corrupted entry, a value from an older version of the site —
25
- * falls through to light.
26
- *
27
- * Every localStorage access is wrapped. It throws in Safari private browsing
28
- * and under some cookie policies. A preference that doesn't persist is a
29
- * minor annoyance; an uncaught throw is a broken page.
30
- */
13
+ /** `=== "dark"`, not a parse: anything unrecognised falls through to light.
14
+ Every localStorage access is wrapped; it throws in some private modes. */
31
15
  export function getThemeChoice(): ThemeChoice {
32
16
  try {
33
17
  if (localStorage.getItem(KEY) === "dark") return "dark";
@@ -48,12 +32,8 @@ export function setThemeChoice(choice: ThemeChoice): void {
48
32
  /* preference just won't persist */
49
33
  }
50
34
 
51
- /* The crossfade class is added for one interaction and then removed.
52
- Left in the stylesheet permanently it would apply to every hover state
53
- on the site — every link, every icon, every row becomes a 400ms colour
54
- fade and the whole interface goes sluggish.
55
-
56
- 440ms = the 400ms transition plus a 40ms margin so nothing is cut off. */
35
+ /* Crossfade on explicit toggles only: the class (motion-tokens.css) is
36
+ added, then removed after 440ms — the 400ms fade plus a 40ms margin. */
57
37
  const root = document.documentElement;
58
38
  root.classList.add("theme-transition");
59
39
  applyTheme(choice);
@@ -73,47 +53,7 @@ export function setThemeChoice(choice: ThemeChoice): void {
73
53
  })();
74
54
  </script>
75
55
 
76
- All four properties are load-bearing:
77
- · INLINE — an external file is a network round trip, and the page
78
- paints during it
79
- · BLOCKING — no async, no defer, no type="module" (which defers
80
- implicitly); it must run before the first paint
81
- · BEFORE CSS — so the attribute is set when the stylesheet applies
82
- · try/catch — an uncaught throw here blocks all subsequent scripts
83
-
84
- For SSR, render the attribute server-side from a cookie instead —
85
- localStorage isn't readable on the server, so a cookie is the only way to
86
- get the value into the initial HTML.
87
- ───────────────────────────────────────────────────────────────────────── */
88
-
89
- /* ─────────────────────────────────────────────────────────────────────────
90
- Adding a third "system" state, if you want it.
91
-
92
- The reference implementation ships two explicit modes because a theme
93
- control should be predictable — "System" means the theme changes when the
94
- OS crosses a sunset threshold, which is surprising if you picked it from a
95
- site's own menu. But respecting the OS on a first visit is the more
96
- considerate default. Both positions are defensible.
97
-
98
- If you go to three, you owe the user two extra things:
99
-
100
- export type ThemeChoice = "light" | "dark" | "system";
101
-
102
- function resolve(choice: ThemeChoice): "light" | "dark" {
103
- if (choice !== "system") return choice;
104
- return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
105
- }
106
-
107
- 1. The head script must resolve "system" too, so it needs the matchMedia
108
- call inlined as well.
109
-
110
- 2. A live listener, so the page follows the OS WHILE OPEN:
111
-
112
- window.matchMedia("(prefers-color-scheme: dark)").addEventListener(
113
- "change",
114
- () => { if (getThemeChoice() === "system") applyTheme(resolve("system")); }
115
- );
116
-
117
- Skipping this is the common bug: the theme follows the OS on load and
118
- then silently stops, which is worse than not offering the option.
56
+ Inline, blocking, before the stylesheet, in try/catch; for SSR, render
57
+ the attribute from a cookie. Why each, and what a third "system" state
58
+ would add: domains/color/theming-mechanics.md.
119
59
  ───────────────────────────────────────────────────────────────────────── */
@@ -109,7 +109,7 @@ A `#16276b` stop on a `#0b0b0b` page loses the stroke's darker half, so the whol
109
109
 
110
110
  ### Status colour is not an exception
111
111
 
112
- Nothing on the site reports state, so Glass ships no status pair; if you add one, it follows domain law 6 and `domains/color/status-and-exceptions.md` (`glass/color/intent-ramp.md` has Glass's notes). Every destructive or session-ending action (delete, clear, sign out) shares one red treatment, wherever it sits.
112
+ Nothing on the site reports state, so Glass ships no status pair; if you add one, it follows domain law 6 and `domains/color/status-and-exceptions.md`. Every destructive or session-ending action (delete, clear, sign out) shares one red treatment, wherever it sits.
113
113
 
114
114
  ## 4. Text is a five-tier ladder — and it is *not* the grey ramp
115
115
 
@@ -156,20 +156,7 @@ Utilities read the variables, so most of the site needs **no per-component `dark
156
156
 
157
157
  ### No flash of wrong theme
158
158
 
159
- A blocking inline script in `<head>`, before any paint (domain law 9):
160
-
161
- ```html
162
- <script>
163
- (function () {
164
- try {
165
- document.documentElement.dataset.theme =
166
- localStorage.getItem("theme") === "dark" ? "dark" : "light";
167
- } catch (e) {}
168
- })();
169
- </script>
170
- ```
171
-
172
- → Persistence, toggle crossfade, light/dark/system trade-offs: `glass/color/theming.md`
159
+ A blocking `<head>` script sets the attribute before paint (domain law 9): `glass/color/assets/theme.ts`. → Two states, Light by default, and the toggle's 400ms plain-`ease` crossfade: `glass/color/theming.md`
173
160
 
174
161
  ## 6. Some colours are runtime, not build-time
175
162
 
@@ -183,24 +170,10 @@ The ramps and surfaces are vocabulary, in `@theme`; the **glass tints** are plai
183
170
  }
184
171
  ```
185
172
 
186
- The `prefers-reduced-transparency` fallback swaps each for a solid tint at runtime, and one isn't a colour at all. Gradients put the `--sv-*` family in `:root` too (two multi-stop gradients and a four-layer `box-shadow`).
173
+ Runtime because the reduced-transparency fallback swaps them, after both theme blocks (`glass/depth/guide.md` §8), and one isn't a colour at all; the `--sv-*` gradients sit in `:root` too.
187
174
 
188
175
  The nav tint is `#f2f2f2` (`gray-100`), not white: grey chrome reads as an object, not a hole in the page. Dark tints are *lighter* than the dark page (domain law 8).
189
176
 
190
- The reduced-transparency swap comes **after** both theme blocks and names both selectors (domain law 10):
191
-
192
- ```css
193
- @media (prefers-reduced-transparency: reduce) {
194
- :root, :root[data-theme="dark"] {
195
- --glass-nav-bg: var(--color-gray-100);
196
- --glass-panel-bg: var(--color-gray-100);
197
- --folder-glass-bg: var(--color-background-100);
198
- }
199
- }
200
- ```
201
-
202
- Dropping the `backdrop-filter` is the other half, and both are required. Blur, saturation, elevation shadows and text contrast over glass belong to `glass/depth/guide.md`.
203
-
204
177
  ## Assets
205
178
 
206
179
  - `glass/color/assets/color-tokens.css` — ramps, themes, glass tints and their fallback swap, focus ring; pairs with `glass/depth/assets/glass.css` (material classes).
@@ -58,20 +58,7 @@ Held out of the ramp because a page background is categorically different from a
58
58
 
59
59
  ## Dark-mode re-tinting
60
60
 
61
- ```css
62
- :root[data-theme="dark"] {
63
- --color-gray-100: #1a1a1a; /* 100–400: dark surfaces, rising slowly */
64
- --color-gray-200: #212121;
65
- --color-gray-300: #282828;
66
- --color-gray-400: #2e2e2e;
67
- --color-gray-500: #707070; /* 500–800: the mid greys jump */
68
- --color-gray-600: #7d7d7d;
69
- --color-gray-700: #8f8f8f;
70
- --color-gray-800: #a0a0a0;
71
- --color-gray-900: #c4c4c4; /* 900–1000: light text */
72
- --color-gray-1000: #ededed;
73
- }
74
- ```
61
+ The values are the dark block of `glass/color/assets/color-tokens.css`.
75
62
 
76
63
  **It's not an inversion** (domain law 8). Inverting the light ramp would put 100 at `#0d0d0d` and 1000 at `#e8e8e8`, evenly spaced. The real dark ramp is *compressed at the bottom* (100→400 spans only `#1a1a1a`→`#2e2e2e`, twenty units) and *expanded in the middle* (400→500 jumps forty units), because dark surfaces must stay distinguishable at very low lightness, where the eye is more sensitive to small differences, while mid-greys need a big jump to separate "border" from "text."
77
64
 
@@ -98,18 +85,7 @@ Against the near-black page the same indices pass — 800 is 7.53:1, 700 is 6.09
98
85
 
99
86
  ### The fix: text tiers as their own tokens
100
87
 
101
- ```css
102
- :root {
103
- --color-muted: #5e5e5e; /* 6.48:1 — deks, supporting copy, <em> */
104
- --color-quiet: #696969; /* 5.49:1 — resting icons, state labels, <cite> */
105
- --color-faint: #757575; /* 4.61:1 — dates, read times, meta */
106
- }
107
- :root[data-theme="dark"] {
108
- --color-muted: #a0a0a0; /* 7.53:1 */
109
- --color-quiet: #878787; /* 5.48:1 */
110
- --color-faint: #7a7a7a; /* 4.59:1 */
111
- }
112
- ```
88
+ `muted`, `quiet` and `faint`, per theme, are in `glass/color/assets/color-tokens.css`; their jobs are §4 of `glass/color/guide.md`.
113
89
 
114
90
  **Every tier clears 4.5:1 in both themes**, including the worst real pairing: muted on `gray-100`, a subtle surface, at 5.79:1.
115
91
 
@@ -126,11 +102,3 @@ Against the near-black page the same indices pass — 800 is 7.53:1, 700 is 6.09
126
102
  **A border step as text.** The one this guide got wrong for a whole revision (above).
127
103
 
128
104
  **"Something between 500 and 600"**, and **auditing contrast in dark mode only**: domain laws 1 and 3. Here the worst surface is a subtle `gray-100`, not `background-100`.
129
-
130
- ## Adding a semantic colour
131
-
132
- The reference implementation has none, because nothing on the site reports state. Check that before you add them. If you do, they get their own pairs outside the grey ramp, kept out of the focus ring's territory, and follow domain law 6 and `domains/color/status-and-exceptions.md`: shape carries the state, red means loss and never a live state, destructive actions take a red ground (solid danger with `background-100` text when highlighted), and small marks clear 3:1 on every ground in each theme.
133
-
134
- ## Spending your one chromatic exception
135
-
136
- Glass spends it once, on the collapsed summary panel: an offer, not content (domain law 7). Its containment rules — only the mark takes the accent; every stop re-picked for dark — are in §3 of `glass/color/guide.md`.
@@ -10,74 +10,13 @@ Glass's theme switch, persistence and crossfade, with the values it ships. The m
10
10
 
11
11
  Tailwind v4 drives `dark:` off the OS media query by default; Glass has a toggle, so `data-theme` on `<html>` is the mechanism (domain law 9). For a permanently-light region inside a dark page, re-declare the custom properties on its container rather than nesting the attribute (`domains/color/theming-mechanics.md`, **Theme islands**).
12
12
 
13
- ## No flash of wrong theme
14
-
15
- ```html
16
- <head>
17
- <!-- Before the stylesheet. Blocking on purpose. -->
18
- <script>
19
- (function () {
20
- try {
21
- document.documentElement.dataset.theme =
22
- localStorage.getItem("theme") === "dark" ? "dark" : "light";
23
- } catch (e) {}
24
- })();
25
- </script>
26
- <link rel="stylesheet" href="…" />
27
- </head>
28
- ```
29
-
30
- Inline, blocking, before the stylesheet, in `try/catch`; it duplicates a few lines of the theme module, with a comment in each place pointing at the other (`domains/color/theming-mechanics.md`, **No flash of wrong theme**).
31
-
32
- ## Persistence and the crossfade
33
-
34
- ```ts
35
- export type ThemeChoice = "light" | "dark";
36
-
37
- const KEY = "theme";
38
-
39
- export function getThemeChoice(): ThemeChoice {
40
- try {
41
- if (localStorage.getItem(KEY) === "dark") return "dark";
42
- } catch { /* localStorage unavailable */ }
43
- return "light";
44
- }
45
-
46
- export function applyTheme(choice: ThemeChoice): void {
47
- document.documentElement.dataset.theme = choice;
48
- }
49
-
50
- export function setThemeChoice(choice: ThemeChoice): void {
51
- try {
52
- localStorage.setItem(KEY, choice);
53
- } catch { /* preference just won't persist */ }
13
+ ## No flash of wrong theme, persistence and the crossfade
54
14
 
55
- // Crossfade only on explicit toggles.
56
- const root = document.documentElement;
57
- root.classList.add("theme-transition");
58
- applyTheme(choice);
59
- window.setTimeout(() => root.classList.remove("theme-transition"), 440);
60
- }
61
- ```
62
-
63
- Three functions for three callers — `getThemeChoice` for initial React state, `applyTheme` for the DOM write, `setThemeChoice` for the user action (`domains/color/theming-mechanics.md`, **Persistence**). The comparison is `=== "dark"`, so anything unrecognised falls through to light.
64
-
65
- ### The 440ms class
66
-
67
- ```css
68
- @media (prefers-reduced-motion: no-preference) {
69
- :root.theme-transition,
70
- :root.theme-transition *,
71
- :root.theme-transition *::before,
72
- :root.theme-transition *::after {
73
- transition: background-color 400ms ease, color 400ms ease, border-color 400ms ease,
74
- fill 400ms ease, stroke 400ms ease, box-shadow 400ms ease !important;
75
- }
76
- }
77
- ```
15
+ The code lives in `glass/color/assets/theme.ts` — the three functions and, at the bottom, the inline head script — and the 440ms `.theme-transition` rule in `glass/motion/assets/motion-tokens.css`. The head script duplicates a few lines of the theme module, with a comment in each place pointing at the other. The mechanism, the head script's four requirements, the three callers and why the crossfade class is temporary and `!important`: `domains/color/theming-mechanics.md`.
78
16
 
79
- Why it's temporary, `!important`, inside `no-preference` and lists the pseudo-elements: `domains/color/theming-mechanics.md`, **The crossfade on toggle**. Glass's values:
17
+ Glass's values:
80
18
 
19
+ - **The comparison is `=== "dark"`**, so anything unrecognised falls through to light.
81
20
  - **440ms**: 400ms plus a 40ms margin so nothing is cut off. For those 440ms it has to beat every component's own `transition-colors duration-150`.
82
21
  - **400ms**: the longest duration in the system, for its largest change — every surface, text colour, border, icon fill and shadow at once.
83
22
  - **Plain `ease`, not the house curve.** The house easing overshoots, and on a colour that sends the background past its target and back — a wobble in the light.
@@ -99,13 +38,7 @@ The reference implementation ships **two** explicit modes, Light and Dark, with
99
38
 
100
39
  Most components need no theme branching; the test for when one does is in `domains/color/theming-mechanics.md`. Glass's cases:
101
40
 
102
- **Multi-layer shadows.** A shadow whose *structure* changes between themes (the inset highlight weakening while the ambient shadows strengthen 10×) can't live in one colour token. Extract it to a shared constant:
103
-
104
- ```tsx
105
- const SHADOW =
106
- "shadow-[inset_0_1px_0_rgba(255,255,255,0.6),0_1px_1px_rgba(0,0,0,0.02),…] " +
107
- "dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.10),0_1px_1px_rgba(0,0,0,0.3),…]";
108
- ```
41
+ **Multi-layer shadows.** A shadow whose *structure* changes between themes (the inset highlight weakening while the ambient shadows strengthen 10×) can't live in one colour token, but Glass keeps it out of `dark:` too: it's one class, `.dock-shadow`, with its `:root[data-theme="dark"]` twin beside it (`glass/depth/depth-cues.md`, §2), not a JS constant.
109
42
 
110
43
  **Ring/border colours as alpha of a specific hue.** `ring-black/[0.06] dark:ring-white/[0.08]` encodes what the alpha ramp does, where the utility won't take a token. Prefer `gray-alpha-*` where you can; use the pair where you can't.
111
44