{"topic":"surfaces","format":"markdown","content":"# Surfaces & depth\n\nGoal: surfaces that read as physical without shouting. The strongest finding\nfrom polished production UIs: **elevation comes from background steps and\nquiet borders far more often than from heavy shadows.** In light mode a card\ngets a soft line and a faint, wide shadow that read as one; nothing heavier\nunless it floats.\n\n## 1. Page vs. surface\n\nThe page background and card surface are different values — always. One small\nstep apart:\n\n- Light mode: page is a soft gray (~97% lightness), cards are white. The card\n  \"pops\" with no border or shadow at all.\n- Dark mode: **inverted** — cards are *lighter* than the page\n  (e.g. page L≈0.15, card L≈0.20 in OKLCH). **No shadows in dark mode,\n  anywhere**: the lightness step and the edges are the elevation. Every\n  shadow is `light-dark(<light value>, transparent)`.\n\nIf the project renders cards on a same-color background with only a shadow,\nconsider stepping the page background instead — it's calmer and works in dark\nmode for free.\n\n## 2. Borders\n\n- **Edges are one flat hairline, drawn as a shadow.** Fields, menus,\n  panels, separators and chips take `--ui-hairline` (1px, at a faint\n  alpha: a half pixel drew fuzzy on rounded corners) as a\n  `box-shadow`: a ring for a box (`var(--ui-edge)`, or\n  `inset 0 0 0 var(--ui-hairline) <color>`), an inset line on one side for\n  a divider (`inset 0 var(--ui-hairline) 0 <color>` on top,\n  `calc(var(--ui-hairline) * -1)` for the bottom). **Never `border`**: a\n  border takes layout space and snaps differently from the rings beside\n  it, so one box's line drifts from the next. Never a literal `1px` for\n  an edge; always the token. No\n  glints, lighter top edges or glassy double rings: a plain line fits any\n  product the component lands in. The exceptions are deliberate: a\n  focused field's edge is 1px (the weight change is part of the focus\n  cue), and forced-colors outlines stay 1px so they never vanish.\n- **Page layout gets no borders.** Headers, sections, hero bands and\n  gallery tiles separate with space and a change of surface, not lines.\n- **One neutral edge for every line.** A card's frame, a field, a button,\n  a switch's track, a table's row rules, a separator, a key cap: all\n  `--ui-border-strong` (drawn by `--ui-edge`; `--ui-border` is the same\n  value), one solid neutral at the one hairline weight. Two\n  grays side by side read as two thicknesses, a fainter line looks\n  thinner, so there is no lighter \"inner\" tier: a box's insides and its\n  frame are the same line. Depth comes from the shadow under a card, not\n  from a darker outline.\n\n  Beyond that one:\n  - `--ui-field-focus`: a hovered, selected or focused control (or the one\n    selected box in a set, like the current plan).\n  - A colored edge, only for status or a component's own hue:\n    `--ui-border-accent`, `-success`, `-warning`, `-danger`, or a hue (a\n    phase, a brand) mixed at `--ui-border-tint`.\n\n  Nothing else, and no exceptions at rest: a checkbox, a radio, a button\n  and a card all rest on the same edge. No hand-picked alphas, no\n  `color-mix` of the text color for an edge, no second gray: two neutral\n  edges that look almost the same read as two weights. The edge is solid,\n  not the text color at an alpha: a see-through line comes out darker over\n  the page than over a card or a field, so one token drew two lines.\n- **One ring per edge.** `--ui-shadow-resting` and `--ui-shadow-floating`\n  already carry the outline; don't add `--ui-edge` or another hairline ring\n  next to them (`--ui-shadow-raised` has none: pair it with `--ui-edge`).\n  Two rings stack into a heavier line, and an inset ring plus an outer one\n  draws a double edge. Pick a real `border` or a ring\n  `box-shadow` per box, never both.\n- Hover states on bordered elements move one step up the set\n  (strong → field-focus). Polished hover deltas are\n  small — sometimes just a few percent.\n- Dividers: either borders or spacing, not both. A fading hairline\n  (`h-[var(--ui-hairline)] bg-gradient-to-r from-transparent via-border to-transparent`) is a\n  refined divider for centered content.\n- Collapse doubled borders in lists/grids: `border-b -mb-px` or\n  `divide-y` instead of per-item borders.\n- Gradient or animated borders are decoration for one or two hero elements\n  per page, max. The robust technique is the concentric wrapper: outer\n  element carries the gradient as background, inner element insets by 1px\n  (`m-px`) with radius = outer − 1, opaque fill.\n\n### Line weights\n\nOne weight for every line on the page, so nothing reads heavier by accident:\n\n- **Edges and dividers: one hairline.** Box-shadow rings and inset lines at\n  `--ui-hairline`, never `border` (it renders at twice the weight). Where a\n  child's background would cover an inset line (opaque sticky cells, a\n  tinted row), draw the line on a `::after` over the children instead.\n  Exceptions: a focused field's 1px edge, forced-colors outlines, a 2px\n  quote bar, and cut-out rings in the surface color around an overlapping\n  mark (an avatar stack, a corner badge).\n- **Icon strokes match the text they sit with**, in two tiers:\n  `--ui-icon-stroke-regular` beside 400 text, `-medium` beside a 500\n  label. No blends, no third tier (typography.md §6).\n- **Label weight by role**, the same everywhere: buttons, chips, tabs and\n  segments, nav destinations, table headers, field labels and badges at\n  500; body, menu and palette rows, list rows (chats, pages, records), field\n  values and meta text at 400. The current item in a list may step to 500.\n\n`tests/consistency.test.mjs` fails on a `border` edge, a 1px ring outside\nfocus, a literal color, a literal `stroke-width` or a literal icon size\n(typography.md §6, \"Icon sizes\") in any CSS module.\n\n## 3. Shadows — when you do use them\n\n- **An edge drawn outside the box needs room outside it.** A ring\n  (`box-shadow: 0 0 0 var(--ui-hairline)`) is shaved off by any ancestor that clips at the\n  box's own edge. Let the clip reach a hairline further\n  (`clip-path: inset(-1px)`) rather than moving the ring inside, which makes\n  that one panel a hairline narrower than its neighbors.\n- **Light mode: the edge and a soft spread shadow read as one depth.**\n  Three steps, each the outline ring plus a tight contact layer and a wide,\n  very faint ambient one in the same ink, pulled in by a negative spread so\n  it gathers under the box instead of washing sideways. The steps grow in\n  reach, not in darkness at the edge. Dark mode has none (§1).\n  - `--ui-shadow-resting`: cards and panels on the page. Not a box nested\n    inside one: that takes `--ui-edge` alone.\n  - `--ui-shadow-raised`: a popover, a featured card, a hovered card (with\n    `--ui-edge` or a field-focus ring, since it carries no ring).\n  - `--ui-shadow-floating`: menus, dialogs, toasts.\n\nNever write a shadow in a component. Every blurred layer is one of the\ntokens above or `--ui-shadow-control`; a ring or line is `--ui-hairline`,\nthe 1px focus edge, or `--ui-knockout` (the surface-colored ring that cuts\na status dot or a stacked avatar out of what it overlaps).\n`tests/consistency.test.mjs` fails on anything else.\n\n- **Every button sits on `--ui-shadow-control`**, filled or not: the\n  resting shadow's soft spread at a control's scale, starting below the\n  edge, so the bottom line of a button is the same line as its top. No\n  contact shadow tight under the edge (it reads as a thicker bottom\n  border) and no light line along the top. Pressing drops it: the control\n  goes flat as it goes down.\n\n- **Tabs and segmented controls share one look; only their semantics\n  differ.** Both are a clear track inside a hairline edge\n  (`--ui-track-edge`), with one flat pill under the chosen item\n  (`--ui-track-pill`, no shadow, no edge) at `--ui-track-inset` from every\n  side, the same label colors and the same glide between items. A chosen\n  item in a track isn't lifted: it's marked. Pick by meaning (tabs switch\n  panels, a segmented control picks a value), never by look.\n\n- Light-mode ambient opacity lives at 3–16%: wide and faint, never a\n  dark band. Dark mode has none (§1).\n- Nothing dark touches an edge. A shadow layer that starts at the edge\n  (a 1–2px contact shadow) makes the bottom line look thicker than the\n  rest; every token's spread starts below it. Fields, which are typed into\n  rather than pressed, get the hairline alone.\n- Tinted shadows: replace gray with the brand hue at low alpha\n  (`0 24px 64px -24px` in the accent color at ~25%) for hero elements —\n  reads as glow, never as dirt.\n- Don't transition box-shadow on hover; polished UIs change background or\n  border color instead. Shadows are static.\n\n### Opaque where things overlap\n\nAnything that can sit on top of a neighbor (stacked avatars, a folded stack\nof site icons, tiles in a group) has an opaque fill: the surface, with any\ntint mixed into it, never a translucent color over whatever is behind. Dim\na state by fading the mark inside the tile, not the tile.\n\n### Footers in a panel\n\nButtons sit the same inset from every edge of the panel, concentric with its\ncorner. Text in the footer (a step count, an error) lines up with the\ncontent above it instead.\n\n### Code\n\nSyntax colors come from the accent and status text roles, mixed in oklch\nbetween hues and in oklab toward the text color so they stay calm, each at\n4.5:1 on the code background in both themes. Four hues at most (keywords,\nstrings, numbers, types); names stay in the text color, so code reads as\ntext with a few marks. Comments are upright, in the subtle text color.\n\n### Scroll edges\n\nA scroll region that clips content fades only the edge with more past it\n(over `--ui-space-6`), never a hard line, and only while something is\nactually hidden there. A region that follows new content keeps its\nfollowing edge crisp while it follows.\n\n### Phase colors\n\nAn agent's working phases (reading, thinking, writing, a source's stages)\nall take the accent: the phase's name and glyph say which one. Green and\nred keep meaning done and failed, grey paused or stopped. One variable\ncarries the state, and every \"now\" element (the dot, the bar, the running\nstep) reads it. No hue blended from two status hues: a component that\nwants its own color for a phase exposes a variable for the product to set.\n\n### Few colors\n\nEvery text, icon and fill on a page comes from one short list, so a screen\nreads as one system:\n\n- **Text and icons:** `--ui-foreground`, `--ui-muted`, `--ui-subtle`, the\n  four status text roles (`--ui-accent-text`, `-success-text`,\n  `-warning-text`, `-danger-text`), and on a filled control `--ui-on-ink`,\n  `--ui-on-ink-muted`, `--ui-on-accent`. Never the foreground color-mixed to\n  a new strength (82%, 60%): pick the role a step up or down.\n- **Fills:** `--ui-fill` and `--ui-fill-strong` (translucent), the same\n  two opaque as `--ui-fill-solid` and `--ui-fill-strong-solid` where they\n  must hide what's under them, and one tint per status hue\n  (`--ui-accent-tint`, `-success-tint`, `-warning-tint`, `-danger-tint`)\n  behind status text. Not a fourth gray at 3% or 7%, not a status tint at\n  6, 8 or 12%.\n- **Pressed and chosen** states may step past these (16% under a pressed\n  row), but the same step everywhere.\n- Exceptions, named: a chart's series, syntax colors in code, real brand\n  marks, a theme builder's swatches.\n\n`tests/consistency.test.mjs` fails on a text color mixed from the\nforeground and on a hue blended from two status hues.\n\n### A stored theme\n\nSet it with an inline script in `<head>` before paint, on `<html\ndata-theme>`. Anything that shows the theme (a toggle's icon) draws from that\nattribute in CSS until it hydrates, then carries the same state, so nothing\nflips at hydration. A change crossfades the page quickly, with no wipe, and\nswitches at once with reduced motion.\n\n### Color-scheme islands\n\nA region shown in the other theme (a light preview on a dark page) sets\n`data-theme`, not just `color-scheme`: a `light-dark()` token resolves where\nit's declared, so only redeclaring the tokens there gives that region its own\nshadows and tints.\n\n### Real marks for real services\n\nShow a service with its real mark, inline as SVG, unaltered, with its name\nin text beside it; a letter tile only when there's no mark. Invented logos\nfor real-looking services read as fake.\n\n## 4. Radius\n\n- One scale, derived from a single knob where possible:\n  `--radius: 10px` with sm/md/lg/xl as ±2–4px `calc()` offsets. Controls\n  (buttons, chips, badges, pills) very often go fully round — `rounded-full`\n  is the most-used radius in polished products.\n- **Concentric rule, no exceptions: inner radius = outer radius − gap.** A\n  card at 16px containing media inset by 4px gives the media 12px. A menu at\n  14px with 6px padding gives its highlighted row 8px. Nested equal radii\n  look wrong at the corners every time.\n  - Write the relationship, not the result:\n    `border-radius: calc(var(--ui-radius-md) - 6px)`, so changing the outer\n    radius or the padding keeps them lined up.\n  - A pill inside a pill lines up when the inner pill's height equals the\n    outer pill's height minus twice the gap.\n  - If the math lands under ~4px, change the padding or the outer radius;\n    don't square the inner corner off.\n  - On pseudo-element overlays use `border-radius: inherit`.\n- Consistency beats taste: any radius not on the scale is a defect.\n- **The radius says what a thing is.** Pick by role, never per component:\n\n  | Role | Radius | Examples |\n  |---|---|---|\n  | Things you press | full | buttons, segmented controls, tabs, chips, a compact select in a toolbar |\n  | Fields you type in or choose from | md | text field, select, combobox, date, phone, number, each code box |\n  | Boxes, panels and cards | lg (24) | cards, tables, code blocks, diffs, menus, dialogs, command palette, drop zone, composer |\n  | Anything nested | outer − gap | options in a list, a button inside a field |\n\n  A select in a form is a field, so it matches the text field beside it; only\n  the compact toolbar select is a pill, like the buttons around it. A list\n  that opens from a field takes the field's radius, so the two read as one\n  piece. A field that holds round buttons is the exception that follows the\n  concentric rule: its radius is the button's radius plus the gap.\n\n## 5. Color tokens & states\n\n- **One small, fixed palette:** neutrals, ink, one accent, and three status\n  tones (success, warning, danger). Nothing else gets a hue.\n  - Tune each theme on its own. One value for both sits at a mid\n    lightness and modest chroma: gray on white, dull on black. In each\n    theme take the most saturated value that stays in sRGB at a lightness\n    holding 3:1 for fills, lines and icons and 4.5:1 for text: on light,\n    fills around oklch L 0.55–0.67 and text around 0.5; on dark, fills\n    0.6–0.78 and text 0.74–0.85. Keep a hue deeper wherever white text sits\n    on it (accent, danger). Tints are the hue at 10% (16% in dark): the `--ui-*-tint` tokens.\n  - Ink (`--ui-ink`, the text color as a fill) is for the strongest action:\n    primary buttons, send, tooltips.\n  - Accent (`--ui-accent`) is a real accent: used sparingly, only where the\n    eye must find an on or chosen state among neutrals. A switch that's on,\n    a checked box or radio, the selected day, a slider's range (a chosen value,\n    like a switch that's on). Progress bars, meters, borders and bands stay\n    neutral. A knob (a switch or slider thumb) is the raised surface in light\n    and a light gray in dark: a knob the color of the surface reads as a hole\n    in a dark track. If two accented things compete on one screen, one of\n    them shouldn't be.\n  - Status tones only ever mean that: success, needs attention (warning,\n    amber), failed (danger).\n  - One quiet touch takes it too, since it only shows while you act:\n    selected text, as a pale tint. The caret stays the text color.\n- Derive interaction states instead of inventing colors:\n\n```css\n.row:hover  { background: color-mix(in srgb, var(--foreground) 6%, transparent); }\n.row:active { background: color-mix(in srgb, var(--foreground) 10%, transparent); transition-duration: 0s; }\n```\n\n  Hover ~5–8% of foreground, active ~10% — and active applies instantly\n  (release eases back).\n- Semantic tokens (`--background`, `--surface`, `--muted`, `--border`,\n  `--ring`, `--destructive`) beat raw palette references in components. If\n  the project has semantic tokens, never bypass them with a raw hex.\n- Dark mode adjustments that are commonly missed: destructive/error colors\n  get *lighter and less saturated* in dark mode; low-alpha error tints need\n  roughly double the alpha to stay visible.\n- The \"alert tint\" trio for banners/badges of any status color:\n  30%-alpha border + 10%-alpha fill + solid text in the status color.\n\n## 6. Overlays, without blur\n\nBlur is the most overused effect in generated UI. It smears text behind it,\ncosts a compositing layer per element, and dates fast. Default to none.\n\n- Floating surfaces (menus, popovers, toasts, sheets) are opaque: the\n  surface color, a hairline and a shadow. They read as above the page\n  by shadow, not by frosting what's under them.\n- Modal scrims: a plain dim, roughly black at 25–30% in light mode and\n  50–55% in dark. No `backdrop-filter`.\n- Motion never blurs: letters, words and icons enter and leave with opacity\n  and a small translate, not `filter: blur()`.\n- Edge fades on scrollable rows are masks, not blur:\n  `mask-image: linear-gradient(to right, transparent, black 10%, black 90%, transparent)`.\n- If a design truly needs glass (media behind a control), one layer per\n  screen, never nested, and the text on it must pass contrast without it.\n\n## 7. Headers\n\nDefault to a header that scrolls away with the page. A sticky bar costs\nreading height on every screen for links people rarely need mid-page. Make a\nheader sticky only when it holds controls used while scrolling (a document\ntoolbar, filters). Then: the page's own background color, solid, no line\nunder it, no blur, and its height published as `--header-height` for\n`scroll-padding-top` and sticky offsets below it.\n\n## 8. Scrollbars & small chrome\n\n- Custom scrollbars only inside app panes (chat, code, file trees): thin\n  (4–8px), transparent track, subtle thumb with a hover step, plus the\n  standard `scrollbar-width: thin; scrollbar-color: ...` for Firefox.\n  Leave the page scrollbar alone.\n- Hide-scrollbar utilities need all three properties\n  (`::-webkit-scrollbar { display: none }`, `scrollbar-width: none`,\n  `-ms-overflow-style: none`) and must only be used where scrolling is\n  visually obvious without the bar.\n- Decorative images: `pointer-events-none select-none draggable=\"false\"`.\n- Icons inherit `currentColor`; inside buttons, `[&_svg]:shrink-0` and\n  pointer-events none. Keep one stroke weight across the icon set.\n\n## 9. Restraint checklist\n\n- Gradients, glows, conic borders, textures: at most one or two attention\n  moments per screen. If every card glows, none does.\n- No pure-black shadows at high opacity in light mode; in dark mode the\n  one edge is a neutral a step lighter than the surface, never a dark gray.\n- Elevation levels in the app: aim for three, not seven — resting, raised\n  (dropdown/popover), overlay (modal). Each maps to one consistent\n  surface + border + shadow combination.\n"}