Files
op-packages/luci-theme-footstrap/styles/02-tokens.css
T

533 lines
33 KiB
CSS

@layer tokens {
/* Design tokens, in two tiers, and the split is load-bearing:
*
* PRIVATE --fs-* every rule in this theme reads only these
* EXPORT --*-color-* LuCI's conventional names, defined FROM the private tier and read
* by nobody here — they exist so a third-party luci-app-* keeps
* working and keeps following the palette and dark mode
*
* :root is a shared scope and every luci-app-* drops CSS into the same document, unlayered,
* which outranks every cascade layer. An app declaring --accent or the conventional
* --text-color-high, which base itself once read 158 times, repaints the theme: 312 of 336
* gallery elements with a hostile :root before the split, 0 after. The bridge is outward-only,
* and tools/audit.py fails on any read of an export name inside styles/.
*
* docs/design-system.md. Light = :root; dark = :root[data-darkmode="true"]. */
:root {
/* Palette colours are in 03-palettes.css, one swappable block per colourway x mode;
* everything here is palette-independent or var()-derived. The inks (--fs-on-accent and
* friends) live in those blocks too, beside the fills they must be legible against: a
* dark palette has light fills and needs a dark ink, and one global `#fff` failed WCAG AA
* on seven of the eight dark-palette fills (down to 1.69:1). */
--fs-shadow: 0 1px 3px rgba(15,23,37,.08);
/* pop surfaces (menus, dropdown lists, the select picker): one value rather than a
* literal per site */
--fs-shadow-pop: 0 12px 34px rgba(0,0,0,.30);
/* the top bar's edge: a drop below plus a 1px inset seam, both a quiet 25% of the border
* colour */
--fs-shadow-bar: 0 1px 3px color-mix(in srgb, var(--fs-border) 25%, transparent),
inset 0 -1px 0 color-mix(in srgb, var(--fs-border) 25%, transparent);
/* ---- two focus rings, and which one a control takes is the contract ----
* -soft is a TINT (10-15% of the accent), measuring 1.15-1.29:1 on the surface it lands
* on: a glow, not an indicator, where WCAG 1.4.11 wants 3:1. It is legitimate only beside
* a second channel — every field taking it also flips border-color to --fs-accent, which
* measures 4.57-7.04:1 across palette x mode x surface, so the border is the indicator and
* the tint is the halo.
*
* -solo is for a control where the ring is the WHOLE indicator and nothing else changes on
* focus: the sidebar menu links and chrome icon buttons, the section tabs, both range
* sliders. Its 2px of surface is what lets it read against a FILLED control, where an
* accent ring would otherwise sit on an accent box; solid accent then measures 5.19-7.04
* on panel and 4.61-7.55 on the canvas.
*
* The invalid twin takes -fill (18%), not -soft: a red ring must read as an alarm rather
* than a hover tint, and it is paired with a red border. */
--fs-focus-ring: 0 0 0 3px var(--fs-accent-soft);
--fs-focus-ring-solo: 0 0 0 2px var(--fs-panel), 0 0 0 5px var(--fs-accent);
--fs-focus-ring-invalid: 0 0 0 3px var(--fs-danger-fill);
/* The chosen-row rail: a chosen row says so with an opaque surface and an inset bar, never
* with a tint of the accent under accent text (4.21:1 in dark — theme/65-dropdown.css).
* Named because it is written in three places that cannot be one rule: the open dropdown's
* row, a native `select option:checked`, and the ring the focused row wears on top. The
* first two are pinned to each other by tools/mirror.mjs. */
--fs-row-rail: inset 2px 0 0 var(--fs-accent);
/* The hairline: every separator in this theme is the same one-pixel line in the same
* border colour, so it is stated once rather than in 69 longhand declarations that would
* each have to change for a 2px high-contrast line or a softer colour. The shorthand is
* the token, so `border`, `border-top` and friends all take it. */
--fs-hairline: 1px solid var(--fs-border);
/* a field reacting to focus: the ring arrives and the edge changes colour together, and a
* transition list that drifts apart shows the border moving before the ring */
--fs-field-transition: border-color var(--fs-dur), box-shadow var(--fs-dur);
/* the theme's one colour literal, deliberately: black at .7 is the absence of light behind
* a dialog, not a tint of any token — a palette-tinted scrim over a dark page dims
* nothing. A token, so the rules stay literal-free. */
--fs-scrim: rgba(0, 0, 0, .7);
/* ---- role tint ladder (derived) ----
* A tint OF a role, mixed FROM it, so it follows the palette with nothing hand-maintained.
* Unnamed, these levels drift: the same outline border was 40% in a table and 45% in the
* action bar, the same hover fill 12% in one file and 18% in another.
*
* -soft 12% a quiet FILL — outline-button :hover
* -fill 18% a stronger FILL — callout/diff surface, the invalid ring
* -line 40% a HAIRLINE — outline-button border, diff border
* -line-hi 55% that hairline on :hover
*
* The role x step matrix is complete on purpose, read or not: a gap is how the drift
* starts, since a rule wanting a level that does not exist invents a percentage.
* --fs-accent-soft is the one member not here — its strength differs per MODE (10% light,
* 15% dark; hicontrast 10/14) — so it sits in the palette blocks. */
--fs-good-soft: color-mix(in srgb, var(--fs-good) 12%, transparent);
--fs-warn-soft: color-mix(in srgb, var(--fs-warn) 12%, transparent);
--fs-danger-soft: color-mix(in srgb, var(--fs-danger) 12%, transparent);
--fs-accent-fill: color-mix(in srgb, var(--fs-accent) 18%, transparent);
--fs-good-fill: color-mix(in srgb, var(--fs-good) 18%, transparent);
--fs-warn-fill: color-mix(in srgb, var(--fs-warn) 18%, transparent);
--fs-danger-fill: color-mix(in srgb, var(--fs-danger) 18%, transparent);
--fs-accent-line: color-mix(in srgb, var(--fs-accent) 40%, transparent);
--fs-good-line: color-mix(in srgb, var(--fs-good) 40%, transparent);
--fs-warn-line: color-mix(in srgb, var(--fs-warn) 40%, transparent);
--fs-danger-line: color-mix(in srgb, var(--fs-danger) 40%, transparent);
--fs-accent-line-hi: color-mix(in srgb, var(--fs-accent) 55%, transparent);
--fs-good-line-hi: color-mix(in srgb, var(--fs-good) 55%, transparent);
--fs-warn-line-hi: color-mix(in srgb, var(--fs-warn) 55%, transparent);
--fs-danger-line-hi: color-mix(in srgb, var(--fs-danger) 55%, transparent);
/* The warn row has no reader today and is kept on purpose: LuCI emits no warning BUTTON,
* so only -fill is consumed. The row is what makes the next component reach for a token
* instead of mixing its own percentage. A grep finding no `var(--fs-warn-line)` is
* expected. */
/* frosted pop surface (top-nav dropdown, rail flyout), deliberately not merged into
* --fs-bar-bg (88%): a bar is a thin strip the page ghosts through on purpose, while a
* dropdown carries a whole menu and must stay readable */
--fs-glass: color-mix(in srgb, var(--fs-panel) 96%, transparent);
/* one blur radius for every frosted surface — the bar said 10px and the pops 12px */
--fs-blur: blur(12px);
/* The circular-arrows refresh glyph, as a mask so its colour follows the palette
* (--fs-good active, --fs-dim paused). One copy: the poll pill collapses to it both in the
* rail and in the bar's compact cluster. A static glyph on purpose — a spinner makes an
* idle poll look busy and moves the click target that pauses it.
*
* OURS, and redrawn to keep it so. It was derived from Lucide's `refresh-cw` (ISC) —
* `M21 3v5h-5` was byte-identical — which is a second outside licence obligation this theme
* never declared. Redrawn as two OPEN arcs with solid triangular heads: a different
* construction, not a nudge of the same one (Lucide caps a continuous stroke with an
* L-shaped hook). Solid heads rather than chevrons because chevrons dissolve at the 18px
* this renders at. */
--fs-icon-refresh: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M4.4 9.6A8.5 8.5 0 0 1 18.6 7.6'/%3E%3Cpath d='M19.6 14.4A8.5 8.5 0 0 1 5.4 16.4'/%3E%3Cpath fill='%23000' stroke='none' d='M21.6 10.6 15.4 8.4 20.4 4.2Z'/%3E%3Cpath fill='%23000' stroke='none' d='M2.4 13.4 8.6 15.6 3.6 19.8Z'/%3E%3C/svg%3E");
/* …and the size it is drawn at, which css-dup cannot police for a glyph inlined once and
* sized at two call sites. The two sites legitimately differ in technique — the rail masks
* the element and drops the border, the bar keeps the bordered square — but not in size. */
--fs-icon-refresh-sz: calc(18px * var(--fs-density-type));
/* the 1px raised edge on a plate carrying text, plus its text shadow */
--fs-emboss: inset 0 1px 0 color-mix(in srgb, var(--fs-panel) 5%, transparent);
--fs-text-emboss: 0 1px 1px color-mix(in srgb, var(--fs-panel) 75%, transparent);
/* Hover cue for a solid control — the one cue that cannot be a colour, the fill it shifts
* being whatever the role token says. Per mode, and the DIRECTION FLIPS: `filter`
* recolours the text too, and a light-mode solid button is a saturated fill with white
* ink, which cannot get brighter — brightening drops white on --fs-accent from 5.19:1 to
* 4.59:1 at 1.08 and to 4.08:1 at a visible 1.15, an AA failure caused by hovering. So
* light darkens (.90 lands at 4.94:1, still clear of the 4.5 floor) and dark, whose fill
* is light and ink dark, brightens (1.15). */
--fs-hover-lift: brightness(.90);
/* ---- motion: four durations, because the UI does four things ----
* There is deliberately no easing token: every transition and animation omits the timing
* function and takes the CSS default (ease), which is one curve, nothing to keep in sync
* and fewer bytes than naming it. A rule needing another curve is making a design decision
* and must justify it in a comment.
*
* The scale governs `animation` too, not only `transition`: one gesture ridden at three
* durations is drift css-dup cannot see, the declaration bodies differing.
* 95-a11y-media.css kills all of this under prefers-reduced-motion. */
--fs-dur: .15s; /* a state change: colour, border, shadow, background, filter */
--fs-dur-move: .2s; /* something that moves or unfolds: transform, max-height */
--fs-dur-fade: .25s; /* a transient thing fading in or out: spinner, notification */
--fs-dur-fill: .4s; /* a progress bar growing to its value */
--fs-content-max: 1280px; /* content column cap */
/* content column side padding, read by .fs-content, the top bar's centering calc and the
* notification width calc, so the three cannot drift */
--fs-content-pad: calc(28px * var(--fs-density-space));
/* The shell's geometry: tokens, not literals, because the JS reads them back — the CSS
* lays the sidebar out and fs-chrome.js's fitShell() subtracts that cut from the viewport
* to decide whether what is left is readable. With copies in JS, narrowing the rail here
* leaves the measurement subtracting the old width. */
--fs-sidebar-w: calc(224px * var(--fs-density-box)); /* the expanded vertical sidebar's column */
--fs-rail-w: calc(68px * var(--fs-density-box)); /* ...and the same sidebar collapsed to its icon rail */
--fs-content-min: 500px; /* below this a content column stops being readable */
/* translucent blurred bar surface: the top-nav bar and the sidebar's phone bar are one
* component in two markups, so one token keeps their glass tint identical */
--fs-bar-bg: color-mix(in srgb, var(--fs-panel) 88%, transparent);
/* height of that bar, and the scroll-margin every anchor target needs to clear it
* (base/10-reset.css): if the two drift, an in-page jump lands under the bar */
--fs-bar-h: calc(46px * var(--fs-density-box));
/* the square icon button in the bar and rail: the logo box, the .fs-themerow controls,
* Log out and the collapsed poll pill are one size, so the right cluster reads as a row
* of equal squares */
--fs-btn-size: calc(34px * var(--fs-density-box));
/* Corner-radius scale: one user-tunable base (Appearance -> Rounding, 0-20px, pre-painted
* on :root by partials/head.ut) with three semantic radii scaling from it proportionally,
* so every surface rounds in step and pills stay round. At base 12: card 12, control 10,
* chip 8. Every border-radius in theme/ and pages/ reads one of these; the only bare px
* left are 0, 50% and three that are not a card's corner at all. */
--fs-radius-base: 12px;
--fs-radius-lg: var(--fs-radius-base); /* cards, panels, modals, table frame, popovers */
--fs-radius: calc(var(--fs-radius-base) * 5 / 6); /* controls: inputs, buttons, dropdowns, tabs (10 @12) */
--fs-radius-sm: calc(var(--fs-radius-base) * 2 / 3); /* chips, code, small insets, rail toggle (8 @12) */
--fs-radius-pill: 999px; /* pills, toggles, badges — always round */
/* A card's inner gutter (.cbi-section). A token because it is read back to UNDO it:
* theme/45-misc.css bleeds the realtime graphs out to the card's border edge by this
* padding plus the 1px border, so a literal in both places would re-clip every graph when
* the gutter narrows. */
--fs-card-pad: calc(16px * var(--fs-density-space));
/* the vertical rhythm between cards (.cbi-section, tab groups, alerts, action bars):
* an unnamed level drifts, so it is stated once */
--fs-card-gap: calc(16px * var(--fs-density-space));
--fs-font-sans: "Manrope", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--fs-font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
/* ---- type scale ----
*
* 13px is the base deliberately: a router UI is dense tables of addresses and counters, and
* the size was chosen for that. The rhythm around it is a unitless 1.5 rather than a hard
* 18px — `line-height: 18px` is inherited as 18px into a 20px heading and clips it, while
* 1.5 is re-resolved against each element's own size.
*
* Every step is a whole px; the line box 1.5 computes is not (13 -> 19.5), which is what
* unitless leading gives on an odd size. What must stay whole is a BOX: never size an
* element `--fs-type * --fs-leading` — take the nearer --fs-space step.
*
* Five steps, and the count is the point: a scale with 1px gaps builds no hierarchy, it
* just makes two rules look unrelated in a grep. Ratio is roughly 1.25 per step, and
* anything needing a size off this list is making a design decision and should say so. */
/* ---- density: one axis, three multipliers ----
*
* Compact / Normal / Large is a client preference stamped as :root[data-density]; absent
* means Normal, so the default costs no attribute and no override.
*
* Three multipliers, because one knob gets all three kinds of size wrong:
*
* --fs-density-type TEXT, moving least (.9 / 1.15). Compact is a request for less air,
* not for smaller writing; .9 puts the base at 12px, which is the
* floor.
* --fs-density-space pure AIR — padding, margin, gap — which is what buys the room, so
* it moves most (.65 at Compact). It does not grow at Large, where
* inflating the air just reads as zoom.
* --fs-density-box boxes that must HOLD text or an icon: the sidebar, the rail, the
* bar height, the icon buttons, the control-height floors. They
* cannot follow the air — the type gives up only a tenth, so a
* sidebar cut by 35% no longer fits the labels it exists to show.
*
* This is only expressible because every size in the theme reads one of these tokens: a
* size written outside the ladders silently stops responding to the axis.
*
* Type is rounded to whole pixels, a fractional font size rendering soft; space is not,
* since a sub-pixel padding is invisible and rounding it would collide steps together. */
--fs-density-type: 1;
--fs-density-space: 1;
--fs-density-box: 1;
/* ---- control heights: three steps, following the density ----
* A minimum height stops a button collapsing onto its text, so it is part of how dense the
* UI feels: left literal, Compact shrinks the writing while the buttons stay put.
* Not --fs-control-h below, which is a FIELD's height derived from the text it must hold;
* these are the floors a chrome control gets when its content is not text. */
--fs-ctl-h-sm: calc(30px * var(--fs-density-box)); /* dense/small: .btn.small, table-row buttons */
--fs-ctl-h: calc(38px * var(--fs-density-box)); /* the default control */
--fs-ctl-h-lg: calc(44px * var(--fs-density-box)); /* prominent: .btn.large, the login submit */
/* Unrounded here, rounded in the @supports below, and the split is the difference between
* a soft failure and no page: `round()` is the youngest thing this theme depends on
* (Chromium 125, Firefox 118, Safari 15.4), and a custom property holds any token stream,
* so an engine without it parses these fine and fails at SUBSTITUTION — `var(--fs-type)`
* is then invalid at computed-value time and computes to `unset`, taking every font-size,
* line-height and field min-height with it. Measured by serving a sheet with `round(`
* renamed: 5881 of 5912 elements on Overview change, body type goes 13px -> 16px, and 5880
* font-size and 5733 line-height declarations are lost. Declared this way, such an engine
* keeps the whole ladder and pays a fractional pixel at the two non-default densities. */
/* One step BELOW the ladder's smallest, and it exists for one measured reason: the Port
* status card's traffic figures. They are the widest thing a card must fit whole, so their
* size is what sets how narrow a card may be — at --fs-type-xs the floor is 106px, at 10px
* it is 94px, which is what turns ten cards plus a lonely eleventh into one row of eleven.
* A figure read as a pair of numbers beside a label tolerates the step that body text would
* not. Declared here rather than as a literal in the page rule so the density axis stays in
* one place. */
--fs-type-2xs: calc(10px * var(--fs-density-type));
--fs-type-xs: calc(11px * var(--fs-density-type)); /* eyebrow, badges, tooltips, dense metadata */
--fs-type: calc(13px * var(--fs-density-type)); /* body, tables, fields, menu items — the base */
--fs-type-lg: calc(16px * var(--fs-density-type)); /* section titles (h3/legend), alert headings */
--fs-type-xl: calc(20px * var(--fs-density-type)); /* page titles */
--fs-type-2xl: calc(26px * var(--fs-density-type)); /* h1 — only a third-party app emits a bare one */
/* ---- WEIGHT: there are only ever three, and they had six spellings ----------
* The theme ships no font at all — the stacks name Manrope and JetBrains Mono first and
* fall through to the system faces — so these are not a ramp to pick from: they are the
* two weights a reader is likely to actually have, plus the "un-bold this"
* value. `700` and `bold` were both in use and are the same weight; so are `400` and
* `normal`, and 400 has no face at all (it resolves onto the 600 one — that is why body
* text is semibold by design, and why adding a 400 face would silently restyle every page).
* Naming them leaves one spelling each. */
--fs-weight-normal: 400; /* "not bold" — no face of its own; resolves onto the 600 */
--fs-weight: 600; /* the UI default: body, labels, controls */
--fs-weight-bold: 700; /* titles, table headers, the eyebrow */
--fs-leading: 1.5; /* unitless ON PURPOSE — see above (13 -> 19.5px) */
--fs-leading-tight: 1.2; /* headings and single-line plates, where 1.5 looks loose */
/* ---- THE EYEBROW — one microlabel idiom, named once -------------------------
*
* Uppercase + tracked + bold + muted is ONE thing this theme draws in a dozen places: table
* column headers, the stacked-card `data-title` labels, the Appearance group labels, the rail
* flyout titles, the login field titles, the search-result section note. It had drifted into
* four spellings of that one idiom — letter-spacing .04/.05/.06em, weight 600 vs 700, colour
* --fs-dim vs --fs-faint — the same unnamed-level drift the role x step matrix above exists to
* stop, only in typography. An eyebrow is a LABEL, so it takes the quietest ink and the widest
* tracking of the set; the alert table's 600 was the odd one out and read lighter than every
* other header on the page.
*
* SIZE is deliberately NOT here: 10px (a card/chrome label) and 11px (a table header, i.e.
* --fs-type-xs) are two real tiers, and collapsing them would resize half the tables.
*
* Two things that look like eyebrows are NOT and must stay out: `h6` is a HEADING (it takes the
* type scale and the heading ink), and `.fs-navlabel` is the menu's section separator, whose
* .1em is deliberately wider than any label — it separates, it does not caption. */
--fs-eyebrow-tracking: .06em;
--fs-eyebrow-weight: 700;
--fs-eyebrow-color: var(--fs-faint);
/* ---- SPACE SCALE — a 4px grid ----------------------------------------------
*
* Same story: the 18px/9px pair base used was upstream's line-height and half of it, so
* every gutter in the theme was a function of Twitter's leading. A 4px grid is independent
* of the type scale, which is the point — changing the font size must not silently
* re-space every widget. `--fs-card-pad` (16px) and `--fs-content-pad` (28px) are already
* on this grid and stay named for their role; these are the generic steps.
*
* Do NOT add a step because one rule wants 10px. An unnamed level is how the radius tints
* drifted into four strengths for two decisions (see the ladder above): pick the nearer
* step, or argue in a comment for why this rule is genuinely different. */
/* The grid is 4px, but the theme genuinely works at 2px between the low steps: a chip's
* inset, a nav item's row padding and a card's inner gutter all landed on 6/10/14 and no
* amount of rounding makes them 4/8/12 without visibly changing the control. So the half
* steps are NAMED rather than pretended away — they were the single largest group of
* magic numbers left in the tree (6px x26, 10px x32, 14px x13, 2px x13). Above 16px the
* grid stays 4px: nothing needs finer, and every value that had drifted between the steps
* up there (22, 26, 42) was snapped onto one. */
--fs-space-0-5: calc(2px * var(--fs-density-space));
--fs-space-1: calc(4px * var(--fs-density-space));
--fs-space-1-5: calc(6px * var(--fs-density-space));
--fs-space-2: calc(8px * var(--fs-density-space));
--fs-space-2-5: calc(10px * var(--fs-density-space));
--fs-space-3: calc(12px * var(--fs-density-space));
--fs-space-3-5: calc(14px * var(--fs-density-space));
--fs-space-4: calc(16px * var(--fs-density-space));
--fs-space-5: calc(20px * var(--fs-density-space));
--fs-space-6: calc(24px * var(--fs-density-space));
--fs-space-7: calc(28px * var(--fs-density-space));
/* The top of the ladder is sparse on purpose — these three are gutters and insets, not
* rhythm, and each has two or three users: the sidebar accordion's indent and the search
* palette's bottom padding (-7), the room a field leaves for its own chevron (-8), the login
* card's bottom margin and the gauge's two right gutters (-10). They are named so that no
* padding in this theme is an unexplained number, not because a fourth caller is expected. */
--fs-space-8: calc(32px * var(--fs-density-space));
--fs-space-10: calc(40px * var(--fs-density-space));
/* the spinner glyph's box (base/95-luci.css `.spinning::before`, width and height): one
* number, one name, read in one place */
--fs-spin-size: var(--fs-space-5);
/* One control height for every single-line field — input, select, .cbi-select,
* .cbi-dropdown and the checkbox/radio row beside them. Derived rather than measured by
* hand: the text box (--fs-type x --fs-leading = 19.5px) plus the vertical inset those
* fields carry (2 x --fs-space-1) plus both 1px borders = 29.5 -> ceil 32 at the default
* density. A literal here is arithmetic over one particular leading, so
* re-scaling the type silently leaves every control the wrong height for its own text. */
--fs-control-h: calc(var(--fs-type) * var(--fs-leading) + var(--fs-space-1) * 2 + 2px);
/* How wide an elastic control gets in a form row. A `.cbi-value-field` is `flex: 1`, so
* anything asking for 100% takes the whole card, and a form whose controls each end at a
* different x reads as broken. Fixed-width controls keep their own 210px; this is the
* ceiling for the ones that stretch. It is a COLUMN width, like the label column beside
* it, so it is deliberately not on the 4px step scale. */
--fs-field-max: 440px;
/* ---- stacking scale ----
* Every z-index in the theme comes from here. Bare numbers across seven files record
* nothing about what should sit above what, which is how a popover came to paint over an
* open modal; as an ordered list that is obvious.
*
* Bottom to top:
* raise in-flow child that must clear its siblings (zonebadge tooltip)
* sticky the sticky bars: top-nav, sidebar phone bar
* flyout nav panels: rail flyouts, top-nav dropdowns, rail tooltips
* header base's sticky <header> (above content, below any popup)
* popover the Appearance popover — ABOVE the chrome, BELOW a modal
* overlay #modal_overlay: a modal is modal, it covers the chrome
* tooltip .cbi-tooltip — must read over modal content
* dropdown an OPEN .cbi-dropdown list — over everything, including inside a modal
* ------------------------------------------------------------------------ */
--fs-z-raise: 2;
--fs-z-sticky: 50;
--fs-z-flyout: 70;
--fs-z-header: 800;
--fs-z-popover: 850;
--fs-z-overlay: 900;
--fs-z-tooltip: 1000;
--fs-z-dropdown: 1100;
/* ---- export tier: outbound only ----
* Nothing in styles/ may read these (tools/audit.py fails the build if it does). They
* exist so a third-party luci-app-* addressing them still gets our palette and dark mode,
* and so an app overwriting one restyles only its own widgets.
*
* high/medium/low is a PROMINENCE scale, not decoration: apps do ask for the gradation —
* one paints its "no data" latency with --primary-color-low, another reads eleven of these
* names, and stock firewall.js and status/cpu.js read them too — so three aliases of one
* token hand back a flat colour.
*
* Direction is bootstrap's, which every app was calibrated against: `high` is the most
* pronounced value of the role, except --background-color-*, which is an ELEVATION axis
* (high = raised). The mechanism differs per family, by measurement — see each block.
*
* Binding constraint: apps read a level as `color:` about as often as as `background:`, so
* every level must clear WCAG AA as text on --fs-bg/--fs-panel/--fs-panel2 and carry a
* legible --on-*-color as a fill. */
--background-color-high: var(--fs-panel);
--background-color-medium: color-mix(in srgb, var(--fs-panel) 55%, var(--fs-panel2));
--background-color-low: var(--fs-panel2);
/* -low is --fs-faint, the theme's own third ink, and not a duplicate of -medium: making the
* two the same token flattens the export (`spread(high, low)` falls to 0.082 in the
* default palette, under the 0.10 the ramp promises) and hands an app asking for a
* gradation one colour twice. Legibility is measured rather than assumed — every level is
* held to AA as text on all three surfaces and as a fill under its --on-* ink, in both
* modes, across the whole palette x tint matrix. */
--text-color-highest: var(--fs-text);
--text-color-high: var(--fs-text);
--text-color-medium: var(--fs-dim);
--text-color-low: var(--fs-faint);
/* borders carry no text, so -low may fade toward the surface: a hairline is supposed to
* disappear into it */
--border-color-high: color-mix(in srgb, var(--fs-border) 60%, var(--fs-text));
--border-color-medium: var(--fs-border);
--border-color-low: color-mix(in srgb, var(--fs-border) 55%, var(--fs-panel));
/* Accent ramps: the axis is CHROMA at constant lightness — `high` is the pure palette
* colour and each step mixes it toward --fs-dim in oklch, which interpolates lightness
* linearly and so drains saturation while leaving luminance put, keeping every level
* printable as text. Both alternatives were measured and rejected:
*
* fading `low` toward the surface spends contrast we do not have — in dark every accent
* already sits at 4.56-4.59:1, and an 8% fade took one level to 4.18:1;
*
* pushing `high` toward --fs-text collapses the ramp in dark, where --fs-text is
* near-white: the strong end comes back 0.055 from the weak end, i.e. flat.
*
* Bootstrap's own ramp walks lightness, which is why it reads inverted in dark mode and
* lands at 3.6:1. Keep its direction, drop its mechanism. Every step is mixed FROM the
* token it grades, so there is no hand-kept second copy of a colour, and
* tools/export-tier.mjs proves the whole tier across palette x mode. */
--primary-color-high: var(--fs-accent);
--primary-color-medium: color-mix(in oklch, var(--fs-accent) 78%, var(--fs-dim));
--primary-color-low: color-mix(in oklch, var(--fs-accent) 45%, var(--fs-dim));
--on-primary-color: var(--fs-on-accent);
--error-color-high: var(--fs-danger);
--error-color-medium: color-mix(in oklch, var(--fs-danger) 78%, var(--fs-dim));
--error-color-low: color-mix(in oklch, var(--fs-danger) 45%, var(--fs-dim));
--on-error-color: var(--fs-on-danger);
--success-color-high: var(--fs-good);
--success-color-medium: color-mix(in oklch, var(--fs-good) 78%, var(--fs-dim));
--success-color-low: color-mix(in oklch, var(--fs-good) 45%, var(--fs-dim));
--on-success-color: var(--fs-on-good);
--warn-color-high: var(--fs-warn);
--warn-color-medium: color-mix(in oklch, var(--fs-warn) 78%, var(--fs-dim));
--warn-color-low: color-mix(in oklch, var(--fs-warn) 45%, var(--fs-dim));
--on-warn-color: var(--fs-on-warn);
--fs-disabled-opacity: .6;
color-scheme: light;
}
/* ---- the rounded half of the type ladder, for engines that can round ----
*
* A fractional font size renders soft, so every size is meant to land on a whole pixel — but a
* token using `round()` on an engine without it is not a fallback, it is a deleted token that
* takes every rule reading it (see the ladder above). So the ladder is declared plainly there
* and rounded here, where the at-rule can be asked first. --fs-control-h is derived from
* --fs-type and rounds UP to the 4px control ladder for the same reason.
*
* `@supports (width: round(1px, 1px))` rather than a `calc()` probe: the function has to be
* tested where a length is expected. */
@supports (width: round(1px, 1px)) {
:root {
--fs-type-2xs: calc(round(10px * var(--fs-density-type), 1px));
--fs-type-xs: calc(round(11px * var(--fs-density-type), 1px));
--fs-type: calc(round(13px * var(--fs-density-type), 1px));
--fs-type-lg: calc(round(16px * var(--fs-density-type), 1px));
--fs-type-xl: calc(round(20px * var(--fs-density-type), 1px));
--fs-type-2xl: calc(round(26px * var(--fs-density-type), 1px));
--fs-control-h: calc(round(up, calc(var(--fs-type) * var(--fs-leading) + var(--fs-space-1) * 2 + 2px), 4px));
}
}
/* ---- the density axis ----
*
* Normal is a bare :root, so the default path costs no attribute and no override.
*
* These multiply the LADDERS, not the rules: a size on a ladder follows the axis for free and
* one that is not silently ignores it. */
:root[data-density="compact"] {
/* type moves least (.9 against .65 for the air): padding is where the room is, so leaning
* on the text would cost legibility to save very little. At .9 the base is 12px and the
* eyebrow 10px, which is the floor. */
--fs-density-type: .9;
--fs-density-box: .85;
--fs-density-space: .65;
}
:root[data-density="large"] {
/* Large is about the TEXT, so the air does not grow with it: only the type and the boxes
* that hold it get bigger. Inflating the spacing too pushes content off the screen and
* makes the setting read as zoom, which is the browser's job. */
--fs-density-type: 1.15;
--fs-density-box: 1.15;
--fs-density-space: 1;
}
:root[data-darkmode="true"] {
/* palette colours are in 03-palettes.css; only mode-level, palette-independent
* tokens live here */
--fs-shadow: 0 1px 2px rgba(0,0,0,.4);
--fs-disabled-opacity: .45;
/* the hover cue flips direction here (see --fs-hover-lift): a dark-mode solid button is a
* light fill with dark ink, so brightening moves it away from both the page and its own
* ink */
--fs-hover-lift: brightness(1.15);
color-scheme: dark;
}
/* No component bridge in any notation (--*-hsl triples, --*-rgb read as `rgba(var(--x), .3)`):
* a hand-kept second copy of a colour goes stale in silence when a palette is recoloured, and
* a missing triple makes the declaration invalid at computed-value time, so the tint vanishes
* with no error tools/audit.py can catch — the var IS defined, only elsewhere. A tint of X is
* mixed FROM X, and a component copy must not be reintroduced in any notation:
* rgba(var(--error-color-high-rgb), .3) -> color-mix(in srgb, var(--fs-danger) 30%, transparent)
* The one surviving --*-rgb is --zone-color-rgb, which is not ours: luci-mod-network writes it
* inline on a zone badge (base/95-luci.css). */
}