@hanzo/design 0.5.11 → 0.5.12

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.
@@ -42,6 +42,11 @@ export interface Preference {
42
42
  * above 1 opens the display end and tightens the small one.
43
43
  */
44
44
  ratio?: number;
45
+ /**
46
+ * A modular scale for the display rungs — 1.618 for golden, 1.25 for a major
47
+ * third. Absent means the authored ramp, which is the tuned default.
48
+ */
49
+ modular?: number;
45
50
  density?: Density;
46
51
  /** Which face the page is set in. */
47
52
  font?: Face;
@@ -74,6 +79,31 @@ export declare const TYPE_MAX = 1.4;
74
79
  */
75
80
  export declare const RATIO_MIN = 0.75;
76
81
  export declare const RATIO_MAX = 1.5;
82
+ /**
83
+ * A MODULAR scale — the classical one, where each display rung is the one below
84
+ * it times a fixed ratio. Golden is 1.618; the musical intervals designers name
85
+ * are 1.2 (minor third), 1.25 (major third), 1.333 (perfect fourth) and 1.5
86
+ * (perfect fifth).
87
+ *
88
+ * This is a different RULE from `ratio`, not another dial on it. `ratio` tunes
89
+ * the contrast of the ramp `tokens/typography.css` authored; this REPLACES that
90
+ * ramp's display half with a geometric one. Naming a golden-ratio preset as a
91
+ * contrast value would have been the dishonest version of this feature.
92
+ *
93
+ * Bounded below 1.05 because a ratio at 1 is not a scale — every display rung
94
+ * collapses onto the one before it — and above 2 because doubling every step
95
+ * puts the fourth rung past a phone's whole width.
96
+ *
97
+ * Golden is exactly 1.618 here and is NOT clamped down to something tamer,
98
+ * because a scale that quietly gives you not-golden when you asked for golden is
99
+ * worse than one that refuses. What makes that safe is the ceiling below: this
100
+ * ramp has EIGHT display rungs and a classical modular scale is used with about
101
+ * four, so at 1.618 the eighth would be 799px. Measured, --text-8xl and
102
+ * --text-9xl are referenced in 15 files across the fleet, so that is a broken
103
+ * page and not a hypothetical.
104
+ */
105
+ export declare const MODULAR_MIN = 1.05;
106
+ export declare const MODULAR_MAX = 2;
77
107
  /**
78
108
  * Is this a colour, or is it something being smuggled into a style attribute?
79
109
  *
@@ -1 +1 @@
1
- {"version":3,"file":"preference.d.ts","sourceRoot":"","sources":["../src/preference.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,MAAM,MAAM,OAAO,GAAG,SAAS,GAAG,SAAS,GAAG,aAAa,CAAC;AAE5D;;;GAGG;AACH,MAAM,MAAM,IAAI,GAAG,SAAS,GAAG,QAAQ,GAAG,OAAO,GAAG,MAAM,CAAC;AAE3D,8CAA8C;AAC9C,MAAM,MAAM,OAAO,GAAG,QAAQ,GAAG,SAAS,GAAG,MAAM,CAAC;AAEpD,MAAM,WAAW,UAAU;IACzB,6DAA6D;IAC7D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,qCAAqC;IACrC,IAAI,CAAC,EAAE,IAAI,CAAC;IACZ,4DAA4D;IAC5D,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,wEAAwE;IACxE,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,QAAQ,OAAO,CAAC;AAC7B,eAAO,MAAM,QAAQ,MAAM,CAAC;AAE5B;;;;;;;;;;GAUG;AACH,eAAO,MAAM,SAAS,OAAO,CAAC;AAC9B,eAAO,MAAM,SAAS,MAAM,CAAC;AAsD7B;;;;;;;GAOG;AACH,wBAAgB,OAAO,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAS1C;AAED;;;;;GAKG;AACH,wBAAgB,IAAI,CAAC,CAAC,EAAE,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAqC1D;AAED,2EAA2E;AAC3E,wBAAgB,GAAG,CAAC,CAAC,EAAE,UAAU,EAAE,QAAQ,SAAc,GAAG,MAAM,CAKjE"}
1
+ {"version":3,"file":"preference.d.ts","sourceRoot":"","sources":["../src/preference.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,MAAM,MAAM,OAAO,GAAG,SAAS,GAAG,SAAS,GAAG,aAAa,CAAC;AAE5D;;;GAGG;AACH,MAAM,MAAM,IAAI,GAAG,SAAS,GAAG,QAAQ,GAAG,OAAO,GAAG,MAAM,CAAC;AAE3D,8CAA8C;AAC9C,MAAM,MAAM,OAAO,GAAG,QAAQ,GAAG,SAAS,GAAG,MAAM,CAAC;AAEpD,MAAM,WAAW,UAAU;IACzB,6DAA6D;IAC7D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,qCAAqC;IACrC,IAAI,CAAC,EAAE,IAAI,CAAC;IACZ,4DAA4D;IAC5D,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,wEAAwE;IACxE,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,QAAQ,OAAO,CAAC;AAC7B,eAAO,MAAM,QAAQ,MAAM,CAAC;AAE5B;;;;;;;;;;GAUG;AACH,eAAO,MAAM,SAAS,OAAO,CAAC;AAC9B,eAAO,MAAM,SAAS,MAAM,CAAC;AAE7B;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,WAAW,OAAO,CAAC;AAChC,eAAO,MAAM,WAAW,IAAI,CAAC;AAmE7B;;;;;;;GAOG;AACH,wBAAgB,OAAO,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAS1C;AAED;;;;;GAKG;AACH,wBAAgB,IAAI,CAAC,CAAC,EAAE,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CA0D1D;AAED,2EAA2E;AAC3E,wBAAgB,GAAG,CAAC,CAAC,EAAE,UAAU,EAAE,QAAQ,SAAc,GAAG,MAAM,CAKjE"}
@@ -49,6 +49,43 @@ export const TYPE_MAX = 1.4;
49
49
  */
50
50
  export const RATIO_MIN = 0.75;
51
51
  export const RATIO_MAX = 1.5;
52
+ /**
53
+ * A MODULAR scale — the classical one, where each display rung is the one below
54
+ * it times a fixed ratio. Golden is 1.618; the musical intervals designers name
55
+ * are 1.2 (minor third), 1.25 (major third), 1.333 (perfect fourth) and 1.5
56
+ * (perfect fifth).
57
+ *
58
+ * This is a different RULE from `ratio`, not another dial on it. `ratio` tunes
59
+ * the contrast of the ramp `tokens/typography.css` authored; this REPLACES that
60
+ * ramp's display half with a geometric one. Naming a golden-ratio preset as a
61
+ * contrast value would have been the dishonest version of this feature.
62
+ *
63
+ * Bounded below 1.05 because a ratio at 1 is not a scale — every display rung
64
+ * collapses onto the one before it — and above 2 because doubling every step
65
+ * puts the fourth rung past a phone's whole width.
66
+ *
67
+ * Golden is exactly 1.618 here and is NOT clamped down to something tamer,
68
+ * because a scale that quietly gives you not-golden when you asked for golden is
69
+ * worse than one that refuses. What makes that safe is the ceiling below: this
70
+ * ramp has EIGHT display rungs and a classical modular scale is used with about
71
+ * four, so at 1.618 the eighth would be 799px. Measured, --text-8xl and
72
+ * --text-9xl are referenced in 15 files across the fleet, so that is a broken
73
+ * page and not a hypothetical.
74
+ */
75
+ export const MODULAR_MIN = 1.05;
76
+ export const MODULAR_MAX = 2;
77
+ /**
78
+ * The rungs a modular scale REGENERATES, in order, and the one it starts from.
79
+ *
80
+ * It deliberately stops at the display register. A geometric scale through the
81
+ * interface rungs is unusable at any real ratio: at 1.25 from a 14px base the
82
+ * next rungs are 17.5 and 21.9, so the 13px nav label and the 15px lead — the
83
+ * near-linear steps a dense interface is built on — do not exist. `xl` is the
84
+ * anchor because it is the last interface rung, so the display half continues
85
+ * from where the interface ends rather than restarting under it.
86
+ */
87
+ const DISPLAY_RUNGS = ["2xl", "3xl", "4xl", "5xl", "6xl", "7xl", "8xl", "9xl"];
88
+ const MODULAR_ANCHOR_REM = 1.0625; // --text-xl, 17px
52
89
  /**
53
90
  * Density moves SPACING only, and its range is much tighter than type's.
54
91
  *
@@ -128,6 +165,26 @@ export function vars(p) {
128
165
  if (typeof p.ratio === "number" && Number.isFinite(p.ratio)) {
129
166
  out["--type-ratio"] = round(clamp(p.ratio, RATIO_MIN, RATIO_MAX));
130
167
  }
168
+ if (typeof p.modular === "number" && Number.isFinite(p.modular)) {
169
+ // Each display rung is the anchor times the ratio to its step, emitted as an
170
+ // explicit property. An INLINE custom property on :root outranks the
171
+ // stylesheet, so these simply replace the authored rungs — nothing has to be
172
+ // unset first, and the interface rungs the sheet declares are untouched
173
+ // because none is named here. It multiplies out with `--type-scale` for free,
174
+ // since every rung is still read through that ramp's own calc.
175
+ const r = clamp(p.modular, MODULAR_MIN, MODULAR_MAX);
176
+ DISPLAY_RUNGS.forEach((rung, i) => {
177
+ const rem = MODULAR_ANCHOR_REM * Math.pow(r, i + 1);
178
+ // THE SAME clamp the authored ramp carries, read from the same two names.
179
+ // A regenerated rung and an authored one must be bounded identically or
180
+ // the bound is a property of which code path produced the value, which is
181
+ // exactly the kind of "it depends" a design system exists to delete. The
182
+ // bounds are declared once, in tokens/typography.css, and referenced here
183
+ // — never restated, so moving one moves both.
184
+ out[`--text-${rung}`] =
185
+ `clamp(var(--text-floor), calc(${round(rem)}rem * var(--type-scale, 1)), var(--text-ceiling))`;
186
+ });
187
+ }
131
188
  if (p.density && p.density in DENSITY) {
132
189
  out["--density"] = round(DENSITY[p.density]);
133
190
  }
@@ -93,6 +93,8 @@ export declare const colors: {
93
93
  export declare const typography: {
94
94
  readonly 'type-scale': "1";
95
95
  readonly 'type-ratio': "1";
96
+ readonly 'text-floor': "0.625rem";
97
+ readonly 'text-ceiling': "30vw";
96
98
  readonly 'text-xs': "0.6875rem";
97
99
  readonly 'leading-xs': "1rem";
98
100
  readonly 'text-sm': "0.8125rem";
@@ -386,6 +388,8 @@ export declare const cssVars: {
386
388
  readonly '--chrome-dot-green': "rgb(34 197 94 / .6)";
387
389
  readonly '--type-scale': "1";
388
390
  readonly '--type-ratio': "1";
391
+ readonly '--text-floor': "0.625rem";
392
+ readonly '--text-ceiling': "30vw";
389
393
  readonly '--text-xs': "0.6875rem";
390
394
  readonly '--leading-xs': "1rem";
391
395
  readonly '--text-sm': "0.8125rem";
@@ -1 +1 @@
1
- {"version":3,"file":"tokens.gen.d.ts","sourceRoot":"","sources":["../src/tokens.gen.ts"],"names":[],"mappings":"AAIA,kEAAkE;AAClE,eAAO,MAAM,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyFT,CAAA;AAEV,0EAA0E;AAC1E,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiEb,CAAA;AAEV,oEAAoE;AACpE,eAAO,MAAM,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2CV,CAAA;AAEV,8DAA8D;AAC9D,eAAO,MAAM,IAAI;;;;;;;;;;;;CAYP,CAAA;AAEV,kEAAkE;AAClE,eAAO,MAAM,MAAM;;;;;;;;;CAST,CAAA;AAEV,wEAAwE;AACxE,eAAO,MAAM,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2BZ,CAAA;AAEV,kEAAkE;AAClE,eAAO,MAAM,MAAM;;;;;;;;;;;;;CAaT,CAAA;AAEV,6DAA6D;AAC7D,eAAO,MAAM,MAAM;;;;;;;;;;;;CAYT,CAAA;AAEV,gEAAgE;AAChE,eAAO,MAAM,KAAK;;;;;;CAMR,CAAA;AAEV,8DAA8D;AAC9D,eAAO,MAAM,IAAI,IAEP,CAAA;AAEV,sFAAsF;AACtF,eAAO,MAAM,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4QV,CAAA;AAEV,MAAM,MAAM,UAAU,GAAG,MAAM,OAAO,OAAO,CAAA"}
1
+ {"version":3,"file":"tokens.gen.d.ts","sourceRoot":"","sources":["../src/tokens.gen.ts"],"names":[],"mappings":"AAIA,kEAAkE;AAClE,eAAO,MAAM,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyFT,CAAA;AAEV,0EAA0E;AAC1E,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmEb,CAAA;AAEV,oEAAoE;AACpE,eAAO,MAAM,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2CV,CAAA;AAEV,8DAA8D;AAC9D,eAAO,MAAM,IAAI;;;;;;;;;;;;CAYP,CAAA;AAEV,kEAAkE;AAClE,eAAO,MAAM,MAAM;;;;;;;;;CAST,CAAA;AAEV,wEAAwE;AACxE,eAAO,MAAM,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2BZ,CAAA;AAEV,kEAAkE;AAClE,eAAO,MAAM,MAAM;;;;;;;;;;;;;CAaT,CAAA;AAEV,6DAA6D;AAC7D,eAAO,MAAM,MAAM;;;;;;;;;;;;CAYT,CAAA;AAEV,gEAAgE;AAChE,eAAO,MAAM,KAAK;;;;;;CAMR,CAAA;AAEV,8DAA8D;AAC9D,eAAO,MAAM,IAAI,IAEP,CAAA;AAEV,sFAAsF;AACtF,eAAO,MAAM,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8QV,CAAA;AAEV,MAAM,MAAM,UAAU,GAAG,MAAM,OAAO,OAAO,CAAA"}
@@ -96,6 +96,8 @@ export const colors = {
96
96
  export const typography = {
97
97
  'type-scale': '1',
98
98
  'type-ratio': '1',
99
+ 'text-floor': '0.625rem',
100
+ 'text-ceiling': '30vw',
99
101
  'text-xs': '0.6875rem',
100
102
  'leading-xs': '1rem',
101
103
  'text-sm': '0.8125rem',
@@ -389,6 +391,8 @@ export const cssVars = {
389
391
  '--chrome-dot-green': 'rgb(34 197 94 / .6)',
390
392
  '--type-scale': '1',
391
393
  '--type-ratio': '1',
394
+ '--text-floor': '0.625rem',
395
+ '--text-ceiling': '30vw',
392
396
  '--text-xs': '0.6875rem',
393
397
  '--leading-xs': '1rem',
394
398
  '--text-sm': '0.8125rem',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/design",
3
- "version": "0.5.11",
3
+ "version": "0.5.12",
4
4
  "packageManager": "pnpm@11.17.0",
5
5
  "description": "Hanzo Design System — monochrome, dark-default tokens + components + brand assets, the single source of truth for every Hanzo surface. CSS + typed programmatic tokens.",
6
6
  "license": "MIT OR Apache-2.0",
@@ -102,6 +102,12 @@ check("EVERY ramp rung carries its multiplier — no rung can opt out", () => {
102
102
  const value = m[1].trim();
103
103
  if (value === "0") continue; // zero times anything is zero
104
104
  if (value.startsWith("var(")) continue; // an alias inherits its target's calc
105
+ // --text-floor / --text-ceiling are the BOUNDS every rung is clamped
106
+ // between, not rungs. A bound that scaled with the knob would move with
107
+ // the value it exists to bound and could never bind — which is the whole
108
+ // reason a floor is stated in absolute units and a ceiling in the
109
+ // viewport's.
110
+ if (/^--text-(floor|ceiling):/.test(m[0])) continue;
105
111
  if (!value.includes(`var(${knob}`)) bare.push(`${file}: ${m[0].trim()}`);
106
112
  }
107
113
  }
@@ -144,6 +150,45 @@ check("an unknown stored value is refused, not passed through", () => {
144
150
  eq(vars({ width: "enormous" }), {});
145
151
  });
146
152
 
153
+ check("a modular scale regenerates the DISPLAY rungs, geometrically", () => {
154
+ const v = vars({ modular: 1.618 });
155
+ const names = Object.keys(v);
156
+ eq(names.length, 8, "a modular scale emits the eight display rungs:");
157
+ // Each rung is the one below it times the ratio — that IS the scale.
158
+ const rem = (n) => parseFloat(v[`--text-${n}`].match(/([\d.]+)rem/)[1]);
159
+ const rungs = ["2xl","3xl","4xl","5xl","6xl","7xl","8xl","9xl"];
160
+ for (let i = 1; i < rungs.length; i++) {
161
+ const r = rem(rungs[i]) / rem(rungs[i - 1]);
162
+ ok(Math.abs(r - 1.618) < 0.001, `${rungs[i]}/${rungs[i-1]} = ${r}, want the ratio`);
163
+ }
164
+ // Anchored on xl (17px), so the display half continues the interface rather
165
+ // than restarting under it.
166
+ ok(Math.abs(rem("2xl") - 1.0625 * 1.618) < 0.001, "2xl must be xl x ratio");
167
+ });
168
+
169
+ check("a modular scale leaves the INTERFACE rungs alone", () => {
170
+ // A geometric scale through xs..xl is unusable at any real ratio: the 13px nav
171
+ // label and the 15px lead simply do not exist on one.
172
+ const emitted = Object.keys(vars({ modular: 1.618 }));
173
+ for (const n of ["--text-xs", "--text-sm", "--text-base", "--text-lg", "--text-xl"]) {
174
+ ok(!emitted.includes(n), `${n} is the interface register and must not be regenerated`);
175
+ }
176
+ });
177
+
178
+ check("a modular scale still answers to the size knob", () => {
179
+ // It multiplies out with --type-scale rather than freezing a px value.
180
+ ok(vars({ modular: 1.5 })["--text-4xl"].includes("var(--type-scale, 1)"),
181
+ "a regenerated rung must carry the size knob");
182
+ });
183
+
184
+ check("the modular ratio is CLAMPED to something that is still a scale", () => {
185
+ // At 1 every display rung collapses onto the one below it.
186
+ const flat = vars({ modular: 1 });
187
+ const rem = (v, n) => parseFloat(v[`--text-${n}`].match(/([\d.]+)rem/)[1]);
188
+ ok(rem(flat, "3xl") > rem(flat, "2xl"), "a clamped ratio must still ascend");
189
+ eq(vars({ modular: 99 })["--text-2xl"], vars({ modular: 2 })["--text-2xl"], "max:");
190
+ });
191
+
147
192
  check("a colour lands on both --primary and --accent", () => {
148
193
  const v = vars({ accent: "#808000" });
149
194
  eq(v["--primary"], "#808000");
@@ -64,12 +64,24 @@ function parse(css) {
64
64
  * against a knob the table does not have.
65
65
  */
66
66
  const KNOB = /^calc\(\s*([^*]+?)\s*\*\s*var\(--(?:type-scale|density),\s*1\)\s*\)$/
67
- const FLOOR = /^max\(\s*[\d.]+[a-z%]*\s*,\s*(calc\(.+\))\s*\)$/
67
+ // The BOUNDS peel first. Every rung is `clamp(floor, value, ceiling)` — one
68
+ // rule at both ends — and the table carries the VALUE: a bound is a rendering
69
+ // decision the browser makes against a live viewport, so a JS table cannot hold
70
+ // one and must not pretend to. `max(floor, value)` is the older one-ended form,
71
+ // kept so a token file mid-migration still generates.
72
+ const BOUNDS = [
73
+ /^clamp\(\s*[^,]+,\s*(calc\(.*\))\s*,\s*[^,]+\)$/,
74
+ /^max\(\s*[\d.]+[a-z%]*\s*,\s*(calc\(.+\))\s*\)$/,
75
+ ]
68
76
  const RUNG =
69
77
  /^calc\(\(\s*([\d.]+)(r?em|px)\s*([+-])\s*([\d.]+)(?:r?em|px)\s*\*\s*var\(--type-ratio,\s*1\)\s*\)\s*\*\s*var\(--type-scale,\s*1\)\s*\)$/
70
78
 
71
79
  const scale = (v) => {
72
- const inner = v.match(FLOOR)?.[1] ?? v
80
+ let inner = v
81
+ for (const b of BOUNDS) {
82
+ const m = inner.match(b)
83
+ if (m) { inner = m[1]; break }
84
+ }
73
85
  const r = inner.match(RUNG)
74
86
  if (r) {
75
87
  const [, anchor, unit, sign, delta] = r
package/src/preference.ts CHANGED
@@ -46,6 +46,11 @@ export interface Preference {
46
46
  * above 1 opens the display end and tightens the small one.
47
47
  */
48
48
  ratio?: number;
49
+ /**
50
+ * A modular scale for the display rungs — 1.618 for golden, 1.25 for a major
51
+ * third. Absent means the authored ramp, which is the tuned default.
52
+ */
53
+ modular?: number;
49
54
  density?: Density;
50
55
  /** Which face the page is set in. */
51
56
  font?: Face;
@@ -81,6 +86,45 @@ export const TYPE_MAX = 1.4;
81
86
  export const RATIO_MIN = 0.75;
82
87
  export const RATIO_MAX = 1.5;
83
88
 
89
+ /**
90
+ * A MODULAR scale — the classical one, where each display rung is the one below
91
+ * it times a fixed ratio. Golden is 1.618; the musical intervals designers name
92
+ * are 1.2 (minor third), 1.25 (major third), 1.333 (perfect fourth) and 1.5
93
+ * (perfect fifth).
94
+ *
95
+ * This is a different RULE from `ratio`, not another dial on it. `ratio` tunes
96
+ * the contrast of the ramp `tokens/typography.css` authored; this REPLACES that
97
+ * ramp's display half with a geometric one. Naming a golden-ratio preset as a
98
+ * contrast value would have been the dishonest version of this feature.
99
+ *
100
+ * Bounded below 1.05 because a ratio at 1 is not a scale — every display rung
101
+ * collapses onto the one before it — and above 2 because doubling every step
102
+ * puts the fourth rung past a phone's whole width.
103
+ *
104
+ * Golden is exactly 1.618 here and is NOT clamped down to something tamer,
105
+ * because a scale that quietly gives you not-golden when you asked for golden is
106
+ * worse than one that refuses. What makes that safe is the ceiling below: this
107
+ * ramp has EIGHT display rungs and a classical modular scale is used with about
108
+ * four, so at 1.618 the eighth would be 799px. Measured, --text-8xl and
109
+ * --text-9xl are referenced in 15 files across the fleet, so that is a broken
110
+ * page and not a hypothetical.
111
+ */
112
+ export const MODULAR_MIN = 1.05;
113
+ export const MODULAR_MAX = 2;
114
+
115
+ /**
116
+ * The rungs a modular scale REGENERATES, in order, and the one it starts from.
117
+ *
118
+ * It deliberately stops at the display register. A geometric scale through the
119
+ * interface rungs is unusable at any real ratio: at 1.25 from a 14px base the
120
+ * next rungs are 17.5 and 21.9, so the 13px nav label and the 15px lead — the
121
+ * near-linear steps a dense interface is built on — do not exist. `xl` is the
122
+ * anchor because it is the last interface rung, so the display half continues
123
+ * from where the interface ends rather than restarting under it.
124
+ */
125
+ const DISPLAY_RUNGS = ["2xl", "3xl", "4xl", "5xl", "6xl", "7xl", "8xl", "9xl"];
126
+ const MODULAR_ANCHOR_REM = 1.0625; // --text-xl, 17px
127
+
84
128
  /**
85
129
  * Density moves SPACING only, and its range is much tighter than type's.
86
130
  *
@@ -169,6 +213,27 @@ export function vars(p: Preference): Record<string, string> {
169
213
  out["--type-ratio"] = round(clamp(p.ratio, RATIO_MIN, RATIO_MAX));
170
214
  }
171
215
 
216
+ if (typeof p.modular === "number" && Number.isFinite(p.modular)) {
217
+ // Each display rung is the anchor times the ratio to its step, emitted as an
218
+ // explicit property. An INLINE custom property on :root outranks the
219
+ // stylesheet, so these simply replace the authored rungs — nothing has to be
220
+ // unset first, and the interface rungs the sheet declares are untouched
221
+ // because none is named here. It multiplies out with `--type-scale` for free,
222
+ // since every rung is still read through that ramp's own calc.
223
+ const r = clamp(p.modular, MODULAR_MIN, MODULAR_MAX);
224
+ DISPLAY_RUNGS.forEach((rung, i) => {
225
+ const rem = MODULAR_ANCHOR_REM * Math.pow(r, i + 1);
226
+ // THE SAME clamp the authored ramp carries, read from the same two names.
227
+ // A regenerated rung and an authored one must be bounded identically or
228
+ // the bound is a property of which code path produced the value, which is
229
+ // exactly the kind of "it depends" a design system exists to delete. The
230
+ // bounds are declared once, in tokens/typography.css, and referenced here
231
+ // — never restated, so moving one moves both.
232
+ out[`--text-${rung}`] =
233
+ `clamp(var(--text-floor), calc(${round(rem)}rem * var(--type-scale, 1)), var(--text-ceiling))`;
234
+ });
235
+ }
236
+
172
237
  if (p.density && p.density in DENSITY) {
173
238
  out["--density"] = round(DENSITY[p.density]);
174
239
  }
package/src/tokens.gen.ts CHANGED
@@ -98,6 +98,8 @@ export const colors = {
98
98
  export const typography = {
99
99
  'type-scale': '1',
100
100
  'type-ratio': '1',
101
+ 'text-floor': '0.625rem',
102
+ 'text-ceiling': '30vw',
101
103
  'text-xs': '0.6875rem',
102
104
  'leading-xs': '1rem',
103
105
  'text-sm': '0.8125rem',
@@ -402,6 +404,8 @@ export const cssVars = {
402
404
  '--chrome-dot-green': 'rgb(34 197 94 / .6)',
403
405
  '--type-scale': '1',
404
406
  '--type-ratio': '1',
407
+ '--text-floor': '0.625rem',
408
+ '--text-ceiling': '30vw',
405
409
  '--text-xs': '0.6875rem',
406
410
  '--leading-xs': '1rem',
407
411
  '--text-sm': '0.8125rem',
package/styles.css CHANGED
@@ -316,24 +316,36 @@
316
316
  A rung is one place, so a rung added later needs a delta and nothing else. */
317
317
  --type-ratio:1;
318
318
 
319
- /* --text-xs/sm carry a floor because the knobs COMPOUND: a flat ratio shrinks
320
- the small end while a small scale shrinks everything, and the two multiply.
321
- Clamping each knob's range separately cannot fix that (0.85 x 0.9 is inside
322
- both bounds and lands at 8.5px); flooring the RESULT can, so no combination
323
- of a person's settings and an org's renders its own labels illegible. */
324
- --text-xs:max(0.625rem, calc((0.875rem - 0.1875rem * var(--type-ratio, 1)) * var(--type-scale, 1))); --leading-xs:calc((1.35rem - 0.35rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 11px — eyebrows / section labels */
325
- --text-sm:max(0.6875rem, calc((0.875rem - 0.0625rem * var(--type-ratio, 1)) * var(--type-scale, 1))); --leading-sm:calc((1.35rem - 0.2rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 13px nav labels, dense body */
326
- --text-base:calc(0.875rem * var(--type-scale, 1)); --leading-base:calc(1.35rem * var(--type-scale, 1)); /* 14px — the ANCHOR: the rung the ratio pivots on, so it carries no ratio term */
327
- --text-lg:calc((0.875rem + 0.0625rem * var(--type-ratio, 1)) * var(--type-scale, 1)); --leading-lg:calc((1.35rem + 0.05rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 15px */
328
- --text-xl:calc((0.875rem + 0.1875rem * var(--type-ratio, 1)) * var(--type-scale, 1)); --leading-xl:calc((1.35rem + 0.2rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 17px */
329
- --text-2xl:calc((0.875rem + 0.4375rem * var(--type-ratio, 1)) * var(--type-scale, 1)); --leading-2xl:calc((1.35rem + 0.35rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 21px */
330
- --text-3xl:calc((0.875rem + 0.75rem * var(--type-ratio, 1)) * var(--type-scale, 1)); --leading-3xl:calc((1.35rem + 0.6rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 26px */
331
- --text-4xl:calc((0.875rem + 1.125rem * var(--type-ratio, 1)) * var(--type-scale, 1)); --leading-4xl:calc((1.35rem + 0.9rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 32px */
332
- --text-5xl:calc((0.875rem + 1.625rem * var(--type-ratio, 1)) * var(--type-scale, 1)); --leading-5xl:1.05; /* 40px display leading is unitless, so it tracks its own size */
333
- --text-6xl:calc((0.875rem + 2.375rem * var(--type-ratio, 1)) * var(--type-scale, 1)); --leading-6xl:1; /* 52px */
334
- --text-7xl:calc((0.875rem + 3.125rem * var(--type-ratio, 1)) * var(--type-scale, 1)); --leading-7xl:1; /* 64px */
335
- --text-8xl:calc((0.875rem + 4.375rem * var(--type-ratio, 1)) * var(--type-scale, 1)); --leading-8xl:1; /* 84px */
336
- --text-9xl:calc((0.875rem + 6.125rem * var(--type-ratio, 1)) * var(--type-scale, 1)); --leading-9xl:1; /* 112px */
319
+ /* EVERY rung is bounded at BOTH ends, by one rule, because the knobs COMPOUND
320
+ and they compound in both directions. Clamping each knob's range separately
321
+ cannot fix either end 0.85 x 0.9 is inside both bounds and lands at 8.5px
322
+ so the RESULT is what carries the bound:
323
+
324
+ --text-floor 10px below this a label is not small, it is unreadable
325
+ --text-ceiling 30vw above this a heading is not type, it is a texture
326
+
327
+ The ceiling is in the VIEWPORT's units because that is what "too big" means:
328
+ measured, the tallest rung (--text-9xl, 112px) is 28.7vw on a 390px phone
329
+ and 7.8vw at 1440, so 30vw binds no published value at any real screen and
330
+ catches only a scale that has run away. Both bounds are stated once here and
331
+ `vars()` emits the same clamp for a modular scale, so a regenerated rung and
332
+ an authored one are bounded identicallythe rule does not depend on which
333
+ produced the value. */
334
+ --text-floor:0.625rem;
335
+ --text-ceiling:30vw;
336
+ --text-xs:clamp(var(--text-floor), calc((0.875rem - 0.1875rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-xs:calc((1.35rem - 0.35rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 11px — eyebrows / section labels */
337
+ --text-sm:clamp(var(--text-floor), calc((0.875rem - 0.0625rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-sm:calc((1.35rem - 0.2rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 13px — nav labels, dense body */
338
+ --text-base:clamp(var(--text-floor), calc(0.875rem * var(--type-scale, 1)), var(--text-ceiling)); --leading-base:calc(1.35rem * var(--type-scale, 1)); /* 14px — the ANCHOR: the rung the ratio pivots on, so it carries no ratio term */
339
+ --text-lg:clamp(var(--text-floor), calc((0.875rem + 0.0625rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-lg:calc((1.35rem + 0.05rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 15px */
340
+ --text-xl:clamp(var(--text-floor), calc((0.875rem + 0.1875rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-xl:calc((1.35rem + 0.2rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 17px */
341
+ --text-2xl:clamp(var(--text-floor), calc((0.875rem + 0.4375rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-2xl:calc((1.35rem + 0.35rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 21px */
342
+ --text-3xl:clamp(var(--text-floor), calc((0.875rem + 0.75rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-3xl:calc((1.35rem + 0.6rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 26px */
343
+ --text-4xl:clamp(var(--text-floor), calc((0.875rem + 1.125rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-4xl:calc((1.35rem + 0.9rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 32px */
344
+ --text-5xl:clamp(var(--text-floor), calc((0.875rem + 1.625rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-5xl:1.05; /* 40px — display leading is unitless, so it tracks its own size */
345
+ --text-6xl:clamp(var(--text-floor), calc((0.875rem + 2.375rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-6xl:1; /* 52px */
346
+ --text-7xl:clamp(var(--text-floor), calc((0.875rem + 3.125rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-7xl:1; /* 64px */
347
+ --text-8xl:clamp(var(--text-floor), calc((0.875rem + 4.375rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-8xl:1; /* 84px */
348
+ --text-9xl:clamp(var(--text-floor), calc((0.875rem + 6.125rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-9xl:1; /* 112px */
337
349
 
338
350
  /* The SAME scale under @hanzo/brand's spelling. @hanzo/gui's shell theme and
339
351
  Hanzo Studio address the ramp as --font-size-*; both names are one value, so
package/tailwind.css CHANGED
@@ -339,24 +339,36 @@
339
339
  A rung is one place, so a rung added later needs a delta and nothing else. */
340
340
  --type-ratio:1;
341
341
 
342
- /* --text-xs/sm carry a floor because the knobs COMPOUND: a flat ratio shrinks
343
- the small end while a small scale shrinks everything, and the two multiply.
344
- Clamping each knob's range separately cannot fix that (0.85 x 0.9 is inside
345
- both bounds and lands at 8.5px); flooring the RESULT can, so no combination
346
- of a person's settings and an org's renders its own labels illegible. */
347
- --text-xs:max(0.625rem, calc((0.875rem - 0.1875rem * var(--type-ratio, 1)) * var(--type-scale, 1))); --leading-xs:calc((1.35rem - 0.35rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 11px — eyebrows / section labels */
348
- --text-sm:max(0.6875rem, calc((0.875rem - 0.0625rem * var(--type-ratio, 1)) * var(--type-scale, 1))); --leading-sm:calc((1.35rem - 0.2rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 13px nav labels, dense body */
349
- --text-base:calc(0.875rem * var(--type-scale, 1)); --leading-base:calc(1.35rem * var(--type-scale, 1)); /* 14px — the ANCHOR: the rung the ratio pivots on, so it carries no ratio term */
350
- --text-lg:calc((0.875rem + 0.0625rem * var(--type-ratio, 1)) * var(--type-scale, 1)); --leading-lg:calc((1.35rem + 0.05rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 15px */
351
- --text-xl:calc((0.875rem + 0.1875rem * var(--type-ratio, 1)) * var(--type-scale, 1)); --leading-xl:calc((1.35rem + 0.2rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 17px */
352
- --text-2xl:calc((0.875rem + 0.4375rem * var(--type-ratio, 1)) * var(--type-scale, 1)); --leading-2xl:calc((1.35rem + 0.35rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 21px */
353
- --text-3xl:calc((0.875rem + 0.75rem * var(--type-ratio, 1)) * var(--type-scale, 1)); --leading-3xl:calc((1.35rem + 0.6rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 26px */
354
- --text-4xl:calc((0.875rem + 1.125rem * var(--type-ratio, 1)) * var(--type-scale, 1)); --leading-4xl:calc((1.35rem + 0.9rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 32px */
355
- --text-5xl:calc((0.875rem + 1.625rem * var(--type-ratio, 1)) * var(--type-scale, 1)); --leading-5xl:1.05; /* 40px display leading is unitless, so it tracks its own size */
356
- --text-6xl:calc((0.875rem + 2.375rem * var(--type-ratio, 1)) * var(--type-scale, 1)); --leading-6xl:1; /* 52px */
357
- --text-7xl:calc((0.875rem + 3.125rem * var(--type-ratio, 1)) * var(--type-scale, 1)); --leading-7xl:1; /* 64px */
358
- --text-8xl:calc((0.875rem + 4.375rem * var(--type-ratio, 1)) * var(--type-scale, 1)); --leading-8xl:1; /* 84px */
359
- --text-9xl:calc((0.875rem + 6.125rem * var(--type-ratio, 1)) * var(--type-scale, 1)); --leading-9xl:1; /* 112px */
342
+ /* EVERY rung is bounded at BOTH ends, by one rule, because the knobs COMPOUND
343
+ and they compound in both directions. Clamping each knob's range separately
344
+ cannot fix either end 0.85 x 0.9 is inside both bounds and lands at 8.5px
345
+ so the RESULT is what carries the bound:
346
+
347
+ --text-floor 10px below this a label is not small, it is unreadable
348
+ --text-ceiling 30vw above this a heading is not type, it is a texture
349
+
350
+ The ceiling is in the VIEWPORT's units because that is what "too big" means:
351
+ measured, the tallest rung (--text-9xl, 112px) is 28.7vw on a 390px phone
352
+ and 7.8vw at 1440, so 30vw binds no published value at any real screen and
353
+ catches only a scale that has run away. Both bounds are stated once here and
354
+ `vars()` emits the same clamp for a modular scale, so a regenerated rung and
355
+ an authored one are bounded identicallythe rule does not depend on which
356
+ produced the value. */
357
+ --text-floor:0.625rem;
358
+ --text-ceiling:30vw;
359
+ --text-xs:clamp(var(--text-floor), calc((0.875rem - 0.1875rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-xs:calc((1.35rem - 0.35rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 11px — eyebrows / section labels */
360
+ --text-sm:clamp(var(--text-floor), calc((0.875rem - 0.0625rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-sm:calc((1.35rem - 0.2rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 13px — nav labels, dense body */
361
+ --text-base:clamp(var(--text-floor), calc(0.875rem * var(--type-scale, 1)), var(--text-ceiling)); --leading-base:calc(1.35rem * var(--type-scale, 1)); /* 14px — the ANCHOR: the rung the ratio pivots on, so it carries no ratio term */
362
+ --text-lg:clamp(var(--text-floor), calc((0.875rem + 0.0625rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-lg:calc((1.35rem + 0.05rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 15px */
363
+ --text-xl:clamp(var(--text-floor), calc((0.875rem + 0.1875rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-xl:calc((1.35rem + 0.2rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 17px */
364
+ --text-2xl:clamp(var(--text-floor), calc((0.875rem + 0.4375rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-2xl:calc((1.35rem + 0.35rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 21px */
365
+ --text-3xl:clamp(var(--text-floor), calc((0.875rem + 0.75rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-3xl:calc((1.35rem + 0.6rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 26px */
366
+ --text-4xl:clamp(var(--text-floor), calc((0.875rem + 1.125rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-4xl:calc((1.35rem + 0.9rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 32px */
367
+ --text-5xl:clamp(var(--text-floor), calc((0.875rem + 1.625rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-5xl:1.05; /* 40px — display leading is unitless, so it tracks its own size */
368
+ --text-6xl:clamp(var(--text-floor), calc((0.875rem + 2.375rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-6xl:1; /* 52px */
369
+ --text-7xl:clamp(var(--text-floor), calc((0.875rem + 3.125rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-7xl:1; /* 64px */
370
+ --text-8xl:clamp(var(--text-floor), calc((0.875rem + 4.375rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-8xl:1; /* 84px */
371
+ --text-9xl:clamp(var(--text-floor), calc((0.875rem + 6.125rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-9xl:1; /* 112px */
360
372
 
361
373
  /* The SAME scale under @hanzo/brand's spelling. @hanzo/gui's shell theme and
362
374
  Hanzo Studio address the ramp as --font-size-*; both names are one value, so
@@ -31,24 +31,36 @@
31
31
  A rung is one place, so a rung added later needs a delta and nothing else. */
32
32
  --type-ratio:1;
33
33
 
34
- /* --text-xs/sm carry a floor because the knobs COMPOUND: a flat ratio shrinks
35
- the small end while a small scale shrinks everything, and the two multiply.
36
- Clamping each knob's range separately cannot fix that (0.85 x 0.9 is inside
37
- both bounds and lands at 8.5px); flooring the RESULT can, so no combination
38
- of a person's settings and an org's renders its own labels illegible. */
39
- --text-xs:max(0.625rem, calc((0.875rem - 0.1875rem * var(--type-ratio, 1)) * var(--type-scale, 1))); --leading-xs:calc((1.35rem - 0.35rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 11px — eyebrows / section labels */
40
- --text-sm:max(0.6875rem, calc((0.875rem - 0.0625rem * var(--type-ratio, 1)) * var(--type-scale, 1))); --leading-sm:calc((1.35rem - 0.2rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 13px nav labels, dense body */
41
- --text-base:calc(0.875rem * var(--type-scale, 1)); --leading-base:calc(1.35rem * var(--type-scale, 1)); /* 14px — the ANCHOR: the rung the ratio pivots on, so it carries no ratio term */
42
- --text-lg:calc((0.875rem + 0.0625rem * var(--type-ratio, 1)) * var(--type-scale, 1)); --leading-lg:calc((1.35rem + 0.05rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 15px */
43
- --text-xl:calc((0.875rem + 0.1875rem * var(--type-ratio, 1)) * var(--type-scale, 1)); --leading-xl:calc((1.35rem + 0.2rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 17px */
44
- --text-2xl:calc((0.875rem + 0.4375rem * var(--type-ratio, 1)) * var(--type-scale, 1)); --leading-2xl:calc((1.35rem + 0.35rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 21px */
45
- --text-3xl:calc((0.875rem + 0.75rem * var(--type-ratio, 1)) * var(--type-scale, 1)); --leading-3xl:calc((1.35rem + 0.6rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 26px */
46
- --text-4xl:calc((0.875rem + 1.125rem * var(--type-ratio, 1)) * var(--type-scale, 1)); --leading-4xl:calc((1.35rem + 0.9rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 32px */
47
- --text-5xl:calc((0.875rem + 1.625rem * var(--type-ratio, 1)) * var(--type-scale, 1)); --leading-5xl:1.05; /* 40px display leading is unitless, so it tracks its own size */
48
- --text-6xl:calc((0.875rem + 2.375rem * var(--type-ratio, 1)) * var(--type-scale, 1)); --leading-6xl:1; /* 52px */
49
- --text-7xl:calc((0.875rem + 3.125rem * var(--type-ratio, 1)) * var(--type-scale, 1)); --leading-7xl:1; /* 64px */
50
- --text-8xl:calc((0.875rem + 4.375rem * var(--type-ratio, 1)) * var(--type-scale, 1)); --leading-8xl:1; /* 84px */
51
- --text-9xl:calc((0.875rem + 6.125rem * var(--type-ratio, 1)) * var(--type-scale, 1)); --leading-9xl:1; /* 112px */
34
+ /* EVERY rung is bounded at BOTH ends, by one rule, because the knobs COMPOUND
35
+ and they compound in both directions. Clamping each knob's range separately
36
+ cannot fix either end 0.85 x 0.9 is inside both bounds and lands at 8.5px
37
+ so the RESULT is what carries the bound:
38
+
39
+ --text-floor 10px below this a label is not small, it is unreadable
40
+ --text-ceiling 30vw above this a heading is not type, it is a texture
41
+
42
+ The ceiling is in the VIEWPORT's units because that is what "too big" means:
43
+ measured, the tallest rung (--text-9xl, 112px) is 28.7vw on a 390px phone
44
+ and 7.8vw at 1440, so 30vw binds no published value at any real screen and
45
+ catches only a scale that has run away. Both bounds are stated once here and
46
+ `vars()` emits the same clamp for a modular scale, so a regenerated rung and
47
+ an authored one are bounded identicallythe rule does not depend on which
48
+ produced the value. */
49
+ --text-floor:0.625rem;
50
+ --text-ceiling:30vw;
51
+ --text-xs:clamp(var(--text-floor), calc((0.875rem - 0.1875rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-xs:calc((1.35rem - 0.35rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 11px — eyebrows / section labels */
52
+ --text-sm:clamp(var(--text-floor), calc((0.875rem - 0.0625rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-sm:calc((1.35rem - 0.2rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 13px — nav labels, dense body */
53
+ --text-base:clamp(var(--text-floor), calc(0.875rem * var(--type-scale, 1)), var(--text-ceiling)); --leading-base:calc(1.35rem * var(--type-scale, 1)); /* 14px — the ANCHOR: the rung the ratio pivots on, so it carries no ratio term */
54
+ --text-lg:clamp(var(--text-floor), calc((0.875rem + 0.0625rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-lg:calc((1.35rem + 0.05rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 15px */
55
+ --text-xl:clamp(var(--text-floor), calc((0.875rem + 0.1875rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-xl:calc((1.35rem + 0.2rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 17px */
56
+ --text-2xl:clamp(var(--text-floor), calc((0.875rem + 0.4375rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-2xl:calc((1.35rem + 0.35rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 21px */
57
+ --text-3xl:clamp(var(--text-floor), calc((0.875rem + 0.75rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-3xl:calc((1.35rem + 0.6rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 26px */
58
+ --text-4xl:clamp(var(--text-floor), calc((0.875rem + 1.125rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-4xl:calc((1.35rem + 0.9rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 32px */
59
+ --text-5xl:clamp(var(--text-floor), calc((0.875rem + 1.625rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-5xl:1.05; /* 40px — display leading is unitless, so it tracks its own size */
60
+ --text-6xl:clamp(var(--text-floor), calc((0.875rem + 2.375rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-6xl:1; /* 52px */
61
+ --text-7xl:clamp(var(--text-floor), calc((0.875rem + 3.125rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-7xl:1; /* 64px */
62
+ --text-8xl:clamp(var(--text-floor), calc((0.875rem + 4.375rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-8xl:1; /* 84px */
63
+ --text-9xl:clamp(var(--text-floor), calc((0.875rem + 6.125rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-9xl:1; /* 112px */
52
64
 
53
65
  /* The SAME scale under @hanzo/brand's spelling. @hanzo/gui's shell theme and
54
66
  Hanzo Studio address the ramp as --font-size-*; both names are one value, so