@hanzo/design 0.4.10 → 0.4.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.
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Import the BUILT package the way a consumer does.
3
+ *
4
+ * Everything else in this repo checks the sources: the token files, the
5
+ * generator's output, the preference contract. All of it passed while
6
+ * `dist/index.js` was unimportable — 0.4.10 and 0.4.11 both shipped a barrel
7
+ * that re-exported `'./preference'` with no extension, which TypeScript emits
8
+ * verbatim and Node ESM refuses to resolve. `pnpm build` was green, `pnpm test`
9
+ * was green, and `import '@hanzo/design'` threw ERR_MODULE_NOT_FOUND for every
10
+ * consumer.
11
+ *
12
+ * A test that reads source cannot see that. This one loads the artifact.
13
+ */
14
+ import { readFileSync, existsSync } from 'node:fs'
15
+ import { fileURLToPath, pathToFileURL } from 'node:url'
16
+ import { dirname, join } from 'node:path'
17
+
18
+ const here = dirname(fileURLToPath(import.meta.url))
19
+ const root = join(here, '..')
20
+
21
+ let failed = 0
22
+ const check = async (name, fn) => {
23
+ try {
24
+ await fn()
25
+ console.log(` ok ${name}`)
26
+ } catch (e) {
27
+ failed++
28
+ console.error(` FAIL ${name}\n ${e.message}`)
29
+ }
30
+ }
31
+
32
+ console.log('dist:')
33
+
34
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
35
+
36
+ await check('every export the package advertises actually exists on disk', () => {
37
+ const missing = []
38
+ for (const [name, entry] of Object.entries(pkg.exports ?? {})) {
39
+ for (const target of typeof entry === 'string' ? [entry] : Object.values(entry)) {
40
+ if (typeof target !== 'string' || target.includes('*')) continue
41
+ if (!existsSync(join(root, target))) missing.push(`${name} -> ${target}`)
42
+ }
43
+ }
44
+ if (missing.length) throw new Error(`exports pointing at nothing:\n ${missing.join('\n ')}`)
45
+ })
46
+
47
+ await check('the barrel IMPORTS — the check that 0.4.10 and 0.4.11 needed', async () => {
48
+ const entry = join(root, 'dist/index.js')
49
+ if (!existsSync(entry)) throw new Error('dist/index.js is missing — run the build first')
50
+ const mod = await import(pathToFileURL(entry).href)
51
+ // The preference API is the part that was unreachable; naming it here means a
52
+ // future re-export losing its extension fails by name rather than by silence.
53
+ for (const name of ['vars', 'css', 'isColor', 'TYPE_MIN', 'TYPE_MAX', 'cssVar', 'tokenValue']) {
54
+ if (mod[name] === undefined) throw new Error(`dist/index.js does not export ${name}`)
55
+ }
56
+ if (typeof mod.vars !== 'function') throw new Error('vars is not callable from the built package')
57
+ const v = mod.vars({ type: 1.2, density: 'compact' })
58
+ if (v['--type-scale'] !== '1.2') throw new Error(`built vars() returned ${JSON.stringify(v)}`)
59
+ })
60
+
61
+ await check('no emitted module uses an extensionless relative specifier', () => {
62
+ // The class of bug, not just the one instance: Node ESM resolves relative
63
+ // specifiers literally, so a missing `.js` is always a runtime failure.
64
+ const bad = []
65
+ for (const [, entry] of Object.entries(pkg.exports ?? {})) {
66
+ const target = typeof entry === 'string' ? entry : entry.import
67
+ if (typeof target !== 'string' || target.includes('*') || !target.endsWith('.js')) continue
68
+ const file = join(root, target)
69
+ if (!existsSync(file)) continue
70
+ for (const m of readFileSync(file, 'utf8').matchAll(/from\s+['"](\.[^'"]*)['"]/g)) {
71
+ if (!/\.(js|mjs|cjs|json|css)$/.test(m[1])) bad.push(`${target}: ${m[1]}`)
72
+ }
73
+ }
74
+ if (bad.length) throw new Error(`extensionless relative imports:\n ${bad.join('\n ')}`)
75
+ })
76
+
77
+ if (failed) {
78
+ console.error(`\n${failed} dist check(s) failed`)
79
+ process.exit(1)
80
+ }
81
+ console.log('dist: all checks passed')
@@ -2,10 +2,13 @@
2
2
  * Preference contract, checked the way this package already checks tokens:
3
3
  * a plain node script, no framework, run by `npm test`.
4
4
  *
