# Development How to bring up a dev router, push a change to it, and prove the change did what you meant. Rules a patch has to follow: [conventions.md](conventions.md). Building a release: [ci.md](ci.md). ## Two modes of working 1. **The fast loop (no package build)** — edit files, push them straight to a router. The theme is templates plus static assets; the only build step is `build-css.sh`, which concatenates `styles/` into `cascade.css` with nothing but `cat` and `awk`. This is the normal mode. 2. **A real package** — for distribution, and for verifying a clean install. ## Install owlab first — it is not optional **owlab is a required part of this checkout, not a convenience.** A change is not finished until it has run on a real OpenWrt userland; see the rule in [conventions.md](conventions.md). ```sh go install owfeed.org/owlab/cmd/owlab@latest owlab doctor # what this machine can do (Docker, arch, emulation) ``` Docker is the only other requirement. Everything else comes out of `owlab.yaml`. ## The dev stand: four containers Brought up by [owlab](https://github.com/owfeed/owlab) from `owlab.yaml` in the repo root. There are **four** routers because the differences that bite are runtime ones and one box will not show them: three axes — package manager, LuCI feed (upstream vs fork), release — covered pairwise by four boxes. | id | distro | release | manager | LuCI | |---|---|---|---|---| | `owrt2512` | OpenWrt | 25.12.4 | apk | http://localhost:8025 | | `owrt2410` | OpenWrt | 24.10.8 | opkg | http://localhost:8024 | | `imm2512` | ImmortalWrt | 25.12.1 | apk | http://localhost:8026 — not a gate target | | `imm2410` | ImmortalWrt | 24.10.6 | opkg | http://localhost:8027 — not a gate target | ```sh owlab up # build and start all four owlab sync --watch # rebuild the CSS and push on every edit owlab open owrt2512 # open LuCI in a browser ``` Log in as `root` with an empty password. Inside is the release's real userland (procd as PID 1, netifd, ubus, rpcd, uhttpd) from its own rootfs tarball, not a home-made imitation. - **Reach them only through `localhost:`.** The docker bridge address routes from the host on native Linux and inside WSL2, but not on Docker Desktop for macOS or Windows — so no command here uses it and the stand behaves identically on any OS. - **Rebuilding an image is a factory reset**: there are no volumes, so `owlab up --rebuild` wipes the pushed theme and you re-run `owlab sync`. That is wanted — it exercises the install path for real, on both package managers. - **There is no `curl` on them**, exactly as on a stock router. Run a curl snippet from the host against `localhost:`, not through `owlab exec`. - **owlab disables mwan3 and watchcat itself.** mwan3 decides the dummy WAN is dead and installs `ip rule … blackhole`: LuCI answers while all outbound traffic hangs with no error. - A hardware router is still reachable as `ssh router`, and `luci-theme-footstrap/dev-sync.sh` pushes to it — for when the question is genuinely about hardware. ## Pushing a change ```sh owlab sync # to every router owlab sync owrt2512 # to one owlab sync --watch # and thereafter on every edit ``` `sync` puts files exactly where `luci.mk` would and drops the same caches its postinst does. The steps are spelled out in `owlab.yaml`: - `build:` rebuilds `cascade.css` from `styles/` (`build-css.sh --dev`, comments intact) before every push. Without it everything is copied except the file LuCI actually requests, and the router 404s on its own stylesheet; - `install:` maps the package directories onto router paths; - `post_sync:` registers the theme and removes legacy directories — here rather than through `root/etc/uci-defaults/…`, because `sync` deliberately does not overwrite `/etc/config` or `/etc/uci-defaults`: that is router state, not package content; - `theme: footstrap` — owlab sets `luci.main.mediaurlbase` after the push. Installing the package only registers the theme, which on a dev stand is the opposite of what you want. Resource JS is copied by glob (all of `htdocs/`), never by a list of names. The list was a bug: a new file made it into the package (luci.mk copies `htdocs/` wholesale) but silently never reached the dev router, so it was first exercised after the release. What `sync` does not do: stamp `FS_VERSION` (the Footstrap tab shows `dev`) and compile `po/*.po` into `.lmo` (strings stay English). Both belong to a real package build, which is where they should be verified. ## If you break it - **A broken template does not brick the UI.** If `header.ut` does not compile, LuCI falls back to the first working theme in `luci.themes` and shows a "Theme fallback" indicator carrying the error. - Manual rollback at any time: ```sh owlab exec owrt2512 -- 'uci set luci.main.mediaurlbase=/luci-static/bootstrap && uci commit luci' ``` - If everything is broken: `uci` is reachable over ssh, and LuCI is not needed to recover. ## Caches while iterating - The menu and dispatcher are cached in `/tmp/luci-indexcache..json`. The hash comes from menu-file mtimes, so it updates itself — but if things look strange: `owlab exec owrt2512 -- 'rm -f /tmp/luci-indexcache*'`. - `.ut` templates are not cached between requests (ucode compiles on the fly) — an edit to `header.ut` is visible on F5. - CSS/JS are cached by the browser. `cascade.css` is served with `?v={{ pkgs_update_time }}`, so touching the package database changes the key and an ordinary F5 picks the file up. **Which file that is depends on the release**, so touch both: ```sh owlab exec owrt2512 -- 'for db in /lib/apk/db/installed /usr/lib/opkg/status; do [ -f "$db" ] && touch "$db"; done' ``` Naming only the apk path means the key never changes on 24.10: the file arrives, the browser serves the old one, and it looks exactly like an edit that did nothing. ## Verifying a change **A template** — with the same `trycompile` LuCI uses, which is also what CI runs: ```sh owlab exec owrt2512 -- 'ucode -T -c -o /dev/null \ /usr/share/ucode/luci/template/themes/footstrap/header.ut' ``` **CSS** — not with screenshots. Live counters (uptime, DHCP leases, wifi signal) move 0.5–1.3% of pixels between two runs of the *same* stylesheet, while a real regression weighs 0.19%. Diff computed styles instead: load the page once, swap the `` for the second sheet, snapshot `getComputedStyle` over every element. Method and traps: [css.md](css.md). **Behaviour** — on a router, with `owlab test` (next section). The gates cannot see behaviour, and a stubbed harness only proves a module loads. **Pure logic — and only the part a router cannot show you** — with the unit suite: ```sh npm test # node --test, ~100 ms, no browser and no stand node --test tests/menutree.test.mjs # one file node --test --test-name-pattern 'alias' tests/ # one case ``` `tests/lib/luci-module.mjs` evaluates a shipped `fs-*.js` inside the same wrapper luci.js uses — `function (window, document, L, )` — so the file under test is the file that ships, not a rewritten copy. `window` and `document` are recorders: they answer the few reads a module makes while it evaluates and remember the listeners and timers it registered. **What belongs here is what a stand cannot produce.** The suite is not a second opinion on layout or on behaviour — there is no box, no paint and no event dispatch, and a measurement faked here would be worth less than nothing. It is for the branches the two stands can never enter: a luci-base with a surface missing (`tests/router-contract.test.mjs`), an alias loop planted by a foreign `menu.d`, a `firstchild` tie broken by key order, a leaf whose own ACL re-opens a read-only path (`tests/menutree.test.mjs`). If a case can be seen on `owlab`, it belongs on `owlab`. Three more kinds have since earned a place here, and each one is a fault the suite FOUND: | File | The branch a stand cannot hold still | |---|---| | `chrome-geometry.test.mjs` | the column's width per combination of layout, rail and window — pure arithmetic over four measured numbers, and the only place every combination can be asked at once. Whether the numbers still describe the page is `live-audit`'s question, not this one | | `interval-pause.test.mjs` | a `visibilitychange` landing inside a specific window: a hide across an in-flight navigation, a view clearing its own timer after a hide/show. Both need a race won on purpose; the harness dispatches the event exactly | | `session-expiry.test.mjs` | the verdict the two interceptors reach on a reply, in both directions. A stand would have to expire a real session mid-run — a fixture, not a test | The rule is unchanged: the harness may not fake a measurement. What these drive is a decision made from numbers somebody else measured, which is a different thing. **Everything else** — the static gates: ```sh npm run check ``` One run covers lint, `audit.py --strict`, the CSS ratchets, orphans, duplicates, `@mirror`, the appearance axes, the chrome fence, the export tier, the rpcd ACL, i18n and axe-core. What each gate holds: [conventions.md](conventions.md). `build-css.sh` additionally checks its own brace balance and refuses to write a suspiciously short file. Two gates run in CI only: `tools/jsmin-verify.mjs`, which needs a jsmin built from `luci-upstream.pin`, and `ucode -T -c` over every template, which the `verify` containers run against the installed theme — the same command as above, so locally it is one `owlab exec`. Nothing in `package.json` reaches the package: the OpenWrt buildbot has no node. ### The three cheap browser gates: `smoke`, `computed-diff` and `pseudo-loc` Between the static gates and a stand there is a step that costs seconds and catches the regression that is in the FILE rather than in the page. Both drive `docs/gallery.html` — every widget LuCI or a third-party app can emit, with the real class names, and no router. ```sh npm run smoke # ~1.4 s: the modules come up in a real DOM, the axes stamp in order npm run computed-diff # ~4 s per viewport: worktree vs HEAD, getComputedStyle over every element npm run computed-diff -- --control # the same sheet twice; must be 0 ``` `smoke` evaluates `fs-fit`, `fs-prefs`, `fs-axes`, `fs-select`, `fs-chrome` and `fs-router` the way luci.js does — the prologue's `'require x as y'` pragmas become the factory's parameters, one copy of that derivation shared with `tests/lib/luci-module.mjs` — and then watches each colour axis write `--fs--h` before `data-`. What it adds over `npm test` is the box: the unit suite's window and document RECORD calls rather than answer them, so a module that throws the first time it measures something passes there and fails on a router. Proven to bite: inverting the two writes in `fs-axes.js` turns five checks red. `computed-diff` builds the stylesheet at `HEAD` with `git archive` (so an uncommitted edit cannot leak into the BEFORE side), loads the gallery once, and swaps the ``. Its floor is 0, and two measurements were needed to get there — the reference sheet is swapped onto ITSELF before the first snapshot, because a colour from a sheet parsed with the document serialises `oklab(…)` and the same colour from one attached later serialises `color(srgb …)` (28 phantom differences in light, 0 in dark); and every running animation is awaited, because a `span.cbi-tooltip` fade was caught at `opacity 0.00245647` by one snapshot and finished by the other. It reports rather than judges unless given `--max N`. **It runs at two viewports, not one** (task 0145): 1280 (every page's design width) and 390x844 (a phone point). A property that only diverges once a flex row is narrower than its own content is invisible at 1280 by construction — the switch-knob overhang bug measured 0 differences there on `worktree vs HEAD` with the fix reverted, and a `width` difference (26.80px vs 40px declared) only at 390. `a11y` and `export-tier` still take Playwright's 1280 default alone; the gap this closes is `computed-diff`'s. `pseudo-loc` catches what neither of the other two can: a box sized for the ENGLISH string it was built against. Both `smoke` and `computed-diff` render the gallery exactly once, in English, and a toggle knob left its pill on every Russian phone at default density (task 0145) is invisible to a gate that never renders a longer string. `pseudo-loc` replaces every visible text node and text-carrying attribute in the gallery with a pseudo-localised form — each word 45% longer, Latin letters swapped for accented look-alikes, no translation catalogue or router needed — and asserts nothing overflows its box, escapes its parent, or is clipped with no scroller to reach it, at 320 and 390 CSS px across all three densities. ```sh npm run pseudo-loc # ~4 s: 6 width x density points, English vs pseudo-localised npm run pseudo-loc -- --verbose # every finding, not just the first 8 per kind npm run pseudo-loc -- --eps 4 # raise the tolerance; re-check the intact tree still passes first ``` Every shape is measured TWICE per point — once on the page as loaded (English), once after pseudo-localisation — and a finding is only reported if it both exceeds the tolerance AND *grew* by more than `--eps` between the two. Without that, `docs/gallery.html` reports overflow that is already there in plain English (13 such findings at 320px measured on this tree: a synthetic unbroken RU compound built to exercise `min-width: 0`, a breadcrumb path, an iface stat line) — real, but not caused by translation length and not this gate's fault to raise. Three more false-positive shapes are guarded structurally rather than by a threshold: an empty `.modal` LuCI keeps in the DOM at all times would read as a host with a clean sheet if it were ever measured, so every check requires `getClientRects().length > 0`, which also excludes a collapsed submenu's links — they carry an ordinary computed `display` while their `
    ` ancestor is `display: none`, and a hidden ancestor gives every descendant zero client rects; and a deliberately fixed foreign width (the filemanager's 600px case, already decided in `tools/baselines/live-audit.json`) is excused by a plain selector ALLOW list, the same shape as `a11y-gallery.mjs`'s `.exclude()` — this gate keeps no baseline file of its own, so an allowlist is the only place left to record the decision. **Proven to bite**: `flex-shrink: 0` on `.cbi-checkbox > label[for]` (`styles/theme/60-inputs.css`, task 0145) reverted in a scratch copy outside this checkout. Intact: 117 findings, none naming the switch. Reverted: 123 findings, +6 — one per width/density point — every one `