trykeyline 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/.claude-plugin/marketplace.json +18 -0
  2. package/LICENSE +21 -0
  3. package/README.md +117 -2
  4. package/bin/keyline.mjs +280 -0
  5. package/package.json +38 -4
  6. package/plugins/keyline/.claude-plugin/plugin.json +14 -0
  7. package/plugins/keyline/plugin.json +22 -0
  8. package/plugins/keyline/skills/keyline/SKILL.md +47 -0
  9. package/plugins/keyline/skills/keyline/commands/audit.md +30 -0
  10. package/plugins/keyline/skills/keyline/commands/color.md +7 -0
  11. package/plugins/keyline/skills/keyline/commands/critique.md +20 -0
  12. package/plugins/keyline/skills/keyline/commands/depth.md +7 -0
  13. package/plugins/keyline/skills/keyline/commands/init.md +16 -0
  14. package/plugins/keyline/skills/keyline/commands/layout.md +7 -0
  15. package/plugins/keyline/skills/keyline/commands/motion.md +7 -0
  16. package/plugins/keyline/skills/keyline/commands/patterns.md +7 -0
  17. package/plugins/keyline/skills/keyline/commands/polish.md +19 -0
  18. package/plugins/keyline/skills/keyline/commands/refine.md +19 -0
  19. package/plugins/keyline/skills/keyline/commands/sync.md +15 -0
  20. package/plugins/keyline/skills/keyline/commands/type.md +7 -0
  21. package/plugins/keyline/skills/keyline/commands/ux-laws.md +26 -0
  22. package/plugins/keyline/skills/keyline/domains/color/deriving-a-ramp.md +144 -0
  23. package/plugins/keyline/skills/keyline/domains/color/guide.md +207 -0
  24. package/plugins/keyline/skills/keyline/domains/color/status-and-exceptions.md +174 -0
  25. package/plugins/keyline/skills/keyline/domains/color/theming-mechanics.md +121 -0
  26. package/plugins/keyline/skills/keyline/domains/depth/elevation-recipes.md +185 -0
  27. package/plugins/keyline/skills/keyline/domains/depth/fallbacks-and-performance.md +132 -0
  28. package/plugins/keyline/skills/keyline/domains/depth/guide.md +226 -0
  29. package/plugins/keyline/skills/keyline/domains/depth/translucent-materials.md +159 -0
  30. package/plugins/keyline/skills/keyline/domains/layout/forks-gating-and-disclosures.md +201 -0
  31. package/plugins/keyline/skills/keyline/domains/layout/guide.md +259 -0
  32. package/plugins/keyline/skills/keyline/domains/layout/measure-and-rhythm.md +193 -0
  33. package/plugins/keyline/skills/keyline/domains/layout/radius-lines-and-layers.md +122 -0
  34. package/plugins/keyline/skills/keyline/domains/motion/guards.md +162 -0
  35. package/plugins/keyline/skills/keyline/domains/motion/guide.md +198 -0
  36. package/plugins/keyline/skills/keyline/domains/motion/ladder-and-curves.md +139 -0
  37. package/plugins/keyline/skills/keyline/domains/motion/patterns.md +196 -0
  38. package/plugins/keyline/skills/keyline/domains/motion/performance.md +121 -0
  39. package/plugins/keyline/skills/keyline/domains/patterns/actions-and-forms.md +204 -0
  40. package/plugins/keyline/skills/keyline/domains/patterns/agents-and-live-states.md +111 -0
  41. package/plugins/keyline/skills/keyline/domains/patterns/collections-and-dashboards.md +131 -0
  42. package/plugins/keyline/skills/keyline/domains/patterns/copy.md +123 -0
  43. package/plugins/keyline/skills/keyline/domains/patterns/guide.md +174 -0
  44. package/plugins/keyline/skills/keyline/domains/patterns/iteration.md +70 -0
  45. package/plugins/keyline/skills/keyline/domains/type/display-tier.md +108 -0
  46. package/plugins/keyline/skills/keyline/domains/type/guide.md +227 -0
  47. package/plugins/keyline/skills/keyline/domains/type/ladders.md +169 -0
  48. package/plugins/keyline/skills/keyline/domains/type/numerals-and-loading.md +107 -0
  49. package/plugins/keyline/skills/keyline/domains/type/prose-and-emphasis.md +125 -0
  50. package/plugins/keyline/skills/keyline/domains/type/type-audit.md +87 -0
  51. package/plugins/keyline/skills/keyline/glass/color/assets/color-tokens.css +299 -0
  52. package/plugins/keyline/skills/keyline/glass/color/assets/theme.ts +119 -0
  53. package/plugins/keyline/skills/keyline/glass/color/guide.md +195 -0
  54. package/plugins/keyline/skills/keyline/glass/color/intent-ramp.md +136 -0
  55. package/plugins/keyline/skills/keyline/glass/color/theming.md +106 -0
  56. package/plugins/keyline/skills/keyline/glass/depth/assets/GlassPanel.tsx +171 -0
  57. package/plugins/keyline/skills/keyline/glass/depth/assets/glass.css +322 -0
  58. package/plugins/keyline/skills/keyline/glass/depth/blur-and-tint.md +50 -0
  59. package/plugins/keyline/skills/keyline/glass/depth/depth-cues.md +87 -0
  60. package/plugins/keyline/skills/keyline/glass/depth/guide.md +147 -0
  61. package/plugins/keyline/skills/keyline/glass/depth/performance-and-fallbacks.md +37 -0
  62. package/plugins/keyline/skills/keyline/glass/layout/adaptive-presentations.md +231 -0
  63. package/plugins/keyline/skills/keyline/glass/layout/assets/SectionNav.tsx +244 -0
  64. package/plugins/keyline/skills/keyline/glass/layout/assets/layout-tokens.css +201 -0
  65. package/plugins/keyline/skills/keyline/glass/layout/guide.md +259 -0
  66. package/plugins/keyline/skills/keyline/glass/layout/measure-and-rhythm.md +134 -0
  67. package/plugins/keyline/skills/keyline/glass/layout/radius-and-elevation.md +103 -0
  68. package/plugins/keyline/skills/keyline/glass/motion/assets/DropMenu.tsx +106 -0
  69. package/plugins/keyline/skills/keyline/glass/motion/assets/motion-presets.ts +210 -0
  70. package/plugins/keyline/skills/keyline/glass/motion/assets/motion-tokens.css +176 -0
  71. package/plugins/keyline/skills/keyline/glass/motion/assets/useReveal.ts +65 -0
  72. package/plugins/keyline/skills/keyline/glass/motion/assets/useScrollTick.ts +183 -0
  73. package/plugins/keyline/skills/keyline/glass/motion/easing-and-duration.md +108 -0
  74. package/plugins/keyline/skills/keyline/glass/motion/guide.md +255 -0
  75. package/plugins/keyline/skills/keyline/glass/motion/reduced-motion.md +81 -0
  76. package/plugins/keyline/skills/keyline/glass/motion/scroll-performance.md +58 -0
  77. package/plugins/keyline/skills/keyline/glass/motion/shared-element.md +161 -0
  78. package/plugins/keyline/skills/keyline/glass/motion/sound.md +195 -0
  79. package/plugins/keyline/skills/keyline/glass/motion/springs.md +51 -0
  80. package/plugins/keyline/skills/keyline/glass/type/assets/prose.tsx +268 -0
  81. package/plugins/keyline/skills/keyline/glass/type/assets/type-tokens.css +416 -0
  82. package/plugins/keyline/skills/keyline/glass/type/guide.md +220 -0
  83. package/plugins/keyline/skills/keyline/glass/type/long-form-prose.md +151 -0
  84. package/plugins/keyline/skills/keyline/glass/type/scale.md +69 -0
  85. package/plugins/keyline/skills/keyline/glass/type/weight-and-tracking.md +61 -0
  86. package/plugins/keyline/skills/keyline/scripts/check/css.mjs +112 -0
  87. package/plugins/keyline/skills/keyline/scripts/check/design.mjs +67 -0
  88. package/plugins/keyline/skills/keyline/scripts/check/files.mjs +44 -0
  89. package/plugins/keyline/skills/keyline/scripts/check/markup.mjs +149 -0
  90. package/plugins/keyline/skills/keyline/scripts/check/rules.mjs +584 -0
  91. package/plugins/keyline/skills/keyline/scripts/check.mjs +297 -0
  92. package/plugins/keyline/skills/keyline/scripts/contrast.mjs +153 -0
  93. package/plugins/keyline/skills/keyline/scripts/scan.mjs +340 -0
  94. package/plugins/keyline/skills/keyline/system/design-md.md +136 -0
  95. package/plugins/keyline/skills/keyline/system/detection.md +53 -0
  96. package/plugins/keyline/skills/keyline/system/guide.md +97 -0
  97. package/plugins/keyline/skills/keyline/system/presets.md +58 -0
  98. package/plugins/keyline/skills/keyline/system/roles.md +150 -0