5
- * The three things worth pinning are the three that would hurt: a preference
6
- * that renders someone's own tools illegible, one that lets a colour field
7
- * carry a second declaration into a stylesheet, and one that emits names no
8
- * token file reads.
5
+ * The load-bearing check is the last one. The first version of preference.ts
6
+ * kept its own copy of the type ramp and the copy was WRONG (lg/xl were 16/18
7
+ * against the tokens' 15/17), so a preference of 1 "leave it alone" — would
8
+ * have resized two rungs of the published design. Nothing caught it, because
9
+ * nothing compared the copy to the source. Now there is no copy: the knobs are
10
+ * multipliers and the ramps live only in tokens/*.css, and check 5 fails if a
11
+ * rung ever stops carrying its multiplier.
9
12
  */
10
13
  import { readFileSync } from "node:fs";
11
14
  import { fileURLToPath } from "node:url";
@@ -55,30 +58,60 @@ check("an unset preference changes nothing", () => {
55
58
  eq(css({}), "");
56
59
  });
57
60
 
58
- check("type scales the WHOLE ramp, keeping every relation", () => {
59
- const v = vars({ type: 2 }); // clamps to TYPE_MAX
60
- const base = parseFloat(v["--text-base"]);
61
- const xs = parseFloat(v["--text-xs"]);
62
- // 0.875 / 0.6875 must survive the transform
63
- const ratio = base / xs;
64
- ok(Math.abs(ratio - 0.875 / 0.6875) < 1e-9, `ratio drifted: ${ratio}`);
61
+ check("type is ONE knob, not a restated ramp", () => {
62
+ const v = vars({ type: 1.2 });
63
+ eq(Object.keys(v), ["--type-scale"], "type must emit exactly one name:");
64
+ eq(v["--type-scale"], "1.2");
65
65
  });
66
66
 
67
67
  check("type is CLAMPED — a preference cannot make the UI illegible", () => {
68
- const tiny = vars({ type: 0.1 });
69
- eq(tiny["--text-base"], `${Math.round(0.875 * TYPE_MIN * 10000) / 10000}rem`, "min");
70
- const huge = vars({ type: 99 });
71
- eq(huge["--text-base"], `${Math.round(0.875 * TYPE_MAX * 10000) / 10000}rem`, "max");
72
- // the smallest rung must stay readable at the floor
73
- ok(parseFloat(tiny["--text-xs"]) * 16 >= 9, "xs fell below 9px");
68
+ eq(vars({ type: 0.1 })["--type-scale"], String(TYPE_MIN), "min");
69
+ eq(vars({ type: 99 })["--type-scale"], String(TYPE_MAX), "max");
70
+ // the smallest rung must stay readable at the floor: 11px * TYPE_MIN
71
+ ok(11 * TYPE_MIN >= 9, "xs falls below 9px at the floor");
74
72
  });
75
73
 
76
- check("density moves the gap ramp, not the type ramp", () => {
74
+ check("density moves spacing only, and never touches type", () => {
77
75
  const v = vars({ density: "compact" });
78
- ok(v["--grid-gap"], "no gap emitted");
79
- ok(!v["--text-base"], "density must not touch type");
80
- ok(parseFloat(v["--grid-gap"]) < 1, "compact should tighten");
81
- ok(parseFloat(vars({ density: "comfortable" })["--grid-gap"]) > 1, "comfortable should loosen");
76
+ eq(Object.keys(v), ["--density"]);
77
+ ok(Number(v["--density"]) < 1, "compact should tighten");
78
+ ok(Number(vars({ density: "comfortable" })["--density"]) > 1, "comfortable should loosen");
79
+ ok(!("--type-scale" in v), "density must not touch type");
80
+ // Spacing compounds through nesting, so the range stays tight enough that a
81
+ // 44px coarse-pointer target does not fall under the floor.
82
+ ok(Number(vars({ density: "compact" })["--density"]) >= 0.8, "compact is too tight to stay tappable");
83
+ });
84
+
85
+ check("EVERY ramp rung carries its multiplier — no rung can opt out", () => {
86
+ // This is the check that the drifting copy would have failed.
87
+ const type = readFileSync(join(root, "tokens/typography.css"), "utf8");
88
+ const space = readFileSync(join(root, "tokens/spacing.css"), "utf8");
89
+
90
+ const bare = [];
91
+ for (const [file, text, knob, re] of [
92
+ ["typography.css", type, "--type-scale", /--text-[a-z0-9]+:\s*([^;]+);/g],
93
+ ["spacing.css", space, "--density", /--space-\d+:\s*([^;]+);/g],
94
+ ]) {
95
+ for (const m of text.matchAll(re)) {
96
+ const value = m[1].trim();
97
+ if (value === "0") continue; // zero times anything is zero
98
+ if (value.startsWith("var(")) continue; // an alias inherits its target's calc
99
+ if (!value.includes(`var(${knob}`)) bare.push(`${file}: ${m[0].trim()}`);
100
+ }
101
+ }
102
+ eq(bare, [], "rungs that do NOT scale with their knob:");
103
+ });
104
+
105
+ check("a unitless leading stays a RATIO — scaling it would double-apply", () => {
106
+ const type = readFileSync(join(root, "tokens/typography.css"), "utf8");
107
+ const bad = [];
108
+ for (const m of type.matchAll(/--leading-[a-z0-9]+:\s*([^;]+);/g)) {
109
+ const v = m[1].trim();
110
+ // A ratio (`1.05`) already scales with the font size it multiplies.
111
+ if (/^[0-9.]+$/.test(v) === false) continue;
112
+ if (v.includes("var(")) bad.push(m[0].trim());
113
+ }
114
+ eq(bad, [], "unitless leadings must not carry a multiplier:");
82
115
  });
83
116
 
84
117
  check("a colour lands on both --primary and --accent", () => {
@@ -111,21 +144,21 @@ check("real colour notations are accepted", () => {
111
144
  check("css() emits ONE block for the selector it is given", () => {
112
145
  const out = css({ type: 1.1, density: "compact", accent: "#fff" }, ":root");
113
146
  ok(out.startsWith(":root{") && out.endsWith("}"), out.slice(0, 40));
114
- ok(!out.includes("}"+"{"), "emitted more than one block");
147
+ ok(!out.includes("}" + "{"), "emitted more than one block");
115
148
  });
116
149
 
117
- check("every emitted name is one the token files actually declare", () => {
150
+ check("every emitted name is one the token files actually read", () => {
118
151
  // A variable nothing reads is a write into another document — the exact
119
- // mistake this package exists to prevent.
120
- const declared = new Set();
121
- for (const f of ["tokens/typography.css", "tokens/grid.css", "tokens/colors.css"]) {
122
- for (const m of readFileSync(join(root, f), "utf8").matchAll(/^\s*(--[a-z0-9-]+)\s*:/gm)) {
123
- declared.add(m[1]);
124
- }
125
- }
152
+ // mistake this package exists to prevent. The knobs are READ by the ramps
153
+ // (as var(--knob, 1)); the colours are DECLARED by colors.css.
154
+ const files = ["tokens/typography.css", "tokens/spacing.css", "tokens/grid.css", "tokens/colors.css"];
155
+ const text = files.map((f) => readFileSync(join(root, f), "utf8")).join("\n");
156
+ const declared = new Set([...text.matchAll(/^\s*(--[a-z0-9-]+)\s*:/gm)].map((m) => m[1]));
157
+ const read = new Set([...text.matchAll(/var\((--[a-z0-9-]+)/g)].map((m) => m[1]));
158
+
126
159
  const emitted = Object.keys(vars({ type: 1.1, density: "compact", accent: "#fff" }));
127
- const orphans = emitted.filter((k) => !declared.has(k));
128
- eq(orphans, [], "emitted names no token file declares:");
160
+ const orphans = emitted.filter((k) => !declared.has(k) && !read.has(k));
161
+ eq(orphans, [], "emitted names no token file declares or reads:");
129
162
  });
130
163
 
131
164
  if (failed) {
package/src/index.ts CHANGED
@@ -66,5 +66,5 @@ export function injectDesignCss(href: string): void {
66
66
  // A person's own reading of the system — type size, density, accent — as CSS
67
67
  // custom properties. Pure: it maps a preference to variables and returns them,
68
68
  // so an app, an embedded preview and a server render all apply it the same way.
69
- export { vars, css, isColor, TYPE_MIN, TYPE_MAX } from './preference';
70
- export type { Preference, Density } from './preference';
69
+ export { vars, css, isColor, TYPE_MIN, TYPE_MAX } from './preference.js';
70
+ export type { Preference, Density } from './preference.js';
package/src/preference.ts CHANGED
@@ -1,23 +1,29 @@
1
1
  /**
2
2
  * A person's own reading of the system: type size, density, accent.
3
3
  *
4
- * The ramps in `tokens/*.css` are the SHAPEthe relationships between sizes,
5
- * gaps and hues that make a surface read as one thing. A preference does not
6
- * replace them and cannot reach inside them. It applies ONE transform to a whole
7
- * axis, so every size still stands in the same relation to every other size and
8
- * a customised UI is the same design at a different setting, not a different
9
- * design.
4
+ * Three knobs, and each is ONE multiplier on a whole axis never a restated
5
+ * ramp. The ramps live in `tokens/*.css`, authored once, and each rung carries
6
+ * its own `calc(<base> * var(--type-scale, 1))`. So a preference sets three
7
+ * numbers and every rung follows, including rungs added later and rungs this
8
+ * file has never heard of.
10
9
  *
11
- * It is a pure function on purpose. `vars()` maps a preference to the custom
12
- * properties that carry it, and returns them; nothing here touches a document.
13
- * That is what lets it be tested without a browser and reused by every surface —
14
- * an app, an embedded builder preview, a server render that inlines the result.
10
+ * That is not a style choice; it is the fix for a real bug. The first version of
11
+ * this module kept its own copy of the type ramp so it could recompute each
12
+ * rung, and the copy was WRONG it had `lg: 1rem` and `xl: 1.125rem` (16px and
13
+ * 18px) while `tokens/typography.css` says `0.9375rem` and `1.0625rem` (15px and
14
+ * 17px). Setting a preference of 1 — "leave it alone" — would have silently
15
+ * resized two rungs of the published design. A second copy of a value is a
16
+ * second source of truth, and it drifted before anyone used it.
15
17
  *
16
- * Three axes, because those are the three the token files already separate:
18
+ * Because the knobs are plain multipliers, any OTHER ramp can opt in the same
19
+ * way. @hanzo/gui compiles its own `--f-size-*` scale for the 1600-odd
20
+ * `fontSize="$n"` call sites in the apps; an app that redeclares those as
21
+ * `calc(<its px> * var(--type-scale, 1))` gets the same control with no change
22
+ * at scale 1.
17
23
  *
18
- * type scales the --text-* ramp
19
- * density scales the --grid-gap-* ramp (the spacing between things)
20
- * accent sets --primary / --accent (the one hue the monochrome brand allows)
24
+ * It is a pure function on purpose: it maps a preference to custom properties
25
+ * and returns them, touching no document. That is what lets an app, an embedded
26
+ * preview and a server render apply it identically.
21
27
  */
22
28
 
23
29
  export type Density = "compact" | "default" | "comfortable";
@@ -42,30 +48,24 @@ export interface Preference {
42
48
  export const TYPE_MIN = 0.85;
43
49
  export const TYPE_MAX = 1.4;
44
50
 
45
- const DENSITY_SCALE: Record<Density, number> = {
46
- compact: 0.75,
51
+ /**
52
+ * Density moves SPACING only, and its range is much tighter than type's.
53
+ *
54
+ * Spacing compounds: a page nests padding inside gap inside margin, so a 0.75
55
+ * multiplier is already three-quarters of every one of those in sequence. Below
56
+ * that, touch targets fall under the 44px floor `base.css` sets for coarse
57
+ * pointers, and the control that promised comfort takes it away.
58
+ */
59
+ const DENSITY: Record<Density, number> = {
60
+ compact: 0.85,
47
61
  default: 1,
48
- comfortable: 1.35,
49
- };
50
-
51
- /** The type ramp, by name, in rem at a 16px root — mirrors tokens/typography.css. */
52
- const TEXT: Record<string, number> = {
53
- xs: 0.6875, sm: 0.8125, base: 0.875, lg: 1, xl: 1.125,
54
- "2xl": 1.3125, "3xl": 1.625, "4xl": 2, "5xl": 2.5,
55
- "6xl": 3.25, "7xl": 4, "8xl": 5.25, "9xl": 7,
56
- };
57
-
58
- /** The gap ramp, in rem — mirrors tokens/grid.css. */
59
- const GAP: Record<string, number> = {
60
- "grid-gap-tight": 0.5,
61
- "grid-gap": 1,
62
- "grid-gap-loose": 1.5,
62
+ comfortable: 1.15,
63
63
  };
64
64
 
65
65
  const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
66
66
 
67
67
  /** Trim to 4dp so a multiplier cannot emit a 17-digit float into a stylesheet. */
68
- const rem = (n: number) => `${Math.round(n * 10000) / 10000}rem`;
68
+ const round = (n: number) => String(Math.round(n * 10000) / 10000);
69
69
 
70
70
  /**
71
71
  * Is this a colour, or is it something being smuggled into a style attribute?
@@ -96,13 +96,11 @@ export function vars(p: Preference): Record<string, string> {
96
96
  const out: Record<string, string> = {};
97
97
 
98
98
  if (typeof p.type === "number" && Number.isFinite(p.type)) {
99
- const k = clamp(p.type, TYPE_MIN, TYPE_MAX);
100
- for (const [name, size] of Object.entries(TEXT)) out[`--text-${name}`] = rem(size * k);
99
+ out["--type-scale"] = round(clamp(p.type, TYPE_MIN, TYPE_MAX));
101
100
  }
102
101
 
103
- if (p.density && p.density in DENSITY_SCALE) {
104
- const k = DENSITY_SCALE[p.density];
105
- for (const [name, size] of Object.entries(GAP)) out[`--${name}`] = rem(size * k);
102
+ if (p.density && p.density in DENSITY) {
103
+ out["--density"] = round(DENSITY[p.density]);
106
104
  }
107
105
 
108
106
  if (p.accent && isColor(p.accent)) {
package/src/tokens.gen.ts CHANGED
@@ -96,31 +96,32 @@ export const colors = {
96
96
 
97
97
  /** typography tokens (from tokens/typography.css). Values are raw CSS. */
98
98
  export const typography = {
99
- 'text-xs': '0.6875rem',
100
- 'leading-xs': '1rem',
101
- 'text-sm': '0.8125rem',
102
- 'leading-sm': '1.15rem',
103
- 'text-base': '0.875rem',
104
- 'leading-base': '1.35rem',
105
- 'text-lg': '0.9375rem',
106
- 'leading-lg': '1.4rem',
107
- 'text-xl': '1.0625rem',
108
- 'leading-xl': '1.55rem',
109
- 'text-2xl': '1.3125rem',
110
- 'leading-2xl': '1.7rem',
111
- 'text-3xl': '1.625rem',
112
- 'leading-3xl': '1.95rem',
113
- 'text-4xl': '2rem',
114
- 'leading-4xl': '2.25rem',
115
- 'text-5xl': '2.5rem',
99
+ 'type-scale': '1',
100
+ 'text-xs': 'calc(0.6875rem * var(--type-scale, 1))',
101
+ 'leading-xs': 'calc(1rem * var(--type-scale, 1))',
102
+ 'text-sm': 'calc(0.8125rem * var(--type-scale, 1))',
103
+ 'leading-sm': 'calc(1.15rem * var(--type-scale, 1))',
104
+ 'text-base': 'calc(0.875rem * var(--type-scale, 1))',
105
+ 'leading-base': 'calc(1.35rem * var(--type-scale, 1))',
106
+ 'text-lg': 'calc(0.9375rem * var(--type-scale, 1))',
107
+ 'leading-lg': 'calc(1.4rem * var(--type-scale, 1))',
108
+ 'text-xl': 'calc(1.0625rem * var(--type-scale, 1))',
109
+ 'leading-xl': 'calc(1.55rem * var(--type-scale, 1))',
110
+ 'text-2xl': 'calc(1.3125rem * var(--type-scale, 1))',
111
+ 'leading-2xl': 'calc(1.7rem * var(--type-scale, 1))',
112
+ 'text-3xl': 'calc(1.625rem * var(--type-scale, 1))',
113
+ 'leading-3xl': 'calc(1.95rem * var(--type-scale, 1))',
114
+ 'text-4xl': 'calc(2rem * var(--type-scale, 1))',
115
+ 'leading-4xl': 'calc(2.25rem * var(--type-scale, 1))',
116
+ 'text-5xl': 'calc(2.5rem * var(--type-scale, 1))',
116
117
  'leading-5xl': '1.05',
117
- 'text-6xl': '3.25rem',
118
+ 'text-6xl': 'calc(3.25rem * var(--type-scale, 1))',
118
119
  'leading-6xl': '1',
119
- 'text-7xl': '4rem',
120
+ 'text-7xl': 'calc(4rem * var(--type-scale, 1))',
120
121
  'leading-7xl': '1',
121
- 'text-8xl': '5.25rem',
122
+ 'text-8xl': 'calc(5.25rem * var(--type-scale, 1))',
122
123
  'leading-8xl': '1',
123
- 'text-9xl': '7rem',
124
+ 'text-9xl': 'calc(7rem * var(--type-scale, 1))',
124
125
  'leading-9xl': '1',
125
126
  'font-size-xs': 'var(--text-xs)',
126
127
  'font-size-sm': 'var(--text-sm)',
@@ -162,21 +163,22 @@ export const typography = {
162
163
 
163
164
  /** spacing tokens (from tokens/spacing.css). Values are raw CSS. */
164
165
  export const spacing = {
166
+ 'density': '1',
165
167
  'space-0': '0',
166
- 'space-1': '0.25rem',
167
- 'space-2': '0.5rem',
168
- 'space-3': '0.75rem',
169
- 'space-4': '1rem',
170
- 'space-5': '1.25rem',
171
- 'space-6': '1.5rem',
172
- 'space-8': '2rem',
173
- 'space-10': '2.5rem',
174
- 'space-12': '3rem',
175
- 'space-14': '3.5rem',
176
- 'space-16': '4rem',
177
- 'space-20': '5rem',
178
- 'space-24': '6rem',
179
- 'space-32': '8rem',
168
+ 'space-1': 'calc(0.25rem * var(--density, 1))',
169
+ 'space-2': 'calc(0.5rem * var(--density, 1))',
170
+ 'space-3': 'calc(0.75rem * var(--density, 1))',
171
+ 'space-4': 'calc(1rem * var(--density, 1))',
172
+ 'space-5': 'calc(1.25rem * var(--density, 1))',
173
+ 'space-6': 'calc(1.5rem * var(--density, 1))',
174
+ 'space-8': 'calc(2rem * var(--density, 1))',
175
+ 'space-10': 'calc(2.5rem * var(--density, 1))',
176
+ 'space-12': 'calc(3rem * var(--density, 1))',
177
+ 'space-14': 'calc(3.5rem * var(--density, 1))',
178
+ 'space-16': 'calc(4rem * var(--density, 1))',
179
+ 'space-20': 'calc(5rem * var(--density, 1))',
180
+ 'space-24': 'calc(6rem * var(--density, 1))',
181
+ 'space-32': 'calc(8rem * var(--density, 1))',
180
182
  'golden-1': '0.25rem',
181
183
  'golden-2': '0.405rem',
182
184
  'golden-3': '0.654rem',
@@ -390,31 +392,32 @@ export const cssVars = {
390
392
  '--chrome-dot-red': 'rgb(239 68 68 / .6)',
391
393
  '--chrome-dot-yellow': 'rgb(234 179 8 / .6)',
392
394
  '--chrome-dot-green': 'rgb(34 197 94 / .6)',
393
- '--text-xs': '0.6875rem',
394
- '--leading-xs': '1rem',
395
- '--text-sm': '0.8125rem',
396
- '--leading-sm': '1.15rem',
397
- '--text-base': '0.875rem',
398
- '--leading-base': '1.35rem',
399
- '--text-lg': '0.9375rem',
400
- '--leading-lg': '1.4rem',
401
- '--text-xl': '1.0625rem',
402
- '--leading-xl': '1.55rem',
403
- '--text-2xl': '1.3125rem',
404
- '--leading-2xl': '1.7rem',
405
- '--text-3xl': '1.625rem',
406
- '--leading-3xl': '1.95rem',
407
- '--text-4xl': '2rem',
408
- '--leading-4xl': '2.25rem',
409
- '--text-5xl': '2.5rem',
395
+ '--type-scale': '1',
396
+ '--text-xs': 'calc(0.6875rem * var(--type-scale, 1))',
397
+ '--leading-xs': 'calc(1rem * var(--type-scale, 1))',
398
+ '--text-sm': 'calc(0.8125rem * var(--type-scale, 1))',
399
+ '--leading-sm': 'calc(1.15rem * var(--type-scale, 1))',
400
+ '--text-base': 'calc(0.875rem * var(--type-scale, 1))',
401
+ '--leading-base': 'calc(1.35rem * var(--type-scale, 1))',
402
+ '--text-lg': 'calc(0.9375rem * var(--type-scale, 1))',
403
+ '--leading-lg': 'calc(1.4rem * var(--type-scale, 1))',
404
+ '--text-xl': 'calc(1.0625rem * var(--type-scale, 1))',
405
+ '--leading-xl': 'calc(1.55rem * var(--type-scale, 1))',
406
+ '--text-2xl': 'calc(1.3125rem * var(--type-scale, 1))',
407
+ '--leading-2xl': 'calc(1.7rem * var(--type-scale, 1))',
408
+ '--text-3xl': 'calc(1.625rem * var(--type-scale, 1))',
409
+ '--leading-3xl': 'calc(1.95rem * var(--type-scale, 1))',
410
+ '--text-4xl': 'calc(2rem * var(--type-scale, 1))',
411
+ '--leading-4xl': 'calc(2.25rem * var(--type-scale, 1))',
412
+ '--text-5xl': 'calc(2.5rem * var(--type-scale, 1))',
410
413
  '--leading-5xl': '1.05',
411
- '--text-6xl': '3.25rem',
414
+ '--text-6xl': 'calc(3.25rem * var(--type-scale, 1))',
412
415
  '--leading-6xl': '1',
413
- '--text-7xl': '4rem',
416
+ '--text-7xl': 'calc(4rem * var(--type-scale, 1))',
414
417
  '--leading-7xl': '1',
415
- '--text-8xl': '5.25rem',
418
+ '--text-8xl': 'calc(5.25rem * var(--type-scale, 1))',
416
419
  '--leading-8xl': '1',
417
- '--text-9xl': '7rem',
420
+ '--text-9xl': 'calc(7rem * var(--type-scale, 1))',
418
421
  '--leading-9xl': '1',
419
422
  '--font-size-xs': 'var(--text-xs)',
420
423
  '--font-size-sm': 'var(--text-sm)',
@@ -452,21 +455,22 @@ export const cssVars = {
452
455
  '--type-caption': '400 var(--text-xs)/var(--leading-xs) var(--font-sans)',
453
456
  '--type-code': '400 var(--text-sm)/var(--leading-relaxed) var(--font-mono)',
454
457
  '--type-eyebrow': '600 0.625rem/1 var(--font-sans)',
458
+ '--density': '1',
455
459
  '--space-0': '0',
456
- '--space-1': '0.25rem',
457
- '--space-2': '0.5rem',
458
- '--space-3': '0.75rem',
459
- '--space-4': '1rem',
460
- '--space-5': '1.25rem',
461
- '--space-6': '1.5rem',
462
- '--space-8': '2rem',
463
- '--space-10': '2.5rem',
464
- '--space-12': '3rem',
465
- '--space-14': '3.5rem',
466
- '--space-16': '4rem',
467
- '--space-20': '5rem',
468
- '--space-24': '6rem',
469
- '--space-32': '8rem',
460
+ '--space-1': 'calc(0.25rem * var(--density, 1))',
461
+ '--space-2': 'calc(0.5rem * var(--density, 1))',
462
+ '--space-3': 'calc(0.75rem * var(--density, 1))',
463
+ '--space-4': 'calc(1rem * var(--density, 1))',
464
+ '--space-5': 'calc(1.25rem * var(--density, 1))',
465
+ '--space-6': 'calc(1.5rem * var(--density, 1))',
466
+ '--space-8': 'calc(2rem * var(--density, 1))',
467
+ '--space-10': 'calc(2.5rem * var(--density, 1))',
468
+ '--space-12': 'calc(3rem * var(--density, 1))',
469
+ '--space-14': 'calc(3.5rem * var(--density, 1))',
470
+ '--space-16': 'calc(4rem * var(--density, 1))',
471
+ '--space-20': 'calc(5rem * var(--density, 1))',
472
+ '--space-24': 'calc(6rem * var(--density, 1))',
473
+ '--space-32': 'calc(8rem * var(--density, 1))',
470
474
  '--golden-1': '0.25rem',
471
475
  '--golden-2': '0.405rem',
472
476
  '--golden-3': '0.654rem',
package/styles.css CHANGED
@@ -290,19 +290,25 @@
290
290
  — the two are the SAME scale, mirrored. A surface/tenant overrides any --text-*
291
291
  on :root to retune density on demand. */
292
292
  :root{
293
- --text-xs:0.6875rem; --leading-xs:1rem; /* 11px eyebrows / section labels */
294
- --text-sm:0.8125rem; --leading-sm:1.15rem; /* 13px nav labels, dense body */
295
- --text-base:0.875rem; --leading-base:1.35rem; /* 14px base app text (was 16px) */
296
- --text-lg:0.9375rem; --leading-lg:1.4rem; /* 15px */
297
- --text-xl:1.0625rem; --leading-xl:1.55rem; /* 17px */
298
- --text-2xl:1.3125rem; --leading-2xl:1.7rem; /* 21px */
299
- --text-3xl:1.625rem; --leading-3xl:1.95rem; /* 26px */
300
- --text-4xl:2rem; --leading-4xl:2.25rem; /* 32px */
301
- --text-5xl:2.5rem; --leading-5xl:1.05; /* 40px */
302
- --text-6xl:3.25rem; --leading-6xl:1; /* 52px */
303
- --text-7xl:4rem; --leading-7xl:1; /* 64px */
304
- --text-8xl:5.25rem; --leading-8xl:1; /* 84px */
305
- --text-9xl:7rem; --leading-9xl:1; /* 112px */
293
+ /* The type knob. 1 is the published scale; every rung below multiplies by
294
+ it, so a surface or a person retunes the WHOLE ramp by setting one
295
+ number and no rung can drift out of relation with the others.
296
+ @hanzo/design's `vars({type})` emits exactly this name. */
297
+ --type-scale:1;
298
+
299
+ --text-xs:calc(0.6875rem * var(--type-scale, 1)); --leading-xs:calc(1rem * var(--type-scale, 1)); /* 11px — eyebrows / section labels */
300
+ --text-sm:calc(0.8125rem * var(--type-scale, 1)); --leading-sm:calc(1.15rem * var(--type-scale, 1)); /* 13px — nav labels, dense body */
301
+ --text-base:calc(0.875rem * var(--type-scale, 1)); --leading-base:calc(1.35rem * var(--type-scale, 1)); /* 14px — base app text (was 16px) */
302
+ --text-lg:calc(0.9375rem * var(--type-scale, 1)); --leading-lg:calc(1.4rem * var(--type-scale, 1)); /* 15px */
303
+ --text-xl:calc(1.0625rem * var(--type-scale, 1)); --leading-xl:calc(1.55rem * var(--type-scale, 1)); /* 17px */
304
+ --text-2xl:calc(1.3125rem * var(--type-scale, 1)); --leading-2xl:calc(1.7rem * var(--type-scale, 1)); /* 21px */
305
+ --text-3xl:calc(1.625rem * var(--type-scale, 1)); --leading-3xl:calc(1.95rem * var(--type-scale, 1)); /* 26px */
306
+ --text-4xl:calc(2rem * var(--type-scale, 1)); --leading-4xl:calc(2.25rem * var(--type-scale, 1)); /* 32px */
307
+ --text-5xl:calc(2.5rem * var(--type-scale, 1)); --leading-5xl:1.05; /* 40px */
308
+ --text-6xl:calc(3.25rem * var(--type-scale, 1)); --leading-6xl:1; /* 52px */
309
+ --text-7xl:calc(4rem * var(--type-scale, 1)); --leading-7xl:1; /* 64px */
310
+ --text-8xl:calc(5.25rem * var(--type-scale, 1)); --leading-8xl:1; /* 84px */
311
+ --text-9xl:calc(7rem * var(--type-scale, 1)); --leading-9xl:1; /* 112px */
306
312
 
307
313
  /* The SAME scale under @hanzo/brand's spelling. @hanzo/gui's shell theme and
308
314
  Hanzo Studio address the ramp as --font-size-*; both names are one value, so
@@ -367,7 +373,7 @@
367
373
  form feels broken. pointer:coarse is the real signal — a desktop mouse keeps
368
374
  the compact 13px field. */
369
375
  @media (pointer:coarse){
370
- :root{--text-control:1rem}
376
+ :root{--text-control:calc(1rem * var(--type-scale, 1))}
371
377
  }
372
378
 
373
379
  /* ── tokens/spacing.css ─────────────────────────────────────── */
@@ -375,21 +381,27 @@
375
381
  declared in hanzo.ai's tailwind.config.ts (legacy v3 config, kept for
376
382
  reference) — use it for editorial layouts, not for component padding. */
377
383
  :root{
384
+ /* The density knob. 1 is the published spacing; every --space-* rung below
385
+ multiplies by it, and --grid-gap-* reference those rungs rather than
386
+ restating them, so gaps, padding and section rhythm move together.
387
+ @hanzo/design's `vars({density})` emits exactly this name. */
388
+ --density:1;
389
+
378
390
  --space-0:0;
379
- --space-1:0.25rem;
380
- --space-2:0.5rem;
381
- --space-3:0.75rem;
382
- --space-4:1rem;
383
- --space-5:1.25rem;
384
- --space-6:1.5rem;
385
- --space-8:2rem;
386
- --space-10:2.5rem;
387
- --space-12:3rem;
388
- --space-14:3.5rem;
389
- --space-16:4rem;
390
- --space-20:5rem;
391
- --space-24:6rem;
392
- --space-32:8rem;
391
+ --space-1:calc(0.25rem * var(--density, 1));
392
+ --space-2:calc(0.5rem * var(--density, 1));
393
+ --space-3:calc(0.75rem * var(--density, 1));
394
+ --space-4:calc(1rem * var(--density, 1));
395
+ --space-5:calc(1.25rem * var(--density, 1));
396
+ --space-6:calc(1.5rem * var(--density, 1));
397
+ --space-8:calc(2rem * var(--density, 1));
398
+ --space-10:calc(2.5rem * var(--density, 1));
399
+ --space-12:calc(3rem * var(--density, 1));
400
+ --space-14:calc(3.5rem * var(--density, 1));
401
+ --space-16:calc(4rem * var(--density, 1));
402
+ --space-20:calc(5rem * var(--density, 1));
403
+ --space-24:calc(6rem * var(--density, 1));
404
+ --space-32:calc(8rem * var(--density, 1));
393
405
 
394
406
  /* golden ramp (φ) — hanzo.ai tailwind.config.ts */
395
407
  --golden-1:0.25rem;