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

33 KiB
Raw Blame History

Conventions

The rules a change to this theme has to follow. Every one of them is a bug that was hit, and most are enforced by a gate — the gate is named next to the rule so you can check yourself before pushing.

Read this before your first patch. For what the theme is, start at architecture.md.

The two commands

npm run check        # every gate below, in one run; must exit 0 before you push
owlab test --release 25.12.4 --install 'dist/noarch/luci-theme-footstrap-*.apk' \
  --assert 'package luci-theme-footstrap' \
  --assert 'http 200 /cgi-bin/luci/admin/status/overview'

…and the same again with --release 24.10.8 --install 'dist/all/*.ipk'. One run per format: --install is a host-side glob evaluated per router, so a pattern matching both hands the apk box an ipk.

Nothing in package.json is shipped. luci.mk copies htdocs/ and ucode/ verbatim and never invokes node, and the OpenWrt buildbot has no node at all.

Keep owlab installed, and run the change on a router

owlab is required, not a convenience. Install it once and keep it:

go install owfeed.org/owlab/cmd/owlab@latest
owlab doctor

A change that alters behaviour is not finished until it has run on a real OpenWrt userland, on both package managers — 25.12/apk and 24.10/opkg. Not "it should work", not "the gates are green": run it. owlab up gives you four disposable routers from owlab.yaml, and owlab test is the local form of the same assertions CI's verify job makes. How: development.md.

Why the rule is written down rather than assumed: every gate in this repository is static. They read files. Not one of them opens a page. The classes of bug they structurally cannot see:

  • a preference that writes to the wrong place (the appearance axes were gated on their contract while one of them silently rewrote a router-wide default);
  • anything that only appears once a session, a poller or a second browser exists;
  • a template that compiles and renders the wrong thing;
  • an install path — postinst, uci-defaults, the marker file, a conffile surviving an upgrade;
  • any difference between apk and opkg.

A stubbed harness in node is worth running for what it does prove — that a module initialises, that an axis stores where you think — but it proves nothing about the router. Say which of the two you did; do not let one stand in for the other.

If you genuinely cannot run it (no Docker on the machine), say so plainly in the commit or the PR rather than reporting the change as verified. An untested change described as tested is worse than an untested change.

Writing CSS

Read only the private tier. Every rule in styles/ reads --fs-* and nothing else. The export names (--primary-color-high, --text-color-*, …) are defined from the private tier and are for third-party apps only.

color: var(--fs-dim);                                    /* yes */
color: var(--text-color-medium);                         /* no — audit.py --strict fails */

Why: :root is one shared scope, and every luci-app-* puts its CSS in the same document unlayered, which outranks any @layer. A hostile :root recoloured 312 of 336 gallery elements before the tiers were split, and 0 after — the measurement and the two tiers are in design-system.md.

Put the rule in the right layer instead of reaching for !important. Layer order is tokens, base, theme, page, and a later layer beats an earlier one regardless of specificity — so a theme rule never needs a flag to beat base.

!important inverts layer order — the mechanics and the numbers behind that are in css.md. Two consequences for a patch:

  • If a rule needs a flag to beat another footstrap rule, the rule is in the wrong layer. Move it or merge the two.
  • A legitimate flag fights only what layers cannot outrank: inline style= and unlayered rules injected by an app.

The one flag sanctioned against our own rules is theme/95-a11y-media.css: prefers-reduced-motion has to kill animations declared in base too, and only an important declaration reaches back a layer. Everything else on the allowlist fights something outside the cascade — theme/90-responsive and pages/20-overview outrank a style= written by 29_ports.js and ui.js, theme/45-misc widens the box the realtime graphs size inline and repaints its inline black border, theme/65-dropdown frees .hide-close from a RichListValue's inline min-width:25vw, and styles/base keeps the six .left/.right/… forcing utilities plus two inline-style fighters. audit.py keeps that list (BANG_OK), .stylelintrc.json states the reason for each file, npm run bang-ok holds the two in step, and css-metrics caps the total at 27.

