mirror of
https://github.com/hanzoai/design.git
synced 2026-10-30 14:58:37 +00:00
Zen draws no Arabic. tokens/fonts.css now declares Noto Kufi Arabic (the default) and Noto Sans Arabic, each under its own family with an Arabic unicode-range, and names --font-arabic first in the sans, mono and serif stacks. Latin passes the companion over without a download, so an English page fetches what it did before, and the first available font a line box is measured from is still Zen. The preference gains an arabic axis (kufi | sans), set by reference to --font-arabic-kufi and --font-arabic-sans. It is its own axis because a face and an Arabic companion are two questions: serif with Kufi is two names, not a pairing enum. check-preference holds the axis and that every stack names the slot first.
1266 lines
71 KiB
CSS
1266 lines
71 KiB
CSS
/* Hanzo Design System — the entry point. Import THIS one file.
|
|
*
|
|
* AUTO-GENERATED by scripts/gen-tokens.mjs from tokens/*.css — DO NOT EDIT.
|
|
* Edit the token in tokens/<group>.css and re-run "npm run gen".
|
|
*
|
|
* Flattened deliberately: a consumer's bundler resolves a nested url() against
|
|
* ITS OWN directory, not ours, so an @import list here is a build error in
|
|
* every app that follows the instruction above. Nested imports must also
|
|
* precede all other rules, which the spec invalidates the moment the consumer
|
|
* imports anything first — browsers then drop them silently and every token
|
|
* resolves to nothing. The content is inlined in the order the groups were
|
|
* always imported in.
|
|
*/
|
|
|
|
/* ── tokens/colors.css ─────────────────────────────────────── */
|
|
/* AUTO-GENERATED by @hanzo/tokens scripts/gen-css.mjs from src/theme.ts — DO NOT EDIT.
|
|
* Edit the token in @hanzo/tokens src/theme.ts and re-run `pnpm --filter @hanzo/tokens gen-css`. */
|
|
/* Hanzo colour tokens — monochrome, dark-default. One hue, an opacity ladder. */
|
|
|
|
:root{
|
|
color-scheme:dark;
|
|
|
|
/* ——— base neutral ladder ——— */
|
|
--neutral-50:#FAFAFA;
|
|
--neutral-100:#F5F5F5;
|
|
--neutral-200:#E5E5E5;
|
|
--neutral-300:#D4D4D4;
|
|
--neutral-400:#A3A3A3;
|
|
--neutral-500:#737373;
|
|
--neutral-600:#525252;
|
|
--neutral-700:#404040;
|
|
--neutral-800:#262626;
|
|
--neutral-900:#171717;
|
|
--neutral-950:#0A0A0A;
|
|
--pure-black:#000000;
|
|
--pure-white:#FFFFFF;
|
|
--hanzo-black:#0A0A0B;
|
|
--hanzo-white:#FFFFFF;
|
|
|
|
/* ——— art: four hues, for artwork only ———
|
|
Light, never paint. They colour hero art and the few soft glows that give a
|
|
section form; chrome, type and every control stay on the monochrome ladder
|
|
below. The same in both themes, because art is not themed. */
|
|
--art-tide:#2F6BFF;
|
|
--art-iris:#8B5CF6;
|
|
--art-ember:#FF7A3D;
|
|
--art-glow:#F4F1FF;
|
|
|
|
/* ——— the opacity ladder: the real palette ——— */
|
|
--white-03:rgb(255 255 255 / .03); /* the raised fill */
|
|
--white-05:rgb(255 255 255 / .05);
|
|
--white-06:rgb(255 255 255 / .06);
|
|
--white-08:rgb(255 255 255 / .08);
|
|
--white-10:rgb(255 255 255 / .10);
|
|
--white-14:rgb(255 255 255 / .14); /* selection wash */
|
|
--white-15:rgb(255 255 255 / .15);
|
|
--white-16:rgb(255 255 255 / .16);
|
|
--white-18:rgb(255 255 255 / .18); /* selection tint */
|
|
--white-20:rgb(255 255 255 / .20);
|
|
--white-22:rgb(255 255 255 / .22);
|
|
--white-30:rgb(255 255 255 / .30);
|
|
--white-40:rgb(255 255 255 / .40);
|
|
--white-45:rgb(255 255 255 / .45); /* the dimmest ink */
|
|
--white-55:rgb(255 255 255 / .55);
|
|
--white-60:rgb(255 255 255 / .60);
|
|
--white-78:rgb(255 255 255 / .78);
|
|
--white-80:rgb(255 255 255 / .80);
|
|
--white-92:rgb(255 255 255 / .92); /* body ink */
|
|
|
|
/* ——— semantic aliases (dark, the default) ——— */
|
|
--background:#0a0a0a;
|
|
--foreground:#e5e5e5; /* the graded ink, one rung short of #fafafa */
|
|
--card:#0f0f0f;
|
|
--card-foreground:#e5e5e5;
|
|
--popover:#0f0f0f;
|
|
--popover-foreground:#e5e5e5;
|
|
--primary:#fafafa;
|
|
--primary-hover:#e5e5e5;
|
|
--primary-foreground:#0a0a0a;
|
|
--secondary:#262626;
|
|
--secondary-hover:#333333;
|
|
--secondary-foreground:#fafafa;
|
|
--muted:#171717;
|
|
--muted-foreground:#a3a3a3;
|
|
--accent:#262626;
|
|
--accent-hover:var(--secondary-hover); /* the accent under the cursor; a chosen accent restates it */
|
|
--accent-foreground:#fafafa;
|
|
--destructive:#dc2626; /* red-600 — a FILL under its foreground, 4.6:1; the error hue is --state-error */
|
|
--destructive-hover:#b91c1c; /* red-700 — deepens under the cursor, 6.2:1 */
|
|
--destructive-foreground:#fafafa;
|
|
|
|
/* ——— boundaries — ONE ladder, cut from alpha, graded by duty ——— */
|
|
--border:var(--white-10); /* the hairline */
|
|
--border-strong:var(--white-16); /* hover, emphasis */
|
|
--border-control:var(--white-15); /* a control's resting edge */
|
|
--border-focus:var(--white-22); /* that control, focused */
|
|
--border-selected:var(--white-30); /* the thing currently CHOSEN */
|
|
--edge:var(--white-06); /* fainter than the hairline: an edge between chrome and page */
|
|
--input:var(--border-control); /* shadcn `border-input` — a control */
|
|
--ring:var(--white-40); /* the focus indicator — 3:1 non-text contrast */
|
|
--ring-halo:var(--white-10); /* the soft halo outside the ring */
|
|
--brand:#e4e4e7;
|
|
--brand-foreground:#09090b;
|
|
--brand-muted:#a3a3a3;
|
|
--black:#000000;
|
|
--white:#fafafa;
|
|
--selection:var(--white-20);
|
|
|
|
/* ——— glass: the lift ——— */
|
|
--glass:var(--white-05);
|
|
--glass-strong:var(--white-08);
|
|
|
|
/* ——— surface recipes ——— */
|
|
--surface-page:var(--background);
|
|
--surface-card:rgb(38 38 38 / .5); /* -> #181818 on the page */
|
|
--surface-card-emphasis:rgb(38 38 38 / .75); /* -> #1f1f1f — featured */
|
|
--surface-card-quiet:rgb(38 38 38 / .35); /* -> #141414 — story cards */
|
|
--surface-overlay:rgb(23 23 23 / .92); /* dropdown / popover panels */
|
|
--surface-header:rgb(0 0 0 / .7); /* fixed nav, with backdrop blur */
|
|
--surface-scrim:rgb(0 0 0 / .8); /* the dialog / sheet backdrop */
|
|
|
|
/* ——— the numeric surface ladder ——— */
|
|
--surface-0:var(--background);
|
|
--surface-1:var(--card);
|
|
--surface-2:var(--muted);
|
|
--surface-3:var(--secondary);
|
|
|
|
/* ——— text ranks — a graded ramp, not one flat white ——— */
|
|
--text-primary:#fafafa;
|
|
--text-secondary:var(--white-78);
|
|
--text-tertiary:var(--white-55);
|
|
--text-helper:var(--muted-foreground);
|
|
--text-disabled:var(--white-30);
|
|
|
|
/* ——— the ONLY permitted hues (DESIGN.md §2.4) ——— */
|
|
--state-error:#ef4444; /* red-500 — destructive / blocking error */
|
|
--state-error-text:#fca5a5; /* red-300 */
|
|
--state-error-bg:rgb(239 68 68 / .1);
|
|
--state-online:#4ade80; /* green-400 — live status dot */
|
|
--state-success:#22c55e; /* green-500 — "Free" / "Save N%" callouts */
|
|
--chrome-dot-red:rgb(239 68 68 / .6);
|
|
--chrome-dot-yellow:rgb(234 179 8 / .6);
|
|
--chrome-dot-green:rgb(34 197 94 / .6);
|
|
}
|
|
|
|
.light{
|
|
color-scheme:light;
|
|
|
|
/* ——— semantic aliases (light) ——— */
|
|
--background:#f7f7f7;
|
|
--foreground:#0a0a0a;
|
|
--card:#f2f2f2;
|
|
--card-foreground:#0a0a0a;
|
|
--popover:#fbfbfb;
|
|
--popover-foreground:#0a0a0a;
|
|
--primary:#0a0a0a;
|
|
--primary-hover:#262626;
|
|
--primary-foreground:#fafafa;
|
|
--secondary:#e4e4e4;
|
|
--secondary-hover:#e0e0e0;
|
|
--secondary-foreground:#0a0a0a;
|
|
--muted:#ededed;
|
|
--muted-foreground:#525252;
|
|
--accent:#e4e4e4;
|
|
--accent-hover:var(--secondary-hover);
|
|
--accent-foreground:#0a0a0a;
|
|
--destructive:#dc2626;
|
|
--destructive-hover:#b91c1c;
|
|
--destructive-foreground:#ffffff;
|
|
|
|
/* ——— boundaries — restated as black-alpha ——— */
|
|
--border:rgb(0 0 0 / .10);
|
|
--border-strong:rgb(0 0 0 / .16);
|
|
--border-control:rgb(0 0 0 / .15);
|
|
--border-focus:rgb(0 0 0 / .32); /* pushed past .22: black-on-white reads fainter */
|
|
--border-selected:rgb(0 0 0 / .42);
|
|
--edge:rgb(0 0 0 / .06);
|
|
--input:var(--border-control);
|
|
--ring:rgb(0 0 0 / .5); /* 3.98:1 on #ffffff, 3.67:1 on #f5f5f5 */
|
|
--ring-halo:rgb(0 0 0 / .07);
|
|
--black:#0a0a0a;
|
|
--white:#ffffff;
|
|
--selection:rgb(0 0 0 / .16);
|
|
|
|
/* ——— glass + surfaces (light) ——— */
|
|
--glass:rgb(0 0 0 / .04);
|
|
--glass-strong:rgb(0 0 0 / .07);
|
|
--surface-card:#f2f2f2;
|
|
--surface-card-emphasis:#fdfdfd;
|
|
--surface-card-quiet:#f5f5f5;
|
|
--surface-overlay:rgb(255 255 255 / .95);
|
|
--surface-header:rgb(255 255 255 / .8);
|
|
--surface-scrim:rgb(0 0 0 / .5);
|
|
|
|
/* ——— text ranks (light) ——— */
|
|
--text-primary:var(--neutral-950);
|
|
--text-secondary:rgb(10 10 10 / .78);
|
|
--text-tertiary:rgb(10 10 10 / .55);
|
|
--text-disabled:rgb(10 10 10 / .3);
|
|
}
|
|
|
|
/* ── tokens/typography.css ─────────────────────────────────────── */
|
|
/* TIGHT app-first type scale — the compact developer-app register (linear.app /
|
|
vercel.com / the Codex desktop look), the Hanzo default across chat / app /
|
|
desktop. Base is 14px, nav 13px, labels 12px; display sizes tightened. Kept in
|
|
lockstep with @hanzo/brand (styles/variables.css --font-size-* + typography.ts)
|
|
— the two are the SAME scale, mirrored. A surface/tenant overrides any --text-*
|
|
on :root to retune density on demand. */
|
|
:root{
|
|
/* The type knob. 1 is the published scale; every rung below multiplies by
|
|
it, so a surface or a person retunes the WHOLE ramp by setting one
|
|
number and no rung can drift out of relation with the others.
|
|
@hanzo/design's `vars({type})` emits exactly this name. */
|
|
--type-scale:1;
|
|
|
|
/* The CONTRAST knob — how far apart the rungs sit, where --type-scale is how
|
|
big they all are. Two different questions, and only the first one was
|
|
askable: a surface could take the whole ramp up 15% but could not flatten a
|
|
dense table's steps or open up a marketing page's, so both settled for the
|
|
app register's tight default.
|
|
|
|
It is authored as a TRANSFORM and not as a generated ramp, which is the
|
|
whole point. `base + (authored - base) * ratio` keeps each rung's authored
|
|
value as the INPUT, so ratio 1 reduces to exactly the published number by
|
|
algebra rather than by care — the deltas below are just `authored - 0.875`.
|
|
The alternative, deriving each rung as base * ratio^n, would restate the
|
|
ramp in a second place, and this scale is not geometric anyway: the app
|
|
register (12/13/14/15/17) is near-linear on purpose and a ratio applied to
|
|
it would erase the 1px steps the compact register is FOR. That drift is not
|
|
hypothetical here — see src/preference.ts, where a recomputed copy of this
|
|
ramp shipped `lg: 16px` against this file's 15px.
|
|
|
|
A rung is one place, so a rung added later needs a delta and nothing else. */
|
|
--type-ratio:1;
|
|
|
|
/* EVERY rung is bounded at BOTH ends, by one rule, because the knobs COMPOUND
|
|
and they compound in both directions. Clamping each knob's range separately
|
|
cannot fix either end — 0.85 x 0.9 is inside both bounds and lands at 8.5px
|
|
— so the RESULT is what carries the bound:
|
|
|
|
--text-floor 12px below this a label is not small, it is unreadable
|
|
--text-ceiling 30vw above this a heading is not type, it is a texture
|
|
|
|
The ceiling is in the VIEWPORT's units because that is what "too big" means:
|
|
measured, the tallest rung (--text-9xl, 112px) is 28.7vw on a 390px phone
|
|
and 7.8vw at 1440, so 30vw binds no published value at any real screen and
|
|
catches only a scale that has run away. Both bounds are stated once here and
|
|
`vars()` emits the same clamp for a modular scale, so a regenerated rung and
|
|
an authored one are bounded identically — the rule does not depend on which
|
|
produced the value. */
|
|
--text-floor:0.75rem;
|
|
--text-ceiling:30vw;
|
|
--text-xs:clamp(var(--text-floor), calc((0.875rem - 0.125rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-xs:calc((1.35rem - 0.35rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 12px — eyebrows / section labels; the smallest type anywhere */
|
|
--text-sm:clamp(var(--text-floor), calc((0.875rem - 0.0625rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-sm:calc((1.35rem - 0.2rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 13px — nav labels, dense body */
|
|
--text-base:clamp(var(--text-floor), calc(0.875rem * var(--type-scale, 1)), var(--text-ceiling)); --leading-base:calc(1.35rem * var(--type-scale, 1)); /* 14px — the ANCHOR: the rung the ratio pivots on, so it carries no ratio term */
|
|
--text-lg:clamp(var(--text-floor), calc((0.875rem + 0.0625rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-lg:calc((1.35rem + 0.05rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 15px */
|
|
--text-xl:clamp(var(--text-floor), calc((0.875rem + 0.1875rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-xl:calc((1.35rem + 0.2rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 17px */
|
|
--text-2xl:clamp(var(--text-floor), calc((0.875rem + 0.4375rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-2xl:calc((1.35rem + 0.35rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 21px */
|
|
--text-3xl:clamp(var(--text-floor), calc((0.875rem + 0.75rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-3xl:calc((1.35rem + 0.6rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 26px */
|
|
--text-4xl:clamp(var(--text-floor), calc((0.875rem + 1.125rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-4xl:calc((1.35rem + 0.9rem * var(--type-ratio, 1)) * var(--type-scale, 1)); /* 32px */
|
|
--text-5xl:clamp(var(--text-floor), calc((0.875rem + 1.625rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-5xl:1.05; /* 40px — display leading is unitless, so it tracks its own size */
|
|
--text-6xl:clamp(var(--text-floor), calc((0.875rem + 2.375rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-6xl:1; /* 52px */
|
|
--text-7xl:clamp(var(--text-floor), calc((0.875rem + 3.125rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-7xl:1; /* 64px */
|
|
--text-8xl:clamp(var(--text-floor), calc((0.875rem + 4.375rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-8xl:1; /* 84px */
|
|
--text-9xl:clamp(var(--text-floor), calc((0.875rem + 6.125rem * var(--type-ratio, 1)) * var(--type-scale, 1)), var(--text-ceiling)); --leading-9xl:1; /* 112px */
|
|
|
|
/* The SAME scale under @hanzo/brand's spelling. @hanzo/gui's shell theme and
|
|
Hanzo Studio address the ramp as --font-size-*; both names are one value, so
|
|
a component written against either resolves here. --text-* is canonical. */
|
|
--font-size-xs:var(--text-xs);
|
|
--font-size-sm:var(--text-sm);
|
|
--font-size-base:var(--text-base);
|
|
--font-size-lg:var(--text-lg);
|
|
--font-size-xl:var(--text-xl);
|
|
--font-size-2xl:var(--text-2xl);
|
|
--font-size-3xl:var(--text-3xl);
|
|
--font-size-4xl:var(--text-4xl);
|
|
--font-size-5xl:var(--text-5xl);
|
|
--font-size-6xl:var(--text-6xl);
|
|
--font-size-7xl:var(--text-7xl);
|
|
--font-size-8xl:var(--text-8xl);
|
|
--font-size-9xl:var(--text-9xl);
|
|
|
|
/* The size a FORM CONTROL renders at — and the ONE step in this scale that is
|
|
not a constant. iOS Safari zooms the viewport whenever a focused input
|
|
computes below 16px, and this scale's base is 14px, so every surface that
|
|
sized a field from --text-sm or --text-base bought the zoom.
|
|
|
|
It has to be a TOKEN rather than a rule because a control's size is almost
|
|
always set inline (every component in components/forms does), and an inline
|
|
style outranks any stylesheet — including a media query. A var() resolves
|
|
per-device inside that inline style, so this is the only construction that
|
|
actually reaches the control. Fields ask for --text-control; nothing else
|
|
should. */
|
|
--text-control:var(--text-sm);
|
|
|
|
--weight-normal:400;
|
|
--weight-medium:500;
|
|
--weight-semibold:600;
|
|
--weight-bold:700;
|
|
|
|
--tracking-tight:-0.025em;
|
|
--tracking-normal:0em;
|
|
--tracking-wide:0.025em;
|
|
--tracking-widest:0.1em; /* eyebrows / uppercase category labels */
|
|
|
|
--leading-none:1;
|
|
--leading-tight:1.25;
|
|
--leading-snug:1.375;
|
|
--leading-normal:1.5;
|
|
--leading-relaxed:1.625;
|
|
--leading-golden:1.618;
|
|
|
|
/* named roles */
|
|
--type-hero:600 var(--text-5xl)/1.05 var(--font-display);
|
|
--type-h2:700 var(--text-4xl)/var(--leading-4xl) var(--font-display);
|
|
--type-h3:600 var(--text-xl)/var(--leading-xl) var(--font-display);
|
|
--type-lead:400 var(--text-lg)/var(--leading-relaxed) var(--font-sans);
|
|
--type-body:400 var(--text-sm)/var(--leading-sm) var(--font-sans);
|
|
--type-caption:400 var(--text-xs)/var(--leading-xs) var(--font-sans);
|
|
--type-code:400 var(--text-sm)/var(--leading-relaxed) var(--font-mono);
|
|
--type-eyebrow:600 0.75rem/1 var(--font-sans);
|
|
}
|
|
|
|
/* On touch, a control goes to 16px. Below that iOS Safari zooms the viewport on
|
|
focus and never zooms back out, which is the single most common way a mobile
|
|
form feels broken. pointer:coarse is the real signal — a desktop mouse keeps
|
|
the compact 13px field. */
|
|
@media (pointer:coarse){
|
|
:root{--text-control:calc(1rem * var(--type-scale, 1))}
|
|
}
|
|
|
|
/* ── tokens/spacing.css ─────────────────────────────────────── */
|
|
/* Spacing: the 4px Tailwind ramp is what ships. The golden-ratio ramp below is
|
|
declared in hanzo.ai's tailwind.config.ts (legacy v3 config, kept for
|
|
reference) — use it for editorial layouts, not for component padding. */
|
|
:root{
|
|
/* The density knob. 1 is the published spacing; every --space-* rung below
|
|
multiplies by it, and --grid-gap-* reference those rungs rather than
|
|
restating them, so gaps, padding and section rhythm move together.
|
|
@hanzo/design's `vars({density})` emits exactly this name. */
|
|
--density:1;
|
|
|
|
--space-0:0;
|
|
--space-1:calc(0.25rem * var(--density, 1));
|
|
--space-2:calc(0.5rem * var(--density, 1));
|
|
--space-3:calc(0.75rem * var(--density, 1));
|
|
--space-4:calc(1rem * var(--density, 1));
|
|
--space-5:calc(1.25rem * var(--density, 1));
|
|
--space-6:calc(1.5rem * var(--density, 1));
|
|
--space-8:calc(2rem * var(--density, 1));
|
|
--space-10:calc(2.5rem * var(--density, 1));
|
|
--space-12:calc(3rem * var(--density, 1));
|
|
--space-14:calc(3.5rem * var(--density, 1));
|
|
--space-16:calc(4rem * var(--density, 1));
|
|
--space-20:calc(5rem * var(--density, 1));
|
|
--space-24:calc(6rem * var(--density, 1));
|
|
--space-32:calc(8rem * var(--density, 1));
|
|
|
|
/* golden ramp (φ) — hanzo.ai tailwind.config.ts */
|
|
--golden-1:0.25rem;
|
|
--golden-2:0.405rem;
|
|
--golden-3:0.654rem;
|
|
--golden-4:1.059rem;
|
|
--golden-5:1.713rem;
|
|
--golden-6:2.772rem;
|
|
--golden-7:4.487rem;
|
|
--golden-8:7.26rem;
|
|
--golden-9:11.749rem;
|
|
--golden-split:38.2% 61.8%; /* @kind other */
|
|
|
|
/* layout rules (DESIGN.md §1.3) —
|
|
MOBILE-FIRST: the values authored here are the PHONE values, and the
|
|
min-width block at the bottom of this file scales them up. A surface that
|
|
uses the raw token therefore gets a layout that already breathes correctly
|
|
at 390px, with no media query of its own. (This is also the value the
|
|
generator captures for tokens.gen.ts — first occurrence wins, and the
|
|
authored default is the small one.) */
|
|
--container-max:80rem; /* max-w-7xl — grids */
|
|
--container-prose:48rem; /* max-w-3xl — centered text */
|
|
--container-wide:72rem; /* max-w-6xl — landing sections */
|
|
/* The page gutter also clears a notch. env() is 0px on every device without
|
|
one, so this is exactly `1rem` in the ordinary case and the safe inset when
|
|
there is something to avoid — no per-app work, no landscape clipping.
|
|
Requires `<meta name="viewport" content="…,viewport-fit=cover">` on the
|
|
host page; without it the UA reports 0 and the max() is inert, not wrong. */
|
|
--gutter:max(1rem,var(--safe-left),var(--safe-right));
|
|
/* The chrome's inset: @hanzogui/shell lays its header and menus on this
|
|
value, so a page that reads it starts in the column the brand mark does.
|
|
20px at 390, 41 at 1024, 58 at 1440. */
|
|
--page-gutter:clamp(20px,4vw,72px);
|
|
--gutter-sm:1.5rem; /* sm:px-6 */
|
|
--gutter-lg:2rem; /* lg:px-8 */
|
|
--section-y:2.5rem; /* py-10 on a phone → py-16 at md */
|
|
--section-y-lg:3.5rem; /* py-14 on a phone → py-24 at md */
|
|
--hero-y:3rem; /* py-12 on a phone → py-20 at md */
|
|
--hero-y-lg:4.5rem; /* py-18 on a phone → py-32 at md */
|
|
--header-height:3.5rem; /* 56px is the phone bar; 64px from md */
|
|
/* The fixed bars a surface actually DRAWS, which base.css's scroll padding
|
|
clears. 0 until a surface with a fixed header or dock sets them, usually
|
|
from a measurement; --header-height is the bar's designed size. */
|
|
--header:0px;
|
|
--dock:0px;
|
|
|
|
/* ——— touch ——— */
|
|
/* 44px is the floor a pointer-coarse target may render at (Apple HIG 44pt /
|
|
WCAG 2.5.5 AAA / 2.5.8 AA's 24px, taken at the higher bar). base.css spends
|
|
it automatically under `@media (pointer:coarse)`, so a button does not have
|
|
to opt in. It was previously a bare `min-height:44px` literal in
|
|
hanzoai/id's stylesheet — one app knowing something the system did not. */
|
|
--tap-target:44px;
|
|
|
|
/* ——— controls ——— */
|
|
/* A control's OWN height, which the floor above does not answer: 44px is the
|
|
smallest a finger may aim at, not the size a thing should be. Nothing named
|
|
the second question, so each surface answered it privately and they drifted
|
|
— @hanzo/ui keeps `CONTROL_H = 36` in TypeScript, hanzoai/app pins a 30px
|
|
`--control-h` over that same 36px library, and hanzoai/id spelled
|
|
`min-height:52px` into three separate rules.
|
|
The first three rungs ARE @hanzo/ui's size table (default/sm/lg), moved
|
|
down to the layer that holds values so a host can answer it once instead of
|
|
each library carrying a private copy. They sit under the tap floor on
|
|
purpose: base.css lifts whichever one is spent to 44px under
|
|
`pointer:coarse`, so one declaration serves a compact pointer control and a
|
|
hittable touch one. -xl is what a surface earns when the control IS the
|
|
content — one field, one button, nothing competing — where the default
|
|
reads as one of a stack rather than as the thing the screen is for. */
|
|
--control-height-sm:32px;
|
|
--control-height:36px;
|
|
--control-height-lg:40px;
|
|
--control-height-xl:52px;
|
|
|
|
/* ——— safe areas ——— */
|
|
/* The notch/home-indicator insets, named once. Anything pinned to a viewport
|
|
edge — a fixed header, a bottom bar, a sheet — adds the matching one. */
|
|
--safe-top:env(safe-area-inset-top,0px);
|
|
--safe-right:env(safe-area-inset-right,0px);
|
|
--safe-bottom:env(safe-area-inset-bottom,0px);
|
|
--safe-left:env(safe-area-inset-left,0px);
|
|
}
|
|
|
|
/* Scale up from the phone. `md` (48rem) is the same breakpoint grid.css
|
|
declares and Tailwind compiles against — one value, not two that agree. */
|
|
@media (min-width:48rem){
|
|
:root{
|
|
--section-y:4rem; /* py-16 — content sections */
|
|
--section-y-lg:6rem; /* py-24 — landing sections */
|
|
--hero-y:5rem; /* py-20 … */
|
|
--hero-y-lg:8rem; /* … lg:py-32 */
|
|
--header-height:4rem;
|
|
}
|
|
}
|
|
|
|
/* ── tokens/grid.css ─────────────────────────────────────── */
|
|
/* Grid — one definition of the field every Hanzo surface lays out on.
|
|
|
|
Derived, not invented. Across hanzo.ai and hanzo.app the grid written by hand
|
|
is overwhelmingly ONE grid: `grid-cols-1 md:grid-cols-2 lg:grid-cols-3` with a
|
|
1.5rem gutter (240 / 197 / 127 / 185 occurrences on hanzo.ai alone, and the
|
|
same shape leads on app). 1, 2, 3 and 4 columns account for 97% of every
|
|
grid-cols- in both repos, and all four divide 12 cleanly — so 12 is the base
|
|
the spans are cut from, and the long tail (5, 6, 7, 8, 12) still lands on it.
|
|
|
|
Containers, gutters and section rhythm are NOT restated here: spacing.css
|
|
already owns --container-max / --container-prose / --container-wide /
|
|
--gutter* / --section-y*, and a grid that redeclared them would be a second
|
|
answer to a settled question. This file adds only what a grid knows that
|
|
spacing does not — how many columns, how far apart, and where it reflows. */
|
|
:root{
|
|
/* The base. Spans are cut from twelve because 1/2/3/4/6 all divide it. */
|
|
--grid-columns:12;
|
|
|
|
/* Gutter. --grid-gap is the measured card-grid default (gap-6 leads hanzo.ai
|
|
at 185 uses); tight and loose are the neighbouring steps that cover app's
|
|
gap-4 (45) and the wide editorial grids (gap-8, 75). All three reference the
|
|
space ramp — the grid does not get its own private set of distances. */
|
|
--grid-gap:var(--space-6);
|
|
--grid-gap-tight:var(--space-4);
|
|
--grid-gap-loose:var(--space-8);
|
|
|
|
/* Reflow points, byte-identical to the Tailwind v4 defaults these surfaces
|
|
already compile against. Same NAMES and same VALUES on purpose: the utility
|
|
layer and anything reading tokens then resolve one value, not two that agree
|
|
by luck. md and lg carry ~85% of every breakpoint prefix in use; xl is 44
|
|
occurrences across both repos and 2xl is 1. */
|
|
--breakpoint-sm:40rem;
|
|
--breakpoint-md:48rem;
|
|
--breakpoint-lg:64rem;
|
|
--breakpoint-xl:80rem;
|
|
--breakpoint-2xl:96rem;
|
|
|
|
/* Intrinsic card grids: state ONE track minimum instead of three column counts
|
|
at three breakpoints. `repeat(auto-fit, minmax(var(--grid-card-min), 1fr))`
|
|
reflows 1 -> 2 -> 3 at the same widths the explicit recipe does, and it does
|
|
it from the space available rather than from the viewport — so a card grid
|
|
inside a sidebar behaves correctly, which the breakpoint version cannot.
|
|
|
|
18rem is chosen, not rounded to: inside --container-max less --gutter-lg on
|
|
both sides, lg (64rem) leaves ~60rem, where 3 tracks need 3x18 + 2x1.5 =
|
|
57rem and 4 would need 76.5rem; md (48rem) leaves ~44rem, where 2 tracks
|
|
need 37.5rem and 3 would need 57rem. The measured 1/2/3 ladder falls out. */
|
|
--grid-card-min:18rem;
|
|
--grid-card-min-wide:24rem;
|
|
}
|
|
|
|
/* ── tokens/radius.css ─────────────────────────────────────── */
|
|
:root{
|
|
/* The CORNER knob — one multiplier on every rung below but the pill, the way
|
|
--type-scale is on type and --density on spacing. @hanzo/ui's gui radii
|
|
multiply by the same name, so a component and a stylesheet stay round
|
|
together. 1 is the published set. */
|
|
--radius-scale:1;
|
|
|
|
--radius:calc(0.5rem * var(--radius-scale, 1)); /* the base token (globals.css) */
|
|
--radius-sm:calc(0.375rem * var(--radius-scale, 1)); /* rounded-md — buttons, inputs */
|
|
--radius-md:calc(0.5rem * var(--radius-scale, 1));
|
|
--radius-lg:calc(0.75rem * var(--radius-scale, 1)); /* rounded-xl — cards */
|
|
--radius-xl:calc(1rem * var(--radius-scale, 1)); /* rounded-2xl — dropdown panels */
|
|
--radius-2xl:calc(1.5rem * var(--radius-scale, 1)); /* rounded-3xl — story / hero cards */
|
|
--radius-composer:calc(28px * var(--radius-scale, 1)); /* the chat composer, 28px at scale 1 */
|
|
--radius-full:9999px; /* pills, CTAs, avatars, badges — a shape, so never scaled */
|
|
}
|
|
|
|
/* ── tokens/elevation.css ─────────────────────────────────────── */
|
|
/* Elevation on a near-black ground is NOT a drop shadow. A dark blur on a dark
|
|
page is nothing — you cannot cast a shadow onto black — which is why a system
|
|
that ships only `box-shadow` reads flat no matter how heavy the alphas get.
|
|
|
|
What actually lifts a surface here is LIGHT, and it arrives in three parts:
|
|
|
|
1. a hairline EDGE (tokens/colors.css --border)
|
|
2. an inset top HIGHLIGHT (--edge-highlight, below)
|
|
3. a wide, very dark DROP (--shadow-*)
|
|
|
|
(2) is the one that was missing and the one that does most of the work: a 1px
|
|
inset white line along the top edge reads as the surface catching the light
|
|
from above, and it is the whole difference between a panel that sits ON the
|
|
page and a rectangle that is merely a different colour than the page. The
|
|
reference spends it on its login card (`inset 0 1px 0 rgb(255 255 255 / .05)`)
|
|
and it is the single cheapest premium signal available on dark UI.
|
|
|
|
They are separate tokens rather than one baked shadow because a component
|
|
composes the ones it needs — `box-shadow: var(--edge-highlight), var(--shadow-lg)`
|
|
— and a card that wants the highlight without the drop should not have to
|
|
accept both. */
|
|
:root{
|
|
--shadow-none:none;
|
|
/* The floating surface: dropdowns, dialogs, sheets, the composer. Wide, soft,
|
|
and pushed well down. It was Tailwind's `shadow-2xl` at .25 alpha, which is
|
|
invisible on #0a0a0a — so the surfaces that float highest had the weakest
|
|
drop in the system while the t-shirt ramp below was correctly tuned. */
|
|
--shadow-floating:0 24px 60px -16px rgb(0 0 0 / .75);
|
|
--shadow-inset-hairline:inset 0 0 0 1px var(--white-10);
|
|
|
|
/* ——— the light ——— */
|
|
/* The top edge, catching light. Add it to any raised surface. */
|
|
--edge-highlight:inset 0 1px 0 0 rgb(255 255 255 / .10);
|
|
/* The bloom around a LIT action on hover — a filled primary button, the send
|
|
key. Monochrome brand: this is where "alive" comes from instead of a hue,
|
|
and it is the whole hover signal on a fill that is already at the top of
|
|
the ink ramp. It was .10, which is invisible: a white glow has to compete
|
|
with the white object casting it, so the alpha that reads around a WHITE
|
|
button is far higher than the one that reads around a dark one. */
|
|
--bloom:0 0 24px -2px rgb(255 255 255 / .28);
|
|
/* The focus halo: soft, outside the ring, carrying no ratio of its own. This
|
|
was `0 0 0 2px var(--ring)` — a second hard ring around the hard ring. */
|
|
--ring-focus:0 0 0 3px var(--ring-halo);
|
|
|
|
/* The t-shirt ramp. Named by size rather than by role, because that is how
|
|
every component library already asks for a shadow (and how @hanzo/brand
|
|
spells it). Alphas are heavier than Tailwind's defaults: on a near-black
|
|
ground a 10% black drop is invisible, so each rung is tuned to read on
|
|
--background. --shadow-2xl and --shadow-floating are the same rung. */
|
|
--shadow-sm:0 1px 2px 0 rgb(0 0 0 / .40);
|
|
--shadow:0 1px 3px 0 rgb(0 0 0 / .45), 0 1px 2px -1px rgb(0 0 0 / .45);
|
|
--shadow-md:0 4px 6px -1px rgb(0 0 0 / .50), 0 2px 4px -2px rgb(0 0 0 / .50);
|
|
--shadow-lg:0 10px 15px -3px rgb(0 0 0 / .55), 0 4px 6px -4px rgb(0 0 0 / .55);
|
|
--shadow-xl:0 20px 25px -5px rgb(0 0 0 / .60), 0 8px 10px -6px rgb(0 0 0 / .60);
|
|
--shadow-2xl:var(--shadow-floating);
|
|
|
|
/* ——— the paper ——— */
|
|
/* Depth carries STATE, not decoration: which sheet is the workspace, which is
|
|
the tool in use, which one opened. Reach for a level before a border — if
|
|
two surfaces need separating, RAISE one rather than outline both, and a
|
|
surface that is already raised does not also need a box drawn round it.
|
|
|
|
A level is a tint AND the light on it, published as a pair, because the
|
|
alternative — every component composing its own `--edge-highlight,
|
|
--shadow-*` — is how two panels on one screen end up lit from two
|
|
directions. Every rung here is pure vertical: a 1px inset line along the
|
|
top edge, a drop straight down. One origin, above and slightly in front.
|
|
|
|
The tints are the surface recipes rather than new values, so sheets stack
|
|
the way paper does: each is an alpha-white wash, one laid on another lands
|
|
a step lighter, and the stack converges on #262626 instead of blowing out.
|
|
|
|
Nothing here is restated in `.light`, deliberately: every part defers to a
|
|
token that already flips, so the ramp inverts with the theme for free. */
|
|
--sheet-0:var(--surface-page); /* the workspace ground */
|
|
--sheet-1:var(--surface-card); /* an active tool */
|
|
--sheet-2:var(--surface-card-emphasis); /* what opened, or what is inside */
|
|
/* The drops are spelled out rather than read from the t-shirt ramp above, and
|
|
that is the whole reason this ladder can be shared. `--shadow-sm` is a name
|
|
this package does not own — @hanzo/brand declares the same names at :root
|
|
with white-canvas alphas, and load order decides who wins — so a rung built
|
|
on one is a rung somebody else can flatten. `--shadow-sheet-*` is ours
|
|
alone, which is what lets @hanzo/ui's `.elevation-*` read it instead of
|
|
keeping a private copy. The numbers ARE rung 1 and rung 2 of the ramp; if
|
|
one moves, move both. `--edge-highlight` stays deferred: it is role-named,
|
|
uncollided, and its `.light` zeroing is what makes one value work on both
|
|
canvases. */
|
|
--shadow-sheet-1:var(--edge-highlight),0 1px 2px 0 rgb(0 0 0 / .40);
|
|
--shadow-sheet-2:var(--edge-highlight),0 10px 15px -3px rgb(0 0 0 / .55),0 4px 6px -4px rgb(0 0 0 / .55);
|
|
|
|
/* The fold — a corner turned back, and the one mark that means THIS OPENS.
|
|
Ornament nowhere: a sheet that does not expand does not wear one.
|
|
It is a background-image, so it costs no element and no pseudo-element —
|
|
paint it on the sheet itself and square that corner
|
|
(`border-bottom-right-radius:0`), because a folded corner is not round.
|
|
The flap catches the light like every other surface; the crease is the
|
|
hairline; both defer, so both invert. */
|
|
--fold:12px;
|
|
--fold-face:linear-gradient(315deg,var(--glass-strong) 0,var(--glass-strong) calc(var(--fold) - 1px),var(--border-strong) calc(var(--fold) - 1px),var(--border-strong) var(--fold),transparent var(--fold)); /* @kind color */
|
|
|
|
/* Ambient hero glow — a single white radial, blurred 120px, low opacity. */
|
|
--glow-hero:radial-gradient(circle,rgb(255 255 255 / .12) 0%,transparent 68%); /* @kind color */
|
|
--glow-hero-blur:120px;
|
|
/* Card top-corner sheen used on the story cards. */
|
|
--sheen-card:radial-gradient(120% 120% at 80% 0%,rgb(255 255 255 / .08) 0%,transparent 55%); /* @kind color */
|
|
/* The panel's top hairline, brightest at the centre and dissolving before it
|
|
reaches either corner — so an edge never terminates in a hard stop. One of
|
|
the two details that make the reference's panels look machined rather than
|
|
drawn (sidebar.css .panel::before). Paint it on a 1px-tall ::before. */
|
|
--sheen-edge:linear-gradient(90deg,transparent,rgb(255 255 255 / .14) 50%,transparent); /* @kind color */
|
|
/* Chrome text: the canonical headline gradient. Never a saturated rainbow. */
|
|
--gradient-chrome:linear-gradient(to right,#ffffff,var(--white-80),var(--white-60));
|
|
--gradient-chrome-2:linear-gradient(to right,#ffffff,var(--neutral-500));
|
|
/* Section-top protection gradient (hero overlays). */
|
|
--gradient-protect:linear-gradient(to bottom,var(--white-10),transparent);
|
|
|
|
/* ——— chrome: the bars the page passes behind ———
|
|
The header and the dock are one material: a translucent fill over a blur.
|
|
Raise the alpha and it stops being glass and becomes a shelf. A menu that
|
|
hangs from the chrome stands on --chrome-panel, which is opaque, because
|
|
running text read through a menu's words is unreadable. @hanzogui/shell
|
|
paints its panels with it. */
|
|
--chrome:rgb(9 9 11 / .72);
|
|
--chrome-blur:blur(20px) saturate(1.8);
|
|
--chrome-panel:var(--popover);
|
|
|
|
/* ——— the pane: a card on the workspace ground ———
|
|
The fill is the paper ladder's; the pane adds what the ladder leaves to the
|
|
call site — a corner, an edge, the lens, and the gutter between panes.
|
|
--pane-solid is the fill composited once, for a reader who asked for no
|
|
transparency or a browser that cannot blur. --pane-ground is two lamps
|
|
under the threshold of reading as a gradient: a blur over a flat colour
|
|
does nothing a plain alpha would not. */
|
|
--pane-blur:var(--chrome-blur);
|
|
--pane-edge:var(--white-08);
|
|
--pane-round:var(--radius-xl);
|
|
--pane-fill:var(--sheet-1);
|
|
--pane-solid:color-mix(in srgb,var(--neutral-800) 50%,var(--background));
|
|
--pane-floor:var(--background);
|
|
--pane-gap:var(--space-2);
|
|
--pane-ground:radial-gradient(80rem 40rem at 12% -10%,rgb(255 255 255 / .035),transparent 60%),radial-gradient(60rem 34rem at 92% 108%,var(--white-03),transparent 60%); /* @kind color */
|
|
}
|
|
|
|
/* A desk gets room to see the ground between panes. */
|
|
@media (min-width:1024px){
|
|
:root{--pane-gap:var(--space-3)}
|
|
}
|
|
|
|
/* Light theme — the same three parts, redistributed.
|
|
On white the order of work inverts: the DROP does almost all of it and the
|
|
inset highlight does none, because a white line on a white card is nothing.
|
|
Every token below is one whose dark value is white — i.e. one that silently
|
|
stops existing in `.light` unless it is restated here. check-tokens.mjs
|
|
scans this file for exactly that omission.
|
|
|
|
--edge-highlight is `inset 0 0 0 0 transparent` rather than `none` on
|
|
purpose: components compose these — `box-shadow: var(--edge-highlight),
|
|
var(--shadow-lg)` — and `none` is not a legal layer in a comma list, so the
|
|
obvious spelling would invalidate the whole declaration and take the drop
|
|
shadow down with it. A zero-size transparent shadow composes to nothing and
|
|
costs nothing. */
|
|
.light{
|
|
--shadow-floating:0 24px 60px -16px rgb(0 0 0 / .18);
|
|
/* On white the drop does all of it, so the paper rungs are restated here even
|
|
though check 4 does not demand it — their white is inside --edge-highlight,
|
|
which flips underneath. Rung 1 and rung 2 of the light ramp, same as dark. */
|
|
--shadow-sheet-1:var(--edge-highlight),0 1px 2px 0 rgb(0 0 0 / .06);
|
|
--shadow-sheet-2:var(--edge-highlight),0 10px 15px -3px rgb(0 0 0 / .09),0 4px 6px -4px rgb(0 0 0 / .07);
|
|
--shadow-inset-hairline:inset 0 0 0 1px rgb(0 0 0 / .10);
|
|
--edge-highlight:inset 0 0 0 0 transparent;
|
|
--bloom:0 6px 20px -4px rgb(0 0 0 / .18);
|
|
--shadow-sm:0 1px 2px 0 rgb(0 0 0 / .06);
|
|
--shadow:0 1px 3px 0 rgb(0 0 0 / .08), 0 1px 2px -1px rgb(0 0 0 / .06);
|
|
--shadow-md:0 4px 6px -1px rgb(0 0 0 / .08), 0 2px 4px -2px rgb(0 0 0 / .06);
|
|
--shadow-lg:0 10px 15px -3px rgb(0 0 0 / .09), 0 4px 6px -4px rgb(0 0 0 / .07);
|
|
--shadow-xl:0 20px 25px -5px rgb(0 0 0 / .10), 0 8px 10px -6px rgb(0 0 0 / .08);
|
|
--glow-hero:radial-gradient(circle,rgb(0 0 0 / .06) 0%,transparent 68%);
|
|
--sheen-card:radial-gradient(120% 120% at 80% 0%,rgb(0 0 0 / .04) 0%,transparent 55%);
|
|
--sheen-edge:linear-gradient(90deg,transparent,rgb(0 0 0 / .08) 50%,transparent);
|
|
--gradient-chrome:linear-gradient(to right,#0a0a0a,rgb(10 10 10 / .8),rgb(10 10 10 / .6));
|
|
--gradient-chrome-2:linear-gradient(to right,#0a0a0a,var(--neutral-500));
|
|
--gradient-protect:linear-gradient(to bottom,rgb(0 0 0 / .10),transparent);
|
|
/* Light glass is the same idea from the other side: the fill goes white, the
|
|
edge and the lamps go dark. */
|
|
--chrome:rgb(255 255 255 / .72);
|
|
--pane-edge:rgb(0 0 0 / .08);
|
|
--pane-fill:var(--chrome);
|
|
--pane-solid:var(--background);
|
|
--pane-floor:var(--surface-card);
|
|
--pane-ground:radial-gradient(80rem 40rem at 12% -10%,rgb(0 0 0 / .03),transparent 60%),radial-gradient(60rem 34rem at 92% 108%,rgb(0 0 0 / .022),transparent 60%);
|
|
}
|
|
|
|
/* No transparency when asked for none, or where nothing can blur behind it:
|
|
a translucent fill with no blur is the page read through the words. */
|
|
@media (prefers-reduced-transparency:reduce){
|
|
:root,:root.light{--pane-fill:var(--pane-solid);--pane-blur:none}
|
|
}
|
|
@supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){
|
|
:root,:root.light{--pane-fill:var(--pane-solid)}
|
|
}
|
|
|
|
/* ── tokens/motion.css ─────────────────────────────────────── */
|
|
/* Motion is restrained: fade + small rise, CSS-only hovers, one breathing glow.
|
|
No springs, no bounce, no parallax, no autoplay carousels. */
|
|
:root{
|
|
--duration-fast:150ms; /* @kind other */ /* dropdown / panel open */
|
|
--duration-base:300ms; /* @kind other */ /* slide-up-fade */
|
|
--duration-slow:400ms; /* @kind other */ /* hero element entry */
|
|
--duration-slower:500ms; /* @kind other */ /* section entry */
|
|
--duration-glow:9s; /* @kind other */ /* ambient radial breathe */
|
|
--duration-press:90ms; /* @kind other */ /* the press-down on :active */
|
|
--ease-out:cubic-bezier(0,0,0.2,1); /* @kind other */
|
|
--ease-in-out:cubic-bezier(0.4,0,0.2,1); /* @kind other */
|
|
/* The entrance curve. Nearly all of the distance is covered in the first
|
|
third and it settles without overshooting, so a panel arrives DECIDED
|
|
rather than drifting in — the difference between 300ms that feels
|
|
immediate and 300ms that feels slow. Use it for anything that appears
|
|
(menus, dialogs, sheets, toasts); --ease-out stays the curve for a state
|
|
change on something already on screen. It is not a spring: this system has
|
|
no bounce anywhere. */
|
|
--ease-emphasis:cubic-bezier(0.16,1,0.3,1); /* @kind other */
|
|
--stagger:60ms; /* @kind other */ /* per-element delay in a group */
|
|
--entry-rise:16px; /* hero y-offset */
|
|
--entry-rise-lg:24px; /* card y-offset */
|
|
}
|
|
|
|
@keyframes hanzo-fade-up{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
|
|
@keyframes hanzo-fade-down{from{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}}
|
|
@keyframes hanzo-slide-up-fade{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}
|
|
/* The entrance for anything that appears OVER the page — dialog, sheet, command
|
|
palette. It arrives from very slightly small and very slightly low, which is
|
|
what reads as "came forward" rather than "was toggled on". 3% and 8px: any
|
|
more and it is an animation you notice, which is the failure mode. Pair it
|
|
with --ease-emphasis. */
|
|
@keyframes hanzo-zoom-in{from{opacity:0;transform:scale(.97) translateY(8px)}to{opacity:1;transform:none}}
|
|
@keyframes hanzo-glow{0%,100%{transform:scale(1);opacity:.45}50%{transform:scale(1.08);opacity:.65}}
|
|
@keyframes hanzo-pulse-dot{0%,100%{opacity:1}50%{opacity:.35}}
|
|
|
|
@media (prefers-reduced-motion:reduce){
|
|
*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important}
|
|
}
|
|
|
|
/* ── tokens/z.css ─────────────────────────────────────── */
|
|
/* Stacking order — the one z-index ladder. Layers are named by role, never by a
|
|
magic number, so a dropdown opened from the fixed header always sits above it
|
|
and nothing ever reaches for 9999. Below --z-raised is ordinary document flow.
|
|
|
|
Rungs are 100 apart so a surface can slot a one-off BETWEEN two roles
|
|
(calc(var(--z-modal) + 1)) without inventing a new decade. */
|
|
:root{
|
|
--z-base:0;
|
|
--z-raised:10; /* hover-lifted cards, sticky table headers */
|
|
--z-sticky:200; /* pinned section rails */
|
|
--z-header:300; /* the fixed site header */
|
|
--z-dropdown:400; /* menus, selects, comboboxes */
|
|
--z-overlay:500; /* dialog / sheet scrim */
|
|
--z-modal:600; /* dialogs, sheets, command palette */
|
|
--z-popover:700; /* popovers, tooltips (also when anchored in modals) */
|
|
--z-toast:800; /* toasts / notifications — always on top */
|
|
|
|
/* What a PAGE draws over itself, between --z-raised and --z-sticky: a panel
|
|
under its scrim under a drawer, then a sheet, the dock and a banner. All of
|
|
it stays under the header and every overlay above. --z-under sits behind
|
|
the flow, for a backdrop. */
|
|
--z-under:-10;
|
|
--z-panel:18;
|
|
--z-scrim:19;
|
|
--z-drawer:20;
|
|
--z-sheet:30;
|
|
--z-dock:40;
|
|
--z-banner:50;
|
|
|
|
/* @hanzo/brand spells the top two rungs --z-tooltip and --z-notification.
|
|
Same rungs, so a component written against either vocabulary stacks
|
|
identically. --z-popover / --z-toast are canonical. */
|
|
--z-tooltip:var(--z-popover);
|
|
--z-notification:var(--z-toast);
|
|
}
|
|
|
|
/* ── tokens/fonts.css ─────────────────────────────────────── */
|
|
/* Zen — the family on Hanzo surfaces, sans and mono, and its Arabic companions.
|
|
SELF-HOSTED. The faces ship inside this package (assets/fonts/*.woff2, two
|
|
variable files, 141 KB total, SIL OFL-1.1 — see assets/fonts/LICENSE-Zen.txt),
|
|
with two Arabic companions beside them (below).
|
|
|
|
Zen is our own family, under the SIL Open Font License. The licence file
|
|
beside the binaries carries every copyright the OFL requires; nothing else in
|
|
this repository restates them, which is the whole of what §2 asks for.
|
|
|
|
SHIPPED here rather than @import-ed from `@hanzo/font`, and that is load
|
|
bearing. `@hanzo/ui` composes this file into the MIDDLE of its theme.css, and
|
|
CSS drops an @import that does not precede every other rule — measured, in
|
|
Chromium, on the built sheet: zero faces loaded and not one woff2 requested.
|
|
An @font-face has no such ordering rule, so declaring the faces is the only
|
|
form that survives being flattened into a larger stylesheet. `@hanzo/font`
|
|
remains the one place the faces are AUTHORED; this is a copy of its output,
|
|
the same way the colour tokens are copied rather than fetched.
|
|
|
|
A copy is only as good as what it copied from, and this one went stale: the
|
|
floor sat at @hanzo/font ^1.8.8, whose faces carry Vercel's vendor tag under
|
|
our filenames, so every `npm run gen` overwrote the good committed Zen with
|
|
Geist and 0.5.23 and 0.5.24 both went out that way — the release commits hold
|
|
the right bytes, the tarballs did not. Two things stop it recurring, each in
|
|
its own place: from 1.9.2 the font package's build refuses to publish a face
|
|
that is not its own, and gen-tokens.mjs refuses to copy from anything below
|
|
that floor.
|
|
|
|
Why self-hosted rather than a third-party CDN:
|
|
- A sign-in page must not make a third-party request. hanzoai/id refused to
|
|
import this file for exactly that reason, which split the token layer: id
|
|
took the colours and not the typeface. Self-hosting removes the reason, so
|
|
every surface can import styles.css unchanged.
|
|
- It was a render-blocking request to a host we do not control, on the
|
|
critical path of every surface, and it broke offline/air-gapped dev.
|
|
- One variable file per family replaces nine static weights, and it is fewer
|
|
bytes than the CSS-then-woff2 round trip.
|
|
|
|
The url()s are relative to THIS file, so they resolve wherever the package is
|
|
mounted — node_modules, a CDN, a copied dist — with no configuration.
|
|
|
|
`font-display: block`, NOT swap, and the difference is the whole brand. `swap`
|
|
paints the fallback IMMEDIATELY and repaints when the face lands, so every first
|
|
visit shows the platform's own sans for as long as the download takes — on Ubuntu
|
|
that is DejaVu, which looks nothing like us and is the single most common way a
|
|
Hanzo surface renders wrong. `block` holds the text invisible for a short period
|
|
(~3s) and then paints Zen, so the fallback is only ever reached when the face
|
|
genuinely fails to arrive. A held word is a moment; the wrong typeface is the
|
|
brand. The generic tail below stays for exactly that real-failure case. */
|
|
|
|
@font-face{
|
|
font-family:"Zen";
|
|
src:url("./assets/fonts/Zen-Variable.woff2") format("woff2");
|
|
font-weight:100 900;
|
|
font-style:normal;
|
|
font-display:block;
|
|
}
|
|
@font-face{
|
|
font-family:"Zen Mono";
|
|
src:url("./assets/fonts/ZenMono-Variable.woff2") format("woff2");
|
|
font-weight:100 900;
|
|
font-style:normal;
|
|
font-display:block;
|
|
}
|
|
|
|
/* ARABIC. Zen draws Latin and Cyrillic and no Arabic, so an Arabic page used to
|
|
fall to whatever face each browser picked. @hanzo/font carries two Arabic
|
|
companions, variable over Zen's own weight axis and cut to the Arabic blocks,
|
|
and they are copied here with the Zen faces: Noto Kufi Arabic (the default,
|
|
geometric like Zen) and Noto Sans Arabic. Both draw every Arabic-script
|
|
language Hanzo ships. They are not ours and keep their names; their licence is
|
|
assets/fonts/LICENSE-Arabic.txt.
|
|
|
|
THE RANGE IS THE WHOLE TRICK. Each face covers the Arabic blocks only, so a
|
|
page with no Arabic on it never requests one, and Latin inside an Arabic page
|
|
is still Zen. The joiners (U+200C-200F) are drawn by the files but kept out of
|
|
the range: an English page with an emoji ZWJ sequence would otherwise fetch an
|
|
Arabic face for one invisible character.
|
|
|
|
Every stack below names `--font-arabic` FIRST. A family whose range excludes a
|
|
character is passed over without a download, so Latin falls through to Zen,
|
|
and the "first available font" a line box and `ch` are measured from is still
|
|
Zen (its range covers the space; the companion's does not). That is what makes
|
|
the choice one property: `@hanzo/appearance` writes `--font-arabic`, and sans,
|
|
mono and serif follow.
|
|
|
|
The default is ALSO declared under "Zen" and "Zen Mono", for anything that
|
|
names a family rather than a token. That only works because its descriptors
|
|
are Zen's exactly (weight 100 900, normal): faces in one family composite only
|
|
when everything but the range matches, and a companion declared at another
|
|
weight wins the weight match for Latin too, refuses every Latin letter, and
|
|
sends the page to the system sans with no font request to show for it.
|
|
|
|
`block` for the reason Zen has it: an Arabic page waits a moment for its face
|
|
rather than painting the platform's and repainting. */
|
|
@font-face{
|
|
font-family:"Zen";
|
|
src:url("./assets/fonts/NotoKufiArabic-Variable.woff2") format("woff2");
|
|
font-weight:100 900;
|
|
font-style:normal;
|
|
font-display:block;
|
|
unicode-range:U+0600-06FF, U+0750-077F, U+FB50-FDFF, U+FE70-FEFC;
|
|
}
|
|
@font-face{
|
|
font-family:"Zen Mono";
|
|
src:url("./assets/fonts/NotoKufiArabic-Variable.woff2") format("woff2");
|
|
font-weight:100 900;
|
|
font-style:normal;
|
|
font-display:block;
|
|
unicode-range:U+0600-06FF, U+0750-077F, U+FB50-FDFF, U+FE70-FEFC;
|
|
}
|
|
@font-face{
|
|
font-family:"Noto Kufi Arabic";
|
|
src:url("./assets/fonts/NotoKufiArabic-Variable.woff2") format("woff2");
|
|
font-weight:100 900;
|
|
font-style:normal;
|
|
font-display:block;
|
|
unicode-range:U+0600-06FF, U+0750-077F, U+FB50-FDFF, U+FE70-FEFC;
|
|
}
|
|
@font-face{
|
|
font-family:"Noto Sans Arabic";
|
|
src:url("./assets/fonts/NotoSansArabic-Variable.woff2") format("woff2");
|
|
font-weight:100 900;
|
|
font-style:normal;
|
|
font-display:block;
|
|
unicode-range:U+0600-06FF, U+0750-077F, U+FB50-FDFF, U+FE70-FEFC;
|
|
}
|
|
|
|
:root{
|
|
/* The Arabic companions by what they are, and the one in use. A person's
|
|
choice replaces --font-arabic with one of the two names above it. */
|
|
--font-arabic-kufi:"Noto Kufi Arabic";
|
|
--font-arabic-sans:"Noto Sans Arabic";
|
|
--font-arabic:var(--font-arabic-kufi);
|
|
--font-sans:var(--font-arabic), Zen, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
--font-display:var(--font-sans);
|
|
--font-mono:var(--font-arabic), Zen Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
|
--font-serif:var(--font-arabic), Georgia, serif;
|
|
/* The default is Zen as drawn, and that is a decision rather than an omission.
|
|
`base.css` puts this on <body>, so whatever is here is the voice of every
|
|
surface in the estate — which is exactly why it should not carry a brand's.
|
|
|
|
It used to read `"ss01","ss02","cv01","cv02","cv03"`, of which FOUR did
|
|
nothing: Zen has no cv01/cv02/cv03 at all, and ss01 and ss02 both remap `a`,
|
|
so ss01 substitutes first and ss02 never matches the glyph it was written
|
|
for. The one that did work quietly gave Lux and Zoo the single-storey `a`
|
|
that belongs to Hanzo.
|
|
|
|
A brand sets its own voice by overriding THIS token — one knob, one place:
|
|
Hanzo "ss01" single-storey a, the neo-grotesque register
|
|
Lux "ss04" straight-leg R, to sit under the wordmark
|
|
Zoo normal the humanist default, the warmest of the three
|
|
Zen ships eleven sets; ss03 is a tailed l, ss05 a serifed I, ss06 an
|
|
alternate G and ss09 a slashed zero with a serif one. Kerning, ligatures and
|
|
contextual alternates are on by default in every browser and do not belong
|
|
in this list. */
|
|
--font-feature-settings:normal;
|
|
}
|
|
/* The three voices above, set by the host's `data-brand` on <html>. */
|
|
[data-brand=hanzo]{--font-feature-settings:"ss01"}
|
|
[data-brand=lux]{--font-feature-settings:"ss04"}
|
|
|
|
/* ── tokens/base.css ─────────────────────────────────────── */
|
|
/* Minimal element defaults so specimen cards and kits inherit the brand without
|
|
a utility framework. Components carry their own styles inline.
|
|
|
|
EVERYTHING HERE IS LAYERED, and that is the most important line in the file.
|
|
A rule outside a cascade layer beats a rule inside one no matter how specific
|
|
the loser is — so an unlayered `a{color:...}` here outranked EVERY Tailwind
|
|
text utility on every anchor, on every surface that imports these tokens. A
|
|
white-on-white primary button is what that looks like: `text-neutral-950` on
|
|
an anchor, silently overridden to --text-primary, invisible on its own white
|
|
fill, and reading as a rendering glitch rather than a cascade bug.
|
|
|
|
The same trap already bit `text-decoration` here once (see the a:hover note
|
|
below). It was fixed by deleting that one declaration, which left the identical
|
|
defect in `color` untouched. Layering fixes the whole class: these are
|
|
DEFAULTS, and a default must lose to anything an app states deliberately.
|
|
|
|
The rules added for controls, media and touch go one step further and wrap
|
|
their selectors in :where(), which has ZERO specificity. @layer decides who
|
|
wins BETWEEN layers; :where() decides who wins INSIDE this one. An app that
|
|
ships its own reset — `input { … }`, `img { … }` — very often lands in the
|
|
same base layer, and these defaults have to lose that collision too. */
|
|
@layer base {
|
|
*{box-sizing:border-box;border-color:var(--border)}
|
|
html{
|
|
-webkit-font-smoothing:antialiased;
|
|
-moz-osx-font-smoothing:grayscale;
|
|
text-rendering:optimizeLegibility;
|
|
/* The page never scrolls sideways. On the root box as well as <body>,
|
|
because the scrollport is the documentElement; `clip` rather than
|
|
`hidden`, so neither becomes a scroll container and `sticky` still
|
|
holds. */
|
|
overflow-x:clip;
|
|
scroll-behavior:smooth;
|
|
/* iOS inflates text when a phone is rotated to landscape unless this is
|
|
pinned. It is the single most common reason a mobile layout that was
|
|
verified in portrait comes apart on its side. */
|
|
-webkit-text-size-adjust:100%;
|
|
/* A scroll the browser makes for the page — an anchor jump, focus moving,
|
|
scrollIntoView — lands clear of a fixed header or dock. A surface that
|
|
has one sets --header / --dock; one that has none scrolls to the edge. */
|
|
scroll-padding-block:var(--header,0px) var(--dock,0px);
|
|
}
|
|
/* A slide settles where it begins: a page whose sections state
|
|
`scroll-snap-align` is the scroller's half here. `proximity`, never
|
|
`mandatory`, so a long section never holds a reader; a page with no snap
|
|
points is untouched. */
|
|
@media (min-width:1024px) and (prefers-reduced-motion:no-preference){
|
|
:where(html){scroll-snap-type:y proximity}
|
|
}
|
|
/* Each font-family carries the stack as a literal fallback. --font-sans lives in
|
|
tokens/fonts.css, which a surface may legitimately import separately; without
|
|
the fallback an unresolved var() makes font-family invalid and the UA drops to
|
|
its SERIF default — the whole console silently rendered in Times. */
|
|
/* The insets keep content out of the notch and the home indicator on a page
|
|
drawn under them (viewport-fit=cover); env() is 0 on a flat screen. The
|
|
top one is the header's to pay. */
|
|
body{margin:0;padding:0 var(--safe-right,0px) var(--safe-bottom,0px) var(--safe-left,0px);overflow-x:clip;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}
|
|
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)}
|
|
p{margin:0;text-wrap:pretty}
|
|
code,pre,kbd,samp{font-family:var(--font-mono,ui-monospace,SFMono-Regular,monospace)}
|
|
/* No `transition` here: the shared interactive rule below states one, and an
|
|
`a` selector (0,0,1) would outrank that :where() (0,0,0) and replace the
|
|
whole shorthand — anchors would transition colour and nothing else. */
|
|
a{color:var(--text-primary);text-decoration:none;text-underline-offset:4px}
|
|
/* Underline is the accessible affordance for a link in RUNNING TEXT and a visual
|
|
bug everywhere else: nav items, cards and anchor-buttons are all <a> too, so a
|
|
blanket `a:hover` underlined every one of them on every surface that imports
|
|
these tokens. Scope it to the elements that actually carry prose; a link that
|
|
is a component states its own hover. The old rule also re-set `color` to the
|
|
value `a` already has — a no-op that only served to outrank a component's own
|
|
hover colour. */
|
|
/* A list or a cell inside chrome is not running text: nav, aside, header and
|
|
the shell's slots carry links that never underline. */
|
|
:is(p,li,blockquote,dd,dt,td,th,figcaption) a:hover:not(:is(nav,aside,header,[role=navigation],[data-hanzo-shell],[data-slot=rail],[data-slot=bar]) a){text-decoration:underline}
|
|
|
|
/* ——— the smooth ———
|
|
Every interactive element in the reference transitions its colour, its fill
|
|
and its EDGE. That is most of what separates "classy and smooth" from a
|
|
correct set of colours: a hairline that steps from .10 to .16 instantly
|
|
reads as a flicker, and over 150ms reads as a response. Granted once, here,
|
|
rather than restated on every component. tokens/motion.css already zeroes
|
|
all of it under prefers-reduced-motion. */
|
|
:where(a,button,[role=button],input,select,textarea,summary,[tabindex]){
|
|
transition:color var(--duration-fast) var(--ease-out),
|
|
background-color var(--duration-fast) var(--ease-out),
|
|
border-color var(--duration-fast) var(--ease-out),
|
|
box-shadow var(--duration-fast) var(--ease-out),
|
|
opacity var(--duration-fast) var(--ease-out),
|
|
transform var(--duration-press) var(--ease-out);
|
|
}
|
|
|
|
/* ——— the press ———
|
|
A button that moves under the finger is the cheapest "this is a real
|
|
control" signal there is, and its absence is most of why a correct-looking
|
|
button still feels like a picture of a button. 2% over 90ms — small enough
|
|
that it reads as pressure rather than as animation, and it is the
|
|
reference's own value (.auth-signin-btn:active). Inputs are excluded: a
|
|
text field does not depress, and scaling one mid-caret is a glitch. */
|
|
:where(button,[role=button],a[role=button],summary):active:not(:disabled){
|
|
transform:scale(.98);
|
|
}
|
|
|
|
/* ——— the tap ———
|
|
No grey flash where a finger lands, and no wait for a double-tap zoom on
|
|
a control. A dialog or drawer scrolls itself and stops at its end rather
|
|
than carrying the page behind it along. */
|
|
:where(a,button,[role=button],input,select,textarea,summary,label){
|
|
-webkit-tap-highlight-color:transparent;
|
|
touch-action:manipulation;
|
|
}
|
|
:where(dialog,[role=dialog],[data-drawer]){overscroll-behavior:contain}
|
|
|
|
/* ——— controls ———
|
|
A bare <input> renders in the UA's face (Arial) at the UA's size with the
|
|
UA's border, which puts two typefaces and a foreign blue focus ring inside
|
|
Hanzo cards on every surface that has not styled its own fields yet. The
|
|
rules below are what a control looks like here. --border-control, NOT
|
|
--border: a field's edge is its own duty and its own rung, so a surface can
|
|
retune fields without touching every divider on the page. It owes no
|
|
contrast ratio — the whole budget goes to --ring, below. */
|
|
:where(input,select,textarea,button){font:inherit;color:inherit}
|
|
:where(input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=color]),select,textarea){
|
|
background:var(--surface-2);
|
|
border:1px solid var(--border-control);
|
|
/* --radius-md (8px), not --radius-sm (6px). On a 36px control the two are
|
|
one step apart and it is the step between "boxy" and "considered";
|
|
--radius-sm keeps its job on genuinely small parts — badges, chips, menu
|
|
rows — where 8px would look bubbly. The reference draws its composer and
|
|
its buttons at 8-10px and nothing at 6px except 22px chips. */
|
|
border-radius:var(--radius-md);
|
|
padding:0 var(--space-3);
|
|
/* A field's ring is drawn INSIDE its own box. The one focus rule below
|
|
reads this; nothing else about the indicator changes.
|
|
|
|
A field lives in containers a button does not — composers, palettes and
|
|
panels that round their corners with `overflow:hidden`. An outline at a
|
|
POSITIVE offset is painted outside the border box, so any such ancestor
|
|
clips it, and the computed style still says `2px solid` while nothing is
|
|
drawn: a chat composer measured byte-identical screenshots focused and
|
|
unfocused, and a search pill kept only its left and right edges. A
|
|
negative offset cannot be clipped by anything, so a field needs to know
|
|
nothing about what contains it.
|
|
|
|
-2 against a 2px ring lays the outline over the outermost 2px of the box,
|
|
so it REPLACES the 1px edge for as long as focus is there rather than
|
|
drawing a second line a pixel inside the first. */
|
|
--ring-offset:-2px;
|
|
}
|
|
:where(textarea){padding:var(--space-2) var(--space-3);resize:vertical}
|
|
/* A field has NO focus rule of its own — see the ring at the bottom of this
|
|
layer, which is the one focus indicator for everything.
|
|
|
|
There used to be one here: `outline:none` plus a brightened edge (.15 ->
|
|
.22) plus a soft halo, the composer look. It was removed because it could
|
|
not do the job in either of the two ways that matter.
|
|
|
|
It was never VISIBLE ENOUGH. Composited on --background the brightened edge
|
|
measures 1.91:1 and the halo 1.25:1, against the 3:1 that WCAG 1.4.11 asks
|
|
of a focus indicator and that this package already gates --ring on. The
|
|
budget was documented as being spent entirely on --ring "because that is
|
|
what a keyboard user navigates by" — true for a button, and false for a
|
|
field for exactly as long as this rule told fields not to use it.
|
|
|
|
And it was SUPPRESSIBLE. It carried the indicator on `border-color`, so any
|
|
app that states `border` on its own fields overrode it — @hanzo/id does,
|
|
unlayered, which beats this layer whatever its specificity, and its focused
|
|
fields sat at the resting .15 while both files read as correct. An outline
|
|
is not a border: nothing in an app's field styling reaches it, so the ring
|
|
paints whether or not the app has opinions about edges. */
|
|
:where(input,select,textarea):hover:not(:focus-visible):not(:disabled){background:var(--surface-3)}
|
|
:where(input,textarea)::placeholder{color:var(--text-disabled)}
|
|
:where(input,select,textarea,button):disabled{opacity:.5;cursor:not-allowed}
|
|
:where(button,[role=button],summary,label,select){cursor:pointer}
|
|
/* A bare <button> loses the UA's outset bevel and grey fill. A button that
|
|
wants an edge or a ground states one, and any rule at all outranks this. */
|
|
:where(button){border:0;background:none}
|
|
|
|
/* ——— the pointer floor ———
|
|
WCAG 2.5.8 AA's 24px, for every pointer; the thumb's 44px is below, under
|
|
pointer:coarse. A min-height cannot size an inline box, so a header or
|
|
footer link is drawn as a box first. An icon-only control has no text to
|
|
widen it, so it is a centred box too.
|
|
|
|
`:only-child` counts ELEMENTS: `<a>Docs <svg/></a>` matches as well, and a
|
|
label+icon control states its own `justify-content`. */
|
|
:where(button,[role=button],header a[href],footer a[href]){min-height:24px}
|
|
:where(header a[href],footer a[href]){display:inline-flex;align-items:center}
|
|
:where(:is(a[href],button,[role=button]):has(> svg:only-child)){display:inline-flex;align-items:center;justify-content:center}
|
|
|
|
/* MONO KEEPS ITS CELLS. Zen Mono fuses `--` into one glyph, and a brand's
|
|
--font-feature-settings rides down into it from <body>, so a command meant
|
|
to be retyped draws one column short. Wherever the mono face is asked for —
|
|
these elements, a gui Text on the `$mono` face (gui names that face's scope
|
|
`font_mono`), or a style naming the token — every character keeps its cell.
|
|
After the controls' `font:inherit`, which would otherwise hand a mono
|
|
button its parent's ligatures back. */
|
|
:where(code,pre,kbd,samp,.font_mono,[style*='--font-mono']){font-variant-ligatures:none;font-feature-settings:normal}
|
|
|
|
/* ——— media ———
|
|
An image, a video or an embed at its intrinsic width is the usual cause of a
|
|
390px page that scrolls sideways. */
|
|
:where(img,svg,video,canvas,iframe,picture,object){max-width:100%}
|
|
/* `auto` keeps the aspect when max-width narrows the box. An element given a
|
|
height and no width keeps the height it was given: an SVG mark has no
|
|
intrinsic size, and `auto` collapses it to 0x0. */
|
|
:where(:is(img,video):is(:not([height]),[width])){height:auto}
|
|
/* A code block scrolls ITSELF rather than widening the document. */
|
|
:where(pre){overflow-x:auto}
|
|
|
|
/* ——— touch ———
|
|
Everything a finger aims at clears 44px, and every field renders at 16px so
|
|
iOS does not zoom the viewport on focus. Both are keyed on pointer:coarse —
|
|
the actual signal — so a desktop mouse still gets compact controls. This is
|
|
the whole of "mobile-first without per-app work": an app that never thought
|
|
about phones still has hittable buttons and a viewport that stays put. */
|
|
@media (pointer:coarse){
|
|
:where(button,[role=button],a[role=button],summary,select,textarea,
|
|
input:not([type=checkbox]):not([type=radio]):not([type=hidden])){
|
|
min-height:var(--tap-target);
|
|
}
|
|
/* @hanzo/gui writes a control's size INLINE, and only an important
|
|
declaration outranks an inline one, so these are the floor that holds
|
|
on a gui control. A switch or checkbox keeps its own box: the button IS
|
|
the track, and the pseudo-element below widens its target. A radio drawn
|
|
as a button — a segment of a toggle group — is a control like any other. */
|
|
:where(button:not([role=switch],[role=checkbox]),[role=button],select,header a[href],footer a[href]){min-height:var(--tap-target) !important}
|
|
:where(:is(a[href],button,[role=button]):has(> svg:only-child)){min-width:var(--tap-target) !important}
|
|
:where(input,select,textarea){font-size:var(--text-control)}
|
|
/* A checkbox, radio or switch must NOT grow to 44px — the box is 16px
|
|
because that is what a checkbox looks like. What has to reach 44px is the
|
|
area a finger may land in, so the target is expanded with a centred
|
|
pseudo-element that changes nothing about layout or paint. WCAG 2.5.8 puts
|
|
the floor at 24px; a 16px box misses it, and every one of these ships at
|
|
16px. */
|
|
:where([role=checkbox],[role=radio],[role=switch],input[type=checkbox],input[type=radio]){position:relative}
|
|
:where([role=checkbox],[role=radio],[role=switch],input[type=checkbox],input[type=radio])::after{
|
|
content:'';position:absolute;top:50%;left:50%;
|
|
width:var(--tap-target);height:var(--tap-target);
|
|
transform:translate(-50%,-50%);
|
|
}
|
|
}
|
|
|
|
/* THE focus indicator. One rule, every focusable thing, no exceptions — a
|
|
button, a link, a summary, a field. 2px at --ring is 3.77:1 on the darkest
|
|
canvas and clears the 2px perimeter WCAG 2.4.13 asks for; the gate in
|
|
check-tokens holds --ring to that and nothing else here may weaken it.
|
|
|
|
WHERE the ring sits is the one thing that varies, and it varies as a VALUE
|
|
the element carries, never as a second rule: `--ring-offset` defaults to 2
|
|
and a field sets it to -2 (see the field block above). Two rules is what
|
|
0.4.9 removed, and a second one here would be the same defect again.
|
|
|
|
There were two rules until 0.4.9, and they collided invisibly. Both computed
|
|
to (0,1,0) — :where() zeroes whatever it wraps, leaving one pseudo-class on
|
|
each side — so the cascade fell through to SOURCE ORDER inside this layer,
|
|
this rule was written later, and it overrode the `outline:none` the field
|
|
rule stated expressly to prevent it. Every focused input on every consumer
|
|
drew BOTH the ring and the edge+halo. Each rule read as correct alone, which
|
|
is why it survived review in both files; the defect existed only in their
|
|
order. One rule cannot disagree with itself. */
|
|
:focus-visible{outline:2px solid var(--ring);outline-offset:var(--ring-offset,2px)}
|
|
/* The ring's colour at REST too, so a control that transitions colours has
|
|
no currentColor -> ring interpolation to flash when focus lands. */
|
|
:where(*){outline-color:var(--ring)}
|
|
/* --white-20 is white-on-white in the light theme, so selection reads through
|
|
--selection, which BOTH themes define. */
|
|
::selection{background:var(--selection);color:var(--text-primary)}
|
|
|
|
/* ——— the scrollbar ———
|
|
6px, no track, no stepper buttons, the thumb on the same hairline ladder as
|
|
everything else. ONE treatment per engine. Chromium 121+ reads the standard
|
|
`scrollbar-color`/`scrollbar-width` as well as the `::-webkit-*` pseudos,
|
|
and an element with either standard property set ignores its pseudos —
|
|
`scrollbar-color` inherits, so stated on html it gives every scroller the
|
|
native bar, which on Linux/GTK draws a boxed arrow at each end. So the
|
|
standard pair goes only to an engine without the pseudos (Firefox), and the
|
|
pseudos serve the rest. Check 1e holds that.
|
|
The thumb is a --white-* rung, so it does not invert; the `.light`
|
|
restatements sit here rather than in colors.css because a scrollbar is an
|
|
element default and this file is where element defaults live. Everything
|
|
stays INSIDE the layer — an unlayered rule here would outrank an app's own
|
|
utilities, which is the defect check 1c exists to catch. */
|
|
@supports not selector(::-webkit-scrollbar){
|
|
:where(html){scrollbar-color:var(--white-15) transparent;scrollbar-width:thin}
|
|
.light{scrollbar-color:rgb(0 0 0 / .18) transparent}
|
|
}
|
|
:where(*)::-webkit-scrollbar{width:6px;height:6px}
|
|
:where(*)::-webkit-scrollbar-track{background:transparent}
|
|
:where(*)::-webkit-scrollbar-thumb{background:var(--white-15);border-radius:var(--radius-full)}
|
|
:where(*)::-webkit-scrollbar-thumb:hover{background:var(--white-30)}
|
|
:where(*)::-webkit-scrollbar-corner{background:transparent}
|
|
/* The steppers. Styling the other pseudos does not remove them; only this
|
|
does. Zero width AND height because each axis reads one of them. */
|
|
:where(*)::-webkit-scrollbar-button{display:none;width:0;height:0}
|
|
/* `.light` itself as well as its subtree: with the theme class on <html>,
|
|
html's own pseudos draw the page's scrollbar. */
|
|
.light::-webkit-scrollbar-thumb,.light :where(*)::-webkit-scrollbar-thumb{background:rgb(0 0 0 / .18)}
|
|
.light::-webkit-scrollbar-thumb:hover,.light :where(*)::-webkit-scrollbar-thumb:hover{background:rgb(0 0 0 / .32)}
|
|
}
|