@hanzo/design 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -127,3 +127,28 @@ Tokens track `hanzo.ai` (`app/globals.css`, `tailwind.config.ts`, `DESIGN.md`) a
127
127
  ## License
128
128
 
129
129
  BSD-3-Clause. Brand marks (the Hanzo logo, partner and provider logos) are the property of their respective owners and are provided for identification.
130
+
131
+ ## Using it in a Tailwind app
132
+
133
+ One import, after Tailwind's:
134
+
135
+ ```css
136
+ @import "tailwindcss";
137
+ @import "@hanzo/design/tailwind.css";
138
+ ```
139
+
140
+ That carries the tokens **and** maps them onto Tailwind's namespace, so
141
+ `bg-background`, `text-muted-foreground` and `border-border` resolve to the
142
+ system's values. There is no `@theme` block to hand-write and keep in sync.
143
+
144
+ Not using Tailwind? `@import "@hanzo/design/styles.css"` is the same tokens
145
+ without the bridge.
146
+
147
+ ### Two rules, and the build enforces both
148
+
149
+ - **Import it after `tailwindcss`.** `@theme` extends the framework, so it has
150
+ to follow it.
151
+ - **Don't re-declare tokens in your app.** An app that states its own palette
152
+ drifts from every other surface, and any token it forgets resolves to nothing
153
+ — `border-border` becomes `currentColor`, which paints a white hairline on
154
+ black and reads as a design choice rather than a missing line.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/design",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "packageManager": "pnpm@11.17.0",
5
5
  "description": "Hanzo Design System \u2014 monochrome, dark-default tokens + components + brand assets, the single source of truth for every Hanzo surface. CSS + typed programmatic tokens.",
6
6
  "license": "BSD-3-Clause",
@@ -27,7 +27,8 @@
27
27
  "./prompts/*": "./prompts/*",
28
28
  "./content/*": "./content/*",
29
29
  "./assets/*": "./assets/*",
30
- "./skills/*": "./skills/*"
30
+ "./skills/*": "./skills/*",
31
+ "./tailwind.css": "./tailwind.css"
31
32
  },
32
33
  "scripts": {
33
34
  "gen": "node scripts/gen-tokens.mjs",
@@ -41,6 +42,7 @@
41
42
  },
