mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-09-13 20:04:45 +08:00
581 lines
39 KiB
CSS
581 lines
39 KiB
CSS
@layer tokens {
|
||
/* ============================================================
|
||
* Design tokens. 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, --radius,
|
||
* --text, --border — or, likelier, the CONVENTIONAL --text-color-high, which base itself
|
||
* once read 158 times — silently repainted this theme: 312 of 336 elements on
|
||
* docs/gallery.html with a hostile :root, 0 after the split. The bridge is OUTWARD-only;
|
||
* audit.py fails on any read of an export name inside styles/.
|
||
*
|
||
* docs/design-system.md. Light = :root; dark = :root[data-darkmode="true"].
|
||
* ============================================================ */
|
||
:root {
|
||
--card-bg-color: var(--fs-panel) !important;
|
||
--border-color: var(--fs-border) !important;
|
||
--app-container_title-color: var(--fs-text) !important;
|
||
--item-label_key-span-color: var(--app-container_title-color) !important;
|
||
--app-container_status-label_block: var(--app-container_title-color) !important;
|
||
/* Palette COLOURS are in 03-palettes.css (one swappable block per colourway × mode);
|
||
* everything here is palette-INDEPENDENT or var()-derived, so it follows the active
|
||
* palette. The INKS (--fs-on-accent &c.) are in the palette blocks too, beside the
|
||
* fills they must be legible against: a dark palette has LIGHT fills and needs a DARK
|
||
* ink, and the single global `--fs-on-accent: #fff` that used to sit here 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, Appearance popover, select picker): one value
|
||
* for the .28/.30/.32 literals that had drifted apart across them. */
|
||
--fs-shadow-pop: 0 12px 34px rgba(0,0,0,.30);
|
||
/* the top bar's edge: a drop below + a 1px inset seam, both a quiet 25% of the border
|
||
* colour. The one bar/card shadow that was written inline (50-toplayout.css) — an
|
||
* unnamed derived level, the drift shape the ladder exists to prevent. */
|
||
--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), so composited on the surface it lands on it
|
||
* measures 1.15 / 1.27 / 1.17 / 1.29 : 1 across palette x mode. That is a GLOW, not an
|
||
* indicator: WCAG 1.4.11 wants 3:1 for the thing that tells you where focus is. It is
|
||
* legitimate only BESIDE a second channel — every field here also flips border-color to
|
||
* --fs-accent, and that change measures 4.88-6.52:1, so the border is the indicator and
|
||
* the tint is the halo around it.
|
||
*
|
||
* -solo is for a control where the ring is the WHOLE indicator: nothing else about it
|
||
* changes on focus. Three groups were relying on the tint alone and therefore showed
|
||
* effectively nothing under the keyboard — every sidebar menu link and the chrome's icon
|
||
* buttons (10-chrome.css), the section tabs (40-tabs.css) and both range sliders. The
|
||
* 2px of surface is what lets it read against a FILLED control too (an accent-filled
|
||
* button or the active tab pill would otherwise take an accent ring on an accent box);
|
||
* solid accent then measures 5.19 / 5.39 / 6.52 / 8.52 on panel, 4.88-9.14 on the page.
|
||
* It had been written out as a literal twice (the filled buttons, the active pill) before
|
||
* it had a name — the drift shape the ladder exists to prevent.
|
||
*
|
||
* The invalid twin takes -fill (18%), not -soft: a red ring must read as an ALARM, not
|
||
* as a hover tint. It is paired with a red BORDER, so the tint rule above applies. */
|
||
--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 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 (the trap the *-hsl/*-rgb bridges fell into — foot of this file).
|
||
* What these mixes lacked was NAMES, and an unnamed level drifts in silence: the same
|
||
* outline border was 40% in a table and 45% in the action bar, the same diff block
|
||
* 30% in base and 18% in theme, the same hover fill 12% here and 18% there — four
|
||
* strengths where the design has two.
|
||
*
|
||
* -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 × step matrix is COMPLETE on purpose, read or not: a gap is how the drift
|
||
* started (--fs-accent-soft existed, good/warn/danger had no sibling, so every rule
|
||
* wanting one invented a percentage). --fs-accent-soft is the one member NOT here —
|
||
* its strength is the only one that 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 it is kept on purpose — do not "clean it up".
|
||
* `good` and `danger` are consumed by the button variants (.cbi-button-positive /
|
||
* -negative / -reset, theme/55-buttons.css) and `warn` only by -fill, because LuCI emits
|
||
* no warning BUTTON. The row is what makes the next component that needs one reach for a
|
||
* token instead of mixing its own percentage, which is the whole reason these four
|
||
* strengths are stated once here rather than at each call site. A grep that finds no
|
||
* `var(--fs-warn-line)` is therefore expected, not a finding. */
|
||
|
||
/* frosted pop surface (top-nav dropdown, rail flyout). NOT merged into --fs-bar-bg
|
||
* (88%): a bar is a thin strip the page scrolls under and seeing content ghost
|
||
* through it is the point; 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,
|
||
* a difference nobody chose. */
|
||
--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-status pill collapses to it
|
||
* both in the rail (20-shell.css) and in the top bar before it stacks (50-toplayout.css),
|
||
* and inlining the SVG twice is exactly the drift css-dup exists to catch. STATIC glyph
|
||
* on purpose — see the rail note; a spinner makes an idle poll look busy and moves the
|
||
* click target that pauses it. */
|
||
/* OURS. It was derived from Lucide's `refresh-cw` (ISC) — `M21 3v5h-5` was byte-identical,
|
||
* and the r=9 arc and `L21 8` terminus came with it — which is a second outside obligation
|
||
* this theme never declared, found while auditing the first. 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 and not chevrons
|
||
* because it renders at 18px — measured, chevrons dissolve at that size. It is a MASK, so
|
||
* fill and stroke both give alpha. */
|
||
--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. De-duplicating the SVG left its GEOMETRY to drift anyway:
|
||
* the rail said 19px and the top bar 18px for the same glyph, which css-dup cannot see —
|
||
* the declaration bodies differ, so it goes quiet. The two sites legitimately differ in
|
||
* TECHNIQUE (the rail masks the element and drops the border; the bar keeps the bordered
|
||
* square to match Appearance/Log out); the size was nobody's decision. */
|
||
--fs-icon-refresh-sz: calc(18px * var(--fs-density-type));
|
||
|
||
/* the 1px raised edge on a plate carrying text, plus its text shadow. .ifacebox
|
||
* mixed --fs-dim and .ifacebadge --fs-panel for the SAME edge at the same 5%. */
|
||
--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, since the fill
|
||
* it shifts is whatever the role token says (the three sites had drifted to
|
||
* 1.06/1.08/1.12). PER MODE, AND THE DIRECTION FLIPS, measured from rendered pixels:
|
||
* `filter` recolours the TEXT too, and a light-mode solid button is a saturated fill
|
||
* with WHITE ink, which cannot get brighter — on --fs-accent (#0969da) white is
|
||
* 5.19:1 at rest, brightness(1.08) drops it to 4.59:1 and a visible 1.15 to 4.08:1,
|
||
* an AA failure caused by hovering. So light DARKENS (0.90 -> 6.16:1: bigger cue AND
|
||
* better contrast); dark, whose fill is light and ink dark, brightens (1.15 -> ~8:1).
|
||
* Both move the button away from the page it sits on. */
|
||
--fs-hover-lift: brightness(.90);
|
||
|
||
/* ---- MOTION — four durations, because the UI does four things. There used to be
|
||
* seven (.12/.125/.15/.2/.22/.25/.4) and four curves (ease, ease-in, linear, one
|
||
* cubic-bezier), none of it chosen — two rules even declared the SAME three
|
||
* properties at the same duration in a different ORDER, so a grep could not see they
|
||
* were the same rule. There is deliberately NO easing token: every transition and
|
||
* animation omits the timing function and takes the CSS default (ease) — one curve,
|
||
* nothing to keep in sync, fewer bytes than naming it. `ease` IS the initial value of
|
||
* animation-timing-function, so writing it in the shorthand changes nothing; four
|
||
* animations said it and one did not, which is the evidence nobody chose it. A rule
|
||
* needing another curve is making a design decision and must justify it in a comment.
|
||
*
|
||
* The scale governs `animation` too — that half was missed the first time. Only the
|
||
* transitions were converted, so `fs-fade` (ONE keyframe, one gesture) was ridden at
|
||
* .14s, .16s and .3s: the same drift as the refresh glyph's 19px/18px, and invisible to
|
||
* css-dup for the same reason — the declaration bodies differ, so it goes quiet.
|
||
* 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 they cannot drift apart. */
|
||
--fs-content-pad: calc(28px * var(--fs-density-space));
|
||
/* ------------------------------------------------------------------------
|
||
* THE SHELL'S GEOMETRY — tokens, not literals, because the JS reads them back with
|
||
* getPropertyValue(): the CSS lays the sidebar out, and fs-chrome.js
|
||
* (fitShell()) subtracts that cut from the viewport to decide whether what is LEFT
|
||
* is still readable. The JS used to keep its own copies (SIDEBAR_W = 224, RAIL_W =
|
||
* 68, CONTENT_MIN = 500) against bare literals in the CSS: narrow the rail here and
|
||
* the measurement silently went on 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; 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/rail: the logo box, Appearance (.fs-themerow),
|
||
* Log out (.fs-logout) and the collapsed poll pill are all one size so the right
|
||
* cluster reads as a row of equal squares. Was a bare 34px in three files with the
|
||
* logo left at 30 — the odd one out. */
|
||
--fs-btn-size: calc(34px * var(--fs-density-box));
|
||
|
||
/* Corner-radius scale. One user-tunable base (Appearance → Rounding, 0–20px,
|
||
* localStorage `fs-radius`, pre-painted inline on :root by partials/head.ut before
|
||
* first paint); the three semantic radii scale from it proportionally so every
|
||
* surface rounds in step, and pills stay fully round. At base 12: card 12, control
|
||
* 10, chip 8. Every border-radius in theme/pages reads one of these — the only bare
|
||
* px left are 0 / 50% / hairline insets and two that are not a card's corner
|
||
* (theme/60-inputs.css 8px = half the 16px scrollbar; theme/70-modal.css 4px). */
|
||
--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, and the
|
||
* offset it needs is this padding plus the card's 1px border. Written as a literal in
|
||
* both places, narrowing the gutter here would silently re-clip every graph — the same
|
||
* trap --fs-sidebar-w exists to close for fitShell(). */
|
||
--fs-card-pad: calc(16px * var(--fs-density-space));
|
||
/* The vertical rhythm BETWEEN cards (.cbi-section, tab groups, alerts, action bars).
|
||
* It was the same 16px hand-written in seven files — 45-misc.css even carried a comment
|
||
* apologising for it ("Same 16px every .cbi-section already uses") — which is an
|
||
* unnamed level, and an unnamed level drifts (see the role-tint ladder above). */
|
||
--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 — footstrap's own, and the reason it exists is not tidiness ----
|
||
*
|
||
* styles/base carried a 13/18 rhythm with 9px and 8.5px halves of it. That is
|
||
* luci-theme-bootstrap's scale, i.e. Twitter's 2011 choice, and it arrived here with the
|
||
* fork rather than by anyone deciding it fits Manrope. Naming those same numbers as tokens
|
||
* would have changed nothing that matters — the numbers ARE the borrowed expression — so
|
||
* the scale is re-derived here and base reads it instead of restating literals.
|
||
*
|
||
* 13px stays the base DELIBERATELY, and it is not the same 13 as upstream's: a router UI is
|
||
* dense tables of addresses and counters, and the size was re-chosen for that, not
|
||
* inherited. What changes is the RHYTHM around it — a unitless 1.5 instead of a hard 18px.
|
||
* Unitless matters: `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), and that is fine
|
||
* — a fractional line box is what unitless leading gives on any odd size, and the baseline
|
||
* still snaps. What must stay whole is a BOX: never size an element `--fs-type * --fs-leading`
|
||
* (a half-pixel border is upstream's 8.5px bug wearing a calc) — take the nearer --fs-space
|
||
* step, which is what a line of text at this scale rounds to anyway. */
|
||
/* FIVE steps, and the count is the point. The scale had grown to eight — 10, 11, 12, 13, 14,
|
||
* 16, 20, 26 — where four of the gaps were a single pixel, which is below the threshold at
|
||
* which anyone reads a size as *different*: it does not build hierarchy, it just makes two
|
||
* rules that look unrelated in a grep. The 1px neighbours were folded together (10 into 11,
|
||
* 12 into 13) and the 14 was split by what it was actually doing — chrome and menu items are
|
||
* body text (13), a section title is a title (16) — which is what finally puts real distance
|
||
* between a heading and the text under it, where before there was one pixel.
|
||
* Ratio is roughly 1.25 per step. Anything that needs a size not on this list is making a
|
||
* design decision and should say so in a comment. */
|
||
/* ---- DENSITY: one axis, two multipliers, and it only works because of the scales ----
|
||
*
|
||
* Compact / Normal / Large is a CLIENT preference (Appearance), stamped as
|
||
* :root[data-density] — absent means Normal, so the default costs no attribute and no
|
||
* override, exactly like every other axis here.
|
||
*
|
||
* THREE multipliers, because three different kinds of size want different treatment and one
|
||
* knob gets all three wrong:
|
||
*
|
||
* --fs-density-type TEXT. Compact does not touch it at all: "compact" is a request for
|
||
* less air, not for smaller writing, and shrinking the text is how a
|
||
* density control turns into an accessibility problem.
|
||
* --fs-density-space pure AIR — padding, margin, gap. This is what actually buys the room,
|
||
* so it moves the most (.65 at Compact).
|
||
* --fs-density-box boxes that must HOLD text or an icon: the sidebar column, the rail,
|
||
* the bar height, the square icon buttons, the control-height floors.
|
||
* These cannot follow the air: with type unchanged, a sidebar cut by
|
||
* 35% would no longer fit the menu labels it exists to show. They move
|
||
* gently (.85), which is roughly what their own padding gave up.
|
||
*
|
||
* This is only expressible because every size in the theme now reads one of these tokens:
|
||
* before that, a density axis would have meant hunting ~240 literals and would have gone
|
||
* stale the first time someone wrote a number. Add a size OUTSIDE these ladders and it
|
||
* silently stops responding to the axis — that is the cost, and the reason the ladders are
|
||
* the contract.
|
||
*
|
||
* Type is rounded to whole pixels (a fractional font size renders 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, and they follow the density too -----------
|
||
* A minimum height is what stops a button collapsing onto its text, so it is part of how
|
||
* dense the UI feels — leave it literal and Compact shrinks the writing while the buttons
|
||
* stay put, which is the opposite of the setting. These had drifted to EIGHT values
|
||
* (28/30/32/36/38/40/42/44) for what the design has three of; each was moved by at most 2px
|
||
* onto the nearest step.
|
||
* Not to be confused with --fs-control-h below: that is a FIELD's height, derived from the
|
||
* text it must hold, and it already follows the type ladder. 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 */
|
||
|
||
--fs-type-xs: calc(round(11px * var(--fs-density-type), 1px)); /* eyebrow, badges, tooltips, dense metadata */
|
||
--fs-type: calc(round(13px * var(--fs-density-type), 1px)); /* body, tables, fields, menu items — the base */
|
||
--fs-type-lg: calc(round(16px * var(--fs-density-type), 1px)); /* section titles (h3/legend), alert headings */
|
||
--fs-type-xl: calc(round(20px * var(--fs-density-type), 1px)); /* page titles */
|
||
--fs-type-2xl: calc(round(26px * var(--fs-density-type), 1px)); /* 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 exactly one or two users (the login card's bottom margin, the gauge's
|
||
* right gutter, the room a field leaves for its own chevron). 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`, its width AND height — one
|
||
* number, one name). It became a token because a SECOND file used to encode the same size:
|
||
* the glyph was absolutely positioned, so theme/55-buttons.css had to clear it with a
|
||
* `padding-left` derived from `left` + `width` + a gap, and the two files drifted with
|
||
* nothing to notice. That padding is gone — the glyph sits in the flow now (issues #15 and
|
||
* #22) — so this is once again read in one place, which is where a resize belongs. */
|
||
--fs-spin-size: var(--fs-space-5);
|
||
|
||
|
||
/* One control height for every field that is a single line — input, select, .cbi-select,
|
||
* .cbi-dropdown, and the checkbox/radio row that must line up beside them. DERIVED, not
|
||
* measured by hand: the text box (--fs-text 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.
|
||
* It was a bare 30px in four rules, which was upstream's same arithmetic over upstream's
|
||
* 18px leading — so re-scaling the type left every control the wrong height for its own
|
||
* text, silently. Stated once here so the two cannot drift again. */
|
||
--fs-control-h: calc(round(up, calc(var(--fs-type) * var(--fs-leading) + var(--fs-space-1) * 2 + 2px), 4px));
|
||
|
||
/* 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 every control ends at a
|
||
* different x reads as broken even when each width is defensible on its own. Fixed-width
|
||
* controls (a select, a number box) stay at their own 210px; this is the ceiling for the
|
||
* ones that stretch: the text/url/search inputs, the dynlist and the range slider.
|
||
*
|
||
* 440px is not on the 4px step scale and does not want to be — it is a COLUMN WIDTH, the
|
||
* same kind of number as the 180px label column beside it, and it was already the figure
|
||
* three rules had reached independently. Named here so a fourth one cannot pick 460. */
|
||
--fs-field-max: 440px;
|
||
|
||
/* ---- Stacking scale ----------------------------------------------------
|
||
* Every z-index in the theme comes from here. There used to be nine bare numbers
|
||
* (2, 40, 50, 60, 70, 800, 900, 1000, 1100) across seven files, with nothing
|
||
* recording what should sit above what — so the Appearance popover (1000) painted
|
||
* OVER an open modal (#modal_overlay, 900). As an ordered list, that bug 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 (audit.py fails the build if it starts to). 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. It was once three aliases of
|
||
* ONE token — a flat colour where the consumer asked for a gradation — and apps DO
|
||
* ask: luci-app-podkop paints its "no data" latency with --primary-color-low and got
|
||
* the same vivid accent as a live value; luci-app-justclash reads eleven of these
|
||
* names; stock firewall.js and status/cpu.js read them too.
|
||
*
|
||
* Direction is bootstrap's, which every app was calibrated against: `high` = the most
|
||
* pronounced value of the role (--text-color-high the readable text, -low the faint
|
||
* one; --border-color-high the visible rule, -low the hairline) — except
|
||
* --background-color-*, which is an ELEVATION axis (high = raised, low = recessed).
|
||
* The MECHANISM differs per family, by measurement — see each block.
|
||
*
|
||
* Binding constraint: apps read a level as `color:` about as often as they read it as
|
||
* `background:` (counted across bootstrap's cascade, firewall.js, cpu.js), 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);
|
||
|
||
/* --text-color-low cannot move: --fs-dim IS the muted text colour and already
|
||
* sits ON the AA floor (4.56:1 on --fs-panel2 in dark), so a fainter step would
|
||
* be an illegible grey. It stays equal to -medium — the ramp is still wide, -high
|
||
* being --fs-text — and a duplicate beats a value nobody can read. */
|
||
--text-color-highest: var(--fs-text);
|
||
--text-color-high: var(--fs-text);
|
||
--text-color-medium: var(--fs-dim);
|
||
--text-color-low: var(--fs-dim);
|
||
|
||
/* borders carry no text, so here -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. Contrast barely moves
|
||
* across the ramp, so every level stays printable as TEXT. Both alternatives were
|
||
* measured and rejected:
|
||
*
|
||
* fade `low` toward the surface (the intuitive "muted") spends contrast we do not
|
||
* have: on --fs-panel2 in dark every accent already sits at 4.56–4.59:1, +0.06 over
|
||
* AA, and an 8% fade took --warn-color-low to 4.18:1 and its ink ON it to 4.45:1.
|
||
*
|
||
* push `high` toward --fs-text (free in contrast terms) collapses the ramp in DARK
|
||
* mode, where --fs-text is near-white: the strong end comes back washed out, 0.055
|
||
* from the weak end, i.e. flat. Lightness is not an axis in both modes at once.
|
||
*
|
||
* Bootstrap's own ramp walks lightness (its --primary-color-low is its DARKEST blue),
|
||
* which is why it reads inverted in dark mode and lands at 3.6:1 — below AA. Keep its
|
||
* direction, drop its mechanism. Every step is mixed FROM the token it grades: no
|
||
* second, hand-kept copy of a colour (foot of this file). tools/export-tier.mjs proves
|
||
* all of it across {footstrap,hicontrast} × {light,dark} — AA as text on all three
|
||
* surfaces, a legible --on-*-color as a fill, and that the ramp is not secretly flat. */
|
||
--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 DENSITY axis, and it is exactly two declarations per step ----------------
|
||
*
|
||
* Normal is a bare :root — no attribute, no override — so the default path costs nothing and a
|
||
* browser that has never touched the control renders identically to before this existed.
|
||
*
|
||
* Space moves further than type in both directions (0.8/1.25 against 0.92/1.15), because that is
|
||
* what the two settings are actually for: Compact is a request for less AIR, not for smaller
|
||
* writing — matching them would put body text at 10.4px, under the floor the rest of this file
|
||
* defends. At Compact the base is 12px and at Large 15px; the eyebrow never drops below 10px.
|
||
*
|
||
* These multiply the LADDERS, not the rules, so a size that is on a ladder follows the axis for
|
||
* free and one that is not silently ignores it. That is the whole reason the ladders came first. */
|
||
:root[data-density="compact"] {
|
||
/* Type moves LEAST (.9 against .65 for the air), and that ordering is the point: padding is
|
||
* where the room actually is, so leaning on the text instead would cost legibility to save
|
||
* very little. At .9 the base is 12px and the eyebrow 10px — the floor this file is willing
|
||
* to go to, and only because the user asked for this mode by name. */
|
||
--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: padding stays on the Normal
|
||
* rhythm and only the type — and the boxes that have to hold it — get bigger. Inflating the
|
||
* spacing as well just pushed content off the screen and made the setting read as "zoom",
|
||
* which is the browser's job, not the theme's. */
|
||
--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 above. A dark-mode solid
|
||
* button is a LIGHT fill with DARK ink, so brightening moves it away from the page
|
||
* and away from its own ink; in light mode that same move erases its white ink's
|
||
* contrast. */
|
||
--fs-hover-lift: brightness(1.15);
|
||
color-scheme: dark;
|
||
}
|
||
|
||
/* BOTH component bridges are GONE: the HSL one (--*-hsl triples, --*-h/s/l parts) and the
|
||
* RGB one that outlived it (--accent-rgb, --error-color-high-rgb, --success-color-high-rgb,
|
||
* read as `rgba(var(--x), .3)`). Same mistake in two notations — a hand-kept second copy of
|
||
* a colour that already exists as a token: it goes stale in silence when a palette is
|
||
* recoloured, and if the triple goes missing the declaration is invalid at computed-value
|
||
* time, so the tint vanishes with NO error (audit.py cannot catch that — the var IS defined,
|
||
* only elsewhere). Every rule that read them takes color-mix() over the palette token now,
|
||
* for identical output:
|
||
* rgba(var(--error-color-high-rgb), .3) -> color-mix(in srgb, var(--fs-danger) 30%, transparent)
|
||
* A tint of X is mixed FROM X. Do not reintroduce a component copy of a colour in any
|
||
* notation. The one surviving --*-rgb is --zone-color-rgb, and it is not ours:
|
||
* luci-mod-network writes it inline on a zone badge (base/95-luci.css). */
|
||
}
|