mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-10-30 16:25:13 +08:00
update 2026-09-20 01:22:33
This commit is contained in:
1 parent
42e0cfbec4
commit
deb27aee9d
864 files changed
+180566
-42
No files matched your search
@@ -0,0 +1,428 @@
|
||||
# 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](architecture.md).
|
||||
|
||||
## The two commands
|
||||
|
||||
```sh
|
||||
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:
|
||||
|
||||
```sh
|
||||
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](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.
|
||||
|
||||
```css
|
||||
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](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](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](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:
|
||||
|
||||
```js
|
||||
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](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.
|
||||
Reference in new issue
Block a user