Files
small-packages/luci-theme-footstrap/docs/design-system.md
T
2026-09-20 01:22:33 +08:00

35 KiB
Raw Blame History

Design system: tokens, palettes, type

What the theme's values are and why they are those values. How the stylesheet is built and how the cascade is kept disciplined: css.md. What the chrome does with them: chrome.md.

Reference mock-ups: docs/design/.

There is one system. One renderer (menu-footstrap.js), one template directory, one cascade.css, one entry in luci.themes. Layout (sidebar or top bar) is a client setting, :root[data-layout], always with an explicit value.

No colour literals in this page on purpose — a colour written in two places drifts, and it already has: this document was once read as the source for --accent-soft: rgba(9,105,218,.10), the very per-component copy the project killed. Values live in styles/02-tokens.css and styles/03-palettes.css, with the reasoning in a comment beside each one.

Two tiers, and the split is load-bearing

  • The private tier --fs-* (02-tokens.css, 03-palettes.css) — --fs-bg, --fs-panel, --fs-panel2, --fs-border, --fs-text, --fs-dim, --fs-faint, --fs-placeholder, --fs-accent, --fs-good/-warn/-danger, --fs-track, plus the radius, z-index, duration and spacing scales. Every rule in the theme reads this and only this.
  • The export tier --*-color-* — the conventional LuCI names (--primary-color-high, --text-color-*, --border-color-*, --on-*-color), defined from the private tier and read by nobody inside the theme. This is not a bridge, it is a one-way export: audit.py --strict fails the build on any read of an export name from styles/.

Why: :root is a shared scope, and every luci-app-* puts its CSS in the same document unlayered, where it outranks any @layer. One app declaring :root { --accent: … } — or --radius, --text, --border, names anyone would take — silently recoloured the whole theme. Reading export names from base was the wider hole still: --text-color-high is a convention, so an app declares it more readily.

Hostile :root over gallery.html elements recoloured
before the split 312 of 336 (93%)
after 0

The exception is a local variable declared inside the very rule that reads it (--bd-color, --fg-color, --on-color, --focus-color): a foreign :root cannot intercept those.

/* NO — reading an export name; the gate fails */
color: var(--text-color-medium);
/* YES — the private tier */
color: var(--fs-dim);

Third-party apps get the other side of this contract in luci-app-styling-guide.md.

The export tier is a RAMP, not a set of aliases

high/medium/low must be three different colours: a consumer asks for a gradation and gets whatever we declared. All three were once aliases of one token — and luci-app-podkop drew its "no data" latency in --primary-color-low, i.e. the same bright accent as a live value. A flat colour passes every contrast threshold there is, so nothing failed; it failed at the user.

The axis of the ramp is chroma at constant lightness (color-mix(in oklch, …, var(--fs-dim))). Both obvious alternatives were measured and rejected: fading low toward the surface spends contrast the palette does not have (in dark mode every accent on --fs-panel2 already sits at 4.56:1, i.e. +0.06 over AA), and pulling high toward --fs-text collapses the ramp in dark mode where --fs-text is nearly white.

The binding constraint: apps read a level as color: about as often as background:, so every level must pass AA as text on --fs-bg/--fs-panel/--fs-panel2 and carry a readable --on-*-color as a fill. tools/export-tier.mjs proves all of it across {footstrap, hicontrast, bootstrap, 2020} × {light, dark} × a sweep of tint hues — 56 combinations and 4352 contrast checks today — including that the ramp is not flat, the only check that can catch flatness. Borders are exempt from the text half: --border-color-low may legitimately fade into the surface, which is what a hairline is for.

Palettes

