@hanzo/design 0.5.0 → 0.5.2

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.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: design-system
3
3
  description: "Use whenever you generate, edit, or review a Hanzo user interface — a page, component, screen, email, or any code change touching colour, type, spacing, elevation, motion, or stacking order. Use it when PLANNING UI work too, so the plan names tokens rather than values. Teaches the Hanzo token layer (@hanzo/design, derived from @hanzo/brand plus @hanzo/logo) and runs the linter that proves generated code actually reaches it. Triggers — build a page, add a component, style, theme, dark mode, colour, hex, palette, font size, spacing, padding, z-index, modal, dropdown, toast, button, card, dialog, icon, make it look Hanzo, brand, design review."
4
- license: BSD-3-Clause
4
+ license: MIT OR Apache-2.0
5
5
  ---
6
6
 
7
7
  # The Hanzo design system
@@ -43,7 +43,9 @@ Then reach for a **name**, never a value:
43
43
  | You want | Use | Never |
44
44
  |---|---|---|
45
45
  | a page / text | `--background`, `--foreground`, `--text-secondary` | `#000`, `#fff`, `rgb(…)` |
46
- | a card, a panel | `--surface-card`, `--border-hairline` | a hand-mixed grey |
46
+ | a card, a panel | `--surface-card`, `--border` | a hand-mixed grey |
47
+ | depth — a tool over the workspace | `--sheet-1` + `--shadow-sheet-1` | a box round both, or your own `box-shadow` |
48
+ | "this expands" | `--fold-face` | a fold as ornament |
47
49
  | rank / emphasis | the ladder `--white-05 … --white-80` | an off-ladder 12% or 37% |
48
50
  | a size | `--type-body`, `--type-h2`, `--text-sm` | `font-size: 13px` |
49
51
  | space | `--space-*`, `--gutter*`, `--container-max` | `padding: 13px` |
package/src/index.ts CHANGED
@@ -63,3 +63,9 @@ export function injectDesignCss(href: string): void {
63
63
  l.setAttribute('data-hanzo-design', '')
64
64
  document.head.appendChild(l)
65
65
  }
