@hanzo/design 0.3.5 → 0.4.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/README.md +25 -0
- package/components/core/Avatar.jsx +1 -1
- package/components/core/Badge.jsx +10 -7
- package/components/core/Button.jsx +1 -2
- package/components/core/Card.jsx +1 -1
- package/components/core/Progress.jsx +1 -1
- package/components/core/Table.jsx +2 -2
- package/components/forms/Checkbox.jsx +1 -1
- package/components/forms/Input.jsx +4 -4
- package/components/forms/Select.jsx +3 -3
- package/components/forms/Slider.jsx +1 -1
- package/components/forms/Textarea.jsx +3 -3
- package/components/navigation/Accordion.jsx +2 -2
- package/components/navigation/navigation.card.html +1 -1
- package/components/overlays/Dialog.jsx +1 -1
- package/components/overlays/DropdownMenu.jsx +3 -3
- package/components/overlays/Sheet.jsx +3 -3
- package/components/overlays/Toaster.jsx +1 -1
- package/dist/tokens.gen.d.ts +92 -60
- package/dist/tokens.gen.d.ts.map +1 -1
- package/dist/tokens.gen.js +92 -60
- package/package.json +11 -11
- package/scripts/check-tokens.mjs +64 -1
- package/scripts/gen-tokens.mjs +79 -0
- package/src/tokens.gen.ts +92 -60
- package/styles.css +313 -81
- package/tailwind.css +787 -0
- package/tokens/base.css +125 -22
- package/tokens/colors.css +121 -52
- package/tokens/spacing.css +46 -7
- package/tokens/typography.css +21 -0
package/styles.css
CHANGED
|
@@ -16,9 +16,25 @@
|
|
|
16
16
|
/* Hanzo is monochrome. One hue rendered through an opacity ladder.
|
|
17
17
|
Base ladder = Tailwind neutral (tailwind.config.ts). Semantic names match
|
|
18
18
|
hanzo.ai's CSS variables exactly, so code copies over 1:1.
|
|
19
|
-
DARK IS THE DEFAULT THEME (hanzo.ai mounts ThemeProvider defaultTheme="dark").
|
|
19
|
+
DARK IS THE DEFAULT THEME (hanzo.ai mounts ThemeProvider defaultTheme="dark").
|
|
20
|
+
|
|
21
|
+
The dark palette is derived from the chat surface in hanzoai/extension
|
|
22
|
+
(packages/browser/src/sidebar.css), which is the reference aesthetic: a
|
|
23
|
+
#0a0a0a ground, #fafafa ink, and boundaries drawn as LOW-ALPHA HAIRLINES.
|
|
24
|
+
Two properties of that reference are load-bearing and easy to lose:
|
|
25
|
+
|
|
26
|
+
1. Borders are alpha, not hex. rgb(255 255 255 / .10) composites correctly on
|
|
27
|
+
#0a0a0a AND on #262626; a solid #1f1f1f reads on the page and vanishes the
|
|
28
|
+
moment it lands on a lifted surface — which is why anyone who needed a
|
|
29
|
+
visible edge reached past it for the CONTROL rung and got a wireframe.
|
|
30
|
+
2. Surfaces lift by tiny steps (0a -> 0f -> 17 -> 26), and ink is a graded
|
|
31
|
+
ramp (#fafafa -> .78 -> .55 -> #a3a3a3), not one flat white. Pure #ffffff
|
|
32
|
+
on pure #000000 is 21:1 — the maximum-contrast pair that exists, and it
|
|
33
|
+
halates. #fafafa on #0a0a0a is 18.1:1: still far past AAA, visibly softer. */
|
|
20
34
|
|
|
21
35
|
:root{
|
|
36
|
+
color-scheme:dark;
|
|
37
|
+
|
|
22
38
|
/* ——— base neutral ladder ——— */
|
|
23
39
|
--neutral-50:#FAFAFA;
|
|
24
40
|
--neutral-100:#F5F5F5;
|
|
@@ -38,45 +54,97 @@
|
|
|
38
54
|
--hanzo-white:#FFFFFF;
|
|
39
55
|
|
|
40
56
|
/* ——— the opacity ladder: the real palette ——— */
|
|
57
|
+
/* Every rung below .30 is a HAIRLINE or a LIFT — the two things this system
|
|
58
|
+
draws with. The rungs are the reference's own values, not a rounded ramp. */
|
|
41
59
|
--white-05:rgb(255 255 255 / .05);
|
|
60
|
+
--white-06:rgb(255 255 255 / .06);
|
|
61
|
+
--white-08:rgb(255 255 255 / .08);
|
|
42
62
|
--white-10:rgb(255 255 255 / .10);
|
|
43
63
|
--white-15:rgb(255 255 255 / .15);
|
|
64
|
+
--white-16:rgb(255 255 255 / .16);
|
|
44
65
|
--white-20:rgb(255 255 255 / .20);
|
|
66
|
+
--white-22:rgb(255 255 255 / .22);
|
|
45
67
|
--white-30:rgb(255 255 255 / .30);
|
|
46
68
|
--white-40:rgb(255 255 255 / .40);
|
|
69
|
+
--white-55:rgb(255 255 255 / .55);
|
|
47
70
|
--white-60:rgb(255 255 255 / .60);
|
|
71
|
+
--white-78:rgb(255 255 255 / .78);
|
|
48
72
|
--white-80:rgb(255 255 255 / .80);
|
|
49
73
|
|
|
50
74
|
/* ——— semantic aliases (dark, the default) ——— */
|
|
51
|
-
--background:#
|
|
52
|
-
--foreground:#
|
|
53
|
-
--card:#
|
|
54
|
-
--card-foreground:#
|
|
55
|
-
--popover:#
|
|
56
|
-
--popover-foreground:#
|
|
57
|
-
--primary:#
|
|
58
|
-
--primary-
|
|
59
|
-
--
|
|
60
|
-
--secondary
|
|
61
|
-
--
|
|
62
|
-
--muted
|
|
63
|
-
--
|
|
64
|
-
--accent
|
|
75
|
+
--background:#0a0a0a;
|
|
76
|
+
--foreground:#fafafa;
|
|
77
|
+
--card:#0f0f0f;
|
|
78
|
+
--card-foreground:#fafafa;
|
|
79
|
+
--popover:#0f0f0f;
|
|
80
|
+
--popover-foreground:#fafafa;
|
|
81
|
+
--primary:#fafafa;
|
|
82
|
+
--primary-hover:#d4d4d4;
|
|
83
|
+
--primary-foreground:#0a0a0a;
|
|
84
|
+
--secondary:#262626;
|
|
85
|
+
--secondary-foreground:#fafafa;
|
|
86
|
+
--muted:#171717;
|
|
87
|
+
--muted-foreground:#a3a3a3;
|
|
88
|
+
--accent:#262626;
|
|
89
|
+
--accent-foreground:#fafafa;
|
|
65
90
|
--destructive:#666666;
|
|
66
|
-
--destructive-foreground:#
|
|
67
|
-
|
|
68
|
-
|
|
91
|
+
--destructive-foreground:#fafafa;
|
|
92
|
+
|
|
93
|
+
/* ——— boundaries ———
|
|
94
|
+
THREE tiers, and the split is the whole point. The first two are DECORATIVE:
|
|
95
|
+
they separate content, WCAG imposes no ratio on them, and they must stay
|
|
96
|
+
quiet. The third is PERCEIVABLE: it is the edge that IS the affordance.
|
|
97
|
+
|
|
98
|
+
--border the hairline. Every card, panel, divider, list row.
|
|
99
|
+
--border-strong the SAME hairline, one step up. Hover, active, emphasis.
|
|
100
|
+
--border-control a control's edge — input, select, textarea, checkbox,
|
|
101
|
+
switch. Must clear 3:1 on every surface in both themes
|
|
102
|
+
(WCAG 1.4.11); check-tokens.mjs pins it.
|
|
103
|
+
|
|
104
|
+
Before this split there were only two rungs and they were an octave apart:
|
|
105
|
+
--border at #1f1f1f (1.27:1, invisible on anything lifted) and
|
|
106
|
+
--border-strong at --neutral-500 (4.43:1). Nothing sat between, so every
|
|
107
|
+
surface that wanted a border it could actually SEE reached for the control
|
|
108
|
+
rung — and a 1px mid-grey box around a card on near-black is a wireframe.
|
|
109
|
+
That is the "garish outlines" complaint, and it is fixed by having a middle.
|
|
110
|
+
|
|
111
|
+
A boundary that is WCAG-perceivable on a near-black ground IS a mid-grey;
|
|
112
|
+
that cannot be tuned away. So it is not tuned away — it is NAMED, so it can
|
|
113
|
+
only be spent where it is owed. "strong" is a degree word and will always be
|
|
114
|
+
read as one; "control" is a duty.
|
|
115
|
+
|
|
116
|
+
Which ladder a boundary is cut from follows from that, and is the whole
|
|
117
|
+
rule: DECORATIVE edges come off the ALPHA ladder, because they owe quiet
|
|
118
|
+
and they must composite over whatever surface they land on. CONTROL edges
|
|
119
|
+
come off the NEUTRAL ladder, because they owe a contrast ratio, and a ratio
|
|
120
|
+
you can only meet at a fixed value is not something to express as alpha.
|
|
121
|
+
--border-focus is therefore one NEUTRAL rung above --border-control, not an
|
|
122
|
+
alpha step: a focused field has to read brighter than a resting one, and
|
|
123
|
+
the resting one already sits at the WCAG floor. */
|
|
124
|
+
--border:var(--white-10);
|
|
125
|
+
--border-strong:var(--white-16);
|
|
126
|
+
--border-control:var(--neutral-500); /* 4.43:1 worst case — see --ring */
|
|
127
|
+
--border-focus:var(--neutral-400); /* one rung up: the field is focused */
|
|
128
|
+
--input:var(--border-control); /* shadcn `border-input` — a control */
|
|
69
129
|
/* A focus indicator is a NON-TEXT CONTRAST target: WCAG 2.4.11/1.4.11 require
|
|
70
130
|
3:1 against every surface it can land on. --neutral-500 is the only rung on
|
|
71
|
-
this ladder that clears 3:1 on all of them — #
|
|
72
|
-
#
|
|
131
|
+
this ladder that clears 3:1 on all of them — #0a0a0a, #0f0f0f, #171717,
|
|
132
|
+
#262626, AND the light theme's #ffffff/#fafafa — so one value serves both
|
|
73
133
|
themes. (Was #333333 = 1.66:1 on --background: not a focus indicator.) */
|
|
74
134
|
--ring:var(--neutral-500);
|
|
75
135
|
--brand:#e4e4e7;
|
|
76
136
|
--brand-foreground:#09090b;
|
|
77
137
|
--brand-muted:#a3a3a3;
|
|
78
138
|
--black:#000000;
|
|
79
|
-
--white:#
|
|
139
|
+
--white:#fafafa;
|
|
140
|
+
--selection:var(--white-20);
|
|
141
|
+
|
|
142
|
+
/* ——— glass: the lift ———
|
|
143
|
+
How a surface rises WITHOUT a block fill. An alpha-white wash composites
|
|
144
|
+
over whatever it lands on, so one value works on the page, on a card and
|
|
145
|
+
inside a popover. This is the mechanism behind the reference's depth. */
|
|
146
|
+
--glass:var(--white-05);
|
|
147
|
+
--glass-strong:var(--white-08);
|
|
80
148
|
|
|
81
149
|
/* ——— surface recipes (card fills used across hanzo.ai) ——— */
|
|
82
150
|
--surface-page:var(--background);
|
|
@@ -86,29 +154,22 @@
|
|
|
86
154
|
--surface-overlay:rgb(10 10 10 / .95); /* dropdown / popover panels */
|
|
87
155
|
--surface-header:rgb(0 0 0 / .7); /* fixed nav, with backdrop blur */
|
|
88
156
|
--surface-scrim:rgb(0 0 0 / .8); /* the dialog / sheet backdrop */
|
|
89
|
-
/* Boundaries come in two kinds and they are NOT interchangeable.
|
|
90
|
-
DECORATIVE (--border, --border-hairline, --border-card): separates content;
|
|
91
|
-
WCAG imposes no ratio. Keep them quiet.
|
|
92
|
-
PERCEIVABLE (--border-strong): identifies a CONTROL — an input edge, a
|
|
93
|
-
switch, a checkbox — and must clear 3:1 (WCAG 1.4.11) on every surface.
|
|
94
|
-
Reach for --border-strong whenever the boundary IS the affordance. */
|
|
95
|
-
--border-hairline:var(--neutral-800);
|
|
96
|
-
--border-card:var(--white-10);
|
|
97
|
-
--border-strong:var(--neutral-500); /* 3.59:1 worst case — see --ring */
|
|
98
157
|
|
|
99
158
|
/* ——— the numeric surface ladder ——— */
|
|
100
159
|
/* Aliases onto the semantic canvases above, so a brand fork that retunes
|
|
101
160
|
--card/--muted/--secondary retunes the ladder with it and the light theme
|
|
102
|
-
inverts for free. Ascending lift: 0 is the page, 3 is a hovered control.
|
|
161
|
+
inverts for free. Ascending lift: 0 is the page, 3 is a hovered control.
|
|
162
|
+
Dark resolves to the reference's own ladder — 0a / 0f / 17 / 26. */
|
|
103
163
|
--surface-0:var(--background);
|
|
104
164
|
--surface-1:var(--card);
|
|
105
165
|
--surface-2:var(--muted);
|
|
106
166
|
--surface-3:var(--secondary);
|
|
107
167
|
|
|
108
|
-
/* ——— text ranks ———
|
|
109
|
-
|
|
110
|
-
--text-
|
|
111
|
-
--text-
|
|
168
|
+
/* ——— text ranks ———
|
|
169
|
+
A graded ramp, not one flat white. The steps are the reference's. */
|
|
170
|
+
--text-primary:#fafafa;
|
|
171
|
+
--text-secondary:var(--white-78);
|
|
172
|
+
--text-tertiary:var(--white-55);
|
|
112
173
|
--text-helper:var(--muted-foreground);
|
|
113
174
|
--text-disabled:var(--white-30);
|
|
114
175
|
|
|
@@ -123,47 +184,55 @@
|
|
|
123
184
|
--chrome-dot-green:rgb(34 197 94 / .6);
|
|
124
185
|
}
|
|
125
186
|
|
|
126
|
-
/* Light theme — the same tokens, inverted.
|
|
187
|
+
/* Light theme — the same tokens, inverted.
|
|
188
|
+
The white-opacity ladder does NOT invert: --white-16 is white-on-white here.
|
|
189
|
+
So EVERY token whose dark value is a --white-* rung must be restated below,
|
|
190
|
+
and check-tokens.mjs fails the build if one is missed — that class of bug is
|
|
191
|
+
silent (the border simply stops existing) and has shipped before. */
|
|
127
192
|
.light{
|
|
193
|
+
color-scheme:light;
|
|
128
194
|
--background:#ffffff;
|
|
129
195
|
--foreground:#0a0a0a;
|
|
130
|
-
|
|
196
|
+
/* A ladder, not four names for #f5f5f5. Light lifts by the same tiny steps
|
|
197
|
+
dark does — ~2% per rung — so --surface-0..3 mean something in both themes. */
|
|
198
|
+
--card:#fafafa;
|
|
131
199
|
--card-foreground:#0a0a0a;
|
|
132
200
|
--popover:#ffffff;
|
|
133
201
|
--popover-foreground:#0a0a0a;
|
|
134
202
|
--primary:#0a0a0a;
|
|
135
|
-
--primary-
|
|
136
|
-
--
|
|
203
|
+
--primary-hover:#262626;
|
|
204
|
+
--primary-foreground:#fafafa;
|
|
205
|
+
--secondary:#ededed;
|
|
137
206
|
--secondary-foreground:#0a0a0a;
|
|
138
207
|
--muted:#f5f5f5;
|
|
139
208
|
--muted-foreground:#525252;
|
|
140
|
-
--accent:#
|
|
209
|
+
--accent:#ededed;
|
|
141
210
|
--accent-foreground:#0a0a0a;
|
|
142
211
|
--destructive:#999999;
|
|
143
212
|
--destructive-foreground:#ffffff;
|
|
144
|
-
--border
|
|
145
|
-
--
|
|
213
|
+
--border:rgb(0 0 0 / .10);
|
|
214
|
+
--border-strong:rgb(0 0 0 / .16);
|
|
215
|
+
--border-control:var(--neutral-500); /* 4.74:1 on #ffffff — conformant here too */
|
|
216
|
+
--border-focus:var(--neutral-600); /* one rung DOWN — darker reads as brighter here */
|
|
217
|
+
--input:var(--border-control);
|
|
146
218
|
/* Same rung as dark: #d4d4d4 measured 1.48:1 on white and could not carry a
|
|
147
219
|
focus indicator either. --neutral-500 is 4.74:1 on #ffffff / 4.38:1 on
|
|
148
220
|
#f5f5f5, so ONE value is conformant in both themes. */
|
|
149
221
|
--ring:var(--neutral-500);
|
|
150
222
|
--black:#0a0a0a;
|
|
151
223
|
--white:#ffffff;
|
|
152
|
-
--
|
|
224
|
+
--selection:rgb(0 0 0 / .16);
|
|
225
|
+
--glass:rgb(0 0 0 / .04);
|
|
226
|
+
--glass-strong:rgb(0 0 0 / .07);
|
|
227
|
+
--surface-card:#fafafa;
|
|
153
228
|
--surface-card-emphasis:#ffffff;
|
|
154
|
-
--surface-card-quiet:#
|
|
229
|
+
--surface-card-quiet:#fcfcfc;
|
|
155
230
|
--surface-overlay:rgb(255 255 255 / .95);
|
|
156
231
|
--surface-header:rgb(255 255 255 / .8);
|
|
157
232
|
--surface-scrim:rgb(0 0 0 / .5);
|
|
158
|
-
--border-hairline:var(--neutral-200);
|
|
159
|
-
--border-card:rgb(0 0 0 / .1);
|
|
160
|
-
--border-strong:var(--neutral-500); /* was --neutral-300 = 1.48:1 on white */
|
|
161
|
-
/* The white-opacity ladder does NOT invert, so --white-40 is white-on-white
|
|
162
|
-
here (1.00:1). Anything that needs a visible edge in BOTH themes must use
|
|
163
|
-
--border-strong, never a --white-* rung. */
|
|
164
233
|
--text-primary:var(--neutral-950);
|
|
165
|
-
--text-secondary:rgb(10 10 10 / .
|
|
166
|
-
--text-tertiary:rgb(10 10 10 / .
|
|
234
|
+
--text-secondary:rgb(10 10 10 / .78);
|
|
235
|
+
--text-tertiary:rgb(10 10 10 / .55);
|
|
167
236
|
--text-disabled:rgb(10 10 10 / .3);
|
|
168
237
|
}
|
|
169
238
|
|
|
@@ -206,6 +275,19 @@
|
|
|
206
275
|
--font-size-8xl:var(--text-8xl);
|
|
207
276
|
--font-size-9xl:var(--text-9xl);
|
|
208
277
|
|
|
278
|
+
/* The size a FORM CONTROL renders at — and the ONE step in this scale that is
|
|
279
|
+
not a constant. iOS Safari zooms the viewport whenever a focused input
|
|
280
|
+
computes below 16px, and this scale's base is 14px, so every surface that
|
|
281
|
+
sized a field from --text-sm or --text-base bought the zoom.
|
|
282
|
+
|
|
283
|
+
It has to be a TOKEN rather than a rule because a control's size is almost
|
|
284
|
+
always set inline (every component in components/forms does), and an inline
|
|
285
|
+
style outranks any stylesheet — including a media query. A var() resolves
|
|
286
|
+
per-device inside that inline style, so this is the only construction that
|
|
287
|
+
actually reaches the control. Fields ask for --text-control; nothing else
|
|
288
|
+
should. */
|
|
289
|
+
--text-control:var(--text-sm);
|
|
290
|
+
|
|
209
291
|
--weight-normal:400;
|
|
210
292
|
--weight-medium:500;
|
|
211
293
|
--weight-semibold:600;
|
|
@@ -234,6 +316,14 @@
|
|
|
234
316
|
--type-eyebrow:600 0.625rem/1 var(--font-sans);
|
|
235
317
|
}
|
|
236
318
|
|
|
319
|
+
/* On touch, a control goes to 16px. Below that iOS Safari zooms the viewport on
|
|
320
|
+
focus and never zooms back out, which is the single most common way a mobile
|
|
321
|
+
form feels broken. pointer:coarse is the real signal — a desktop mouse keeps
|
|
322
|
+
the compact 13px field. */
|
|
323
|
+
@media (pointer:coarse){
|
|
324
|
+
:root{--text-control:1rem}
|
|
325
|
+
}
|
|
326
|
+
|
|
237
327
|
/* ── tokens/spacing.css ─────────────────────────────────────── */
|
|
238
328
|
/* Spacing: the 4px Tailwind ramp is what ships. The golden-ratio ramp below is
|
|
239
329
|
declared in hanzo.ai's tailwind.config.ts (legacy v3 config, kept for
|
|
@@ -267,18 +357,57 @@
|
|
|
267
357
|
--golden-9:11.749rem;
|
|
268
358
|
--golden-split:38.2% 61.8%; /* @kind other */
|
|
269
359
|
|
|
270
|
-
/* layout rules (DESIGN.md §1.3)
|
|
360
|
+
/* layout rules (DESIGN.md §1.3) —
|
|
361
|
+
MOBILE-FIRST: the values authored here are the PHONE values, and the
|
|
362
|
+
min-width block at the bottom of this file scales them up. A surface that
|
|
363
|
+
uses the raw token therefore gets a layout that already breathes correctly
|
|
364
|
+
at 390px, with no media query of its own. (This is also the value the
|
|
365
|
+
generator captures for tokens.gen.ts — first occurrence wins, and the
|
|
366
|
+
authored default is the small one.) */
|
|
271
367
|
--container-max:80rem; /* max-w-7xl — grids */
|
|
272
368
|
--container-prose:48rem; /* max-w-3xl — centered text */
|
|
273
369
|
--container-wide:72rem; /* max-w-6xl — landing sections */
|
|
274
|
-
|
|
370
|
+
/* The page gutter also clears a notch. env() is 0px on every device without
|
|
371
|
+
one, so this is exactly `1rem` in the ordinary case and the safe inset when
|
|
372
|
+
there is something to avoid — no per-app work, no landscape clipping.
|
|
373
|
+
Requires `<meta name="viewport" content="…,viewport-fit=cover">` on the
|
|
374
|
+
host page; without it the UA reports 0 and the max() is inert, not wrong. */
|
|
375
|
+
--gutter:max(1rem,var(--safe-left),var(--safe-right));
|
|
275
376
|
--gutter-sm:1.5rem; /* sm:px-6 */
|
|
276
377
|
--gutter-lg:2rem; /* lg:px-8 */
|
|
277
|
-
--section-y:
|
|
278
|
-
--section-y-lg:
|
|
279
|
-
--hero-y:
|
|
280
|
-
--hero-y-lg:
|
|
281
|
-
--header-height:
|
|
378
|
+
--section-y:2.5rem; /* py-10 on a phone → py-16 at md */
|
|
379
|
+
--section-y-lg:3.5rem; /* py-14 on a phone → py-24 at md */
|
|
380
|
+
--hero-y:3rem; /* py-12 on a phone → py-20 at md */
|
|
381
|
+
--hero-y-lg:4.5rem; /* py-18 on a phone → py-32 at md */
|
|
382
|
+
--header-height:3.5rem; /* 56px is the phone bar; 64px from md */
|
|
383
|
+
|
|
384
|
+
/* ——— touch ——— */
|
|
385
|
+
/* 44px is the floor a pointer-coarse target may render at (Apple HIG 44pt /
|
|
386
|
+
WCAG 2.5.5 AAA / 2.5.8 AA's 24px, taken at the higher bar). base.css spends
|
|
387
|
+
it automatically under `@media (pointer:coarse)`, so a button does not have
|
|
388
|
+
to opt in. It was previously a bare `min-height:44px` literal in
|
|
389
|
+
hanzoai/id's stylesheet — one app knowing something the system did not. */
|
|
390
|
+
--tap-target:44px;
|
|
391
|
+
|
|
392
|
+
/* ——— safe areas ——— */
|
|
393
|
+
/* The notch/home-indicator insets, named once. Anything pinned to a viewport
|
|
394
|
+
edge — a fixed header, a bottom bar, a sheet — adds the matching one. */
|
|
395
|
+
--safe-top:env(safe-area-inset-top,0px);
|
|
396
|
+
--safe-right:env(safe-area-inset-right,0px);
|
|
397
|
+
--safe-bottom:env(safe-area-inset-bottom,0px);
|
|
398
|
+
--safe-left:env(safe-area-inset-left,0px);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/* Scale up from the phone. `md` (48rem) is the same breakpoint grid.css
|
|
402
|
+
declares and Tailwind compiles against — one value, not two that agree. */
|
|
403
|
+
@media (min-width:48rem){
|
|
404
|
+
:root{
|
|
405
|
+
--section-y:4rem; /* py-16 — content sections */
|
|
406
|
+
--section-y-lg:6rem; /* py-24 — landing sections */
|
|
407
|
+
--hero-y:5rem; /* py-20 … */
|
|
408
|
+
--hero-y-lg:8rem; /* … lg:py-32 */
|
|
409
|
+
--header-height:4rem;
|
|
410
|
+
}
|
|
282
411
|
}
|
|
283
412
|
|
|
284
413
|
/* ── tokens/grid.css ─────────────────────────────────────── */
|
|
@@ -473,25 +602,128 @@
|
|
|
473
602
|
|
|
474
603
|
/* ── tokens/base.css ─────────────────────────────────────── */
|
|
475
604
|
/* Minimal element defaults so specimen cards and kits inherit the brand without
|
|
476
|
-
a utility framework. Components carry their own styles inline.
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
605
|
+
a utility framework. Components carry their own styles inline.
|
|
606
|
+
|
|
607
|
+
EVERYTHING HERE IS LAYERED, and that is the most important line in the file.
|
|
608
|
+
A rule outside a cascade layer beats a rule inside one no matter how specific
|
|
609
|
+
the loser is — so an unlayered `a{color:...}` here outranked EVERY Tailwind
|
|
610
|
+
text utility on every anchor, on every surface that imports these tokens. A
|
|
611
|
+
white-on-white primary button is what that looks like: `text-neutral-950` on
|
|
612
|
+
an anchor, silently overridden to --text-primary, invisible on its own white
|
|
613
|
+
fill, and reading as a rendering glitch rather than a cascade bug.
|
|
614
|
+
|
|
615
|
+
The same trap already bit `text-decoration` here once (see the a:hover note
|
|
616
|
+
below). It was fixed by deleting that one declaration, which left the identical
|
|
617
|
+
defect in `color` untouched. Layering fixes the whole class: these are
|
|
618
|
+
DEFAULTS, and a default must lose to anything an app states deliberately.
|
|
619
|
+
|
|
620
|
+
The rules added for controls, media and touch go one step further and wrap
|
|
621
|
+
their selectors in :where(), which has ZERO specificity. @layer decides who
|
|
622
|
+
wins BETWEEN layers; :where() decides who wins INSIDE this one. An app that
|
|
623
|
+
ships its own reset — `input { … }`, `img { … }` — very often lands in the
|
|
624
|
+
same base layer, and these defaults have to lose that collision too. */
|
|
625
|
+
@layer base {
|
|
626
|
+
*{box-sizing:border-box;border-color:var(--border)}
|
|
627
|
+
html{
|
|
628
|
+
-webkit-font-smoothing:antialiased;
|
|
629
|
+
text-rendering:optimizeLegibility;
|
|
630
|
+
scroll-behavior:smooth;
|
|
631
|
+
/* iOS inflates text when a phone is rotated to landscape unless this is
|
|
632
|
+
pinned. It is the single most common reason a mobile layout that was
|
|
633
|
+
verified in portrait comes apart on its side. */
|
|
634
|
+
-webkit-text-size-adjust:100%;
|
|
635
|
+
}
|
|
636
|
+
/* Each font-family carries the stack as a literal fallback. --font-sans lives in
|
|
637
|
+
tokens/fonts.css, which a surface may legitimately import separately; without
|
|
638
|
+
the fallback an unresolved var() makes font-family invalid and the UA drops to
|
|
639
|
+
its SERIF default — the whole console silently rendered in Times. */
|
|
640
|
+
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);overflow-wrap:break-word}
|
|
641
|
+
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)}
|
|
642
|
+
p{margin:0;text-wrap:pretty}
|
|
643
|
+
code,pre,kbd{font-family:var(--font-mono,ui-monospace,SFMono-Regular,monospace)}
|
|
644
|
+
/* No `transition` here: the shared interactive rule below states one, and an
|
|
645
|
+
`a` selector (0,0,1) would outrank that :where() (0,0,0) and replace the
|
|
646
|
+
whole shorthand — anchors would transition colour and nothing else. */
|
|
647
|
+
a{color:var(--text-primary);text-decoration:none;text-underline-offset:4px}
|
|
648
|
+
/* Underline is the accessible affordance for a link in RUNNING TEXT and a visual
|
|
649
|
+
bug everywhere else: nav items, cards and anchor-buttons are all <a> too, so a
|
|
650
|
+
blanket `a:hover` underlined every one of them on every surface that imports
|
|
651
|
+
these tokens. Scope it to the elements that actually carry prose; a link that
|
|
652
|
+
is a component states its own hover. The old rule also re-set `color` to the
|
|
653
|
+
value `a` already has — a no-op that only served to outrank a component's own
|
|
654
|
+
hover colour. */
|
|
655
|
+
:is(p,li,blockquote,dd,dt,td,th,figcaption) a:hover{text-decoration:underline}
|
|
656
|
+
|
|
657
|
+
/* ——— the smooth ———
|
|
658
|
+
Every interactive element in the reference transitions its colour, its fill
|
|
659
|
+
and its EDGE. That is most of what separates "classy and smooth" from a
|
|
660
|
+
correct set of colours: a hairline that steps from .10 to .16 instantly
|
|
661
|
+
reads as a flicker, and over 150ms reads as a response. Granted once, here,
|
|
662
|
+
rather than restated on every component. tokens/motion.css already zeroes
|
|
663
|
+
all of it under prefers-reduced-motion. */
|
|
664
|
+
:where(a,button,[role=button],input,select,textarea,summary,[tabindex]){
|
|
665
|
+
transition:color var(--duration-fast) var(--ease-out),
|
|
666
|
+
background-color var(--duration-fast) var(--ease-out),
|
|
667
|
+
border-color var(--duration-fast) var(--ease-out),
|
|
668
|
+
box-shadow var(--duration-fast) var(--ease-out),
|
|
669
|
+
opacity var(--duration-fast) var(--ease-out);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/* ——— controls ———
|
|
673
|
+
A bare <input> renders in the UA's face (Arial) at the UA's size with the
|
|
674
|
+
UA's border, which puts two typefaces and a foreign blue focus ring inside
|
|
675
|
+
Hanzo cards on every surface that has not styled its own fields yet. The
|
|
676
|
+
rules below are what a control looks like here. --border-control, NOT
|
|
677
|
+
--border: the boundary IS the affordance, so it is the rung that owes 3:1. */
|
|
678
|
+
:where(input,select,textarea,button){font:inherit;color:inherit}
|
|
679
|
+
:where(input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=color]),select,textarea){
|
|
680
|
+
background:var(--surface-2);
|
|
681
|
+
border:1px solid var(--border-control);
|
|
682
|
+
border-radius:var(--radius-sm);
|
|
683
|
+
padding:0 var(--space-3);
|
|
684
|
+
}
|
|
685
|
+
:where(textarea){padding:var(--space-2) var(--space-3);resize:vertical}
|
|
686
|
+
:where(input,select,textarea):focus-visible{border-color:var(--border-focus)}
|
|
687
|
+
:where(input,textarea)::placeholder{color:var(--text-disabled)}
|
|
688
|
+
:where(input,select,textarea,button):disabled{opacity:.5;cursor:not-allowed}
|
|
689
|
+
:where(button,[role=button],summary,label,select){cursor:pointer}
|
|
690
|
+
|
|
691
|
+
/* ——— media ———
|
|
692
|
+
An image, a video or an embed at its intrinsic width is the usual cause of a
|
|
693
|
+
390px page that scrolls sideways. */
|
|
694
|
+
:where(img,svg,video,canvas,iframe,picture,object){max-width:100%}
|
|
695
|
+
:where(img,video){height:auto}
|
|
696
|
+
/* A code block scrolls ITSELF rather than widening the document. */
|
|
697
|
+
:where(pre){overflow-x:auto}
|
|
698
|
+
|
|
699
|
+
/* ——— touch ———
|
|
700
|
+
Everything a finger aims at clears 44px, and every field renders at 16px so
|
|
701
|
+
iOS does not zoom the viewport on focus. Both are keyed on pointer:coarse —
|
|
702
|
+
the actual signal — so a desktop mouse still gets compact controls. This is
|
|
703
|
+
the whole of "mobile-first without per-app work": an app that never thought
|
|
704
|
+
about phones still has hittable buttons and a viewport that stays put. */
|
|
705
|
+
@media (pointer:coarse){
|
|
706
|
+
:where(button,[role=button],a[role=button],summary,select,textarea,
|
|
707
|
+
input:not([type=checkbox]):not([type=radio]):not([type=hidden])){
|
|
708
|
+
min-height:var(--tap-target);
|
|
709
|
+
}
|
|
710
|
+
:where(input,select,textarea){font-size:var(--text-control)}
|
|
711
|
+
/* A checkbox, radio or switch must NOT grow to 44px — the box is 16px
|
|
712
|
+
because that is what a checkbox looks like. What has to reach 44px is the
|
|
713
|
+
area a finger may land in, so the target is expanded with a centred
|
|
714
|
+
pseudo-element that changes nothing about layout or paint. WCAG 2.5.8 puts
|
|
715
|
+
the floor at 24px; a 16px box misses it, and every one of these ships at
|
|
716
|
+
16px. */
|
|
717
|
+
:where([role=checkbox],[role=radio],[role=switch],input[type=checkbox],input[type=radio]){position:relative}
|
|
718
|
+
:where([role=checkbox],[role=radio],[role=switch],input[type=checkbox],input[type=radio])::after{
|
|
719
|
+
content:'';position:absolute;top:50%;left:50%;
|
|
720
|
+
width:var(--tap-target);height:var(--tap-target);
|
|
721
|
+
transform:translate(-50%,-50%);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
:focus-visible{outline:2px solid var(--ring);outline-offset:2px}
|
|
726
|
+
/* --white-20 is white-on-white in the light theme, so selection reads through
|
|
727
|
+
--selection, which BOTH themes define. */
|
|
728
|
+
::selection{background:var(--selection);color:var(--text-primary)}
|
|
729
|
+
}
|