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,251 @@
1
+ # Typographic System
2
+
3
+ A type system is a set of promises about hierarchy: that the largest thing on a page is the most structural, that two things at one size do the same kind of job, that a reader who has learned one page can read the next. This guide builds those promises for a project and checks the code keeps them.
4
+
5
+ The organising idea is **a size exists because a role needs it** — not because a ratio produced it or a template wanted to feel important. The laws hold under every preset; the dials move between them.
6
+
7
+ ## Before you start
8
+
9
+ Read `DESIGN.md` at the project root: `## Profile` for the preset and any areas on another one, `## Type` for the project's name for each role and the decisions already made. Reason in roles; write code in the project's names.
10
+
11
+ - **No `DESIGN.md`:** run `/keyline init` for this domain only, do the task, then recommend the full setup.
12
+ - **A role you need has no row:** reuse a value already doing that job; otherwise add one in the project's naming style and record it in `DESIGN.md`.
13
+ - **The task would break a law or move a dial off its preset:** say so and ask; if the user confirms, record it with its reason under `## Decisions`.
14
+ - **Preset `glass`:** the values are in `DESIGN.md`; `glass/type/guide.md` has the exact recipes behind them.
15
+
16
+ ## Laws
17
+
18
+ ### 1. Sizes belong to roles, not to a ratio
19
+
20
+ **Every size on the ladder names the role that needs it; a size no role claims is deleted, not kept for variety.** A modular scale from a 14px base at 1.25× gives 14, 17.5, 21.9, 27.3 — too few sizes in the band where chrome lives, unclaimed ones above the ceiling that invite use, and fractions that hint differently across engines. Let the ladder be as dense or sparse as the roles demand.
21
+
22
+ The ladder has a floor as well as a ceiling. Below the readable floor type is recognised, not read — a two-letter lettermark, card lettering — and nothing a user must read lives there. A base size no role claims (a `body { font-size: 15px }` nothing uses explicitly) is waiting to be used by accident.
23
+
24
+ Type inside artwork — an illustration or diagram, an SVG or an HTML mock marked `role="img"` — is exempt from the ladder, as colour law 1 exempts colour there; any label in it a reader must read still clears the readable floor.
25
+
26
+ Cut downward, checking what each remaining step was holding up: removing a 30px Glass article title took three more sizes with it, two of which existed only to sit between it and the body.
27
+
28
+ → Per-preset ladders, and adding a role without growing one: `domains/type/ladders.md`
29
+
30
+ ### 2. A scale is only a system if every template is on it
31
+
32
+ **No template opens off the ladder, and templates of one kind open alike; a page type with its own hierarchy is opting out of the system, not expressing importance.** Glass articles once opened at 30px with 19px section headings while every index opened at 18px, and a case study felt like a different site. Readers feel the opt-out as inconsistency before they notice sizes; the fix was the site's existing ladder, not a compromise. An article may open above an index — its title starts a piece, an index's labels a list (the ceiling dial) — but every article opens at one size, and every index at another.
33
+
34
+ Areas on another preset are declared by path in `DESIGN.md`, not drifted into. Inside one every template shares its ladder, and roles crossing the boundary — navigation, `type.ui`, `type.meta`, the footer — keep one set of values, so the chrome doesn't change between marketing site and app.
35
+
36
+ The same holds for one element: a pane title at 14/600 where every other page's is 13/500 reads as a bug. Sibling controls in a strip match in size, weight and icon; where they differ, the better one wins.
37
+
38
+ ### 3. A tier never outranks the heading it sits under
39
+
40
+ **Nothing inside a section — a lead, a pull quote, a keyline, a note — may be larger, heavier or louder than the heading that owns it.** A Glass keyline at 17px under a 16px heading read as paragraphs shouting over their titles; an in-body heading larger than the page's title inverts the document. Check role pairs — `type.lead` against `type.heading`, `type.heading` against `type.title` — since a violation only shows where the two meet.
41
+
42
+ `type.display`, the one role above `type.title`, obeys it too: one loudest moment, nothing under it competing. Two are two pages' worth of hierarchy on one.
43
+
44
+ A figure that *is* the content of its block — a price on a plan card, a value in a stat strip, a big number — is data, not a tier, and may be larger than its label; the label keeps the lead by position and weight.
45
+
46
+ In a row or card the rule runs the other way: **its title is the darkest thing in it.** A title greyed to say read or done is outranked by its own metadata, and a list of them reads as all grey; state rides on a dot, a glyph or the state weight (law 5).
47
+
48
+ ### 4. When size stops separating roles, each separates on a different axis
49
+
50
+ **Two roles at one size must differ on weight, leading, colour tier, a rule or spacing — and each must own at least one signal no other role at that size uses.** That is a short ladder working, not failing. Five roles share the Glass body size:
51
+
52
+ | Role at body size | Separates on |
53
+ |---|---|
54
+ | `type.body` | nothing — the baseline the others are read against |
55
+ | `type.lead` | leading alone, 0.15 more open than the body |
56
+ | `keyline` (project role), a section's one claim | `weight.strong`, and symmetric space: it belongs to neither neighbour |
57
+ | pull quote (project role) | a solid bar and an indent |
58
+ | `type.supporting` (the dek) | `text.secondary`, and tighter leading than the body |
59
+
60
+ A further role there needs an unused axis, or it isn't a role.
61
+
62
+ The corollary matters more: **when you remove a signal, audit the ones you left in.** The Glass pull quote's 2px border at ten percent black, invisible on white, survived only while the quote was two steps larger than the body; when the size step went, it had to become a real bar.
63
+
64
+ Between sizes too, size is the weakest signal in the 11–20px band: a 13 → 14 → 20 ladder at one weight reads flat. **A step up in rank moves weight and tracking with it** — headings tighten as they grow while labels stay at zero — never size alone.
65
+
66
+ → Reading blocks and the prose that carries them: `domains/type/prose-and-emphasis.md`
67
+
68
+ ### 5. Every weight and every family has one job
69
+
70
+ **Weight marks structure and state, never decoration; each weight in use has a job you can name, and a weight with no job isn't loaded.** A heading's job is to be found, not loud. Push `type.heading` to 700 and `type.subheading` needs 600, `<strong>` has nowhere to go, and the axis is spent on two heading levels — how systems end with five weights and no hierarchy. The count is a dial; the discipline isn't.
71
+
72
+ **Pick weight by what the text is doing there, not how important it feels.** Each size has a quiet weight and a lifted one: lift one step at most, never on two neighbouring lines — a card's time and title both lifted cancel out. Every title in a set takes one weight; a finished card's lighter title reads as a mistake, so the column or a glyph says it.
73
+
74
+ A second family earns its place with a second register — literal text in a code face, a brand display voice, interface text beside a serif reading face — never because "headings should feel different", which size, weight, leading and tracking handle. A code face for a handful of spans is a system stack, not a download; IDs, codes and times rarely need even that — tabular, slashed-zero figures (law 10) set them apart in the text face.
75
+
76
+ Weight is the cheapest second channel for state: colour alone fails WCAG 1.4.1, so an active row adds `weight.strong` to the colour colour law 7 gives it — only on states that don't follow the pointer (active, current, selected, not hover), since a weight change alters width and shifts neighbours. **Weight carries one state per product:** if unread rows step up beside their dot, current and selected use a fill, a marker or tier, or the step means nothing.
77
+
78
+ ### 6. Leading follows the job
79
+
80
+ **Display, reading and UI text each have their own leading curve, and a role never borrows another curve's leading.** Leading must exceed the gap inside a line, which the x-height sets: at display sizes a tight ratio reads generously; at body sizes it collides. Fixed-height single-line UI runs at 1, so centring is exact and a descender can't shift the box.
81
+
82
+ | Curve | Roles | Rule |
83
+ |---|---|---|
84
+ | Display | `type.display`, `type.title`, `type.heading`, `type.subheading` | Tighter as size grows; a wrapping title gets a touch more than a heading |
85
+ | Reading | `type.lead`, `type.body`, `type.supporting` | More open as the passage or measure lengthens |
86
+ | UI | `type.ui`, `type.label`, `type.meta`, `type.data` | 1 in a fixed-height single-line box; the dial's value where it wraps |
87
+
88
+ The failures mirror each other: reading leading on a nav row loses the list's rhythm; UI leading makes a paragraph a wall. On a short ladder leading is a role signal, so a lead 0.15 off the body needs that number exact.
89
+
90
+ ### 7. Tracking is a size correction, in `em`
91
+
92
+ **Negative above the text range, zero on body copy, positive on tiny text and capitals — always in `em`, never in `px`.** Sidebearings are drawn for text sizes, so scaled-up type looks loose until pulled in; below the text range strokes thicken and letters merge, and capitals, lacking ascenders and descenders, need more air still. A `px` value right at 32px destroys 14px text.
93
+
94
+ Never track body copy: reading pattern-matches word shapes, and ±0.01em on a paragraph is invisible at best, a cost at worst. Tracking six different steps means the scale is doing too much work.
95
+
96
+ ### 8. UI copy and reading copy are different systems
97
+
98
+ **Chrome is set for scanning and prose for sustained reading, and the difference lives in leading, measure and tier — not necessarily size.** A 14px nav row and a 14px paragraph can share a size and nothing else: the row at 1 or 1.4 leading and `text.secondary`, read in a glance; the paragraph open-leaded, at `measure.reading` and `text.primary`. When something looks off, check first for a UI role with reading values, or the reverse. Under every preset, a product's help text is reading copy inside a tool, with reading leading and measure.
99
+
100
+ ### 9. Emphasis maps onto axes the system already has
101
+
102
+ **`<em>` and `<strong>` keep their semantics and map onto weight, colour or a true style the family ships — never a synthesised style, and never anything heavier than a heading.** `font-synthesis: none` makes a missing italic or bold a visible gap in review, not a smeared glyph in production. `<strong>` takes `weight.strong`, not the default 700, which in open prose is a dark blot read before the sentence. Keep strong runs to a few words; a long bold clause reads as a heading that failed to get its own line.
103
+
104
+ The mapping covers user-agent styles: `<cite>`, `<blockquote>`, `<b>`, `<th>` and `<address>` carry italics and weights a global `em { … }` rule misses. Whether italic exists is a dial; no browser default leaking past the mapping is not.
105
+
106
+ ### 10. Numerals that align are tabular
107
+
108
+ **Any number in a column, a table, a timestamp, a counter or anything that updates live uses tabular figures.** Default digits are proportional — a 1 is narrower than an 8 — so a value shakes as it updates and a column of figures never lines up. Tabular digits all share one width, and one line of CSS sets them. `type.data` is usually `type.ui` or `type.body` plus `font-variant-numeric: tabular-nums`, not its own size. Global or scoped is a dial; a live number never shifting its row is the law. The property fails silently, so check the family ships `tnum`.
109
+
110
+ → Figures, alignment and feature settings: `domains/type/numerals-and-loading.md`
111
+
112
+ ### 11. Prose you can't put classes on is a guest, not a second system
113
+
114
+ **Rendered Markdown, CMS fields and imported HTML are styled by element inside one scoped class, and every element maps onto a role the ladder already has.** Left alone, imported prose brings the browser's ladder — a 2em `h1`, a 1.5em `h2`, a heavy disc bullet — and the page quietly runs two systems. A document's `h1` and `h2` map to `type.heading`, never `type.title`. → The full mapping: `domains/type/prose-and-emphasis.md`
115
+
116
+ ### 12. The font arrives without moving the text
117
+
118
+ **Self-host the family, keep `font-display: swap`, and pair it with a metric-matched fallback, so that if the swap happens the lines don't move.** A third-party font CDN costs two DNS lookups and TLS handshakes, which `preconnect` shortens but can't remove; `swap` without metric matching is a reflow under the reader's eye, worse than a wait. Tune loading for the frame where one string carries the screen — a hero headline, an opening line — where a substitution is unmissable.
119
+
120
+ ### 13. An icon is set to the text beside it
121
+
122
+ **An icon beside a label takes the label's size and weight, not a size of its own.** 13px text takes a 13–14px icon and a 4–6px gap; a few pixels larger and the icon is read before its word. The size is the drawn glyph, not the SVG box: on a 24-unit grid with 2 units of padding, a 16px box draws about 13px. Stroke follows weight — 1.75 on a 24 grid beside 400–500 text, where 2 looks bold — and retuning beats switching family. Record each role's icon size in its `## Type` row (law 2).
123
+
124
+ ## Dials
125
+
126
+ | Dial | editorial | product | expressive |
127
+ |---|---|---|---|
128
+ | `type.body` size | 16–18px | 14px | 17–20px |
129
+ | `type.ui` size | body − 1–2px | 13px (12px in dense tables) | 15–16px |
130
+ | Ceiling: `type.title` ÷ `type.body` | ≤ 2.25 for an article's or page's title; index and chrome titles may sit lower | ≤ 1.6 | ≤ 2.5 |
131
+ | `type.display` | none | none | one per page, derived from the measure; ≥ 1.5 × `type.title` at every width (× `type.heading` where the display is the `h1`) |
132
+ | Distinct sizes above the floor (excluding display and code) | ≤ 7 | ≤ 7 | ≤ 8 |
133
+ | Readable floor | 12px | 11px | 12px |
134
+ | Weights | 2: `weight.regular`, `weight.strong` | 2: `weight.regular` 400, `weight.strong` at 500; a third (600, headings) only for a recorded job | 2 for text, plus `weight.display` |
135
+ | Families | one for reading, plus a system-stack mono; a second for interface text (navigation, buttons, tables, forms) when the reading face is a serif | one; `type.code` wherever literal code appears; paths, URLs, IDs and hostnames in the text face's tabular, slashed-zero figures unless they are code the reader will type | up to two (`family.display`), plus mono |
136
+ | Leading, reading | 1.6–1.75 | 1.45–1.55 | 1.5–1.65 |
137
+ | Leading, headings | 1.2–1.3 | 1.25–1.35 | title 1.1–1.2; display 0.95–1.05 |
138
+ | Leading, multi-line UI | 1.4 | 1.3–1.4 | 1.4 |
139
+ | Tracking at the top of the ladder | −0.02em | −0.02em on every heading from 14px | display −0.03 to −0.045em |
140
+ | Uppercase labels | none | none: sentence case everywhere, separated by tier and a rule | eyebrows, +0.06–0.1em |
141
+ | Italic | true italic, if the family ships one, for `em` and titles of works | none: `em` → `text.secondary` | true italic allowed as a display voice |
142
+ | Numerals | tabular wherever numbers align or update; proportional in prose | tabular on every number, scoped to numeric elements (tables, figures, counters, dates), not the root; slashed zero on IDs and codes | proportional in display, tabular in data |
143
+ | Responsive sizes | the article title only: one smaller step on phones | none | display, title and section headings fluid via `clamp()` with a `rem` intercept |
144
+ | Unit | `rem` | `rem` | `rem` (fluid values with a `rem` intercept) |
145
+
146
+ **Ceiling.** The ratio, not the pixel value, carries character. Editorial keeps the reading the loudest thing on the page, but an article's title has to read as the start of the piece, so the ceiling allows it: an 18px body takes a title near 40px. Everything inside the article stays close to the body. Index pages and chrome titles may sit lower, still on the ladder (law 2): a page listing pieces isn't opening one. The `glass` preset keeps its own lower ceiling by decision (`glass/type/guide.md`). Raising a ceiling costs more than one size, as every tier between wants a step; record any raise with the role that needed it.
147
+
148
+ **Display.** Derived from the headline and the column, not body × ratio; within 1.5× of `type.title` it is a big title, not a tier. On a page whose `h1` is the display — a landing page with no separate title — there is no `type.title`, and display is measured against `type.heading`: at least 1.5× at every width. → Derivation, bounds, leading, tracking and weight: `domains/type/display-tier.md`
149
+
150
+ **Weights.** Product runs two, with `weight.strong` at 500 rather than 600: on a dense screen headings are found by size, tracking and tier, and a 600 heading forces a middle weight for tabs, totals and labels to hold their own against it — how a product ends up with three weights and a noisy page. Keep strong at 500 and the active tab, the column total and the row title all take it. A third weight needs a job the two can't carry, recorded in `DESIGN.md`; it costs a font file and a check that every weight still has exactly one job. Expressive's `weight.display` can go *lighter* than regular (`domains/type/display-tier.md`).
151
+
152
+ The commonest recorded third is 600 for headings on a dense screen, where 500 is spent lifting lines inside rows and nothing in a row goes above it — a size × quiet, lifted, heading table in `DESIGN.md` (`domains/type/ladders.md`). Either way product drifts heavy: expect to step weights *down*, one step per context, rather than re-pick the scale.
153
+
154
+ **Leading.** The reading range moves with the measure — long lines need more leading for the eye to find the next — so wide measures sit at the top. Product sits lowest: short reading copy, narrow measure.
155
+
156
+ **Tracking.** Values scale with how far above the text range the ladder climbs: editorial's touches two sizes, expressive display needs the most, and a family with an optical-size axis does some of that itself, so sits at the low end. Product's headings sit inside the text range, so its −0.02em from 14px is a role signal moving with weight (law 4), not a size correction; body, labels and UI copy stay at 0.
157
+
158
+ **Readable floor.** Product can go to 11px because its reader is fluent and glancing — a column header, a unit, a keyboard hint; editorial and expressive readers are not, and an 11px caption goes unread.
159
+
160
+ **Uppercase labels.** Capitals are a texture; a page of them is signage. Uppercase eyebrows over every section and column are what a template looks like, so product separates a group label from the same-size rows beneath it by tier (`text.tertiary`) and layout law 3's group device — a rule in editorial, the group bar in product — not by case. Expressive may use capitals for an eyebrow, tracked positive (law 7); editorial doesn't need them. Sentence case in the words themselves, generated strings included, is patterns law 1.
161
+
162
+ **Families.** Reading and interface are two registers (law 5). With a sans reading face one family carries both; with a serif, navigation, buttons, tables and forms may take a second, interface family (`family.ui`), so a serif doesn't set the chrome.
163
+
164
+ **Numerals.** Product sets tabular on every number but scopes it to numeric elements, not the root: some families' `tnum` also changes punctuation, and set globally it widens the hyphen in "Sign-ups".
165
+
166
+ **Italic.** The Glass preset's ban is about its family, whose italic fights the letterforms, not about italic. Editorial with a good true italic should use it; product rarely has reading copy long enough to need one, and saves a file.
167
+
168
+ **Responsive sizes.** In editorial and product a responsive step is a symptom of a size too large for the narrowest measure: fix the ladder, not a breakpoint, and on a phone fix the measure or leading (`domains/type/ladders.md`). The one editorial exception is the article title, which the higher ceiling makes large enough to wrap to four lines at 320px: it takes one smaller step on phones, and nothing else moves. Expressive is the exception: display is derived from a column that changes, and title and section headings scale with it, each via `clamp()` with a `rem` intercept, so the steps between them hold at every width. One step holds under every preset: inputs are 16px on narrow, touch-first widths, since iOS Safari zooms into a smaller field on focus — never `maximum-scale=1`, which takes zoom from every reader.
169
+
170
+ **Unit.** `rem` respects a user's root font size; `px` doesn't, and the Glass preset uses it to protect 1px relationships in its small band, relying on browser zoom. Either is defensible; mixing them is not. Never size text in `vw` alone — it ignores zoom, failing WCAG 1.4.4.
171
+
172
+ ## Building it for a project
173
+
174
+ 1. **Read the reader and the family.** The preset sets the dials; the family adjusts them. Note its x-height (a large one reads bigger, so body can sit a pixel lower), true italic, `tnum`, `opsz` axis and weights. Record `family.text`; `family.display` only if the preset allows and a register needs it; `family.ui` only beside a serif reading face.
175
+ 2. **List the roles the project has, not the ones this guide lists** — canonical and project roles (a `keyline`, a `kicker`, a `stat`) found in the templates. No instance, no row.
176
+ 3. **Fix body first, then the ceiling** (`type.body` from dial and family, `type.title` within the ratio); everything else sits between them and the floor.
177
+ 4. **Place the other roles, reusing sizes** with different leading, weight or tier; a new size only when no unused axis is left, naming the sizes it sits between.
178
+ 5. **Assign each shared-size role its axis** in the table (law 4); a row whose only signal is shared at that size is a collision to fix now.
179
+ 6. **Set leading per curve and tracking per size** from the dials (laws 6–7).
180
+ 7. **Map emphasis, numerals and imported prose** onto those roles (laws 9–11).
181
+ 8. **Load the fonts** self-hosted, only the weights in the table, with a metric-matched fallback. → `domains/type/numerals-and-loading.md`
182
+ 9. **Check the pairs:** each supporting role against its heading, each template's opening against the others'.
183
+
184
+ Worked example — **Tally**, a `product` app with an `expressive` marketing area: `domains/type/ladders.md`.
185
+
186
+ Tailwind v4 takes these in `@theme` as `--text-*` and `--font-weight-*` (leading as `--text-*--line-height`); v3 under `theme.extend.fontSize` as `[size, { lineHeight, letterSpacing }]` tuples; a JS theme as a `typography` object keyed by role names. With `tailwind-merge`, register the role names, or `cn()` reads `text-label` as a colour and silently drops it (`domains/type/type-audit.md`).
187
+
188
+ ## Auditing existing code
189
+
190
+ Inventory first, then read by template — drift is almost always one or two templates running their own scale.
191
+
192
+ ```sh
193
+ node <keyline>/scripts/scan.mjs . --ignore <prose and example dirs>
194
+ rg -o "text-\[[^\]]+\]|font-size:\s*[^;]+" src | sort | uniq -c | sort -rn
195
+ rg -o "font-(thin|light|normal|medium|semibold|bold|black)|font-weight:\s*\d+" src | sort | uniq -c
196
+ rg -n "italic|<i>|<cite|<blockquote" src
197
+ rg -n "tracking-|letter-spacing|uppercase|text-transform" src
198
+ rg -n "tabular-nums|tnum|font-variant-numeric" src
199
+ rg -n "@font-face|fonts.googleapis|font-display|size-adjust|next/font|fontsource" .
200
+ rg -n "dangerouslySetInnerHTML|v-html|innerHTML|\{@html|marked|remark|markdown-it" src
201
+ ```
202
+
203
+ Evidence per law:
204
+
205
+ - **Roles (1).** Each distinct size, count and role; sizes used once or twice are strays or unnamed roles — say which.
206
+ - **Templates (2).** Each template's first-heading and body size; one opening higher is a finding, by path.
207
+ - **Pairs (3).** Section blocks' size and weight against their heading; grep `type.lead`, quote and stat styles.
208
+ - **Axes (4).** Each role's signal on shared sizes; one with no signal of its own is a collision.
209
+ - **Weights and families (5).** Loaded against used; weights with no nameable job; webfonts for a handful of spans.
210
+ - **Leading and tracking (6, 7).** Headings or rows with reading leading, tracked body copy, `px` letter-spacing, uppercase labels the preset doesn't allow.
211
+ - **Emphasis (9).** `font-synthesis` unset over a missing style, `<strong>` heavier than headings, UA italics on `cite` and `blockquote`.
212
+ - **Numerals (10).** Tables, prices, timers, counters or date columns without tabular figures; live numbers, watched for a second.
213
+ - **Imported prose (11).** Every HTML injection point, scoped and mapped or falling back to the browser.
214
+ - **Loading (12).** Third-party font hosts, `swap` without a metric-matched fallback, weights requested but unused.
215
+ - **Icons (13).** Icon sizes and strokes against the text beside them; sibling controls that differ.
216
+
217
+ Report one line per finding, with evidence, in `DESIGN.md` `## Open issues`: *"`type.lead` (`.intro`) is 20px under an 18px `type.heading` on `app/blog/[slug]` — outranks its heading."* Don't fix while auditing unless asked.
218
+
219
+ **Sweep after every type decision** (the sweep: `domains/patterns/iteration.md`). Raw sizes hide a partial type update from grep, so walk every route rendered against the `## Type` table and migrate raw sizes to role tokens as you go. → `domains/type/type-audit.md`
220
+
221
+ ## Writing back to DESIGN.md
222
+
223
+ - **`## Type`** — families; a row per role in use (project name, size, leading, tracking, weight, tier); project roles under their canonical parent with their separating axis; each weight's job, and with three the size × use table; each role's icon size. Keep existing names; mark new ones `proposed`.
224
+ - **`## Decisions`** — every dial moved off the preset, dated, with its reason (a third weight and the table that needed it, a raised ceiling and its role, italic for a true italic); any law knowingly broken, with the user's confirmation.
225
+ - **`## Open issues`** — each violation with its evidence, removed when fixed.
226
+
227
+ Write back whenever the ladder changes shape — a role, size, weight or family added or dropped. A size without a row is how a ladder turns back into a pile of values.
228
+
229
+ ## Checklist
230
+
231
+ - [ ] Every size maps to a named role in `DESIGN.md`; no unclaimed base size.
232
+ - [ ] One ladder per area; roles crossing a boundary keep one set of values.
233
+ - [ ] Nothing outranks its heading; no in-body heading outranks `type.title`; at most one `type.display` per page.
234
+ - [ ] Each role sharing a size owns a signal no other role there uses; each rank up moves weight or tracking too.
235
+ - [ ] One element, one set of values on every page; row and card titles are their darkest text.
236
+ - [ ] Every weight and family has a job; no weight changes on hover.
237
+ - [ ] Weight lifts one step, never on neighbours; one weight per set of titles; at most one state on weight.
238
+ - [ ] Active and selected states carry a signal besides colour.
239
+ - [ ] Display, reading and UI each use their own leading curve; fixed-height single-line UI uses 1.
240
+ - [ ] Tracking is in `em`: negative only above the text range, positive on tiny text and capitals, zero on body.
241
+ - [ ] UI and reading copy differ in leading, measure and tier; in-product help text is reading copy.
242
+ - [ ] `font-synthesis: none`; `<strong>` is `weight.strong`; no UA italic or bold leaks past the mapping.
243
+ - [ ] Aligned and live numbers are tabular, and the family ships `tnum`.
244
+ - [ ] Imported prose: one scoped class, existing roles, first-child margin reset, wide content scrolling in its own box.
245
+ - [ ] Fonts self-hosted, `swap`, metric-matched fallback; only used weights requested.
246
+ - [ ] Icons match the size and stroke of their text; inputs are 16px on touch widths.
247
+ - [ ] After a type change, every route swept rendered; no raw sizes left.
248
+ - [ ] Nothing a user must read sits below the readable floor.
249
+ - [ ] Dials match the preset: body size, ceiling ratio, weight and family counts, leading ranges, tracking at the top.
250
+ - [ ] Display derived from the measure; every fluid size clamped with a `rem` intercept; no text sized in `vw` alone.
251
+ - [ ] Italic, case (generated labels included), numeral defaults and the unit match the preset or a recorded decision.
@@ -0,0 +1,169 @@
1
+ # Ladders, per preset
2
+
3
+ Worked ladders for the three adaptable presets, and how to extend or cut one. The numbers assume an ordinary x-height; move them for your family, and record the move.
4
+
5
+ ## Against modular scales
6
+
7
+ The standard advice is a base and a ratio. From 14px at 1.25×:
8
+
9
+ ```
10
+ 14 → 17.5 → 21.88 → 27.34 → 34.18
11
+ ```
12
+
13
+ Three problems, under every preset:
14
+
15
+ - **Too few sizes where the chrome lives.** Between 11 and 16px sit tooltips, tags, timestamps, labels, nav rows and paragraphs — five or six densities; the ratio offers 14 and then 17.5.
16
+ - **Sizes nobody asked for.** 22, 27 and 34 arrive whether a role needs them or not, and one template using 27px "for variety" is how a scale stops being one.
17
+ - **Fractions.** 17.5 and 21.88 round differently across engines, and small subpixel type shows hinting artefacts.
18
+
19
+ A ratio can sanity-check *gaps* — a heading under about 1.15× the body relies on weight alone — but never produces sizes.
20
+
21
+ ## Editorial
22
+
23
+ Body 17px, an article's title well above it so the piece visibly starts, and everything inside the article close to the body, so the reading is the loudest thing on the page.
24
+
25
+ | Role | Size | Leading | Tracking | Weight | Tier | Separates on |
26
+ |---|---|---|---|---|---|---|
27
+ | `type.title` | 36 | 1.2 | −0.02em | strong | `text.primary` | size |
28
+ | `type.heading` | 20 | 1.3 | −0.015em | strong | `text.primary` | size |
29
+ | `type.subheading` | 17 | 1.4 | 0 | strong | `text.primary` | weight, tight leading, space above |
30
+ | `type.lead` | 17 | 1.85 | 0 | regular | `text.primary` | leading |
31
+ | `type.body` | 17 | 1.7 | 0 | regular | `text.primary` | the baseline |
32
+ | `type.supporting` | 17 | 1.5 | 0 | regular | `text.secondary` | tier, tighter leading |
33
+ | pull quote (project role) | 20 | 1.4 | 0 | regular, italic | `text.primary` | a bar and a voice, the reading family's italic |
34
+ | `type.ui` | 15 | 1 / 1.4 | 0 | regular | `text.secondary` | size, curve |
35
+ | `type.meta` | 14 | 1.5 | 0 | regular | `text.tertiary` | size, tier |
36
+ | `type.label` | 13 | 1.3 | 0 | regular | `text.secondary` | size |
37
+ | `type.code` | 15 | 1.6 | 0 | regular, mono | `text.secondary` | family |
38
+ | tooltip (project role under `type.meta`) | 12 | 1 | +0.01em | regular | `text.inverse` | the floor |
39
+
40
+ Seven sizes above the floor; ceiling 36 ÷ 17 = 2.12. An index page's title may sit lower, at `type.heading`. Four roles share 17px, each owning a signal. The pull quote may rise to `type.heading`'s size, never above it (law 3), and parts from the heading there on regular weight, the bar and the italic; without a true italic its voice is `weight.strong` instead. A quote at body size is a blockquote, not a pull quote. A subheading and a keyline (if the project has one) both take `weight.strong`: the subheading separates on tight leading and asymmetric space (it belongs to what follows), the keyline on open leading and symmetric space.
41
+
42
+ An article's navigation, buttons, tables and forms may take a sans `family.ui` when the reading face is a serif: reading and interface are two registers (law 5).
43
+
44
+ ## Product
45
+
46
+ Body 14px, chrome at 13. UI and data roles dominate; two weights, with strong at 500, so a heading is found by size and tier without going heavy — or a recorded third for headings (below).
47
+
48
+ | Role | Size | Leading | Tracking | Weight | Tier | Separates on |
49
+ |---|---|---|---|---|---|---|
50
+ | `type.title` | 20 | 1.3 | −0.02em | strong | `text.primary` | size |
51
+ | `type.heading` | 16 | 1.35 | −0.02em | strong | `text.primary` | size |
52
+ | `type.subheading` | 14 | 1.4 | −0.02em | strong | `text.primary` | weight, tracking |
53
+ | `type.body` | 14 | 1.5 | 0 | regular | `text.primary` | the baseline |
54
+ | `type.supporting` | 14 | 1.45 | 0 | regular | `text.secondary` | tier |
55
+ | `type.ui` | 13 | 1 / 1.4 | 0 | regular; strong when active | `text.secondary` | size, curve |
56
+ | `type.data` | 13 | 1 | 0 | regular; totals strong | `text.primary` | tabular figures |
57
+ | `type.label` | 12 | 1.3 | 0 | regular; strong as a field label | `text.secondary` | tier |
58
+ | `type.meta` | 12 | 1.4 | 0 | regular | `text.tertiary` | tier |
59
+ | group label (project role under `type.label`) | 12 | 1.3 | 0, sentence case | regular | `text.tertiary` | a hairline rule beneath |
60
+ | `type.code` | 12.5 | 1.5 | 0 | regular, mono | `text.secondary` | family |
61
+
62
+ Five sizes plus code; ceiling 20 ÷ 14 = 1.43, and the dial allows up to 22 if a dashboard title needs it. The 12.5 compensates for a family rather than serving a role: monospace runs wide and steps down to fill the same measure. The row applies wherever literal code appears; paths, URLs, IDs, hostnames and times stay in the text face, tabular with a slashed zero, unless they are code the reader will type.
63
+
64
+ Headline figures — a stat strip's values, a plan card's price — are data, not a tier (law 3): they sit at `type.title` or one step above it, and their label keeps the lead by position and weight. Headings from 14px take −0.02em as a role signal (the tracking dial); a figure or title at 24px and over, if the ladder has one, takes −0.04em. `type.ui`, `type.label` and the reading roles stay at 0.
65
+
66
+ A field's label is `type.label` at 12/500; its hint is `type.meta`, the same size in `text.tertiary` at 400. The label leads by weight and tier, not size. A row's second line may be 13px.
67
+
68
+ Two product rules. A dense table (a ledger, a log) may drop `type.ui` and `type.data` to 12px together, never one alone, or row and figures stop sharing a baseline rhythm. And an app's page title labels where you are; don't raise it to announce a feature. In a header bar it is chrome — the nav's size and weight, 13/500 — while the title of the open record or pane is the heading and takes the heading weight; one page setting its header title at 14/600 reads as a bug. A page uses one or the other: a header bar holding the title at chrome size, or a page that opens with a heading in its content at `type.title` — never both.
69
+
70
+ ### Three weights: quiet, lifted, heading
71
+
72
+ The recorded third weight, for a dense screen where most lines share two or three sizes and each needs a quiet and a lifted voice. Weight is chosen by what the text is doing in that spot; the table goes into `DESIGN.md` as written, one row per size in use. A records app's:
73
+
74
+ | Size | Quiet, 400 | Lifted, 500 | Heading, 600 |
75
+ |---|---|---|---|
76
+ | 20 | — | — | the open record's title |
77
+ | 16 | — | — | pane, dialog and section headings |
78
+ | 14 | body, descriptions, a board card's title | a list row's title | `type.subheading`; unread row title (the state weight) |
79
+ | 13 | a row's second line, a card's time | nav items, header-bar page title, tabs, buttons, table header labels | — |
80
+ | 12 | timestamps, hints, captions | field labels, a group label's count | — |
81
+
82
+ Its rules: nothing inside a row, card or folder goes above 500 but the state weight; lift by one step, never on two neighbouring lines; every title in a set takes one weight. A board's card titles sit a step lighter than a list's row titles: a board shows many titles at once, and at 500 the whole board reads heavy.
83
+
84
+ Expect the first pass to be a step too heavy everywhere. Correct it one step at a time per context, then sweep (`type-audit.md`) — a partial step-down is worse than none.
85
+
86
+ ### Worked example: Tally
87
+
88
+ **Tally**, a fictional invoicing app: preset `product`, with `app/(marketing)/** → expressive`; a grotesque with a large x-height, `tnum`, no italic, weights 400 and 500. The app shell is the ladder above, in tokens.
89
+
90
+ ```css
91
+ :root {
92
+ --font-sans: "Tally Grotesk", "Tally Grotesk Fallback", system-ui, sans-serif;
93
+ --font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
94
+
95
+ --text-title: 1.25rem; /* 20px type.title */
96
+ --text-heading: 1rem; /* 16px type.heading */
97
+ --text-body: 0.875rem; /* 14px type.body · type.subheading */
98
+ --text-ui: 0.8125rem; /* 13px type.ui */
99
+ --text-meta: 0.75rem; /* 12px type.meta · type.label */
100
+ --text-code: 0.78125rem; /* 12.5px type.code */
101
+
102
+ --weight-regular: 400;
103
+ --weight-strong: 500; /* headings, active tab, column totals, <strong> */
104
+ }
105
+ body { font: var(--weight-regular) var(--text-body)/1.5 var(--font-sans); font-synthesis: none; }
106
+ .data, td, time, .figure, .count { font-variant-numeric: tabular-nums; } /* product: every numeric element, not the root */
107
+ ```
108
+
109
+ `type.subheading` shares the body size and separates on weight; `type.label` and the group label share the meta size and separate on tier and the group label's rule; `type.data` has no size of its own. The marketing area adds a derived `type.display` and a larger ladder, while `type.ui` and `type.meta` stay identical across the boundary (law 2).
110
+
111
+ Its display tier is the neutral-grotesque derivation in `display-tier.md` — 960 ÷ (20 × 0.52) and 343 ÷ (14 × 0.52), between 375px and 1280px viewports: 47 → 92px at 1.0 leading and −0.035em. Against a 28–40px expressive `type.title`, it stays above 1.5× at both ends.
112
+
113
+ ## Expressive
114
+
115
+ Body 18px, a wide ladder, and a derived display tier above it all (`display-tier.md`).
116
+
117
+ | Role | Size | Leading | Tracking | Weight | Tier | Separates on |
118
+ |---|---|---|---|---|---|---|
119
+ | `type.display` | derived, fluid | 0.95–1.05 | −0.035em | `weight.display` | `text.primary` | size, family |
120
+ | `type.title` | 28 → 40, fluid | 1.15 | −0.025em | strong | `text.primary` | size |
121
+ | `type.heading` | 28 → 36, fluid | 1.2 | −0.02em | strong | `text.primary` | size |
122
+ | `type.subheading` | 21 | 1.3 | −0.01em | strong | `text.primary` | weight |
123
+ | `type.lead` | 21 | 1.55 | 0 | regular | `text.primary` | leading, weight |
124
+ | `type.body` | 18 | 1.6 | 0 | regular | `text.primary` | the baseline |
125
+ | `type.supporting` | 18 | 1.5 | 0 | regular | `text.secondary` | tier |
126
+ | `type.ui` | 16 | 1 / 1.4 | 0 | regular | `text.secondary` | size |
127
+ | `type.label` | 14 | 1.3 | 0 | strong | `text.secondary` | weight |
128
+ | `type.meta` | 14 | 1.5 | 0 | regular | `text.tertiary` | tier |
129
+ | eyebrow (project role under `type.label`) | 13 | 1.2 | +0.08em, caps | strong | `text.secondary` | case |
130
+ | `type.code` | 16 | 1.6 | 0 | regular, mono | `text.secondary` | family |
131
+
132
+ Seven sizes above the floor, excluding display; ceiling 40 ÷ 18 = 2.2. Title and section headings are fluid with display, each via `clamp()` with a `rem` intercept. Heading and phone title meet at 28 at the narrowest width; the title separates there on position and the space above it, acceptable because it is alone at the top of its page. A landing page whose `h1` is the display has no title: display clears 1.5 × the heading at every width — at least 42px on a phone and 54px at full width.
133
+
134
+ The 21 lead clears law 3 under a 28 heading, not under a 21 subheading; a lead never opens a subsection.
135
+
136
+ ## Adding a role
137
+
138
+ Ask, in order:
139
+
140
+ 1. **Can an existing size take it with different leading, weight or tier?** Usually.
141
+ 2. **Is it a new role, or a variant in a different container?** "Card title" and `type.subheading` are one role. "Modal title" is `type.heading`.
142
+ 3. **If it needs a size, which neighbour does it displace?** A ladder that only grows stopped being a system; name the two sizes it sits between and why neither can take it.
143
+
144
+ Record the answer as a project role under its canonical parent, with its separating signal.
145
+
146
+ ## Cutting a ladder
147
+
148
+ Cut from the top down, asking after each cut what the next rung was holding up. An old Glass preset ladder:
149
+
150
+ | Size | Was | Why it went |
151
+ |---|---|---|
152
+ | 30 | Article title | One template opening above every other page |
153
+ | 19 | Prose heading, pull quote | Came down with the title — an in-body heading can't outrank the page's own |
154
+ | 17 | Dek | Existed only to sit between 30 and 16 |
155
+ | 15 | `body` base | A default no role claimed |
156
+
157
+ Three of the four served the size above them — the usual shape.
158
+
159
+ ## Small steps are not small
160
+
161
+ In the 11–16px band a 1px step isn't negligible: perceived difference scales with ratio, and 1px at 12 is 8%, more than 2px at 24. Size also does the least work there — a 13px meta line and a 14px paragraph differ mostly by tier and position — so a small step can be a small supporting signal. Above the text range steps must widen; 2px at 40 is noise.
162
+
163
+ ## Viewports
164
+
165
+ Outside expressive display, title and section headings, nothing resizes. A phone held closer roughly cancels its narrower column, so a 17px paragraph reads at a similar apparent size; bumping body up gives four-word lines and a dominant ragged edge.
166
+
167
+ ## Units
168
+
169
+ Pick one and hold it. `rem` from a 16px root: 24 → 1.5rem, 20 → 1.25rem, 17 → 1.0625rem, 14 → 0.875rem, 13 → 0.8125rem, 12 → 0.75rem, 11 → 0.6875rem; a user's 20px root scales every step, which is the point. A ladder kept in `px` for exact 1px relationships says so in `## Decisions`, having traded a user setting for them.
@@ -0,0 +1,107 @@
1
+ # Numerals and loading
2
+
3
+ Two things nobody notices until they break: figures that hold still, and fonts that arrive without moving the text.
4
+
5
+ ## Figures
6
+
7
+ Digits come four ways, from two independent choices.
8
+
9
+ | | Proportional | Tabular |
10
+ |---|---|---|
11
+ | **Lining** (cap height, uniform) | Headlines, display numerals, a number in a UI label | Tables, prices, timers, dates in a column — the default for interfaces |
12
+ | **Oldstyle** (ascend and descend like lowercase) | Numbers in running prose, in an editorial system whose family has them | Rarely: aligned figures in a book-like table |
13
+
14
+ ```css
15
+ font-variant-numeric: tabular-nums; /* digits share one advance */
16
+ font-variant-numeric: lining-nums tabular-nums; /* for families that default to oldstyle */
17
+ font-variant-numeric: oldstyle-nums proportional-nums; /* prose, editorial only */
18
+ font-variant-numeric: slashed-zero; /* IDs, codes, anything with O and 0 */
19
+ font-variant-numeric: diagonal-fractions; /* 1/2 → ½, where the family supports it */
20
+ ```
21
+
22
+ Prefer it to the low-level `font-feature-settings: "tnum" 1, "zero" 1`: `font-feature-settings` is all-or-nothing, so a later rule setting one feature silently drops the others.
23
+
24
+ ### Where tabular is mandatory
25
+
26
+ - **Any column.** Prices unaligned on the decimal can't be scanned; the eye reads the column's shape first.
27
+ - **Anything live.** A clock, a countdown, a counter, an upload percentage, a ticker. Proportional digits change width, so the row twitches every update and the rest of the line jitters.
28
+ - **Dates and times in lists** — an experience list, a changelog, a log. Ranges that don't line up look unfinished.
29
+ - **Ordered-list markers past nine.**
30
+
31
+ Proportional figures are right in a running sentence, where tabular spacing opens odd gaps around a 1, and in display numerals, where those gaps show at size.
32
+
33
+ ### Global or scoped
34
+
35
+ The dial decides: `product` sets tabular on every number, scoped to numeric elements — tables, figures, counters, dates — rather than the root; `editorial` scopes it to tables, lists, meta lines and anything live, leaving prose proportional; `expressive` sets it on `type.data` only. No aligned or live number is proportional.
36
+
37
+ Scope it even where nearly every number aligns: some families' `tnum` also changes punctuation — Inter's widens the hyphen, so "Sign-ups" opens a gap — and set on the root it disturbs text that has no figures in it.
38
+
39
+ ### Check the family has it
40
+
41
+ Without `tnum`, a family renders proportional digits and throws no error. Test in the DOM, not the specimen (canvas can't apply `font-variant-numeric`):
42
+
43
+ ```js
44
+ const probe = (s) => { const el = Object.assign(document.createElement("span"), { textContent: s });
45
+ el.style.cssText = "font: 400 16px 'Your Family'; font-variant-numeric: tabular-nums; position:absolute";
46
+ document.body.append(el); const w = el.getBoundingClientRect().width; el.remove(); return w; };
47
+ probe("1111") === probe("0000"); // false: the family has no tnum, and needs another plan
48
+ ```
49
+
50
+ If not, set `type.data` in the mono or a sibling family that has it, and record the decision.
51
+
52
+ ### Aligning a numeric column
53
+
54
+ - **Right-align numbers**, left-align text, and align each header with its column.
55
+ - **Fixed decimals within a column.** 12.5 above 3.25 misaligns even in tabular figures; show both to two places.
56
+ - **Units in the header, not the cells.** "Amount (GBP)" over bare figures, not a currency sign pushing each row's digits out of line. A sign that must be in the cell gets its own column, or pad with a figure space (U+2007), one tabular digit wide.
57
+ - **A real minus.** U+2212 (−) is a plus's width, on the maths axis; a hyphen is narrower and low, and misaligns negatives by a hair.
58
+ - **Totals take a weight, not a size.** In `product`, `weight.strong` separates a totals row without breaking the column's rhythm.
59
+
60
+ ## Loading
61
+
62
+ ### Self-host
63
+
64
+ A third-party font service means a DNS lookup and TLS handshake for the stylesheet, then again for the file host, before the font is requested. `preconnect` shortens that but can't remove it: you still wait on a stylesheet from one origin to learn a file URL on another. Self-hosted, the file rides the HTML's connection and can be preloaded from the first byte. Framework loaders do this at build time — `next/font`, `@nuxt/fonts` — and `@fontsource` packages for any bundler; otherwise, serve the files with the static assets.
65
+
66
+ ### Request only what the table uses
67
+
68
+ Load the weights `DESIGN.md` lists and nothing else: five loaded, two used, is three downloads with no job. A variable file covering the range is usually smaller than three static ones. Subset to the scripts you ship with `unicode-range`; the `expressive` display face needs far less than the text face.
69
+
70
+ Preload only what the first frame needs — the text face at `weight.regular` and, in `expressive`, the display weight; preloading everything competes with the page for bandwidth.
71
+
72
+ Mono for code is a system stack — `ui-monospace, "SF Mono", Menlo, Consolas, monospace` — unless code is the product; every platform ships a good one.
73
+
74
+ ### Swap, with a metric-matched fallback
75
+
76
+ `font-display: swap` stays: a flash of fallback text beats a flash of nothing. But swapping faces with different metrics reflows every line under the reader. The fix is a fallback scaled to the webfont's metrics, its own `@font-face` over a local system font:
77
+
78
+ ```css
79
+ @font-face {
80
+ font-family: "Brand Sans Fallback";
81
+ src: local("Arial");
82
+ size-adjust: <S>;
83
+ ascent-override: <A>;
84
+ descent-override: <D>;
85
+ line-gap-override: <L>;
86
+ }
87
+ :root { --font-sans: "Brand Sans", "Brand Sans Fallback", system-ui, sans-serif; }
88
+ ```
89
+
90
+ From the webfont's metrics (units per em `U`, ascent, descent, line gap, and average character width `w`) and the fallback's average width `wf` in its own units per em `Uf`:
91
+
92
+ ```
93
+ S = (w / U) ÷ (wf / Uf) a ratio; write it in CSS as a percentage
94
+ A = (ascent / U) ÷ S
95
+ D = (|descent| / U) ÷ S
96
+ L = (lineGap / U) ÷ S
97
+ ```
98
+
99
+ `next/font` generates this (`adjustFontFallback`, on by default); `fontaine` and Capsize's `createFontStack` do it for any stack. Use them rather than tuning by eye, and match vertical metrics as well as width: width alone still changes the line box.
100
+
101
+ ### Tune for the frame that shows it
102
+
103
+ A swap costs most where one string carries the screen — an opening line alone, a hero headline, one large dashboard number. Preload, subset and match for that frame, not the average page: in body text a matched swap is invisible; under a lone headline it is the first thing seen.
104
+
105
+ ### Smoothing
106
+
107
+ `-webkit-font-smoothing: antialiased` thins text on macOS, most visibly light on dark. Set it on `body` once or not at all; per component, one weight looks different in two places.