66
+
67
+ // A person's own reading of the system — type size, density, accent — as CSS
68
+ // custom properties. Pure: it maps a preference to variables and returns them,
69
+ // so an app, an embedded preview and a server render all apply it the same way.
70
+ export { vars, css, isColor, TYPE_MIN, TYPE_MAX } from './preference.js';
71
+ export type { Preference, Density } from './preference.js';
@@ -0,0 +1,122 @@
1
+ /**
2
+ * A person's own reading of the system: type size, density, accent.
3
+ *
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.
9
+ *
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.
17
+ *
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.
23
+ *
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.
27
+ */
28
+
29
+ export type Density = "compact" | "default" | "comfortable";
30
+
31
+ export interface Preference {
32
+ /** Multiplier on the type ramp. 1 is the published scale. */
33
+ type?: number;
34
+ density?: Density;
35
+ /** A CSS colour for --primary / --accent. Rejected unless it is one. */
36
+ accent?: string;
37
+ }
38
+
39
+ /**
40
+ * The type multiplier is CLAMPED, and the bounds are not arbitrary.
41
+ *
42
+ * Below 0.85 the smallest rung (--text-xs, 11px) drops under 9.4px, which stops
43
+ * being small and starts being unreadable — and a preference that lets someone
44
+ * render their own tools illegible is a trap, not a choice. Above 1.4 the
45
+ * chrome stops fitting its own containers: this app's builder header already
46
+ * overlaps its actions below 1440px at scale 1.
47
+ */
48
+ export const TYPE_MIN = 0.85;
49
+ export const TYPE_MAX = 1.4;
50
+
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,
61
+ default: 1,
62
+ comfortable: 1.15,
63
+ };
64
+
65
+ const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
66
+
67
+ /** Trim to 4dp so a multiplier cannot emit a 17-digit float into a stylesheet. */
68
+ const round = (n: number) => String(Math.round(n * 10000) / 10000);
69
+
70
+ /**
71
+ * Is this a colour, or is it something being smuggled into a style attribute?
72
+ *
73
+ * A preference is user input and its destination is CSS. `#fff`, `rgb(...)`,
74
+ * `oklch(...)` and the bare keywords are colours; anything carrying a `;`, a
75
+ * `}`, or a `url(` is trying to be a second declaration, and the answer is to
76
+ * drop the axis rather than to sanitise a string into something plausible.
77
+ */
78
+ export function isColor(v: string): boolean {
79
+ const s = v.trim();
80
+ if (!s || s.length > 64) return false;
81
+ if (/[;{}()]/.test(s) && !/^(rgb|rgba|hsl|hsla|oklch|oklab|lab|lch|color)\([^;{}]*\)$/i.test(s)) return false;
82
+ return (
83
+ /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(s) ||
84
+ /^(rgb|rgba|hsl|hsla|oklch|oklab|lab|lch|color)\([^;{}]*\)$/i.test(s) ||
85
+ /^[a-z]{3,20}$/i.test(s)
86
+ );
87
+ }
88
+
89
+ /**
90
+ * The custom properties a preference produces.
91
+ *
92
+ * Only the axes actually set appear, so an app can spread the result over
93
+ * whatever it already has without a default silently overriding a brand.
94
+ */
95
+ export function vars(p: Preference): Record<string, string> {
96
+ const out: Record<string, string> = {};
97
+
98
+ if (typeof p.type === "number" && Number.isFinite(p.type)) {
99
+ out["--type-scale"] = round(clamp(p.type, TYPE_MIN, TYPE_MAX));
100
+ }
101
+
102
+ if (p.density && p.density in DENSITY) {
103
+ out["--density"] = round(DENSITY[p.density]);
104
+ }
105
+
106
+ if (p.accent && isColor(p.accent)) {
107
+ // Both names, because the ramp uses --primary for action surfaces and
108
+ // --accent for selection. One hue, stated once, landing on both.
109
+ out["--primary"] = p.accent.trim();
110
+ out["--accent"] = p.accent.trim();
111
+ }
112
+
113
+ return out;
114
+ }
115
+
116
+ /** `vars()` as a declaration block, for a <style> tag or an SSR inline. */
117
+ export function css(p: Preference, selector = "html:root"): string {
118
+ const v = vars(p);
119
+ const keys = Object.keys(v);
120
+ if (!keys.length) return "";
121
+ return `${selector}{${keys.map((k) => `${k}:${v[k]}`).join(";")}}`;
122
+ }
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',
@@ -246,6 +248,13 @@ export const elevation = {
246
248
  'shadow-lg': '0 10px 15px -3px rgb(0 0 0 / .55), 0 4px 6px -4px rgb(0 0 0 / .55)',
247
249
  'shadow-xl': '0 20px 25px -5px rgb(0 0 0 / .60), 0 8px 10px -6px rgb(0 0 0 / .60)',
248
250
  'shadow-2xl': 'var(--shadow-floating)',
251
+ 'sheet-0': 'var(--surface-page)',
252
+ 'sheet-1': 'var(--surface-card)',
253
+ 'sheet-2': 'var(--surface-card-emphasis)',
254
+ 'shadow-sheet-1': 'var(--edge-highlight),var(--shadow-md)',
255
+ 'shadow-sheet-2': 'var(--edge-highlight),var(--shadow-lg)',
256
+ 'fold': '12px',
257
+ 'fold-face': 'linear-gradient(315deg,var(--glass-strong) 0,var(--glass-strong) calc(var(--fold) - 1px),var(--border-strong) calc(var(--fold) - 1px),var(--border-strong) var(--fold),transparent var(--fold))',
249
258
  'glow-hero': 'radial-gradient(circle,rgb(255 255 255 / .12) 0%,transparent 68%)',
250
259
  'glow-hero-blur': '120px',
251
260
  'sheen-card': 'radial-gradient(120% 120% at 80% 0%,rgb(255 255 255 / .08) 0%,transparent 55%)',
@@ -390,31 +399,32 @@ export const cssVars = {
390
399
  '--chrome-dot-red': 'rgb(239 68 68 / .6)',
391
400
  '--chrome-dot-yellow': 'rgb(234 179 8 / .6)',
392
401
  '--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',
402
+ '--type-scale': '1',
403
+ '--text-xs': 'calc(0.6875rem * var(--type-scale, 1))',
404
+ '--leading-xs': 'calc(1rem * var(--type-scale, 1))',
405
+ '--text-sm': 'calc(0.8125rem * var(--type-scale, 1))',
406
+ '--leading-sm': 'calc(1.15rem * var(--type-scale, 1))',
407
+ '--text-base': 'calc(0.875rem * var(--type-scale, 1))',
408
+ '--leading-base': 'calc(1.35rem * var(--type-scale, 1))',
409
+ '--text-lg': 'calc(0.9375rem * var(--type-scale, 1))',
410
+ '--leading-lg': 'calc(1.4rem * var(--type-scale, 1))',
411
+ '--text-xl': 'calc(1.0625rem * var(--type-scale, 1))',
412
+ '--leading-xl': 'calc(1.55rem * var(--type-scale, 1))',
413
+ '--text-2xl': 'calc(1.3125rem * var(--type-scale, 1))',
414
+ '--leading-2xl': 'calc(1.7rem * var(--type-scale, 1))',
415
+ '--text-3xl': 'calc(1.625rem * var(--type-scale, 1))',
416
+ '--leading-3xl': 'calc(1.95rem * var(--type-scale, 1))',
417
+ '--text-4xl': 'calc(2rem * var(--type-scale, 1))',
418
+ '--leading-4xl': 'calc(2.25rem * var(--type-scale, 1))',
419
+ '--text-5xl': 'calc(2.5rem * var(--type-scale, 1))',
410
420
  '--leading-5xl': '1.05',
411
- '--text-6xl': '3.25rem',
421
+ '--text-6xl': 'calc(3.25rem * var(--type-scale, 1))',
412
422
  '--leading-6xl': '1',
413
- '--text-7xl': '4rem',
423
+ '--text-7xl': 'calc(4rem * var(--type-scale, 1))',
414
424
  '--leading-7xl': '1',
415
- '--text-8xl': '5.25rem',
425
+ '--text-8xl': 'calc(5.25rem * var(--type-scale, 1))',
416
426
  '--leading-8xl': '1',
417
- '--text-9xl': '7rem',
427
+ '--text-9xl': 'calc(7rem * var(--type-scale, 1))',
418
428
  '--leading-9xl': '1',
419
429
  '--font-size-xs': 'var(--text-xs)',
420
430
  '--font-size-sm': 'var(--text-sm)',
@@ -452,21 +462,22 @@ export const cssVars = {
452
462
  '--type-caption': '400 var(--text-xs)/var(--leading-xs) var(--font-sans)',
453
463
  '--type-code': '400 var(--text-sm)/var(--leading-relaxed) var(--font-mono)',
454
464
  '--type-eyebrow': '600 0.625rem/1 var(--font-sans)',
465
+ '--density': '1',
455
466
  '--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',
467
+ '--space-1': 'calc(0.25rem * var(--density, 1))',
468
+ '--space-2': 'calc(0.5rem * var(--density, 1))',
469
+ '--space-3': 'calc(0.75rem * var(--density, 1))',
470
+ '--space-4': 'calc(1rem * var(--density, 1))',
471
+ '--space-5': 'calc(1.25rem * var(--density, 1))',
472
+ '--space-6': 'calc(1.5rem * var(--density, 1))',
473
+ '--space-8': 'calc(2rem * var(--density, 1))',
474
+ '--space-10': 'calc(2.5rem * var(--density, 1))',
475
+ '--space-12': 'calc(3rem * var(--density, 1))',
476
+ '--space-14': 'calc(3.5rem * var(--density, 1))',
477
+ '--space-16': 'calc(4rem * var(--density, 1))',
478
+ '--space-20': 'calc(5rem * var(--density, 1))',
479
+ '--space-24': 'calc(6rem * var(--density, 1))',
480
+ '--space-32': 'calc(8rem * var(--density, 1))',
470
481
  '--golden-1': '0.25rem',
471
482
  '--golden-2': '0.405rem',
472
483
  '--golden-3': '0.654rem',
@@ -524,6 +535,13 @@ export const cssVars = {
524
535
  '--shadow-lg': '0 10px 15px -3px rgb(0 0 0 / .55), 0 4px 6px -4px rgb(0 0 0 / .55)',
525
536
  '--shadow-xl': '0 20px 25px -5px rgb(0 0 0 / .60), 0 8px 10px -6px rgb(0 0 0 / .60)',
526
537
  '--shadow-2xl': 'var(--shadow-floating)',
538
+ '--sheet-0': 'var(--surface-page)',
539
+ '--sheet-1': 'var(--surface-card)',
540
+ '--sheet-2': 'var(--surface-card-emphasis)',
541
+ '--shadow-sheet-1': 'var(--edge-highlight),var(--shadow-md)',
542
+ '--shadow-sheet-2': 'var(--edge-highlight),var(--shadow-lg)',
543
+ '--fold': '12px',
544
+ '--fold-face': 'linear-gradient(315deg,var(--glass-strong) 0,var(--glass-strong) calc(var(--fold) - 1px),var(--border-strong) calc(var(--fold) - 1px),var(--border-strong) var(--fold),transparent var(--fold))',
527
545
  '--glow-hero': 'radial-gradient(circle,rgb(255 255 255 / .12) 0%,transparent 68%)',
528
546
  '--glow-hero-blur': '120px',
529
547
  '--sheen-card': 'radial-gradient(120% 120% at 80% 0%,rgb(255 255 255 / .08) 0%,transparent 55%)',
package/styles.css CHANGED
@@ -229,23 +229,23 @@
229
229
  silent (the border simply stops existing) and has shipped before. */
230
230
  .light{
231
231
  color-scheme:light;
232
- --background:#ffffff;
232
+ --background:#f7f7f7;
233
233
  --foreground:#0a0a0a;
234
234
  /* A ladder, not four names for #f5f5f5. Light lifts by the same tiny steps
235
235
  dark does — ~2% per rung — so --surface-0..3 mean something in both themes. */
236
- --card:#fafafa;
236
+ --card:#f2f2f2;
237
237
  --card-foreground:#0a0a0a;
238
- --popover:#ffffff;
238
+ --popover:#fbfbfb;
239
239
  --popover-foreground:#0a0a0a;
240
240
  --primary:#0a0a0a;
241
241
  --primary-hover:#262626;
242
242
  --primary-foreground:#fafafa;
243
- --secondary:#ededed;
243
+ --secondary:#e4e4e4;
244
244
  --secondary-hover:#e0e0e0;
245
245
  --secondary-foreground:#0a0a0a;
246
- --muted:#f5f5f5;
246
+ --muted:#ededed;
247
247
  --muted-foreground:#525252;
248
- --accent:#ededed;
248
+ --accent:#e4e4e4;
249
249
  --accent-foreground:#0a0a0a;
250
250
  --destructive:var(--state-error);
251
251
  --destructive-hover:#dc2626;
@@ -270,9 +270,9 @@
270
270
  --selection:rgb(0 0 0 / .16);
271
271
  --glass:rgb(0 0 0 / .04);
272
272
  --glass-strong:rgb(0 0 0 / .07);
273
- --surface-card:#fafafa;
274
- --surface-card-emphasis:#ffffff;
275
- --surface-card-quiet:#fcfcfc;
273
+ --surface-card:#f2f2f2;
274
+ --surface-card-emphasis:#fdfdfd;
275
+ --surface-card-quiet:#f5f5f5;
276
276
  --surface-overlay:rgb(255 255 255 / .95);
277
277
  --surface-header:rgb(255 255 255 / .8);
278
278
  --surface-scrim:rgb(0 0 0 / .5);
@@ -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;
@@ -576,6 +588,41 @@
576
588
  --shadow-lg:0 10px 15px -3px rgb(0 0 0 / .55), 0 4px 6px -4px rgb(0 0 0 / .55);
577
589
  --shadow-xl:0 20px 25px -5px rgb(0 0 0 / .60), 0 8px 10px -6px rgb(0 0 0 / .60);
578
590
  --shadow-2xl:var(--shadow-floating);
591
+
592
+ /* ——— the paper ——— */
593
+ /* Depth carries STATE, not decoration: which sheet is the workspace, which is
594
+ the tool in use, which one opened. Reach for a level before a border — if
595
+ two surfaces need separating, RAISE one rather than outline both, and a
596
+ surface that is already raised does not also need a box drawn round it.
597
+
598
+ A level is a tint AND the light on it, published as a pair, because the
599
+ alternative — every component composing its own `--edge-highlight,
600
+ --shadow-*` — is how two panels on one screen end up lit from two
601
+ directions. Every rung here is pure vertical: a 1px inset line along the
602
+ top edge, a drop straight down. One origin, above and slightly in front.
603
+
604
+ The tints are the surface recipes rather than new values, so sheets stack
605
+ the way paper does: each is an alpha-white wash, one laid on another lands
606
+ a step lighter, and the stack converges on #262626 instead of blowing out.
607
+
608
+ Nothing here is restated in `.light`, deliberately: every part defers to a
609
+ token that already flips, so the ramp inverts with the theme for free. */
610
+ --sheet-0:var(--surface-page); /* the workspace ground */
611
+ --sheet-1:var(--surface-card); /* an active tool */
612
+ --sheet-2:var(--surface-card-emphasis); /* what opened, or what is inside */
613
+ --shadow-sheet-1:var(--edge-highlight),var(--shadow-md);
614
+ --shadow-sheet-2:var(--edge-highlight),var(--shadow-lg);
615
+
616
+ /* The fold — a corner turned back, and the one mark that means THIS OPENS.
617
+ Ornament nowhere: a sheet that does not expand does not wear one.
618
+ It is a background-image, so it costs no element and no pseudo-element —
619
+ paint it on the sheet itself and square that corner
620
+ (`border-bottom-right-radius:0`), because a folded corner is not round.
621
+ The flap catches the light like every other surface; the crease is the
622
+ hairline; both defer, so both invert. */
623
+ --fold:12px;
624
+ --fold-face:linear-gradient(315deg,var(--glass-strong) 0,var(--glass-strong) calc(var(--fold) - 1px),var(--border-strong) calc(var(--fold) - 1px),var(--border-strong) var(--fold),transparent var(--fold)); /* @kind color */
625
+
579
626
  /* Ambient hero glow — a single white radial, blurred 120px, low opacity. */
580
627
  --glow-hero:radial-gradient(circle,rgb(255 255 255 / .12) 0%,transparent 68%); /* @kind color */
581
628
  --glow-hero-blur:120px;
@@ -834,19 +881,26 @@
834
881
  padding:0 var(--space-3);
835
882
  }
836
883
  :where(textarea){padding:var(--space-2) var(--space-3);resize:vertical}
837
- /* ——— focus, on a control ———
838
- Not the generic ring. A field already HAS an edge, so focus brightens that
839
- edge (.15 -> .22) and adds a soft halo just outside it — which is what the
840
- reference does (.composer-box:focus-within) and what separates a focused
841
- field from a browser default. The generic `outline` is suppressed here
842
- precisely because it would draw a second, harder box around this one. */
843
- :where(input,select,textarea):focus-visible{
844
- outline:none;
845
- border-color:var(--border-focus);
846
- box-shadow:var(--ring-focus);
847
- }
848
- /* A hovered field lifts its surface a rung. Nudging its EDGE from .15 to .16
849
- is a state nobody can see. */
884
+ /* A field has NO focus rule of its own — see the ring at the bottom of this
885
+ layer, which is the one focus indicator for everything.
886
+
887
+ There used to be one here: `outline:none` plus a brightened edge (.15 ->
888
+ .22) plus a soft halo, the composer look. It was removed because it could
889
+ not do the job in either of the two ways that matter.
890
+
891
+ It was never VISIBLE ENOUGH. Composited on --background the brightened edge
892
+ measures 1.91:1 and the halo 1.25:1, against the 3:1 that WCAG 1.4.11 asks
893
+ of a focus indicator and that this package already gates --ring on. The
894
+ budget was documented as being spent entirely on --ring "because that is
895
+ what a keyboard user navigates by" true for a button, and false for a
896
+ field for exactly as long as this rule told fields not to use it.
897
+
898
+ And it was SUPPRESSIBLE. It carried the indicator on `border-color`, so any
899
+ app that states `border` on its own fields overrode it — @hanzo/id does,
900
+ unlayered, which beats this layer whatever its specificity, and its focused
901
+ fields sat at the resting .15 while both files read as correct. An outline
902
+ is not a border: nothing in an app's field styling reaches it, so the ring
903
+ paints whether or not the app has opinions about edges. */
850
904
  :where(input,select,textarea):hover:not(:focus-visible):not(:disabled){background:var(--surface-3)}
851
905
  :where(input,textarea)::placeholder{color:var(--text-disabled)}
852
906
  :where(input,select,textarea,button):disabled{opacity:.5;cursor:not-allowed}
@@ -886,6 +940,19 @@
886
940
  }
887
941
  }
888
942
 
943
+ /* THE focus indicator. One rule, every focusable thing, no exceptions — a
944
+ button, a link, a summary, a field. 2px at --ring is 3.77:1 on the darkest
945
+ canvas and clears the 2px perimeter WCAG 2.4.13 asks for; the gate in
946
+ check-tokens holds --ring to that and nothing else here may weaken it.
947
+
948
+ There were two rules until 0.4.9, and they collided invisibly. Both computed
949
+ to (0,1,0) — :where() zeroes whatever it wraps, leaving one pseudo-class on
950
+ each side — so the cascade fell through to SOURCE ORDER inside this layer,
951
+ this rule was written later, and it overrode the `outline:none` the field
952
+ rule stated expressly to prevent it. Every focused input on every consumer
953
+ drew BOTH the ring and the edge+halo. Each rule read as correct alone, which
954
+ is why it survived review in both files; the defect existed only in their
955
+ order. One rule cannot disagree with itself. */
889
956
  :focus-visible{outline:2px solid var(--ring);outline-offset:2px}
890
957
  /* --white-20 is white-on-white in the light theme, so selection reads through
891
958
  --selection, which BOTH themes define. */