Files
design/styles.css
zeekay 96c95b31ed Arabic: the companions from @hanzo/font 1.9.4, --font-arabic first in every stack, and an arabic axis; 0.5.37
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.
2026-10-22 19:34:37 -07:00

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)}
}