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.
- package/.claude-plugin/marketplace.json +18 -0
- package/LICENSE +21 -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/plugin.json +22 -0
- package/plugins/keyline/skills/keyline/SKILL.md +47 -0
- package/plugins/keyline/skills/keyline/commands/audit.md +30 -0
- package/plugins/keyline/skills/keyline/commands/color.md +7 -0
- package/plugins/keyline/skills/keyline/commands/critique.md +20 -0
- package/plugins/keyline/skills/keyline/commands/depth.md +7 -0
- package/plugins/keyline/skills/keyline/commands/init.md +16 -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 +19 -0
- package/plugins/keyline/skills/keyline/commands/sync.md +15 -0
- package/plugins/keyline/skills/keyline/commands/type.md +7 -0
- package/plugins/keyline/skills/keyline/commands/ux-laws.md +26 -0
- package/plugins/keyline/skills/keyline/domains/color/deriving-a-ramp.md +144 -0
- package/plugins/keyline/skills/keyline/domains/color/guide.md +207 -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 +185 -0
- package/plugins/keyline/skills/keyline/domains/depth/fallbacks-and-performance.md +132 -0
- package/plugins/keyline/skills/keyline/domains/depth/guide.md +226 -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 +259 -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 +198 -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 +174 -0
- package/plugins/keyline/skills/keyline/domains/patterns/iteration.md +70 -0
- package/plugins/keyline/skills/keyline/domains/type/display-tier.md +108 -0
- package/plugins/keyline/skills/keyline/domains/type/guide.md +227 -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 +87 -0
- package/plugins/keyline/skills/keyline/glass/color/assets/color-tokens.css +299 -0
- package/plugins/keyline/skills/keyline/glass/color/assets/theme.ts +119 -0
- package/plugins/keyline/skills/keyline/glass/color/guide.md +195 -0
- package/plugins/keyline/skills/keyline/glass/color/intent-ramp.md +136 -0
- package/plugins/keyline/skills/keyline/glass/color/theming.md +106 -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 +147 -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 +201 -0
- package/plugins/keyline/skills/keyline/glass/layout/guide.md +259 -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 +103 -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 +255 -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 +195 -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 +416 -0
- package/plugins/keyline/skills/keyline/glass/type/guide.md +220 -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 +297 -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 +97 -0
- package/plugins/keyline/skills/keyline/system/presets.md +58 -0
- package/plugins/keyline/skills/keyline/system/roles.md +150 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Prose and emphasis
|
|
2
|
+
|
|
3
|
+
The reading half: blocks sharing the body size and what separates them, emphasis per preset, and imported prose. Spacing values between blocks belong to the layout guide; this file names only the ratios that are type signals.
|
|
4
|
+
|
|
5
|
+
## The container sets the register
|
|
6
|
+
|
|
7
|
+
One wrapper carries the reading size, leading and tier; every block inside overrides only what differs.
|
|
8
|
+
|
|
9
|
+
```css
|
|
10
|
+
.prose {
|
|
11
|
+
font-size: var(<type.body size>);
|
|
12
|
+
line-height: var(<type.body leading>);
|
|
13
|
+
color: var(<text.primary>);
|
|
14
|
+
max-inline-size: var(<measure.reading>);
|
|
15
|
+
}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
A paragraph setting its own size inside `.prose` is a finding: it has opted out of the register, and the next restyle will miss it.
|
|
19
|
+
|
|
20
|
+
## Reading blocks, and the axis each owns
|
|
21
|
+
|
|
22
|
+
Each block at body size needs a signal the others don't use (law 4). A typical set:
|
|
23
|
+
|
|
24
|
+
| Block | Role | Its signal | What it must not do |
|
|
25
|
+
|---|---|---|---|
|
|
26
|
+
| Body | `type.body` | none — the baseline | set its own size |
|
|
27
|
+
| Lead | `type.lead` | more open leading, more space below | be larger than the heading above it |
|
|
28
|
+
| Keyline | project role under `type.body` | `weight.strong`, symmetric space | appear twice in one section |
|
|
29
|
+
| Dek | `type.supporting` | `text.secondary`, tighter leading | read as the first paragraph |
|
|
30
|
+
| Pull quote | project role between `type.body` and `type.heading` | a solid bar and a voice — the reading family's italic or the display weight | rise above `type.heading` (law 3), or stay at body size, where it is a blockquote |
|
|
31
|
+
| Note | project role under `type.meta` | one size down, `text.tertiary`, a lighter rule | share the quote's rule weight |
|
|
32
|
+
| Caption | `type.meta` | one size down, `text.tertiary` | sit at body size, competing with the next paragraph |
|
|
33
|
+
|
|
34
|
+
**Dek and lead are the tightest pairing** — often adjacent, told apart only by tier and leading. Check them first when a page looks undifferentiated.
|
|
35
|
+
|
|
36
|
+
**Two indented rules on one page must be unmistakable.** If quote and note both carry a left rule, the rules differ in weight (a heavy bar against a hairline) *and* their type in size and tier. The Glass quote's bar is three times the note's rule; the note is a step smaller and two tiers quieter.
|
|
37
|
+
|
|
38
|
+
**Heading space is asymmetric, and the ratio is the type signal**: about 3:1 above to below, so a heading belongs to what it introduces; equal space floats it between sections. The keyline is the deliberate exception, symmetric because it belongs to neither neighbour.
|
|
39
|
+
|
|
40
|
+
## Two block types worth having
|
|
41
|
+
|
|
42
|
+
Both came from one Glass diagnosis: a hundred-odd paragraphs and nothing else. Not too much content — **one texture**.
|
|
43
|
+
|
|
44
|
+
**The keyline — the one sentence a section is for.** Promote a sentence out of its paragraph and delete it there; that discipline is the point, so a section that gains a keyline gets shorter. One per section, never two.
|
|
45
|
+
|
|
46
|
+
**The note — an aside outside the argument**: a caveat, a scope limit, provenance. With somewhere to go, qualifications leave the main text.
|
|
47
|
+
|
|
48
|
+
Enforce the discipline at build time: fail on a blank keyline or note, two keylines in one section, or a keyline grown back to paragraph length.
|
|
49
|
+
|
|
50
|
+
## Quotes
|
|
51
|
+
|
|
52
|
+
A pull quote may be set up to `type.heading`'s size, never above it (law 3), separated by the bar and a voice — the reading family's italic or the display weight. A quote at body size is a blockquote, not a pull quote: there the bar is the whole signal, so it has to be a real one:
|
|
53
|
+
|
|
54
|
+
```css
|
|
55
|
+
.quote { display: flex; gap: var(<space.tight>); }
|
|
56
|
+
.quote::before {
|
|
57
|
+
content: "";
|
|
58
|
+
flex: none;
|
|
59
|
+
inline-size: 3px; /* rule.emphasis */
|
|
60
|
+
border-radius: 999px;
|
|
61
|
+
background: var(<mark>);
|
|
62
|
+
}
|
|
63
|
+
.quote cite { display: block; font-style: normal; font-size: var(<type.meta size>); color: var(<text.tertiary>); }
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
A flex child, not `border-inline-start`: a border can't be rounded alone, and a squared bar reads as a table rule. With no vertical margin, flex's stretch runs it the full height, citation included. Wherever italic is off, `cite` needs `font-style: normal`: `<cite>` and `<blockquote>` carry user-agent italics a global `em` rule doesn't reach.
|
|
67
|
+
|
|
68
|
+
## Lists
|
|
69
|
+
|
|
70
|
+
Markers sit a tier below the text: `li::marker { color: var(<mark>) }`; at text colour, bullets pull the eye down the left edge. Ordered markers take `font-variant-numeric: tabular-nums` so item 10 doesn't shift its text. Items sit closer to each other than to what follows the list.
|
|
71
|
+
|
|
72
|
+
## Emphasis, per preset
|
|
73
|
+
|
|
74
|
+
Keep the semantic tags — assistive technology announces them — and map their look onto an existing axis.
|
|
75
|
+
|
|
76
|
+
| | editorial | product | expressive |
|
|
77
|
+
|---|---|---|---|
|
|
78
|
+
| `<strong>` | `weight.strong` + `text.primary` | `weight.strong` | `weight.strong` |
|
|
79
|
+
| `<em>` | true italic, if the family ships one; else `text.secondary`, no slant | `text.secondary`, no slant | true italic if the family ships one |
|
|
80
|
+
| `<cite>`, titles of works | true italic, or the `<em>` mapping | the `<em>` mapping | true italic |
|
|
81
|
+
| `<b>`, `<i>` | same as `strong`, `em` | same | same |
|
|
82
|
+
|
|
83
|
+
`<em>` on a quieter tier gives two-directional emphasis — strong advances, em recedes — that a bold-only system can't, and suits interfaces without a usable italic. Where the family has a real text italic, use it: editorial without italic loses the convention readers rely on for titles and terms.
|
|
84
|
+
|
|
85
|
+
Three rules hold whatever the mapping:
|
|
86
|
+
|
|
87
|
+
```css
|
|
88
|
+
html { font-synthesis: none; } /* a missing style is a gap you see, not a smear you ship */
|
|
89
|
+
strong, b { font-weight: var(<weight.strong>); } /* not the UA's 700 */
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
- **Never heavier than a heading.** `<strong>` at 700 under a 500 heading outranks it (law 3, inside a line).
|
|
93
|
+
- **Short.** Longer than a few words, it wants to be its own block (law 9).
|
|
94
|
+
- **Weight only on states that don't follow the pointer.** Emphasis that changes on hover shifts the line.
|
|
95
|
+
|
|
96
|
+
## Prose you can't put classes on
|
|
97
|
+
|
|
98
|
+
Markdown, a CMS rich-text field, a README: a string with nothing to hang a class on. Style by element inside one scoped class, each element mapped onto an existing role:
|
|
99
|
+
|
|
100
|
+
```css
|
|
101
|
+
.prose-imported { font-size: var(<type.body size>); line-height: var(<type.body leading>); }
|
|
102
|
+
.prose-imported > :first-child { margin-block-start: 0; }
|
|
103
|
+
.prose-imported h1,
|
|
104
|
+
.prose-imported h2 { font-size: var(<type.heading size>); line-height: var(<type.heading leading>);
|
|
105
|
+
letter-spacing: var(<type.heading tracking>); font-weight: var(<weight.strong>); }
|
|
106
|
+
.prose-imported h3 { font-size: var(<type.subheading size>); font-weight: var(<weight.strong>); }
|
|
107
|
+
.prose-imported h4 { font-size: var(<type.meta size>); font-weight: var(<weight.strong>);
|
|
108
|
+
color: var(<text.secondary>); }
|
|
109
|
+
.prose-imported code { font-family: var(<family.mono>); font-size: 0.875em; }
|
|
110
|
+
.prose-imported pre,
|
|
111
|
+
.prose-imported .table-scroll { overflow-x: auto; }
|
|
112
|
+
.prose-imported table { font-size: var(<type.meta size>); font-variant-numeric: tabular-nums; }
|
|
113
|
+
.prose-imported li::marker { color: var(<mark>); font-variant-numeric: tabular-nums; }
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
What this must get right that authored prose doesn't (add a role only where the document needs something your pages don't):
|
|
117
|
+
|
|
118
|
+
- **A document `h1` is not the page's title.** Map it to `type.heading`, or the imported text outranks its page.
|
|
119
|
+
- **The first child's top margin goes**, or an opening heading pushes its space above nothing.
|
|
120
|
+
- **Three heading levels, not two.** Documents nest deeper than designed pages: `h3` takes `type.subheading`, and `h4` a real role, not a browser default.
|
|
121
|
+
- **Code blocks and tables scroll inside their own box**, being Markdown's two unbounded things. Wrap each `<table>` in a scrolling element at render time; the page never scrolls sideways.
|
|
122
|
+
- **Your own markers.** A UA disc at body size is usually the heaviest thing on the page.
|
|
123
|
+
- **Only mapped elements.** Unmapped ones — `<dl>`, `<kbd>`, `<sup>` — fall through to the browser. List what the source can emit and map each, or strip it at render.
|
|
124
|
+
|
|
125
|
+
**Imported prose is a guest in the type system, not a second one.** A role added for it — only where the document needs something the pages don't — is a project role with a `DESIGN.md` row like any other.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Type audit and sweeps
|
|
2
|
+
|
|
3
|
+
The type guide's *Auditing existing code* and the detector find values in source. This file checks what renders, and sweeps a product after a type decision so no instance is left on the old value.
|
|
4
|
+
|
|
5
|
+
## Why source isn't enough
|
|
6
|
+
|
|
7
|
+
The same class renders differently through inheritance, a component's own default and a variant prop, and a merge utility can drop a class without a trace. A raw `text-sm` doesn't say which role it was meant to be, so grep can count it but not judge it. Partial updates — the list rows on the new weight, the board cards still on the old — are the commonest type bug, and only the rendered page shows them.
|
|
8
|
+
|
|
9
|
+
## The rendered inventory
|
|
10
|
+
|
|
11
|
+
Every route has to be reachable: where pages need a backend, run the real app on mock data. Then, per route, list every distinct text style and how often it appears:
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
const seen = new Map();
|
|
15
|
+
for (const el of document.querySelectorAll("body *")) {
|
|
16
|
+
const own = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
|
|
17
|
+
if (!own || !el.getClientRects().length) continue;
|
|
18
|
+
const s = getComputedStyle(el);
|
|
19
|
+
const key = [s.fontSize, s.lineHeight, s.fontWeight, s.letterSpacing, s.textTransform].join(" · ");
|
|
20
|
+
const hit = seen.get(key) ?? { count: 0, sample: el.textContent.trim().slice(0, 40) };
|
|
21
|
+
hit.count++; seen.set(key, hit);
|
|
22
|
+
}
|
|
23
|
+
console.table([...seen].map(([style, v]) => ({ style, ...v })).sort((a, b) => b.count - a.count));
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Read it against the `## Type` table:
|
|
27
|
+
|
|
28
|
+
- **A style with no row** is a stray or an unnamed role; say which.
|
|
29
|
+
- **A style used once or twice** is usually a component that missed the last change.
|
|
30
|
+
- **Two styles for one element across routes** — a header title at 13/500 here and 14/600 there — is law 2, by path.
|
|
31
|
+
- **A role row in two weights in one set** — some card titles lifted, some not — is a partial update.
|
|
32
|
+
|
|
33
|
+
## Icons beside text
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
for (const svg of document.querySelectorAll("svg")) {
|
|
37
|
+
const host = svg.parentElement; if (!host?.textContent.trim()) continue;
|
|
38
|
+
const text = parseFloat(getComputedStyle(host).fontSize), icon = svg.getBoundingClientRect().height;
|
|
39
|
+
if (icon / text > 1.15 || icon / text < 0.9) console.log(Math.round(icon), "px icon beside", text, "px text", host);
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Then compare stroke widths across siblings in one strip: two buttons whose icons differ in size or stroke are a finding, fixed toward the better one.
|
|
44
|
+
|
|
45
|
+
## Inputs on touch
|
|
46
|
+
|
|
47
|
+
At a phone width, any `input`, `textarea` or `select` whose computed `font-size` is under 16px makes iOS Safari zoom on focus. Check one form per template.
|
|
48
|
+
|
|
49
|
+
## Migrating raw sizes to roles
|
|
50
|
+
|
|
51
|
+
Migrate by what the element is doing, not by its current value: two `text-sm` may be two roles — a nav row is `type.ui` and should have been 13, a description is `type.body`. Against the product ladder in `ladders.md`:
|
|
52
|
+
|
|
53
|
+
| Raw value | Usually |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `text-xs`, 12px | `type.label` or `type.meta`, told apart by tier |
|
|
56
|
+
| `text-[13px]` | `type.ui`, or `type.data` with tabular figures |
|
|
57
|
+
| `text-sm`, 14px | `type.body`, `type.subheading`, or a `type.ui` that drifted up |
|
|
58
|
+
| `text-base`, 16px | `type.heading`, or an input on touch |
|
|
59
|
+
|
|
60
|
+
Every instance moved to a role token is one less to find next time; a sweep that leaves raw values behind has to be repeated by hand.
|
|
61
|
+
|
|
62
|
+
## The `tailwind-merge` trap
|
|
63
|
+
|
|
64
|
+
`tailwind-merge` resolves conflicts by class group, and `text-*` is shared by size and colour. A name it doesn't know as a size — `text-label` — it files as a colour, so `cn("text-label", "text-muted-foreground")` keeps only the last and the size vanishes, with no error and no warning. Weights have the same trap: `font-strong` is filed as a family and loses to `font-sans`. Register the role names:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { extendTailwindMerge } from "tailwind-merge";
|
|
68
|
+
|
|
69
|
+
export const twMerge = extendTailwindMerge({
|
|
70
|
+
extend: {
|
|
71
|
+
classGroups: {
|
|
72
|
+
"font-size": [{ text: ["title", "heading", "body", "ui", "label", "meta", "code"] }],
|
|
73
|
+
"font-weight": [{ font: ["regular", "strong"] }],
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Use the project's names, and keep the list beside the theme tokens so a new role is registered in the same edit. To check, pass a role size and a text colour through `cn()` in one component and read the computed `font-size`.
|
|
80
|
+
|
|
81
|
+
## The sweep
|
|
82
|
+
|
|
83
|
+
The sweep is `domains/patterns/iteration.md`'s. For a type role, two steps of its own:
|
|
84
|
+
|
|
85
|
+
- **Grep for the old value** in the role's raw forms (`font-semibold`, `text-[14px]`, `font-weight: 600`) and decide each hit; raw sizes are where a partial update hides.
|
|
86
|
+
- **Check the generated strings** for case while you're there (patterns law 1): field names, column headers, tool labels, dialog titles and toasts that arrive in Title Case from a schema or a config.
|
|
87
|
+
`rg -n "(label|title|header|name):\s*['\"][A-Z][a-z]+ [A-Z][a-z]+" src`
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/* ─────────────────────────────────────────────────────────────────────────
|
|
2
|
+
Colour and theming — the complete drop-in
|
|
3
|
+
Tailwind v4. Both ramps, both themes, glass tints, focus ring.
|
|
4
|
+
───────────────────────────────────────────────────────────────────────── */
|
|
5
|
+
|
|
6
|
+
@import "tailwindcss";
|
|
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. */
|
|
14
|
+
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
|
15
|
+
|
|
16
|
+
@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. */
|
|
41
|
+
--color-gray-100: #f2f2f2;
|
|
42
|
+
--color-gray-200: #ebebeb;
|
|
43
|
+
--color-gray-300: #e6e6e6;
|
|
44
|
+
--color-gray-400: #eaeaea;
|
|
45
|
+
--color-gray-500: #c9c9c9;
|
|
46
|
+
--color-gray-600: #a8a8a8;
|
|
47
|
+
--color-gray-700: #8f8f8f;
|
|
48
|
+
--color-gray-800: #7d7d7d;
|
|
49
|
+
--color-gray-900: #4d4d4d;
|
|
50
|
+
--color-gray-1000: #171717;
|
|
51
|
+
|
|
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. */
|
|
57
|
+
--color-gray-alpha-100: #0000000d;
|
|
58
|
+
--color-gray-alpha-200: #00000015;
|
|
59
|
+
--color-gray-alpha-300: #0000001a;
|
|
60
|
+
--color-gray-alpha-400: #00000014;
|
|
61
|
+
--color-gray-alpha-500: #00000036;
|
|
62
|
+
--color-gray-alpha-600: #0000003d;
|
|
63
|
+
|
|
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. */
|
|
68
|
+
--color-background-100: #ffffff;
|
|
69
|
+
--color-background-200: #fafafa;
|
|
70
|
+
|
|
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. */
|
|
91
|
+
--color-muted: #5e5e5e;
|
|
92
|
+
--color-quiet: #696969;
|
|
93
|
+
--color-faint: #757575;
|
|
94
|
+
|
|
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. */
|
|
99
|
+
--color-blue-700: #006bff;
|
|
100
|
+
|
|
101
|
+
/* Elevated surface for sliding highlights and active pills — white here,
|
|
102
|
+
a raised grey in dark (overridden below). */
|
|
103
|
+
--color-highlight: #ffffff;
|
|
104
|
+
}
|
|
105
|
+
|
|
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. */
|
|
114
|
+
:root {
|
|
115
|
+
color-scheme: light;
|
|
116
|
+
--glass-nav-bg: rgba(242, 242, 242, 0.72);
|
|
117
|
+
--glass-panel-bg: rgba(243, 243, 243, 0.97);
|
|
118
|
+
--folder-glass-bg: linear-gradient(to bottom, rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0.34));
|
|
119
|
+
|
|
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. */
|
|
130
|
+
--sv-edge-top: #16276b; /* deep navy at the top of the 1px stroke */
|
|
131
|
+
--sv-edge-mid: #2f5ac4;
|
|
132
|
+
--sv-edge-bottom: #6d97ea; /* running to a lighter blue at the bottom */
|
|
133
|
+
--sv-fill-top: #f4f6fc;
|
|
134
|
+
--sv-fill-bottom: #eef2fa;
|
|
135
|
+
--sv-accent: #2f5ac4; /* the MARK, and only the mark */
|
|
136
|
+
--sv-sheen-hi: #8fb2f2;
|
|
137
|
+
|
|
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`). */
|
|
141
|
+
--sv-glow:
|
|
142
|
+
0 1px 2px rgba(22, 39, 107, 0.05),
|
|
143
|
+
-10px 0 20px -18px rgba(47, 90, 196, 0.38),
|
|
144
|
+
10px 0 20px -18px rgba(47, 90, 196, 0.38),
|
|
145
|
+
0 10px 24px -16px rgba(47, 90, 196, 0.3);
|
|
146
|
+
--sv-glow-lift:
|
|
147
|
+
0 1px 2px rgba(22, 39, 107, 0.08),
|
|
148
|
+
-14px 0 28px -18px rgba(47, 90, 196, 0.75),
|
|
149
|
+
14px 0 28px -18px rgba(47, 90, 196, 0.75),
|
|
150
|
+
0 14px 34px -16px rgba(47, 90, 196, 0.6);
|
|
151
|
+
}
|
|
152
|
+
|
|
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. */
|
|
162
|
+
:root[data-theme="dark"] {
|
|
163
|
+
color-scheme: dark;
|
|
164
|
+
|
|
165
|
+
--color-gray-100: #1a1a1a;
|
|
166
|
+
--color-gray-200: #212121;
|
|
167
|
+
--color-gray-300: #282828;
|
|
168
|
+
--color-gray-400: #2e2e2e;
|
|
169
|
+
--color-gray-500: #707070;
|
|
170
|
+
--color-gray-600: #7d7d7d;
|
|
171
|
+
--color-gray-700: #8f8f8f;
|
|
172
|
+
--color-gray-800: #a0a0a0;
|
|
173
|
+
--color-gray-900: #c4c4c4;
|
|
174
|
+
--color-gray-1000: #ededed;
|
|
175
|
+
|
|
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. */
|
|
180
|
+
--color-gray-alpha-100: #ffffff0d;
|
|
181
|
+
--color-gray-alpha-200: #ffffff15;
|
|
182
|
+
--color-gray-alpha-300: #ffffff1f;
|
|
183
|
+
--color-gray-alpha-400: #ffffff17;
|
|
184
|
+
--color-gray-alpha-500: #ffffff36;
|
|
185
|
+
--color-gray-alpha-600: #ffffff45;
|
|
186
|
+
|
|
187
|
+
/* Near-black, not #000: pure black gives shadows nothing to sit on and
|
|
188
|
+
produces harsh contrast with light text. */
|
|
189
|
+
--color-background-100: #0b0b0b;
|
|
190
|
+
--color-background-200: #161616;
|
|
191
|
+
|
|
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. */
|
|
197
|
+
--color-muted: #a0a0a0;
|
|
198
|
+
--color-quiet: #878787;
|
|
199
|
+
--color-faint: #7a7a7a;
|
|
200
|
+
|
|
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. */
|
|
203
|
+
--color-blue-700: #3b82f6;
|
|
204
|
+
|
|
205
|
+
--color-highlight: #2c2c2c;
|
|
206
|
+
|
|
207
|
+
--glass-nav-bg: rgba(26, 26, 26, 0.72);
|
|
208
|
+
--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. */
|
|
211
|
+
--folder-glass-bg: linear-gradient(to bottom, rgba(48, 48, 48, 0.5), rgba(30, 30, 30, 0.34));
|
|
212
|
+
|
|
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. */
|
|
224
|
+
--sv-edge-top: #24407f;
|
|
225
|
+
--sv-edge-mid: #3f6ed0;
|
|
226
|
+
--sv-edge-bottom: #86adf7;
|
|
227
|
+
--sv-fill-top: #171d2a;
|
|
228
|
+
--sv-fill-bottom: #1b2230;
|
|
229
|
+
--sv-accent: #86adf7;
|
|
230
|
+
--sv-sheen-hi: #dbe8ff;
|
|
231
|
+
--sv-glow:
|
|
232
|
+
0 1px 2px rgba(0, 0, 0, 0.5),
|
|
233
|
+
-10px 0 22px -18px rgba(90, 140, 240, 0.45),
|
|
234
|
+
10px 0 22px -18px rgba(90, 140, 240, 0.45),
|
|
235
|
+
0 10px 26px -16px rgba(63, 110, 208, 0.35);
|
|
236
|
+
--sv-glow-lift:
|
|
237
|
+
0 1px 2px rgba(0, 0, 0, 0.55),
|
|
238
|
+
-14px 0 30px -18px rgba(120, 165, 250, 0.9),
|
|
239
|
+
14px 0 30px -18px rgba(120, 165, 250, 0.9),
|
|
240
|
+
0 14px 36px -16px rgba(90, 140, 240, 0.7);
|
|
241
|
+
}
|
|
242
|
+
|
|
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. */
|
|
246
|
+
@media (prefers-reduced-transparency: reduce) {
|
|
247
|
+
:root,
|
|
248
|
+
:root[data-theme="dark"] {
|
|
249
|
+
--glass-nav-bg: var(--color-gray-100);
|
|
250
|
+
--glass-panel-bg: var(--color-gray-100);
|
|
251
|
+
--folder-glass-bg: var(--color-background-100);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
@layer base {
|
|
256
|
+
body {
|
|
257
|
+
background-color: var(--color-background-100);
|
|
258
|
+
color: var(--color-gray-1000);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/* Inverted selection — the primary text colour becomes the surface. */
|
|
262
|
+
::selection {
|
|
263
|
+
background: var(--color-gray-1000);
|
|
264
|
+
color: var(--color-background-100);
|
|
265
|
+
}
|
|
266
|
+
|
|
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. */
|
|
271
|
+
:focus-visible {
|
|
272
|
+
outline: 2px solid var(--color-blue-700);
|
|
273
|
+
outline-offset: 2px;
|
|
274
|
+
border-radius: 2px;
|
|
275
|
+
}
|
|
276
|
+
a:focus-visible {
|
|
277
|
+
outline-offset: 3px;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
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 is not here. A shadow that changes STRUCTURE between themes can't
|
|
295
|
+
be a single token, so it lives in one class with its dark twin beside it:
|
|
296
|
+
.dock-shadow, at the foot of glass/depth/assets/glass.css. Not a JS
|
|
297
|
+
constant — the reference implementation started with one and moved to the
|
|
298
|
+
class (glass/depth/depth-cues.md, §2).
|
|
299
|
+
───────────────────────────────────────────────────────────────────────── */
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme persistence. Two explicit modes, Light or Dark; Light is the default.
|
|
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
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type ThemeChoice = "light" | "dark";
|
|
19
|
+
|
|
20
|
+
const KEY = "theme";
|
|
21
|
+
|
|
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
|
+
*/
|
|
31
|
+
export function getThemeChoice(): ThemeChoice {
|
|
32
|
+
try {
|
|
33
|
+
if (localStorage.getItem(KEY) === "dark") return "dark";
|
|
34
|
+
} catch {
|
|
35
|
+
/* localStorage unavailable */
|
|
36
|
+
}
|
|
37
|
+
return "light";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function applyTheme(choice: ThemeChoice): void {
|
|
41
|
+
document.documentElement.dataset.theme = choice;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function setThemeChoice(choice: ThemeChoice): void {
|
|
45
|
+
try {
|
|
46
|
+
localStorage.setItem(KEY, choice);
|
|
47
|
+
} catch {
|
|
48
|
+
/* preference just won't persist */
|
|
49
|
+
}
|
|
50
|
+
|
|
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. */
|
|
57
|
+
const root = document.documentElement;
|
|
58
|
+
root.classList.add("theme-transition");
|
|
59
|
+
applyTheme(choice);
|
|
60
|
+
window.setTimeout(() => root.classList.remove("theme-transition"), 440);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* ─────────────────────────────────────────────────────────────────────────
|
|
64
|
+
The no-flash head script. Copy this into index.html, inline, in <head>,
|
|
65
|
+
BEFORE the stylesheet. It duplicates the read above on purpose.
|
|
66
|
+
|
|
67
|
+
<script>
|
|
68
|
+
(function () {
|
|
69
|
+
try {
|
|
70
|
+
document.documentElement.dataset.theme =
|
|
71
|
+
localStorage.getItem("theme") === "dark" ? "dark" : "light";
|
|
72
|
+
} catch (e) {}
|
|
73
|
+
})();
|
|
74
|
+
</script>
|
|
75
|
+
|
|
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.
|
|
119
|
+
───────────────────────────────────────────────────────────────────────── */
|