Win on specificity, never on source order. Two rules with the same specificity where the later one is load-bearing is the same failure as 220 !important, only quieter. Cap: [1,7,0], held by css-metrics.

Edit the rule that already styles the selector. Do not append a second one. The shadowed- declaration counter in audit.py is at 0 and stays there.

No colour literals. A #hex follows neither the palette nor dark mode. The single exception is --fs-scrim — black at .7 is the absence of light behind a dialog, not a shade of any token.

Stay inside your zone. There are three: the theme's own chrome (fs-* names, data-fs-chrome roots — ours alone, and fenced against foreign CSS); the shared LuCI widget surface (cbi-*, .table, .alert-message — ours to style, nobody's to own); and another package's namespace, which the theme never reaches into. fs-overview.js used to live in luci-mod-status's global include directory, where LuCI evaluates every *.js — so it was downloaded and run on the overview of routers using a different theme. It is a chrome module now. The fence that keeps foreign CSS out of zone one is described in third-party-apps.md.

Coverage is a contract. A selector no stock LuCI page renders still gets styled: some third-party luci-app-* emits it. "Looks unused, delete it" un-styles somebody's app. PurgeCSS, uncss and any coverage-based trimming are forbidden here. The one safe dead-CSS search is css-orphans, and it is safe only because nothing outside the theme can emit an fs-* class.

Pin a duplicate you cannot merge. css-dup finds identical declaration bodies under mutually exclusive guards — a shape no linter calls an error and the shape that drifts. Worse, css-dup matches identical bodies, so it goes quiet exactly when the copies diverge. Every duplicate body must be either merged or wrapped in @mirror, and tools/mirror.mjs then holds the copies byte-identical. A @mirror group with one copy is a hard failure.

A display rule on .table/.thead/.tbody/.tfoot/.tr/.th/.td needs a role to match. A display other than table/table-row/table-cell/… drops the implicit table/row/cell role HTML-AAM derives from it (WCAG 1.3.1), and fs-select.js's role map is what puts it back. tools/table-contract.mjs holds both directions: it fails if the role map shrinks and it fails if a theme selector sets display on one of these classes with no entry in the map to pair with it.

A flex item's automatic minimum size defaults to its CONTENT size, and overflow is what zeroes it. Any flex item with no explicit min-width (row axis) or min-height (column axis) still has one: the automatic minimum size, which resolves to the item's min-content box unless the item's own overflow is something other than visible, in which case the automatic minimum drops to zero (CSS Flexbox §4.5). One cause, four symptoms in one session, in both directions:

  • .cbi-checkbox is inline-flex and its pill had no flex-shrink: 0 — the automatic minimum let the pill compress below its declared --sw-w while the knob kept its literal size and a travel distance derived from the width the pill no longer had.
  • Under 767px a form label is flex: 1 1 100% with no min-width on ssclash — its automatic minimum fell back to content width and it escaped its own section.
  • A closed dropdown's overflow: hidden; text-overflow: ellipsis were already correct and inert: the automatic minimum held the li at content width, so there was nothing left to clip.
  • The opposite fault, same rule: overflow: hidden on a row-action button zeroed its automatic minimum, so flex-grow divided the row by count instead of by need and every button converged on one width at every viewport — a desktop regression shipped as the fix for a phone one.

Before adding a declaration to a flex item, name which of the two behaviours it needs: a button that must actually shrink wants overflow: hidden (or an explicit min-width: 0) to reach zero; a label or a pill that must clip while holding its own width wants the overflow property paired with an explicit min-width/max-width that is not auto, so the automatic minimum never gets a vote. No gate holds this rule in general. pseudo-loc catches the first shape after the fact — a caption that grew and then overflowed its shrunk pill reads as a finding — but not the third: an inert text-overflow: ellipsis produces no overflow at all, because the box it sits on was never given the room to shrink in the first place, and nothing measures a clip rule that never fires.

