35 KiB
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 --strictfails the build on any read of an export name fromstyles/.
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 intowindow.__fsSD, whichhead.utstamps. 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.localStorageis 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-mixin 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 afilewallpaper, 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-accentor acolor-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,
normalresolves 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-faceand nothing else. The families come from the template, souci set footstrap.settings.font_sans=…by hand takes effect with nothing regenerated. - The
:rootblock is unlayered, so it beatslayer(tokens)whatever order the sheets arrive in — the same reason atheme/rule never needs!importantto outrankbase/. Nothing understyles/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):
- 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. - 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.
- 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.