@@ -0,0 +1,195 @@
1
+ # Glass Colour
2
+
3
+ > **Glass preset.** This is the colour system running on [modimihir.com](https://modimihir.com), with its real values, meant to be copied whole. Read it beside `domains/color/guide.md`: the laws and their reasons are there, and this guide holds only what Glass decides — its ramps, its tokens, its recipes and why it chose them. Under any other preset, the domain guide leads and this guide is only a worked example.
4
+
5
+ Monochrome, built on [Geist](https://vercel.com/geist)'s grayscale semantics under Apple-style translucent materials. Each step of the grey ramp is a job, not a lightness (domain law 1): `gray-400` is *the colour a border is*, and dark mode re-tints the same indices.
6
+
7
+ ## 1. The intent-indexed ramp
8
+
9
+ | Step | Intent | Light | Dark |
10
+ |---|---|---|---|
11
+ | 100 | Raised element's surface | `#f2f2f2` | `#1a1a1a` |
12
+ | 200 | Surface, hovered | `#ebebeb` | `#212121` |
13
+ | 300 | Surface, active/pressed | `#e6e6e6` | `#282828` |
14
+ | 400 | **Border** | `#eaeaea` | `#2e2e2e` |
15
+ | 500 | Border, hovered — and decorative marks | `#c9c9c9` | `#707070` |
16
+ | 600 | Border, active | `#a8a8a8` | `#7d7d7d` |
17
+ | 700 | Solid fill (button, marker) | `#8f8f8f` | `#8f8f8f` |
18
+ | 800 | Solid fill, hovered | `#7d7d7d` | `#a0a0a0` |
19
+ | 900 | **Secondary text** | `#4d4d4d` | `#c4c4c4` |
20
+ | 1000 | **Primary text** | `#171717` | `#ededed` |
21
+
22
+ Two surfaces sit apart; a page background is not a component surface:
23
+
24
+ | Token | Intent | Light | Dark |
25
+ |---|---|---|---|
26
+ | `background-100` | Page / card surface | `#ffffff` | `#0b0b0b` |
27
+ | `background-200` | Subtle secondary surface | `#fafafa` | `#161616` |
28
+
29
+ **400 is lighter than 300 in the light theme** (`#eaeaea` vs `#e6e6e6`): intent wins over monotonicity (domain law 1).
30
+
31
+ **Steps 500–800 are borders and fills, never text** (§4).
32
+
33
+ → Each step and its misuse: `glass/color/intent-ramp.md`
34
+
35
+ ## 2. The parallel translucent ramp
36
+
37
+ Glass's translucent set (domain law 4), for borders on glass, dividers, hairlines and overlays:
38
+
39
+ ```css
40
+ --color-gray-alpha-100: #0000000d; /* 5% */
41
+ --color-gray-alpha-200: #00000015; /* 8% */
42
+ --color-gray-alpha-300: #0000001a; /* 10% */
43
+ --color-gray-alpha-400: #00000014; /* 8% */
44
+ --color-gray-alpha-500: #00000036; /* 21% */
45
+ --color-gray-alpha-600: #0000003d; /* 24% */
46
+ ```
47
+
48
+ In dark mode it inverts to **white**-based:
49
+
50
+ ```css
51
+ :root[data-theme="dark"] {
52
+ --color-gray-alpha-100: #ffffff0d;
53
+ --color-gray-alpha-400: #ffffff17;
54
+ /* … */
55
+ }
56
+ ```
57
+
58
+ **Solid ramp for opaque surfaces, alpha ramp for anything layered:** a solid `gray-400` border on a translucent panel ignores what's behind it; `gray-alpha-400` sits *on* it, white page or photograph.
59
+
60
+ ## 3. One accent for focus — and at most one deliberate exception
61
+
62
+ ```css
63
+ --color-blue-700: #006bff; /* light */
64
+ --color-blue-700: #3b82f6; /* dark */
65
+ ```
66
+
67
+ One accent, for the focus ring only, the editorial setting of the accent dial. Links stay monochrome; blue ones read as unstyled defaults and dull a blue ring.
68
+
69
+ ```css
70
+ :focus-visible {
71
+ outline: 2px solid var(--color-blue-700);
72
+ outline-offset: 2px;
73
+ border-radius: 2px;
74
+ }
75
+ a:focus-visible { outline-offset: 3px; }
76
+ ```
77
+
78
+ Why `:focus-visible`, the offset and the wider link offset: domain law 5. The dark value is brighter (`#3b82f6` vs `#006bff`), re-picked rather than reused (domain law 8).
79
+
80
+ ### The second exception, if you allow one
81
+
82
+ Glass spends its one exception on a collapsed "short version" panel atop a long case study: **the panel is an offer rather than content** ("do you want the three-line version?"), and nothing else on the site is an offer, so the reason can't spread (domain law 7).
83
+
84
+ ```css
85
+ :root {
86
+ --sv-edge-top: #16276b; /* navy, top of the stroke */
87
+ --sv-edge-mid: #2f5ac4;
88
+ --sv-edge-bottom: #6d97ea; /* lighter blue, bottom */
89
+ --sv-fill-top: #f4f6fc;
90
+ --sv-fill-bottom: #eef2fa;
91
+ --sv-accent: #2f5ac4; /* the mark only */
92
+ }
93
+ ```
94
+
95
+ Only the mark takes `--sv-accent`; the text stays in the page's ink (domain law 7). In dark mode every stop moves (domain law 8):
96
+
97
+ ```css
98
+ :root[data-theme="dark"] {
99
+ --sv-edge-top: #24407f; /* was #16276b */
100
+ --sv-edge-mid: #3f6ed0;
101
+ --sv-edge-bottom: #86adf7;
102
+ --sv-fill-top: #171d2a; /* tinted, not darkened */
103
+ --sv-fill-bottom: #1b2230;
104
+ --sv-accent: #86adf7;
105
+ }
106
+ ```
107
+
108
+ A `#16276b` stop on a `#0b0b0b` page loses the stroke's darker half, so the whole ramp lifts, not just that end. The fill is *tinted* to `gray-100`'s weight, not darkened, so it doesn't punch a hole in the page (the glass-tint rule, §6). The material — a 1px gradient stroke, a side-weighted glow, no wrapper — belongs to `glass/depth/guide.md`.
109
+
110
+ ### Status colour is not an exception
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.
113
+
114
+ ## 4. Text is a five-tier ladder — and it is *not* the grey ramp
115
+
116
+ The lower three tiers are **their own tokens**, set by contrast (domain laws 2 and 3):
117
+
118
+ | Tier | Token | Light | Dark | Job |
119
+ |---|---|---|---|---|
120
+ | Primary | `gray-1000` | 17.93:1 | 16.81:1 | Body copy, headings, active state |
121
+ | Secondary | `gray-900` | 8.45:1 | 11.28:1 | Emphasis short of primary, ancestor rows |
122
+ | Muted | `muted` `#5e5e5e` / `#a0a0a0` | 6.48:1 | 7.53:1 | Deks, supporting copy, `<em>` |
123
+ | Quiet | `quiet` `#696969` / `#878787` | 5.49:1 | 5.48:1 | Resting icons, state labels, `<cite>` |
124
+ | Faint | `faint` `#757575` / `#7a7a7a` | 4.61:1 | 4.59:1 | Dates, read times, meta |
125
+
126
+ In a light theme only 900 and 1000 of the grey ramp read as text; the site shipped mid steps as text until an audit caught it (the failure table and fix: `glass/color/intent-ramp.md`).
127
+
128
+ **Icons go on `quiet`, not a tier of their own.** They need only 3:1, but usually sit beside a label at the same colour, and two tokens kept matched is more coupling than it saves. An icon rests a tier *below* its label (domain law 2); both go to primary on hover.
129
+
130
+ **Decorative marks stay on the grey ramp**: `bg-gray-500` for a bullet, a 3px dot or an inactive rail marker (domain law 2's `mark`).
131
+
132
+ **`<em>` maps to *muted*.** Italics are off globally (`glass/type/guide.md`), so `*em*` recedes to `muted` and `**strong**` advances to `gray-1000` + weight 500.
133
+
134
+ ## 5. Theming: one attribute, one set of tokens
135
+
136
+ `data-theme` on `<html>` drives dark mode, not `prefers-color-scheme`, so a toggle can override the OS (domain law 9):
137
+
138
+ ```css
139
+ @custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
140
+ ```
141
+
142
+ The dark theme is one block re-tinting the same names:
143
+
144
+ ```css
145
+ :root[data-theme="dark"] {
146
+ color-scheme: dark;
147
+ --color-gray-100: #1a1a1a; /* low steps become dark surfaces */
148
+ --color-gray-1000: #ededed; /* high steps become light text */
149
+ --color-gray-alpha-100: #ffffff0d;
150
+ --color-background-100: #0b0b0b;
151
+ /* … */
152
+ }
153
+ ```
154
+
155
+ Utilities read the variables, so most of the site needs **no per-component `dark:` classes**. Dark surfaces are `#0b0b0b`, not `#000000` (domain law 8).
156
+
157
+ ### No flash of wrong theme
158
+
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`
160
+
161
+ ## 6. Some colours are runtime, not build-time
162
+
163
+ The ramps and surfaces are vocabulary, in `@theme`; the **glass tints** are plain `:root` properties (domain law 10):
164
+
165
+ ```css
166
+ :root {
167
+ --glass-nav-bg: rgba(242, 242, 242, 0.72);
168
+ --glass-panel-bg: rgba(243, 243, 243, 0.97);
169
+ --folder-glass-bg: linear-gradient(to bottom, rgba(255,255,255,0.55), rgba(255,255,255,0.34));
170
+ }
171
+ ```
172
+
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.
174
+
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).
176
+
177
+ ## Assets
178
+
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).
180
+ - `glass/color/assets/theme.ts` — persistence, toggle crossfade, no-flash head script.
181
+
182
+ ## Checklist
183
+
184
+ The domain checklist, plus Glass's values:
185
+
186
+ - [ ] Steps used for their intent: borders at 400, surfaces at 100–300, the page on `background-100`.
187
+ - [ ] **No step from 500 to 800 as text**; text is `gray-900`/`gray-1000` or muted/quiet/faint.
188
+ - [ ] Resting icons on `quiet`; decorative marks on `gray-500`.
189
+ - [ ] `gray-alpha-*` for anything layered, white-based in dark.
190
+ - [ ] `blue-700` for focus only, `#006bff` light and `#3b82f6` dark; links monochrome.
191
+ - [ ] At most the one `--sv-*` exception, every stop re-picked for dark.
192
+ - [ ] `data-theme` on `<html>`, set by the head script; dark page `#0b0b0b`.
193
+ - [ ] Glass tints and `--sv-*` in `:root`, not `@theme`; the reduced-transparency swap last.
194
+ - [ ] Contrast checked in both themes; muted text on a subtle surface is what fails.
195
+ - [ ] Glass surfaces audited against the `glass/depth/guide.md` checklist.
@@ -0,0 +1,136 @@
1
+ # The intent-indexed ramp
2
+
3
+ Each Glass step, what it's for and how it was misused. Why a ramp indexed by job beats one indexed by lightness: domain law 1 in `domains/color/guide.md`.
4
+
5
+ ## The steps
6
+
7
+ ### 100 · 200 · 300 — surface and its states
8
+
9
+ A raised element's background, hover and pressed state: three states, one step apart, so the progression stays even.
10
+
11
+ - **100** is also a general subtle surface — an image wall's backdrop, a cover placeholder, a hover bar in a list.
12
+ - **200** also fills a small static thing that should read as an inert placeholder (faux body lines in a preview card).
13
+ - **300** also fills a small structural mark, such as a preview card's title bar.
14
+
15
+ ### 400 — border
16
+
17
+ The default border, one of the two most-used steps.
18
+
19
+ **In light mode, 400 (`#eaeaea`) is lighter than 300 (`#e6e6e6`)** (domain law 1). The two are never adjacent in practice, so nothing looks wrong.
20
+
21
+ On anything translucent, use `gray-alpha-400` instead (§2 of `glass/color/guide.md`).
22
+
23
+ ### 500 · 600 — border states (and where icons used to go)
24
+
25
+ 500 is a hovered border, 600 an active one.
26
+
27
+ **600 used to be the resting colour of an icon, and that was a bug.** An icon should rest a tier below the label beside it, so `hover:text-gray-1000` gives both the same perceived jump — but 600 is 2.38:1 on white in the light theme, under even the 3:1 non-text floor (table below). Icons now rest on `quiet`, keeping the one-tier-below relationship on a token picked for contrast; see §4 of `glass/color/guide.md`.
28
+
29
+ 500 was where the reference implementation put its **faint text** tier — timestamps, dates, read times. `#c9c9c9` on white is 1.66:1: illegible, below even the 3:1 non-text floor. Text tiers are now separate tokens (below).
30
+
31
+ 500 keeps its border-hover intent and its use as the fill for *decorative* marks (list bullets, a 3px dot, an inactive rail marker).
32
+
33
+ ### 700 · 800 — solid fill
34
+
35
+ A filled element that isn't the primary action: a small marker, a filled chip.
36
+
37
+ The reference implementation also used **800 as its muted text tier** — deks, secondary paragraphs, `<em>`, supporting copy. `#7d7d7d` on white is **4.12:1**, which **fails** WCAG AA for normal text (4.5:1). It was the site's most-used text colour, and it failed everywhere in the light theme. An earlier version of this file put it at "about 4.6:1"; computed, it's 4.12:1 (domain law 3).
38
+
39
+ ### 900 — secondary text
40
+
41
+ Emphasised but not primary: a role title above its description, an ancestor row in a table of contents (the section you're inside while a child row is active), body copy where primary would be too heavy.
42
+
43
+ The gap between 900 (`#4d4d4d`) and 1000 (`#171717`) is deliberately large: secondary should be clearly a step down, not almost-primary.
44
+
45
+ ### 1000 — primary text
46
+
47
+ Body copy, headings, active states, `<strong>`. `#171717`, not `#000000` (domain law 8).
48
+
49
+ Also the *fill* for an inverted element: a tooltip is `bg-gray-1000` with `text-background-100`.
50
+
51
+ **Not for a ticked box in a long list** (`domains/color/status-and-exceptions.md`, **Done, and where the reader is**). Glass's ticked box is a `gray-100` square on its 400 border with a `gray-1000` tick; a switch keeps the inverted fill.
52
+
53
+ ### background-100 · background-200
54
+
55
+ Held out of the ramp because a page background is categorically different from a component surface. 100 is the page and card surface; 200 a subtle secondary surface.
56
+
57
+ `background-100` is also the **text** colour for anything inverted, which is why it's a token rather than a hardcoded white: a tooltip's text has to become near-black in the dark theme, and does so for free.
58
+
59
+ ## Dark-mode re-tinting
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
+ ```
75
+
76
+ **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
+
78
+ **700 is identical in both themes** (`#8f8f8f`): a mid-grey solid fill needs the same value either way, the one step where semantic and value coincide.
79
+
80
+ **The step order stays monotonic in dark mode** (400 `#2e2e2e` > 300 `#282828`). No contradiction: the constraint is the *intent*, and in dark mode a slightly lighter border also happens to be the right lightness order.
81
+
82
+ Low steps are surfaces and high steps are text in both themes, so `border-gray-400` is a border in light and dark, with no `dark:` variant.
83
+
84
+ ## Borders and text are different jobs
85
+
86
+ The hardest-won rule here (domain law 2), from auditing the reference implementation against this guide's own checklist. In the light theme:
87
+
88
+ | Step | Light value | As text on white | Verdict |
89
+ |---|---|---|---|
90
+ | 500 | `#c9c9c9` | 1.66:1 | illegible |
91
+ | 600 | `#a8a8a8` | 2.38:1 | fails even the 3:1 non-text floor |
92
+ | 700 | `#8f8f8f` | 3.23:1 | fails |
93
+ | 800 | `#7d7d7d` | 4.12:1 | fails |
94
+ | 900 | `#4d4d4d` | 8.45:1 | passes |
95
+ | 1000 | `#171717` | 17.93:1 | passes |
96
+
97
+ Against the near-black page the same indices pass — 800 is 7.53:1, 700 is 6.09:1 — so a system reviewed in dark ships this light theme.
98
+
99
+ ### The fix: text tiers as their own tokens
100
+
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
+ ```
113
+
114
+ **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
+
116
+ **The ladder stays strictly descending** (domain law 3) — 17.93 / 8.45 / 6.48 / 5.49 / 4.61 in light, 16.81 / 11.28 / 7.53 / 5.48 / 4.59 in dark.
117
+
118
+ **Only `faint` moved much in dark mode.** `muted` keeps the old 800 value, which already passed there: the failure was overwhelmingly a light-theme one.
119
+
120
+ ## Common failures
121
+
122
+ **A surface step as a border.** `border-gray-200` looks fine in light mode and vanishes in dark, where 200 is a dark surface nearly flush with 100.
123
+
124
+ **A text step as an icon.** `text-gray-900` on a 20px icon reads as heavy as primary text and pulls focus off the label. Use `quiet`.
125
+
126
+ **A border step as text.** The one this guide got wrong for a whole revision (above).
127
+
128
+ **"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`.
@@ -0,0 +1,106 @@
1
+ # Theming
2
+
3
+ Glass's theme switch, persistence and crossfade, with the values it ships. The mechanism, the head script's four requirements, persistence, `color-scheme`, two states or three, and theme islands are in `domains/color/theming-mechanics.md`; this file holds what Glass does and why.
4
+
5
+ ## A data attribute, not `prefers-color-scheme`
6
+
7
+ ```css
8
+ @custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
9
+ ```
10
+
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
+
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 */ }
54
+
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
+ ```
78
+
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:
80
+
81
+ - **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
+ - **400ms**: the longest duration in the system, for its largest change — every surface, text colour, border, icon fill and shadow at once.
83
+ - **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.
84
+
85
+ ## `color-scheme`
86
+
87
+ ```css
88
+ :root { color-scheme: light; }
89
+ :root[data-theme="dark"] { color-scheme: dark; }
90
+ ```
91
+
92
+ What it re-tints, and the white scrollbar track that shows it's missing: `domains/color/theming-mechanics.md`, **`color-scheme`**.
93
+
94
+ ## Two states, not three
95
+
96
+ The reference implementation ships **two** explicit modes, Light and Dark, with Light as the default. No "System": a theme control should be predictable, and "System" changes the theme when the OS crosses a sunset threshold, which surprises someone who picked it from the site's own menu. Two `aria-pressed` buttons are also simpler to build and explain than a tri-state. What three would cost — the head script resolving `system`, and a live listener — is in `domains/color/theming-mechanics.md`, **Two states or three**.
97
+
98
+ ## Where `dark:` variants are still needed
99
+
100
+ Most components need no theme branching; the test for when one does is in `domains/color/theming-mechanics.md`. Glass's cases:
101
+
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, 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.
103
+
104
+ **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.
105
+
106
+ **Anything two-toned by design.** The frosted folder pocket uses `border-white/60 dark:border-white/[0.08]`: a bright top edge that must nearly vanish on dark, where it would read as a glowing line.
@@ -0,0 +1,171 @@
1
+ /**
2
+ * The glass surface, as a component — every depth cue in one place.
3
+ *
4
+ * The point of this file is the wrapper/surface split and the depth cues in
5
+ * one place. (The elevation stack itself is better as the `.dock-shadow`
6
+ * class in glass/depth/assets/glass.css — see the note on the SHADOW constant below.)
7
+ * Glass is four properties plus three depth cues, and the cues are the part
8
+ * people skip: a blurred tinted rectangle with no hairline and no shadow is a
9
+ * patch, not a floating panel.
10
+ *
11
+ * Pairs with glass/depth/assets/glass.css (the materials) and
12
+ * glass/color/assets/color-tokens.css (the tints and the ramps).
13
+ */
14
+
15
+ import type { ReactNode } from "react";
16
+
17
+ /* ── Elevation ────────────────────────────────────────────────────────────
18
+ Four layers modelling one light source above the element:
19
+
20
+ inset 1px white the specular catch where a raised surface faces the
21
+ light. Contributes more to the floating impression
22
+ than any of the shadows, and is the one most often
23
+ omitted — remove it and the surface looks printed on.
24
+ 1px/1px @ 2% contact shadow: resting on something, not pasted over.
25
+ 8/16 @ 4% near ambient.
26
+ 24/32 @ 6% far ambient. Two layers rather than one because a
27
+ single large shadow reads as fog; two fall off like
28
+ real penumbra.
29
+
30
+ Negative spread (-4px, -8px) contracts the shadow before blurring, keeping
31
+ it tucked under the element instead of haloing past its edges. This is the
32
+ detail most hand-written shadows miss, and why stock box-shadow presets
33
+ often look like a glow.
34
+
35
+ Opacities are 2-6%. Elevation should be felt, not seen.
36
+
37
+ Dark mode is a DIFFERENT shadow, not the same one re-tinted:
38
+ · shadows go up ~10x — 4% black on #0b0b0b is mathematically
39
+ invisible; dark needs 30-60% to register at all
40
+ · highlight drops ~6x — 0.6 white on a dark surface reads as a glowing
41
+ edge rather than a catch light
42
+
43
+ Because the STRUCTURE changes and not just the values, elevation cannot be
44
+ a single token — it needs a per-theme pair, and this is the one place raw
45
+ rgba() outside the token file is acceptable.
46
+
47
+ PREFER THE CLASS. `.dock-shadow` in glass/depth/assets/glass.css is the same four
48
+ layers in a form you can actually read and diff against its dark twin.
49
+ The reference implementation started with the constant below and moved
50
+ to the class, for three reasons:
51
+
52
+ · it's ~400 characters either way, and four CSS lines beat one string
53
+ · a constant still gets imported and interpolated at every call site,
54
+ so a grep for the shadow finds five hits rather than one definition
55
+ · the arbitrary-value escaping is a live hazard — underscores for
56
+ spaces, no spaces inside the parens, and a typo produces a class the
57
+ build silently drops: no shadow, no error
58
+
59
+ The constant is kept here for codebases that can't add a stylesheet. */
60
+ export const SHADOW =
61
+ "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)] " +
62
+ "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)]";
63
+
64
+ /** Hover cards and other briefly-floating panels. One ambient layer, no
65
+ contact shadow — it's hovering, not resting. */
66
+ export const SHADOW_PANEL =
67
+ "shadow-[0_8px_24px_-8px_rgba(0,0,0,0.12)] dark:shadow-[0_8px_24px_-8px_rgba(0,0,0,0.6)]";
68
+
69
+ /** Tooltips. 30% black in BOTH themes — it sits on bg-gray-1000, which is
70
+ near-black in light and near-white in dark, so the SURFACE sets the
71
+ requirement rather than the page. Elevation is relative to what's
72
+ immediately behind, not to the theme. */
73
+ export const SHADOW_TIP = "shadow-[0_4px_10px_-4px_rgba(0,0,0,0.3)]";
74
+
75
+ /* ── The hairline ─────────────────────────────────────────────────────────
76
+ From the TRANSLUCENT ramp, not a solid grey. A solid border doesn't
77
+ participate in what's behind it, so over a photograph it reads as a
78
+ pasted-on frame. And because the alpha ramp inverts to white-based in dark
79
+ mode, this is automatically lighter than its surface there and darker here.
80
+
81
+ 0.5px renders as a true sub-pixel hairline on retina and rounds UP to 1px
82
+ elsewhere — it degrades to the next weight rather than disappearing. */
83
+ const HAIRLINE = "border-[0.5px] border-gray-alpha-400";
84
+
85
+ export type GlassTier = "nav" | "panel";
86
+
87
+ /**
88
+ * A glass surface with all three depth cues attached.
89
+ *
90
+ * `tier` picks the material — and the choice is about how little you know
91
+ * about the backdrop, not about size or importance:
92
+ *
93
+ * nav 20px blur, 72% tint. Fixed chrome that arbitrary content scrolls
94
+ * under.
95
+ * panel 24px blur, 97% tint. A floating menu that must stay legible over
96
+ * anything — including over other glass.
97
+ *
98
+ * `isolate` is on by default because these surfaces usually contain a sliding
99
+ * highlight at -z-10 (`glass/motion/guide.md`). Without it, -z-10
100
+ * would drop the highlight behind the container's own tint instead of just
101
+ * behind the icons.
102
+ */
103
+ export function GlassSurface({
104
+ tier = "panel",
105
+ as: Tag = "div",
106
+ isolate = true,
107
+ className = "",
108
+ children,
109
+ }: {
110
+ tier?: GlassTier;
111
+ as?: "div" | "ul" | "nav" | "aside";
112
+ isolate?: boolean;
113
+ className?: string;
114
+ children: ReactNode;
115
+ }) {
116
+ const material = tier === "nav" ? "glass-nav" : "glass-panel";
117
+
118
+ return (
119
+ <Tag
120
+ className={`${material} ${HAIRLINE} ${SHADOW} relative ${
121
+ isolate ? "isolate" : ""
122
+ } rounded-2xl ${className}`}
123
+ >
124
+ {children}
125
+ </Tag>
126
+ );
127
+ }
128
+
129
+ /* ─────────────────────────────────────────────────────────────────────────
130
+ THE WRAPPER / SURFACE SPLIT — the one structural rule to internalise.
131
+
132
+ A transform on an ancestor weakens or breaks backdrop-filter in its
133
+ subtree: the blur samples the wrong region, or nothing. So an animating
134
+ menu must be TWO elements — the wrapper animates, the surface blurs, and
135
+ they are never the same node:
136
+
137
+ <DropMenu open={open}> // animates scale + y. No filter.
138
+ <GlassSurface tier="panel"> // filters. Never transformed.
139
+ …
140
+ </GlassSurface>
141
+ </DropMenu>
142
+
143
+ Where you can't split them (a surface that must animate itself), fall back
144
+ to glass-panel's 97% tint and treat the blur as decoration.
145
+
146
+ This is also a performance win, not just a correctness one: the wrapper
147
+ animates on the compositor (transform and opacity only), and the glass
148
+ child's filter isn't being recomputed against a moving containing block
149
+ every frame.
150
+
151
+ Same root cause, second symptom: a transform also makes an element the
152
+ containing block for position: fixed descendants. In the reference
153
+ implementation the fixed nav strip deliberately carries NO transform, so
154
+ the mobile sheet's w-screen full-bleed overlay can span the viewport
155
+ instead of being trapped inside the strip's bounds.
156
+
157
+ When something filtered or positioned behaves inexplicably, check every
158
+ ancestor for: transform, filter, perspective, contain, will-change.
159
+
160
+ ── And close menus by UNMOUNTING them ─────────────────────────────────
161
+ A backdrop-filter element still pays its full filter pass when it's
162
+ transparent — visibility is decided at composite time, after the filter has
163
+ already run. opacity: 0 and visibility: hidden do NOT save you; unmounting
164
+ (or display: none) is the only thing that does.
165
+
166
+ <AnimatePresence>
167
+ {open && <m.div …>{children}</m.div>}
168
+ </AnimatePresence>
169
+
170
+ Which also keeps a closed menu out of the tab order.
171
+ ───────────────────────────────────────────────────────────────────────── */