Writing JS

Never put a regex literal straight after return or =>. jsmin (which luci.mk runs on the buildbot) decides whether / opens a regex or divides by looking at one preceding character, and neither n (from return) nor > (from =>) is in its list:

return /^https?:\/\//i.test(a);      /* jsmin reads // as a comment, EATS THE REST OF THE FILE
                                        and exits 0 */
return (/^https?:\/\//i.test(a));    /* `(` is in the list — safe */

Upstream bugs: openwrt/luci#8299, #8020, #8021, #8256. A zero exit code proves nothing — the corruption is silent. Two gates: eslint's wrap-regex forbids the shape, and tools/jsmin-verify.mjs builds the same jsmin and fails unless the token stream of the minified output matches the source. A backtick inside ${…} in a template string is out too (jsmin loses the string, but that one fails loudly).

Comments are free on the router. They are ~60% of the JS source and none of them ship: release CI pre-minifies with terser, and a node-less build runs jsmin. What a comment says is governed by "Comments" below.

A required module is a singleton; there is no inheritance between modules. 'require X' hands you a constructed instance, not a class, so base.extend from another module throws base.extend is not a function, and returning a plain object throws factory yields invalid constructor. Compose instead: menu-footstrap-common.js exports init(renderMainMenu) and menu-footstrap.js injects its renderer as a parameter.

Require a stock class through window.L. The L a module factory receives and window.L are different objects, and ui hangs its helpers (itemlist, showModal, …) on window.L only. Worse, require() caches by class name, so the first requirer fixes the class↔L binding — a theme module that beats the router to a stock class makes the page die on L.itemlist is not a function, intermittently. Use const RT = window.L; RT.require(name).

E() cannot build SVG. It goes through document.createElement, which is the HTML namespace. SVG in the theme is assembled as a string with fixed markup, and user data goes in separately via textContent. This is a namespace fact, not a style choice.

All "does it fit?" logic goes in fs-fit.js. One engine, one ResizeObserver, one per-frame coalescer for the whole theme. Do not grow a second observer. Its three rules — measure uncollapsed, re-fit synchronously on a mutation, coalesce on resize — are each a bug that was hit; they are documented in the file header.

Never answer "does it fit?" with a viewport breakpoint. matchMedia('(max-width: 767px)') used to decide this, and between 768 and 779 px the chrome drew as a bar while the menu still behaved as an accordion. The sidebar gives way when the content column would be unreadable, which depends on the slice (224 px expanded, 68 px rail) — one breakpoint gives both states the same answer. data-narrow on :root is the single source of truth, read by both CSS and flyoutMode().

Observer hygiene. Watch the narrowest node, use attributeFilter, guard against your own writes, coalesce into one frame, disconnect() on teardown. LuCI's poll rewrites content once a second, so a loose observer on document.body runs a full scan every tick.

Listener lifecycle: AbortController + { signal }, abort() on teardown. It is the only pattern that reliably removes anonymous handlers.

Comments

The reader is a model with this file open and no session history. It needs, per line it is about to change: what must stay true, why, and where the proof is. Everything else costs it context and gives nothing back. CLAUDE.md carries the short form; this is the full rule, with the example behind each line.

What a comment carries

part example required
the invariant min-height AND height are pinned to the same value for the whole pass always
the reason, as a number the bar walked 230 -> 123 px inside one pass, 107 px of growth when one was measured
one pointer docs/anchoring.md, "The corrections"; openwrt/luci#8981; issue #41 when the proof is longer than a line

One of each, in that order, in one sentence where it fits. A comment with no invariant is deleted, not reworded: restating the line it sits on is what it did.

What a comment does not carry

cut goes to why
attempts and their order ("first shape … second shape …") docs/<area>.md, one paragraph per attempt the code is the survivor; the losers are history
a task or session name (task refill2) nothing — cite the docs heading instead names nothing the reader can open
a ../tmp/… path docs/<area>.md restates the finding the file is not in the repository; the pointer is dead the next day
a CI run id the changelog entry, once the run expires; the number it produced does not
the same fact the docs already hold one line and the pointer two copies drift
CAPS for emphasis, a rhetorical question, a warning to a future maintainer plain prose an LLM weights capitals as shouting, not as priority

Size

comment limit
inline, on or above a statement 1 line; 2 when the reason is a number
above a function or a rule block 8 lines
module or file header 15 lines: purpose, invariants, what the file does not own
comment share of a shipped file under 40 % of the bytes

Over the limit means the text is an explanation, and an explanation is a docs/ section pointed at from one line. fs-fit.js at 85 % comment bytes is the measurement this table comes from: the rules it documents are sound, and no reader finds them.

Rules that hold regardless of size

  • A number or a name is part of the contract. 15 comments once said the poll re-renders "once a second" while pollinterval ships at 5 s. The comment changes in the edit that changes the code, or git preserves the lie.
  • A negative result stays, in one line, present tense. display: none on a zeroed tab pane buys nothing: scrollHeight 1039 either way. Not how it was found, not which attempt it was.
  • References survive every compression pass: issue numbers, spec text quoted verbatim, upstream commits, file paths inside the repository.
  • Some comments are code: @mirror name/tag / @endmirror (npm run mirror), /* fs:probe */ (strip-probes.sh), the eslint 'require …' pragmas, the Makefile's buildroot signature line (npm run marker), the literal dataset.fsFit write (tools/table-contract.mjs). Reword one and a gate or the build breaks, silently in the Makefile's case.
  • Edit the comment that is there. Never stack a second one on the first.
  • Formal English. No exclamation, no addressing the reader.
  • A # line inside a quoted ssh "$R" "…" string is part of the string. sh -n after the edit.

Where the narrative goes

The measurement that justifies a rule is written once, in docs/<area>.md, under a heading the code can cite: what was seen, what it was, the command that tells the two apart. docs/anchoring.md (the reference) and docs/anchoring-log.md (the findings) are those files for fs-fit.js. A docs section may be as long as the finding needs; the comment citing it may not.

After a bulk comment pass

Prove the code did not move: a token-stream compare against HEAD for every JS file, a comment-stripped and whitespace-normalised diff for CSS, .ut, shell and yaml. That is what caught a deleted Makefile marker and a lost shell escape. The full-tree compare is T2 (tools/bg.sh); the pass is finished when its output has been read.

Templates and translation

msgid is a GLOBAL name shared with every luci-app on the router. load_catalog merges all *.<lang>.lmo into one dictionary and returns the first archive with a matching hash, so readdir order decides whose translation wins. The layout switch rendered "Максимум" on a Russian router because somebody's catalogue translates Top that way — correct in a bandwidth dialog, nonsense on a layout switch.

  • Strings on the Footstrap tab: use a context — _(str, 'footstrap').
  • Chrome (Menu, Logout, Skip to content), login and warning strings: deliberately no context, so they inherit luci-base's translation in the ~40 languages the theme has no catalogue for.

Nothing here fails loudly: _() without a catalogue is just English text. npm run i18n fails if the .pot is stale or any msgstr is empty.

Set the custom property BEFORE the attribute. Every appearance axis is implemented twice — inline in partials/head.ut before first paint (no module loader exists yet) and live in fs-prefs.js — and the order is load-bearing. Reversed, a reload paints exactly one frame in the previous hue: a symptom nobody reports and no other test catches. tools/axes.mjs derives the contract from the JS and checks the template against it.

Package and registration

One entry in luci.themes — Footstrap → /luci-static/footstrap. Layout, mode, palette, tint, accent and rounding are client axes (localStorage + attributes on :root), not theme entries and not a server choice. Do not reintroduce -dark/-light symlink themes or layout-specific entries.

A fresh install may activate the theme; an upgrade must never change the active one. What tells the two apart is whether the registration was already there: uci-defaults writes mediaurlbase only in the run that first added luci.themes.Footstrap, which is the idiom the other themes in the tree use. $PKG_UPGRADE is checked as well and carries nothing on its own — apk never exports it.

rpcd reload, never restart. rpcd holds sessions in memory; a restart logs out every LuCI user including the admin who just clicked Update. reload re-reads acl.d/*, which is all this package needs.

One asset per package per format in a release. Nothing on a router picks an asset any more — the installer takes the feed — but a reader that does gets one candidate, not a guess: GitHub returns assets sorted by name, and in v0.8.4 a luci-i18n-… package sorted ahead of luci-theme-…, so the self-update script shipped at the time installed a 6 KB catalogue instead of the theme, reported success, and offered the same update forever (issue #6). Code already on somebody's router cannot be fixed remotely; only the release can. CI fails unless each package resolves to exactly one asset under its name-anchored regex.

The translation catalogue lives in po/. That is the directory LUCI_LANGUAGES globs, so luci.mk bakes a luci-i18n-footstrap-<lang> package per language exactly as it does for every luci-app — and it is the only directory Weblate, which CONTRIBUTING names as the way to translate LuCI, can see. It was i18n/ while a fielded self-update script resolved the theme by name and took head -1 (issue #6); that script is retired and owfeed builds the release as one artifact per format regardless, so the rename no longer bought anything.

No runtime dependency beyond +luci-base. curl is not in OpenWrt's default set (the base image ships uclient-fetch); fall back, do not depend. jsonfilter, sha256sum and usign are in the base image.

The trust chain

install.sh installs from the owfeed-packages feed, so the package manager is what verifies the bytes: apk checks the index against owfeed-packages.pem, opkg against usign key 9040356b214084da, and both keys are pinned in the script itself — it runs from wget | sh before any package of ours exists. The script's own fetch of those keys uses a verified TLS channel: never -k / --no-check-certificate, and never as a retry, because a failed verification is the MITM case.

The installer no longer downloads release assets, so it carries no sha256, no usign -V and no --allow-untrusted. The release still signs everything — a manifest.txt plus a detached .sig per asset, both verified in the release job — because that is what a by-hand install and a mirror have to be checked against.

An ed25519 signature is the link that holds and a sha256 alone cannot: GitHub computes the asset digest from the uploaded bytes, so whoever can swap an asset gets the digest recomputed for them.

Changelog and release

Every substantive commit writes into ## [Unreleased] in the same commit as the code — a changelog written afterwards is written from the diff, and the diff is exactly what does not know why. Format, categories and the release runbook: releasing.md.

The gates, and what each one holds

Gate Holds
lint eslint over htdocs/ and ucode/, stylelint over styles/ — correctness only, not formatting
audit audit.py --strict: undefined var(), shadowed declarations, export-tier reads, dead base declarations, stray !important, colour literals
css-metrics ratchet: !important ≤ 27, max specificity [1,7,0], 0 empty rules
css-orphans dead fs-* selectors — it gates the forward direction (styled, emitted by nothing) and reports the reverse, where an unstyled class is often legitimate (a JS hook, an element riding on inherited styles). A new name in the reverse list wants a look or a line in JUSTIFIED_UNSTYLED; it does not fail the build
acl every shipped acl.d/*.json parses and grants something — rpcd skips an unreadable file silently
css-dup identical declaration bodies under different guards
tables the table contract: where a cell may break (one allowlist, no viewport queries), the floor holds when a data table is squeezed, a carded cell prints its caption, and no .cbi-dropdown sits inside a scroll container
mirror @mirror-pinned copies still byte-identical
bang-ok every !important sits in an allowlisted file
axes the pre-paint in head.ut agrees with the live appearance appliers, and header.ut reads every saved option back
scroll-anchor (live) grows something above the reader and asserts the page does not move under them — twice, once with the engine's own scroll anchoring suppressed (the Safari path, forced on any engine with localStorage.fsEngineAnchor='off') and once without, so a fallback that also runs where the engine already corrects is caught as well. Plus a scripted flick up and down: the theme may not correct WHILE the reader moves. Three page shapes (section bodies, a polled table's rows, a bare table under #view) across the two scrollers a layout and a width can produce; --full adds the axes measured not to change the answer, which is what CI crosses on a push
table-tick (live) performs a poll tick on purpose — rows out, rows back in, marks stripped, then a forced layout — and fails if the replaced table was laid out before anything answered for it. The intermediate lasts a microtask, so no sampler can see it: with the stylesheet's gate removed this reports 613px on Обзор@390 and 817px on Processes, and nothing with it in place
floor-contract (live) the poll floor: every data-fs-floor box is stripped and re-measured with offsetHeight, and the two must agree within 4px; a tab switch must leave no floor on the pane the reader left; and the tallest floor on the page is emptied and must be given back — judged against what the EMPTY box stands at, since a box with no children still has its padding (34px on /admin/network/network either way). All three faults shipped and none is visible in a file or on a page that is only loaded: 41px short on .cbi-section-descr (/admin/network/dhcp), 2432px left on Initscripts after a tab switch, 927px never taken off on Network → Interfaces. A box the poll refilled or replaced is not judged
fit-quiet (live) the bar may not get shorter while it measures itself. fitChrome() strips the layout classes to ask whether the menu fits a row, and the bar is above the reader on every page: 33px of dip at 767px, 8px at 480px, which an engine with no scroll anchoring lays the page out against. The gate gives the bar a classList of its own and reads the height back at the instant the classes come off — the symptom does not reproduce headless, so the cause is what is watched
page-modules the data-page → module map in menu-footstrap-common.js names the same page each mapped module tests for itself, every mapped module exports the wire() the loader calls, and no 'require' pragma is left for one anywhere — one pragma puts the file back on every page and takes the saving with it
chrome-fence the [data-fs-chrome] marker, fence and pin still match the chrome
export-tier the --*-color-* contract: each level readable as text on three surfaces, each --on-* readable on its fill, and the ramp is not flat — measured with and without prefers-contrast: more, which re-states the inks
css-i18n translatable strings emitted from CSS
conffiles every shipped /etc/config/* is declared a conffile — /etc/config/footstrap is written at runtime by Save-as-default, and an undeclared one is replaced on upgrade
changelog section set, order, dates, compare links, RU mirror parity, bold leads
i18n .pot current, no empty msgstr
shell every shell script in the source tree parses (sh -n) — including release-notes.sh, which otherwise fails inside the release job
marker the call BuildPackage literal include/scan.mk greps for, without which the SDK does not see the package at all
a11y axe-core WCAG 2.2 AA over docs/gallery.html, {light,dark} × {footstrap,hicontrast,bootstrap,2020,forum} × {untinted,60°,260°}
placeholder-ink a hint may not read as a value the reader typed: every placeholder attribute and every li[placeholder] row on docs/gallery.html, across all eight palette/mode combinations, must have travelled ≥40% of the way from the field's own ink to its fill in light and ≥30% in dark (oklab lightness), and still measure 3:1 on that fill — SC 1.4.11, not AA's 4.5, which is the decision the token records: a dark palette has 6.45:1 of ink to spend against light's 14.84:1, and a hint that clears AA has not moved far enough to stop reading as a value. Every combination runs again under prefers-contrast: more, where the query hands the AA ink back and the thresholds swap (≥15%, 4.5:1). a11y is excluded from li[placeholder] for the same decision — axe measures that row as text and skips the attribute carrying the same ink. axe-core skips ::placeholder entirely and the old ink was legal by every threshold the theme had — 11.12:1 on footstrap light, against the value's 14.84:1. It also caught a colour rule that had never applied: li[placeholder] (0,2,2) lost to the menu row's own color (0,3,2)
test the unit suite (node --test, no browser): the shipped module is evaluated inside the same wrapper luci.js uses, and its pure logic is driven directly. For the cases a stand cannot produce — a luci-base with a surface missing, an alias loop in a foreign menu.d, a firstchild tie — not as a second opinion on what the stands already cover
size ceiling on what the router SENDS, pinned once per release by /release (--pin, measured + 2 %) and never raised between releases: cascade.css after build-css.sh + the token mangle, and the shipped JS after terser — the package build's own asset half, reproduced. uhttpd serves /www uncompressed, so these are wire bytes
icons the committed app-icon rasters still match logo.svg (per channel, with a tolerance) and still hold the maskable invariants — they are generated by tools/build-icons.mjs and cannot be rebuilt on the buildbot

The live gates, and why a file cannot answer for a page

npm run live — three gates that need a running owlab router, which is why they are not in npm run check. Every gate above measures a file; every bug users have reported was about a page: #11 a column shredded to one character per line, #22 a clipped submenu title, #14 an indicator that did not fit, #10 phantom scroll from a hidden pane, #12 a doubled scrollbar in one engine, #8/#33/#36 a third-party app laid out wrong, and — reported on the upstream PR — two pages that came back empty after a client navigation while every static gate stayed green.

Gate Holds
upstream the coupling registry: every assumption fs-*.js makes about luci-base (L.Poll's alias and queue, uci.state.values/loaded, uci.load() answering "what did THIS call fetch", network.js loading its three packages exactly once, require() publishing onto L, the modal contract, where addNotification puts a banner, an open dropdown being position: absolute) checked against the luci-base the router runs. Each failure names the module here that was written against it
spa-parity every menu page opened BOTH ways — by click and by full load — compared on content, on uci's cache, on network.getWifiDevices() and on console errors. No baseline: a difference is always a bug
install-check install.sh run twice on a stand — fresh, then over its own result — asserting the package is installed, luci.main.mediaurlbase points here and cascade.css is on disk. Three field reports (#16, #28, #30) were this script alone, all on the second-run path. It leaves the PUBLISHED release installed and re-syncs the working tree afterwards, so it runs last
scroll-jank scrolls with a real wheel, long enough that a poll tick lands inside it, in BOTH layouts (they scroll different elements) and on every engine asked for: a table may not re-decide its remedy while the page moves, an element's document position may not change under the reader, and Chromium's layout-shift score for the window must stay under 0.02. The pass the theme defers to the stop is reported in a phase of its own, never failed. Chromium by default; --engines chromium,firefox,webkit needs the other two installed
live-audit every menu page at 320/390/568/768/1024/1440, each resized into AND one (768 by default) ENTERED with a load of its own — a fault that only exists on arrival is invisible to a resize, which is how a table 65px past its column reached a user; the arrival records only what the resize at that width did not, because a fault both passes see is one fault: sideways document scroll, an element past the content column with no scroller, a clipped non-scrolling box, a sub-24px hit target with a neighbour, an operable element with no name, two stacked scrollports, a JS error. Ratcheted against tools/baselines/live-audit.json — a NEW signature fails, and --update (read the diff first) rewrites it. That file is a union across platforms: a handful of findings sit within a pixel of their threshold and text metrics differ between a maintainer's containers and CI's runner, so six signatures first appeared in CI

--engine firefox and --engine webkit run the audit in the other two engines, keyed separately in the baseline: #12 was Firefox-only, and a chromium run must not bless a finding it never saw.

Two more run in CI only. tools/jsmin-verify.mjs needs a jsmin built from the commit in luci-upstream.pin. ucode -T -c over every template runs inside the verify containers, against the installed theme with the router's own interpreter — which is also how you run it locally (owlab exec … ucode -T -c), so nothing here has to build one.