35 KiB
The stylesheet: source tree, layers, build
cascade.css is generated from styles/ and is not in git. This page covers how the tree is
organised, how the cascade is kept disciplined, how the build works, and how to prove a CSS
change did what you meant.
Token names and values: design-system.md. The rules a patch must follow: conventions.md.
One directory per cascade layer
luci-theme-footstrap/
build-css.sh
styles/
00-header.css banner + the single @layer declaration
02-tokens.css @layer tokens private --fs-* tier + the --*-color-* export tier
03-palettes.css @layer tokens palettes (tokens only)
04-nocolormix.css @layer tokens static twins for the 36 color-mix() tokens
base/ @layer base widget defaults the views count on
10-reset 20-typography 30-forms 40-tables 50-chrome
60-modal 70-buttons 90-widgets 95-luci
theme/ @layer theme footstrap's own components and layouts
10-chrome 15-wallpaper 16-login-bg 20-shell 25-progressbar 30-tables
35-alerts 40-tabs 45-misc 50-toplayout 55-buttons 60-inputs
65-dropdown 70-modal 75-search 90-responsive 95-a11y-media 97-print
pages/ @layer page per-page corrections
10-login 20-overview 30-software 40-sshkeys 50-leases
60-assoclist 70-syslog 80-appearance 90-processes
Concatenation order is styles/ → base/ → theme/ → pages/, and inside each directory the
numeric prefix is the order.
base/ came out of one 2300-line file. The split was purely mechanical: not a single rule moved
within the layer, and the computed-style diff was zero — that was the condition for calling it
done.
Palettes are split across two files on purpose: 03-palettes.css holds only token definitions
and lives in the tokens layer, while rules like
:root[data-wallpaper="pattern"] .fs-main { background-color: … } are ordinary styles and would
lose to theme from inside tokens. They live in theme/15-wallpaper.css.
The directory cannot be called src/ — to luci.mk that means C sources.
Layers
@layer tokens, base, theme, page;
A later layer beats an earlier one regardless of selector specificity. So a theme rule
never has to outrank a base rule by specificity or by !important.
The unlayered level outranks every layer and is deliberately left empty — it is the escape
hatch. node.css, which LuCI attaches for individual pages after the theme, also lands there.
Layer order is fixed by the FIRST mention of a name, and that can be hijacked
@layer tokens, base, theme, page; holds the order only while cascade.css is the first sheet
in the document to name a layer. A name met earlier becomes the first — that is, the weakest
— layer, and everything declared afterwards stacks above it. If a foreign sheet carrying
@layer theme lands ahead of ours, the order becomes theme, tokens, base, page, and base,
where * { padding: 0 } lives, starts beating the entire chrome. Measured on a router:
.fs-content loses its 24px 28px, and the bar, tabs and buttons collapse with it.
Not hypothetical — it is the flip side of the re-host in fs-sheets.js: we wrap a foreign sheet
in @layer theme, but where the package inserted it is the package's choice. Ace (pulled in by
luci-app-ssclash and any package with an editor) puts its <style> first child of <head>
through dom.importCssString, and lazily — some of it on first hover. Hence the bug report
shaped "fine after a reload, right up until I hover something".
One declaration fixes it: fs-sheets.js re-inserts @layer tokens, base, theme, page; as a
new <style>, first child of <head>. New specifically — inserting a sheet recomputes the
order, moving an existing one does not (checked both ways). A static declaration in the
template cannot help: a foreign sheet can still land in front of it, so the answer has to be
reactive to a <head> mutation.
!important inverts layer order
For important declarations the order is reversed: important in base beats important in
theme, which beats important in page.
base/ is down to 8: six are the .left/.right/.center/.top/.middle/.bottom utilities, whose
whole point is coercion, and two fight inline style= (zone colours, stroke:black on SVG graph
lines).
The rule for a flag anywhere: it must fight inline style= or an unlayered <style> an
app injected (package-manager.js emits exactly that). Anything else is cargo cult — if a rule
needs a flag to beat another footstrap rule, it is in the wrong layer. A revision against that
criterion, checked property-by-property against what the JS actually writes inline, removed 11
flags of 43 and added one that was missing.
The single exception is theme/95-a11y-media.css: prefers-reduced-motion has to kill
animations declared in base as well as theme, and only an important declaration reaches back
a layer. The inversion is what makes that file possible. audit.py holds the allowlist
(BANG_OK) and --strict fails on any flag outside it; css-metrics caps the total at 27.
The browser floor
The theme has never declared one, and the sheet quietly moved it twice. npm run css-floor now
derives it from the built stylesheet and fails if this section disagrees.
Chrome 108, Firefox 101, Safari 15.4 — below that the page is wrong, not plainer.
That floor is set by @layer, :is()/:where(), :focus-visible, svh/dvh, accent-color
and the logical properties. Nothing on the list has a fallback worth writing: a theme whose layers
are ignored is not a theme.
Five features are used above the floor and are progressive — the rule does not apply and the page is plainer:
| Feature | Chrome / Firefox / Safari | What is lost below it |
|---|---|---|
:has() |
105 / 121 / 15.4 | 59 refinements: port tiles, password control-group, stacked-table footers |
color-mix() |
111 / 113 / 16.2 | the 36 mixed tokens fall back to styles/04-nocolormix.css |
@container |
105 / 110 / 16.0 | five width adaptations inside fs-view / fs-content |
text-wrap: pretty, scrollbar-width |
— | typographic polish |
overflow: clip value |
90 / 94 / 16.0 | .fs-main's overflow-x: clip and .fs-staging's overflow: clip drop, and the browser's own cross-axis correction (CSS Overflow 3) takes over: a sideways scrollbar on the desktop sidebar's .fs-main (its sibling axis is already overflow-y: auto) or, in the top/narrow layouts and on .fs-staging, whole-page horizontal scroll — exactly how stock LuCI (no clip at all) already renders. .fs-staging also stays visibility: hidden regardless, since nothing inside a staged view sets visibility: visible on itself, so nothing is exposed. Measured with overflow-x/overflow forced to initial in a live Chromium, task 0151 |
Two rules follow, and the gate holds both.
A :has() part never shares a selector list with a part that has none. A selector list is not
forgiving: one unsupported compound invalidates the whole rule. .table.fs-stacked .tr, .table.fs-stacked tfoot:not(:has(> .tr)) was one rule, so Firefox 115 ESR lost display: flex on
every stacked card — a broken mobile table, not a missing refinement. Split it, or wrap the list in
:is(), which is forgiving and drops only the compound it cannot parse.
A token built with color-mix() gets a static twin in 04-nocolormix.css. A custom property
keeps any token stream it is handed, so the failure is deferred to the point of use: background: var(--fs-good-soft) becomes invalid at computed-value time and computes to unset. That is not a
fall back to the previous declaration — the ordinary two-declaration trick does nothing for a
token, which is why the twins are a file and not a prelude. Tints degrade to transparent
(an absent surface never lowers contrast), hairlines to the solid colour they are a fraction of,
and the focus ring to --fs-focus-ring-solo, which was already mix-free.
Adding any CSS feature the sheet has not used before fails css-floor until it is classified in
tools/css-floor.mjs as hard or soft and the baseline is refreshed with
node tools/css-floor.mjs --update. The JS floor is lower and is not the constraint: the SPA
router uses nothing younger than ResizeObserver, and requestIdleCallback and CSS.supports
are both feature-detected.
Classification is by PROPERTY, which misses a value with its own support story: overflow is
ancient, overflow: clip is Safari 16, and the gate said nothing while the value shipped a version
past the floor above. css-floor's VALUES table checks the exact property/value pairs worth
tracking — most values share their property's support, so this stays a short, explicit list rather
than a scan of every value in the sheet.
Vendor prefixes still in the sheet
Every prefix earns its place here or it goes — checked against MDN's browser-compat-data and
caniuse each time, not carried forward on habit. -webkit-mask-image is the one that looks safe
to cut and is not: it was nearly dropped from memory alone before the version was actually
checked.
| Prefix | Cost | Status (checked 2026-09-05) |
|---|---|---|
-webkit-mask-image / -webkit-mask-size / -webkit-mask-repeat / -webkit-mask |
450 B | required — unprefixed masking ships only from Chrome 120; the floor is 108 |
-webkit-backdrop-filter |
214 B | removed 2026-09-05 — with it the property itself: the theme carries no backdrop-filter any more (see the changelog for the measurement) |
-webkit-line-clamp + -webkit-box-orient |
49 B | required — no standard line-clamp equivalent at the floor |
::-webkit-scrollbar-thumb |
56 B | required — no standard scrollbar-styling equivalent at the floor |
-webkit-appearance |
96 B, 4 occurrences | removed 2026-09-05 — unprefixed appearance ships from Chrome 84 / Firefox 80 / Safari 15.4 (MDN BCD; caniuse gives Chrome 83), all at or above the floor |
The build
build-css.sh concatenates the directories and — without --dev — runs the result through
two awk passes: 467 615 B → 135 655 B (measured 2026-08-12; the figure moves with the tree, the
ratio does not). That is not cosmetic: uhttpd serves
/www/luci-static/*.css with no gzip, so every byte travels as-is. (The package build shortens
the private token names on top of that, landing around 120 KB — see package.md.)
With --dev it is a plain cat: the output is byte-identical to the concatenated sources. A
file to read on a router, not one to ship.
Pass 1 — comments. Strips /* … */ except the /*! … */ banner, which is Apache-2.0
attribution and is copied verbatim. The stripper is string-aware: a naive search for the
nearest /* would eat everything up to the next */ on the first content: "/*".
Pass 2 — whitespace CSS ignores anyway: the space after :, spaces around { } ; ,, the
last ; of a block, and the newline after each declaration, so the output is one rule per
line. All of it inside the same string-aware pass. The last ; used to be removed by a bolted-on
| sed 's/;}/}/g', and sed does not see strings: content: ";}" became content: "}", and a
data URI with the same two bytes broke the same way. No such pair exists in the tree today — and
that is precisely how the bug waits for whoever adds the first one.
What is not touched, each for its own reason:
- a single space between selectors —
.a .bis a descendant,.a.bis not; - spaces inside
calc()— mandatory around*,/and the minus incalc(100% - 8px); - a newline inside a declaration — also a space. When the scanner once joined lines
needlessly, a wrapped
calc()came out as…))- .004 …; a minus with no space before it is a parse error, the declaration fell away,--fs-tint-cbecame undefined,--fs-bginvalid at computed-value time, and the canvas silently went white (caught byexport-tieras 1.5:1); - anything inside a string — every data URI here is quoted and full of
:,;and spaces.
Selectors and declarations are never rewritten.
Two guards against a broken file (there is no upper size budget — it was removed):
- Braces are counted twice — before and after compression — and the build fails if the
rule count changed. The first count only sees the input; compression is the pass that
can corrupt a sheet, so an unchanged counter is the proof that it did not. Always counted on a
comment-stripped copy, otherwise a lone
{in prose would fail a valid--devbuild. - A floor,
FS_CSS_FLOOR= 81 920 bytes. A correctness gate, not a size budget: the script refuses to write a suspiciously short file — a truncated write, a full disk, a compression that ate the tail — which would ship a sheet missing its second half.
An unknown option is an error rather than an output path (--devv once wrote the stylesheet to a
file called --devv).
How it runs on OpenWrt
luci.mk copies only luasrc ucode htdocs root src into PKG_BUILD_DIR, then calls
Build/Prepare/$(LUCI_NAME). styles/ is not in that list, so the script reads from $(CURDIR)
and writes straight into the build tree — the sources stay clean. It needs only cat and awk,
so the OpenWrt buildbot builds it with no host dependency.
Why no preprocessor
No LuCI theme runs one on the buildbot: luci.mk can only minify, and there is no node/sass/
postcss there. argon (LESS), aurora (Tailwind + Vite) and fluent (SCSS + Vite) therefore compile
on a developer machine and commit the built CSS. cat is enough here, so the build honestly
travels with the package.
Multiple <link>s are normal practice (stock bootstrap ships cascade.css + mobile.css) but
buy nothing here: the same total bytes, and ?v= (pkgs_update_time) is one value for the whole
page, so the files cannot be invalidated separately anyway. A runtime @import (material's
approach) is worse than a second <link> — it is discovered only after the first file is fetched
and parsed, serialising the requests.
@mirror: duplication you cannot delete but can stop rotting
css-dup finds two rules with identical declarations under mutually exclusive guards (a media
query against an attribute selector, two @container thresholds). A cascade-aware reader needs
both copies, so no linter will ever call it an error — and this is exactly the shape that drifts.
The trap it was built for: css-dup matches identical bodies, so the moment the copies diverge
they stop being a duplicate and it goes quiet — precisely when it should shout.
So every duplicated body must be a decision: merge it into one rule, or pin it. There is no numeric budget — a budget is a number nobody defends, and it waves through the next unexplained copy for free.
The body is pinned, not the rule, so the wrapper goes inside the braces (the selectors are legitimately different; only the declarations must match):
.table.fs-stacked .td[data-title]::before {
/* @mirror table-card/label */
content: attr(data-title); display: block; margin-bottom: 3px;
/* @endmirror */
}
css-dup then accepts the duplicate and tools/mirror.mjs (npm run mirror) keeps the copies
byte-identical: edit one and the build fails until you fix the other. An unpinned duplicate is
a hard failure. A @mirror group with one copy is also a failure — a mirror of one holds
nothing.
npm run mirror prints the current groups; read them from there, not from a doc. Today it reports
seven, plus one whole-file mirror:
| Group | Copies |
|---|---|
table-card/label, table-card/actions, table-card/actions-inner |
theme/30-tables.css (.fs-stacked) ↔ theme/65-dropdown.css (@container) |
ind-badge/paint, poll-glyph/mask |
two places in theme/20-shell.css (the sidebar and the rail) |
selected-row/paint |
theme/60-inputs.css ↔ theme/65-dropdown.css |
@same-file LICENSE |
the whole file |
The card contract: what is measured and what is not
A table folds into cards by two different mechanisms, and that is not an unfinished job.
A data table is measured. fs-fit.js (through fitTables in fs-select.js) removes the
class, reads the width, decides, and sets .fs-stacked. The decision depends on what the table
needs, not on the screen, so @media cannot express it: cards can happen at any viewport
width. Measuring is safe because a data table holds no widgets.
But a measurement is never taken while the reader is scrolling, and a table that has not been answered for takes no room. Both come from the same failure, reported from an iPhone against a remote router: the poll REPLACES these tables, so every tick handed the fitter an unmarked element that was laid out full-width for a frame — several screens taller, at 390 px, than the card stack it was about to become — and the fit pass itself read layout once per table in the middle of a flick, which is exactly the work iOS holds the main thread back to prevent.
So fs-fit.js has two registration channels, and which one a pass belongs in is a contract rather
than a habit: fit.add() for a pass that may read layout, which therefore never runs while the
reader scrolls and is re-run when they stop, and fit.addAlways() for a pass that only writes.
Marking a freshly arrived table is the second kind — it cannot wait, because
:root[data-fs-fit] .table.fs-dt:not(.fs-fitted) { display: none } keeps an unanswered table out of
the layout until it has an answer. Where does that answer come from without measuring? From the
SLOT: the section frame survives a poll tick, so the table that was replaced left its decision
there, keyed by column count and room. The guard on :root[data-fs-fit] is what makes the rule
safe: the attribute is written by the module that CLEARS the rule, so a document where that JS never
ran shows every table exactly as it did before the rule existed.
And a table this rule is still hiding may not be judged. A display: none box has no content
width, so the first pass over a table with no slot to inherit from read scrollWidth: 0, found that
0 overflows nothing, concluded "no remedy" — and CACHED that answer. The table then appeared at its
natural width: on Status → Processes at 768px, 777px inside a 712px column, columns cut off by
.fs-main { overflow-x: clip } with no scrollbar and nothing to say so. A second pass ~60 ms later
usually corrected it, which is why it reached a user rather than a gate: that pass exists only if
something mutates #view again, and a page that renders once and stands still never does. So
fitTables() treats a zero measurement as no answer at all — it lifts the gate, asks for one more
frame, and writes nothing to the slot. Measured entering that page at 768: 2 of 8 arrivals on 0.13.1
and 3 of 8 mid-fix left the table past the column; 0 of 12 after, on each of the four stands.
A config table (.cbi-section-table) is not measured and must stay on
@container fs-content (max-width: 960px). Its rows are full of widgets (fs-select.js turns
every <select> into a ui.Dropdown), and a widget bakes in the width of the layout it was laid
out in — so unfolding it to take a reading changes what you are measuring. Measured on a live
router: after such a toggle the firewall zone table claimed it needed 1747 px where it really
needs 1190 px, and overflowed its section by 557 px — an overflow the pure-CSS version
never had. The act of measuring was the bug. Do not "finish the job".
The price is the last irreducible duplicate: the same declarations under a class and under an
@container, which CSS cannot factor apart. It is pinned with @mirror table-card/{label,actions}.
Which of the three a table gets, and what decides it
Every <table> or <div class="table"> under one of the two content roots — #view, and the
#modal_overlay that ui.showModal() builds its dialog in — lands in exactly one of three tiers, and the
discriminator is does it have a header row, never who wrote it:
| Tier | Reached by | What it does |
|---|---|---|
| measured card | a header row and not .cbi-section-table → fs-select.js tags .table.fs-dt |
folds into labelled cards when it stops fitting, at any width |
@container card |
.cbi-section-table |
folds at 960 px of content, never measured (above) |
| scroll | no header row at all | display: block; overflow-x: auto on the table itself |
Both roots, and that was a bug for as long as it said one. The dialog is a sibling of #view on
<body>, so a table in it matched no selector this file describes: it was never tagged, never
captioned, never measured, and — since fs-fit.js watched only the one host — never re-measured when
the dialog replaced its rows. Measured at a 390 px viewport with the wireless scan dialog's own
markup: the table rendered 373 px wide inside 317 px of dialog, the Encryption column got 10 px
and printed one character per line; carded, it is 317 px with every value on a labelled line. The
roots are listed once in fs-select.js (ROOTS) and every query is built from them, because three
selectors that each name the same two places are three chances to fix only two.
A dialog is a root only while it is open. hideModal() drops modal-overlay-active off <body>
and leaves the markup where it is, and the hidden overlay shrink-fits — measured at 270 px, i.e.
236 px of room for a table that will be shown at the dialog's real width. Measuring that is both
waste (every pass, on a polled page) and a decision about a width nobody will see, so the pass asks
per-pass whether the dialog is open. The flag flips after showModal() writes the content, so
fs-fit.js watches body's class as well as the two subtrees; without it a dialog's table would
wait for its next poll to be fitted.
A header row is any of four markups, and each missing one has cost a page: .tr.table-titles
(L.ui.Table), .tr.cbi-section-table-titles (the apk Software list), a <thead> — E()-built, so
its <th>s may hang off it directly with no <tr> — and a first row made entirely of <th>, which
is what a foreign app writing plain HTML emits. labelCells() then copies the heading of the
column each cell sits in into data-title, which is what the card prints above the value; it never
overwrites one the app set, and it counts COLUMNS rather than cells so a colspan does not shift
every caption after it by one.
The third tier is the deliberate refusal. A card prints attr(data-title) above each value, and a
matrix — a log, a statistics grid, a layout table — has no headings to print, so carding one yields a
column of numbers with nothing saying what they are. Comparison is that shape's whole point, so it
scrolls instead. The scroll rule is not scoped to a phone. It spent its life inside
@media (max-width: 767px), which is the same mistake the card stack was built to avoid: whether a
table fits is a property of its content and its column, and a 400 px panel on a 1600 px desktop hits
the wall a phone hits. It costs nothing where the table fits — overflow paints a scrollbar only
when there is something to scroll.
The floor, and why exactly one tier has one
A cell that may break anywhere has a min-content of one character — that is what the value
means (css-text-3 §5.4: the soft wrap opportunities it introduces are counted towards min-content,
which break-word does not do). Given that, auto table layout is free to starve a column instead of
the table overflowing, and fit.overflows() — the one question the browser answers exactly — goes
blind. That single fact produced four per-page nowrap rules and three JS heuristics, each
reconstructing the number the engine had before the theme threw it away.
So the break value follows the tier, and the split is the design:
| Tier | Break value | Why |
|---|---|---|
| measured data table, while it is a table | break-word (theme/30-tables.css) |
it can card, drop columns or shred one column, so it must tell the truth about what it needs |
| carded data table | anywhere |
every value owns the row; there is no neighbour left to starve |
| config table, key/value include, meter rows, realtime legend | anywhere (base/40-tables.css) |
none of them is measured and none can card on demand — containment is the only outcome available |
| header row, first column | break-word (base/40-tables.css) |
the labels you read the table by (issues #32, #36) |
tools/table-contract.mjs (npm run tables) holds that table to the sheet, selector by selector: a
fifth place to decide how a cell may break is a hard failure, and so is any of them appearing under a
viewport query.
One !important earns its place here. luci-mod-status's processes.js writes
style="word-break: break-word" on its Command span — the deprecated alias for
overflow-wrap: anywhere, from an inline declaration no layer can outrank. Measured at a 720 px
viewport: the column sat at 126 px against a 353 px token, and forcing that column's
overflow-wrap to each of the three values in turn changed nothing, because the floor was being
erased one level down. Neutralised, the same table reports 963 px of need in 688 px of room —
the truth the ladder needs.
And one rule had to go for any of it to work below 767 px. theme/90-responsive.css used to give
table.fs-dt display: block, which discards the table formatting context: the rows become an
anonymous table sized to the block, so the table can never be wider than its parent and therefore can
never overflow. With it in place no floor was readable at a phone width, at any break value.
The ladder: what happens when a table does not fit
fitTables() asks one question — does it overflow? — and answers it with the cheapest remedy that
works, re-measuring at every rung:
- it fits → it stays a table, and nothing was written;
- drop the columns the view marked expendable (
hide-xs/hide-sm, whichui.Tablecopies from the header cell onto every body cell) and ask again — upstream's own priority hint, honoured by measurement instead of at 767 px; - let the widest breakable column shred (
.fs-td-break; never the first column, never anowrapone) and ask again; - card it (
.fs-stacked).
Rungs 2 and 3 need no threshold, and that is the point: the guard is the second measurement. Where
one long token is the whole problem, breaking that column keeps the table a table; where every column
is over its share, breaking one changes nothing and the card is right. The only number left in the
file is CRAMPED (568), the judgement that a table below that much room has stopped being a table at
all — measured, stated, and not derivable from anything.
A table with no header row cannot card (a card prints data-title, and it has none), so it
scrolls inside itself instead — .fs-xscroll, written only when it is measured to overflow and
holds no widget. That refusal is not caution: overflow-x: auto computes overflow-y to auto as
well (css-overflow-3 §3.1), so a scrolling table clips every popup inside it, and luci-base sizes an
open dropdown against the nearest scroll parent. WCAG 2.2 SC 1.4.10 names data tables as the
exception where two-dimensional scrolling is acceptable, which is what makes this an outcome rather
than a failure — and the scrolled table keeps its first column pinned, takes a tab stop with
role="group" and a name, and draws a focus ring, because a scroll box the keyboard cannot reach is
content the keyboard cannot read.
Proving a CSS change
Screenshots do not work here. On a live router, uptime, DHCP leases and signal strength give 0.5–1.3% pixel difference between two runs of the same stylesheet, while a real regression (buttons switched to a monospace font) weighs 0.19%. The noise buries the signal.
The method that does work: load the page once, snapshot getComputedStyle over ~50 properties for
every element, swap the <link> for the second stylesheet, snapshot again. Same DOM, same data —
so any difference was caused by the CSS.
cssdiff.py, the script the changelog andaudit.pyrefer to by name, is the maintainer's own tooling and is not in this repository. Without it, drive the four steps above with Playwright yourself — the result a review asks for is the property diff, not that particular script.
Two traps in the method itself:
- Web fonts. Neither sheet ships one any more, but a machine with Manrope or JetBrains Mono
installed still resolves them, and any page-supplied
@font-facerestarts font matching when the<link>is swapped. A snapshot taken before matching settles measures fallback metrics — every width on the page shifts by a pixel or two and drowns the real diff (291 false differences on the firewall page, back when the faces were bundled). Wait fordocument.fonts.readybefore each snapshot. admin/status/overviewpolls and redraws itself, so it shows ~18 differences even with identical CSS on both sides. Run a control pass (A = B) to learn each page's noise floor.
Four bugs it caught that a screenshot diff missed: .cbi-value-field * painting buttons inside a
field monospace; a max-width: 100% filed under a "phone overflow" banner but sitting outside
its media query; table-row buttons described twice so the height came from one block and the
padding from another; and an actions column losing its right alignment.
The component gallery
docs/gallery.html renders every widget ui.js/cbi.js can emit, with the real class names, so
you do not have to hunt the router for a page that happens to contain the control you changed. It
is not part of the package; it is published to GitHub Pages alongside the built stylesheet, and
the a11y gate runs axe-core against it.
It immediately found two holes that existed on no router page but that any third-party
luci-app-* can hit: native input[type=button|submit|reset] (which styles/base gave only
width/height: auto) and the .label family.
There is nothing left to shrink, and that is measured
The idea "132 KB is a lot, let us compress it" comes back periodically. It has been tested with measurements rather than argument, and the answer is no in every direction. This section exists so the next attempt starts from these numbers instead of redoing the work.
There is no dead code: 96% of the bytes actually match. Rule coverage via CDP
(CSS.startRuleUsageTracking) across 15 router pages plus gallery.html gave 92%; a second
pass through the branches the first missed (top layout, dark mode, wallpaper, reduced motion,
Russian locale) took it to 96%. The remaining ~4.8 KB is four conditional branches that each
have to exist: @media print, the latin-ext/cyrillic @font-face blocks, an assoclist rule
that needs a page with connected clients, and the licence banner.
Trap in the measurement itself: CDP returns only used rules in
ruleUsage, and an@layer/@mediablock is reported together with everything nested in it. The first two attempts honestly reported "100% coverage" and were lies. Collect the use ranges, merge the overlaps, and subtract from a parse of the file. A number obtained any other way needs redoing.
And it is not a deletion list. The coverage contract forbids removing a selector because it was not seen — see conventions.md. The measurement answers "where is the weight", not "what can go".
No structural reserves either. Selectors are 37% of the file, declarations 58%. Native nesting,
under an honest criterion (adjacent groups only, no reordering that would break the cascade), saves
4.2 KB / 3.5% — not worth rewriting the tree for ~3 ms on the wire. Vendor prefixes are 648 B,
data URIs 2.2 KB, all needed. The largest rules (the :root token block, the palettes) are
foundation, not fat.
Compression is unavailable in principle. gzip would take ~132 KB to ~23 KB, the biggest
possible win — but uhttpd has no compression option at all, in neither -h nor
/etc/config/uhttpd, and it does not serve a pre-compressed .gz. A grep of the whole uhttpd
source for gzip|content-encoding|deflate|zlib|brotli returns exactly one hit, a MIME type. The
2015 patches were not merged. Not our lever until the web server changes.
Also tried and rejected, so nobody re-derives them:
<link rel=preload>for JS modules is harmful, not merely useless. LuCI fetches modules by plain XHR, whose mode does not match preload's CORS mode, so every file downloads twice: FCP 336 → 460 ms, 708 → 862 KB on two modules. Retested asas="fetch" fetchpriority="low"over 22 modules: 49 → 71 requests, +250 KB wasted, wall time unchanged. No form of preload deduplicates against the XHR loader.- Flattening the
requiregraph at the entry point hits the connection limit, not the depth. A flatL.requirelist does collect the four waves into one — and gains almost nothing (835 → 804 ms at 60 ms RTT), because HTTP/1.1 keeps 6 connections per host and the waves simply re-form as batches of six. Worse,menu-footstrapfinishes at 395 ms instead of 200, queued behind twenty siblings. - Warming
uci.load()for common configs: 1113 → 1106 ms, identical XHR count. - Service Worker and CacheStorage are unavailable in principle. A LAN IP over http is not a
secure context, so
serviceWorker,cachesandnavigator.storageare allfalse. The whole precache/offline family is out — not expensive, impossible. (DecompressionStreamis available, so "ship.gzand inflate in JS" is technically possible: 135 KB → 23 KB. At the cost of FOUC on a cold load, a<noscript>fallback, and zero gain warm. Not done.) - Splitting into a critical and a deferred sheet trades the wrong way: cold FCP 336 → 276 ms, but warm 108 → 180 ms — and an admin browses with a warm cache.
- Dropping the font preloads: −24 ms FCP at the cost of FOUT. Overtaken by 0.12.1, which dropped the webfonts themselves — there is no preload and no FOUT left to trade.
content-visibilityfor long tables: empty. Real router tables are short (Startup 46 rows, Processes 34) and a full layout pass costs 2–3 ms.
Where the time actually is. Re-measured after the webfonts left, on the owlab 25.12.4 stand with the packaged artefacts (mangled sheet, terser'd JS), overview page, five cold contexts, medians: 691 KB over 56 requests, of which ours is 187 KB (121 774 B stylesheet plus 69 593 B of JS), the LuCI core 485 KB, the document 18 KB and fonts 0 KB. The core figure is this stand's package set, not a floor. FCP is set by the sheet as a whole, but cutting the sheet by 10% is ~10 ms — to move FCP visibly you would have to halve it, and as shown above there is nothing to halve. A warm SPA transition costs 9 ms against 96 ms for a full load: the theme's main optimisation is already done, and it lives in the router (spa-router.md), not in the stylesheet.