Five, all in styles/03-palettes.css, one self-contained block per (palette × mode): footstrap (GitHub Primer colours, the default, filling a bare :root), hicontrast (data-palette="hicontrast"), bootstrap (data-palette="bootstrap", the stock LuCI theme's surfaces and greys — its semantic colours and its light ink are raised where they miss AA, which that block documents in place), 2020 (data-palette="2020", the OpenWrt 2020 theme's colourway) and forum (data-palette="forum", the OpenWrt forum's Discourse colourway). Light mode is the bare :root; dark is :root[data-darkmode="true"]. The file also carries the instructions for adding a palette.

forum is sampled straight off forum.openwrt.org's own getComputedStyle, one read per colour scheme rather than a guess: light ink #222/page #fff/accent #0088cc, dark ink #ddd/page #222/accent #0f82af, plus its success, danger and highlight (search-hit) colours. None of the six clears 4.5:1 as text on the surface Discourse pairs it with — its own contrast rules are for a filled pill or an icon, not for the export tier every app prints text in — so each is walked the way 2020's cyan was, hue and saturation held while lightness moves until AA clears (worst case 1.07:1 on white, for the yellow search-highlight that has no footstrap analogue and fills --fs-warn instead, being the only status colour Discourse's scheme leaves spare). The forum's header bar (rgb(0, 43, 73)) is not carried into either mode: its own dark scheme already has a canvas colour, #222, unlike 2020's theme which had none to sample.

2020 is the CI cyan #00B5E2 on the navy #002B49, which is one scheme and no dark mode in the theme it comes from. Here it is a pair, and the split is the interesting part: 2020's own scheme IS the dark mode — navy canvas, cyan nearly untouched — while the light mode keeps the hue and darkens it, because #00B5E2 measures 2.09:1 on white and the export tier every app prints text in wants 4.5. Its three semantic colours move for the same reason: on white 2020's green is 2.28:1 and its amber 3.42:1, while its red at 5.25:1 is the one kept where it was.

hicontrast is the same tokens, deeper and more saturated. Its light accents are deliberately darkened: they used to be brighter than the defaults, so a palette named "hicontrast" contrasted worse than the default (--fs-good as a label on --fs-panel — 2.55:1 against 5.08:1).

Ink is per palette AND per mode

--fs-on-accent / --fs-on-good / --fs-on-warn / --fs-on-danger live in 03-palettes.css next to the fills they have to be readable on. A dark palette has light fills and therefore needs dark ink: one global --fs-on-accent: #fff failed WCAG AA on seven of eight dark fills, down to 1.69:1 against a required 4.5. A new palette must define all four and check them against its own fills.

The fourth ink is the one that says "nothing here"

--fs-text, --fs-dim and --fs-faint are three weights of a value. --fs-placeholder is not a weight of anything — it is what a field shows when it holds NOTHING, and LuCI puts the option's default there (form.Value.placeholder). Drawn in --fs-dim it measured 11.12:1 on footstrap light's field fill against the value's own 14.84:1, near enough that two forum readers reported fields as holding values they had never typed.

It is mixed from --fs-text toward --fs-panel2 in oklch, because the step is a lightness one, and the PERCENTAGE is per mode rather than per palette — 55% light, 64% dark. Dark is the constrained half: its ink starts at 6.45:1 on the field against light's 14.84:1, so the same percentage there would buy a hint nobody can read.

This is the one token the theme ships deliberately under AA — 3.99-4.48:1 in light, 3.43-6.05:1 in dark, at 43-44% and 33-35% of the ink-to-field range — because a hint mistaken for a value makes a reader configure the wrong thing, while a hint at 3.43:1 makes them look twice. The AA-clearing mixes were measured first and moved 37% and 20%: the same fault, quieter. 3:1 (SC 1.4.11) is the line not crossed, and prefers-contrast: more buys the AA ink back in both modes (theme/95-a11y-media.css): that reader has said which of the two they want.

placeholder-ink holds both ends across the eight palette/mode combinations and runs each of them again under the query. It is the only gate that can: axe-core does not measure ::placeholder at all, and it is excluded from the li[placeholder] row that carries the same ink, since a rule reaching half a decision would fail it.

The derived ladder: four steps, and the matrix is deliberately full

A tint of a role is mixed from that role, so it follows the palette and cannot go stale. What it lacked was a name, and an unnamed step drifts silently: the same border was 40% in a table and 45% in an action panel, the same diff block 30% in base and 18% in theme, the same hover fill 12% here and 18% there — four forces where the design knows two.

Step Strength What it is
-soft 12% a quiet fill — hover on an outline button
-fill 18% a stronger fill — callout/diff, the invalid ring
-line 40% a hairline border
-line-hi 55% the same hairline on hover

The role × step matrix is filled completely on purpose, whether or not anything reads a cell today: a hole is exactly where the drift started (--fs-accent-soft existed, good/warn/ danger had no sibling, and every rule invented its own percentage). --fs-accent-soft is the one member still in 03-palettes.css, because its strength is the only one that depends on mode (10% light / 15% dark).

Other derived values: --fs-glass (a frosted popup surface, panel at 96%) and --fs-blur (one blur radius for every frosted surface); --fs-emboss / --fs-text-emboss (a raised 1px edge); --fs-hover-lift (the one hover hint that cannot be a colour, since it brightens whatever fill the role set — and its direction depends on mode: light darkens, dark lightens); --fs-focus-ring and --fs-focus-ring-invalid (which takes -fill, not -soft: a red ring has to read as alarm).

The bar's blur stays, and that is a decision rather than an oversight. --fs-bar-bg at 88% was chosen for the blur: content has to show through the strip, and without the blur it shows through sharply and reads as dirt under the text. One backdrop layer on one strip is what any native mobile navbar does — unlike theme/15-wallpaper.css, which removed the blur from every form button, where it was dozens of layers rather than one. It could not be measured either way: a main-thread rAF loop cannot see compositor work, and a headless browser does not rasterise at all, so the remaining step is a run on a real phone. Meanwhile there is a proper opt-out — @media (prefers-reduced-transparency: reduce) in theme/95-a11y-media.css switches --fs-bar-bg/--fs-glass to the opaque --fs-panel and kills --fs-blur. That is both an accommodation and a measured line of retreat, and it leaves the choice with the device's owner.

--fs-bar-bg (88%) is deliberately NOT merged with --fs-glass (96%): the bar is a thin strip the page scrolls under, and content showing through is the point; a dropdown carries a menu and has to stay readable. --fs-scrim is the theme's one colour literal and stays one: black at .7 is the absence of light behind a dialog, not a shade of any token.

Scales

Radius — one user-facing base (Footstrap page → Rounding, 0–20 px), from which three semantic radii are derived proportionally: cards/panels/modals/popovers, controls (inputs, buttons, dropdowns, tabs, menu items, logo) and small parts (chips, code, insets). Pills and toggles are always fully round. Every border-radius in theme/pages reads one of them.

Z-index — --fs-z-*, and every z-index in the theme comes from here. Bottom to top: raise 2 → sticky 50 → flyout 70 → header 800 → popover 850 → overlay 900 → tooltip 1000 → dropdown 1100. Before the scale there were nine bare numbers across seven files and nothing fixing the order — the appearance popover of the day drew on top of an open modal. As a list, that bug is obvious.

Motion — four durations, because the UI does four things: --fs-dur .15s (state change: colour, border, shadow, background, filter), --fs-dur-move .2s (transform, max-height), --fs-dur-fade .25s (something transient going away: spinner, notification), --fs-dur-fill .4s (a progress bar growing to its value). There were seven durations (.12/.125/.15/.2/.22/.25/.4) and four curves, none of them chosen.

There is deliberately no easing token: every transition omits the timing function and takes the CSS default (ease) — one curve, nothing to keep in sync, and fewer bytes than naming it. A rule that needs a different curve should justify it in a comment, because it is making a design decision.

Spacing — a half-step scale in 02-tokens.css: --fs-space-0-5, -1, -1-5, -2, -2-5, -3, -3-5, -4, -5, -6, -7, -8, -10 (the number is the step, -1 = 4 px at normal density).

Density multiplies the type scale, the spacing scale and the shell geometry, through three tokens the Density axis sets: --fs-density-type, --fs-density-space and --fs-density-box. Compact is .9 / .65 / .85, Normal is 1 / 1 / 1 (a bare :root, so the default costs no attribute), Large is 1.15 / 1 / 1.15 — the air gives up the most at Compact and does not grow at Large, so the two ends are deliberately not mirror images. Every type, spacing and geometry token is a calc() over one of them — which is why the numbers below are quoted at normal density.

Shell geometry — --fs-sidebar-w (224 px), --fs-rail-w (68 px), --fs-content-min (500 px), plus --fs-content-max (1280 px), --fs-content-pad (28 px) and --fs-bar-h (46 px) — tokens rather than literals because the JS reads them: fitShell() uses getComputedStyle to subtract the sidebar's slice from the viewport and decide whether the remaining column is readable. The JS used to keep its own copies against bare literals in the CSS, so narrowing the rail in styles left the measurement quietly subtracting the old width. Reading them through getComputedStyle is also what makes the measurement follow Density for free.

Shadows are --fs-shadow (per mode) and --fs-shadow-pop (floating surfaces).

The appearance axes

The controls are fs-appearance.js, which builds the form; view/footstrap/appearance.js dispatches it at a route of its own, System → Footstrap (admin/system/footstrap, registered in root/usr/share/luci/menu.d/luci-theme-footstrap.json, ACL-gated the same as any luci-app-* page). It used to be a fifth tab stapled onto the stock System → System page instead, beside General Settings / Logging / Time Synchronization / Language and Style — watching body[data-page] the way fs-overview.js does, then appending one .cbi-tabcontainer and one <li> to the group ui.tabs had already initialised by hand, because initTabGroup() returns immediately on a group carrying data-initialized and clearing that flag builds a second menu beside the first. That workaround stood in for a route: a luci-theme-* package was believed unable to register a dispatcher node of its own. It can — the entry belongs to the package, not to being the active theme, so it is present for exactly as long as the package is installed, regardless of which theme is selected — and docs/architecture.md has the boundary that replaced the old belief.

The values live in fs-prefs.js. Grouped the way the page groups them — Interface (Layout, Theme, Palette, Density, Rounding, Submenus), then the Colours fold (Tint and its strength, the four colour axes, the four surface axes), then the Background fold (Wallpaper, Dim, Pattern and its three axes, File), then Defaults:

Axis Values localStorage :root
Layout top (default) / sidebar fs-layout data-layout (always explicit)
Theme auto / light / dark fs-darkmode data-darkmode + data-theme + data-bs-theme
Palette footstrap / hicontrast / bootstrap / 2020 / forum fs-palette data-palette
Density compact / normal / large fs-density data-density
Wallpaper off / pattern / file fs-wallpaper data-wallpaper — pattern tiles an admin-uploaded SVG through a CSS mask; Scale, Strength and Colours are axes of their own
Tint off, hue 1–360°, #rrggbb fs-tint data-tint=hue|hex, --fs-tint-h / --fs-bg
Tint strength 0–200%, default 100 fs-tint-strength --fs-tint-strength
Accent off, hue 1–360°, #rrggbb fs-accent data-accent=hue|hex, --fs-accent-h / --fs-accent
Good / Warning / Danger same shape as Accent fs-good, fs-warn, fs-danger data-good|warn|danger, --fs-*-h / --fs-*
Cards / Controls / Sidebar / Borders off, #rrggbb fs-card, fs-control, fs-bar, fs-line — (inline --fs-panel, --fs-panel2, --fs-bar-bg, --fs-border)
Photo dim 0–100%, default 74 fs-photo-dim --fs-photo-dim
Pattern scale 40–1600 px, default 440 fs-pattern-size --fs-pattern-size
Pattern strength 0–100%, default 20 fs-pattern-strength --fs-pattern-strength
Pattern colours theme / original fs-pattern-ink data-pattern-ink=original
Rounding 0–20 px, default 12 fs-radius --fs-radius-base
Submenus keep open / auto-collapse fs-menu-autocollapse — (no attribute)

Photo dim is the scrim over a file wallpaper, and the three Pattern axes are the same arrangement for the tiled SVG: both images' bytes are router-side — a file cannot live in localStorage — but how a given browser draws them is an ordinary axis.

Pattern colours is the one axis whose off state is the interesting one. On theme (the bare :root) the SVG is painted through a CSS mask, so the file supplies alpha and the theme supplies --fs-text: the same upload reads correctly in both modes and under every palette. On original the mask is dropped for a plain tiled background-image, because a mask flattens artwork that carries its own palette to a single colour. Neither the file nor the two numbers are the axis that decides whether anything paints — that is fs-wallpaper.

Three more fs- keys are not axes: fs-rail (the sidebar collapsed to an icon rail, toggled in the chrome), fs-menu-open (the remembered set of open accordion sections) and fs-recent (the command palette's history). None of them has a router default, which is why fs-rail may delete its key on the off state where an axis may not.

Three layers, and the browser always wins

The effective value of every axis is localStorage ?? router default ?? built-in.

  • The router default is what Save as default writes: saveAsDefault() uci-sets the axes into /etc/config/footstrap, and the server reads them back into window.__fsSD, which head.ut stamps. So a new browser, an incognito window or a cleared cache inherits the router's look — including the pre-login page, which is the point of putting the wallpaper there.
  • The built-in is a bare :root.
  • This browser's own choice overrides the router default in either direction.

Every applier therefore stores its choice EXPLICITLY, including the off/default value, and that is load-bearing. Once a router default exists, clearing the key no longer means "the built-in" — it means "inherit whatever the router set". An applier that deleted the key on the default value could not express "I want the built-in, not the router default", so a router-defaulted tint could not be turned back off. lsDel is reserved for Reset to default, which drops back to the router default on purpose.

Nothing else writes to the router

/etc/config/footstrap is written by Save as default and by nothing else. The only other uci writes on the whole page are the login-background token on upload and its blanking on remove — the identity of a file that has to live on the router anyway.

Two axes used to write through the moment they changed: wallpaper on every pick and photo_dim on every drag, on the argument that the File photo is router-side, so "which wallpaper shows it" and "how dim" belonged beside the image. The argument did not survive its consequence: choosing Cats in one browser silently re-pointed the router-wide default for every other device — and because the write also moved the Save baseline, the button did not even light up. A per-browser preference must never mutate shared state with no way to see that it did. Both are ordinary axes now.

/etc/config/footstrap ships as an empty stub and is written at runtime, which is why it must be declared a conffile — see package.md.

Every axis is implemented twice — inline in partials/head.ut before first paint, where require is impossible because the module loader does not exist yet, and live in fs-prefs.js. The two cannot be merged byte for byte, so tools/axes.mjs derives the contract from the JS and checks the template against it: keys, :root attributes, custom properties, the 1–360 ranges, the rounding default — and the load-bearing ordering rule, custom property first, attribute second. Reversed, a reload paints exactly one frame in the previous hue. The gate exists for that one line: it would be fixed in the live applier and forgotten in the template, and the only symptom is a single wrong frame nobody reports.

The colour axes, and why the slider went

Nine axes take a colour: Tint (the canvas), Accent, the three status colours Good / Warning / Danger, and the four surfaces Cards / Controls / Sidebar / Borders. Each holds one of three things — off, a hue 1–360°, or a #rrggbb — and data-<axis> carries hue or hex to say which. The surfaces are the exception and hold only a colour: they set an inline custom property and no attribute at all (surfaceAxis in fs-prefs.js), because there is nothing for a rule to match.

The UI offers only the hex field. The hue slider was here and is gone: rotating a hue keeps the palette's chroma, so no angle of it reaches a grey — which is the one thing #20 asked for. The hue mode stays in storage and in the stylesheet so a value saved before the change goes on painting.

The ink over a hex fill is derived, in CSS. --fs-on-accent and the three status inks become oklch(from <fill> clamp(0, (l - .62) * -100, 1) 0 0) — black above the sRGB crossover, white below, chroma zeroed. The rule is written [data-accent="hex"][data-accent]: the palette's dark block is also (0,2,0) and later in the file, so the single-attribute form lost in dark mode only and left a grey accent carrying near-black ink at 1.9:1. Surfaces get no derived ink — what reads on them is --fs-text, a palette token these axes must not move — so the page reports the contrast each choice lands at instead.

Tint and Accent

In HUE mode both are an angle 1–360°, both rotate oklch(from …), and both default to "off" (no attribute).

  • Tint (data-tint, --fs-tint-h) washes a hue into the canvas (--fs-bg, the surface the cards float on), so a whole LuCI reads as green / purple / amber at a glance. localStorage is bound to the origin, so "which router is this" comes for free with no server state: the same browser shows the main router green and the access point purple. Nothing else moves — cards, chrome and semantic colours keep their palette values, because a status colour recoloured for identification would start lying about status.

    It rotates chroma and hue rather than mixing a colour in: color-mix in a polar space is a trap (measured on a dark canvas, "2%" and "6%" at hue 165 gave the identical green — the knob controlled nothing).

    Tint strength (--fs-tint-strength, 0–200%, default 100) is the paired axis: the hue picks the colour, this picks how strongly it reads. It is a multiplier on the tint chroma, it only bites while a hue is set, and it is hidden and moot under a file wallpaper, where the tint resets to neutral because the photo covers the canvas. Do not confuse it with Photo dim, which darkens that photo rather than colouring the canvas.

  • Accent (data-accent, --fs-accent-h) is the same idea applied to the interface colour: the rotation moves every accented control, because they all read --fs-accent or a color-mix() from it. oklch(from … l c H) preserves the palette's lightness and chroma and changes only the hue, so the contrast of --fs-on-accent — which follows lightness — holds at any angle. The ink is not recomputed.

To avoid a cycle (--fs-bg cannot be defined through itself — that is invalid at computed-value time and drops the colour silently), the palette declares the raw --fs-bg-base / --fs-accent-base / --fs-accent-lt-base, and exactly one block owns the derivation.

Typography

THE THEME CARRIES NO FONTS. --fs-font-sans and --fs-font-mono (02-tokens.css) name Manrope and JetBrains Mono first and the system stack after:

--fs-font-sans: "Manrope", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--fs-font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;

A bare family name in font-family is matched against the fonts installed on the visitor's machine before the browser moves down the list, so an admin who has either face installed sees the theme drawn in it and one who does not falls through silently — no request, no 404, no flash. Measured in the browser on a machine with neither installed: "Manrope", system-ui renders at exactly the system-ui width, while Impact, system-ui and "Courier New", system-ui render at their own — the mechanism works, the theme simply has nothing to add to it.

  • Sans: Manrope where present. Designed against weights 600 and 700; with a locally installed family that has 400, normal resolves to 400 rather than the semibold the design assumed.
  • Mono: JetBrains Mono where present. Numeric values, hostnames, versions, port names.

There is no bold mono and it must not come back. <strong> is a LABEL — LuCI writes every status as <strong>MAC:</strong> ac:1f:6b:… — so on a monospace surface it takes the interface face instead (theme/45-misc.css; likewise .ifacebadge as a badge, and code/pre, whose literal must not inherit the container's emphasis). Before that rule, 227 elements across seven pages rendered in bold mono and the browser fetched jetbrains-mono-600 (20 KB) for the word "MAC:" — 30% of all font traffic. Anything now asking for mono at weight ≥600 gets a synthetic bold that smears the monospace grid; if a rule "needs bold mono", the question is whether it is a label (then it is not mono at all), not whether to bring 20 KB back.

Excluding an element from the mono rule is not enough — it still inherits mono from its parent. Sans has to be assigned. (The first attempt added strong to a :not() and changed nothing at all.)

Sizes (px): card title 14/700; KPI number 27/700 mono; large number 38–40/700 mono; body 13–14; uppercase label 11/700 with letter-spacing:.05em; micro-caption 11–12 dim. Weight 800 from the mock-up is not loaded — 18 KB for six elements; everything that asked for 800 draws at 700.

A table's column heading is not one of those labels. It carries the size, the weight, the tracking and the faint ink, and NOT text-transform: uppercase: the string is the app's, and uppercasing it printed LuCI's own IPv4 address as IPV4 ADDRESS, which reads as a typo rather than as a style (issue #39). It is also the string LuCI copies into data-title for the card view, from innerText — so a transform here rewrites the label the cards print and any selector keyed on it. Four rules hold that decision together: .cbi-section .table .th and the title row in theme/30-tables.css, the card label mirrored into theme/65-dropdown.css, and the alert's table in theme/35-alerts.css. The chrome's own eyebrows — menu section labels, KPI captions, the search palette's group headings — stay uppercase; they are the theme's words, not an app's.

The fonts used to be self-hosted: nine .woff2 subsets (3 faces × latin / latin-ext / cyrillic) under htdocs/luci-static/footstrap/fonts/, @font-face in styles/01-fonts.css, and the two latin Manrope subsets preloaded in partials/head.ut. They are gone, and the measurement is why: the built package went from 128 290 to 66 690 bytes, −48% — a far larger share than the raw 88 kB of .woff2 suggests, because woff2 is already compressed and gains nothing from the package's own compression while everything else does.

Removing them takes THREE deletions, not one, and the third is the one that hides: the @font-face block, the files, and the <link rel=preload> pair. A preload is not in the stylesheet, so dropping the rules alone left it behind and every page still asked the router for the files — six 404s per page, measured, before that was noticed.

The OFL-1.1 half of PKG_LICENSE went with them. OFL §2 requires the notice and licence to travel with every copy of the Font Software; with no Font Software in the package, declaring OFL would be a false statement about its contents.

Putting a font back, per router

The package still ships none. What it ships is the seam: three uci options in footstrap.settings, filled in by fonts/set-font.sh (a repository tool, run once on the router) or by hand. fonts/README.md is the admin-facing page; this is the contract.

option what sanitised in partials/head.ut by
font_sans a font-family stack, printed into an unlayered :root block _sd_family() — A-Za-z0-9 ,._"'-, ≤120 chars
font_mono the same for the monospace face _sd_family()
fonts md5 of the generated @font-face sheet: the cache key and the switch that emits its <link> the hex whitelist login_bg and pattern use

The two halves are independent by design. A stack alone costs nothing and renders for whoever has that face installed — the same mechanism the defaults above rely on. A stack plus a file in /etc/footstrap/fonts/ makes the router serve it, so every visitor gets it.

Three things about the shape are load-bearing:

  • The generated sheet carries @font-face and nothing else. The families come from the template, so uci set footstrap.settings.font_sans=… by hand takes effect with nothing regenerated.
  • The :root block is unlayered, so it beats layer(tokens) whatever order the sheets arrive in — the same reason a theme/ rule never needs !important to outrank base/. Nothing under styles/ changed to make this work.
  • The <link> is emitted only when the token is non-empty, and the token is written only after the file exists. That is the preload lesson above, applied before it could repeat.

None of the three is an axis: no localStorage, no Appearance control, no entry in snapshotAxes() (tools/axes.mjs knows them as server-only, beside the two upload tokens). A font is a property of the router, not a per-visitor preference — the one Appearance-adjacent setting with no browser layer at all.

The weights are where an installed face goes wrong quietly. Body text is 600, so what a face claims decides what is visible: a single static file declared 400 700 covers both, the browser stops synthesising, and every heading renders in the regular face. The script therefore declares one static face as 400 alone, a pair as 400 600 + 700, and leaves 100 900 to be spelled out for a variable font.

Components: mock-up primitive → LuCI class

Component Spec LuCI class to style
Panel / card --fs-panel background, 1px --fs-border, --fs-radius-lg .cbi-section, .cbi-map > *, .table containers
KPI card --fs-radius-lg; column of uppercase label + mono-27 number + dim caption no direct analogue — status overview blocks
Progress bar track --fs-track at --fs-radius-pill; fill --fs-accent; value mono/dim over the right edge .cbi-progressbar
Percent badge font: 11/700; padding: 2px 7px; colour + -soft fill by status inline status in .cbi-progressbar, zonebadge
Status pill --fs-radius-pill; --fs-panel2 fill, --fs-border; active text --fs-good #indicators [data-indicator]
Menu item --fs-radius; active --fs-accent-soft on --fs-accent; inactive --fs-dim #topmenu li a, sidebar nav
Logo 30px square, --fs-radius, gradient --fs-accent → --fs-accent-lt, wifi SVG on currentColor, wordmark 16/700 .fs-brand/.fs-logo in partials/brand.ut
Table row flex, space-between, 1px --fs-border bottom; label dim, value mono .cbi-value, .table .tr

Rings, sparklines and port tiles from the mock-up are content, drawn by view JS — not something a theme can produce. See the boundary in architecture.md.

Meter polarity

annotateMeter() (menu-footstrap-common.js) colours a .cbi-progressbar from its fill percentage — 80%/92% warn/danger, docs/conventions.md — but a fill is not always "how much is used". Forum #134 (2026-09-06) caught Status -> Overview's memory row 0, "Total Available" / «Свободно», turning yellow at 91% full: a HIGH reading there is healthy, not a warning. Row 4, "Swap free" / «Свободно в подкачке», is worse — a fully free swap reads 100% and showed permanent danger on an otherwise healthy router. Both measured live on owrt2512 and owrt2410, en and ru.

Three decisions, each keyed on the row's own label — _(msgid), no msgctxt, the same match ROLES in fs-overview.js already makes, verified against modules/luci-base/po/ru/base.po: every msgid used here has an entry there, so it is in the base domain loaded on EVERY admin page, not one only Status -> Overview fetches (unlike _('Free'), absent from that catalogue, which comes back untranslated wherever it is tried — the trap this rule exists to avoid falling into a second time):

  1. Inverted ("Total Available", "Swap free"): the warn/danger split fires on a LOW reading — 100 - FS_METER_WARN / 100 - FS_METER_DANGER, not a second pair of numbers, because an inverted bar asks the identical health question ("how much headroom is left") from the other end of the same fill.
  2. Neutral ("Buffered", "Cached"): no colour, ever. A full page cache is the kernel doing its job, not a resource running out — colouring it "danger" would say something false.
  3. Unrecognised (everything else, including any bar a third-party app draws): keeps the plain fill-based rule. Seventeen bars measured live on Overview alone (storage, active connections) are used-based and carry no name this theme special-cases, and an app's own meter is unnamed by construction — so fill-based stays the FALLBACK. A wrong colour on the rare inverted app meter this theme has never seen is the accepted cost of not going dark on the common, correct case.