trykeyline 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/.claude-plugin/marketplace.json +18 -0
  2. package/LICENSE +37 -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/skills/keyline/SKILL.md +57 -0
  8. package/plugins/keyline/skills/keyline/commands/audit.md +25 -0
  9. package/plugins/keyline/skills/keyline/commands/color.md +7 -0
  10. package/plugins/keyline/skills/keyline/commands/critique.md +43 -0
  11. package/plugins/keyline/skills/keyline/commands/depth.md +7 -0
  12. package/plugins/keyline/skills/keyline/commands/init.md +15 -0
  13. package/plugins/keyline/skills/keyline/commands/layout.md +7 -0
  14. package/plugins/keyline/skills/keyline/commands/motion.md +7 -0
  15. package/plugins/keyline/skills/keyline/commands/patterns.md +7 -0
  16. package/plugins/keyline/skills/keyline/commands/polish.md +19 -0
  17. package/plugins/keyline/skills/keyline/commands/refine.md +29 -0
  18. package/plugins/keyline/skills/keyline/commands/sync.md +17 -0
  19. package/plugins/keyline/skills/keyline/commands/type.md +7 -0
  20. package/plugins/keyline/skills/keyline/domains/color/deriving-a-ramp.md +144 -0
  21. package/plugins/keyline/skills/keyline/domains/color/guide.md +243 -0
  22. package/plugins/keyline/skills/keyline/domains/color/status-and-exceptions.md +174 -0
  23. package/plugins/keyline/skills/keyline/domains/color/theming-mechanics.md +121 -0
  24. package/plugins/keyline/skills/keyline/domains/depth/elevation-recipes.md +191 -0
  25. package/plugins/keyline/skills/keyline/domains/depth/fallbacks-and-performance.md +132 -0
  26. package/plugins/keyline/skills/keyline/domains/depth/guide.md +255 -0
  27. package/plugins/keyline/skills/keyline/domains/depth/translucent-materials.md +159 -0
  28. package/plugins/keyline/skills/keyline/domains/layout/forks-gating-and-disclosures.md +201 -0
  29. package/plugins/keyline/skills/keyline/domains/layout/guide.md +284 -0
  30. package/plugins/keyline/skills/keyline/domains/layout/measure-and-rhythm.md +193 -0
  31. package/plugins/keyline/skills/keyline/domains/layout/radius-lines-and-layers.md +122 -0
  32. package/plugins/keyline/skills/keyline/domains/motion/guards.md +162 -0
  33. package/plugins/keyline/skills/keyline/domains/motion/guide.md +234 -0
  34. package/plugins/keyline/skills/keyline/domains/motion/ladder-and-curves.md +139 -0
  35. package/plugins/keyline/skills/keyline/domains/motion/patterns.md +196 -0
  36. package/plugins/keyline/skills/keyline/domains/motion/performance.md +121 -0
  37. package/plugins/keyline/skills/keyline/domains/patterns/actions-and-forms.md +204 -0
  38. package/plugins/keyline/skills/keyline/domains/patterns/agents-and-live-states.md +111 -0
  39. package/plugins/keyline/skills/keyline/domains/patterns/collections-and-dashboards.md +131 -0
  40. package/plugins/keyline/skills/keyline/domains/patterns/copy.md +123 -0
  41. package/plugins/keyline/skills/keyline/domains/patterns/guide.md +216 -0
  42. package/plugins/keyline/skills/keyline/domains/patterns/iteration.md +78 -0
  43. package/plugins/keyline/skills/keyline/domains/type/display-tier.md +108 -0
  44. package/plugins/keyline/skills/keyline/domains/type/guide.md +251 -0
  45. package/plugins/keyline/skills/keyline/domains/type/ladders.md +169 -0
  46. package/plugins/keyline/skills/keyline/domains/type/numerals-and-loading.md +107 -0
  47. package/plugins/keyline/skills/keyline/domains/type/prose-and-emphasis.md +125 -0
  48. package/plugins/keyline/skills/keyline/domains/type/type-audit.md +90 -0
  49. package/plugins/keyline/skills/keyline/glass/color/assets/color-tokens.css +319 -0
  50. package/plugins/keyline/skills/keyline/glass/color/assets/theme.ts +119 -0
  51. package/plugins/keyline/skills/keyline/glass/color/guide.md +222 -0
  52. package/plugins/keyline/skills/keyline/glass/color/intent-ramp.md +136 -0
  53. package/plugins/keyline/skills/keyline/glass/color/theming.md +112 -0
  54. package/plugins/keyline/skills/keyline/glass/depth/assets/GlassPanel.tsx +171 -0
  55. package/plugins/keyline/skills/keyline/glass/depth/assets/glass.css +322 -0
  56. package/plugins/keyline/skills/keyline/glass/depth/blur-and-tint.md +50 -0
  57. package/plugins/keyline/skills/keyline/glass/depth/depth-cues.md +87 -0
  58. package/plugins/keyline/skills/keyline/glass/depth/guide.md +216 -0
  59. package/plugins/keyline/skills/keyline/glass/depth/performance-and-fallbacks.md +37 -0
  60. package/plugins/keyline/skills/keyline/glass/layout/adaptive-presentations.md +231 -0
  61. package/plugins/keyline/skills/keyline/glass/layout/assets/SectionNav.tsx +244 -0
  62. package/plugins/keyline/skills/keyline/glass/layout/assets/layout-tokens.css +199 -0
  63. package/plugins/keyline/skills/keyline/glass/layout/guide.md +258 -0
  64. package/plugins/keyline/skills/keyline/glass/layout/measure-and-rhythm.md +134 -0
  65. package/plugins/keyline/skills/keyline/glass/layout/radius-and-elevation.md +102 -0
  66. package/plugins/keyline/skills/keyline/glass/motion/assets/DropMenu.tsx +106 -0
  67. package/plugins/keyline/skills/keyline/glass/motion/assets/motion-presets.ts +210 -0
  68. package/plugins/keyline/skills/keyline/glass/motion/assets/motion-tokens.css +176 -0
  69. package/plugins/keyline/skills/keyline/glass/motion/assets/useReveal.ts +65 -0
  70. package/plugins/keyline/skills/keyline/glass/motion/assets/useScrollTick.ts +183 -0
  71. package/plugins/keyline/skills/keyline/glass/motion/easing-and-duration.md +108 -0
  72. package/plugins/keyline/skills/keyline/glass/motion/guide.md +286 -0
  73. package/plugins/keyline/skills/keyline/glass/motion/reduced-motion.md +81 -0
  74. package/plugins/keyline/skills/keyline/glass/motion/scroll-performance.md +58 -0
  75. package/plugins/keyline/skills/keyline/glass/motion/shared-element.md +161 -0
  76. package/plugins/keyline/skills/keyline/glass/motion/sound.md +192 -0
  77. package/plugins/keyline/skills/keyline/glass/motion/springs.md +51 -0
  78. package/plugins/keyline/skills/keyline/glass/type/assets/prose.tsx +268 -0
  79. package/plugins/keyline/skills/keyline/glass/type/assets/type-tokens.css +414 -0
  80. package/plugins/keyline/skills/keyline/glass/type/guide.md +222 -0
  81. package/plugins/keyline/skills/keyline/glass/type/long-form-prose.md +151 -0
  82. package/plugins/keyline/skills/keyline/glass/type/scale.md +69 -0
  83. package/plugins/keyline/skills/keyline/glass/type/weight-and-tracking.md +61 -0
  84. package/plugins/keyline/skills/keyline/scripts/check/css.mjs +112 -0
  85. package/plugins/keyline/skills/keyline/scripts/check/design.mjs +67 -0
  86. package/plugins/keyline/skills/keyline/scripts/check/files.mjs +44 -0
  87. package/plugins/keyline/skills/keyline/scripts/check/markup.mjs +149 -0
  88. package/plugins/keyline/skills/keyline/scripts/check/rules.mjs +584 -0
  89. package/plugins/keyline/skills/keyline/scripts/check.mjs +294 -0
  90. package/plugins/keyline/skills/keyline/scripts/contrast.mjs +153 -0
  91. package/plugins/keyline/skills/keyline/scripts/scan.mjs +340 -0
  92. package/plugins/keyline/skills/keyline/system/design-md.md +136 -0
  93. package/plugins/keyline/skills/keyline/system/detection.md +53 -0
  94. package/plugins/keyline/skills/keyline/system/guide.md +101 -0
  95. package/plugins/keyline/skills/keyline/system/presets.md +58 -0
  96. package/plugins/keyline/skills/keyline/system/roles.md +150 -0
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Long-form prose renderer — the reading half of the type system.
3
+ *
4
+ * The reading column is TWO SIZES: 16px for headings, 14px for everything a
5
+ * reader reads. Which means size does no work below the headings, and the
6
+ * blocks sharing 14px have to separate on other axes — each on a different
7
+ * one, or they collide:
8
+ *
9
+ * lead leading, 1.9 against the body's 1.75
10
+ * keyline weight 500, and 36px of air on both sides
11
+ * body 1.75 — the baseline the others are read against
12
+ * quote a 3px rounded bar and a 16px indent
13
+ * note 13px, the faint tier, and a 1px rule
14
+ *
15
+ * The rule that produced this: A TIER MUST NEVER OUTRANK THE HEADING IT SITS
16
+ * UNDER. An earlier pass ran the keyline at 17px, above the 16px heading it
17
+ * belonged to, and the page read as though the paragraphs were shouting over
18
+ * the titles. Pulling everything to one size removes any chance of that
19
+ * returning.
20
+ *
21
+ * Spacing is deliberately asymmetric around headings: 40px above, 12px below,
22
+ * so a heading belongs to the paragraph that follows it rather than floating
23
+ * between two sections. The keyline is the one symmetric block, because it
24
+ * belongs to neither neighbour.
25
+ *
26
+ * Colour comes from the Glass colour guide (`glass/color/guide.md`): `gray-1000`/`gray-900` for
27
+ * the two upper text tiers, and the `muted`/`quiet`/`faint` tokens below them
28
+ * (which are deliberately NOT ramp steps — 500–800 are border values and fail
29
+ * as text in a light theme). `gray-500` stays on decorative marks, which carry
30
+ * no text. `reveal` comes from glass/motion.
31
+ */
32
+
33
+ import { Fragment, type ReactNode } from "react";
34
+
35
+ export type Block =
36
+ | { type: "p" | "lead" | "keyline" | "note"; text: string }
37
+ | { type: "h2"; text: string }
38
+ | { type: "quote"; text: string; cite?: string }
39
+ | { type: "figure"; src: string; alt: string; caption: string; aspect?: string }
40
+ | { type: "ul" | "ol"; items: string[] };
41
+
42
+ export function Prose({ blocks }: { blocks: Block[] }) {
43
+ return (
44
+ <div className="text-[14px] leading-[1.75] text-gray-1000">
45
+ {blocks.map((b, i) => {
46
+ switch (b.type) {
47
+ /* The opening paragraph. Same size as the body; the 1.9 leading is
48
+ the entire difference, and it's what stops a piece reading as one
49
+ undifferentiated block from the first screen. */
50
+ case "lead":
51
+ return (
52
+ <p key={i} className="reveal mb-8 text-[14px] leading-[1.9] text-gray-1000">
53
+ {renderInline(b.text)}
54
+ </p>
55
+ );
56
+
57
+ case "p":
58
+ return (
59
+ <p key={i} className="reveal mb-6">
60
+ {renderInline(b.text)}
61
+ </p>
62
+ );
63
+
64
+ /* The one sentence a section is for. Authored by promoting a
65
+ sentence out of a paragraph and DELETING it from the paragraph —
66
+ a section that gains a keyline gets shorter. One per section.
67
+ Everything that makes it a landmark comes from weight and from
68
+ the 36px of air, so it can never outrank the heading above it. */
69
+ case "keyline":
70
+ return (
71
+ <p
72
+ key={i}
73
+ className="reveal my-9 text-[14px] font-medium leading-[1.6] text-gray-1000"
74
+ >
75
+ {renderInline(b.text)}
76
+ </p>
77
+ );
78
+
79
+ /* An aside set outside the argument — a caveat, a scope limit, a
80
+ piece of provenance. Its rule is 1px against the quote's 3px and
81
+ its type is four steps down, so two indented rules on one page
82
+ can't be mistaken for each other. */
83
+ case "note":
84
+ return (
85
+ <p
86
+ key={i}
87
+ className="reveal my-7 border-l border-gray-alpha-300 pl-4 text-[13px] leading-[1.6] text-faint"
88
+ >
89
+ {renderInline(b.text)}
90
+ </p>
91
+ );
92
+
93
+ /* 16px against 14px body — one step, and the weight, the tight
94
+ leading and the 40px above do the real work. This is the same
95
+ size as the page's own title, separated by position alone. */
96
+ case "h2":
97
+ return (
98
+ <h2
99
+ key={i}
100
+ className="reveal mt-10 mb-3 text-[16px] font-medium leading-[1.2] tracking-[-0.015em] text-gray-1000"
101
+ >
102
+ {renderInline(b.text)}
103
+ </h2>
104
+ );
105
+
106
+ /* The type is identical to the body around it, so THE BAR IS THE
107
+ ENTIRE SIGNAL — which is why it's a real one. It used to be a 2px
108
+ alpha-300 border, ten percent black, near enough invisible on a
109
+ white page; survivable only while the quote was also two steps
110
+ larger than the body. When you remove one signal, audit the ones
111
+ you left in.
112
+
113
+ A flex child rather than a border-left, because a border can't be
114
+ rounded on its own and a squared-off bar reads as a table rule.
115
+ No vertical margin on it, so flex's default `stretch` runs it the
116
+ full height of the quote including the citation. */
117
+ case "quote":
118
+ return (
119
+ <blockquote key={i} className="reveal my-9 flex gap-4">
120
+ <span aria-hidden className="w-[3px] shrink-0 rounded-full bg-gray-500" />
121
+ <div className="text-[14px] leading-[1.75] text-gray-1000">
122
+ {renderInline(b.text)}
123
+ {/* not-italic is required — <cite> carries UA italics that the
124
+ global `em, i { font-style: normal }` rule doesn't reach. */}
125
+ {b.cite && (
126
+ <cite className="mt-2 block text-[13px] not-italic text-quiet">— {b.cite}</cite>
127
+ )}
128
+ </div>
129
+ </blockquote>
130
+ );
131
+
132
+ /* Aspect ratio declared on a wrapper so the box is reserved before
133
+ the image loads and nothing shifts. `ring-1` rather than a border,
134
+ so the outline doesn't participate in the box model and the corner
135
+ radius stays exact.
136
+
137
+ The caption is the point of the figure, not a label on it — what
138
+ the thing was for, and which constraint shaped it. Set at the note
139
+ tier so it reads as apparatus; at body size it would compete with
140
+ the paragraph after it. */
141
+ case "figure":
142
+ return (
143
+ <figure key={i} className="reveal my-9">
144
+ <div className="overflow-hidden rounded-[10px] bg-gray-100 ring-1 ring-gray-alpha-300">
145
+ <div style={{ aspectRatio: b.aspect ?? "16 / 9" }}>
146
+ <img
147
+ src={b.src}
148
+ alt={b.alt}
149
+ loading="lazy"
150
+ decoding="async"
151
+ className="h-full w-full object-cover"
152
+ />
153
+ </div>
154
+ </div>
155
+ <figcaption className="mt-2.5 text-[13px] leading-[1.55] text-faint">
156
+ {renderInline(b.caption)}
157
+ </figcaption>
158
+ </figure>
159
+ );
160
+
161
+ /* marker: drops the bullets to the quiet tier. At the text colour
162
+ they read as a column of dark dots pulling the eye down the left
163
+ edge; quiet, they read as structure. space-y-2 inside vs mb-6
164
+ below — items belong to each other more than to what follows. */
165
+ case "ul":
166
+ return (
167
+ <ul key={i} className="reveal mb-6 list-disc space-y-2 pl-5 marker:text-gray-500">
168
+ {b.items.map((it, j) => (
169
+ <li key={j}>{renderInline(it)}</li>
170
+ ))}
171
+ </ul>
172
+ );
173
+ case "ol":
174
+ return (
175
+ <ol key={i} className="reveal mb-6 list-decimal space-y-2 pl-5 marker:text-gray-500">
176
+ {b.items.map((it, j) => (
177
+ <li key={j}>{renderInline(it)}</li>
178
+ ))}
179
+ </ol>
180
+ );
181
+
182
+ default:
183
+ return null;
184
+ }
185
+ })}
186
+ </div>
187
+ );
188
+ }
189
+
190
+ /**
191
+ * Inline markdown for **strong**, *em* and [links](/there), mapped onto the
192
+ * system's two axes instead of bold and italic:
193
+ *
194
+ * **strong** → weight 500 + primary colour (advances)
195
+ * *em* → muted colour, no slant (recedes)
196
+ *
197
+ * This gives genuinely two-directional emphasis, which a bold-only system
198
+ * can't express. The semantic tags stay — screen readers still announce them.
199
+ *
200
+ * One caution specific to a two-size reading column: <strong> and the keyline
201
+ * are now the same weight at the same size, so a paragraph with a long bolded
202
+ * clause reads as a keyline that failed to get its own line. Keep strong
203
+ * emphasis to a few words; anything longer wants to BE a keyline.
204
+ *
205
+ * Links are here because the strongest sentences often live in data rather
206
+ * than JSX, and a claim that can't carry a link to its own evidence is just an
207
+ * assertion. One link treatment everywhere prose carries one.
208
+ */
209
+ const TOKENS = /(\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)\s]+\))/g;
210
+ const LINK = /^\[([^\]]+)\]\(([^)\s]+)\)$/;
211
+
212
+ const LINK_CLASS =
213
+ "underline decoration-gray-alpha-500 decoration-1 underline-offset-[3px] " +
214
+ "transition-[opacity,text-decoration-color] duration-150 ease-geist " +
215
+ "hover:decoration-gray-1000 hover:opacity-70";
216
+
217
+ export function renderInline(text: string): ReactNode {
218
+ const tokens = text.split(TOKENS).filter(Boolean);
219
+ return (
220
+ <>
221
+ {tokens.map((tok, i) => {
222
+ if (tok.startsWith("**") && tok.endsWith("**")) {
223
+ return (
224
+ <strong key={i} className="font-medium text-gray-1000">
225
+ {tok.slice(2, -2)}
226
+ </strong>
227
+ );
228
+ }
229
+
230
+ const link = LINK.exec(tok);
231
+ if (link) {
232
+ const [, label, href] = link;
233
+ // In-app paths route through the framework's Link; everything else
234
+ // (mailto:, other origins, files) is a plain anchor, and an http(s)
235
+ // one opens away from the site.
236
+ const external = /^https?:/.test(href);
237
+ return (
238
+ <a
239
+ key={i}
240
+ href={href}
241
+ className={LINK_CLASS}
242
+ {...(external ? { target: "_blank", rel: "noopener" } : {})}
243
+ >
244
+ {label}
245
+ </a>
246
+ );
247
+ }
248
+
249
+ if (tok.startsWith("*") && tok.endsWith("*")) {
250
+ return (
251
+ <em key={i} className="text-muted">
252
+ {tok.slice(1, -1)}
253
+ </em>
254
+ );
255
+ }
256
+ return <Fragment key={i}>{tok}</Fragment>;
257
+ })}
258
+ </>
259
+ );
260
+ }
261
+
262
+ /** The same string with its markers removed — for titles, meta and plain text. */
263
+ export function stripInlineMarkup(text: string): string {
264
+ return text
265
+ .replace(/\[([^\]]+)\]\([^)\s]+\)/g, "$1")
266
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
267
+ .replace(/\*([^*]+)\*/g, "$1");
268
+ }
@@ -0,0 +1,414 @@
1
+ /* ─────────────────────────────────────────────────────────────────────────
2
+ Typographic system — tokens
3
+ Drop-in for Tailwind v4 (@theme) or any CSS setup (the :root block).
4
+ Swap "Archivo" for your family; keep the ladder.
5
+
6
+ The ladder tops out at 18px on purpose. See glass/type/scale.md — a low
7
+ ceiling is what forces every template onto one scale, and it's the single
8
+ most consequential choice in this system.
9
+ ───────────────────────────────────────────────────────────────────────── */
10
+
11
+ @theme {
12
+ /* Self-hosted, with a metric-matched fallback generated by the loader
13
+ (next/font, fontaine, or a hand-tuned @font-face with size-adjust). The
14
+ system faces after it are belt and braces for a request that never lands.
15
+ A bare `display: swap` without metric matching is a reflow under the
16
+ reader's eye — see §7 of glass/type/guide.md. */
17
+ --font-sans: var(--font-archivo), system-ui, -apple-system, "Helvetica Neue", sans-serif;
18
+
19
+ /* Code, commands and paths. System stack on purpose — a second webfont for
20
+ a handful of monospace runs isn't worth the request. A second family
21
+ earns a download only if it's carrying reading copy. */
22
+ --font-mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
23
+
24
+ /* ── Size ladder ──────────────────────────────────────────────────────
25
+ Each size exists for a named role. See glass/type/scale.md before
26
+ adding one — most new roles fit an existing size at a new leading. */
27
+ --text-page: 18px; /* index-page title — the top of the ladder */
28
+ --text-section: 16px; /* detail-page title · section heading · prose heading */
29
+ --text-copy: 14px; /* body · lead · keyline · quote · dek · UI rows */
30
+ --text-summary: 13.5px; /* collapsed summary lines — the one half-step */
31
+ --text-dense: 13px; /* notes · captions · meta · facts · footer */
32
+ --text-code: 12.5px; /* monospace runs, stepped down for a wider family */
33
+ --text-micro: 12px; /* tags · micro-labels */
34
+ --text-tip: 11px; /* tooltips · initials */
35
+ --text-mark: 8.5px; /* lettermarks — recognised, not read */
36
+ --text-card: 7.5px; /* card lettering — the one place 600 is allowed */
37
+
38
+ /* ── Leading: three curves, one per job ───────────────────────────────
39
+ Display tightens as size grows. Reading opens as passages lengthen.
40
+ UI collapses to 1 so fixed-height boxes centre exactly.
41
+
42
+ With the size ladder this short, leading is a ROLE SIGNAL, not a
43
+ refinement: lead / body / dek are the same size and differ only here. */
44
+ --leading-page: 1.25;
45
+ --leading-title: 1.3; /* a detail-page title, which wraps to two lines */
46
+ --leading-heading: 1.2; /* a section heading, which doesn't */
47
+ --leading-lead: 1.9; /* the opening paragraph */
48
+ --leading-prose: 1.75; /* body — the baseline the others read against */
49
+ --leading-read: 1.65;
50
+ --leading-keyline: 1.6;
51
+ --leading-body: 1.55;
52
+ --leading-tight: 1.4;
53
+ --leading-none: 1;
54
+
55
+ /* ── Tracking: a large-size correction ────────────────────────────────
56
+ Two values, 2px apart, because there are only two sizes above 14px.
57
+ A system tracking six steps has a scale doing too much work. */
58
+ --tracking-page: -0.02em; /* 18px */
59
+ --tracking-heading: -0.015em; /* 16px */
60
+ --tracking-card: -0.01em; /* 7.5px, set solid */
61
+ --tracking-mark: 0.02em; /* tiny caps need the opposite */
62
+
63
+ /* ── Weight: two, and only two ─────────────────────────────────────── */
64
+ --font-weight-normal: 400; /* all body, all prose, all quiet UI */
65
+ --font-weight-medium: 500; /* headings, <strong>, active state, marks */
66
+ /* The one exception, and it lives below the readable floor: at 7.5px a 500
67
+ doesn't survive rasterisation, so the choice is 600 or nothing. One
68
+ instance of this in a codebase is the right number. */
69
+ --font-weight-card: 600;
70
+ }
71
+
72
+ /* Text colour tiers live in the Glass colour guide (`glass/color/guide.md`). The roles below
73
+ reference them by name:
74
+ primary → --color-gray-1000 17.93:1
75
+ secondary → --color-gray-900 8.45:1
76
+ muted → --color-muted 6.48:1
77
+ quiet → --color-quiet 5.49:1
78
+ faint → --color-faint 4.61:1
79
+
80
+ Only the top two are ramp steps. muted/quiet/faint are their OWN tokens,
81
+ because a border needs 3:1 against its surface and text needs 4.5:1 — one
82
+ step can't serve both. Reusing mid-ramp greys (500-800) for text fails in
83
+ a light theme while looking perfectly fine in dark. */
84
+
85
+ @layer base {
86
+ body {
87
+ font-family: var(--font-sans);
88
+ font-weight: var(--font-weight-normal);
89
+ font-size: var(--text-copy);
90
+ line-height: var(--leading-body);
91
+
92
+ /* Tabular by default. In a text-heavy interface most numbers sit in a
93
+ column that should align — dates, timestamps, read times, clocks.
94
+ A live-updating number is unreadable without this. */
95
+ font-variant-numeric: tabular-nums;
96
+
97
+ -webkit-font-smoothing: antialiased;
98
+ text-rendering: optimizeLegibility;
99
+ }
100
+
101
+ /* Emphasis is re-mapped onto weight and colour. A separate italic file
102
+ fights geometric letterforms, and a 700 bold blots open prose. Keep the
103
+ semantic tags — only the visual mapping changed. */
104
+ em,
105
+ i {
106
+ font-style: normal;
107
+ }
108
+
109
+ /* <cite> and <blockquote> carry UA italics the rule above doesn't reach. */
110
+ cite {
111
+ font-style: normal;
112
+ }
113
+ }
114
+
115
+ /* ─────────────────────────────────────────────────────────────────────────
116
+ Role classes — optional. Use these, or apply the tokens inline through
117
+ Tailwind arbitrary values (text-[14px] leading-[1.65]) as the reference
118
+ implementation does. Either way the numbers come from one place.
119
+
120
+ Note how many of these share --text-copy. That's the system working: once
121
+ the ladder is short, a role is a (size, leading, weight, rule) tuple and
122
+ size is usually the part that doesn't vary.
123
+ ───────────────────────────────────────────────────────────────────────── */
124
+
125
+ @layer components {
126
+ /* An index page's title. The largest role there is. */
127
+ .t-page {
128
+ font-size: var(--text-page);
129
+ line-height: var(--leading-page);
130
+ letter-spacing: var(--tracking-page);
131
+ font-weight: var(--font-weight-medium);
132
+ }
133
+
134
+ /* A detail page's title. Looser leading than a section heading because it
135
+ wraps; otherwise identical, and deliberately so. */
136
+ .t-title {
137
+ font-size: var(--text-section);
138
+ line-height: var(--leading-title);
139
+ letter-spacing: var(--tracking-heading);
140
+ font-weight: var(--font-weight-medium);
141
+ }
142
+
143
+ .t-section {
144
+ font-size: var(--text-section);
145
+ line-height: var(--leading-heading);
146
+ letter-spacing: var(--tracking-heading);
147
+ font-weight: var(--font-weight-medium);
148
+ }
149
+
150
+ .t-sub {
151
+ font-size: var(--text-copy);
152
+ line-height: var(--leading-tight);
153
+ font-weight: var(--font-weight-medium);
154
+ }
155
+
156
+ /* Long-form body. Same size as a UI paragraph; the leading is the whole
157
+ difference, and it has to be exact. */
158
+ .t-prose {
159
+ font-size: var(--text-copy);
160
+ line-height: var(--leading-prose);
161
+ }
162
+
163
+ /* The opening paragraph. Separates from .t-prose on leading alone. */
164
+ .t-lead {
165
+ font-size: var(--text-copy);
166
+ line-height: var(--leading-lead);
167
+ }
168
+
169
+ /* The one sentence a section is for. Separates on weight and on air —
170
+ symmetric margins, because it belongs to neither neighbour. */
171
+ .t-keyline {
172
+ font-size: var(--text-copy);
173
+ line-height: var(--leading-keyline);
174
+ font-weight: var(--font-weight-medium);
175
+ margin-block: 36px;
176
+ }
177
+
178
+ /* A standfirst. Separates from .t-lead on colour and leading — the
179
+ tightest pairing in the system. Check this one first if a page starts
180
+ to look undifferentiated. */
181
+ .t-dek {
182
+ font-size: var(--text-copy);
183
+ line-height: var(--leading-body);
184
+ color: var(--color-muted);
185
+ }
186
+
187
+ .t-read {
188
+ font-size: var(--text-copy);
189
+ line-height: var(--leading-read);
190
+ }
191
+
192
+ .t-body {
193
+ font-size: var(--text-copy);
194
+ line-height: var(--leading-body);
195
+ }
196
+
197
+ /* Single-line UI text in a fixed-height box. leading-none so a descender
198
+ can't shift the box and vertical centring is exact. */
199
+ .t-row {
200
+ font-size: var(--text-copy);
201
+ line-height: var(--leading-none);
202
+ }
203
+
204
+ .t-summary {
205
+ font-size: var(--text-summary);
206
+ line-height: var(--leading-keyline);
207
+ }
208
+
209
+ .t-dense {
210
+ font-size: var(--text-dense);
211
+ line-height: var(--leading-tight);
212
+ }
213
+
214
+ /* An aside set outside the argument — a caveat, a scope limit, a piece of
215
+ provenance. Its 1px rule is deliberately lighter than the pull quote's
216
+ 3px bar: two indented rules on one page must be unmistakable. */
217
+ .t-note {
218
+ font-size: var(--text-dense);
219
+ line-height: var(--leading-keyline);
220
+ color: var(--color-faint);
221
+ border-left: 1px solid var(--color-gray-alpha-300);
222
+ padding-left: 16px;
223
+ margin-block: 28px;
224
+ }
225
+
226
+ .t-meta {
227
+ font-size: var(--text-dense);
228
+ }
229
+
230
+ .t-code {
231
+ font-family: var(--font-mono);
232
+ font-size: var(--text-code);
233
+ line-height: var(--leading-keyline);
234
+ }
235
+
236
+ .t-micro {
237
+ font-size: var(--text-micro);
238
+ }
239
+
240
+ .t-tip {
241
+ font-size: var(--text-tip);
242
+ line-height: var(--leading-none);
243
+ }
244
+
245
+ /* Not text — a two-or-three-letter stand-in for a logo, sized below the
246
+ readability floor because you recognise it rather than read it. */
247
+ .t-mark {
248
+ font-size: var(--text-mark);
249
+ line-height: var(--leading-none);
250
+ letter-spacing: var(--tracking-mark);
251
+ font-weight: var(--font-weight-medium);
252
+ }
253
+
254
+ .t-card {
255
+ font-size: var(--text-card);
256
+ line-height: 1.2;
257
+ letter-spacing: var(--tracking-card);
258
+ font-weight: var(--font-weight-card);
259
+ }
260
+ }
261
+
262
+ /* ─────────────────────────────────────────────────────────────────────────
263
+ Imported prose — a rendered Markdown document, a CMS field, a README.
264
+ It arrives as an HTML string, so it can't carry utility classes. Style it
265
+ by element inside one scoped class, and map it onto the roles above rather
266
+ than letting it invent a second ladder.
267
+ ───────────────────────────────────────────────────────────────────────── */
268
+
269
+ @layer components {
270
+ .prose-imported {
271
+ font-size: var(--text-copy);
272
+ line-height: var(--leading-read);
273
+ color: var(--color-muted);
274
+ }
275
+ /* The document starts with whatever it starts with; a leading <h2> would
276
+ otherwise push 44px of nothing above itself. */
277
+ .prose-imported > :first-child {
278
+ margin-top: 0;
279
+ }
280
+
281
+ /* Three levels, not two — an imported document nests deeper than authored
282
+ prose, so h4 gets a real role instead of a browser default. */
283
+ .prose-imported h2 {
284
+ margin: 44px 0 12px;
285
+ font-size: var(--text-section);
286
+ font-weight: var(--font-weight-medium);
287
+ line-height: var(--leading-heading);
288
+ letter-spacing: var(--tracking-heading);
289
+ color: var(--color-gray-1000);
290
+ }
291
+ .prose-imported h3 {
292
+ margin: 32px 0 10px;
293
+ font-size: var(--text-copy);
294
+ font-weight: var(--font-weight-medium);
295
+ line-height: var(--leading-tight);
296
+ color: var(--color-gray-1000);
297
+ }
298
+ .prose-imported h4 {
299
+ margin: 24px 0 8px;
300
+ font-size: var(--text-dense);
301
+ font-weight: var(--font-weight-medium);
302
+ line-height: var(--leading-tight);
303
+ color: var(--color-gray-900);
304
+ }
305
+
306
+ .prose-imported p,
307
+ .prose-imported ul,
308
+ .prose-imported ol,
309
+ .prose-imported blockquote {
310
+ margin: 12px 0;
311
+ }
312
+
313
+ /* A UA disc at 14px is far too heavy for this system. Same 3px dot as the
314
+ hand-rolled bullets everywhere else. */
315
+ .prose-imported ul {
316
+ list-style: none;
317
+ padding-left: 18px;
318
+ }
319
+ .prose-imported ul > li {
320
+ position: relative;
321
+ }
322
+ .prose-imported ul > li::before {
323
+ content: "";
324
+ position: absolute;
325
+ left: -14px;
326
+ top: 10px;
327
+ width: 3px;
328
+ height: 3px;
329
+ border-radius: 999px;
330
+ background: var(--color-gray-500);
331
+ }
332
+ .prose-imported ol {
333
+ list-style: decimal;
334
+ padding-left: 18px;
335
+ }
336
+ /* Tabular, so a run past 9 doesn't shift its text. */
337
+ .prose-imported li::marker {
338
+ color: var(--color-faint);
339
+ font-variant-numeric: tabular-nums;
340
+ }
341
+
342
+ .prose-imported strong {
343
+ font-weight: var(--font-weight-medium);
344
+ color: var(--color-gray-1000);
345
+ }
346
+
347
+ .prose-imported code {
348
+ font-family: var(--font-mono);
349
+ font-size: 0.87em;
350
+ background: var(--color-gray-100);
351
+ border-radius: 4px;
352
+ padding: 1.5px 4px;
353
+ color: var(--color-gray-1000);
354
+ }
355
+
356
+ /* Code blocks and tables are the two things a Markdown document has that a
357
+ designed page doesn't, and both are unbounded. Each scrolls inside its
358
+ own box — the page body must never scroll sideways. */
359
+ .prose-imported pre {
360
+ margin: 16px 0;
361
+ padding: 14px 16px;
362
+ background: var(--color-background-200);
363
+ border: 1px solid var(--color-gray-alpha-300);
364
+ border-radius: 12px;
365
+ overflow-x: auto;
366
+ }
367
+ .prose-imported pre code {
368
+ background: none;
369
+ padding: 0;
370
+ font-size: var(--text-code);
371
+ line-height: var(--leading-keyline);
372
+ color: var(--color-gray-900);
373
+ }
374
+
375
+ /* Wrap each <table> in a div with this class at render time. */
376
+ .prose-imported .table-scroll {
377
+ margin: 16px 0;
378
+ overflow-x: auto;
379
+ }
380
+ .prose-imported table {
381
+ width: 100%;
382
+ border-collapse: collapse;
383
+ font-size: var(--text-dense);
384
+ line-height: 1.5;
385
+ }
386
+ .prose-imported th,
387
+ .prose-imported td {
388
+ padding: 8px 12px 8px 0;
389
+ text-align: left;
390
+ vertical-align: top;
391
+ border-bottom: 1px solid var(--color-gray-alpha-300);
392
+ }
393
+ .prose-imported th {
394
+ font-weight: var(--font-weight-medium);
395
+ color: var(--color-gray-1000);
396
+ white-space: nowrap;
397
+ }
398
+
399
+ .prose-imported blockquote {
400
+ padding-left: 14px;
401
+ border-left: 2px solid var(--color-gray-alpha-300);
402
+ color: var(--color-faint);
403
+ }
404
+ .prose-imported hr {
405
+ margin: 32px 0;
406
+ border: 0;
407
+ border-top: 1px solid var(--color-gray-alpha-300);
408
+ }
409
+ .prose-imported img {
410
+ max-width: 100%;
411
+ height: auto;
412
+ border-radius: 12px;
413
+ }
414
+ }