@hanzo/design 0.2.0 → 0.3.1
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/LICENSE +23 -16
- package/README.md +54 -5
- package/assets/fonts/Geist-Variable.woff2 +0 -0
- package/assets/fonts/GeistMono-Variable.woff2 +0 -0
- package/assets/fonts/LICENSE-Geist.txt +92 -0
- package/components/core/HanzoLogo.jsx +1 -1
- package/components/overlays/Dialog.jsx +1 -1
- package/components/overlays/DropdownMenu.jsx +1 -1
- package/components/overlays/Sheet.jsx +1 -1
- package/components/overlays/Toaster.jsx +1 -1
- package/dist/index.d.ts +22 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -8
- package/dist/tokens.gen.d.ts +64 -4
- package/dist/tokens.gen.d.ts.map +1 -1
- package/dist/tokens.gen.js +64 -4
- package/docs/README.md +1 -1
- package/docs/integrate.md +4 -4
- package/guidelines/DESIGN.md +106 -0
- package/package.json +15 -7
- package/prompts/README.md +13 -1
- package/prompts/studio-designer.md +41 -0
- package/prompts/system.md +1 -1
- package/scripts/check-tokens.mjs +93 -0
- package/scripts/gen-tokens.mjs +78 -0
- package/scripts/lint.mjs +179 -0
- package/skills/design-system/SKILL.md +103 -0
- package/src/index.ts +26 -9
- package/src/tokens.gen.ts +64 -4
- package/styles.css +4 -1
- package/tokens/base.css +7 -3
- package/tokens/colors.css +32 -4
- package/tokens/elevation.css +12 -0
- package/tokens/fonts.css +30 -3
- package/tokens/typography.css +19 -0
- package/tokens/z.css +18 -9
- package/ui_kits/.gitkeep +0 -0
package/scripts/lint.mjs
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// lint.mjs — the gate on CONSUMER code. `check-tokens.mjs` proves the token
|
|
3
|
+
// layer is sound; this proves the code that USES it actually reaches it.
|
|
4
|
+
//
|
|
5
|
+
// Every rule here is a defect that shipped, silently, to a live Hanzo surface:
|
|
6
|
+
// a var() nothing declares paints nothing, a raw hex ignores the theme, a bare
|
|
7
|
+
// z-index wins a fight it should have lost, an ALL-CAPS label is a different
|
|
8
|
+
// brand. None of them throw. So they fail here.
|
|
9
|
+
//
|
|
10
|
+
// npx hanzo-design-lint <path...> # default: cwd
|
|
11
|
+
//
|
|
12
|
+
// Exit 1 on any violation. That is the point.
|
|
13
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
14
|
+
import { fileURLToPath } from 'node:url'
|
|
15
|
+
import { dirname, join, relative, extname, sep } from 'node:path'
|
|
16
|
+
|
|
17
|
+
const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
18
|
+
|
|
19
|
+
// ── the vocabulary: every token the design system actually declares ──────
|
|
20
|
+
const TOKENS = new Set()
|
|
21
|
+
{
|
|
22
|
+
const dir = join(pkgRoot, 'tokens')
|
|
23
|
+
for (const f of readdirSync(dir).filter((f) => f.endsWith('.css')))
|
|
24
|
+
for (const [, n] of readFileSync(join(dir, f), 'utf8').matchAll(/--([A-Za-z0-9-]+)\s*:/g))
|
|
25
|
+
TOKENS.add(n)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── what we read ─────────────────────────────────────────────────────────
|
|
29
|
+
const EXT = new Set(['.css', '.scss', '.jsx', '.tsx', '.js', '.ts', '.vue', '.svelte', '.html'])
|
|
30
|
+
const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'out', 'coverage', 'vendor', '__pycache__'])
|
|
31
|
+
// Third-party marks keep their own hex by design (DESIGN.md §2.4); so do the
|
|
32
|
+
// token files themselves, which are where raw values are SUPPOSED to live.
|
|
33
|
+
const EXEMPT = /(^|\/)(tokens|assets|logos|providers|ui_kits|guidelines)(\/|$)|\.card\.html$/
|
|
34
|
+
|
|
35
|
+
const walk = (p, out = []) => {
|
|
36
|
+
const st = statSync(p)
|
|
37
|
+
if (st.isFile()) { if (EXT.has(extname(p))) out.push(p); return out }
|
|
38
|
+
for (const e of readdirSync(p)) if (!SKIP.has(e) && !e.startsWith('.')) walk(join(p, e), out)
|
|
39
|
+
return out
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const strip = (s) => s.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
|
|
43
|
+
const lineOf = (src, i) => src.slice(0, i).split('\n').length
|
|
44
|
+
|
|
45
|
+
// Read one property's value out of a JSX style object, respecting quotes and
|
|
46
|
+
// nested parens — `linear-gradient(a, var(--x))` is ONE value, not two.
|
|
47
|
+
function styleValue(body, key) {
|
|
48
|
+
const at = body.search(new RegExp(`\\b${key}\\s*:`))
|
|
49
|
+
if (at < 0) return null
|
|
50
|
+
let i = body.indexOf(':', at) + 1, depth = 0, quote = null, out = ''
|
|
51
|
+
for (; i < body.length; i++) {
|
|
52
|
+
const c = body[i]
|
|
53
|
+
if (quote) { out += c; if (c === quote && body[i - 1] !== '\\') quote = null; continue }
|
|
54
|
+
if (c === '"' || c === "'" || c === '`') { quote = c; out += c; continue }
|
|
55
|
+
if (c === '(' || c === '[') depth++
|
|
56
|
+
if (c === ')' || c === ']') depth--
|
|
57
|
+
if (c === ',' && depth === 0) break
|
|
58
|
+
out += c
|
|
59
|
+
}
|
|
60
|
+
return out.trim()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const findings = []
|
|
64
|
+
const flag = (file, line, rule, detail, fix) =>
|
|
65
|
+
findings.push({ file, line, rule, detail, fix })
|
|
66
|
+
|
|
67
|
+
// ── the rules ────────────────────────────────────────────────────────────
|
|
68
|
+
function lintFile(abs, root) {
|
|
69
|
+
const rel = relative(root, abs).split(sep).join('/')
|
|
70
|
+
if (EXEMPT.test('/' + rel)) return
|
|
71
|
+
const raw = readFileSync(abs, 'utf8')
|
|
72
|
+
const src = strip(raw)
|
|
73
|
+
const isStyle = /\.(css|scss)$/.test(rel)
|
|
74
|
+
|
|
75
|
+
// 1. var() must resolve — against the design tokens or a local declaration.
|
|
76
|
+
// This is the menu that painted with undefined tokens.
|
|
77
|
+
const local = new Set([...src.matchAll(/--([A-Za-z0-9-]+)\s*:/g)].map((m) => m[1]))
|
|
78
|
+
for (const m of src.matchAll(/var\(\s*--([A-Za-z0-9-]+)\s*(\)|,)/g)) {
|
|
79
|
+
const [, name, close] = m
|
|
80
|
+
if (TOKENS.has(name) || local.has(name)) continue
|
|
81
|
+
if (close === ',') continue // an explicit fallback is a deliberate choice
|
|
82
|
+
flag(rel, lineOf(src, m.index), 'unresolved-token',
|
|
83
|
+
`var(--${name}) — nothing declares it`,
|
|
84
|
+
'use a token from @hanzo/design or declare it locally')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 2. no raw colour in a surface. The theme cannot reach a literal.
|
|
88
|
+
for (const m of src.matchAll(/#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b|\b(?:rgba?|hsla?)\(/g)) {
|
|
89
|
+
const line = lineOf(src, m.index)
|
|
90
|
+
const text = src.split('\n')[line - 1] ?? ''
|
|
91
|
+
if (/currentColor|transparent|url\(|\.svg|<svg|xmlns|viewBox|stopColor|fill=/.test(text)) continue
|
|
92
|
+
flag(rel, line, 'raw-color', m[0].startsWith('#') ? m[0] : m[0] + '…)',
|
|
93
|
+
'use --foreground / --surface-card / the --white-* ladder')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 3. no bare z-index. The ladder is --z-base … --z-notification.
|
|
97
|
+
for (const m of src.matchAll(/z-?[Ii]ndex\s*[:=]\s*['"{ ]*(-?\d+)/g))
|
|
98
|
+
flag(rel, lineOf(src, m.index), 'raw-z-index', `z-index: ${m[1]}`,
|
|
99
|
+
'use --z-dropdown / --z-modal / --z-toast …')
|
|
100
|
+
for (const m of src.matchAll(/\bz-\[(-?\d+)\]/g))
|
|
101
|
+
flag(rel, lineOf(src, m.index), 'raw-z-index', `z-[${m[1]}]`,
|
|
102
|
+
'use var(--z-…) via an arbitrary property, not a magic number')
|
|
103
|
+
|
|
104
|
+
// 4. no raw font-size. The scale is --type-* / --text-*.
|
|
105
|
+
for (const m of src.matchAll(/font-?[Ss]ize\s*[:=]\s*['"{ ]*(\d+(?:\.\d+)?)(px|pt)/g))
|
|
106
|
+
flag(rel, lineOf(src, m.index), 'raw-font-size', `${m[1]}${m[2]}`,
|
|
107
|
+
'use --type-body / --type-h2 / the --text-* scale')
|
|
108
|
+
|
|
109
|
+
// 5. sentence case. ALL CAPS is a different brand — eyebrows excepted, and
|
|
110
|
+
// an eyebrow says so by using --type-eyebrow / the eyebrow class.
|
|
111
|
+
if (isStyle) {
|
|
112
|
+
for (const m of src.matchAll(/text-transform\s*:\s*uppercase/g)) {
|
|
113
|
+
const around = src.slice(Math.max(0, m.index - 400), m.index)
|
|
114
|
+
if (/eyebrow|--type-eyebrow|tracking-widest/i.test(around)) continue
|
|
115
|
+
flag(rel, lineOf(src, m.index), 'all-caps', 'text-transform: uppercase',
|
|
116
|
+
'sentence case; use --type-eyebrow if it is genuinely an eyebrow')
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
for (const m of src.matchAll(/>\s*([A-Z][A-Z0-9 &/'-]{5,40})\s*</g)) {
|
|
120
|
+
const t = m[1].trim()
|
|
121
|
+
if (!/[A-Z]{2}/.test(t) || /^[A-Z0-9 &/'-]+$/.test(t) === false) continue
|
|
122
|
+
if (t.split(/\s+/).every((w) => w.length <= 3)) continue // AI, API, GPU…
|
|
123
|
+
const line = lineOf(src, m.index)
|
|
124
|
+
const around = src.split('\n').slice(Math.max(0, line - 4), line).join(' ')
|
|
125
|
+
if (/eyebrow|uppercase|tracking-widest/i.test(around)) continue
|
|
126
|
+
flag(rel, line, 'all-caps', `"${t}"`, 'sentence case for labels and headings')
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 6. no inline style carrying design decisions. It outranks every token and
|
|
131
|
+
// every theme, which is exactly why it keeps being reached for.
|
|
132
|
+
// Only a LITERAL is a violation: `color:'#fff'`, `fontSize:13`. An
|
|
133
|
+
// identifier (`background:FILL[variant]`) is indirection we cannot read,
|
|
134
|
+
// and guessing there would train people to ignore the linter.
|
|
135
|
+
for (const m of src.matchAll(/style=\{\{([^}]*)\}\}/g)) {
|
|
136
|
+
const body = m[1]
|
|
137
|
+
const bad = ['color', 'background', 'backgroundColor', 'fontSize', 'boxShadow', 'borderColor']
|
|
138
|
+
.filter((k) => {
|
|
139
|
+
const val = styleValue(body, k)
|
|
140
|
+
if (val === null) return false
|
|
141
|
+
if (/var\(/.test(val)) return false
|
|
142
|
+
if (/^['"`]?(transparent|none|inherit|currentColor|unset|initial|auto)['"`]?$/i.test(val)) return false
|
|
143
|
+
return /^['"`]/.test(val) || /^-?\d/.test(val)
|
|
144
|
+
})
|
|
145
|
+
if (bad.length)
|
|
146
|
+
flag(rel, lineOf(src, m.index), 'inline-style', `style={{ ${bad.join(', ')} }}`,
|
|
147
|
+
'move to a class; if it must be inline, the value must be var(--token)')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 7. one icon set.
|
|
151
|
+
for (const m of src.matchAll(/from\s+['"](@?[\w./-]*(?:react-icons|heroicons|font-awesome|@mui\/icons|feather-icons|phosphor)[\w./-]*)['"]/g))
|
|
152
|
+
flag(rel, lineOf(src, m.index), 'icon-set', m[1], 'lucide-react, one set, no other')
|
|
153
|
+
|
|
154
|
+
// 8. the import that makes all of the above resolvable.
|
|
155
|
+
for (const m of src.matchAll(/['"]@hanzoai\/design/g))
|
|
156
|
+
flag(rel, lineOf(src, m.index), 'wrong-package', '@hanzoai/design',
|
|
157
|
+
'the package is @hanzo/design — @hanzoai/design is a 404 and resolves nothing')
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── run ──────────────────────────────────────────────────────────────────
|
|
161
|
+
const targets = process.argv.slice(2).filter((a) => !a.startsWith('-'))
|
|
162
|
+
const roots = targets.length ? targets : [process.cwd()]
|
|
163
|
+
let scanned = 0
|
|
164
|
+
for (const r of roots) for (const f of walk(r)) { scanned++; lintFile(f, r === f ? dirname(r) : r) }
|
|
165
|
+
|
|
166
|
+
const RULES = ['unresolved-token', 'raw-color', 'raw-z-index', 'raw-font-size', 'all-caps', 'inline-style', 'icon-set', 'wrong-package']
|
|
167
|
+
if (!findings.length) {
|
|
168
|
+
console.log(`hanzo-design-lint: ${scanned} files, clean`)
|
|
169
|
+
process.exit(0)
|
|
170
|
+
}
|
|
171
|
+
for (const rule of RULES) {
|
|
172
|
+
const hits = findings.filter((f) => f.rule === rule)
|
|
173
|
+
if (!hits.length) continue
|
|
174
|
+
console.log(`\n${rule} (${hits.length}) — ${hits[0].fix}`)
|
|
175
|
+
for (const h of hits.slice(0, 20)) console.log(` ${h.file}:${h.line} ${h.detail}`)
|
|
176
|
+
if (hits.length > 20) console.log(` … ${hits.length - 20} more`)
|
|
177
|
+
}
|
|
178
|
+
console.log(`\n${findings.length} violation(s) across ${scanned} files`)
|
|
179
|
+
process.exit(1)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: design-system
|
|
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
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# The Hanzo design system
|
|
8
|
+
|
|
9
|
+
One atom, and everything else follows from it:
|
|
10
|
+
|
|
11
|
+
**Monochrome. True black. White type. Colour only as state, never as decoration.**
|
|
12
|
+
|
|
13
|
+
## The derivation chain — never reach past it
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
@hanzo/brand (brand.json — the identity: marks, palette source, motion intent)
|
|
17
|
+
@hanzo/logo (the mark itself, as SVG + React)
|
|
18
|
+
│ derive
|
|
19
|
+
▼
|
|
20
|
+
@hanzo/design (tokens/*.css — 221 CSS custom properties, the ONLY vocabulary)
|
|
21
|
+
│ consume
|
|
22
|
+
▼
|
|
23
|
+
your component
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
A component reads `@hanzo/design`. It does **not** reach past it to `@hanzo/brand`,
|
|
27
|
+
to `@hanzo/logo`'s raw SVG, or to a literal value. If a component needs something
|
|
28
|
+
the token layer does not express, the fix is a **new token**, not a local
|
|
29
|
+
constant — otherwise the next surface invents a second one and the two drift.
|
|
30
|
+
(That is how one product ended up with 25 different z-index values.)
|
|
31
|
+
|
|
32
|
+
The package is **`@hanzo/design`**. `@hanzoai/design` does not exist on npm; an
|
|
33
|
+
import of it resolves to nothing, and an unresolved token layer paints nothing.
|
|
34
|
+
|
|
35
|
+
## Start here
|
|
36
|
+
|
|
37
|
+
```css
|
|
38
|
+
@import "@hanzo/design/styles.css"; /* every token, as a CSS custom property */
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Then reach for a **name**, never a value:
|
|
42
|
+
|
|
43
|
+
| You want | Use | Never |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| a page / text | `--background`, `--foreground`, `--text-secondary` | `#000`, `#fff`, `rgb(…)` |
|
|
46
|
+
| a card, a panel | `--surface-card`, `--border-hairline` | a hand-mixed grey |
|
|
47
|
+
| rank / emphasis | the ladder `--white-05 … --white-80` | an off-ladder 12% or 37% |
|
|
48
|
+
| a size | `--type-body`, `--type-h2`, `--text-sm` | `font-size: 13px` |
|
|
49
|
+
| space | `--space-*`, `--gutter*`, `--container-max` | `padding: 13px` |
|
|
50
|
+
| a layer | `--z-dropdown`, `--z-modal`, `--z-toast` | `z-index: 9999` |
|
|
51
|
+
| a scrim | `--surface-scrim` | `rgba(0,0,0,.8)` |
|
|
52
|
+
| an icon | `lucide-react` | react-icons, heroicons, MUI |
|
|
53
|
+
|
|
54
|
+
The **only** coloured pixels permitted: `--state-error`, `--state-online`,
|
|
55
|
+
`--state-success`, the macOS chrome dot trio, and third-party brand logos. A blue
|
|
56
|
+
button or a purple gradient is not Hanzo — make it white on black.
|
|
57
|
+
|
|
58
|
+
Sentence case for headings and buttons. ALL CAPS is only an eyebrow, and an
|
|
59
|
+
eyebrow says so with `--type-eyebrow`.
|
|
60
|
+
|
|
61
|
+
## Read before you write
|
|
62
|
+
|
|
63
|
+
Load these from the installed package (`node_modules/@hanzo/design/`) or the repo:
|
|
64
|
+
|
|
65
|
+
| File | When |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `prompts/system.md` | Always. The whole language, one screen. Paste as system prompt for a sub-agent. |
|
|
68
|
+
| `prompts/rules.md` | The do/don't table. Walk it before you return code. |
|
|
69
|
+
| `components/**/*.prompt.md` | Per component, when you place one. |
|
|
70
|
+
| `guidelines/DESIGN.md` | The reasoning, when a rule seems to be in your way. |
|
|
71
|
+
| `tokens/*.css` | The authoritative list. Grep it rather than guessing a name. |
|
|
72
|
+
|
|
73
|
+
## Finish by proving it — this step is not optional
|
|
74
|
+
|
|
75
|
+
Generated code that violates the token layer must be **caught, not shipped**.
|
|
76
|
+
Nothing here fails loudly on its own: an undefined `var()` paints nothing, a raw
|
|
77
|
+
hex silently ignores the theme, a class with no rule paints nothing. So run the
|
|
78
|
+
gate on every file you touched:
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
npx hanzo-design-lint <paths…> # exit 0 = clean, exit 1 = violations
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
It checks eight things, each of which has shipped to production at least once:
|
|
85
|
+
|
|
86
|
+
1. `unresolved-token` — a `var(--x)` nothing declares
|
|
87
|
+
2. `raw-color` — a hex / `rgb()` / `hsl()` literal in a surface
|
|
88
|
+
3. `raw-z-index` — a magic number instead of the ladder
|
|
89
|
+
4. `raw-font-size` — `px`/`pt` instead of the scale
|
|
90
|
+
5. `all-caps` — an uppercase label that is not an eyebrow
|
|
91
|
+
6. `inline-style` — a literal colour/size in `style={{…}}` (a `var()` is fine)
|
|
92
|
+
7. `icon-set` — an icon library that is not lucide
|
|
93
|
+
8. `wrong-package` — `@hanzoai/design`, which resolves to nothing
|
|
94
|
+
|
|
95
|
+
Fix every finding, then re-run until clean. **Do not** silence a rule, and do not
|
|
96
|
+
report the work as done on a red gate. If a rule is genuinely wrong for a case,
|
|
97
|
+
say so explicitly in your summary rather than working around it.
|
|
98
|
+
|
|
99
|
+
## The one test
|
|
100
|
+
|
|
101
|
+
Monochrome, true black, white type, colour only as state. If the screenshot would
|
|
102
|
+
look at home on hanzo.ai, it passes. If it looks like a generic SaaS template, it
|
|
103
|
+
fails — and the fix is your work, never the atom.
|
package/src/index.ts
CHANGED
|
@@ -12,17 +12,30 @@
|
|
|
12
12
|
export * from './tokens.gen.js'
|
|
13
13
|
import { cssVars, type CssVarName } from './tokens.gen.js'
|
|
14
14
|
|
|
15
|
+
/** A token name with the leading `--` omitted: `'background'` for `'--background'`. */
|
|
16
|
+
export type TokenName = CssVarName extends `--${infer N}` ? N : never
|
|
17
|
+
|
|
15
18
|
/**
|
|
16
|
-
* A `var(--name
|
|
17
|
-
* reach a token so it resolves through the live CSS cascade (honoring
|
|
18
|
-
* viewer's light/dark theme) rather than baking a
|
|
19
|
+
* A `var(--name, <authored literal>)` reference to a token — the ONE way code
|
|
20
|
+
* should reach a token, so it resolves through the live CSS cascade (honoring
|
|
21
|
+
* the viewer's light/dark theme and any brand fork) rather than baking a value.
|
|
22
|
+
*
|
|
23
|
+
* background: cssVar('--background') // → "var(--background, #000000)"
|
|
24
|
+
* color: cssVar('foreground', '#fff')// → "var(--foreground, #fff)"
|
|
19
25
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
26
|
+
* The name is checked AGAINST THE STYLESHEET at compile time. That check used to
|
|
27
|
+
* be opted out of with `| (string & {})`, which is how `cssVar('surface-1')`
|
|
28
|
+
* shipped: the token did not exist, `var(--surface-1)` resolved to nothing, and
|
|
29
|
+
* a menu painted transparent with no error anywhere. An undefined custom
|
|
30
|
+
* property fails SILENTLY, so the type is the only place it can be caught.
|
|
31
|
+
*
|
|
32
|
+
* When no explicit fallback is given the token's own authored literal is used,
|
|
33
|
+
* so the reference still paints on a host that has not loaded the CSS layer.
|
|
22
34
|
*/
|
|
23
|
-
export function cssVar(name: CssVarName |
|
|
24
|
-
const n = name.startsWith('--') ? name : `--${name}`
|
|
25
|
-
|
|
35
|
+
export function cssVar(name: CssVarName | TokenName, fallback?: string): string {
|
|
36
|
+
const n = (name.startsWith('--') ? name : `--${name}`) as CssVarName
|
|
37
|
+
const lit = fallback ?? (cssVars as Record<string, string>)[n]
|
|
38
|
+
return lit ? `var(${n}, ${lit})` : `var(${n})`
|
|
26
39
|
}
|
|
27
40
|
|
|
28
41
|
/** The raw authored value of a token (the literal from the CSS), or `undefined`. */
|
|
@@ -35,8 +48,12 @@ export function tokenValue(name: CssVarName): string | undefined {
|
|
|
35
48
|
* cannot use a bundler CSS import (e.g. a runtime-mounted island). Prefer the
|
|
36
49
|
* static `import '@hanzo/design/styles.css'` where a bundler is available.
|
|
37
50
|
* No-op outside the browser.
|
|
51
|
+
*
|
|
52
|
+
* `href` is REQUIRED: this used to default to esm.sh, which silently made a
|
|
53
|
+
* third-party CDN the origin of the entire token layer for anyone who called it
|
|
54
|
+
* bare. Pass a URL you serve.
|
|
38
55
|
*/
|
|
39
|
-
export function injectDesignCss(href
|
|
56
|
+
export function injectDesignCss(href: string): void {
|
|
40
57
|
if (typeof document === 'undefined') return
|
|
41
58
|
if (document.querySelector('link[data-hanzo-design]')) return
|
|
42
59
|
const l = document.createElement('link')
|
package/src/tokens.gen.ts
CHANGED
|
@@ -45,7 +45,7 @@ export const colors = {
|
|
|
45
45
|
'destructive-foreground': '#f5f5f5',
|
|
46
46
|
'border': '#1f1f1f',
|
|
47
47
|
'input': '#1f1f1f',
|
|
48
|
-
'ring': '
|
|
48
|
+
'ring': 'var(--neutral-500)',
|
|
49
49
|
'brand': '#e4e4e7',
|
|
50
50
|
'brand-foreground': '#09090b',
|
|
51
51
|
'brand-muted': '#a3a3a3',
|
|
@@ -57,9 +57,14 @@ export const colors = {
|
|
|
57
57
|
'surface-card-quiet': 'rgb(23 23 23 / .4)',
|
|
58
58
|
'surface-overlay': 'rgb(10 10 10 / .95)',
|
|
59
59
|
'surface-header': 'rgb(0 0 0 / .7)',
|
|
60
|
+
'surface-scrim': 'rgb(0 0 0 / .8)',
|
|
60
61
|
'border-hairline': 'var(--neutral-800)',
|
|
61
62
|
'border-card': 'var(--white-10)',
|
|
62
|
-
'border-strong': 'var(--neutral-
|
|
63
|
+
'border-strong': 'var(--neutral-500)',
|
|
64
|
+
'surface-0': 'var(--background)',
|
|
65
|
+
'surface-1': 'var(--card)',
|
|
66
|
+
'surface-2': 'var(--muted)',
|
|
67
|
+
'surface-3': 'var(--secondary)',
|
|
63
68
|
'text-primary': 'var(--pure-white)',
|
|
64
69
|
'text-secondary': 'var(--white-80)',
|
|
65
70
|
'text-tertiary': 'var(--white-60)',
|
|
@@ -99,6 +104,23 @@ export const typography = {
|
|
|
99
104
|
'leading-6xl': '1',
|
|
100
105
|
'text-7xl': '4rem',
|
|
101
106
|
'leading-7xl': '1',
|
|
107
|
+
'text-8xl': '5.25rem',
|
|
108
|
+
'leading-8xl': '1',
|
|
109
|
+
'text-9xl': '7rem',
|
|
110
|
+
'leading-9xl': '1',
|
|
111
|
+
'font-size-xs': 'var(--text-xs)',
|
|
112
|
+
'font-size-sm': 'var(--text-sm)',
|
|
113
|
+
'font-size-base': 'var(--text-base)',
|
|
114
|
+
'font-size-lg': 'var(--text-lg)',
|
|
115
|
+
'font-size-xl': 'var(--text-xl)',
|
|
116
|
+
'font-size-2xl': 'var(--text-2xl)',
|
|
117
|
+
'font-size-3xl': 'var(--text-3xl)',
|
|
118
|
+
'font-size-4xl': 'var(--text-4xl)',
|
|
119
|
+
'font-size-5xl': 'var(--text-5xl)',
|
|
120
|
+
'font-size-6xl': 'var(--text-6xl)',
|
|
121
|
+
'font-size-7xl': 'var(--text-7xl)',
|
|
122
|
+
'font-size-8xl': 'var(--text-8xl)',
|
|
123
|
+
'font-size-9xl': 'var(--text-9xl)',
|
|
102
124
|
'weight-normal': '400',
|
|
103
125
|
'weight-medium': '500',
|
|
104
126
|
'weight-semibold': '600',
|
|
@@ -181,6 +203,12 @@ export const elevation = {
|
|
|
181
203
|
'shadow-floating': '0 25px 50px -12px rgb(0 0 0 / .25)',
|
|
182
204
|
'shadow-inset-hairline': 'inset 0 0 0 1px var(--white-10)',
|
|
183
205
|
'ring-focus': '0 0 0 2px var(--ring)',
|
|
206
|
+
'shadow-sm': '0 1px 2px 0 rgb(0 0 0 / .40)',
|
|
207
|
+
'shadow': '0 1px 3px 0 rgb(0 0 0 / .45), 0 1px 2px -1px rgb(0 0 0 / .45)',
|
|
208
|
+
'shadow-md': '0 4px 6px -1px rgb(0 0 0 / .50), 0 2px 4px -2px rgb(0 0 0 / .50)',
|
|
209
|
+
'shadow-lg': '0 10px 15px -3px rgb(0 0 0 / .55), 0 4px 6px -4px rgb(0 0 0 / .55)',
|
|
210
|
+
'shadow-xl': '0 20px 25px -5px rgb(0 0 0 / .60), 0 8px 10px -6px rgb(0 0 0 / .60)',
|
|
211
|
+
'shadow-2xl': 'var(--shadow-floating)',
|
|
184
212
|
'glow-hero': 'radial-gradient(circle,rgb(255 255 255 / .12) 0%,transparent 68%)',
|
|
185
213
|
'glow-hero-blur': '120px',
|
|
186
214
|
'sheen-card': 'radial-gradient(120% 120% at 80% 0%,rgb(255 255 255 / .08) 0%,transparent 55%)',
|
|
@@ -214,6 +242,8 @@ export const zIndex = {
|
|
|
214
242
|
'z-modal': '600',
|
|
215
243
|
'z-popover': '700',
|
|
216
244
|
'z-toast': '800',
|
|
245
|
+
'z-tooltip': 'var(--z-popover)',
|
|
246
|
+
'z-notification': 'var(--z-toast)',
|
|
217
247
|
} as const
|
|
218
248
|
|
|
219
249
|
/** fonts tokens (from tokens/fonts.css). Values are raw CSS. */
|
|
@@ -273,7 +303,7 @@ export const cssVars = {
|
|
|
273
303
|
'--destructive-foreground': '#f5f5f5',
|
|
274
304
|
'--border': '#1f1f1f',
|
|
275
305
|
'--input': '#1f1f1f',
|
|
276
|
-
'--ring': '
|
|
306
|
+
'--ring': 'var(--neutral-500)',
|
|
277
307
|
'--brand': '#e4e4e7',
|
|
278
308
|
'--brand-foreground': '#09090b',
|
|
279
309
|
'--brand-muted': '#a3a3a3',
|
|
@@ -285,9 +315,14 @@ export const cssVars = {
|
|
|
285
315
|
'--surface-card-quiet': 'rgb(23 23 23 / .4)',
|
|
286
316
|
'--surface-overlay': 'rgb(10 10 10 / .95)',
|
|
287
317
|
'--surface-header': 'rgb(0 0 0 / .7)',
|
|
318
|
+
'--surface-scrim': 'rgb(0 0 0 / .8)',
|
|
288
319
|
'--border-hairline': 'var(--neutral-800)',
|
|
289
320
|
'--border-card': 'var(--white-10)',
|
|
290
|
-
'--border-strong': 'var(--neutral-
|
|
321
|
+
'--border-strong': 'var(--neutral-500)',
|
|
322
|
+
'--surface-0': 'var(--background)',
|
|
323
|
+
'--surface-1': 'var(--card)',
|
|
324
|
+
'--surface-2': 'var(--muted)',
|
|
325
|
+
'--surface-3': 'var(--secondary)',
|
|
291
326
|
'--text-primary': 'var(--pure-white)',
|
|
292
327
|
'--text-secondary': 'var(--white-80)',
|
|
293
328
|
'--text-tertiary': 'var(--white-60)',
|
|
@@ -323,6 +358,23 @@ export const cssVars = {
|
|
|
323
358
|
'--leading-6xl': '1',
|
|
324
359
|
'--text-7xl': '4rem',
|
|
325
360
|
'--leading-7xl': '1',
|
|
361
|
+
'--text-8xl': '5.25rem',
|
|
362
|
+
'--leading-8xl': '1',
|
|
363
|
+
'--text-9xl': '7rem',
|
|
364
|
+
'--leading-9xl': '1',
|
|
365
|
+
'--font-size-xs': 'var(--text-xs)',
|
|
366
|
+
'--font-size-sm': 'var(--text-sm)',
|
|
367
|
+
'--font-size-base': 'var(--text-base)',
|
|
368
|
+
'--font-size-lg': 'var(--text-lg)',
|
|
369
|
+
'--font-size-xl': 'var(--text-xl)',
|
|
370
|
+
'--font-size-2xl': 'var(--text-2xl)',
|
|
371
|
+
'--font-size-3xl': 'var(--text-3xl)',
|
|
372
|
+
'--font-size-4xl': 'var(--text-4xl)',
|
|
373
|
+
'--font-size-5xl': 'var(--text-5xl)',
|
|
374
|
+
'--font-size-6xl': 'var(--text-6xl)',
|
|
375
|
+
'--font-size-7xl': 'var(--text-7xl)',
|
|
376
|
+
'--font-size-8xl': 'var(--text-8xl)',
|
|
377
|
+
'--font-size-9xl': 'var(--text-9xl)',
|
|
326
378
|
'--weight-normal': '400',
|
|
327
379
|
'--weight-medium': '500',
|
|
328
380
|
'--weight-semibold': '600',
|
|
@@ -393,6 +445,12 @@ export const cssVars = {
|
|
|
393
445
|
'--shadow-floating': '0 25px 50px -12px rgb(0 0 0 / .25)',
|
|
394
446
|
'--shadow-inset-hairline': 'inset 0 0 0 1px var(--white-10)',
|
|
395
447
|
'--ring-focus': '0 0 0 2px var(--ring)',
|
|
448
|
+
'--shadow-sm': '0 1px 2px 0 rgb(0 0 0 / .40)',
|
|
449
|
+
'--shadow': '0 1px 3px 0 rgb(0 0 0 / .45), 0 1px 2px -1px rgb(0 0 0 / .45)',
|
|
450
|
+
'--shadow-md': '0 4px 6px -1px rgb(0 0 0 / .50), 0 2px 4px -2px rgb(0 0 0 / .50)',
|
|
451
|
+
'--shadow-lg': '0 10px 15px -3px rgb(0 0 0 / .55), 0 4px 6px -4px rgb(0 0 0 / .55)',
|
|
452
|
+
'--shadow-xl': '0 20px 25px -5px rgb(0 0 0 / .60), 0 8px 10px -6px rgb(0 0 0 / .60)',
|
|
453
|
+
'--shadow-2xl': 'var(--shadow-floating)',
|
|
396
454
|
'--glow-hero': 'radial-gradient(circle,rgb(255 255 255 / .12) 0%,transparent 68%)',
|
|
397
455
|
'--glow-hero-blur': '120px',
|
|
398
456
|
'--sheen-card': 'radial-gradient(120% 120% at 80% 0%,rgb(255 255 255 / .08) 0%,transparent 55%)',
|
|
@@ -418,6 +476,8 @@ export const cssVars = {
|
|
|
418
476
|
'--z-modal': '600',
|
|
419
477
|
'--z-popover': '700',
|
|
420
478
|
'--z-toast': '800',
|
|
479
|
+
'--z-tooltip': 'var(--z-popover)',
|
|
480
|
+
'--z-notification': 'var(--z-toast)',
|
|
421
481
|
'--font-sans': '"Geist","Geist Sans",ui-sans-serif,system-ui,sans-serif',
|
|
422
482
|
'--font-display': 'var(--font-sans)',
|
|
423
483
|
'--font-mono': '"Geist Mono",ui-monospace,SFMono-Regular,monospace',
|
package/styles.css
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
/* Hanzo Design System — global entry point. Import THIS one file.
|
|
2
|
-
|
|
2
|
+
Every token group is served here; nothing is authored but left unimported.
|
|
3
|
+
(`tokens/z.css` went missing from this list once and the whole ladder stopped
|
|
4
|
+
resolving while still type-checking — see scripts/check-tokens.mjs, which now
|
|
5
|
+
fails the build if any tokens/*.css is not imported below.) */
|
|
3
6
|
@import url("tokens/fonts.css");
|
|
4
7
|
@import url("tokens/colors.css");
|
|
5
8
|
@import url("tokens/typography.css");
|
package/tokens/base.css
CHANGED
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
a utility framework. Components carry their own styles inline. */
|
|
3
3
|
*{box-sizing:border-box;border-color:var(--border)}
|
|
4
4
|
html{-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;scroll-behavior:smooth}
|
|
5
|
-
|
|
6
|
-
|
|
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)}
|
|
7
11
|
p{margin:0;text-wrap:pretty}
|
|
8
|
-
code,pre,kbd{font-family:var(--font-mono)}
|
|
12
|
+
code,pre,kbd{font-family:var(--font-mono,ui-monospace,SFMono-Regular,monospace)}
|
|
9
13
|
a{color:var(--text-primary);text-decoration:none;text-underline-offset:4px;transition:color var(--duration-fast) var(--ease-out)}
|
|
10
14
|
a:hover{color:var(--text-primary);text-decoration:underline}
|
|
11
15
|
:focus-visible{outline:2px solid var(--ring);outline-offset:2px}
|
package/tokens/colors.css
CHANGED
|
@@ -51,7 +51,12 @@
|
|
|
51
51
|
--destructive-foreground:#f5f5f5;
|
|
52
52
|
--border:#1f1f1f;
|
|
53
53
|
--input:#1f1f1f;
|
|
54
|
-
|
|
54
|
+
/* A focus indicator is a NON-TEXT CONTRAST target: WCAG 2.4.11/1.4.11 require
|
|
55
|
+
3:1 against every surface it can land on. --neutral-500 is the only rung on
|
|
56
|
+
this ladder that clears 3:1 on all of them — #000000, #0a0a0a, #101010,
|
|
57
|
+
#1a1a1a, AND the light theme's #ffffff/#f5f5f5 — so one value serves both
|
|
58
|
+
themes. (Was #333333 = 1.66:1 on --background: not a focus indicator.) */
|
|
59
|
+
--ring:var(--neutral-500);
|
|
55
60
|
--brand:#e4e4e7;
|
|
56
61
|
--brand-foreground:#09090b;
|
|
57
62
|
--brand-muted:#a3a3a3;
|
|
@@ -65,9 +70,25 @@
|
|
|
65
70
|
--surface-card-quiet:rgb(23 23 23 / .4); /* bg-neutral-900/40 — story cards */
|
|
66
71
|
--surface-overlay:rgb(10 10 10 / .95); /* dropdown / popover panels */
|
|
67
72
|
--surface-header:rgb(0 0 0 / .7); /* fixed nav, with backdrop blur */
|
|
73
|
+
--surface-scrim:rgb(0 0 0 / .8); /* the dialog / sheet backdrop */
|
|
74
|
+
/* Boundaries come in two kinds and they are NOT interchangeable.
|
|
75
|
+
DECORATIVE (--border, --border-hairline, --border-card): separates content;
|
|
76
|
+
WCAG imposes no ratio. Keep them quiet.
|
|
77
|
+
PERCEIVABLE (--border-strong): identifies a CONTROL — an input edge, a
|
|
78
|
+
switch, a checkbox — and must clear 3:1 (WCAG 1.4.11) on every surface.
|
|
79
|
+
Reach for --border-strong whenever the boundary IS the affordance. */
|
|
68
80
|
--border-hairline:var(--neutral-800);
|
|
69
81
|
--border-card:var(--white-10);
|
|
70
|
-
--border-strong:var(--neutral-
|
|
82
|
+
--border-strong:var(--neutral-500); /* 3.59:1 worst case — see --ring */
|
|
83
|
+
|
|
84
|
+
/* ——— the numeric surface ladder ——— */
|
|
85
|
+
/* Aliases onto the semantic canvases above, so a brand fork that retunes
|
|
86
|
+
--card/--muted/--secondary retunes the ladder with it and the light theme
|
|
87
|
+
inverts for free. Ascending lift: 0 is the page, 3 is a hovered control. */
|
|
88
|
+
--surface-0:var(--background);
|
|
89
|
+
--surface-1:var(--card);
|
|
90
|
+
--surface-2:var(--muted);
|
|
91
|
+
--surface-3:var(--secondary);
|
|
71
92
|
|
|
72
93
|
/* ——— text ranks ——— */
|
|
73
94
|
--text-primary:var(--pure-white);
|
|
@@ -107,7 +128,10 @@
|
|
|
107
128
|
--destructive-foreground:#ffffff;
|
|
108
129
|
--border:#e5e5e5;
|
|
109
130
|
--input:#e5e5e5;
|
|
110
|
-
|
|
131
|
+
/* Same rung as dark: #d4d4d4 measured 1.48:1 on white and could not carry a
|
|
132
|
+
focus indicator either. --neutral-500 is 4.74:1 on #ffffff / 4.38:1 on
|
|
133
|
+
#f5f5f5, so ONE value is conformant in both themes. */
|
|
134
|
+
--ring:var(--neutral-500);
|
|
111
135
|
--black:#0a0a0a;
|
|
112
136
|
--white:#ffffff;
|
|
113
137
|
--surface-card:#f5f5f5;
|
|
@@ -115,9 +139,13 @@
|
|
|
115
139
|
--surface-card-quiet:#fafafa;
|
|
116
140
|
--surface-overlay:rgb(255 255 255 / .95);
|
|
117
141
|
--surface-header:rgb(255 255 255 / .8);
|
|
142
|
+
--surface-scrim:rgb(0 0 0 / .5);
|
|
118
143
|
--border-hairline:var(--neutral-200);
|
|
119
144
|
--border-card:rgb(0 0 0 / .1);
|
|
120
|
-
--border-strong:var(--neutral-
|
|
145
|
+
--border-strong:var(--neutral-500); /* was --neutral-300 = 1.48:1 on white */
|
|
146
|
+
/* The white-opacity ladder does NOT invert, so --white-40 is white-on-white
|
|
147
|
+
here (1.00:1). Anything that needs a visible edge in BOTH themes must use
|
|
148
|
+
--border-strong, never a --white-* rung. */
|
|
121
149
|
--text-primary:var(--neutral-950);
|
|
122
150
|
--text-secondary:rgb(10 10 10 / .8);
|
|
123
151
|
--text-tertiary:rgb(10 10 10 / .6);
|
package/tokens/elevation.css
CHANGED
|
@@ -6,6 +6,18 @@
|
|
|
6
6
|
--shadow-floating:0 25px 50px -12px rgb(0 0 0 / .25); /* shadow-2xl: composer, dropdowns, mega panel */
|
|
7
7
|
--shadow-inset-hairline:inset 0 0 0 1px var(--white-10);
|
|
8
8
|
--ring-focus:0 0 0 2px var(--ring);
|
|
9
|
+
|
|
10
|
+
/* The t-shirt ramp. Named by size rather than by role, because that is how
|
|
11
|
+
every component library already asks for a shadow (and how @hanzo/brand
|
|
12
|
+
spells it). Alphas are heavier than Tailwind's defaults: on a near-black
|
|
13
|
+
ground a 10% black drop is invisible, so each rung is tuned to read on
|
|
14
|
+
--background. --shadow-2xl and --shadow-floating are the same rung. */
|
|
15
|
+
--shadow-sm:0 1px 2px 0 rgb(0 0 0 / .40);
|
|
16
|
+
--shadow:0 1px 3px 0 rgb(0 0 0 / .45), 0 1px 2px -1px rgb(0 0 0 / .45);
|
|
17
|
+
--shadow-md:0 4px 6px -1px rgb(0 0 0 / .50), 0 2px 4px -2px rgb(0 0 0 / .50);
|
|
18
|
+
--shadow-lg:0 10px 15px -3px rgb(0 0 0 / .55), 0 4px 6px -4px rgb(0 0 0 / .55);
|
|
19
|
+
--shadow-xl:0 20px 25px -5px rgb(0 0 0 / .60), 0 8px 10px -6px rgb(0 0 0 / .60);
|
|
20
|
+
--shadow-2xl:var(--shadow-floating);
|
|
9
21
|
/* Ambient hero glow — a single white radial, blurred 120px, low opacity. */
|
|
10
22
|
--glow-hero:radial-gradient(circle,rgb(255 255 255 / .12) 0%,transparent 68%); /* @kind color */
|
|
11
23
|
--glow-hero-blur:120px;
|
package/tokens/fonts.css
CHANGED
|
@@ -1,7 +1,34 @@
|
|
|
1
1
|
/* Geist Sans + Geist Mono — the only two faces on Hanzo surfaces.
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
SELF-HOSTED. The faces ship inside this package (assets/fonts/*.woff2, two
|
|
3
|
+
variable files, 141 KB total, SIL OFL-1.1 — see assets/fonts/LICENSE-Geist.txt).
|
|
4
|
+
|
|
5
|
+
Why self-hosted rather than @import from fonts.googleapis.com:
|
|
6
|
+
- A sign-in page must not make a third-party request. hanzoai/id refused to
|
|
7
|
+
import this file for exactly that reason, which split the token layer: id
|
|
8
|
+
took the colours and not the typeface. Self-hosting removes the reason, so
|
|
9
|
+
every surface can import styles.css unchanged.
|
|
10
|
+
- The @import was a render-blocking request to a host we do not control, on
|
|
11
|
+
the critical path of every surface, and it broke offline/air-gapped dev.
|
|
12
|
+
- One variable file per family replaces nine static weights, and it is fewer
|
|
13
|
+
bytes than the CSS-then-woff2 round trip Google served.
|
|
14
|
+
|
|
15
|
+
The url()s are relative to THIS file, so they resolve wherever the package is
|
|
16
|
+
mounted — node_modules, a CDN, a copied dist — with no configuration. */
|
|
17
|
+
|
|
18
|
+
@font-face{
|
|
19
|
+
font-family:"Geist";
|
|
20
|
+
src:url("../assets/fonts/Geist-Variable.woff2") format("woff2");
|
|
21
|
+
font-weight:100 900;
|
|
22
|
+
font-style:normal;
|
|
23
|
+
font-display:swap;
|
|
24
|
+
}
|
|
25
|
+
@font-face{
|
|
26
|
+
font-family:"Geist Mono";
|
|
27
|
+
src:url("../assets/fonts/GeistMono-Variable.woff2") format("woff2");
|
|
28
|
+
font-weight:100 900;
|
|
29
|
+
font-style:normal;
|
|
30
|
+
font-display:swap;
|
|
31
|
+
}
|
|
5
32
|
|
|
6
33
|
:root{
|
|
7
34
|
--font-sans:"Geist","Geist Sans",ui-sans-serif,system-ui,sans-serif;
|