Files

300 lines
19 KiB
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)}
}