42
43
  "files": [
43
44
  "dist",
45
+ "tailwind.css",
44
46
  "src",
45
47
  "styles.css",
46
48
  "tokens",
@@ -3,7 +3,7 @@
3
3
  // unimported token file resolves nothing, and a 1.66:1 focus ring looks fine to
4
4
  // whoever shipped it. None of them can fail loudly on their own, so they fail
5
5
  // here. Run via `npm test` (part of `build`).
6
- import { readFileSync, readdirSync } from 'node:fs'
6
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
7
7
  import { fileURLToPath } from 'node:url'
8
8
  import { dirname, join } from 'node:path'
9
9
 
@@ -44,6 +44,59 @@ const pass = (msg) => console.log(` ok ${msg}`)
44
44
  entry.includes('@import')
45
45
  ? fail('styles.css contains an @import — a consumer cannot resolve it (see gen-tokens.mjs)')
46
46
  : pass('styles.css has no @import to resolve')
47
+
48
+ // Every asset it names must EXIST at the path it names, from the root the
49
+ // bundle now sits at. tokens/fonts.css points one directory up because it is
50
+ // authored one directory down; flattening rebases that, and a rebase that
51
+ // silently stopped happening would put the faces outside the package and fail
52
+ // every consumer's build on a missing module.
53
+ const urls = [...entry.matchAll(/url\(\s*['"]?([^'")]+)['"]?\s*\)/g)]
54
+ .map((m) => m[1])
55
+ .filter((u) => !/^(data:|https?:|\/\/)/.test(u))
56
+ const broken = urls.filter((u) => !existsSync(join(root, u)))
57
+ broken.length
58
+ ? broken.forEach((u) => fail(`styles.css references ${u}, which does not exist at the package root`))
59
+ : pass(`all ${urls.length} asset url()s resolve from the package root`)
60
+ }
61
+
62
+ // ── 1b. the Tailwind bridge is complete and self-contained ───────────────
63
+ // An app should write ONE import and get working utilities. Each thing checked
64
+ // here failed silently in production before it was checked: a missing slot
65
+ // makes that utility resolve to nothing (`border-border` -> currentColor -> a
66
+ // white hairline on black), and a surviving @import makes the browser drop
67
+ // every token without a word.
68
+ {
69
+ const tw = strip(read(join(root, 'tailwind.css')))
70
+ tw.includes('@import')
71
+ ? fail('tailwind.css contains an @import — invalid after `@import "tailwindcss"`, so the tokens are dropped')
72
+ : pass('tailwind.css has no @import to invalidate')
73
+
74
+ // Every Tailwind colour slot an app will reach for must be mapped.
75
+ const slots = ['background', 'foreground', 'card', 'popover', 'primary', 'secondary',
76
+ 'muted', 'muted-foreground', 'accent', 'destructive', 'border', 'input', 'ring']
77
+ const unmapped = slots.filter((s) => !tw.includes(`--color-${s}:`))
78
+ unmapped.length
79
+ ? fail(`tailwind.css does not map: ${unmapped.join(', ')}`)
80
+ : pass(`tailwind.css maps all ${slots.length} core colour slots`)
81
+
82
+ // And it must carry the values, not merely reference them.
83
+ tw.includes('--border:')
84
+ ? pass('tailwind.css carries the token values inline')
85
+ : fail('tailwind.css maps slots but carries no tokens — every utility resolves to nothing')
86
+ }
87
+
88
+ // ── 1c. element defaults must LOSE to an app's utilities ─────────────────
89
+ // A rule outside a cascade layer beats a rule inside one regardless of
90
+ // specificity, so an unlayered `a{color:...}` here silently outranked every
91
+ // Tailwind text utility on every anchor — a `text-neutral-950` primary button
92
+ // rendered white-on-white and read as a rendering glitch. These are DEFAULTS;
93
+ // a default that cannot be overridden is not a default.
94
+ {
95
+ const base = strip(read(join(tokensDir, 'base.css'))).trim()
96
+ const layered = /^@layer\s+base\s*\{/.test(base) && base.endsWith('}')
97
+ layered
98
+ ? pass('base.css element defaults are inside @layer base')
99
+ : fail('base.css is UNLAYERED — its element rules outrank every utility an app writes')
47
100
  }
48
101
 
49
102
  // ── 2. every var() used inside the token layer must resolve ──────────────
@@ -109,9 +109,97 @@ const banner = `/* Hanzo Design System — the entry point. Import THIS one file
109
109
  * always imported in.
110
110
  */
111
111
  `
112
+ // Hoisting a file from tokens/ to the package root moves what its relative
113
+ // url()s point at. tokens/fonts.css says `../assets/fonts/Geist-Variable.woff2`
114
+ // — correct from tokens/, one directory too high from the root, where it lands
115
+ // outside the package entirely and every consumer's build fails on a missing
116
+ // module. Rebasing is therefore part of flattening, not an afterthought: the
117
+ // bundle is one level shallower, so `../` becomes `./`.
118
+ const rebase = (css) =>
119
+ css.replace(/url\((\s*['"]?)\.\.\/(?!\.)/g, 'url($1./')
120
+
112
121
  const bundle = banner + FILES.map((f) => {
113
- const css = readFileSync(join(tokensDir, `${f}.css`), 'utf8').trim()
122
+ const css = rebase(readFileSync(join(tokensDir, `${f}.css`), 'utf8').trim())
114
123
  return `\n/* ── tokens/${f}.css ─────────────────────────────────────── */\n${css}\n`
115
124
  }).join('')
116
125
  writeFileSync(join(root, 'styles.css'), bundle)
117
126
  console.log(`gen-tokens: wrote styles.css — ${FILES.length} token groups, flattened`)
127
+
128
+ // ── tailwind.css — the ONE line a Tailwind app writes ────────────────────
129
+ // Every app that consumed these tokens still had to hand-write the bridge from
130
+ // our variable names to Tailwind's utility namespace:
131
+ //
132
+ // @theme inline { --color-background: var(--background); ...twenty more }
133
+ //
134
+ // Twenty lines of boilerplate, copied into each new app, is a drift vector with
135
+ // a silent failure mode: miss one and that utility resolves to nothing —
136
+ // `border-border` becomes currentColor and paints a white hairline on black,
137
+ // which reads as a design choice rather than a missing line. Tabs shipped with
138
+ // three of these and used forty-one.
139
+ //
140
+ // So the bridge is generated here from SLOTS, and an app writes one import
141
+ // after `@import "tailwindcss"`. There is nothing left to get wrong, and adding
142
+ // a semantic token wires it up for every app at once.
143
+ const SLOTS = [
144
+ 'background', 'foreground',
145
+ 'card', 'card-foreground',
146
+ 'popover', 'popover-foreground',
147
+ 'primary', 'primary-foreground',
148
+ 'secondary', 'secondary-foreground',
149
+ 'muted', 'muted-foreground',
150
+ 'accent', 'accent-foreground',
151
+ 'destructive', 'destructive-foreground',
152
+ 'border', 'input', 'ring',
153
+ ]
154
+ // State colours are their own vocabulary and keep their names, so `text-error`
155
+ // and `bg-online` mean what they say instead of borrowing a neutral slot.
156
+ const STATE = { error: 'state-error', success: 'state-success', online: 'state-online' }
157
+
158
+ const declared = new Set(flat.map(([n]) => n.replace(/^--/, '')))
159
+ const missing = SLOTS.filter((s) => !declared.has(s))
160
+ if (missing.length) {
161
+ console.error(`gen-tokens: tokens/colors.css declares no --${missing.join(', --')}`)
162
+ process.exit(1)
163
+ }
164
+
165
+ const tw = `/* Hanzo Design System — Tailwind v4 bridge. The ONE import a Tailwind app adds.
166
+ *
167
+ * AUTO-GENERATED by scripts/gen-tokens.mjs — DO NOT EDIT.
168
+ *
169
+ * @import "tailwindcss";
170
+ * @import "@hanzo/design/tailwind.css";
171
+ *
172
+ * It carries the tokens AND maps them onto Tailwind's utility namespace, so
173
+ * \`bg-background\`, \`text-muted-foreground\` and \`border-border\` resolve to the
174
+ * system's values with nothing to wire up by hand.
175
+ *
176
+ * Import it AFTER tailwindcss: @theme has to come after the framework it
177
+ * extends, and the element defaults are layered so your utilities still win.
178
+ *
179
+ * "inline" is deliberate — it compiles \`border-border\` to var(--border) rather
180
+ * than to that variable's build-time value, so switching .light at runtime moves
181
+ * the UI and not merely the variables.
182
+ *
183
+ * The tokens are inlined below rather than @imported for the reason styles.css
184
+ * is flattened: an @import must precede every other rule, so importing this
185
+ * after tailwindcss would invalidate it and the browser would drop the whole
186
+ * token layer without a word.
187
+ */
188
+ ${bundle}
189
+
190
+ @theme inline {
191
+ ${SLOTS.map((s) => ` --color-${s}: var(--${s});`).join('\n')}
192
+
193
+ ${Object.entries(STATE).map(([k, v]) => ` --color-${k}: var(--${v});`).join('\n')}
194
+
195
+ --radius-sm: var(--radius-sm);
196
+ --radius-md: var(--radius-md);
197
+ --radius-lg: var(--radius-lg);
198
+ --radius-xl: var(--radius-xl);
199
+
200
+ --font-sans: var(--font-sans);
201
+ --font-mono: var(--font-mono);
202
+ }
203
+ `
204
+ writeFileSync(join(root, 'tailwind.css'), tw)
205
+ console.log(`gen-tokens: wrote tailwind.css — ${SLOTS.length} colour slots + ${Object.keys(STATE).length} states`)
package/styles.css CHANGED
@@ -449,14 +449,14 @@
449
449
 
450
450
  @font-face{
451
451
  font-family:"Geist";
452
- src:url("../assets/fonts/Geist-Variable.woff2") format("woff2");
452
+ src:url("./assets/fonts/Geist-Variable.woff2") format("woff2");
453
453
  font-weight:100 900;
454
454
  font-style:normal;
455
455
  font-display:swap;
456
456
  }
457
457
  @font-face{
458
458
  font-family:"Geist Mono";
459
- src:url("../assets/fonts/GeistMono-Variable.woff2") format("woff2");
459
+ src:url("./assets/fonts/GeistMono-Variable.woff2") format("woff2");
460
460
  font-weight:100 900;
461
461
  font-style:normal;
462
462
  font-display:swap;
@@ -473,25 +473,40 @@
473
473
 
474
474
  /* ── tokens/base.css ─────────────────────────────────────── */
475
475
  /* Minimal element defaults so specimen cards and kits inherit the brand without
476
- a utility framework. Components carry their own styles inline. */
477
- *{box-sizing:border-box;border-color:var(--border)}
478
- html{-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;scroll-behavior:smooth}
479
- /* Each font-family carries the stack as a literal fallback. --font-sans lives in
480
- tokens/fonts.css, which a surface may legitimately import separately; without
481
- the fallback an unresolved var() makes font-family invalid and the UA drops to
482
- its SERIF default the whole console silently rendered in Times. */
483
- body{margin:0;background:var(--background);color:var(--foreground);font-family:var(--font-sans,ui-sans-serif,system-ui,sans-serif);font-size:var(--text-base);line-height:var(--leading-base);font-feature-settings:var(--font-feature-settings)}
484
- h1,h2,h3,h4{margin:0;font-family:var(--font-display,var(--font-sans,ui-sans-serif,system-ui,sans-serif));letter-spacing:var(--tracking-tight);color:var(--text-primary)}
485
- p{margin:0;text-wrap:pretty}
486
- code,pre,kbd{font-family:var(--font-mono,ui-monospace,SFMono-Regular,monospace)}
487
- a{color:var(--text-primary);text-decoration:none;text-underline-offset:4px;transition:color var(--duration-fast) var(--ease-out)}
488
- /* Underline is the accessible affordance for a link in RUNNING TEXT and a visual
489
- bug everywhere else: nav items, cards and anchor-buttons are all <a> too, so a
490
- blanket `a:hover` underlined every one of them on every surface that imports
491
- these tokens. Scope it to the elements that actually carry prose; a link that
492
- is a component states its own hover. The old rule also re-set `color` to the
493
- value `a` already has a no-op that only served to outrank a component's own
494
- hover colour. */
495
- :is(p,li,blockquote,dd,dt,td,th,figcaption) a:hover{text-decoration:underline}
496
- :focus-visible{outline:2px solid var(--ring);outline-offset:2px}
497
- ::selection{background:var(--white-20);color:#fff}
476
+ a utility framework. Components carry their own styles inline.
477
+
478
+ EVERYTHING HERE IS LAYERED, and that is the most important line in the file.
479
+ A rule outside a cascade layer beats a rule inside one no matter how specific
480
+ the loser is so an unlayered `a{color:...}` here outranked EVERY Tailwind
481
+ text utility on every anchor, on every surface that imports these tokens. A
482
+ white-on-white primary button is what that looks like: `text-neutral-950` on
483
+ an anchor, silently overridden to --text-primary, invisible on its own white
484
+ fill, and reading as a rendering glitch rather than a cascade bug.
485
+
486
+ The same trap already bit `text-decoration` here once (see the a:hover note
487
+ below). It was fixed by deleting that one declaration, which left the identical
488
+ defect in `color` untouched. Layering fixes the whole class: these are
489
+ DEFAULTS, and a default must lose to anything an app states deliberately. */
490
+ @layer base {
491
+ *{box-sizing:border-box;border-color:var(--border)}
492
+ html{-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;scroll-behavior:smooth}
493
+ /* Each font-family carries the stack as a literal fallback. --font-sans lives in
494
+ tokens/fonts.css, which a surface may legitimately import separately; without
495
+ the fallback an unresolved var() makes font-family invalid and the UA drops to
496
+ its SERIF default — the whole console silently rendered in Times. */
497
+ body{margin:0;background:var(--background);color:var(--foreground);font-family:var(--font-sans,ui-sans-serif,system-ui,sans-serif);font-size:var(--text-base);line-height:var(--leading-base);font-feature-settings:var(--font-feature-settings)}
498
+ h1,h2,h3,h4{margin:0;font-family:var(--font-display,var(--font-sans,ui-sans-serif,system-ui,sans-serif));letter-spacing:var(--tracking-tight);color:var(--text-primary)}
499
+ p{margin:0;text-wrap:pretty}
500
+ code,pre,kbd{font-family:var(--font-mono,ui-monospace,SFMono-Regular,monospace)}
501
+ a{color:var(--text-primary);text-decoration:none;text-underline-offset:4px;transition:color var(--duration-fast) var(--ease-out)}
502
+ /* Underline is the accessible affordance for a link in RUNNING TEXT and a visual
503
+ bug everywhere else: nav items, cards and anchor-buttons are all <a> too, so a
504
+ blanket `a:hover` underlined every one of them on every surface that imports
505
+ these tokens. Scope it to the elements that actually carry prose; a link that
506
+ is a component states its own hover. The old rule also re-set `color` to the
507
+ value `a` already has — a no-op that only served to outrank a component's own
508
+ hover colour. */
509
+ :is(p,li,blockquote,dd,dt,td,th,figcaption) a:hover{text-decoration:underline}
510
+ :focus-visible{outline:2px solid var(--ring);outline-offset:2px}
511
+ ::selection{background:var(--white-20);color:#fff}
512
+ }
package/tailwind.css ADDED
@@ -0,0 +1,570 @@
1
+ /* Hanzo Design System — Tailwind v4 bridge. The ONE import a Tailwind app adds.
2
+ *
3
+ * AUTO-GENERATED by scripts/gen-tokens.mjs — DO NOT EDIT.
4
+ *
5
+ * @import "tailwindcss";
6
+ * @import "@hanzo/design/tailwind.css";
7
+ *
8
+ * It carries the tokens AND maps them onto Tailwind's utility namespace, so
9
+ * `bg-background`, `text-muted-foreground` and `border-border` resolve to the
10
+ * system's values with nothing to wire up by hand.
11
+ *
12
+ * Import it AFTER tailwindcss: @theme has to come after the framework it
13
+ * extends, and the element defaults are layered so your utilities still win.
14
+ *
15
+ * "inline" is deliberate — it compiles `border-border` to var(--border) rather
16
+ * than to that variable's build-time value, so switching .light at runtime moves
17
+ * the UI and not merely the variables.
18
+ *
19
+ * The tokens are inlined below rather than @imported for the reason styles.css
20
+ * is flattened: an @import must precede every other rule, so importing this
21
+ * after tailwindcss would invalidate it and the browser would drop the whole
22
+ * token layer without a word.
23
+ */
24
+ /* Hanzo Design System — the entry point. Import THIS one file.
25
+ *
26
+ * AUTO-GENERATED by scripts/gen-tokens.mjs from tokens/*.css — DO NOT EDIT.
27
+ * Edit the token in tokens/<group>.css and re-run "npm run gen".
28
+ *
29
+ * Flattened deliberately: a consumer's bundler resolves a nested url() against
30
+ * ITS OWN directory, not ours, so an @import list here is a build error in
31
+ * every app that follows the instruction above. Nested imports must also
32
+ * precede all other rules, which the spec invalidates the moment the consumer
33
+ * imports anything first — browsers then drop them silently and every token
34
+ * resolves to nothing. The content is inlined in the order the groups were
35
+ * always imported in.
36
+ */
37
+
38
+ /* ── tokens/colors.css ─────────────────────────────────────── */
39
+ /* Hanzo is monochrome. One hue rendered through an opacity ladder.
40
+ Base ladder = Tailwind neutral (tailwind.config.ts). Semantic names match
41
+ hanzo.ai's CSS variables exactly, so code copies over 1:1.
42
+ DARK IS THE DEFAULT THEME (hanzo.ai mounts ThemeProvider defaultTheme="dark"). */
43
+
44
+ :root{
45
+ /* ——— base neutral ladder ——— */
46
+ --neutral-50:#FAFAFA;
47
+ --neutral-100:#F5F5F5;
48
+ --neutral-200:#E5E5E5;
49
+ --neutral-300:#D4D4D4;
50
+ --neutral-400:#A3A3A3;
51
+ --neutral-500:#737373;
52
+ --neutral-600:#525252;
53
+ --neutral-700:#404040;
54
+ --neutral-800:#262626;
55
+ --neutral-900:#171717;
56
+ --neutral-950:#0A0A0A;
57
+ --pure-black:#000000;
58
+ --pure-white:#FFFFFF;
59
+ /* Press-kit brand constants (public/press/hanzo/README.md). */
60
+ --hanzo-black:#0A0A0B;
61
+ --hanzo-white:#FFFFFF;
62
+
63
+ /* ——— the opacity ladder: the real palette ——— */
64
+ --white-05:rgb(255 255 255 / .05);
65
+ --white-10:rgb(255 255 255 / .10);
66
+ --white-15:rgb(255 255 255 / .15);
67
+ --white-20:rgb(255 255 255 / .20);
68
+ --white-30:rgb(255 255 255 / .30);
69
+ --white-40:rgb(255 255 255 / .40);
70
+ --white-60:rgb(255 255 255 / .60);
71
+ --white-80:rgb(255 255 255 / .80);
72
+
73
+ /* ——— semantic aliases (dark, the default) ——— */
74
+ --background:#000000;
75
+ --foreground:#ededed;
76
+ --card:#0a0a0a;
77
+ --card-foreground:#f5f5f5;
78
+ --popover:#0a0a0a;
79
+ --popover-foreground:#f5f5f5;
80
+ --primary:#ffffff;
81
+ --primary-foreground:#000000;
82
+ --secondary:#1a1a1a;
83
+ --secondary-foreground:#f5f5f5;
84
+ --muted:#101010;
85
+ --muted-foreground:#888888;
86
+ --accent:#1a1a1a;
87
+ --accent-foreground:#f5f5f5;
88
+ --destructive:#666666;
89
+ --destructive-foreground:#f5f5f5;
90
+ --border:#1f1f1f;
91
+ --input:#1f1f1f;
92
+ /* A focus indicator is a NON-TEXT CONTRAST target: WCAG 2.4.11/1.4.11 require
93
+ 3:1 against every surface it can land on. --neutral-500 is the only rung on
94
+ this ladder that clears 3:1 on all of them — #000000, #0a0a0a, #101010,
95
+ #1a1a1a, AND the light theme's #ffffff/#f5f5f5 — so one value serves both
96
+ themes. (Was #333333 = 1.66:1 on --background: not a focus indicator.) */
97
+ --ring:var(--neutral-500);
98
+ --brand:#e4e4e7;
99
+ --brand-foreground:#09090b;
100
+ --brand-muted:#a3a3a3;
101
+ --black:#000000;
102
+ --white:#f5f5f5;
103
+
104
+ /* ——— surface recipes (card fills used across hanzo.ai) ——— */
105
+ --surface-page:var(--background);
106
+ --surface-card:rgb(23 23 23 / .5); /* bg-neutral-900/50 — grid tiles */
107
+ --surface-card-emphasis:rgb(23 23 23 / .8);/* bg-neutral-900/80 — featured */
108
+ --surface-card-quiet:rgb(23 23 23 / .4); /* bg-neutral-900/40 — story cards */
109
+ --surface-overlay:rgb(10 10 10 / .95); /* dropdown / popover panels */
110
+ --surface-header:rgb(0 0 0 / .7); /* fixed nav, with backdrop blur */
111
+ --surface-scrim:rgb(0 0 0 / .8); /* the dialog / sheet backdrop */
112
+ /* Boundaries come in two kinds and they are NOT interchangeable.
113
+ DECORATIVE (--border, --border-hairline, --border-card): separates content;
114
+ WCAG imposes no ratio. Keep them quiet.
115
+ PERCEIVABLE (--border-strong): identifies a CONTROL — an input edge, a
116
+ switch, a checkbox — and must clear 3:1 (WCAG 1.4.11) on every surface.
117
+ Reach for --border-strong whenever the boundary IS the affordance. */
118
+ --border-hairline:var(--neutral-800);
119
+ --border-card:var(--white-10);
120
+ --border-strong:var(--neutral-500); /* 3.59:1 worst case — see --ring */
121
+
122
+ /* ——— the numeric surface ladder ——— */
123
+ /* Aliases onto the semantic canvases above, so a brand fork that retunes
124
+ --card/--muted/--secondary retunes the ladder with it and the light theme
125
+ inverts for free. Ascending lift: 0 is the page, 3 is a hovered control. */
126
+ --surface-0:var(--background);
127
+ --surface-1:var(--card);
128
+ --surface-2:var(--muted);
129
+ --surface-3:var(--secondary);
130
+
131
+ /* ——— text ranks ——— */
132
+ --text-primary:var(--pure-white);
133
+ --text-secondary:var(--white-80);
134
+ --text-tertiary:var(--white-60);
135
+ --text-helper:var(--muted-foreground);
136
+ --text-disabled:var(--white-30);
137
+
138
+ /* ——— the ONLY permitted hues (DESIGN.md §2.4) ——— */
139
+ --state-error:#ef4444; /* red-500 — destructive / blocking error */
140
+ --state-error-text:#fca5a5; /* red-300 */
141
+ --state-error-bg:rgb(239 68 68 / .1);
142
+ --state-online:#4ade80; /* green-400 — live status dot */
143
+ --state-success:#22c55e; /* green-500 — "Free" / "Save N%" callouts */
144
+ --chrome-dot-red:rgb(239 68 68 / .6);
145
+ --chrome-dot-yellow:rgb(234 179 8 / .6);
146
+ --chrome-dot-green:rgb(34 197 94 / .6);
147
+ }
148
+
149
+ /* Light theme — the same tokens, inverted. Rare: only /brand-style docs pages. */
150
+ .light{
151
+ --background:#ffffff;
152
+ --foreground:#0a0a0a;
153
+ --card:#f5f5f5;
154
+ --card-foreground:#0a0a0a;
155
+ --popover:#ffffff;
156
+ --popover-foreground:#0a0a0a;
157
+ --primary:#0a0a0a;
158
+ --primary-foreground:#ffffff;
159
+ --secondary:#f5f5f5;
160
+ --secondary-foreground:#0a0a0a;
161
+ --muted:#f5f5f5;
162
+ --muted-foreground:#525252;
163
+ --accent:#f5f5f5;
164
+ --accent-foreground:#0a0a0a;
165
+ --destructive:#999999;
166
+ --destructive-foreground:#ffffff;
167
+ --border:#e5e5e5;
168
+ --input:#e5e5e5;
169
+ /* Same rung as dark: #d4d4d4 measured 1.48:1 on white and could not carry a
170
+ focus indicator either. --neutral-500 is 4.74:1 on #ffffff / 4.38:1 on
171
+ #f5f5f5, so ONE value is conformant in both themes. */
172
+ --ring:var(--neutral-500);
173
+ --black:#0a0a0a;
174
+ --white:#ffffff;
175
+ --surface-card:#f5f5f5;
176
+ --surface-card-emphasis:#ffffff;
177
+ --surface-card-quiet:#fafafa;
178
+ --surface-overlay:rgb(255 255 255 / .95);
179
+ --surface-header:rgb(255 255 255 / .8);
180
+ --surface-scrim:rgb(0 0 0 / .5);
181
+ --border-hairline:var(--neutral-200);
182
+ --border-card:rgb(0 0 0 / .1);
183
+ --border-strong:var(--neutral-500); /* was --neutral-300 = 1.48:1 on white */
184
+ /* The white-opacity ladder does NOT invert, so --white-40 is white-on-white
185
+ here (1.00:1). Anything that needs a visible edge in BOTH themes must use
186
+ --border-strong, never a --white-* rung. */
187
+ --text-primary:var(--neutral-950);
188
+ --text-secondary:rgb(10 10 10 / .8);
189
+ --text-tertiary:rgb(10 10 10 / .6);
190
+ --text-disabled:rgb(10 10 10 / .3);
191
+ }
192
+
193
+ /* ── tokens/typography.css ─────────────────────────────────────── */
194
+ /* TIGHT app-first type scale — the compact developer-app register (linear.app /
195
+ vercel.com / the Codex desktop look), the Hanzo default across chat / app /
196
+ desktop. Base is 14px, nav 13px, labels 11px; display sizes tightened. Kept in
197
+ lockstep with @hanzo/brand (styles/variables.css --font-size-* + typography.ts)
198
+ — the two are the SAME scale, mirrored. A surface/tenant overrides any --text-*
199
+ on :root to retune density on demand. */
200
+ :root{
201
+ --text-xs:0.6875rem; --leading-xs:1rem; /* 11px — eyebrows / section labels */
202
+ --text-sm:0.8125rem; --leading-sm:1.15rem; /* 13px — nav labels, dense body */
203
+ --text-base:0.875rem; --leading-base:1.35rem; /* 14px — base app text (was 16px) */
204
+ --text-lg:0.9375rem; --leading-lg:1.4rem; /* 15px */
205
+ --text-xl:1.0625rem; --leading-xl:1.55rem; /* 17px */
206
+ --text-2xl:1.3125rem; --leading-2xl:1.7rem; /* 21px */
207
+ --text-3xl:1.625rem; --leading-3xl:1.95rem; /* 26px */
208
+ --text-4xl:2rem; --leading-4xl:2.25rem; /* 32px */
209
+ --text-5xl:2.5rem; --leading-5xl:1.05; /* 40px */
210
+ --text-6xl:3.25rem; --leading-6xl:1; /* 52px */
211
+ --text-7xl:4rem; --leading-7xl:1; /* 64px */
212
+ --text-8xl:5.25rem; --leading-8xl:1; /* 84px */
213
+ --text-9xl:7rem; --leading-9xl:1; /* 112px */
214
+
215
+ /* The SAME scale under @hanzo/brand's spelling. @hanzo/gui's shell theme and
216
+ Hanzo Studio address the ramp as --font-size-*; both names are one value, so
217
+ a component written against either resolves here. --text-* is canonical. */
218
+ --font-size-xs:var(--text-xs);
219
+ --font-size-sm:var(--text-sm);
220
+ --font-size-base:var(--text-base);
221
+ --font-size-lg:var(--text-lg);
222
+ --font-size-xl:var(--text-xl);
223
+ --font-size-2xl:var(--text-2xl);
224
+ --font-size-3xl:var(--text-3xl);
225
+ --font-size-4xl:var(--text-4xl);
226
+ --font-size-5xl:var(--text-5xl);
227
+ --font-size-6xl:var(--text-6xl);
228
+ --font-size-7xl:var(--text-7xl);
229
+ --font-size-8xl:var(--text-8xl);
230
+ --font-size-9xl:var(--text-9xl);
231
+
232
+ --weight-normal:400;
233
+ --weight-medium:500;
234
+ --weight-semibold:600;
235
+ --weight-bold:700;
236
+
237
+ --tracking-tight:-0.025em;
238
+ --tracking-normal:0em;
239
+ --tracking-wide:0.025em;
240
+ --tracking-widest:0.1em; /* eyebrows / uppercase category labels */
241
+
242
+ --leading-none:1;
243
+ --leading-tight:1.25;
244
+ --leading-snug:1.375;
245
+ --leading-normal:1.5;
246
+ --leading-relaxed:1.625;
247
+ --leading-golden:1.618;
248
+
249
+ /* named roles */
250
+ --type-hero:600 var(--text-5xl)/1.05 var(--font-display);
251
+ --type-h2:700 var(--text-4xl)/var(--leading-4xl) var(--font-display);
252
+ --type-h3:600 var(--text-xl)/var(--leading-xl) var(--font-display);
253
+ --type-lead:400 var(--text-lg)/var(--leading-relaxed) var(--font-sans);
254
+ --type-body:400 var(--text-sm)/var(--leading-sm) var(--font-sans);
255
+ --type-caption:400 var(--text-xs)/var(--leading-xs) var(--font-sans);
256
+ --type-code:400 var(--text-sm)/var(--leading-relaxed) var(--font-mono);
257
+ --type-eyebrow:600 0.625rem/1 var(--font-sans);
258
+ }
259
+
260
+ /* ── tokens/spacing.css ─────────────────────────────────────── */
261
+ /* Spacing: the 4px Tailwind ramp is what ships. The golden-ratio ramp below is
262
+ declared in hanzo.ai's tailwind.config.ts (legacy v3 config, kept for
263
+ reference) — use it for editorial layouts, not for component padding. */
264
+ :root{
265
+ --space-0:0;
266
+ --space-1:0.25rem;
267
+ --space-2:0.5rem;
268
+ --space-3:0.75rem;
269
+ --space-4:1rem;
270
+ --space-5:1.25rem;
271
+ --space-6:1.5rem;
272
+ --space-8:2rem;
273
+ --space-10:2.5rem;
274
+ --space-12:3rem;
275
+ --space-14:3.5rem;
276
+ --space-16:4rem;
277
+ --space-20:5rem;
278
+ --space-24:6rem;
279
+ --space-32:8rem;
280
+
281
+ /* golden ramp (φ) — hanzo.ai tailwind.config.ts */
282
+ --golden-1:0.25rem;
283
+ --golden-2:0.405rem;
284
+ --golden-3:0.654rem;
285
+ --golden-4:1.059rem;
286
+ --golden-5:1.713rem;
287
+ --golden-6:2.772rem;
288
+ --golden-7:4.487rem;
289
+ --golden-8:7.26rem;
290
+ --golden-9:11.749rem;
291
+ --golden-split:38.2% 61.8%; /* @kind other */
292
+
293
+ /* layout rules (DESIGN.md §1.3) */
294
+ --container-max:80rem; /* max-w-7xl — grids */
295
+ --container-prose:48rem; /* max-w-3xl — centered text */
296
+ --container-wide:72rem; /* max-w-6xl — landing sections */
297
+ --gutter:1rem; /* px-4 */
298
+ --gutter-sm:1.5rem; /* sm:px-6 */
299
+ --gutter-lg:2rem; /* lg:px-8 */
300
+ --section-y:4rem; /* py-16 — content sections */
301
+ --section-y-lg:6rem; /* py-24 — landing sections */
302
+ --hero-y:5rem; /* py-20 … */
303
+ --hero-y-lg:8rem; /* … lg:py-32 */
304
+ --header-height:4rem;
305
+ }
306
+
307
+ /* ── tokens/grid.css ─────────────────────────────────────── */
308
+ /* Grid — one definition of the field every Hanzo surface lays out on.
309
+
310
+ Derived, not invented. Across hanzo.ai and hanzo.app the grid written by hand
311
+ is overwhelmingly ONE grid: `grid-cols-1 md:grid-cols-2 lg:grid-cols-3` with a
312
+ 1.5rem gutter (240 / 197 / 127 / 185 occurrences on hanzo.ai alone, and the
313
+ same shape leads on app). 1, 2, 3 and 4 columns account for 97% of every
314
+ grid-cols- in both repos, and all four divide 12 cleanly — so 12 is the base
315
+ the spans are cut from, and the long tail (5, 6, 7, 8, 12) still lands on it.
316
+
317
+ Containers, gutters and section rhythm are NOT restated here: spacing.css
318
+ already owns --container-max / --container-prose / --container-wide /
319
+ --gutter* / --section-y*, and a grid that redeclared them would be a second
320
+ answer to a settled question. This file adds only what a grid knows that
321
+ spacing does not — how many columns, how far apart, and where it reflows. */
322
+ :root{
323
+ /* The base. Spans are cut from twelve because 1/2/3/4/6 all divide it. */
324
+ --grid-columns:12;
325
+
326
+ /* Gutter. --grid-gap is the measured card-grid default (gap-6 leads hanzo.ai
327
+ at 185 uses); tight and loose are the neighbouring steps that cover app's
328
+ gap-4 (45) and the wide editorial grids (gap-8, 75). All three reference the
329
+ space ramp — the grid does not get its own private set of distances. */
330
+ --grid-gap:var(--space-6);
331
+ --grid-gap-tight:var(--space-4);
332
+ --grid-gap-loose:var(--space-8);
333
+
334
+ /* Reflow points, byte-identical to the Tailwind v4 defaults these surfaces
335
+ already compile against. Same NAMES and same VALUES on purpose: the utility
336
+ layer and anything reading tokens then resolve one value, not two that agree
337
+ by luck. md and lg carry ~85% of every breakpoint prefix in use; xl is 44
338
+ occurrences across both repos and 2xl is 1. */
339
+ --breakpoint-sm:40rem;
340
+ --breakpoint-md:48rem;
341
+ --breakpoint-lg:64rem;
342
+ --breakpoint-xl:80rem;
343
+ --breakpoint-2xl:96rem;
344
+
345
+ /* Intrinsic card grids: state ONE track minimum instead of three column counts
346
+ at three breakpoints. `repeat(auto-fit, minmax(var(--grid-card-min), 1fr))`
347
+ reflows 1 -> 2 -> 3 at the same widths the explicit recipe does, and it does
348
+ it from the space available rather than from the viewport — so a card grid
349
+ inside a sidebar behaves correctly, which the breakpoint version cannot.
350
+
351
+ 18rem is chosen, not rounded to: inside --container-max less --gutter-lg on
352
+ both sides, lg (64rem) leaves ~60rem, where 3 tracks need 3x18 + 2x1.5 =
353
+ 57rem and 4 would need 76.5rem; md (48rem) leaves ~44rem, where 2 tracks
354
+ need 37.5rem and 3 would need 57rem. The measured 1/2/3 ladder falls out. */
355
+ --grid-card-min:18rem;
356
+ --grid-card-min-wide:24rem;
357
+ }
358
+
359
+ /* ── tokens/radius.css ─────────────────────────────────────── */
360
+ :root{
361
+ --radius:0.5rem; /* the base token (globals.css) */
362
+ --radius-sm:0.375rem; /* rounded-md — buttons, inputs */
363
+ --radius-md:0.5rem;
364
+ --radius-lg:0.75rem; /* rounded-xl — cards */
365
+ --radius-xl:1rem; /* rounded-2xl — dropdown panels */
366
+ --radius-2xl:1.5rem; /* rounded-3xl — story / hero cards */
367
+ --radius-composer:28px; /* the chat composer, exactly 28px */
368
+ --radius-full:9999px; /* pills, CTAs, avatars, badges */
369
+ }
370
+
371
+ /* ── tokens/elevation.css ─────────────────────────────────────── */
372
+ /* Hanzo barely uses shadow: on black, elevation reads as a hairline border plus
373
+ a wide, very dark drop. Only two levels ship (Tailwind's shadow-2xl for
374
+ floating surfaces) plus the ambient radial glow used behind heroes. */
375
+ :root{
376
+ --shadow-none:none;
377
+ --shadow-floating:0 25px 50px -12px rgb(0 0 0 / .25); /* shadow-2xl: composer, dropdowns, mega panel */
378
+ --shadow-inset-hairline:inset 0 0 0 1px var(--white-10);
379
+ --ring-focus:0 0 0 2px var(--ring);
380
+
381
+ /* The t-shirt ramp. Named by size rather than by role, because that is how
382
+ every component library already asks for a shadow (and how @hanzo/brand
383
+ spells it). Alphas are heavier than Tailwind's defaults: on a near-black
384
+ ground a 10% black drop is invisible, so each rung is tuned to read on
385
+ --background. --shadow-2xl and --shadow-floating are the same rung. */
386
+ --shadow-sm:0 1px 2px 0 rgb(0 0 0 / .40);
387
+ --shadow:0 1px 3px 0 rgb(0 0 0 / .45), 0 1px 2px -1px rgb(0 0 0 / .45);
388
+ --shadow-md:0 4px 6px -1px rgb(0 0 0 / .50), 0 2px 4px -2px rgb(0 0 0 / .50);
389
+ --shadow-lg:0 10px 15px -3px rgb(0 0 0 / .55), 0 4px 6px -4px rgb(0 0 0 / .55);
390
+ --shadow-xl:0 20px 25px -5px rgb(0 0 0 / .60), 0 8px 10px -6px rgb(0 0 0 / .60);
391
+ --shadow-2xl:var(--shadow-floating);
392
+ /* Ambient hero glow — a single white radial, blurred 120px, low opacity. */
393
+ --glow-hero:radial-gradient(circle,rgb(255 255 255 / .12) 0%,transparent 68%); /* @kind color */
394
+ --glow-hero-blur:120px;
395
+ /* Card top-corner sheen used on the story cards. */
396
+ --sheen-card:radial-gradient(120% 120% at 80% 0%,rgb(255 255 255 / .08) 0%,transparent 55%); /* @kind color */
397
+ /* Chrome text: the canonical headline gradient. Never a saturated rainbow. */
398
+ --gradient-chrome:linear-gradient(to right,#ffffff,var(--white-80),var(--white-60));
399
+ --gradient-chrome-2:linear-gradient(to right,#ffffff,var(--neutral-500));
400
+ /* Section-top protection gradient (hero overlays). */
401
+ --gradient-protect:linear-gradient(to bottom,var(--white-10),transparent);
402
+ }
403
+
404
+ /* ── tokens/motion.css ─────────────────────────────────────── */
405
+ /* Motion is restrained: fade + small rise, CSS-only hovers, one breathing glow.
406
+ No springs, no bounce, no parallax, no autoplay carousels. */
407
+ :root{
408
+ --duration-fast:150ms; /* @kind other */ /* dropdown / panel open */
409
+ --duration-base:300ms; /* @kind other */ /* slide-up-fade */
410
+ --duration-slow:400ms; /* @kind other */ /* hero element entry */
411
+ --duration-slower:500ms; /* @kind other */ /* section entry */
412
+ --duration-glow:9s; /* @kind other */ /* ambient radial breathe */
413
+ --ease-out:cubic-bezier(0,0,0.2,1); /* @kind other */
414
+ --ease-in-out:cubic-bezier(0.4,0,0.2,1); /* @kind other */
415
+ --stagger:60ms; /* @kind other */ /* per-element delay in a group */
416
+ --entry-rise:16px; /* hero y-offset */
417
+ --entry-rise-lg:24px; /* card y-offset */
418
+ }
419
+
420
+ @keyframes hanzo-fade-up{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
421
+ @keyframes hanzo-fade-down{from{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}}
422
+ @keyframes hanzo-slide-up-fade{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}
423
+ @keyframes hanzo-glow{0%,100%{transform:scale(1);opacity:.45}50%{transform:scale(1.08);opacity:.65}}
424
+ @keyframes hanzo-pulse-dot{0%,100%{opacity:1}50%{opacity:.35}}
425
+
426
+ @media (prefers-reduced-motion:reduce){
427
+ *,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important}
428
+ }
429
+
430
+ /* ── tokens/z.css ─────────────────────────────────────── */
431
+ /* Stacking order — the one z-index ladder. Layers are named by role, never by a
432
+ magic number, so a dropdown opened from the fixed header always sits above it
433
+ and nothing ever reaches for 9999. Below --z-raised is ordinary document flow.
434
+
435
+ Rungs are 100 apart so a surface can slot a one-off BETWEEN two roles
436
+ (calc(var(--z-modal) + 1)) without inventing a new decade. */
437
+ :root{
438
+ --z-base:0;
439
+ --z-raised:10; /* hover-lifted cards, sticky table headers */
440
+ --z-sticky:200; /* pinned section rails */
441
+ --z-header:300; /* the fixed site header */
442
+ --z-dropdown:400; /* menus, selects, comboboxes */
443
+ --z-overlay:500; /* dialog / sheet scrim */
444
+ --z-modal:600; /* dialogs, sheets, command palette */
445
+ --z-popover:700; /* popovers, tooltips (also when anchored in modals) */
446
+ --z-toast:800; /* toasts / notifications — always on top */
447
+
448
+ /* @hanzo/brand spells the top two rungs --z-tooltip and --z-notification.
449
+ Same rungs, so a component written against either vocabulary stacks
450
+ identically. --z-popover / --z-toast are canonical. */
451
+ --z-tooltip:var(--z-popover);
452
+ --z-notification:var(--z-toast);
453
+ }
454
+
455
+ /* ── tokens/fonts.css ─────────────────────────────────────── */
456
+ /* Geist Sans + Geist Mono — the only two faces on Hanzo surfaces.
457
+ SELF-HOSTED. The faces ship inside this package (assets/fonts/*.woff2, two
458
+ variable files, 141 KB total, SIL OFL-1.1 — see assets/fonts/LICENSE-Geist.txt).
459
+
460
+ Why self-hosted rather than @import from fonts.googleapis.com:
461
+ - A sign-in page must not make a third-party request. hanzoai/id refused to
462
+ import this file for exactly that reason, which split the token layer: id
463
+ took the colours and not the typeface. Self-hosting removes the reason, so
464
+ every surface can import styles.css unchanged.
465
+ - The @import was a render-blocking request to a host we do not control, on
466
+ the critical path of every surface, and it broke offline/air-gapped dev.
467
+ - One variable file per family replaces nine static weights, and it is fewer
468
+ bytes than the CSS-then-woff2 round trip Google served.
469
+
470
+ The url()s are relative to THIS file, so they resolve wherever the package is
471
+ mounted — node_modules, a CDN, a copied dist — with no configuration. */
472
+
473
+ @font-face{
474
+ font-family:"Geist";
475
+ src:url("./assets/fonts/Geist-Variable.woff2") format("woff2");
476
+ font-weight:100 900;
477
+ font-style:normal;
478
+ font-display:swap;
479
+ }
480
+ @font-face{
481
+ font-family:"Geist Mono";
482
+ src:url("./assets/fonts/GeistMono-Variable.woff2") format("woff2");
483
+ font-weight:100 900;
484
+ font-style:normal;
485
+ font-display:swap;
486
+ }
487
+
488
+ :root{
489
+ --font-sans:"Geist","Geist Sans",ui-sans-serif,system-ui,sans-serif;
490
+ --font-display:var(--font-sans);
491
+ --font-mono:"Geist Mono",ui-monospace,SFMono-Regular,monospace;
492
+ --font-serif:Georgia,serif;
493
+ /* hanzo.ai sets these OpenType features on <body>. */
494
+ --font-feature-settings:"ss01","ss02","cv01","cv02","cv03";
495
+ }
496
+
497
+ /* ── tokens/base.css ─────────────────────────────────────── */
498
+ /* Minimal element defaults so specimen cards and kits inherit the brand without
499
+ a utility framework. Components carry their own styles inline.
500
+
501
+ EVERYTHING HERE IS LAYERED, and that is the most important line in the file.
502
+ A rule outside a cascade layer beats a rule inside one no matter how specific
503
+ the loser is — so an unlayered `a{color:...}` here outranked EVERY Tailwind
504
+ text utility on every anchor, on every surface that imports these tokens. A
505
+ white-on-white primary button is what that looks like: `text-neutral-950` on
506
+ an anchor, silently overridden to --text-primary, invisible on its own white
507
+ fill, and reading as a rendering glitch rather than a cascade bug.
508
+
509
+ The same trap already bit `text-decoration` here once (see the a:hover note
510
+ below). It was fixed by deleting that one declaration, which left the identical
511
+ defect in `color` untouched. Layering fixes the whole class: these are
512
+ DEFAULTS, and a default must lose to anything an app states deliberately. */
513
+ @layer base {
514
+ *{box-sizing:border-box;border-color:var(--border)}
515
+ html{-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;scroll-behavior:smooth}
516
+ /* Each font-family carries the stack as a literal fallback. --font-sans lives in
517
+ tokens/fonts.css, which a surface may legitimately import separately; without
518
+ the fallback an unresolved var() makes font-family invalid and the UA drops to
519
+ its SERIF default — the whole console silently rendered in Times. */
520
+ body{margin:0;background:var(--background);color:var(--foreground);font-family:var(--font-sans,ui-sans-serif,system-ui,sans-serif);font-size:var(--text-base);line-height:var(--leading-base);font-feature-settings:var(--font-feature-settings)}
521
+ h1,h2,h3,h4{margin:0;font-family:var(--font-display,var(--font-sans,ui-sans-serif,system-ui,sans-serif));letter-spacing:var(--tracking-tight);color:var(--text-primary)}
522
+ p{margin:0;text-wrap:pretty}
523
+ code,pre,kbd{font-family:var(--font-mono,ui-monospace,SFMono-Regular,monospace)}
524
+ a{color:var(--text-primary);text-decoration:none;text-underline-offset:4px;transition:color var(--duration-fast) var(--ease-out)}
525
+ /* Underline is the accessible affordance for a link in RUNNING TEXT and a visual
526
+ bug everywhere else: nav items, cards and anchor-buttons are all <a> too, so a
527
+ blanket `a:hover` underlined every one of them on every surface that imports
528
+ these tokens. Scope it to the elements that actually carry prose; a link that
529
+ is a component states its own hover. The old rule also re-set `color` to the
530
+ value `a` already has — a no-op that only served to outrank a component's own
531
+ hover colour. */
532
+ :is(p,li,blockquote,dd,dt,td,th,figcaption) a:hover{text-decoration:underline}
533
+ :focus-visible{outline:2px solid var(--ring);outline-offset:2px}
534
+ ::selection{background:var(--white-20);color:#fff}
535
+ }
536
+
537
+
538
+ @theme inline {
539
+ --color-background: var(--background);
540
+ --color-foreground: var(--foreground);
541
+ --color-card: var(--card);
542
+ --color-card-foreground: var(--card-foreground);
543
+ --color-popover: var(--popover);
544
+ --color-popover-foreground: var(--popover-foreground);
545
+ --color-primary: var(--primary);
546
+ --color-primary-foreground: var(--primary-foreground);
547
+ --color-secondary: var(--secondary);
548
+ --color-secondary-foreground: var(--secondary-foreground);
549
+ --color-muted: var(--muted);
550
+ --color-muted-foreground: var(--muted-foreground);
551
+ --color-accent: var(--accent);
552
+ --color-accent-foreground: var(--accent-foreground);
553
+ --color-destructive: var(--destructive);
554
+ --color-destructive-foreground: var(--destructive-foreground);
555
+ --color-border: var(--border);
556
+ --color-input: var(--input);
557
+ --color-ring: var(--ring);
558
+
559
+ --color-error: var(--state-error);
560
+ --color-success: var(--state-success);
561
+ --color-online: var(--state-online);
562
+
563
+ --radius-sm: var(--radius-sm);
564
+ --radius-md: var(--radius-md);
565
+ --radius-lg: var(--radius-lg);
566
+ --radius-xl: var(--radius-xl);
567
+
568
+ --font-sans: var(--font-sans);
569
+ --font-mono: var(--font-mono);
570
+ }
package/tokens/base.css CHANGED
@@ -1,23 +1,38 @@
1
1
  /* Minimal element defaults so specimen cards and kits inherit the brand without
2
- a utility framework. Components carry their own styles inline. */
3
- *{box-sizing:border-box;border-color:var(--border)}
4
- html{-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;scroll-behavior:smooth}
5
- /* Each font-family carries the stack as a literal fallback. --font-sans lives in
6
- tokens/fonts.css, which a surface may legitimately import separately; without
7
- the fallback an unresolved var() makes font-family invalid and the UA drops to
8
- its SERIF default the whole console silently rendered in Times. */
9
- body{margin:0;background:var(--background);color:var(--foreground);font-family:var(--font-sans,ui-sans-serif,system-ui,sans-serif);font-size:var(--text-base);line-height:var(--leading-base);font-feature-settings:var(--font-feature-settings)}
10
- h1,h2,h3,h4{margin:0;font-family:var(--font-display,var(--font-sans,ui-sans-serif,system-ui,sans-serif));letter-spacing:var(--tracking-tight);color:var(--text-primary)}
11
- p{margin:0;text-wrap:pretty}
12
- code,pre,kbd{font-family:var(--font-mono,ui-monospace,SFMono-Regular,monospace)}
13
- a{color:var(--text-primary);text-decoration:none;text-underline-offset:4px;transition:color var(--duration-fast) var(--ease-out)}
14
- /* Underline is the accessible affordance for a link in RUNNING TEXT and a visual
15
- bug everywhere else: nav items, cards and anchor-buttons are all <a> too, so a
16
- blanket `a:hover` underlined every one of them on every surface that imports
17
- these tokens. Scope it to the elements that actually carry prose; a link that
18
- is a component states its own hover. The old rule also re-set `color` to the
19
- value `a` already has a no-op that only served to outrank a component's own
20
- hover colour. */
21
- :is(p,li,blockquote,dd,dt,td,th,figcaption) a:hover{text-decoration:underline}
22
- :focus-visible{outline:2px solid var(--ring);outline-offset:2px}
23
- ::selection{background:var(--white-20);color:#fff}
2
+ a utility framework. Components carry their own styles inline.
3
+
4
+ EVERYTHING HERE IS LAYERED, and that is the most important line in the file.
5
+ A rule outside a cascade layer beats a rule inside one no matter how specific
6
+ the loser is so an unlayered `a{color:...}` here outranked EVERY Tailwind
7
+ text utility on every anchor, on every surface that imports these tokens. A
8
+ white-on-white primary button is what that looks like: `text-neutral-950` on
9
+ an anchor, silently overridden to --text-primary, invisible on its own white
10
+ fill, and reading as a rendering glitch rather than a cascade bug.
11
+
12
+ The same trap already bit `text-decoration` here once (see the a:hover note
13
+ below). It was fixed by deleting that one declaration, which left the identical
14
+ defect in `color` untouched. Layering fixes the whole class: these are
15
+ DEFAULTS, and a default must lose to anything an app states deliberately. */
16
+ @layer base {
17
+ *{box-sizing:border-box;border-color:var(--border)}
18
+ html{-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;scroll-behavior:smooth}
19
+ /* Each font-family carries the stack as a literal fallback. --font-sans lives in
20
+ tokens/fonts.css, which a surface may legitimately import separately; without
21
+ the fallback an unresolved var() makes font-family invalid and the UA drops to
22
+ its SERIF default — the whole console silently rendered in Times. */
23
+ body{margin:0;background:var(--background);color:var(--foreground);font-family:var(--font-sans,ui-sans-serif,system-ui,sans-serif);font-size:var(--text-base);line-height:var(--leading-base);font-feature-settings:var(--font-feature-settings)}
24
+ h1,h2,h3,h4{margin:0;font-family:var(--font-display,var(--font-sans,ui-sans-serif,system-ui,sans-serif));letter-spacing:var(--tracking-tight);color:var(--text-primary)}
25
+ p{margin:0;text-wrap:pretty}
26
+ code,pre,kbd{font-family:var(--font-mono,ui-monospace,SFMono-Regular,monospace)}
27
+ a{color:var(--text-primary);text-decoration:none;text-underline-offset:4px;transition:color var(--duration-fast) var(--ease-out)}
28
+ /* Underline is the accessible affordance for a link in RUNNING TEXT and a visual
29
+ bug everywhere else: nav items, cards and anchor-buttons are all <a> too, so a
30
+ blanket `a:hover` underlined every one of them on every surface that imports
31
+ these tokens. Scope it to the elements that actually carry prose; a link that
32
+ is a component states its own hover. The old rule also re-set `color` to the
33
+ value `a` already has — a no-op that only served to outrank a component's own
34
+ hover colour. */
35
+ :is(p,li,blockquote,dd,dt,td,th,figcaption) a:hover{text-decoration:underline}
36
+ :focus-visible{outline:2px solid var(--ring);outline-offset:2px}
37
+ ::selection{background:var(--white-20);color:#fff}
38
+ }