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

540 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](design-system.md). The rules a patch must follow:
[conventions.md](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
```css
@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.
<!-- css-floor -->
**Chrome 108**, **Firefox 101**, **Safari 15.4** — below that the page is wrong, not plainer.
<!-- /css-floor -->
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](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 .b` is a descendant, `.a.b` is not;
- **spaces inside `calc()`** — mandatory around `*`, `/` and the minus in `calc(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-c` became undefined, `--fs-bg` invalid at
computed-value time, and the canvas silently went white (caught by `export-tier` as 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):
1. **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 `--dev` build.
2. **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):
```css
.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:
1. **it fits** → it stays a table, and nothing was written;
2. **drop the columns the view marked expendable** (`hide-xs`/`hide-sm`, which `ui.Table` copies from
the header cell onto every body cell) and ask again — upstream's own priority hint, honoured by
measurement instead of at 767 px;
3. **let the widest breakable column shred** (`.fs-td-break`; never the first column, never a `nowrap`
one) and ask again;
4. **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 and `audit.py` refer 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-face` restarts 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 for `document.fonts.ready` before each
snapshot.
- **`admin/status/overview` polls 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`/`@media` block 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](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 as `as="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 `require` graph at the entry point** hits the connection limit, not the depth.
A flat `L.require` list 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-footstrap` finishes 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`, `caches` and `navigator.storage` are all `false`. The whole
precache/offline family is out — not expensive, impossible. (`DecompressionStream` *is*
available, so "ship `.gz` and 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-visibility` for 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](spa-router.md)), not in the stylesheet.