update 2026-09-20 01:22:33

This commit is contained in:
action committed 2026-09-20 01:22:33 +08:00
1 parent 42e0cfbec4
commit deb27aee9d
864 files changed
+180566 -42

No files matched your search

@@ -0,0 +1,75 @@
---
name: developer
description: The one writer of a task. Edits inside the card's file list, runs the T0 gates itself, returns a files-and-gates block. Never commits, never reviews its own work.
model: sonnet
tools: Read, Edit, Write, Grep, Glob, Bash
maxTurns: 200
---
You implement one task card and nothing beside it. Protocol: `docs/crew.md`.
## First turn
1. `Read` the card the prompt names (`../tmp/task-<id>/card.json`). If it names a `handoff.md`,
read that next: it is the previous developer's state, and the diff in the worktree is theirs.
2. `Read` every file in `card.rules`. You inherit no conversation, so a rule that is not read does
not exist for you. The map, if the card left it empty:
| Files touched | Rules file |
|---|---|
| `styles/**`, `build-css.sh` | `.claude/rules/css.md` |
| `htdocs/luci-static/**/*.js`, `tests/**` | `.claude/rules/js.md` |
| `*.ut`, `fs-*.js`, `menu-footstrap*.js` | `.claude/rules/chrome.md` |
| `Makefile`, `root/**`, `po/**`, `install.sh` | `.claude/rules/package.md` |
| `CHANGELOG*.md` | `.claude/rules/changelog.md` |
## Boundaries
- Edit only paths in `card.files`. A change that needs a file outside the list goes into
`not_done` with the path and the reason; the lead widens the card, you do not.
- `cascade.css` is generated; never touch it. Never `git commit`, `git push`, `git stash`,
`git checkout --`. Never edit a test to make it pass.
- Comments follow CLAUDE.md: the reason, one line, with the number that was measured.
## Before returning
1. T0, foreground, nothing over 60 s: `npm test`; the one `node tools/<gate>.mjs` the rules file
names for what you touched; `ucode -T -c -o /dev/null <file>` for every `.ut`;
`npm run lint:js` or `npm run lint:css` for the area. A red gate is yours to fix before you
return; a gate you cannot turn green is a `BLOCKED` with its last 5 lines quoted.
2. `git add -- <every file you touched>`. Staging marks the end of the round: the tester reads
round 1 as `git diff HEAD` and every later round as `git diff` against the index.
3. `diff_hash=$(git diff HEAD | git hash-object --stdin)`.
4. If `not_done` is not empty, or you are near your turn limit, write
`../tmp/task-<id>/handoff.md` with five headings: done, files, decisions, dead-ends, next.
## Voice
Work in caveman full, the mode the lead runs in — every line you emit, not the return block alone:
a question back to the lead, a `BLOCKED` line, a note on a gate that did not run. Drop articles and
filler, one idea per line, fragments over sentences, the short synonym over the long one. Never add
a word to sound caveman: where plain wording is already shorter, it is the plain wording that ships.
Exact and untouched: commands, flags, paths, `file:line`, numbers with their units, quoted error
text, and every `not`, `no`, `only`, `except` — a dropped negation costs more than every token it
saves. The block's own field names are the ones the schema below prints, unchanged.
What you leave behind on disk — code, comments, a changelog line, a handoff, an issue draft — stays
normal English prose: it is read by people who never saw this session.
## Return block
At most 25 lines. Paths, not contents. No diff, no code, no narration of the attempt.
```
STATUS: DONE | PARTIAL | BLOCKED
files:
<path> — <why, one line>
gates:
<command> → exit <n>
diff_hash: <sha>
staged: yes
not_done:
<path or item> — <reason>
handoff: <path> | none
```
@@ -0,0 +1,40 @@
---
name: researcher
description: Answers one question from sources and ranks them. For library, API, spec and upstream-LuCI questions; never for anything the repository's own docs/ already settle. Writes no code.
model: sonnet
tools: WebSearch, WebFetch, Read, mcp__context7__resolve-library-id, mcp__context7__query-docs
disallowedTools: Edit, Write, NotebookEdit
maxTurns: 20
---
Answer the question the prompt asks, then show what the answer rests on. Read the source; do not
answer from memory. An unknown stays unknown.
Rank every source, highest first: spec text > upstream source or commit > official docs > issue
thread > blog. Where ranks disagree, the higher rank wins and the disagreement is stated. For a
library or CLI, Context7 first, the web after.
## Voice
Work in caveman full, the mode the lead runs in — every line you emit, not the return block alone:
a question back to the lead, a `BLOCKED` line, a note on a gate that did not run. Drop articles and
filler, one idea per line, fragments over sentences, the short synonym over the long one. Never add
a word to sound caveman: where plain wording is already shorter, it is the plain wording that ships.
Exact and untouched: commands, flags, paths, `file:line`, numbers with their units, quoted error
text, and every `not`, `no`, `only`, `except` — a dropped negation costs more than every token it
saves. The block's own field names are the ones the schema below prints, unchanged.
What you leave behind on disk — code, comments, a changelog line, a handoff, an issue draft — stays
normal English prose: it is read by people who never saw this session.
## Return block
At most 25 lines.
```
ANSWER: <two to five sentences, the conclusion first>
sources:
<rank> <url> <date> <what it settles, one line>
unknown: <what could not be confirmed, or none>
```
@@ -0,0 +1,46 @@
---
name: security
description: Runs the security-review skill over one task's diff and reports. Never fixes. Called per task except CSS-only or one-line diffs; the Opus pass before a release or an upstream PR is /security-review in the main thread and is not replaced by this.
model: sonnet
tools: Read, Grep, Glob, Bash
disallowedTools: Edit, Write, NotebookEdit
skills:
- security-review
maxTurns: 25
---
You review the diff the prompt names (`git diff HEAD`, or the path to a patch) with the
`security-review` skill and return findings. You change nothing: a finding goes to the human, and
the human decides whether it becomes a card.
The surface that matters in this repository, from CLAUDE.md: the installer and its signature chain
(`install.sh`), any shell that runs over a build tree (`tools/*.sh`, `luci-theme-footstrap/*.sh`),
the login template (that page is unauthenticated), sinks in the browser JS (`innerHTML`, `eval`,
URL handling in `fs-*.js`), and the packaging pipeline (`Makefile`, `postinst`, `postrm`,
`uci-defaults`). A diff that touches none of these still gets the skill's pass; say so.
## Voice
Work in caveman full, the mode the lead runs in — every line you emit, not the return block alone:
a question back to the lead, a `BLOCKED` line, a note on a gate that did not run. Drop articles and
filler, one idea per line, fragments over sentences, the short synonym over the long one. Never add
a word to sound caveman: where plain wording is already shorter, it is the plain wording that ships.
Exact and untouched: commands, flags, paths, `file:line`, numbers with their units, quoted error
text, and every `not`, `no`, `only`, `except` — a dropped negation costs more than every token it
saves. The block's own field names are the ones the schema below prints, unchanged.
What you leave behind on disk — code, comments, a changelog line, a handoff, an issue draft — stays
normal English prose: it is read by people who never saw this session.
## Return block
At most 25 lines. Paths and lines, not contents.
```
RESULT: CLEAN | FINDINGS
skill: loaded | unavailable
findings:
<severity> <path>:<line> <one sentence: what, and what an attacker gets>
surface_touched: <list from the paragraph above, or none>
```
@@ -0,0 +1,104 @@
---
name: tester
description: Read-only verifier of a task card. Runs the full cycle the card asks for — T0, the two browser gates, both owlab stands, owlab test for release-grade work, the hardware router only when the card says so — and returns PASS or NEEDS_WORK with a command and its output behind every finding.
model: opus
tools: Read, Grep, Glob, Bash
disallowedTools: Edit, Write, NotebookEdit
maxTurns: 140
---
You judge; you do not fix. Asked to fix, answer `read-only` and return `NEEDS_WORK`. Read-only is
a rule you keep, not a fence around you: `Edit` and `Write` are absent from your tools, but `Bash`
would let you write, stage or delete, and none of that is yours to do. You see the
diff and the card, never the developer's reasoning, and that is the point: a judge that watched the
work grades the work it watched. Protocol: `docs/crew.md`; the stands: `docs/development.md`.
## Inputs
- `../tmp/task-<id>/card.json`: goal, `files`, `acceptance[]` (command + expected output), `live[]`
(gate + flags), `grade` (`task` | `release`), `hardware`, `round`.
- Round 1 reads `git diff HEAD`. Round N+1 reads `git diff` (worktree against the index, which
holds the previous round) plus the previous findings the prompt carries. Never re-read the full
diff on a later round.
## Runbook
Every `tools/bg.sh` call below is paired, in the same turn, with `tools/bg-wait.sh <run-id>` started
as a **background** command. A waiter in the foreground blocks past the 5-minute limit and is killed,
which is the failure the pairing exists to prevent. Read every waiter's result before you return:
one still running when you finish wakes you again afterwards and re-sends your verdict as noise.
1. Classify the diff: css-only / js / template / package-or-build (`Makefile`, `root/**`,
`build-css.sh`, `tools/stage.sh`, `strip-*`). `scope_ok` is `git diff HEAD --name-only` being a
subset of `card.files`.
2. T0, foreground: `npm test`; every `acceptance[].cmd`; `ucode -T -c -o /dev/null <file>` for each
`.ut`; `npm run lint:js` or `npm run lint:css` by area.
3. `npm run smoke`; `npm run computed-diff`. A css-only diff whose number surprises you gets
`npm run computed-diff -- --control`, which must print 0 before the first number means anything.
4. Stands: `docker ps --format '{{.Names}}\t{{.Ports}}'`. If `owrt2512` and `owrt2410` are not up,
detach: `tools/bg.sh sh -c 'export PATH=$HOME/go/bin:$PATH; owlab up'`, then the waiter.
5. `owlab sync owrt2512 && owlab sync owrt2410`. Prove the bytes arrived: `md5sum` of a touched
resource on the host against `owlab exec owrt2410 -- md5sum /www/luci-static/resources/<file>`.
opkg answers `up to date` to a matching version and installs nothing; the checksum is the proof.
6. Reset the syslog before any live gate (`banip/processing_log` prints it as page content):
`for c in owrt2512 owrt2410; do docker exec owlab-luci-theme-footstrap-$c /etc/init.d/log restart; done`.
7. Every gate in `card.live`, one call per gate per stand, detached:
`tools/bg.sh sh -c 'export PATH=$HOME/go/bin:$PATH; node tools/<gate>.mjs --only owrt2512 <flags>'`
then `tools/bg-wait.sh <run-id>`; the same with `--only owrt2410`. Never `npm run live -- --all`:
npm hands the flags to the last gate only. A Playwright stack with `log: []` is a run killed by
the 5-minute limit: report it as `not_run`, never re-run with a larger timeout.
8. Release-grade only (`card.grade == release`, or the diff is package-or-build):
`./tools/stage.sh && owfeed build`; `owlab down` first, because `owlab test` binds host port 2222
and removes an existing stand; then one detached run per format with the five assertions from
`docs/development.md` "Proving it on a router", verbatim. Say in the verdict that the stands are
down.
9. Hardware only when `card.hardware` is true, which the lead sets on the maintainer's explicit
word for this task; `ssh` and `dev-sync.sh` prompt the human and that prompt is the gate.
Pre-check, and abort if either path is missing:
`ssh <host> 'ls -d /www/luci-static/bootstrap /usr/share/ucode/luci/template/themes/bootstrap/header.ut && uci get luci.main.mediaurlbase'`.
Then `luci-theme-footstrap/dev-sync.sh <host>`; then over ssh
`for f in /usr/share/ucode/luci/template/themes/footstrap/*.ut /usr/share/ucode/luci/template/themes/footstrap/partials/*.ut; do ucode -T -c -o /dev/null "$f" || echo FAIL $f; done`;
then `curl -s -o /dev/null -w '%{http_code}' http://<host>/cgi-bin/luci/` expecting 200 (an
admin path answers 403 unauthenticated and proves nothing). On any failure print the rollback
line — `uci set luci.main.mediaurlbase=/luci-static/bootstrap; uci commit luci` — and stop; do
not run it unasked.
## Findings
- A finding is `blocking` when it breaks correctness, the card's acceptance, a gate, or
security; everything else is `blocking: false` and never triggers a round on its own.
- Ids are stable across rounds: `<gate>:<path>:<slug>`. The same id twice is what the lead
calls a stall, so do not rename a finding you are repeating.
- Evidence is the command and its output, trimmed to the lines that show it. A finding without a
command is an opinion and does not go in the block.
## Voice
Work in caveman full, the mode the lead runs in — every line you emit, not the return block alone:
a question back to the lead, a `BLOCKED` line, a note on a gate that did not run. Drop articles and
filler, one idea per line, fragments over sentences, the short synonym over the long one. Never add
a word to sound caveman: where plain wording is already shorter, it is the plain wording that ships.
Exact and untouched: commands, flags, paths, `file:line`, numbers with their units, quoted error
text, and every `not`, `no`, `only`, `except` — a dropped negation costs more than every token it
saves. The block's own field names are the ones the schema below prints, unchanged.
What you leave behind on disk — code, comments, a changelog line, a handoff, an issue draft — stays
normal English prose: it is read by people who never saw this session.
## Verdict block
At most 25 lines. No diff, no code.
```
VERDICT: PASS | NEEDS_WORK
round: <n> scope_ok: yes | no
gates:
<name> → exit <n> <one line of evidence>
findings:
<id> blocking: true|false <path> <what>
evidence: <command> → <output line>
covered: t0, smoke, computed_diff, owrt2512: [<gates>], owrt2410: [<gates>], owlab_test: apk|ipk|none, hardware: yes|no
not_run:
<name> — <why>
```
+91
View File
@@ -0,0 +1,91 @@
#!/bin/sh
# PreToolUse/Bash: hold the changelog contract on `git commit` mechanically.
#
# "NO COMMIT LANDS WITHOUT ITS CHANGELOG ENTRY … in the same commit, in BOTH files" was a rule the
# model had to remember; a rule remembered is a rule broken eventually. This denies the commit
# instead. The two exemptions are exactly the ones CLAUDE.md already names: CLAUDE.md itself, and a
# commit that touches nothing but the changelog (a fix to an [Unreleased] entry already written).
#
# settings.json gates this with `if: Bash(git commit *)`, but the guard below repeats the test: an
# older Claude Code with no `if` support would otherwise pay node's startup on every bash call.
set -eu
# Without jq the hook cannot read the command at all. Say so and stand aside: a hook that exits
# non-zero here blocks nothing anyway, it only prints a shell error before every bash call.
if ! command -v jq >/dev/null 2>&1; then
echo "precommit-gate: jq not found, the changelog contract is NOT being enforced" >&2
exit 0
fi
IN=$(cat)
CMD=$(printf '%s' "$IN" | jq -r '.tool_input.command // ""')
# Fast path. Anything that is not a commit leaves without touching git or node.
case "$CMD" in
*'git commit'*) ;;
*) exit 0 ;;
esac
# Resolved BEFORE the `cd` below, or a hook invoked by a relative path would resolve `$0` against
# the wrong directory. settings.json passes an absolute `${CLAUDE_PROJECT_DIR}/...`, so this only
# matters when the hook is run by hand — which is how it gets tested.
HOOK_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd -P)
CWD=$(printf '%s' "$IN" | jq -r '.cwd // ""')
[ -n "$CWD" ] && cd "$CWD"
# The contract belongs to THIS repository, and a session commits in others: owfeed-packages, and
# the openwrt/luci fork behind an upstream PR. Neither tree has a CHANGELOG.md, so the hook denied
# every commit there over a rule those repositories never agreed to — measured on a package removal
# in owfeed-packages, which could not be committed at all until this test existed. The hook ships
# inside .claude/, so the repository holding that directory is the one it speaks for; anywhere else
# it stands aside without a word.
GIT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
[ "$GIT_ROOT" = "$HOOK_ROOT" ] || exit 0
deny() {
jq -n --arg r "$1" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: $r
}
}'
exit 0
}
# `git commit -a` stages tracked changes as it runs, so the staged index is not what the commit
# will hold. Compare against HEAD in that case, and against the index otherwise.
#
# Only the part before the first quote is searched: the message is always quoted, and scanning the
# whole line made `git commit -m "add -a flag"` read as `-a` — which compares against HEAD, sees
# every dirty file in the tree, and denies a commit that holds none of them.
FLAGS=${CMD%%[\"\']*}
case "$FLAGS" in
*' -a'*|*' --all'*) FILES=$(git diff --name-only HEAD 2>/dev/null || true) ;;
*) FILES=$(git diff --cached --name-only 2>/dev/null || true) ;;
esac
# An empty set is `git commit --amend` with no staged change, or a commit that will fail on its own.
[ -n "$FILES" ] || exit 0
# Substantive = everything except the two exemptions. Documentation, CI and packaging are IN, which
# is CLAUDE.md's rule verbatim: "This covers documentation, benchmarks, CI and packaging too".
SUBSTANTIVE=$(printf '%s\n' "$FILES" | grep -v -x -e 'CLAUDE.md' -e 'CHANGELOG.md' -e 'CHANGELOG_ru.md' || true)
if [ -n "$SUBSTANTIVE" ]; then
HAS_EN=$(printf '%s\n' "$FILES" | grep -c -x 'CHANGELOG.md' || true)
HAS_RU=$(printf '%s\n' "$FILES" | grep -c -x 'CHANGELOG_ru.md' || true)
if [ "$HAS_EN" -eq 0 ] || [ "$HAS_RU" -eq 0 ]; then
deny "Changelog contract: this commit changes $(printf '%s\n' "$SUBSTANTIVE" | wc -l | tr -d ' ') file(s) and stages CHANGELOG.md=$HAS_EN CHANGELOG_ru.md=$HAS_RU. Both must be in the SAME commit, under ## [Unreleased], as '- **one-line effect.** then the rationale'. An entry written afterwards is written from the diff, and the diff is what does not know why. Exempt: CLAUDE.md alone, or a fix to an [Unreleased] entry already written. Files: $(printf '%s' "$SUBSTANTIVE" | tr '\n' ' ')"
fi
fi
# The mechanical half — sections, order, dates, compare links, RU mirror parity, the mandatory bold
# lead. A bullet with no bold lead is silently dropped from the release page, which is why this runs
# before the commit rather than at tag time.
if ! OUT=$(node tools/changelog.mjs 2>&1); then
deny "node tools/changelog.mjs failed, so this commit would land a broken changelog: $OUT"
fi
exit 0
+23
View File
@@ -0,0 +1,23 @@
#!/bin/sh
# SessionStart: say out loud when the session was started somewhere the project's rules do not load.
#
# The checkout's parent holds luci-fork/ and tmp/ and is not a git repository. A session started
# there loads no CLAUDE.md, every `owlab` call exits with "no owlab.yaml found", and the failure
# reads as a broken owlab rather than a wrong directory. The marker is owlab.yaml, which exists at
# the repo root and nowhere above it.
set -eu
NOTES=''
add() { NOTES="${NOTES}${NOTES:+
}$1"; }
[ -f owlab.yaml ] || add "No owlab.yaml in $(pwd): this session was NOT started at the repo root, so CLAUDE.md, .claude/rules/ and .claude/settings.json are not loaded and every owlab command will exit with 'no owlab.yaml found'. The repo root is the workspace; the shipped package is luci-theme-footstrap/ one level down. Ask the user to restart from the root rather than working around it."
[ -n "$NOTES" ] || exit 0
jq -n --arg c "$NOTES" '{
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: $c
}
}'
@@ -0,0 +1,41 @@
---
paths:
- "CHANGELOG*.md"
description: The changelog contract — sections, the bold lead, and the bilingual mirror.
---
# The changelog
**NO COMMIT LANDS WITHOUT ITS CHANGELOG ENTRY.** It goes under `## [Unreleased]`, in the **same
commit as the code**, and in **BOTH `CHANGELOG.md` AND `CHANGELOG_ru.md`** — never one now and its
mirror later. An entry written afterwards is written from the diff, and the diff is exactly what
does not know why. This covers documentation, benchmarks, CI and packaging too, not just code: if
the commit is worth making, it is worth one line saying what changed. The only things that skip it
are `CLAUDE.md` and a fix to an `[Unreleased]` entry already written.
Enforced by `.claude/hooks/precommit-gate.sh`, which denies a `git commit` whose staged set has
substantive files and not both changelog files, and then runs `npm run changelog`. The hook is the
defence; this page is the reason, and the prose below is the half no gate can check.
- Sections are `Added / Changed / Deprecated / Removed / Fixed / Security / Performance`, one of
each per version, in that fixed order — append into the section that already exists in its
canonical slot, never add a second `### Changed` on top.
- Each entry is `- **one-line effect.** then the rationale`. The bold lead **is** the release note
(`release-notes.sh` emits leads only), so it must read on its own — **a bullet with no bold lead
is silently dropped from the release**. Write the effect, not the diff; keep the measurement.
- **Keep the number the change was made for.** "20 KB of font for 227 labels", "312 of 336 elements
recoloured by a hostile `:root`", "1.69:1 where AA wants 4.5" — the measurement is the difference
between a changelog and a marketing blurb.
- **Do not cite commits** or retell a file's history. **Edit the existing entry; do not append a
second one.** Merge related commits into one entry.
- The two files are **edited in one commit**. Same section set and order, same numbers, versions and
`compare` links — only the prose may differ, never the facts. The English file is the primary
source for the release-note generator.
`npm run changelog` holds the mechanical half: the set, order and uniqueness of sections, empty
sections, dates, `compare` links, mirror parity and the mandatory bold lead. It exists because
`[Unreleased]` once accumulated a **duplicate `### Changed`** over several commits, each of which
looked fine on its own, and the notes are generated at tag time when the tag is already pushed.
The full contract, its sources and the anti-patterns: `docs/releasing.md`. Cutting a release is the
`/release` skill.
@@ -0,0 +1,46 @@
---
paths:
- "**/*.ut"
- "**/fs-*.js"
- "**/menu-footstrap*.js"
description: The one renderer, the three ownership zones, and the fence that keeps a foreign app out of the chrome.
---
# The chrome, and sharing a document with third-party apps
- **One theme entry, one template dir, one renderer.** Layout is a **client** axis
(`:root[data-layout]`, always an explicit value) — never write a `:not([data-layout=…])` guard and
never add a second renderer. The bar is the base; the vertical sidebar is one guarded override
that wins on specificity.
- Three zones: **ours** (`fs-*`, `--fs-*`, `[data-fs-chrome]`), **shared LuCI** (`.cbi-*`, `#view` —
where an app is *entitled* to win on specificity), **theirs**. Check who owns a name before
"fixing" a collision: `.left`/`.right`/`.center` and `ul.nav` are LuCI's.
- The chrome is defended by **not matching**: the mark in `header.ut`, the fence in `fs-sheets.js`,
the pin in `theme/10-chrome.css` (inherited properties, roots alone). `npm run chrome-fence`
derives the mark from the markup and compares whole canonical strings — a token-wise check once
passed on a fence that was the exact inverse of one.
- **A view's CSS is never deleted** — a sheet imported at module eval never comes back. An invasive
sheet makes the next navigation a full load instead; only a byte-identical duplicate may be
dropped.
- Every Appearance axis is implemented twice (pre-paint in `partials/head.ut`, live in
`fs-prefs.js`) and **the custom property is set BEFORE the attribute** — reversed, a reload paints
one frame in the previous hue. `npm run axes` derives the contract from the JS; `npm run smoke`
watches the two writes actually happen in that order on a live `:root`.
## Templates
Syntax-check the way LuCI does, on every `.ut` touched this session — that is T0:
```sh
ucode -T -c -o /dev/null <template>.ut
```
The full sweep over every installed template is the fifth assertion of `owlab test` and runs in CI's
`verify` job on both 25.12 and 24.10; it is not reproduced locally.
A `_()` with no catalogue renders silently in English: run `luci-theme-footstrap/update-po.sh` after
touching any `_()`. A msgid is a **global** name shared with every app — Appearance labels carry the
`footstrap` msgctxt; the chrome, the login/notice sentences and the System/Memory/Storage titles
deliberately do not.
Deeper: `docs/chrome.md`, `docs/third-party-apps.md`, `docs/spa-router.md`.
+52
View File
@@ -0,0 +1,52 @@
---
paths:
- "**/styles/**/*.css"
- "**/cascade.css"
- "**/build-css.sh"
description: The cascade layers, the token tiers, and how a CSS change is proved.
---
# CSS
- **`htdocs/luci-static/footstrap/cascade.css` is generated — never edit it.** Source is `styles/`.
Enforced by `permissions.deny: Edit(**/cascade.css)` in `.claude/settings.json`, which also covers
a shell redirection into that path; the rule is here for the reason, not as the defence.
- Layer order `tokens, base, theme, page`, one directory per layer, filename prefix = source order.
A later layer beats an earlier one regardless of specificity, so a theme rule never needs
`!important` to outrank base. A file added to a layer directory needs its own `@layer` wrapper —
`build-css.sh` refuses one that has none and would be swallowed into the block above it.
- **Read the private `--fs-*` tier only.** The `--*-color-*` export tier is defined from it and read
by nobody inside `styles/` (`audit --strict` fails). A hostile `:root` recoloured 312 of 336
gallery elements before the split, 0 after.
- **`!important` is a gated allowlist and inverts layer order.** A flag must fight an inline
`style=` or an app's unlayered rule; one that beats another footstrap rule means the rule is in
the wrong layer. `theme/95-a11y-media.css` is the one sanctioned exception.
- **Edit the rule that already styles the selector** — never append a second one. **Win on
specificity, never on source order.**
- **Coverage is a contract.** Never delete a selector because no stock page renders it — some
third-party app emits it. `css-orphans` is the only safe dead-CSS search, and only because `fs-*`
is ours alone.
- **The browser floor is Chrome 108 / Firefox 101 / Safari 15.4**, derived from the sheet by
`npm run css-floor` and stated in `docs/css.md`. Two shapes fail it: a `:has()` compound sharing
a selector list with one that has none (the list is not forgiving — the whole rule dies below
the floor; split it or wrap it in `:is()`), and a CSS feature the sheet has not used before,
until it is classified hard/soft in `tools/css-floor.mjs`. A token built with `color-mix()`
needs its static twin in `styles/04-nocolormix.css`: a custom property fails at the point of
USE, so no fallback declaration in the same block can catch it.
- **No colour literals** (`--fs-scrim` excepted); a tint of X is mixed **from** X. Never reintroduce
a component bridge (`--*-rgb`, `--*-hsl`).
- **Merge a duplicate or pin it in `@mirror`.** An unpinned duplicate is a hard failure, and so is a
`@mirror` group with one copy.
- **`styles/base/` is editable**, but prefer the matching `styles/theme/` file, and justify any base
edit that changes output with a near-empty computed diff.
- **No bold mono**: `<strong>` is a LABEL and must be *assigned* the sans face — excluding it from
the mono rule changes nothing, since it still inherits.
## Proving the change
`npm run computed-diff` (T1, ~4 s) is the cheap half; `--control` runs the same sheet on both sides
and must report 0. The gallery has every widget and none of the pages, so a clean diff is an early
result, not a release: the live half is `npm run live` and `owlab test` (T2), where the same sheet
twice moves 0.5-1.3% of pixels and a real regression weighs 0.19% — never screenshots. Method and
the two corrections the gate needed: `docs/css.md` "Proving a CSS change", `docs/development.md`
"The two cheap browser gates".
+40
View File
@@ -0,0 +1,40 @@
---
paths:
- "**/htdocs/luci-static/**/*.js"
- "tests/**/*.mjs"
description: Module composition, the jsmin traps, and what fs-fit owns.
---
# JS
- One concern per module; `L.require` makes a singleton and throws `DependencyError` on a cycle, so
a module can never `extend` another — compose by calling.
- **All "does it fit" logic lives in `fs-fit.js`.** Measure uncollapsed, re-fit synchronously on a
mutation, coalesce on resize. `data-narrow` — not a viewport media query — is the single source of
"the sidebar became a bar", and the widths are read from the CSS tokens with `getComputedStyle`,
never copied into JS.
- **Never put a regex literal straight after `return` or `=>`** — jsmin eats the rest of the file
and **exits 0**. Wrap it: `return (/^https?:\/\//i.test(a));`. No backtick inside a `${…}`.
eslint's `wrap-regex` holds the cause and `tools/jsmin-verify.mjs` the consequence; the second one
needs a jsmin built from `luci-upstream.pin` and runs in CI only (T2).
- **Require a stock class through `window.L`** (`const RT = window.L; RT.require(name)`) — the `L` a
factory receives has no `ui` helpers, and `require()` caches the first requirer's binding.
- **`FS_VERSION` stays in `fs-version.js` at that path** — the Makefile, `dev-sync.sh` and
`tools/stage.sh` sed it by path; moving it makes every release report "(dev)".
- **The theme never checks for its own updates and never reaches a third-party host at run time.**
Upgrades are the package manager's job (the installer adds the feed). `fs-router` exports
`onNavigate(fn)` so an optional module can register itself without the router naming anyone —
keep that seam inverted, but do not re-add an updater behind it.
## What to run after an edit here
`npm run lint:js` is the T0 gate. Then the one that covers what you touched: `axes` for `fs-prefs`
or `fs-axes`, `chrome-fence` for `fs-sheets`/`fs-prefs`, `page-modules` for the page→module map,
`css-orphans` if a `fs-*` class name moved, `floor` (T2, live) for anything in `holdFloor()` or
`naturalHeight()` — it is the only gate that reads a floor's height back and empties a box to see
the floor come off.
`npm run smoke` (T1, ~1.4 s) runs the modules against a real DOM on the gallery with every
dependency stubbed; `npm test` covers only the branches a stand cannot enter. Neither answers for
a page: behaviour is `owlab test` and the live gates (T2), and a green smoke never earns a release
the right to skip them. `docs/development.md` "The two cheap browser gates".
@@ -0,0 +1,49 @@
---
paths:
- "**/Makefile"
- "**/root/**"
- "**/po/**"
- "**/owfeed.yml"
- "install.sh"
description: Packaging invariants that fail silently on somebody else's router.
---
# Package
Every rule here shares one shape: nothing fails at build time, and the damage lands on a router that
is not yours, weeks later. Four of them are now held by `npm run makefile-contract`
(`tools/makefile-contract.mjs`) and the rest by the gates named beside them.
- **`+luci-base` is the whole dependency list and keeping it that way is a constraint.** `curl` is
not in OpenWrt's default set — fall back to `uclient-fetch` instead of adding a dep.
- **The catalogue lives in `po/`** — what `LUCI_LANGUAGES` globs and what Weblate translates.
luci.mk emits the per-language packages and so does owfeed: `luci-i18n-footstrap-<lang>`, catalogue
`footstrap.<lang>.lmo`, plus a uci-defaults line registering the language. The theme itself carries
NO catalogue — two packages owning one path is an install apk refuses. `po/`, `tools/stage.sh`
(staging trees + the language label) and `owfeed.yml` all repeat the language list and are held
together by `npm run i18n-packages`; `tools/check-packages.sh` asserts the built set.
- Anything under `root/etc/config/` MUST be in the `conffiles` define (`npm run conffiles`) — else
the manager replaces it on upgrade and the theme's own Update wipes the admin's saved defaults,
reporting success.
- `postinst`/`postrm` use **`rpcd reload`, never `restart`** — restart logs out every LuCI session.
- A malformed `acl.d/*.json` is skipped by rpcd **silently**, so the grant goes to nobody and only
Save-as-default and the background upload break, on someone else's router (`npm run acl`).
- `root/etc/uci-defaults/30_luci-theme-footstrap` is the single source of registration; fresh
install vs upgrade is the marker file `/usr/share/luci-theme-footstrap/.installed`, and **an
upgrade must never change the active theme** (`$PKG_UPGRADE` is dead — apk never exports it).
- Do not set `PKG_VERSION` (git-derived). `LUCI_MINIFY_CSS:=0` — csstidy mangles `:has()` and
`color-mix()`.
- The Makefile's buildroot signature line is what `include/scan.mk` greps for to see the package at
all. It must stay last, with nothing between it and the text it announces (`npm run marker`); it
has been deleted as boilerplate once.
- **The trust chain fails closed**: a verified TLS channel (never `-k`, never as a retry), an
ed25519 `usign` signature, then GitHub's sha256. The signature is the link that holds — GitHub
*computes* the asset digest, so a swapped asset passes the checksum. A missing digest, `.sig` or
usign is a refusal, not a downgrade.
After an edit here, T0 is the one gate that covers the file: `npm run makefile-contract` and
`npm run marker` for the Makefile (they read different lines — run both), `npm run conffiles` for
`root/etc/config/**`, `npm run acl` for `acl.d/**`, `npm run i18n` for `po/**`, `npm run shell` for
any `*.sh`.
Deeper: `docs/package.md`, `docs/ci.md`.
+102
View File
@@ -0,0 +1,102 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"env": {
"BASH_DEFAULT_TIMEOUT_MS": "120000",
"BASH_MAX_TIMEOUT_MS": "300000"
},
"attribution": {
"commit": "",
"pr": "",
"sessionUrl": false
},
"permissions": {
"deny": [
"Edit(**/cascade.css)",
"Edit(tmp/**)",
"Edit(**/docs/screenshots/**)"
],
"additionalDirectories": [
"../tmp"
],
"ask": [
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(npm run live:*)",
"Bash(luci-theme-footstrap/dev-sync.sh:*)",
"Bash(./luci-theme-footstrap/dev-sync.sh:*)",
"Bash(ssh:*)",
"Bash(git stash:*)",
"Bash(git clean:*)",
"Bash(git restore:*)",
"Bash(git reset --hard:*)",
"Bash(git checkout -- :*)",
"Bash(git add -A:*)",
"Bash(git add --all:*)",
"Bash(git add .:*)"
],
"allow": [
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git show:*)",
"Bash(git branch:*)",
"Bash(git add:*)",
"Bash(gh api:*)",
"Bash(gh run view:*)",
"Bash(gh pr view:*)",
"Bash(gh pr checks:*)",
"Bash(npm test:*)",
"Bash(npm run check:*)",
"Bash(npm run lint:*)",
"Bash(npm run changelog:*)",
"Bash(npm run computed-diff:*)",
"Bash(npm run smoke:*)",
"Bash(npm run makefile-contract:*)",
"Bash(node tools/*)",
"Bash(node .claude/tooling/*)",
"Bash(ucode -T -c *)",
"Bash(sh -n *)",
"Bash(md5sum:*)",
"Bash(tools/bg.sh:*)",
"Bash(tools/bg-wait.sh:*)",
"Bash(./tools/stage.sh:*)",
"Bash(owfeed build:*)",
"Bash(owlab up:*)",
"Bash(owlab down:*)",
"Bash(owlab sync:*)",
"Bash(owlab test:*)",
"Bash(owlab exec:*)",
"Bash(owlab status:*)",
"Bash(docker ps:*)",
"Bash(docker exec owlab-*)",
"Read(../tmp/**)",
"Write(../tmp/**)",
"Edit(../tmp/**)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git commit *)",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/precommit-gate.sh",
"statusMessage": "Checking the changelog contract"
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/session-start.sh"
}
]
}
]
}
}
@@ -0,0 +1,91 @@
---
name: release
description: Cut a footstrap release — the pre-tag matrix, the changelog rename, the tag, and publishing the feed. Use when asked to cut, tag or ship a release, or to check whether a release is ready.
---
# Cutting a release
`docs/releasing.md` is the authority and carries the per-issue table; this is the order and the
things that are easy to skip. **The order is load-bearing** — the tag must point at a commit that
already contains its own changelog entry.
Nothing below is optional because a cheap gate went green. `npm run computed-diff` and
`npm run smoke` are early detectors on a static gallery; they do not stand in for a userland.
## 1. Scope the diff
```sh
git status --short | awk '{print $2}' | sed 's#/.*##' | sort -u
git status --short | grep -E 'styles/|menu-footstrap|\.ut$|fs-sheets|fs-select|fs-fit' \
|| echo 'no CSS / renderer / template / fence changes'
```
The diff touching `styles/`, the renderer, the templates or the fence makes the live check for
issues #1–#5 and #7–#11 mandatory. **Always mandatory regardless of the diff:** the whole automatic
section, plus issue #6 (packaging and asset selection), because #6 breaks at the release level and
nothing fails until a router in the field pulls the update.
## 2. The full pre-tag matrix
All four, and every one of them read rather than merely started. The three long ones are T2: start
them detached with `tools/bg.sh` and report the run-ids, then read the logs.
| Must be green | How |
|---|---|
| `npm run check` | one run, exit 0. Every static gate |
| `owlab test`, **both formats** | two invocations, one per format — never one run with two `--release` flags |
| the wide live gates | one call per gate — `npm run live -- …` does not forward its flags, `docs/releasing.md` |
| `/security-review` | on the final branch diff, **before** the tag |
```sh
./tools/stage.sh && owfeed build
owlab test --release 25.12.4 --install 'dist/noarch/luci-theme-footstrap-*.apk' --assert …
owlab test --release 24.10.8 --install 'dist/all/luci-theme-footstrap_*.ipk' --assert …
```
`--install` is a host-side glob evaluated per router, so `dist/*/…` hands the apk box an ipk as well
and the install fails on both (measured: `0 of 2 routers passed`). The five assertions and the exact
flags: `docs/development.md`. `tools/jsmin-verify.mjs` is not in `check` — CI runs it.
**A release is not cut until every log above has been read.** A detached run nobody opened is not a
green gate, and the tier a check sits in never changes what the release requires.
## 3. Asset selection (issue #6), separately and always
The most fragile part of every release and the only one that fails silently: the notes and the asset
choice are evaluated at tag time, in the field, months later. The `build` job asserts exactly N
assets per format, each resolving through its own name-anchored regex `^<name>[-_][^/]*\.EXT$` to
exactly one. Simulate it against the shape of the coming release — the loop is in
`docs/releasing.md` step 3.
## 4. The runbook
0. `node tools/size-budget.mjs --pin` — re-pins the size ceiling to this build plus 2 %. This is
the ONLY time the numbers in `tools/size-budget.mjs` change; between releases a change that
crosses them removes something instead. Goes into the same commit as the rename below.
1. Rename `[Unreleased]` to `## [x.y.z] — YYYY-MM-DD` in **both** `CHANGELOG.md` and
`CHANGELOG_ru.md`, and add the `compare/` link at the bottom of each. Canonical section order,
every bullet with a bold lead.
2. `npm run changelog` — green.
3. Commit that change. Conventional Commits, English, **no AI attribution**.
4. Tag `vx.y.z` on this commit. **Never tag first.**
5. **Before pushing, read the recent runs on `main`**: `gh run list --limit 6` (on the maintainer's
Windows machine `gh` is not on `PATH` in Git Bash or WSL — call it by its full path,
`"/c/Program Files/GitHub CLI/gh.exe" run list --limit 6`). A run already red before your push is
still red after it, and `release` does not fire until whatever it currently gates on is green: a
tag pushed onto a broken pipeline cannot publish. A pre-existing failure needs an owner before you
tag — either an infrastructure condition to fix first, or a real regression that has nothing to
do with this release but will still hold it.
6. Push the commit and the tag to `origin` — the only remote.
7. **The release is not cut until the tag's own run is read, job by job** — not "wait for green".
`gh run view <id>` for the job list, `gh run view --job <id> --log-failed` for the failing
step's log; say whether a red job is the diff, infrastructure, or an upstream feed. Then check
the release carries the expected assets plus a `.sig` for each.
8. **Publish the feed.** The theme installs from owfeed-packages, so a release nobody can
`apk upgrade` into is half a release. Bump `packages/luci-theme-footstrap/upstream.sh`, check the
sha256s in the diff against the assets you downloaded, merge, then read the **served** index
rather than the workflow log: `apk adbdump` on `releases/25.12/<arch>/packages.adb` and
`Packages.gz` on `releases/24.10/<arch>/`.
Steps 3, 4 and 6 need an explicit instruction from the maintainer, each time. `git commit` and
`git push` are `ask` in `.claude/settings.json` for exactly this reason.
@@ -0,0 +1,123 @@
---
name: upstream-pr
description: Готовит и ведёт pull request темы в openwrt/luci — сборка ветки из main, локальные проверки, сообщения коммитов по местному стилю, мониторинг и ответ на ревью. Применять всегда, когда правка темы должна уехать в апстрим, когда мейнтейнер запросил изменения, или когда надо пересобрать ветку после force-push. Triggers - "PR в апстрим", "отправить в luci", "ответить на ревью", "upstream PR", "пересобери ветку".
---
# PR в openwrt/luci
Тема живёт в апстриме с 2026-08-20. Код там принимают почти всегда; заворачивают за **форму** —
за дробление коммитов и за длину сообщений.
**Правило поверх всех: коротко.** Медиана тела коммита в openwrt/luci — три строки. Сообщение
говорит, ЧТО поменялось; разжёвывать, почему именно так, не нужно.
## Порядок работы
1. **Ветка.** Переиграть нужные изменения `main` в отдельной worktree, сгруппировать по логике,
синхронизировать на каждой точке. → `reference/mechanics.md`
2. **Проверки локально.** `npm run check`, их eslint, их правила формальностей, компиляция
шаблонов, живые стенды. Ловит то, за что иначе прилетит в ревью. → `reference/preflight.md`
3. **Сообщения.** Минимально достаточные, чтобы понять каждое изменение: одно-два предложения.
Пишутся последними, когда состав коммитов уже не изменится.
4. **Публикация.** `gh pr create`, тело — по строке на коммит, ссылки и `Closes #NNNN` только здесь.
5. **Мониторинг ревью.** Ревью от бота разбираем сами: проверяем каждое замечание, настоящие
ошибки чиним сразу, треды закрываем. Ревью от человека — уведомление пользователю, разбор
по существу и вердикт по каждому пункту. → `reference/answering-review.md`
## Сколько коммитов
**Минимально достаточное число, разбитое по логике.** Одно изменение — один коммит; несколько
мелких правок одной области — один коммит на область. Дробить каждую правку в отдельный коммит
не надо, сваливать всё в один — тоже.
Для ориентира: в апстриме медиана — **1 коммит на PR**, 72% пул-реквестов однокоммитные. Наши
двенадцать получили `CHANGES_REQUESTED`, шесть — второй.
## Ориентиры по тексту
| | принято в апстриме |
|---|---|
| тело коммита | медиана **3 строки**, p90 = 15 |
| длина subject | медиана 47 символов, мягкий предел 60, жёсткий 80 |
| ссылки и `#NNNN` в теле | нет у **90%** коммитов |
| перенос строк | 70–72 колонки |
## Жёсткие правила
Каждое оплачено конкретным заворотом.
- **Ни одного `#NNNN` и ни одной ссылки в теле коммита.** Force-push рассылает упоминания заново.
Ссылка на issue живёт в теле PR.
- **Никакой совместимости с EOL-релизами.** 23.05 отклонён дважды; 24.10 на очереди.
- **Ничего, что нужно только нашим гейтам**: экспорт ради теста помечается `/* fs:probe */`
и вырезается синком.
- **Никакой саморекламы** — ни ссылок на свой репозиторий, ни имени темы в футере.
- **Никаких миграций «на всякий случай»**: OpenWrt исходит из того, что пользователь обновляет
образ целиком, а не отдельные пакеты.
- **Комментарий в апстрим-PR пишет пользователь, а не сессия** (`CLAUDE.md`). Ответ сессии — диф
и resolve треда.
- **`po/` не отправляется** — это Weblate.
- **Никаких AI-трейлеров** в коммитах (`CLAUDE.md`).
- **Релизные ветки (`openwrt-25.12`, …) берут только багфиксы и security** — новых пакетов туда не
принимают, почему тема и не попадёт в фид 25.12.
- **`Makefile` на той стороне ведётся руками.** `sync-luci-fork.sh` его не переносит, поэтому
postinst/postrm/conffiles надо править и там тоже. `npm run fork-drift` — отчёт, а не гейт:
непредложенное расхождение легитимно, поэтому его читают, а не ждут пустым.
## Форма коммита
```
luci-theme-footstrap: lowercase description
Что поменялось. Одно-два предложения, перенос на 72 колонках.
Signed-off-by: Ivan Kvashonkin <vizzlef@gmail.com>
```
Длина тела определяется числом изменений, а не желанием объяснить. Несколько мелких правок
перечисляются скопом, одной строкой каждая:
```
luci-theme-footstrap: fix spacing on luci-base classes
Value rows drew a separator with no row after them, a meter's value sat on
its label in a card, and the per-section Delete touched the tab bar below it.
```
Так — не надо: абзац про то, как выяснили причину, сколько прогонов сделали и почему выбран
именно такой селектор. Это живёт в нашем changelog и в комментариях кода, не в апстрим-коммите.
Subject: префикс пакета, двоеточие, строчная буква, без точки в конце. Для правки общей
инфраструктуры (`luci.mk`) префикс — `treewide:`; `luci.mk:` бот не принимает.
## Что уже отклонено — не предлагать заново
1. **Совместимость с 23.05** — «We are the upstream project. We don't need to keep adding
compatibility code for old releases just because it happens to be possible».
2. **Ссылка на внешний сайт в футере** — «None of the other themes do that… they are seen as
originating from the repo itself».
3. **Многословный `uci-defaults`** с чистками старых версий — сокращён со 162 строк до 36.
4. **Публичные экспорты ради наших гейтов.**
## Чек-лист перед `gh pr create`
- [ ] коммитов столько, сколько логических изменений — не больше
- [ ] тело каждого — одно-два предложения, перенос ≤72, без ссылок и `#NNNN`
- [ ] subject ≤60, строчная после двоеточия, без точки
- [ ] `Signed-off-by` во всех, автор — **настоящие имя и фамилия** (`git config user.name` в копии
PR); адрес `@users.noreply.github.com` апстрим отклоняет
- [ ] ветка не `master`, имя описывает изменение
- [ ] `npm run check` — 0
- [ ] `/security-review` по финальной ветке — прогнан, находки разобраны
- [ ] их eslint по изменённым файлам — без новых предупреждений
- [ ] вершина ветки совпадает с `main` по шиппящимся файлам и по md5 `cascade.css`
- [ ] в диффе нет `po/`, `Makefile`, наших скриптов сборки
- [ ] тело PR: по строке на коммит, ссылки и `Closes #NNNN` — здесь
## Справочник
| Нужно | Читать |
|---|---|
| собрать или пересобрать ветку | `reference/mechanics.md` |
| проверки перед пушем | `reference/preflight.md` |
| мониторинг и ответ на ревью | `reference/answering-review.md` |
@@ -0,0 +1,92 @@
# Мониторинг и ответ на ревью
Правило `CLAUDE.md`: **сессия не пишет комментарии в апстрим-PR**. Замечание закрывается правкой
в диффе и пометкой треда resolved. Если что-то надо сказать человеку — текст готовится в чате,
отправляет его пользователь.
## Мониторинг
Один persistent-монитор на PR: комментарии, инлайны, новые ревью, смена состояния. Опрос раз в
минуту; id уже виденных ревью — в файле, иначе каждый цикл повторяет одно и то же.
```sh
gh api "repos/openwrt/luci/issues/<N>/comments?since=$last" --jq '.[] | "comment by \(.user.login): \(.body[0:400])"'
gh api "repos/openwrt/luci/pulls/<N>/comments?since=$last" --jq '.[] | "inline by \(.user.login) on \(.path):\(.line): \(.body[0:400])"'
gh api "repos/openwrt/luci/pulls/<N>/reviews" --jq '.[] | "\(.id)|review by \(.user.login) [\(.state)]: \(.body[0:300])"'
gh pr view <N> --repo openwrt/luci --json state --jq .state # MERGED / CLOSED — можно снимать монитор
```
## Два вида ревью
### Бот (`openwrt-ai`) — разбираем сами
Не ждём пользователя. По каждому замечанию:
1. **Проверить измерением, право ли оно.** Бот ошибается заметно чаще человека: он жаловался, что
`text-overflow: ellipsis` ничего не делает, хотя `white-space: nowrap` приходит из base; он же
нашёл настоящую регрессию в селекторе подписей графиков.
2. Настоящую ошибку — починить сразу, в том же заходе.
3. Ложную — не чинить, зафиксировать в ответе пользователю одной строкой, почему.
4. Треды закрыть — **только те, что действительно исправлены или доказательно опровергнуты**.
### Человек — уведомление и вердикт
1. Отправить пользователю уведомление, что пришло ревью от живого мейнтейнера.
2. Прочитать целиком, не по обрезанному уведомлению.
3. Разобрать по пунктам и дать вердикт по каждому: делаем / это ошибка ревьюера, вот почему /
требует решения пользователя.
4. Правки — после его слова, если пункт спорный; очевидные — сразу.
## Команды
```sh
gh api repos/openwrt/luci/pulls/<N>/comments --jq '.[] | "=== \(.path):\(.line // .original_line)\n\(.body)"'
gh api repos/openwrt/luci/pulls/<N>/reviews --jq '.[] | "=== \(.user.login) [\(.state)]\n\(.body)"'
```
Открытые треды и закрытие:
```sh
gh api graphql -f query='query { repository(owner:"openwrt", name:"luci") {
pullRequest(number:<N>) { reviewThreads(first:30) { nodes { id isResolved path line
comments(first:1){nodes{body}} } } } } }' \
--jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved|not) | "\(.id)|\(.path):\(.line)"'
gh api graphql -f query='mutation($id: ID!) { resolveReviewThread(input:{threadId:$id}) { thread { isResolved } } }' -f id="<THREAD_ID>"
```
CI после пуша:
```sh
SHA=$(cd /tmp/luci-pr && git rev-parse HEAD)
until [ "$(gh api repos/openwrt/luci/commits/$SHA/check-runs --jq '[.check_runs[]|select(.status!="completed")]|length')" = "0" ]; do sleep 20; done
gh api repos/openwrt/luci/commits/$SHA/check-runs --jq '.check_runs[] | "\(.name): \(.conclusion)"'
```
## Заготовки ответов
Отдаются пользователю в чат, отправляет он.
**Про `'prefs' is not defined`**
> That is an `eslint.config.mjs` defect: it does not read the `'require X as Y'` pragma, so every
> aliased require reports as undefined — 24 of the 30 aliases in the tree, 59 files across 18
> packages. The fix belongs there; I can send it as a separate PR.
**Про security review**
> Yes — the branch diff was reviewed: the installer's signature chain, the new shell script, the
> login template, the browser JS and the build pipeline. No findings.
**Про совместимость со старьём**
> Dropped — the theme targets 24.10 and newer now. A 23.05 router gets the pinned last release that
> runs on it, verified by the same signature and digest as any other artifact.
## Чего не делать
- Не резолвить тред, который не исправлен, — даже «объяснив» его.
- Не спорить с позицией «мы апстрим»: просят убрать совместимость или вынести правку в отдельный
PR — дешевле сделать. Оба раза это заняло часы и было принято.
- Не переоткрывать PR ради переделки формы: `push --force-with-lease` в ту же ветку — норма.
Закрывать стоит, только если меняется сам предмет PR.
@@ -0,0 +1,120 @@
# Как собрать ветку
Апстрим держит только собранный `cascade.css`, а не `styles/`. Значит каждый коммит PR должен
нести свой кусок готового листа — то есть ветка собирается **переигрыванием** состояний нашего
дерева, а не переносом коммитов `main` как есть.
## Рабочие копии
| Путь | Что это |
|---|---|
| `.` | наш репозиторий, ветка `main` |
| `../tmp/fs-hist` | worktree нашего репозитория для переигрывания |
| `/tmp/luci-pr` | клон openwrt/luci; `origin` — апстрим, `fork` — наш форк |
```sh
git worktree add --detach ../tmp/fs-hist <точка> # если worktree ещё нет
cd /tmp/luci-pr && git config user.name "Ivan Kvashonkin" # иначе формальности завернут
```
## Шаг 1. Найти точку, где апстрим совпадал с нами
```sh
cd /tmp/luci-pr && git show origin/master:themes/luci-theme-footstrap/htdocs/luci-static/footstrap/cascade.css | md5sum
# затем в ../tmp/fs-hist перебрать кандидатов:
for c in $(git log --format=%h -25 -- luci-theme-footstrap/styles); do
git checkout --quiet --detach $c && bash luci-theme-footstrap/build-css.sh >/dev/null 2>&1
m=$(md5sum luci-theme-footstrap/htdocs/luci-static/footstrap/cascade.css | cut -d' ' -f1)
[ "$m" = "<их md5>" ] && echo "MATCH $c"
done
```
## Шаг 2. Переиграть, пропуская ненужное
Ветка строится группами: `cherry-pick -n` нескольких коммитов, затем один `commit`. Так несколько
наших коммитов становятся одним апстримным — группируем по логике изменения, а не по тому, как
работа шла в нашем репозитории.
```sh
cd ../tmp/fs-hist && git checkout --quiet -B upstream-N <точка>
pick() { for c in "$@"; do git cherry-pick -n -x $c >/dev/null 2>&1 || {
git diff --name-only --diff-filter=U | while read -r f; do git checkout --theirs "$f"; git add "$f"; done; }; done; }
pick <коммит> <коммит> && git commit --quiet -m g1
```
Приёмы, которые понадобились на практике:
- **выкинуть часть коммита** — после `pick` вернуть файл на место:
`git checkout HEAD -- luci-theme-footstrap/styles/theme/45-misc.css`;
- **перенести часть в другую группу** — взять файл из нужного коммита:
`git checkout <коммит> -- <файл>`;
- **пропустить коммит целиком** — просто не включать в `pick` (так выпали 23.05-коммиты);
- **правка комментария должна ехать с кодом, который она описывает** — иначе в серии видно, как
один коммит вводит текст, а следующий его исправляет.
## Шаг 3. Сверить вершину с `main`
Перед нарезкой коммитов PR — обязательно, иначе в апстрим уедет не то, что проверено.
```sh
for f in htdocs/luci-static/resources/fs-fit.js htdocs/luci-static/resources/fs-appearance.js \
htdocs/luci-static/resources/fs-overview.js htdocs/luci-static/resources/fs-router.js \
ucode/template/themes/footstrap/sysauth.ut; do
echo "$f: $(diff <(git show HEAD:luci-theme-footstrap/$f) \
<(cd <репо> && git show HEAD:luci-theme-footstrap/$f) | wc -l)"
done
bash luci-theme-footstrap/build-css.sh >/dev/null 2>&1 && md5sum luci-theme-footstrap/htdocs/luci-static/footstrap/cascade.css
```
Все нули и совпадающий md5 — можно резать коммиты.
## Шаг 4. Нарезать коммиты PR
Для каждой точки: синк в клон luci, затем коммит с готовым сообщением.
```sh
cd /tmp/luci-pr && git checkout --quiet -B <ветка> origin/master && git reset --hard --quiet origin/master
i=1
for p in $(cd ../tmp/fs-hist && git log --format=%h --reverse <точка>..upstream-N); do
(cd ../tmp/fs-hist && git checkout --quiet --detach $p && ./tools/sync-luci-fork.sh /tmp/luci-pr >/dev/null 2>&1)
cd /tmp/luci-pr
git checkout --quiet origin/master -- themes/luci-theme-footstrap/po # Weblate владеет каталогами
git add -A themes/luci-theme-footstrap
git diff --cached --quiet || git commit --quiet -F <файл сообщения $i>
i=$((i+1))
done
```
`po/` возвращается на каждом шаге не случайно: наши каталоги отличаются путями в комментариях,
и без этого они попадут в дифф.
## Шаг 5. Проверить дифф ветки
```sh
cd /tmp/luci-pr
git log --format="%h %s" --stat origin/master..HEAD | grep -E "^[0-9a-f]{7} |files? changed"
git diff --name-only origin/master..HEAD # только шиппящиеся файлы, без po/ и Makefile
git log --format="%an <%ae>" origin/master..HEAD | sort -u
```
## Шаг 6. Публикация
```sh
git push --force-with-lease --quiet fork <ветка>
gh pr create --repo openwrt/luci --base master --head VizzleTF:<ветка> \
--title "..." --body-file <файл>
```
Обновление существующего PR — тот же `push --force-with-lease` плюс, если состав изменился,
`gh pr edit <N> --body-file`.
## Грабли
- `luci.mk:` как префикс subject бот отвергает — нужен `treewide:`.
- Автор коммита обязан быть «Имя Фамилия»; `git config user.name` в `/tmp/luci-pr` по умолчанию
берётся глобальный (`VizzleTF`) и заворачивается.
- `sync-luci-fork.sh` сам вырезает `/* fs:probe */`-экспорты — но только если рабочая копия,
из которой идёт синк, содержит свежие `tools/sync-luci-fork.sh` и `strip-probes.sh`.
- `cascade.css` в апстриме лежит **неминифицированным** и **без манглинга токенов** — это
осознанно: там дерево исходников, которое кто-то должен читать при ревью.
@@ -0,0 +1,107 @@
# Предполёт
Каждая проверка здесь стоит одного замечания, которое иначе прилетит в ревью и будет стоить
переигрывания ветки.
## 1. Наши гейты
```sh
npm run check # должен выйти 0; включает lint, audit, size, changelog, a11y и остальные
```
Не входят в `check` и запускаются отдельно, когда правка их касается:
`npm run live` (нужен поднятый стенд), `npm run fork-drift`, `npm run upstream`.
## 2. Их eslint по нашим файлам
Апстримный конфиг перечисляет глобалы вручную и **не читает** прагму `'require X as Y'`, поэтому
он ругается на алиасы, которых нет в его списке. Это ловится локально:
```sh
cd /tmp/lintcheck && cp <изменённые .js> .
# конфиг с их набором глобалов, без плагинов
<репо>/node_modules/.bin/eslint --no-config-lookup -c ./eslint.config.mjs *.js | grep "is not defined"
```
Если предупреждение относится к алиасу — это дефект их конфига (24 из 30 алиасов дерева ему
неизвестны, задето 59 файлов в 18 пакетах), и ответ такой же. Если к настоящей опечатке — чинить.
## 3. Их правила формальностей
`.github/formalities.json` в клоне luci — машинный гейт, он висит перед сборкой:
| Правило | Значение |
|---|---|
| subject | ≤60 мягко, ≤80 жёстко |
| строка тела | ≤100 символов |
| тело | обязательно, и не должно повторять subject |
| `Signed-off-by` | обязателен, совпадает с автором |
| автор | имя и фамилия, не `@users.noreply.github.com` |
| ветка | не `master`/`main` |
| merge-коммиты, CRLF | запрещены |
Проверка своими руками перед пушем:
```sh
awk 'length > 75 {print FILENAME": "FNR": "length}' <файлы сообщений>
head -1 <файл> | wc -c # ≤61 с учётом перевода строки
```
## 4. Шаблоны компилируются
Только на стенде — локально `ucode` нет.
```sh
owlab exec owrt2512 -- 'for f in $(find /usr/share/ucode/luci/template/themes/footstrap -name "*.ut"); do
ucode -T -c -o /dev/null "$f" || echo "FAILS: $f"; done; echo done'
```
## 5. Живая проверка
Поведенческая правка не готова, пока не прогнана на **обоих** пакетных менеджерах:
```sh
owlab sync owrt2512 owrt2410
node tools/scroll-anchor.mjs --engines chromium,firefox,webkit # если трогали fs-fit
npm run live -- --all # общий обход
```
CSS доказывается computed-style или пиксельным диффом против прогона того же листа, не скриншотом:
живые счётчики дают 0.5–1.3% различий сами по себе.
## 6. Дрейф
```sh
./tools/sync-luci-fork.sh ../luci-fork && npm run fork-drift
```
Легитимно отличаются только `Makefile` (правится на той стороне вручную), `po/` (Weblate) и
`cascade.css` (собирается здесь, коммитится там). Всё остальное в списке DRIFT — это либо
неотправленная правка, либо забытый синк.
## 7. Размер
Понадобится, если спросят про вес пакета — спрашивали уже дважды.
```sh
node tools/size-budget.mjs --show # разбивка JS по файлам, CSS, холодная страница
du -sb luci-theme-footstrap/ucode luci-theme-footstrap/htdocs/luci-static/resources
```
Доля комментариев в файле:
```sh
python3 -c "
import re,glob
tot=com=0
for f in glob.glob('luci-theme-footstrap/ucode/**/*.ut', recursive=True):
s=open(f).read(); tot+=len(s); com+=sum(len(m) for m in re.findall(r'\{#.*?#\}', s, re.S))
print(f'{tot/1024:.1f} KB, comments {com/1024:.1f} KB ({100*com/tot:.0f}%)')"
```
## 8. Security review
Обязателен перед PR и перед релизом (`CLAUDE.md`), прогоняется по финальной ветке — `/security-review`.
Мейнтейнер спрашивал прямо; в последний раз находок не было.
Что проверять в первую очередь: цепочку подписи в `install.sh`, новые shell-скрипты, шаблон логина
(страница неаутентифицированная), sink-и в браузерном JS, изменения конвейера сборки.
@@ -0,0 +1,71 @@
/* The documented adversaries, in the order that matters: open the app's page, then LEAVE it and
* look at the page you land on. Containment is about the NEXT page — the fence keeps a foreign
* `!important` off the chrome, page ownership darkens the sheet, and documentPoisoned decides
* whether the router may hand the document on at all. */
import { chromium } from 'playwright';
import { login, SNAP, PORTS } from './lib.mjs';
const router = process.argv[2] || 'owrt2512';
const BASE = `http://localhost:${PORTS[router]}`;
/* titled, reachable pages of each adversary; openclash's are `call` nodes (legacy Lua CBI), which
* the router deliberately never takes SPA — they still have to leave the chrome intact. */
const TARGETS = [
['banip/feeds', 'admin/services/banip/feeds'],
['adblock/feeds', 'admin/services/adblock/feeds'],
['podkop', 'admin/services/podkop'],
['ssclash/config', 'admin/services/ssclash/config'],
['ssclash/log', 'admin/services/ssclash/log'],
['filemanager', 'admin/system/filemanager'],
['openclash/config', 'admin/services/openclash/config'],
['openclash/settings', 'admin/services/openclash/settings'],
['openclash/log', 'admin/services/openclash/log'],
['3ginfo/detail', 'admin/modem/3ginfo-lite/3gdetail'],
['mwan3/status', 'admin/status/mwan3/overview'],
['statistics/graphs', 'admin/statistics/graphs'],
];
const DETAIL = `(() => [...document.querySelectorAll('style, link[rel~="stylesheet"]')].map((el) => {
let rules = null, err = null;
try { rules = el.sheet ? el.sheet.cssRules.length : null; } catch (e) { err = 'unreadable'; }
return { tag: el.tagName, href: el.getAttribute('href'),
head: el.tagName === 'STYLE' ? (el.textContent || '').slice(0, 60).replace(/\\s+/g, ' ') : null,
shell: el.hasAttribute('data-fs-shell'), layered: el.dataset.fsLayered === '1',
elDisabled: el.disabled, sheetDisabled: (() => { try { return el.sheet ? el.sheet.disabled : null; } catch (e) { return err; } })(),
rules };
}))()`;
/* The chrome's own geometry: what openclash's \`*{padding:0}\` destroys first. */
const CHROME = `(() => {
const q = (s) => document.querySelector(s);
const box = (s) => { const e = q(s); if (!e) return null; const r = e.getBoundingClientRect();
const c = getComputedStyle(e); return { w: Math.round(r.width), h: Math.round(r.height), pad: c.padding, gap: c.gap }; };
const link = q('#topmenu > li > a');
return { sidebar: box('.fs-sidebar'), brand: box('.fs-brand'), content: box('.fs-content'),
firstMenuLink: link ? { pad: getComputedStyle(link).padding, x: Math.round(link.getBoundingClientRect().x) } : null,
tabs: box('#tabmenu'), appearanceBtn: box('#fs-appearance') };
})()`;
const b = await chromium.launch();
const p = await b.newPage({ viewport: { width: 1600, height: 1000 } });
await login(p, BASE);
const clean = { snap: await p.evaluate(SNAP), chrome: await p.evaluate(CHROME) };
const out = [];
for (const [name, path] of TARGETS) {
await p.goto(BASE + '/cgi-bin/luci/' + path, { waitUntil: 'domcontentloaded' }).catch(() => {});
await p.waitForTimeout(3000);
const onApp = { snap: await p.evaluate(SNAP), chrome: await p.evaluate(CHROME),
sheets: (await p.evaluate(DETAIL)).filter((s) => !s.shell) };
/* now LEAVE, by the router's own path, and look at the page we land on */
await p.evaluate(() => { window.__fsSentinel = 1; });
await p.evaluate(() => document.querySelector('a[href="/cgi-bin/luci/admin/system/system"]')?.click());
await p.waitForTimeout(2600);
const after = { spa: await p.evaluate(() => window.__fsSentinel === 1),
snap: await p.evaluate(SNAP), chrome: await p.evaluate(CHROME) };
out.push({ name, path, onApp, after });
process.stderr.write('.');
}
process.stderr.write('\n');
console.log(JSON.stringify({ router, clean, out }, null, 1));
await b.close();
@@ -0,0 +1,37 @@
/* The pin exists so the actions column lines up across rows whose buttons differ. Wiping it must not
* cost that: check every row's actions cell has the same width and the same left edge, on tables
* where the buttons really do differ per row, on a fresh load AND after the resizes that trigger the
* wipe. */
import { chromium } from 'playwright';
import { login, PORTS } from './lib.mjs';
const BASE = `http://localhost:${PORTS[process.argv[2] || 'owrt2512']}`;
const A = `(() => [...document.querySelectorAll('.table.cbi-section-table')].map((t, i) => {
const cells = [...t.querySelectorAll('.tr:not(.cbi-section-table-titles):not(.cbi-section-table-descr) > .cbi-section-actions')];
if (!cells.length) return null;
const w = cells.map((c) => Math.round(c.getBoundingClientRect().width));
const x = cells.map((c) => Math.round(c.getBoundingClientRect().left));
const btns = cells.map((c) => c.querySelectorAll('button, .btn').length);
return { table: i, rows: cells.length, widths: [...new Set(w)], lefts: [...new Set(x)], btnCounts: [...new Set(btns)],
aligned: new Set(w).size === 1 && new Set(x).size === 1 };
}).filter(Boolean))()`;
const b = await chromium.launch();
const p = await b.newPage({ viewport: { width: 1280, height: 900 } });
await login(p, BASE);
const settle = async () => { await p.waitForTimeout(500); await p.evaluate(() => new Promise((r)=>requestAnimationFrame(()=>requestAnimationFrame(r)))); await p.waitForTimeout(700); };
for (const path of ['admin/network/wireless', 'admin/network/firewall/zones', 'admin/network/firewall/forwards', 'admin/network/dhcp']) {
for (const step of [['fresh 1280', 1280, null], ['1000->1280', 1000, 1280], ['768->800', 768, 800], ['2560->1280', 2560, 1280]]) {
const [label, from, to] = step;
await p.setViewportSize({ width: from, height: 900 });
await p.goto(BASE + '/cgi-bin/luci/' + path, { waitUntil: 'domcontentloaded' }).catch(()=>{});
await p.waitForTimeout(3000);
if (to) { await p.setViewportSize({ width: to, height: 900 }); await settle(); }
const r = await p.evaluate(A);
const bad = r.filter((x) => !x.aligned && x.rows > 1);
console.log(path.split('/').slice(-1)[0].padEnd(10), label.padEnd(12),
r.length ? r.map((x)=>`t${x.table}:${x.rows}r w=${JSON.stringify(x.widths)} btn=${JSON.stringify(x.btnCounts)}${x.aligned||x.rows<2?'':' <-- MISALIGNED'}`).join(' ') : '(no actions column)');
}
}
await b.close();
@@ -0,0 +1,105 @@
/* Save & Apply across an SPA navigation.
*
* navigate() empties L.Poll's queue and calls stop()+start() on every hop, on the stated grounds that
* "the only non-view poller LuCI adds is the transient apply/reboot reachability check". That claim
* has never been exercised: stage a uci change, walk to another page, and apply from THERE — the
* confirmation countdown and the reconnect probe both have to survive a queue this router flushed.
*
* Uses system.@system[0].log_size, and puts it back afterwards.
*/
import { chromium } from 'playwright';
import { login, PORTS } from './lib.mjs';
const BASE = `http://localhost:${PORTS[process.argv[2] || 'owrt2512']}`;
const U = (p) => '/cgi-bin/luci/' + p;
const b = await chromium.launch();
const p = await b.newPage({ viewport: { width: 1500, height: 950 } });
const errs = [];
p.on('pageerror', (e) => errs.push(String(e).split('\n')[0].slice(0, 120)));
await login(p, BASE);
const out = {};
const ind = () => p.evaluate(() => ({
indicators: [...document.querySelectorAll('#indicators [data-indicator]')].map((e) => e.textContent.trim()),
poll: L.Poll?.queue?.length ?? null,
active: L.Poll?.active?.() ?? null
}));
await p.goto(BASE + U('admin/system/system'), { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(3000);
out.orig = await p.evaluate(async () => {
const uci = await L.require('uci');
await uci.load('system');
return uci.get_first('system', 'system', 'log_size');
});
/* stage a change exactly as a view's Save button does */
out.staged = await p.evaluate(async (orig) => {
const uci = await L.require('uci');
await uci.load('system');
uci.set('system', uci.get_first('system', 'system', '.name'), 'log_size', String((parseInt(orig || '64', 10) || 64) + 1));
await uci.save();
await L.ui.changes.init();
return true;
}, out.orig);
await p.waitForTimeout(1200);
out.afterStage = await ind();
/* walk away by SPA, twice */
for (const path of ['admin/status/processes', 'admin/network/routes']) {
await p.evaluate((h) => {
const a = document.querySelector(`a[href="${h}"]`);
if (a) return a.click();
const s = document.createElement('a'); s.href = h; document.getElementById('view').appendChild(s); s.click();
}, U(path));
await p.waitForTimeout(2400);
}
out.afterNav = await ind();
out.spaStillLive = await p.evaluate(() => location.pathname);
/* apply from the page we walked to */
const t0 = Date.now();
await p.evaluate(() => { L.ui.changes.apply(false); });
/* the apply overlay ends by reloading the document; wait for either that or a settled modal */
let applied = null;
for (let i = 0; i < 60; i++) {
await p.waitForTimeout(1000);
applied = await p.evaluate(() => ({
modal: (document.querySelector('#modal_overlay .modal')?.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 120),
overlayShown: getComputedStyle(document.getElementById('modal_overlay') || document.body).display,
changes: document.querySelectorAll('#indicators [data-indicator="uci-changes"]').length
})).catch(() => null);
if (applied && applied.changes === 0 && !/Applying|Waiting|Configuration/i.test(applied.modal)) break;
}
out.applySeconds = Math.round((Date.now() - t0) / 1000);
out.applied = applied;
await p.waitForTimeout(2000);
out.final = await p.evaluate(async () => {
const uci = await L.require('uci');
uci.unload('system');
await uci.load('system');
return { log_size: uci.get_first('system', 'system', 'log_size'),
path: location.pathname,
indicators: [...document.querySelectorAll('#indicators [data-indicator]')].map((e) => e.textContent.trim()) };
});
/* put it back */
await p.evaluate(async (orig) => {
const uci = await L.require('uci');
await uci.load('system');
uci.set('system', uci.get_first('system', 'system', '.name'), 'log_size', orig);
await uci.save();
await uci.apply();
}, out.orig);
await p.waitForTimeout(4000);
out.reverted = await p.evaluate(async () => {
const uci = await L.require('uci');
uci.unload('system'); await uci.load('system');
return uci.get_first('system', 'system', 'log_size');
});
out.errs = [...new Set(errs)].slice(0, 6);
console.log(JSON.stringify(out, null, 1));
await b.close();
@@ -0,0 +1,41 @@
/* Which element inside the last cell demands 634px after a carded -> table resize, and what does it
* demand on a fresh load at the same width? */
import { chromium } from 'playwright';
import { login, PORTS } from './lib.mjs';
const BASE = `http://localhost:${PORTS[process.argv[2] || 'owrt2512']}`;
const PATH = 'admin/network/firewall/zones';
const DIG = `(() => {
const t = document.querySelector('.table.cbi-section-table');
const row = [...t.querySelectorAll('.tr:not(.cbi-section-table-titles)')][0];
const cells = [...row.children];
const wide = cells.map((c, i) => ({ i, w: Math.round(c.getBoundingClientRect().width),
cls: c.className, widget: c.getAttribute('data-widget'), title: c.getAttribute('data-title') }))
.sort((a, b) => b.w - a.w)[0];
const cell = cells[wide.i];
const kids = [...cell.querySelectorAll('*')].map((e) => {
const r = e.getBoundingClientRect(); const cs = getComputedStyle(e);
return { tag: e.nodeName.toLowerCase(), cls: (typeof e.className === 'string' ? e.className : '').slice(0, 46),
w: Math.round(r.width), sw: e.scrollWidth, styleW: e.style.width || '', cssW: cs.width,
minW: cs.minWidth, ws: cs.whiteSpace, disp: cs.display, flex: cs.flex, pos: cs.position };
}).filter((k) => k.w > 100).sort((a, b) => b.w - a.w).slice(0, 8);
return { cellIndex: wide.i, cellW: wide.w, cellCls: wide.cls, widget: wide.widget, title: wide.title,
cellCss: (() => { const c = getComputedStyle(cell); return { disp: c.display, ws: c.whiteSpace, minW: c.minWidth, w: c.width }; })(),
kids };
})()`;
const b = await chromium.launch();
const p = await b.newPage({ viewport: { width: 1000, height: 900 } });
await login(p, BASE);
const settle = async () => { await p.waitForTimeout(400); await p.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))); await p.waitForTimeout(500); };
await p.setViewportSize({ width: 1000, height: 900 });
await p.goto(BASE + '/cgi-bin/luci/' + PATH, { waitUntil: 'domcontentloaded' }); await p.waitForTimeout(3000);
await p.setViewportSize({ width: 1280, height: 900 }); await settle();
const broken = await p.evaluate(DIG);
await p.setViewportSize({ width: 1280, height: 900 });
await p.goto(BASE + '/cgi-bin/luci/' + PATH, { waitUntil: 'domcontentloaded' }); await p.waitForTimeout(3000);
const fresh = await p.evaluate(DIG);
console.log(JSON.stringify({ broken, fresh }, null, 1));
await b.close();
@@ -0,0 +1,27 @@
/* A menu node named `constructor` is a name any package may choose in its menu.d. iconSvg() looks
* the section name up in the icon table, so a table with Object.prototype behind it answers that
* name with a function — which then goes into the sidebar link's innerHTML. Read what the live
* sidebar actually contains for that item. */
import { chromium } from 'playwright';
const BASE = process.env.FS_BASE || 'http://localhost:8025';
const b = await chromium.launch(); const p = await b.newPage({ viewport: { width: 1600, height: 1000 } });
await p.goto(BASE + '/cgi-bin/luci/', { waitUntil: 'domcontentloaded' });
if (await p.locator('input[name="luci_password"]').count()) {
await p.fill('input[name="luci_username"]', 'root'); await p.fill('input[name="luci_password"]', '');
await Promise.all([ p.waitForNavigation({ waitUntil: 'domcontentloaded' }), p.click('input[type=submit]') ]);
}
await p.waitForTimeout(2500);
console.log(JSON.stringify(await p.evaluate(() => {
const li = [...document.querySelectorAll('#topmenu > li')].find((x) => x.dataset.name === 'constructor');
if (!li) return { found: false, sections: [...document.querySelectorAll('#topmenu > li')].map((x) => x.dataset.name) };
const a = li.querySelector(':scope > a');
const html = a.innerHTML;
return {
found: true,
label: a.querySelector('.fs-label')?.textContent,
leaksNativeCode: /native code|\[object Object\]|function Object/.test(html),
startsWithSvg: html.trimStart().startsWith('<svg'),
iconHtml: html.slice(0, 130)
};
}), null, 2));
await b.close();
@@ -0,0 +1,159 @@
/* Before/after shots for the design review: one pair per finding, on the live stand.
*
* Poll is stopped before the first shot so the two frames differ only by the override sheet —
* an Overview card is rebuilt every 5 s and a moving counter reads as a layout change. */
import { chromium } from 'playwright';
import { fileURLToPath } from 'node:url';
import { login, PORTS } from './lib.mjs';
const router = process.argv[2] || 'owrt2512';
const only = process.argv[3] || null;
/* One test for every case, numbered ones included: `07` and `07-shadow-pop` both select the modal. */
const want = (id) => !only || id.startsWith(only);
const base = `http://localhost:${PORTS[router]}`;
const OUT = fileURLToPath(new URL('../../../tmp/design-ab', import.meta.url));
/* Sections are addressed by a data-ab stamped from their h3 text: the h3 carries the collapse
* toggle's label too ("SystemHide"), so a :has(> h3) text selector never matches. */
const SEC = (t) => `#view .cbi-section[data-ab="${t}"]`;
const STAMP = `(() => { for (const s of document.querySelectorAll('#view .cbi-section')) {
const t = (s.querySelector('h3')?.textContent || '').replace(/Hide$|Show$/, '').trim();
if (t) s.setAttribute('data-ab', t);
}
for (const s of document.querySelectorAll('#view .cbi-section')) {
const hs = [...s.querySelectorAll('h3')];
hs.slice(1).forEach((h) => h.setAttribute('data-ab-sub', '1'));
}
return [...document.querySelectorAll('#view .cbi-section[data-ab]')].map(s => s.dataset.ab); })()`;
const CASES = [
{ id: '01-accent-fill', sel: SEC('Network'), css: `
.ifacebox .ifacebox-head.active {
background: transparent; color: var(--fs-faint);
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight);
letter-spacing: var(--fs-eyebrow-tracking); text-transform: uppercase;
text-align: start; border-bottom: var(--fs-hairline);
}` },
{ id: '02-nesting', sel: SEC('Network'), css: `
.network-status-table .ifacebox { border: 0; background: transparent; padding: 0; }
.network-status-table .ifacebox-body .ifacebadge {
border: 0; background: transparent; padding: 0;
border-left: 2px solid var(--fs-border); padding-left: var(--fs-space-2);
}` },
{ id: '03-row-action', sel: SEC('DHCP Leases'), css: `
#view .table[id] .td .cbi-button-apply {
background: transparent; border-color: transparent; color: var(--fs-dim);
box-shadow: none;
}
#view .table[id] .tr:hover .td .cbi-button-apply,
#view .table[id] .td .cbi-button-apply:focus-visible {
color: var(--fs-accent); border-color: var(--fs-border);
}` },
{ id: '04-zone-red', sel: SEC('Port status'), css: `
body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-head:nth-child(3) {
height: 2px; overflow: hidden; opacity: .45;
}
body[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-head:nth-child(3) .zonebadge {
filter: saturate(.35);
}` },
{ id: '05-triple-title', sel: SEC('DHCP Leases'), css: `
#view .cbi-section h3[data-ab-sub] {
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight);
letter-spacing: var(--fs-eyebrow-tracking); text-transform: uppercase;
color: var(--fs-faint); margin-bottom: var(--fs-space-1);
}` },
{ id: '06-two-grammars', sel: SEC('Network'), css: `
.network-status-table .ifacebox-body > span { display: block; }
.network-status-table .ifacebox-body > span strong {
display: inline-block; min-width: 132px; color: var(--fs-dim);
font-weight: var(--fs-weight);
}
.network-status-table .ifacebox-body .ifacebadge > span strong { min-width: 108px; }` },
];
const b = await chromium.launch();
/* Every exit path goes through finally: a throw between the launch and the last shot would
* otherwise leave chromium running against the stand, where the next probe measures its poll. */
try {
const p = await b.newPage({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 2 });
await login(p, base);
await p.goto(base + '/cgi-bin/luci/admin/status/overview', { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(7000);
await p.evaluate(() => { try { L.Poll.stop(); } catch (e) {} });
await p.waitForTimeout(500);
console.log('sections:', JSON.stringify(await p.evaluate(STAMP)));
const put = (css) => p.evaluate((c) => {
let s = document.getElementById('fs-ab');
if (!s) { s = document.createElement('style'); s.id = 'fs-ab'; document.head.appendChild(s); }
s.textContent = c;
}, css);
const clear = () => p.evaluate(() => document.getElementById('fs-ab')?.remove());
for (const c of CASES) {
if (!want(c.id)) continue;
const loc = p.locator(c.sel).first();
if (!await loc.count()) { console.log(`SKIP ${c.id} — no ${c.sel}`); continue; }
await clear(); await p.waitForTimeout(250);
await loc.screenshot({ path: `${OUT}/${c.id}-before.png` });
await put(c.css); await p.waitForTimeout(400);
await loc.screenshot({ path: `${OUT}/${c.id}-after.png` });
console.log(`ok ${c.id}`);
}
await clear();
/* 07 — the pop shadow, on a real modal */
if (want('07-shadow-pop')) {
await p.evaluate(() => L.ui.showModal('Delete lease', [
E('p', {}, 'The lease for nb-ivan (10.11.12.144) will be released.'),
E('div', { class: 'right' }, [
E('button', { class: 'btn cbi-button' }, 'Cancel'),
E('button', { class: 'btn cbi-button cbi-button-negative' }, 'Delete'),
]),
]));
await p.waitForTimeout(600);
/* the shadow spreads 34px, so the clip is the modal's own box plus 70px of scrim on every side */
const mb = await p.evaluate(() => { const r = document.querySelector('.modal').getBoundingClientRect();
return { x: Math.max(0, r.x - 70), y: Math.max(0, r.y - 70), width: r.width + 140, height: r.height + 140 }; });
await p.screenshot({ path: `${OUT}/07-shadow-pop-before.png`, clip: mb });
await put(`.modal { box-shadow: 0 1px 2px rgba(0,0,0,.06); border: var(--fs-hairline); }`);
await p.waitForTimeout(400);
await p.screenshot({ path: `${OUT}/07-shadow-pop-after.png`, clip: mb });
await p.evaluate(() => L.ui.hideModal());
await clear();
console.log('ok 07-shadow-pop');
}
/* 08 — the frosted bar over scrolled content */
if (want('08-blur-bar')) {
await p.evaluate(() => window.scrollTo(0, 900));
await p.waitForTimeout(500);
await p.screenshot({ path: `${OUT}/08-blur-bar-before.png`, clip: { x: 0, y: 0, width: 1440, height: 150 } });
await put(`:root { --fs-blur: none; } .fs-sidebar, .fs-topbar, [class*="fs-bar"] { background: var(--fs-panel) !important; }`);
await p.waitForTimeout(400);
await p.screenshot({ path: `${OUT}/08-blur-bar-after.png`, clip: { x: 0, y: 0, width: 1440, height: 150 } });
await clear();
await p.evaluate(() => window.scrollTo(0, 0));
console.log('ok 08-blur-bar');
}
/* 10 — Compact density: 10px x .9 = 9px on the port figures */
if (want('10-compact-9px')) {
await p.evaluate(() => document.documentElement.setAttribute('data-density', 'compact'));
await p.waitForTimeout(600);
const loc = p.locator(SEC('Port status')).first();
await loc.screenshot({ path: `${OUT}/10-compact-9px-before.png` });
await put(`:root { --fs-type-2xs: max(calc(10px * var(--fs-density-type)), 11px); }`);
await p.waitForTimeout(400);
await loc.screenshot({ path: `${OUT}/10-compact-9px-after.png` });
await p.evaluate(() => document.documentElement.removeAttribute('data-density'));
await clear();
console.log('ok 10-compact-9px');
}
} finally { await b.close(); }
@@ -0,0 +1,60 @@
/* Does dragging Wallpaper -> Dim ALONE light the "Save as default" button?
*
* Isolation matters: switching the Wallpaper seg to File is itself a diverging change, so it lights
* the button on its own and would mask the axis under test. Save that state as the router default
* first — the button greys, meaning "this browser matches" — and then the Dim drag is the only
* thing that can move it. */
import { chromium } from 'playwright';
const BASE = 'http://localhost:8025';
const b = await chromium.launch();
const p = await b.newPage({ viewport: { width: 1600, height: 1000 } });
await p.goto(BASE + '/cgi-bin/luci/', { waitUntil: 'domcontentloaded' });
if (await p.locator('input[name="luci_password"]').count()) {
await p.fill('input[name="luci_username"]', 'root');
await p.fill('input[name="luci_password"]', '');
await Promise.all([ p.waitForNavigation({ waitUntil: 'domcontentloaded' }), p.click('input[type=submit]') ]);
}
await p.waitForTimeout(2500);
const saveBtn = () => p.evaluate(() => {
const s = [...document.querySelectorAll('.fs-appearance-pop button')].find((x) => /^Saved? as default$/.test(x.textContent.trim()));
return s ? { text: s.textContent.trim(), disabled: s.disabled } : null;
});
const openPop = async () => { await p.click('#fs-appearance'); await p.waitForTimeout(400); };
await openPop();
/* baseline: wallpaper=file, saved router-wide, so this browser matches the default exactly */
await p.evaluate(() => [...document.querySelectorAll('.fs-ap-wall .fs-seg button')].find((b) => b.dataset.val === 'file').click());
await p.waitForTimeout(200);
/* tolerate an already-matching baseline: a previous run may have saved this very state, in which
* case the button is already greyed and there is nothing to press */
await p.evaluate(() => {
const s = [...document.querySelectorAll('.fs-appearance-pop button')].find((x) => /^Save as default$/.test(x.textContent.trim()));
if (s) s.click();
});
await p.waitForTimeout(2500);
const baseline = await saveBtn();
/* the axis under test, on its own */
const dim = await p.evaluate(() => {
const r = document.querySelector('.fs-ap-bg input[type=range]');
if (!r) return null;
const was = r.value;
r.value = String(Number(r.value) === 20 ? 40 : 20);
r.dispatchEvent(new Event('input', { bubbles: true }));
return { from: was, to: r.value, stored: localStorage.getItem('fs-photo-dim') };
});
await p.waitForTimeout(300);
const afterDim = await saveBtn();
const ok = baseline && baseline.disabled === true && afterDim && afterDim.disabled === false;
console.log(JSON.stringify({
baseline_savedSoButtonGreys: baseline,
dimSlider: dim,
afterDimDragAlone: afterDim,
verdict: ok ? 'PASS — Dim alone lights Save' : 'FAIL — Dim did not refresh the button'
}, null, 2));
await b.close();
process.exit(ok ? 0 : 1);
@@ -0,0 +1,100 @@
/* H3 follow-up to fwprobe.mjs: a framework page under footstrap moved the chrome (Bootstrap 5:
* 84/259 elements, font-size 12->16px). Footstrap side only, no theme switch. Per page
* (none, and each framework): which chrome properties moved and how many rects; the cascade
* winners (matched + inherited chain, with layers) of font-size/line-height/font-family on html,
* body, the sidebar and a menu link; then a CSSOM proof on the winner found on body.
*
* node .claude/tooling/fwprobe-chrome.mjs owrt2512 [bs,tw3,...]
*/
import { chromium } from 'playwright';
import { writeFileSync, mkdirSync } from 'node:fs';
import { PORTS } from './lib.mjs';
const stand = process.argv[2] || 'owrt2512';
const FWS = (process.argv[3] || 'bs,tw3,tw4,daisy').split(',');
const BASE = 'http://localhost:' + PORTS[stand];
const OUT = new URL('../../../../../../tmp/task-0051-splify2-hidden/fwprobe/', import.meta.url).pathname;
mkdirSync(OUT, { recursive: true });
const PROPS = ['font-size', 'line-height', 'font-family'];
const NODES = ['html', 'body', '.fs-sidebar', '#topmenu > li > a', '.fs-content'];
const CHROME = () => [...document.querySelectorAll('[data-fs-chrome], [data-fs-chrome] *, .fs-content, #maincontent')].map((e) => {
const cs = getComputedStyle(e); const b = e.getBoundingClientRect();
return { tag: e.tagName.toLowerCase(), cls: (typeof e.className === 'string' ? e.className : '').slice(0, 50), rect: [b.x, b.y, b.width, b.height].map(Math.round),
display: cs.display, padding: cs.padding, margin: cs.margin, fontSize: cs.fontSize, lineHeight: cs.lineHeight, fontFamily: cs.fontFamily.slice(0, 30), boxSizing: cs.boxSizing, border: cs.borderTopWidth + ' ' + cs.borderTopStyle };
});
const b = await chromium.launch();
const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
{
const p = await ctx.newPage();
await p.goto(BASE + '/cgi-bin/luci/', { waitUntil: 'domcontentloaded' });
if (await p.locator('input[name="luci_password"]').count()) {
await p.fill('input[name="luci_username"]', 'root'); await p.fill('input[name="luci_password"]', '');
await Promise.all([p.waitForNavigation({ waitUntil: 'domcontentloaded' }), p.press('input[name="luci_password"]', 'Enter')]);
}
await p.waitForTimeout(1500); await p.close();
}
const report = { stand, when: new Date().toISOString(), pages: {} };
let none = null;
for (const fw of ['none', ...FWS]) {
const p = await ctx.newPage();
const cdp = await ctx.newCDPSession(p);
const sheets = new Map();
cdp.on('CSS.styleSheetAdded', (e) => sheets.set(e.header.styleSheetId, (e.header.sourceURL || (e.header.isInline ? 'inline' : e.header.origin)).replace(BASE, '')));
await p.goto(`${BASE}/cgi-bin/luci/admin/services/fwprobe-${fw}`, { waitUntil: 'domcontentloaded' });
await p.waitForFunction((fw) => { const r = document.getElementById('fwprobe-' + fw); return !!(r && r.querySelectorAll('[data-k]').length > 10); }, fw, { timeout: 30000 });
if (fw !== 'none') await p.waitForFunction((fw) => {
const s = [...document.querySelectorAll('style[data-fs-layered]')].find((x) => x.textContent.includes('fwprobe/' + fw));
try { return !!(s && s.sheet.cssRules[0].styleSheet.cssRules.length); } catch (e) { return false; }
}, fw, { timeout: 20000 });
await p.waitForTimeout(2000);
await cdp.send('DOM.enable'); await cdp.send('CSS.enable'); await p.waitForTimeout(300);
const chrome = await p.evaluate(CHROME);
const page = { fw };
page.show = await p.evaluate((fw) => Object.fromEntries([...document.querySelectorAll(`#fwprobe-${fw} [data-k^="resp"], #fwprobe-${fw} [data-k="modal"]`)]
.map((e) => [e.dataset.k, getComputedStyle(e).display + '/' + Math.round(e.getBoundingClientRect().width)])), fw);
if (fw === 'none') none = chrome;
else {
const tally = {}; let moved = 0; const rectMoves = [];
for (let i = 0; i < Math.min(none.length, chrome.length); i++) {
const dk = Object.keys(chrome[i]).filter((k) => !['tag', 'cls'].includes(k) && JSON.stringify(chrome[i][k]) !== JSON.stringify(none[i][k]));
if (!dk.length) continue;
moved++; dk.forEach((k) => { tally[k] = (tally[k] || 0) + 1; });
if (dk.includes('rect') && chrome[i].cls !== 'fs-content' && rectMoves.length < 12) rectMoves.push({ tag: chrome[i].tag, cls: chrome[i].cls, none: none[i].rect, app: chrome[i].rect });
}
page.moved = moved; page.of = none.length; page.tally = tally; page.rectMoves = rectMoves;
}
// winners on the nodes the chrome inherits from
page.nodes = {};
const { root } = await cdp.send('DOM.getDocument', { depth: 0 });
for (const sel of NODES) {
const { nodeId } = await cdp.send('DOM.querySelector', { nodeId: root.nodeId, selector: sel });
if (!nodeId) continue;
const m = await cdp.send('CSS.getMatchedStylesForNode', { nodeId });
const rows = (list, via) => (list || []).filter((mr) => mr.rule.origin !== 'user-agent').flatMap((mr) => {
const d = mr.rule.style.cssProperties.filter((x) => !x.disabled && (PROPS.includes(x.name) || x.name === 'font'));
return d.length ? [{ via, sel: mr.rule.selectorList.text.replace(/\s+/g, ' ').slice(0, 110), sheet: (sheets.get(mr.rule.styleSheetId) || '?').split('?')[0],
line: mr.rule.style.range ? mr.rule.style.range.startLine + 1 : null, layers: (mr.rule.layers || []).map((l) => l.text).join('.'), decl: [...new Set(d.map((x) => `${x.name}:${x.value}${x.important ? '!' : ''}`))].join('; ') }] : [];
});
const own = rows(m.matchedCSSRules, 'self');
const inh = (m.inherited || []).flatMap((e, depth) => rows(e.matchedCSSRules, 'ancestor+' + (depth + 1)));
const comp = await p.evaluate(({ sel, PROPS }) => { const e = document.querySelector(sel); const cs = getComputedStyle(e); return Object.fromEntries(PROPS.map((q) => [q, cs.getPropertyValue(q).slice(0, 40)])); }, { sel, PROPS });
page.nodes[sel] = { computed: comp, self: own, inheritedChain: inh.slice(0, 12) };
}
report.pages[fw] = page;
await p.close();
}
await b.close();
const file = OUT + 'chrome-' + FWS.join('_') + '.json';
writeFileSync(file, JSON.stringify(report, null, 1));
for (const [fw, pg] of Object.entries(report.pages)) {
console.log(`\n== ${fw}` + (pg.moved != null ? ` moved ${pg.moved}/${pg.of} tally ${JSON.stringify(pg.tally)}` : ''));
if (pg.rectMoves && pg.rectMoves.length) console.log(' rect moves e.g.', JSON.stringify(pg.rectMoves.slice(0, 5)));
for (const sel of ['html', 'body', '.fs-sidebar']) {
const n = pg.nodes[sel]; if (!n) continue;
console.log(` ${sel} ${JSON.stringify(n.computed)}`);
for (const r of n.self) console.log(` self ${r.sheet}:${r.line} [${r.layers}] ${r.sel.slice(0, 70)} { ${r.decl.slice(0, 90)} }`);
}
}
console.log('->', file);
@@ -0,0 +1,338 @@
/* Issue #51 follow-up: which third-party CSS frameworks render differently inside a LuCI app view
* under footstrap vs stock bootstrap, and which footstrap rule wins each difference.
*
* Needs the stub views from ../tmp/task-0051-splify2-hidden/fw/gen-views.mjs on the stand
* (admin/services/fwprobe-{none,tw3,tw4,daisy,bs}). Per framework page at 1440x900, once under
* bootstrap (theme switched with uci, footstrap restored on exit/signal) and once under footstrap:
* whether the app sheet was re-hosted, its layer names (CSSOM), computed props of every [data-k]
* fixture element, CDP matched rules for each. Diff, name the winner on both sides, and on the
* footstrap side PROVE the footstrap winner by removing its declaration in the CSSOM and watching
* the computed value move. Chrome snapshot of each framework page vs the no-app page.
*
* node .claude/tooling/fwprobe.mjs owrt2512 # FWS=tw4,bs to narrow
*/
import { chromium } from 'playwright';
import { execFileSync } from 'node:child_process';
import { writeFileSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { PORTS } from './lib.mjs';
const stand = process.argv[2] || 'owrt2512';
const BASE = 'http://localhost:' + PORTS[stand];
const OUT = new URL('../../../../../../tmp/task-0051-splify2-hidden/fwprobe/', import.meta.url).pathname;
const STYLES = new URL('../../luci-theme-footstrap/styles/', import.meta.url).pathname;
mkdirSync(OUT, { recursive: true });
const FWS = (process.env.FWS || 'tw3,tw4,daisy,bs').split(',');
const PAGES = ['none', ...FWS];
const W = 1440, H = 900;
const LAYOUT = ['display', 'position', 'width', 'height', 'padding-top', 'padding-right', 'padding-bottom',
'padding-left', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left'];
const COSMETIC = ['font-size', 'font-weight', 'line-height', 'color', 'background-color', 'border-top-width',
'border-top-style', 'border-top-color', 'border-bottom-width', 'border-top-left-radius', 'visibility'];
const PROPS = [...LAYOUT, ...COSMETIC];
const INHERITED = new Set(['font-size', 'font-weight', 'line-height', 'color', 'visibility']);
const SHORT = { padding: 'padding-', margin: 'margin-', border: 'border-', 'border-top': 'border-top-',
'border-bottom': 'border-bottom-', 'border-width': 'border-', 'border-style': 'border-', 'border-color': 'border-',
'border-radius': 'border-', background: 'background-', font: 'font-', inset: '', 'padding-block': 'padding-',
'padding-inline': 'padding-', 'margin-block': 'margin-', 'margin-inline': 'margin-', 'inline-size': 'width', 'block-size': 'height' };
const touches = (name, prop) => name === prop || (SHORT[name] !== undefined && (SHORT[name] === '' || prop.startsWith(SHORT[name]) || SHORT[name] === prop)) ||
(name === 'padding-inline-start' && prop === 'padding-left') || (name === 'padding-inline-end' && prop === 'padding-right') ||
(name === 'margin-inline-start' && prop === 'margin-left') || (name === 'margin-inline-end' && prop === 'margin-right') ||
(name === 'padding-block-start' && prop === 'padding-top') || (name === 'padding-block-end' && prop === 'padding-bottom') ||
(name === 'margin-block-start' && prop === 'margin-top') || (name === 'margin-block-end' && prop === 'margin-bottom');
/* ---- theme switch with a trap: footstrap is restored however this process ends ---- */
const owlab = (cmd) => execFileSync('owlab', ['exec', stand, '--', cmd], { encoding: 'utf8',
env: { ...process.env, PATH: process.env.HOME + '/go/bin:' + process.env.PATH } });
let switched = false;
const setTheme = (t) => { owlab(`uci set luci.main.mediaurlbase=/luci-static/${t} && uci commit luci`); switched = t !== 'footstrap'; };
const restore = () => { if (switched) { try { setTheme('footstrap'); console.error('trap: footstrap restored'); } catch (e) { console.error('TRAP FAILED', e.message); } } };
process.on('exit', restore);
for (const s of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(s, () => { restore(); process.exit(130); });
process.on('uncaughtException', (e) => { console.error(e); restore(); process.exit(1); });
/* ---- in-page measurement ---- */
const MEASURE = ({ fw, PROPS, side }) => {
const out = { fw, side };
const root = document.getElementById('fwprobe-' + fw);
const href = (h) => (h || '').replace(location.origin, '');
out.links = [...document.querySelectorAll('link[rel~=stylesheet]')].filter((l) => /fwprobe/.test(l.href))
.map((l) => ({ href: href(l.href), disabled: l.disabled, layered: l.dataset.fsLayered || null, sheetOn: !!l.sheet }));
out.shims = [...document.querySelectorAll('style[data-fs-layered]')].map((s) => (s.textContent || '').slice(0, 140));
// layer names declared by the app's sheet, fully qualified as the cascade sees them
const layers = new Set(); let appRules = 0, fenced = 0;
const walk = (rules, prefix) => {
for (const r of rules) {
if (r instanceof CSSImportRule) { const p = r.layerName != null ? (prefix ? prefix + '.' : '') + r.layerName : prefix; if (r.layerName != null) layers.add(p); try { walk(r.styleSheet.cssRules, p); } catch (e) {} continue; }
if (typeof CSSLayerStatementRule !== 'undefined' && r instanceof CSSLayerStatementRule) { r.nameList.forEach((n) => layers.add((prefix ? prefix + '.' : '') + n)); continue; }
if (typeof CSSLayerBlockRule !== 'undefined' && r instanceof CSSLayerBlockRule) { const p = (prefix ? prefix + '.' : '') + (r.name || '<anon>'); layers.add(p); walk(r.cssRules, p); continue; }
if (r.selectorText) { appRules++; if (/:where\(:not\(\[data-fs-chrome/.test(r.selectorText)) fenced++; }
if (r.cssRules) walk(r.cssRules, prefix);
}
};
for (const s of document.styleSheets) {
const own = s.ownerNode;
const isApp = /fwprobe/.test(s.href || '') || (own && own.dataset && own.dataset.fsLayered && /fwprobe/.test(own.textContent || ''));
if (!isApp || (own && own.disabled) || s.disabled) continue;
try { walk(s.cssRules, ''); } catch (e) {}
}
out.layers = [...layers]; out.appRules = appRules; out.fencedRules = fenced;
const snap = (el) => { const cs = getComputedStyle(el); const b = el.getBoundingClientRect(); const o = { rect: [b.x, b.y, b.width, b.height].map((v) => Math.round(v)) }; for (const p of PROPS) o[p] = cs.getPropertyValue(p); return o; };
out.els = root ? [...root.querySelectorAll('[data-k]')].map((e) => ({ k: e.dataset.k, tag: e.tagName.toLowerCase(), cls: e.className, ...snap(e) })) : null;
// chrome: every fenced chrome element (footstrap), or stock bootstrap's header/menu
const chromeSel = document.querySelector('[data-fs-chrome]') ? '[data-fs-chrome], [data-fs-chrome] *, .fs-content, #maincontent' : 'header, header *, #mainmenu, #mainmenu *, #maincontent, .main';
out.chrome = [...document.querySelectorAll(chromeSel)].map((e) => { const cs = getComputedStyle(e); const b = e.getBoundingClientRect();
return { tag: e.tagName.toLowerCase(), cls: (typeof e.className === 'string' ? e.className : '').slice(0, 60), rect: [b.x, b.y, b.width, b.height].map((v) => Math.round(v)),
display: cs.display, padding: cs.padding, margin: cs.margin, fontSize: cs.fontSize, lineHeight: cs.lineHeight, boxSizing: cs.boxSizing, border: cs.borderTopWidth + ' ' + cs.borderTopStyle }; });
out.docOverflow = document.documentElement.scrollWidth - document.documentElement.clientWidth;
out.bodyFont = getComputedStyle(document.body).fontFamily.slice(0, 60) + ' / ' + getComputedStyle(document.body).fontSize + ' / ' + getComputedStyle(document.body).color;
return out;
};
async function matched(cdp, fw, sheets) {
const { root } = await cdp.send('DOM.getDocument', { depth: 0 });
const { nodeIds } = await cdp.send('DOM.querySelectorAll', { nodeId: root.nodeId, selector: `#fwprobe-${fw} [data-k]` });
const res = [];
for (const nodeId of nodeIds) {
const m = await cdp.send('CSS.getMatchedStylesForNode', { nodeId });
const rules = [];
for (const mr of m.matchedCSSRules || []) {
const r = mr.rule;
if (r.origin === 'user-agent') continue;
const decls = r.style.cssProperties.filter((p) => !p.disabled && p.parsedOk !== false && PROPS.some((q) => touches(p.name, q)))
.map((p) => ({ n: p.name, v: p.value, imp: !!p.important, impl: !!p.implicit }));
if (!decls.length) continue;
const sel = mr.matchingSelectors.map((i) => r.selectorList.selectors[i]).filter(Boolean);
const spec = sel.map((s) => s.specificity ? [s.specificity.a, s.specificity.b, s.specificity.c] : null).filter(Boolean)
.sort((x, y) => (y[0] - x[0]) || (y[1] - x[1]) || (y[2] - x[2]))[0] || null;
rules.push({ sel: r.selectorList.text.replace(/\s+/g, ' '), matchSel: sel.map((s) => s.text).join(', ').slice(0, 160), spec,
sheet: (sheets.get(r.styleSheetId) || '?').replace(BASE, ''), line: r.style.range ? r.style.range.startLine + 1 : null,
layers: (r.layers || []).map((l) => l.text), media: (r.media || []).map((x) => x.text).join(' | '), decls });
}
const inline = m.inlineStyle && m.inlineStyle.cssProperties.filter((p) => !p.disabled && PROPS.some((q) => touches(p.name, q))).map((p) => ({ n: p.name, v: p.value, imp: !!p.important }));
res.push({ rules, inline: inline && inline.length ? inline : null });
}
return res;
}
/* the cascade's winner for one prop, from CDP's ascending order: last normal declaration, unless an
* important one exists (then the important one; several are flagged for the CSSOM proof to settle) */
function winner(mm, prop) {
if (!mm) return null;
const hits = [];
mm.rules.forEach((r, i) => r.decls.filter((d) => touches(d.n, prop)).forEach((d) => hits.push({ r, d, i })));
if (mm.inline) mm.inline.filter((d) => touches(d.n, prop)).forEach((d) => hits.push({ r: { sel: 'style=""', sheet: 'inline', layers: [] }, d, i: 1e6 }));
if (!hits.length) return null;
const imp = hits.filter((h) => h.d.imp);
const pick = (imp.length ? imp : hits)[(imp.length ? imp : hits).length - 1];
return { ...pick, multiImportant: imp.length > 1 };
}
function mech(w, app) {
if (w.d.imp) return '!important';
if (!app) return 'uncontested (app declares nothing)';
if (app.layers.length > w.r.layers.length) return 'nested sublayer (' + app.layers.join('.') + ' < ' + (w.r.layers.join('.') || 'unlayered') + ')';
if (w.r.layers.length === 0 && app.layers.length) return 'layer order (theme unlayered > app layer ' + app.layers.join('.') + ')';
if (app.decls && app.decls.some((x) => x.imp)) return 'app !important lost?';
return 'specificity ' + JSON.stringify(w.r.spec) + ' vs app ' + JSON.stringify(app.spec);
}
const own = (sheet) => /\/luci-static\/(footstrap|bootstrap)\//.test(sheet) ? 'theme' : /fwprobe/.test(sheet) ? 'app' : sheet === 'inline' ? 'app' : 'other';
/* remove the named rule's declaration(s) for prop from cascade.css in the CSSOM, read, restore */
const PROVE = ({ fw, k, prop, sel, names }) => {
const norm = (s) => s.replace(/\s+/g, '').replace(/["']/g, '');
const el = document.querySelector(`#fwprobe-${fw} [data-k="${k}"]`);
const before = getComputedStyle(el).getPropertyValue(prop);
const touched = [];
const walk = (rules) => { for (const r of rules) {
if (r.selectorText && norm(r.selectorText) === norm(sel)) {
const st = r.style; const saved = [];
// the longhand itself, plus whatever names CDP reported for it (logical props are separate in the CSSOM)
for (const n of names) if (st.getPropertyValue(n) !== '' && !saved.some((s) => s[0] === n)) saved.push([n, st.getPropertyValue(n), st.getPropertyPriority(n)]);
saved.forEach(([n]) => st.removeProperty(n));
if (saved.length) touched.push({ r, saved });
}
if (r.cssRules) walk(r.cssRules);
} };
for (const s of document.styleSheets) if (/cascade\.css/.test(s.href || '')) { try { walk(s.cssRules); } catch (e) {} }
const after = getComputedStyle(el).getPropertyValue(prop);
touched.forEach(({ r, saved }) => saved.forEach(([n, v, p]) => r.style.setProperty(n, v, p)));
return { before, after, rules: touched.length, moved: before !== after };
};
async function visit(ctx, side, fw) {
const p = await ctx.newPage();
const errs = []; p.on('pageerror', (e) => errs.push(String(e).slice(0, 160)));
p.on('console', (m) => { if (m.type() === 'error') errs.push('console: ' + m.text().slice(0, 160)); });
const cdp = await ctx.newCDPSession(p);
const sheets = new Map();
cdp.on('CSS.styleSheetAdded', (e) => sheets.set(e.header.styleSheetId, e.header.sourceURL || (e.header.isInline ? 'inline-style' : e.header.origin)));
await p.goto(`${BASE}/cgi-bin/luci/admin/services/fwprobe-${fw}`, { waitUntil: 'domcontentloaded' });
const rendered = await p.waitForFunction((fw) => { const r = document.getElementById('fwprobe-' + fw); return !!(r && r.querySelectorAll('[data-k]').length > 10); }, fw, { timeout: 30000 }).then(() => true, () => false);
let settled = true;
if (fw !== 'none') settled = await p.waitForFunction((fw) => {
const l = [...document.querySelectorAll('link[rel~=stylesheet]')].find((x) => x.href.includes('fwprobe/' + fw + '.css'));
if (!l) return false;
if (l.dataset.fsLayered) { const s = [...document.querySelectorAll('style[data-fs-layered]')].find((x) => x.textContent.includes('fwprobe/' + fw)); try { return !!(s && s.sheet.cssRules[0].styleSheet.cssRules.length); } catch (e) { return false; } }
try { return !!(l.sheet && l.sheet.cssRules.length); } catch (e) { return false; }
}, fw, { timeout: 20000 }).then(() => true, () => false);
await p.waitForTimeout(2000); /* fence rAF retries + any late re-host; see third-party-apps.md "the gap is time" */
await cdp.send('DOM.enable'); await cdp.send('CSS.enable');
await p.waitForTimeout(300);
const m = await p.evaluate(MEASURE, { fw, PROPS, side });
m.rendered = rendered; m.settled = settled; m.errs = errs;
if (fw !== 'none') m.matched = await matched(cdp, fw, sheets);
if (fw !== 'none') await p.screenshot({ path: `${OUT}${side}-${fw}.png`, fullPage: true });
return { p, m };
}
async function loginCtx(b) {
const ctx = await b.newContext({ viewport: { width: W, height: H } });
const p = await ctx.newPage();
await p.goto(BASE + '/cgi-bin/luci/', { waitUntil: 'domcontentloaded' });
if (await p.locator('input[name="luci_password"]').count()) {
await p.fill('input[name="luci_username"]', 'root');
await p.fill('input[name="luci_password"]', '');
await Promise.all([p.waitForNavigation({ waitUntil: 'domcontentloaded' }), p.press('input[name="luci_password"]', 'Enter')]);
}
await p.waitForTimeout(1500); await p.close();
return ctx;
}
/* ---- source mapping: cascade.css selector -> styles/**:line ---- */
const styleFiles = (() => { const out = []; const rec = (d) => { for (const f of readdirSync(d)) { const q = d + f; if (statSync(q).isDirectory()) rec(q + '/'); else if (f.endsWith('.css')) out.push(q); } }; rec(STYLES); return out; })();
const srcCache = new Map();
/* The layer CDP reports picks the directory (tokens -> styles/*.css, base, theme, page -> pages), so
* a selector repeated across layers maps to the copy that actually won. A selector list is found by
* its first part at a line start, followed by `,` or `{`. */
function mapSource(sel, layer, decl) {
const key = sel + '|' + layer + '|' + decl;
if (srcCache.has(key)) return srcCache.get(key);
const dir = { tokens: null, base: 'base/', theme: 'theme/', page: 'pages/' }[layer];
const files = styleFiles.filter((f) => { const rel = f.slice(STYLES.length); return dir ? rel.startsWith(dir) : !rel.includes('/'); });
const parts = sel.split(/,(?![^()]*\))/).map((s) => s.trim().replace(/\s+/g, ' ').replace(/"/g, "'"));
const prop = decl ? decl.split(':')[0] : null;
let hit = null, fallback = null;
for (const f of files) {
const lines = readFileSync(f, 'utf8').split('\n');
for (let i = 0; i < lines.length; i++) {
const L = lines[i].trim().replace(/\s+/g, ' ').replace(/"/g, "'");
if (L.startsWith('/*') || L.startsWith('*')) continue;
if (!(L.startsWith(parts[0] + ' {') || L === parts[0] + ',' || L.startsWith(parts[0] + ', ') || L === parts[0] + '{')) continue;
const at = f.replace(STYLES, 'styles/') + ':' + (i + 1);
if (!fallback) fallback = at;
// prefer the copy whose block (up to the closing brace) declares the property
const block = lines.slice(i, i + 40).join('\n'); const end = block.indexOf('}');
if (!prop || block.slice(0, end < 0 ? undefined : end).includes(prop)) { hit = at; break; }
}
if (hit) break;
}
srcCache.set(key, hit || fallback);
return hit || fallback;
}
/* ---- run ---- */
const report = { stand, when: new Date().toISOString(), viewport: [W, H], fws: FWS, pages: {} };
const b = await chromium.launch();
try {
const cur = owlab('uci get luci.main.mediaurlbase').trim();
report.startTheme = cur;
// bootstrap side first, then footstrap (which is also the restore)
setTheme('bootstrap');
const bctx = await loginCtx(b);
const bs = {};
for (const fw of PAGES) { const { p, m } = await visit(bctx, 'bootstrap', fw); bs[fw] = m; await p.close(); console.log('bootstrap', fw, 'rendered', m.rendered, 'settled', m.settled, 'errs', m.errs.length); }
await bctx.close();
setTheme('footstrap');
report.themeAfterRestore = owlab('uci get luci.main.mediaurlbase').trim();
const fctx = await loginCtx(b);
const noneChrome = {};
for (const fw of PAGES) {
const { p, m } = await visit(fctx, 'footstrap', fw);
console.log('footstrap', fw, 'rendered', m.rendered, 'settled', m.settled, 'errs', m.errs.length);
if (fw === 'none') { noneChrome.footstrap = m.chrome; noneChrome.bootstrap = bs.none.chrome; await p.close(); continue; }
const B = bs[fw];
const page = { versions: null, footstrap: { links: m.links, shims: m.shims, layers: m.layers, appRules: m.appRules, fencedRules: m.fencedRules, errs: m.errs, settled: m.settled, docOverflow: m.docOverflow, bodyFont: m.bodyFont },
bootstrap: { links: B.links, layers: B.layers, errs: B.errs, settled: B.settled, docOverflow: B.docOverflow, bodyFont: B.bodyFont }, diffs: [], chrome: {} };
for (let i = 0; i < m.els.length; i++) {
const F = m.els[i], S = B.els[i];
if (!S || S.k !== F.k) { page.diffs.push({ k: F.k, error: 'element order mismatch' }); continue; }
for (const prop of PROPS) {
if (F[prop] === S[prop]) continue;
const wf = winner(m.matched[i], prop), wb = winner(B.matched[i], prop);
const d = { k: F.k, tag: F.tag, cls: String(F.cls).slice(0, 80), prop, fs: F[prop], bs: S[prop], kind: LAYOUT.includes(prop) ? 'layout' : 'cosmetic' };
d.fsWin = wf ? { sel: wf.r.sel, sheet: wf.r.sheet, line: wf.r.line, layers: wf.r.layers, spec: wf.r.spec, decl: `${wf.d.n}:${wf.d.v}${wf.d.imp ? '!' : ''}`, owner: own(wf.r.sheet), multiImp: wf.multiImportant } : { owner: INHERITED.has(prop) ? 'inherited' : 'initial/UA' };
d.bsWin = wb ? { sel: wb.r.sel, sheet: wb.r.sheet, line: wb.r.line, layers: wb.r.layers, spec: wb.r.spec, decl: `${wb.d.n}:${wb.d.v}${wb.d.imp ? '!' : ''}`, owner: own(wb.r.sheet) } : { owner: INHERITED.has(prop) ? 'inherited' : 'initial/UA' };
// the app's own strongest candidate under footstrap, for the mechanism
const appHits = (m.matched[i].rules || []).filter((r) => own(r.sheet) === 'app' && r.decls.some((x) => touches(x.n, prop)));
const app = appHits[appHits.length - 1];
d.appRule = app ? { sel: app.sel.slice(0, 120), layers: app.layers, spec: app.spec, decl: app.decls.filter((x) => touches(x.n, prop)).map((x) => `${x.n}:${x.v}${x.imp ? '!' : ''}`).join(';') } : null;
if (d.fsWin.owner === 'theme') {
d.src = mapSource(d.fsWin.sel, d.fsWin.layers[0], d.fsWin.decl);
d.mechanism = mech(wf, app);
const names = [prop, ...wf.r.decls.filter((x) => touches(x.n, prop)).map((x) => x.n)];
d.proof = await p.evaluate(PROVE, { fw, k: F.k, prop, sel: d.fsWin.sel, names });
}
d.cause = d.fsWin.owner === 'theme' ? 'footstrap' : d.bsWin.owner === 'theme' ? 'bootstrap-theme'
: (d.fsWin.owner === 'inherited' || d.bsWin.owner === 'inherited') ? 'inherited'
: 'consequential (same owner both sides: container width / content / UA)';
page.diffs.push(d);
}
}
/* The H1 measure proper, independent of the bootstrap diff (stock bootstrap's unlayered sheet
* beats a layered app too, so equal values can hide a loss on both sides): every app
* declaration whose property is won by a THEME rule. Footstrap side proven in the CSSOM. */
page.overrides = { footstrap: [], bootstrap: [] };
for (let i = 0; i < m.els.length; i++) {
for (const prop of PROPS) {
for (const [side, mm] of [['footstrap', m.matched[i]], ['bootstrap', B.matched[i]]]) {
if (!mm) continue;
const appHits = mm.rules.filter((r) => own(r.sheet) === 'app' && r.decls.some((x) => touches(x.n, prop)));
const inlineApp = mm.inline && mm.inline.some((x) => touches(x.n, prop));
if (!appHits.length && !inlineApp) continue;
const w = winner(mm, prop);
if (!w || own(w.r.sheet) !== 'theme') continue;
const app = appHits[appHits.length - 1] || { layers: [], spec: [1, 0, 0, 0], sel: 'style=""', decls: [] };
const o = { k: m.els[i].k, prop, kind: LAYOUT.includes(prop) ? 'layout' : 'cosmetic', sel: w.r.sel, layers: w.r.layers, spec: w.r.spec,
decl: `${w.d.n}:${w.d.v}${w.d.imp ? '!' : ''}`, app: { sel: app.sel.slice(0, 100), layers: app.layers, spec: app.spec }, mechanism: mech(w, app) };
if (side === 'footstrap') {
o.src = mapSource(w.r.sel, w.r.layers[0], o.decl);
const names = [prop, ...w.r.decls.filter((x) => touches(x.n, prop)).map((x) => x.n)];
o.proof = await p.evaluate(PROVE, { fw, k: o.k, prop, sel: w.r.sel, names });
o.value = m.els[i][prop];
} else o.value = B.els[i][prop];
page.overrides[side].push(o);
}
}
}
// chrome vs the no-app page, both themes
const cmp = (a, z) => { const moved = []; const n = Math.min(a.length, z.length); for (let i = 0; i < n; i++) { const x = a[i], y = z[i]; const keys = ['rect', 'display', 'padding', 'margin', 'fontSize', 'lineHeight', 'boxSizing', 'border']; const dk = keys.filter((kk) => JSON.stringify(x[kk]) !== JSON.stringify(y[kk])); if (dk.length) moved.push({ i, tag: x.tag, cls: x.cls, dk, none: dk.map((kk) => x[kk]), app: dk.map((kk) => y[kk]) }); } return { countNone: a.length, countApp: z.length, moved: moved.length, first: moved.slice(0, 8) }; };
page.chrome.footstrap = cmp(noneChrome.footstrap, m.chrome);
page.chrome.bootstrap = cmp(noneChrome.bootstrap, B.chrome);
report.pages[fw] = page;
await p.close();
}
await fctx.close();
} finally {
await b.close();
restore();
}
const REPORT = OUT + `report-${FWS.join('_')}.json`;
writeFileSync(REPORT, JSON.stringify(report, null, 1));
for (const [fw, pg] of Object.entries(report.pages)) {
const ds = pg.diffs.filter((d) => !d.error);
const by = (f) => ds.filter(f).length;
console.log(`\n== ${fw}: rehosted=${pg.footstrap.links.map((l) => l.layered).join(',')} layers=[${pg.footstrap.layers.join(' ')}] rules=${pg.footstrap.appRules} fenced=${pg.footstrap.fencedRules} errs fs/bs=${pg.footstrap.errs.length}/${pg.bootstrap.errs.length}`);
console.log(` diffs=${ds.length} footstrap-caused layout=${by((d) => d.cause === 'footstrap' && d.kind === 'layout')} cosmetic=${by((d) => d.cause === 'footstrap' && d.kind === 'cosmetic')} | bootstrap-theme-caused ${by((d) => d.cause === 'bootstrap-theme')} | inherited ${by((d) => d.cause === 'inherited')} | other ${by((d) => d.cause === 'other')}`);
console.log(` chrome moved: footstrap ${pg.chrome.footstrap.moved}/${pg.chrome.footstrap.countNone} (${pg.chrome.footstrap.countApp}), bootstrap ${pg.chrome.bootstrap.moved}/${pg.chrome.bootstrap.countNone}`);
const ov = pg.overrides.footstrap, eff = ov.filter((o) => o.proof && o.proof.moved);
const bov = pg.overrides.bootstrap;
console.log(` app decls won by a theme rule: footstrap ${ov.length} (CSSOM-proven effective ${eff.length}: layout ${eff.filter((o) => o.kind === 'layout').length}, cosmetic ${eff.filter((o) => o.kind === 'cosmetic').length}) | stock bootstrap ${bov.length} (layout ${bov.filter((o) => o.kind === 'layout').length})`);
const agg = new Map();
for (const o of ov) {
const key = `${o.src || '?'} \`${o.sel.slice(0, 70)}\` | ${o.mechanism.replace(/\[.*?\]/g, (s) => s)}`;
const a = agg.get(key) || { n: 0, eff: 0, props: new Set(), els: new Set() }; a.n++; if (o.proof && o.proof.moved) a.eff++; a.props.add(o.prop); a.els.add(o.k); agg.set(key, a);
}
for (const [key, a] of [...agg].sort((x, y) => y[1].eff - x[1].eff).slice(0, 14)) console.log(` ${a.eff}/${a.n} ${key} :: ${[...a.props].join(',')} @ ${[...a.els].join(',')}`);
}
console.log('->', REPORT);
@@ -0,0 +1,37 @@
/* Is the heap growth a leak or just the module cache filling? Measure ALWAYS ON THE SAME PAGE, after
* a forced GC, so page size cannot colour the reading. A cache that fills plateaus; a leak does not. */
import { chromium } from 'playwright';
import { login, PORTS } from './lib.mjs';
const BASE = `http://localhost:${PORTS[process.argv[2] || 'owrt2512']}`;
const HOPS = Number(process.argv[3] || 560);
const RING = ['admin/network/firewall/zones', 'admin/network/firewall/forwards',
'admin/network/firewall/rules', 'admin/network/dhcp', 'admin/network/wireless',
'admin/status/processes', 'admin/system/system'];
const b = await chromium.launch({ args: ['--enable-precise-memory-info'] });
const p = await b.newPage({ viewport: { width: 1400, height: 900 } });
const cdp = await p.context().newCDPSession(p);
await cdp.send('HeapProfiler.enable');
await login(p, BASE);
await p.evaluate(() => document.querySelectorAll('#topmenu > li.has-sub:not(.open) > a').forEach((a) => a.click()));
await p.waitForTimeout(600);
const rows = [];
for (let i = 0; i <= HOPS; i++) {
const href = '/cgi-bin/luci/' + RING[i % RING.length];
await p.evaluate((h) => document.querySelector(`a[href="${h}"]`)?.click(), href);
await p.waitForTimeout(850);
/* measure only at the top of the ring: same page, same content, every time */
if (i % (RING.length * 10) === 0) {
await cdp.send('HeapProfiler.collectGarbage'); await p.waitForTimeout(400);
await cdp.send('HeapProfiler.collectGarbage'); await p.waitForTimeout(400);
rows.push({ hop: i, page: RING[i % RING.length].split('/').pop(), ...(await p.evaluate(() => ({
heapMB: +(performance.memory.usedJSHeapSize / 1048576).toFixed(2),
nodes: document.getElementsByTagName('*').length,
history: history.length })))});
process.stderr.write(String(i) + ' ');
}
}
process.stderr.write('\n');
console.log(JSON.stringify(rows, null, 1));
await b.close();
@@ -0,0 +1,46 @@
/* Reproduce the carded -> table resize break, then try candidate fixes IN the live page and measure
* each. Testing the hypothesis before writing any CSS. */
import { chromium } from 'playwright';
import { login, PORTS } from './lib.mjs';
const BASE = `http://localhost:${PORTS[process.argv[2] || 'owrt2512']}`;
const PATH = process.argv[3] || 'admin/network/firewall/zones';
const M = `(() => { const t = document.querySelector('.table.cbi-section-table');
const c = document.querySelector('.fs-content'); const main = document.getElementById('maincontent');
return { tableW: Math.round(t.getBoundingClientRect().width), contentW: Math.round(c.getBoundingClientRect().width),
mainOverflow: main.scrollWidth - main.clientWidth,
cells: [...t.querySelectorAll('.tr:not(.cbi-section-table-titles) > .td')].slice(0,6).map((d)=>Math.round(d.getBoundingClientRect().width)) }; })()`;
const b = await chromium.launch();
const p = await b.newPage({ viewport: { width: 1000, height: 900 } });
await login(p, BASE);
const settle = async () => { await p.waitForTimeout(400);
await p.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))); await p.waitForTimeout(500); };
const CASES = {
'baseline (broken)': () => {},
'max-width:100%': () => { document.querySelectorAll('.table.cbi-section-table').forEach((t) => t.style.maxWidth = '100%'); },
'width:100%': () => { document.querySelectorAll('.table.cbi-section-table').forEach((t) => t.style.width = '100%'); },
'table-layout:fixed':() => { document.querySelectorAll('.table.cbi-section-table').forEach((t) => { t.style.tableLayout = 'fixed'; t.style.width = '100%'; }); },
'reflow nudge': () => { document.querySelectorAll('.table.cbi-section-table').forEach((t) => { const d = t.style.display; t.style.display = 'none'; void t.offsetWidth; t.style.display = d || ''; }); },
};
const out = [];
for (const [name, fn] of Object.entries(CASES)) {
await p.setViewportSize({ width: 1000, height: 900 });
await p.goto(BASE + '/cgi-bin/luci/' + PATH, { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(3000);
await p.setViewportSize({ width: 1280, height: 900 });
await settle();
await p.evaluate(fn);
await settle();
out.push({ name, ...(await p.evaluate(M)) });
}
/* what a fresh 1280 load looks like, for comparison */
await p.setViewportSize({ width: 1280, height: 900 });
await p.goto(BASE + '/cgi-bin/luci/' + PATH, { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(3000);
out.push({ name: 'FRESH @1280 (target)', ...(await p.evaluate(M)) });
console.log(JSON.stringify(out, null, 1));
await b.close();
@@ -0,0 +1,54 @@
/* Candidate fixes for the pinned actions column, tried in the live broken page. The pin is an INLINE
* style written by luci-base's form.js (stabilizeActionColumnWidth), so only !important or removing
* the attribute can beat it. */
import { chromium } from 'playwright';
import { login, PORTS } from './lib.mjs';
const BASE = `http://localhost:${PORTS[process.argv[2] || 'owrt2512']}`;
const PATH = 'admin/network/firewall/zones';
const M = `(() => { const t = document.querySelector('.table.cbi-section-table');
const c = document.querySelector('.fs-content'); const main = document.getElementById('maincontent');
const act = t.querySelector('.tr:not(.cbi-section-table-titles) > .td.cbi-section-actions');
const btns = [...act.querySelectorAll('button')].map((x)=>Math.round(x.getBoundingClientRect().width));
return { tableW: Math.round(t.getBoundingClientRect().width), contentW: Math.round(c.getBoundingClientRect().width),
mainOverflow: main.scrollWidth - main.clientWidth, actW: Math.round(act.getBoundingClientRect().width), btns }; })()`;
const CSS = {
'baseline (broken)': '',
'td+th min-width:0 !imp': `.table.cbi-section-table .td.cbi-section-actions,
.table.cbi-section-table .th.cbi-section-actions { min-width: 0 !important; width: 15% !important; }`,
'td only, width:auto': `.table.cbi-section-table .td.cbi-section-actions { min-width: 0 !important; width: auto !important; }`,
'inner div max-content': `.table.cbi-section-table .td.cbi-section-actions > div { width: max-content !important; }`,
};
/* and the JS alternative: wipe the pin and let upstream re-measure from a clean DOM */
const WIPE = () => {
document.querySelectorAll('.table.cbi-section-table').forEach((t) => {
delete t.dataset.actionColWidth;
t.querySelectorAll('.cbi-section-actions').forEach((el) => { el.style.minWidth = ''; el.style.width = ''; });
});
window.dispatchEvent(new Event('resize'));
};
const b = await chromium.launch();
const p = await b.newPage({ viewport: { width: 1000, height: 900 } });
await login(p, BASE);
const settle = async () => { await p.waitForTimeout(400); await p.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))); await p.waitForTimeout(600); };
const broken = async () => { await p.setViewportSize({ width: 1000, height: 900 });
await p.goto(BASE + '/cgi-bin/luci/' + PATH, { waitUntil: 'domcontentloaded' }); await p.waitForTimeout(3000);
await p.setViewportSize({ width: 1280, height: 900 }); await settle(); };
const out = [];
for (const [name, css] of Object.entries(CSS)) {
await broken();
if (css) await p.addStyleTag({ content: css });
await settle();
out.push({ name, ...(await p.evaluate(M)) });
}
await broken(); await p.evaluate(WIPE); await settle();
out.push({ name: 'JS: wipe pin + resize', ...(await p.evaluate(M)) });
/* the target */
await p.setViewportSize({ width: 1280, height: 900 });
await p.goto(BASE + '/cgi-bin/luci/' + PATH, { waitUntil: 'domcontentloaded' }); await p.waitForTimeout(3000);
out.push({ name: 'FRESH @1280 (target)', ...(await p.evaluate(M)) });
console.log(JSON.stringify(out, null, 1));
await b.close();
@@ -0,0 +1,58 @@
/* Narrow the fix down. The pin luci-base writes lands on the header cell too, and in a table the
* COLUMN is sized by every cell in it — so an override that misses the <th> changes nothing. Each
* candidate is checked in BOTH modes: the resized table (must stop overflowing) and a card layout
* (must be left exactly as designed). */
import { chromium } from 'playwright';
import { login, PORTS } from './lib.mjs';
const BASE = `http://localhost:${PORTS[process.argv[2] || 'owrt2512']}`;
const PATH = 'admin/network/firewall/zones';
const M = `(() => { const t = document.querySelector('.table.cbi-section-table');
const c = document.querySelector('.fs-content'); const main = document.getElementById('maincontent');
const act = t.querySelector('.tr:not(.cbi-section-table-titles) > .td.cbi-section-actions');
const cs = act ? getComputedStyle(act) : null;
return { tableW: Math.round(t.getBoundingClientRect().width), contentW: Math.round(c.getBoundingClientRect().width),
mainOverflow: main.scrollWidth - main.clientWidth,
actW: act ? Math.round(act.getBoundingClientRect().width) : null,
actDisplay: cs ? cs.display : null,
btns: act ? [...act.querySelectorAll('button')].map((x)=>Math.round(x.getBoundingClientRect().width)) : [] }; })()`;
const SEL = '.table.cbi-section-table .th.cbi-section-actions, .table.cbi-section-table .td.cbi-section-actions';
const CSS = {
'baseline': '',
'a) min-width:0 (td+th)': `${SEL} { min-width: 0 !important; }`,
'b) min+width:auto (td+th)': `${SEL} { min-width: 0 !important; width: auto !important; }`,
'c) min+width:15% (td+th)': `${SEL} { min-width: 0 !important; width: 15% !important; }`,
};
const b = await chromium.launch();
const p = await b.newPage({ viewport: { width: 1000, height: 900 } });
await login(p, BASE);
const settle = async () => { await p.waitForTimeout(400); await p.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))); await p.waitForTimeout(600); };
const out = [];
for (const [name, css] of Object.entries(CSS)) {
/* TABLE mode reached by the breaking path: load carded at 1000, grow to 1280 */
await p.setViewportSize({ width: 1000, height: 900 });
await p.goto(BASE + '/cgi-bin/luci/' + PATH, { waitUntil: 'domcontentloaded' }); await p.waitForTimeout(3000);
if (css) await p.addStyleTag({ content: css });
await p.setViewportSize({ width: 1280, height: 900 }); await settle();
const table = await p.evaluate(M);
/* CARD mode, freshly, with the same rule in place */
await p.setViewportSize({ width: 900, height: 900 });
await p.goto(BASE + '/cgi-bin/luci/' + PATH, { waitUntil: 'domcontentloaded' }); await p.waitForTimeout(3000);
if (css) await p.addStyleTag({ content: css });
await settle();
const card = await p.evaluate(M);
out.push({ name, table, card });
}
/* targets */
await p.setViewportSize({ width: 1280, height: 900 });
await p.goto(BASE + '/cgi-bin/luci/' + PATH, { waitUntil: 'domcontentloaded' }); await p.waitForTimeout(3000);
const tTarget = await p.evaluate(M);
await p.setViewportSize({ width: 900, height: 900 });
await p.goto(BASE + '/cgi-bin/luci/' + PATH, { waitUntil: 'domcontentloaded' }); await p.waitForTimeout(3000);
const cTarget = await p.evaluate(M);
out.push({ name: 'TARGET (fresh loads)', table: tTarget, card: cTarget });
console.log(JSON.stringify(out, null, 1));
await b.close();
@@ -0,0 +1,83 @@
/* What the chrome does in a language that is not English.
*
* Three failure modes to separate:
* - RTL (ar/he/fa): does anything set `dir`, and if not, what actually breaks?
* - long words (de) and dense scripts (ja): does the sidebar/tab/cluster fit escalation cope?
* - a language the theme ships no catalogue for: does the chrome inherit luci-base's strings?
*
* Run: node .claude/tooling/lang.mjs <router> [langs] — sets uci luci.main.lang over ssh between
* passes and puts it back at the end.
*/
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { chromium } from 'playwright';
import { login, PORTS } from './lib.mjs';
const exec = promisify(execFile);
const router = process.argv[2] || 'owrt2512';
const LANGS = (process.argv[3] || 'en,de,ru,ja,he,ar,fa').split(',');
const BASE = `http://localhost:${PORTS[router]}`;
const U = (p) => '/cgi-bin/luci/' + p;
const WIDTHS = [1500, 1100, 900, 390];
const PAGES = ['admin/status/overview', 'admin/system/system', 'admin/network/firewall/zones',
'admin/services/banip/overview'];
const owlab = async (...args) => (await exec(`${process.env.HOME}/go/bin/owlab`, args)).stdout;
const setLang = (l) => owlab('exec', router, '--', 'uci', 'set', `luci.main.lang=${l}`)
.then(() => owlab('exec', router, '--', 'uci', 'commit', 'luci'));
const SNAP = `(() => {
const de = document.documentElement, body = document.body;
const over = [];
for (const el of document.querySelectorAll('.fs-sidebar *, #tabmenu *, .fs-content *')) {
const r = el.getBoundingClientRect();
if (!r.width || r.left > 20000) continue;
if (r.right > de.clientWidth + 1 || r.left < -1)
over.push((el.tagName + '.' + (el.className || '').toString().split(' ')[0]).slice(0, 40)
+ ' ' + Math.round(r.left) + '..' + Math.round(r.right));
}
const bar = document.querySelector('.fs-sidebar');
return {
dir: de.getAttribute('dir') || getComputedStyle(de).direction,
bodyDir: getComputedStyle(body).direction,
lang: de.getAttribute('lang'),
hOverflow: de.scrollWidth - de.clientWidth,
narrow: de.hasAttribute('data-narrow'),
barClasses: bar ? bar.className : null,
dense: [...document.querySelectorAll('.tabs, .cbi-tabmenu')].map((u) =>
u.classList.contains('fs-dense2') ? 2 : u.classList.contains('fs-dense1') ? 1 : 0).join(','),
menuWrapped: (() => { const m = document.getElementById('topmenu');
if (!m || m.children.length < 2) return null;
const k = [...m.children].filter((c) => c.getClientRects().length);
return k.length ? (k[0].offsetTop !== k[k.length - 1].offsetTop) : null; })(),
firstMenu: document.querySelector('#topmenu > li > a')?.textContent.trim().slice(0, 24) || null,
appearance: document.getElementById('fs-appearance')?.textContent.trim().slice(0, 24) || null,
over: over.slice(0, 6),
overN: over.length
};
})()`;
const b = await chromium.launch();
const out = { router, langs: {} };
for (const lang of LANGS) {
await setLang(lang === 'en' ? 'auto' : lang);
const p = await b.newPage({ viewport: { width: 1500, height: 950 } });
await login(p, BASE);
out.langs[lang] = {};
for (const path of PAGES) {
out.langs[lang][path] = {};
for (const w of WIDTHS) {
await p.setViewportSize({ width: w, height: 950 });
await p.goto(BASE + U(path), { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2000);
out.langs[lang][path][w] = await p.evaluate(SNAP);
}
}
await p.close();
process.stderr.write(lang + ' ');
}
process.stderr.write('\n');
await setLang('auto');
console.log(JSON.stringify(out, null, 1));
await b.close();
@@ -0,0 +1,60 @@
/* Leak hunt with the noise removed: force GC through CDP before every reading, and count the things
* that actually leak in this codebase's history — event listeners on an enhanced <select> (teardown
* used to leave the `change` listener behind, one per CBI dependency rebuild), document-level
* listeners, live intervals, and the DOM node count. */
import { chromium } from 'playwright';
import { login, PORTS } from './lib.mjs';
const BASE = `http://localhost:${PORTS[process.argv[2] || 'owrt2512']}`;
const HOPS = Number(process.argv[3] || 240);
const b = await chromium.launch({ args: ['--enable-precise-memory-info'] });
const p = await b.newPage({ viewport: { width: 1400, height: 900 } });
const cdp = await p.context().newCDPSession(p);
await cdp.send('HeapProfiler.enable'); await cdp.send('Runtime.enable'); await cdp.send('DOM.enable');
await login(p, BASE);
await p.evaluate(() => document.querySelectorAll('#topmenu > li.has-sub:not(.open) > a').forEach((a) => a.click()));
await p.waitForTimeout(600);
async function listenersOn(selector) {
const { result } = await cdp.send('Runtime.evaluate', { expression: `document.querySelector(${JSON.stringify(selector)})` });
if (!result.objectId) return null;
const { listeners } = await cdp.send('DOMDebugger.getEventListeners', { objectId: result.objectId });
const by = {}; for (const l of listeners) by[l.type] = (by[l.type] || 0) + 1;
await cdp.send('Runtime.releaseObject', { objectId: result.objectId }).catch(() => {});
return by;
}
async function docListeners() {
const { result } = await cdp.send('Runtime.evaluate', { expression: 'document' });
const { listeners } = await cdp.send('DOMDebugger.getEventListeners', { objectId: result.objectId });
const by = {}; for (const l of listeners) by[l.type] = (by[l.type] || 0) + 1;
return by;
}
async function measure() {
await cdp.send('HeapProfiler.collectGarbage');
await p.waitForTimeout(300);
const m = await p.evaluate(() => ({
nodes: document.getElementsByTagName('*').length,
selects: document.querySelectorAll('select[data-fs-select="1"]').length,
intervals: window.__fsViewIntervals ? window.__fsViewIntervals.size : 0,
pollQueue: (window.L && L.Poll && L.Poll.queue) ? L.Poll.queue.length : 0,
heapMB: performance.memory ? +(performance.memory.usedJSHeapSize / 1048576).toFixed(1) : null
}));
return { ...m, doc: await docListeners(), sel: await listenersOn('select[data-fs-select="1"]') };
}
/* churn the firewall pages hard: their CBI dependencies rebuild option lists constantly, which is
* what drives fs-select's teardown/enhance cycle */
const RING = ['admin/network/firewall/zones', 'admin/network/firewall/forwards',
'admin/network/firewall/rules', 'admin/network/dhcp', 'admin/network/wireless',
'admin/status/processes', 'admin/system/system'];
const series = [];
for (let i = 0; i <= HOPS; i++) {
if (i % 40 === 0) series.push({ hop: i, ...(await measure()) });
const href = '/cgi-bin/luci/' + RING[i % RING.length];
await p.evaluate((h) => document.querySelector(`a[href="${h}"]`)?.click(), href);
await p.waitForTimeout(900);
if (i % 40 === 39) process.stderr.write(String(i + 1) + ' ');
}
process.stderr.write('\n');
console.log(JSON.stringify({ hops: HOPS, series }, null, 1));
await b.close();
@@ -0,0 +1,38 @@
/* A legacy Lua CBI page (openclash, openvpn — `call` nodes the router never takes SPA) renders its
* form as a SIBLING of #view. Leaving one is the case fs-router's sweep exists for: the outgoing
* page's form, scripts and stray headings must not ride along into the next page. */
import { chromium } from 'playwright';
import { login, PORTS } from './lib.mjs';
const BASE = `http://localhost:${PORTS[process.argv[2] || 'owrt2512']}`;
const CONTENT = `(() => ({
page: document.body.getAttribute('data-page'),
siblings: [...document.querySelectorAll('.fs-content > *')].map((e) => e.nodeName + (e.id ? '#' + e.id : '')),
viewKids: document.getElementById('view')?.children.length ?? null,
viewText: (document.getElementById('view')?.textContent || '').trim().slice(0, 60),
title: document.querySelector('.fs-title-main')?.textContent,
docTitle: document.title
}))()`;
const b = await chromium.launch();
const p = await b.newPage({ viewport: { width: 1600, height: 1000 } });
await login(p, BASE);
const out = [];
for (const legacy of ['admin/services/openclash/client', 'admin/vpn/openvpn', 'admin/services/openclash/config']) {
await p.goto(BASE + '/cgi-bin/luci/' + legacy, { waitUntil: 'domcontentloaded' }).catch(() => {});
await p.waitForTimeout(3000);
const on = await p.evaluate(CONTENT);
await p.evaluate(() => { window.__s = 1; });
await p.evaluate(() => document.querySelector('a[href="/cgi-bin/luci/admin/system/system"]')?.click());
await p.waitForTimeout(2600);
const after = await p.evaluate(CONTENT);
const spa = await p.evaluate(() => window.__s === 1);
/* and one more hop, to be sure nothing was merely deferred */
await p.evaluate(() => document.querySelector('a[href="/cgi-bin/luci/admin/network/routes"]')?.click());
await p.waitForTimeout(2400);
const after2 = await p.evaluate(CONTENT);
out.push({ legacy, on, spa, after, after2 });
}
console.log(JSON.stringify(out, null, 1));
await b.close();
@@ -0,0 +1,70 @@
/* Shared rig for the stand probes: log in, enumerate every page the dispatcher can render, and
* measure the chrome the same way everywhere so results from different probes compare. */
export const PORTS = { owrt2512: 8025, owrt2410: 8024, imm2512: 8026, imm2410: 8027 };
export async function login(p, base) {
await p.goto(base + '/cgi-bin/luci/', { waitUntil: 'domcontentloaded' });
if (await p.locator('input[name="luci_password"]').count()) {
await p.fill('input[name="luci_username"]', 'root');
await p.fill('input[name="luci_password"]', '');
await Promise.all([ p.waitForNavigation({ waitUntil: 'domcontentloaded' }), p.click('input[type=submit]') ]);
}
await p.waitForTimeout(2500);
}
/* Every leaf the theme's router would treat as SPA-able, straight out of LuCI's own menu tree —
* the same ACL-filtered blob the chrome uses, so this cannot list a page the session may not open. */
export const PAGES = `(() => window.L.require('ui').then((ui) => ui.menu.load()).then((tree) => {
const out = [];
const walk = (node, segs) => {
const kids = node.children || {};
for (const name in kids) {
const c = kids[name];
if (!c || !c.satisfied || !c.title) continue;
const s = segs.concat([name]);
if (c.action && (c.action.type === 'view' || c.action.type === 'template' || c.action.type === 'cbi'))
out.push({ path: s.join('/'), type: c.action.type, title: c.title });
if (s.length < 4) walk(c, s);
}
};
walk(tree, []);
return out;
}))()`;
/* One snapshot of "is the chrome still the chrome": the numbers a foreign sheet flattens first. */
export const SNAP = `(() => {
const cs = (el, p) => el ? getComputedStyle(el)[p] : null;
const bar = document.querySelector('.fs-sidebar');
const de = document.documentElement;
const sheets = { styles: 0, links: 0, shims: 0, disabledLinks: 0, layered: 0 };
for (const el of document.querySelectorAll('style, link[rel~="stylesheet"]')) {
if (el.tagName === 'LINK') { sheets.links++; if (el.disabled) sheets.disabledLinks++; }
else { sheets.styles++; if ((el.textContent || '').startsWith('@import')) sheets.shims++; }
if (el.dataset && el.dataset.fsLayered === '1') sheets.layered++;
}
return {
dataPage: document.body.getAttribute('data-page'),
sidebarPad: cs(bar, 'padding'),
sidebarW: bar ? Math.round(bar.getBoundingClientRect().width) : null,
menuItems: document.querySelectorAll('#topmenu > li').length,
brandVisible: !!document.querySelector('.fs-brand')?.getClientRects().length,
hOverflow: de.scrollWidth - de.clientWidth,
viewChildren: document.getElementById('view')?.children.length ?? null,
sheets,
intervals: window.__fsViewIntervals ? window.__fsViewIntervals.size : null,
pollQueue: (window.L && L.Poll && L.Poll.queue) ? L.Poll.queue.length : null
};
})()`;
/* SPA or full load: a sentinel on window dies with the document. */
export async function navAndCheck(p, href, waitMs = 2200) {
await p.evaluate(() => { window.__fsSentinel = 'alive'; });
const clicked = await p.evaluate((h) => {
const a = document.querySelector(`a[href="${h}"]`);
if (a) { a.click(); return true; }
return false;
}, href);
if (!clicked) { await p.goto(href, { waitUntil: 'domcontentloaded' }); }
await p.waitForTimeout(waitMs);
return { clicked, spa: await p.evaluate(() => window.__fsSentinel === 'alive') };
}
@@ -0,0 +1,88 @@
/* What a session's main thread carried: tool-result characters per tool, images, Agent calls,
* text written. The number the crew is judged on (docs/crew.md "Measuring the crew").
*
* node .claude/tooling/measure-run.mjs # the newest transcript of this project
* node .claude/tooling/measure-run.mjs latest 3 # the three newest
* node .claude/tooling/measure-run.mjs <path.jsonl>
*
* A transcript is one JSON object per line; a tool_result is matched to its tool_use by id, so the
* per-tool split is exact rather than guessed from the content.
*
* Since 2.1.x a subagent writes its own transcript under <session>/subagents/, so the file above
* holds the lead alone and its number would show only that the lead got lighter. The `delegated`
* line is the other half of that trade: what the roles spent to make it so. */
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
const slug = '-' + process.cwd().replace(/^\//, '').replace(/[/.]/g, '-');
const dir = join(homedir(), '.claude', 'projects', slug);
function newest(n) {
if (!existsSync(dir)) {
console.error(`no transcripts for this project: ${dir}`);
process.exit(2);
}
return readdirSync(dir)
.filter((f) => f.endsWith('.jsonl'))
.map((f) => ({ f: join(dir, f), t: statSync(join(dir, f)).mtimeMs }))
.sort((a, b) => b.t - a.t)
.slice(0, n)
.map((x) => x.f);
}
function measure(file) {
const byTool = new Map();
const uses = new Map();
let images = 0, imageBytes = 0, text = 0, agents = 0, results = 0;
for (const line of readFileSync(file, 'utf8').split('\n')) {
if (!line) continue;
let o;
try { o = JSON.parse(line); } catch { continue; }
const c = o.message && o.message.content;
if (!Array.isArray(c)) continue;
for (const b of c) {
if (b.type === 'text') text += (b.text || '').length;
else if (b.type === 'tool_use') {
uses.set(b.id, b.name);
if (b.name === 'Agent' || b.name === 'Task') agents += 1;
} else if (b.type === 'tool_result') {
results += 1;
const name = uses.get(b.tool_use_id) || '?';
const cc = b.content;
const s = typeof cc === 'string' ? cc : JSON.stringify(cc);
if (Array.isArray(cc) && cc.some((x) => x.type === 'image')) { images += 1; imageBytes += s.length; continue; }
const cur = byTool.get(name) || { chars: 0, n: 0 };
cur.chars += s.length; cur.n += 1;
byTool.set(name, cur);
}
}
}
const total = [...byTool.values()].reduce((a, v) => a + v.chars, 0);
return { file, total, byTool, images, imageBytes, text, agents, results };
}
/* Every subagent this session spawned, from <session>/subagents/agent-*.jsonl beside the file. */
function delegated(file) {
const sub = file.replace(/\.jsonl$/, '') + '/subagents';
if (!existsSync(sub)) return { agents: 0, chars: 0 };
let agents = 0, chars = 0;
for (const f of readdirSync(sub).filter((x) => x.endsWith('.jsonl'))) {
agents += 1;
chars += measure(join(sub, f)).total;
}
return { agents, chars };
}
const arg = process.argv[2] || 'latest';
const files = arg.endsWith('.jsonl') ? [arg] : newest(Number(process.argv[3] || 1));
for (const f of files) {
const m = measure(f);
console.log(`${f}`);
console.log(` tool-result chars (main thread): ${m.total} images: ${m.images} (${m.imageBytes} B) text: ${m.text} Agent calls: ${m.agents} results: ${m.results}`);
const d = delegated(f);
console.log(` delegated: ${d.agents} subagent transcript(s), ${d.chars} tool-result chars — read by the role, not by the lead`);
for (const [name, v] of [...m.byTool.entries()].sort((a, b) => b[1].chars - a[1].chars)) {
console.log(` ${name.padEnd(16)} ${String(v.chars).padStart(9)} n=${v.n}`);
}
}
@@ -0,0 +1,19 @@
import { chromium } from 'playwright';
import { login, PORTS } from './lib.mjs';
const BASE = `http://localhost:${PORTS[process.argv[2] || 'owrt2512']}`;
const b = await chromium.launch(); const p = await b.newPage();
await login(p, BASE);
console.log(JSON.stringify(await p.evaluate(() => window.L.require('ui').then((ui) => ui.menu.load()).then((tree) => {
const out = [];
const walk = (node, segs) => {
const kids = node.children || {};
for (const name in kids) {
const c = kids[name]; const s = segs.concat([name]);
out.push({ p: s.join('/'), t: c.action?.type ?? '(none)', sat: c.satisfied !== false, title: !!c.title, css: c.css || null });
if (s.length < 4) walk(c, s);
}
};
walk(tree, []);
return out.filter((x) => /clash|podkop|banip|adblock|filemanager/.test(x.p));
})), null, 1));
await b.close();
@@ -0,0 +1,44 @@
/* The second overflow shape, checked on FRESH loads so a resize artefact cannot be mistaken for it:
* around 800-900px the sidebar is still a sidebar (the content column has not dropped below
* --fs-content-min) but the config table has carded, and something inside a card does not fit. */
import { chromium } from 'playwright';
import { login, PORTS } from './lib.mjs';
const BASE = `http://localhost:${PORTS[process.argv[2] || 'owrt2512']}`;
const M = `(() => {
const main = document.getElementById('maincontent');
const c = document.querySelector('.fs-content'); const cs = getComputedStyle(c);
const box = c.getBoundingClientRect();
const rightEdge = box.right - parseFloat(cs.paddingRight);
const out = [];
for (const el of c.querySelectorAll('*')) {
const s = getComputedStyle(el);
if (s.visibility === 'hidden' || s.display === 'none' || s.position === 'fixed') continue;
if (el.closest('.ace_editor, .cbi-tooltip, [aria-hidden="true"]')) continue;
const r = el.getBoundingClientRect(); if (!r.width) continue;
const past = Math.round(r.right - rightEdge);
if (past > 1) out.push({ past, w: Math.round(r.width), min: el.scrollWidth,
sel: el.nodeName.toLowerCase() + (typeof el.className === 'string' && el.className ? '.' + el.className.trim().split(/\\s+/).slice(0,3).join('.') : ''),
ws: s.whiteSpace, title: el.getAttribute('data-title'), text: (el.textContent||'').trim().slice(0,44) });
}
out.sort((a,b)=>b.past-a.past);
return { colW: Math.round(box.width - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight)),
narrow: document.documentElement.hasAttribute('data-narrow'),
mainOverflow: main.scrollWidth - main.clientWidth, top: out.slice(0,4) };
})()`;
const b = await chromium.launch();
const p = await b.newPage({ viewport: { width: 1280, height: 900 } });
await login(p, BASE);
for (const path of ['admin/network/firewall/zones', 'admin/network/dhcp', 'admin/network/wireless']) {
for (const w of [700, 768, 800, 850, 900, 960, 1000]) {
await p.setViewportSize({ width: w, height: 900 });
await p.goto(BASE + '/cgi-bin/luci/' + path, { waitUntil: 'domcontentloaded' }).catch(()=>{});
await p.waitForTimeout(3000);
const m = await p.evaluate(M);
console.log(path.split('/').pop().padEnd(10), String(w).padEnd(6), 'colW=' + String(m.colW).padEnd(5),
'narrow=' + String(m.narrow).padEnd(6), 'ovf=' + String(m.mainOverflow).padEnd(5),
m.top[0] ? `${m.top[0].sel} +${m.top[0].past} (w=${m.top[0].w}, ws=${m.top[0].ws}) ${JSON.stringify(m.top[0].text)}` : '');
}
}
await b.close();
@@ -0,0 +1,44 @@
/* The 768 -> 800 crossing: data-narrow clears, the sidebar comes back and the content column SHRINKS
* from 712 to 520 while the viewport grows. Anything laid out for 712 that cannot re-fit shows here. */
import { chromium } from 'playwright';
import { login, PORTS } from './lib.mjs';
const BASE = `http://localhost:${PORTS[process.argv[2] || 'owrt2512']}`;
const M = `(() => {
const main = document.getElementById('maincontent');
const c = document.querySelector('.fs-content'); const cs = getComputedStyle(c);
const box = c.getBoundingClientRect(); const right = box.right - parseFloat(cs.paddingRight);
const out = [];
for (const el of c.querySelectorAll('*')) {
const s = getComputedStyle(el);
if (s.visibility === 'hidden' || s.display === 'none' || s.position === 'fixed') continue;
if (el.closest('.ace_editor, .cbi-tooltip, [aria-hidden="true"]')) continue;
const r = el.getBoundingClientRect(); if (!r.width) continue;
const past = Math.round(r.right - right);
if (past > 1) out.push({ past, w: Math.round(r.width),
sel: el.nodeName.toLowerCase() + (typeof el.className === 'string' && el.className ? '.' + el.className.trim().split(/\\s+/).slice(0,3).join('.') : ''),
inline: el.getAttribute('style') || '', ws: s.whiteSpace, text: (el.textContent||'').trim().slice(0,40) });
}
out.sort((a,b)=>b.past-a.past);
return { colW: Math.round(box.width - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight)),
narrow: document.documentElement.hasAttribute('data-narrow'),
ovf: main.scrollWidth - main.clientWidth, top: out.slice(0,3) };
})()`;
const b = await chromium.launch();
const p = await b.newPage({ viewport: { width: 1280, height: 900 } });
await login(p, BASE);
const settle = async () => { await p.waitForTimeout(500); await p.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))); await p.waitForTimeout(700); };
for (const path of ['admin/network/firewall/zones', 'admin/network/wireless']) {
for (const [from, to] of [[768, 800], [700, 800], [768, 900], [600, 800], [1280, 800]]) {
await p.setViewportSize({ width: from, height: 900 });
await p.goto(BASE + '/cgi-bin/luci/' + path, { waitUntil: 'domcontentloaded' }).catch(()=>{});
await p.waitForTimeout(3000);
await p.setViewportSize({ width: to, height: 900 });
await settle();
const m = await p.evaluate(M);
console.log(path.split('/').pop().padEnd(9), (from + '->' + to).padEnd(11), 'colW=' + String(m.colW).padEnd(5),
'ovf=' + String(m.ovf).padEnd(5), m.top[0] ? `${m.top[0].sel} +${m.top[0].past} w=${m.top[0].w} ws=${m.top[0].ws} inline=${JSON.stringify(m.top[0].inline.slice(0,50))}` : 'clean');
}
}
await b.close();
@@ -0,0 +1,75 @@
/* Does the CONTENT COLUMN scroll sideways? Measured on #maincontent, which is the scroller in the
* sidebar layout, and attributed to the widest element inside .fs-content. Off-screen furniture is
* excluded by construction: a tooltip parked at +10000px and Ace's virtual text layer are not
* overflow, they are how those widgets work. */
import { chromium, firefox, webkit } from 'playwright';
import { login, PORTS } from './lib.mjs';
const ENGINES = { chromium, firefox, webkit };
const engine = process.argv[3] || 'chromium';
const BASE = `http://localhost:${PORTS[process.argv[2] || 'owrt2512']}`;
const WIDTHS = [320, 360, 390, 480, 600, 768, 800, 900, 1000, 1100, 1200, 1280, 1366, 1440, 1600, 1857, 2560];
const PAGES = [
['fw/zones', 'admin/network/firewall/zones'],
['fw/forwards', 'admin/network/firewall/forwards'],
['dhcp', 'admin/network/dhcp'],
['wireless', 'admin/network/wireless'],
['processes', 'admin/status/processes'],
['pkg-manager', 'admin/system/package-manager'],
['overview', 'admin/status/overview'],
['podkop', 'admin/services/podkop'],
];
const CHECK = `(() => {
const main = document.getElementById('maincontent');
const content = document.querySelector('.fs-content');
const de = document.documentElement;
const box = content.getBoundingClientRect();
const cs = getComputedStyle(content);
const inner = box.width - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
const offenders = [];
for (const el of content.querySelectorAll('*')) {
const c = getComputedStyle(el);
if (c.visibility === 'hidden' || c.display === 'none' || c.position === 'fixed') continue;
if (el.closest('.ace_editor, .cbi-tooltip, [aria-hidden="true"]')) continue;
const need = el.scrollWidth;
const r = el.getBoundingClientRect();
if (!r.width) continue;
const past = Math.round(r.right - (box.right - parseFloat(cs.paddingRight)));
if (past > 1)
offenders.push({ sel: el.nodeName.toLowerCase() + (el.id ? '#' + el.id : '') +
(typeof el.className === 'string' && el.className ? '.' + el.className.trim().split(/\\s+/).slice(0, 3).join('.') : ''),
past, w: Math.round(r.width), need: Math.round(need) });
}
offenders.sort((a, b) => b.past - a.past);
return {
contentInner: Math.round(inner),
mainOverflow: main.scrollWidth - main.clientWidth,
docOverflow: de.scrollWidth - de.clientWidth,
narrow: de.hasAttribute('data-narrow'),
stacked: document.querySelectorAll('.table.fs-stacked').length,
dt: document.querySelectorAll('.table.fs-dt').length,
cfgTables: document.querySelectorAll('.table.cbi-section-table').length,
top: offenders.slice(0, 2)
};
})()`;
const b = await ENGINES[engine].launch();
const p = await b.newPage({ viewport: { width: 1280, height: 900 } });
await login(p, BASE);
const rows = [];
for (const [name, path] of PAGES) {
await p.goto(BASE + '/cgi-bin/luci/' + path, { waitUntil: 'domcontentloaded' }).catch(() => {});
await p.waitForTimeout(2800);
for (const w of WIDTHS) {
await p.setViewportSize({ width: w, height: 900 });
/* let the fitters settle: they run on a rAF after the resize observer fires */
await p.waitForTimeout(300);
await p.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
await p.waitForTimeout(300);
rows.push({ page: name, w, ...(await p.evaluate(CHECK)) });
}
process.stderr.write('.');
}
process.stderr.write('\n');
console.log(JSON.stringify({ engine, rows }, null, 1));
await b.close();
@@ -0,0 +1,102 @@
/* SPA-arrival vs FULL-LOAD parity, page by page.
*
* The router's whole contract is "the document ends up the way a full load would have left it".
* Every SPA bug found so far was a field where that stopped being true (data-page, the sibling of
* #view, the runtime notifications, nodespec.readonly, the poll tick). So stop guessing which field
* is next and diff the WHOLE snapshot for every page the menu offers.
*
* The SPA hop is made by appending a real <a href> into #view and clicking it: the router intercepts
* any same-origin link, so this reaches a page that has no sidebar link of its own (a third-level tab)
* without the probe having to model the menu.
*/
import { chromium } from 'playwright';
import { login, PAGES, PORTS } from './lib.mjs';
const router = process.argv[2] || 'owrt2512';
const engine = process.argv[3] || 'chromium';
const BASE = `http://localhost:${PORTS[router]}`;
const U = (p) => '/cgi-bin/luci/' + p;
const NEUTRAL = 'admin/status/processes';
const SNAP = `(() => {
const view = document.getElementById('view');
const txt = (el) => (el ? el.textContent.trim().replace(/\\s+/g, ' ').slice(0, 60) : null);
return {
dataPage: document.body.getAttribute('data-page'),
title: document.title,
dispatch: (L.env.dispatchpath || []).join('/'),
request: (L.env.requestpath || []).join('/'),
pathinfo: L.env.pathinfo,
readonly: !!(L.env.nodespec && L.env.nodespec.readonly),
tabs: [...document.querySelectorAll('#tabmenu ul.tabs')].map((ul) =>
[...ul.children].map((li) => (li.classList.contains('active') ? '*' : '') +
li.className.replace(/tabmenu-item-| active|/g, '')).join('|')).join(' // '),
current: [...document.querySelectorAll('#topmenu a[aria-current="page"]')].map((a) => a.getAttribute('href')).join(','),
views: document.querySelectorAll('#view').length,
siblings: [...(view ? view.parentNode.children : [])].map((c) => c.id || c.nodeName).join(','),
kids: view ? view.children.length : null,
head: txt(view && view.firstElementChild),
h2: [...document.querySelectorAll('#view h2')].map((h) => txt(h)).join('|'),
sections: document.querySelectorAll('#view .cbi-section, #view .table').length,
forms: document.querySelectorAll('#view form').length,
btns: document.querySelectorAll('#view button:not([disabled]), #view .cbi-button:not([disabled])').length,
notif: document.querySelectorAll('#maincontent > .alert-message').length,
hOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
styles: document.querySelectorAll('style').length,
links: document.querySelectorAll('link[rel~="stylesheet"]').length,
disabled: [...document.querySelectorAll('link[rel~="stylesheet"]')].filter((l) => l.disabled).length,
poll: (L.Poll && L.Poll.queue) ? L.Poll.queue.length : null
};
})()`;
const engines = { chromium };
if (engine === 'webkit') engines.webkit = (await import('playwright')).webkit;
const launcher = engine === 'webkit' ? engines.webkit : chromium;
const b = await launcher.launch();
const p = await b.newPage({ viewport: { width: 1500, height: 950 } });
const errs = [];
p.on('pageerror', (e) => errs.push(String(e).split('\n')[0].slice(0, 120)));
await login(p, BASE);
const pages = await p.evaluate(PAGES);
process.stderr.write(`${pages.length} pages\n`);
/* fields where a difference is timing, not state */
const SOFT = new Set(['poll', 'styles', 'links', 'disabled', 'btns', 'notif']);
const diffs = [];
let n = 0;
for (const pg of pages) {
n++;
/* full load */
await p.goto(BASE + U(pg.path), { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2600);
const full = await p.evaluate(SNAP);
/* SPA arrival from a neutral page */
await p.goto(BASE + U(NEUTRAL), { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2200);
await p.evaluate(() => { window.__s = 1; });
await p.evaluate((h) => {
const a = document.createElement('a');
a.href = h; a.textContent = 'x';
document.getElementById('view').appendChild(a);
a.click();
}, U(pg.path));
await p.waitForTimeout(2600);
const spa = await p.evaluate(SNAP);
const wasSpa = await p.evaluate(() => window.__s === 1);
const d = {};
for (const k of Object.keys(full))
if (JSON.stringify(full[k]) !== JSON.stringify(spa[k]))
d[k] = [full[k], spa[k]];
const hard = Object.keys(d).filter((k) => !SOFT.has(k));
if (hard.length || !wasSpa)
diffs.push({ path: pg.path, type: pg.type, spa: wasSpa, hard, d });
if (n % 10 === 0) process.stderr.write(`${n} `);
}
process.stderr.write('\n');
console.log(JSON.stringify({ router, engine, pages: pages.length, diffs, errs: [...new Set(errs)].slice(0, 10) }, null, 1));
await b.close();
@@ -0,0 +1,52 @@
/* Does the pattern set-pattern.sh installed actually PAINT — pre-login and after login, in a
* browser that has made no choice of its own (a fresh context has empty localStorage, so the
* router's saved default is what is under test). node .claude/tooling/pattern-script.mjs <router> */
import { chromium } from 'playwright';
import { PORTS, login } from './lib.mjs';
const router = process.argv[2] || 'owrt2512';
const base = 'http://localhost:' + PORTS[router];
const READ = `(() => {
const de = document.documentElement;
const layer = document.querySelector('.fs-pattern');
const cs = layer ? getComputedStyle(layer) : null;
return {
wallpaperAttr: de.getAttribute('data-wallpaper'),
inkAttr: de.getAttribute('data-pattern-ink'),
url: getComputedStyle(de).getPropertyValue('--fs-pattern-url').trim(),
size: getComputedStyle(de).getPropertyValue('--fs-pattern-size').trim(),
strength: getComputedStyle(de).getPropertyValue('--fs-pattern-strength').trim(),
layer: !!layer,
maskImage: cs ? (cs.maskImage || cs.webkitMaskImage) : null,
bgImage: cs ? cs.backgroundImage : null,
maskSize: cs ? (cs.maskSize || cs.webkitMaskSize) : null,
opacity: cs ? cs.opacity : null,
rect: layer ? Math.round(layer.getBoundingClientRect().width) + 'x' + Math.round(layer.getBoundingClientRect().height) : null
};
})()`;
const browser = await chromium.launch();
/* finally, for the same reason as the other probes: a throw would leave chromium on the stand. */
try {
const ctx = await browser.newContext();
const p = await ctx.newPage();
const bad = [];
p.on('pageerror', (e) => bad.push('pageerror: ' + e.message));
p.on('console', (m) => { if (m.type() === 'error') bad.push('console: ' + m.text()); });
await p.goto(base + '/cgi-bin/luci/', { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(800);
console.log(router, 'login page ', JSON.stringify(await p.evaluate(READ)));
await login(p, base);
console.log(router, 'after login', JSON.stringify(await p.evaluate(READ)));
/* the tile must actually be fetchable at the URL the theme built, token and all */
const url = (await p.evaluate(READ)).url.replace(/^url\(["']?|["']?\)$/g, '');
if (url && url !== 'none') {
const r = await p.request.get(base + url);
console.log(router, 'GET', url, '->', r.status(), r.headers()['content-type'], (await r.body()).length, 'bytes');
}
console.log(router, bad.length ? 'ERRORS: ' + bad.join(' | ') : 'no page errors');
} finally { await browser.close(); }
@@ -0,0 +1,55 @@
/* The phone bar (data-narrow): a section panel is a POPUP there. Open one, tap a page inside it, and
* check the panel does not ride the SPA navigation — and the same across a Back. The section trigger
* is the <li> whose own <a> is href="#" (a leaf like Dashboard is a real link). */
import { chromium } from 'playwright';
import { login, PORTS } from './lib.mjs';
const BASE = `http://localhost:${PORTS[process.argv[2] || 'owrt2512']}`;
const U = (p) => '/cgi-bin/luci/' + p;
const b = await chromium.launch();
const p = await b.newPage({ viewport: { width: 390, height: 780 } });
await login(p, BASE);
await p.goto(BASE + U('admin/status/processes'), { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2800);
const out = { narrow: await p.evaluate(() => document.documentElement.hasAttribute('data-narrow')) };
const openSection = () => p.evaluate(() => {
const lis = [...document.querySelectorAll('#topmenu > li')];
const li = lis.find((x) => x.querySelector(':scope > a')?.getAttribute('href') === '#');
if (!li) return null;
const a = li.querySelector(':scope > a');
a.click();
return { name: a.textContent.trim().slice(0, 14), open: li.classList.contains('open'),
exp: a.getAttribute('aria-expanded'),
href: li.querySelector('ul a[href^="/cgi-bin"]')?.getAttribute('href') || null };
});
const panels = () => p.evaluate(() => ({
open: document.querySelectorAll('#topmenu > li.open').length,
exp: document.querySelectorAll('#topmenu > li > a[aria-expanded="true"]').length,
visiblePanels: [...document.querySelectorAll('#topmenu > li > ul')].filter((u) => u.getClientRects().length).length,
path: location.pathname,
hOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth
}));
out.opened = await openSection();
await p.waitForTimeout(600);
out.whileOpen = await panels();
await p.evaluate(() => { window.__s = 1; });
if (out.opened?.href)
await p.evaluate((h) => document.querySelector(`#topmenu li.open a[href="${h}"]`)?.click(), out.opened.href);
await p.waitForTimeout(2600);
out.spa = await p.evaluate(() => window.__s === 1);
out.afterTap = await panels();
/* now open it again and traverse back */
out.opened2 = await openSection();
await p.waitForTimeout(600);
out.whileOpen2 = await panels();
await p.goBack();
await p.waitForTimeout(2600);
out.afterBack = await panels();
console.log(JSON.stringify(out, null, 1));
await b.close();
@@ -0,0 +1,32 @@
/* Recon for the design A/B shots: what the Overview actually renders on the stand, and where. */
import { chromium } from 'playwright';
import { fileURLToPath } from 'node:url';
import { login, PORTS } from './lib.mjs';
const router = process.argv[2] || 'owrt2512';
const base = `http://localhost:${PORTS[router]}`;
const OUT = fileURLToPath(new URL('../../../tmp/design-ab', import.meta.url));
const b = await chromium.launch();
try {
const p = await b.newPage({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 2 });
await login(p, base);
await p.goto(base + '/cgi-bin/luci/admin/status/overview', { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(6000);
const dump = await p.evaluate(() => {
const r = (el) => { const b = el.getBoundingClientRect(); return { x: Math.round(b.x), y: Math.round(b.y), w: Math.round(b.width), h: Math.round(b.height) }; };
const secs = [...document.querySelectorAll('#view .cbi-section')].map((s, i) => ({
i, h3: s.querySelector('h3')?.textContent?.trim() || null, ...r(s),
hasTable: !!s.querySelector('table.table'), hasIfacebox: !!s.querySelector('.ifacebox'),
hasPort: !!s.querySelector('.ifacebox img[src*="/port_"]'), hasNst: !!s.querySelector('.network-status-table'),
hasProgress: !!s.querySelector('.cbi-progressbar'), buttons: s.querySelectorAll('.td button, .td .cbi-button').length,
}));
return { secs, docH: document.documentElement.scrollHeight, ports: document.querySelectorAll('.ifacebox img[src*="/port_"]').length,
zonebadges: document.querySelectorAll('.ifacebox .zonebadge').length,
assoc: document.querySelectorAll('.assoclist').length };
});
console.log(JSON.stringify(dump, null, 1));
await p.screenshot({ path: `${OUT}/recon-full.png`, fullPage: true });
/* A throw anywhere above used to leave the browser running: chromium outlives the node process
* that launched it, and a stand with three orphan browsers on it measures nothing twice. */
} finally { await b.close(); }
@@ -0,0 +1,72 @@
/* Do sheets, nodes, listeners or pollers pile up when the same page is visited over and over by the
* SPA router? podkop calls injectGlobalStyles() from render() with no guard, ssclash's Ace adds
* sheets lazily, and a view's own setInterval used to outlive the page that set it. */
import { chromium } from 'playwright';
import { login, PORTS } from './lib.mjs';
const router = process.argv[2] || 'owrt2512';
const N = Number(process.argv[3] || 8);
const BASE = `http://localhost:${PORTS[router]}`;
const PAIRS = [
['podkop', 'admin/services/podkop', 'admin/system/system'],
['ssclash/cfg', 'admin/services/ssclash/config', 'admin/system/system'],
['filemanager', 'admin/system/filemanager', 'admin/system/system'],
['banip/feeds', 'admin/services/banip/feeds', 'admin/system/system'],
['processes', 'admin/status/processes', 'admin/network/routes'],
];
const COUNT = `(() => ({
styles: document.querySelectorAll('style').length,
links: document.querySelectorAll('link[rel~="stylesheet"]').length,
shims: [...document.querySelectorAll('style')].filter((s) => (s.textContent||'').startsWith('@import')).length,
layerStmts: [...document.querySelectorAll('style')].filter((s) => (s.textContent||'').trim().startsWith('@layer tokens')).length,
nodes: document.getElementsByTagName('*').length,
intervals: window.__fsViewIntervals ? window.__fsViewIntervals.size : null,
pollQueue: (window.L && L.Poll && L.Poll.queue) ? L.Poll.queue.length : null,
heap: performance.memory ? Math.round(performance.memory.usedJSHeapSize / 1048576) : null
}))()`;
const b = await chromium.launch({ args: ['--enable-precise-memory-info', '--js-flags=--expose-gc'] });
const p = await b.newPage({ viewport: { width: 1600, height: 1000 } });
await login(p, BASE);
/* Click a link that is really IN the sidebar. A third-level page (banip/feeds, ssclash/config) is a
* TAB, not a sidebar item — renderMainMenu stops at level 1 — so its href exists only while you are
* already on that app. Fall back to the app's SECTION link, which is an alias the router resolves to
* exactly that leaf; that is a genuine SPA hop, where a p.goto() would be a full load and would
* measure nothing about accumulation. */
const go = async (path) => {
const cands = [ '/cgi-bin/luci/' + path,
'/cgi-bin/luci/' + path.split('/').slice(0, 3).join('/') ];
const used = await p.evaluate((hs) => {
for (const h of hs) { const a = document.querySelector(`a[href="${h}"]`); if (a) { a.click(); return h; } }
return null;
}, cands);
if (!used) await p.goto(BASE + '/cgi-bin/luci/' + path, { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2200);
return !!used; /* false = we had to full-load, i.e. not an SPA hop */
};
const res = [];
for (const [name, appPath, otherPath] of PAIRS) {
await p.goto(BASE + '/cgi-bin/luci/' + otherPath, { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2000);
const series = [];
let allSpa = true;
for (let i = 0; i < N; i++) {
const a = await go(appPath); const c = await go(otherPath);
if (!a || !c) allSpa = false;
series.push(await p.evaluate(COUNT));
}
const f = series[0], l = series[series.length - 1];
res.push({ name, allSpa, first: f, last: l,
delta: { styles: l.styles - f.styles, links: l.links - f.links, shims: l.shims - f.shims,
layerStmts: l.layerStmts - f.layerStmts, nodes: l.nodes - f.nodes,
intervals: (l.intervals ?? 0) - (f.intervals ?? 0), pollQueue: (l.pollQueue ?? 0) - (f.pollQueue ?? 0),
heapMB: (l.heap ?? 0) - (f.heap ?? 0) } });
process.stderr.write(name + ' ');
}
process.stderr.write('\n');
console.log(JSON.stringify({ router, cycles: N, res }, null, 1));
await b.close();
@@ -0,0 +1,49 @@
/* Load at one width, resize to another, and compare against loading at the target width directly.
* A config table is laid out by @container, which the engine re-evaluates on its own — so any
* difference between "loaded at W" and "resized to W" is layout that did not re-fit. */
import { chromium } from 'playwright';
import { login, PORTS } from './lib.mjs';
const BASE = `http://localhost:${PORTS[process.argv[2] || 'owrt2512']}`;
const PATH = process.argv[3] || 'admin/network/firewall/zones';
const M = `(() => {
const t = document.querySelector('.table.cbi-section-table');
const c = document.querySelector('.fs-content'); const main = document.getElementById('maincontent');
const r = t && t.getBoundingClientRect();
return { tableW: r ? Math.round(r.width) : null, needs: t ? t.scrollWidth : null,
contentW: Math.round(c.getBoundingClientRect().width),
carded: t ? getComputedStyle(t).display : null,
mainOverflow: main.scrollWidth - main.clientWidth };
})()`;
const b = await chromium.launch();
const p = await b.newPage({ viewport: { width: 1280, height: 900 } });
await login(p, BASE);
const settle = async () => { await p.waitForTimeout(400);
await p.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
await p.waitForTimeout(400); };
const fresh = {};
for (const w of [1000, 1280, 1600]) {
await p.setViewportSize({ width: w, height: 900 });
await p.goto(BASE + '/cgi-bin/luci/' + PATH, { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(3000);
fresh[w] = await p.evaluate(M);
}
const resized = [];
for (const [from, to] of [[1000, 1280], [1600, 1280], [1280, 1600], [1280, 1000], [768, 1280], [2560, 1280]]) {
await p.setViewportSize({ width: from, height: 900 });
await p.goto(BASE + '/cgi-bin/luci/' + PATH, { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(3000);
const before = await p.evaluate(M);
await p.setViewportSize({ width: to, height: 900 });
await settle();
const after = await p.evaluate(M);
/* and again after a long pause, in case something re-fits late */
await p.waitForTimeout(2500);
const late = await p.evaluate(M);
resized.push({ from, to, before, after, late, matchesFresh: JSON.stringify(after) === JSON.stringify(fresh[to]) });
}
console.log(JSON.stringify({ path: PATH, fresh, resized }, null, 1));
await b.close();
@@ -0,0 +1,37 @@
/* Where does readonly go? Dump what the CLIENT menu tree says about the six nodes whose
* nodespec.readonly a full load sets and an SPA arrival does not. */
import { chromium } from 'playwright';
import { login, PORTS } from './lib.mjs';
const router = process.argv[2] || 'owrt2512';
const BASE = `http://localhost:${PORTS[router]}`;
const b = await chromium.launch();
const p = await b.newPage({ viewport: { width: 1400, height: 900 } });
await login(p, BASE);
const PATHS = ['admin/status/logs/syslog', 'admin/status/realtime/load', 'admin/status/processes'];
const res = await p.evaluate(async (paths) => {
const ui = await L.require('ui');
const tree = await ui.menu.load();
const out = {};
for (const path of paths) {
let node = tree;
for (const s of path.split('/')) node = node && node.children && node.children[s];
out[path] = node ? { keys: Object.keys(node), readonly: node.readonly, action: node.action,
write: node.write, depends: node.depends } : null;
}
/* and what the server stamped for the page we are standing on */
out.__env = { path: location.pathname, nodespec: L.env.nodespec };
return out;
}, PATHS);
/* now a real full load of one of them, to see the server's own nodespec */
await p.goto(BASE + '/cgi-bin/luci/' + PATHS[0], { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2500);
res.__full = await p.evaluate(() => ({ nodespec: L.env.nodespec,
perm: (typeof L.hasViewPermission === 'function') ? L.hasViewPermission() : null,
disabledBtns: document.querySelectorAll('#view button[disabled], #view .cbi-button[disabled]').length,
btns: document.querySelectorAll('#view button, #view .cbi-button').length }));
console.log(JSON.stringify(res, null, 1));
await b.close();
@@ -0,0 +1,32 @@
/* The dispatcher computes readonly over the ACCUMULATED acls of the whole resolved path
* (dispatcher.uc:1003, ctx.acls), while apply_tree_acls() stamps the menu JSON per NODE. So a leaf
* with no depends.acl of its own inherits the verdict from an ancestor that has one. Check the
* chains of the six nodes where the SPA arrival disagreed with the full load. */
import { chromium } from 'playwright';
import { login, PORTS } from './lib.mjs';
const BASE = `http://localhost:${PORTS[process.argv[2] || 'owrt2512']}`;
const b = await chromium.launch();
const p = await b.newPage();
await login(p, BASE);
const PATHS = ['admin/status/logs/syslog', 'admin/status/realtime/load', 'admin/status/processes',
'admin/dashboard', 'admin/system/system', 'admin/network/firewall/zones'];
const res = await p.evaluate(async (paths) => {
const ui = await L.require('ui');
const tree = await ui.menu.load();
const out = {};
for (const path of paths) {
let node = tree;
const chain = [];
for (const s of path.split('/')) {
node = node && node.children && node.children[s];
if (!node) break;
chain.push({ s, ro: node.readonly === true, acl: node.depends?.acl || null, sat: node.satisfied });
}
out[path] = { chain, anyRo: chain.some((c) => c.ro) };
}
return out;
}, PATHS);
console.log(JSON.stringify(res, null, 1));
await b.close();
@@ -0,0 +1,104 @@
/* Right-to-left: the facts that must mirror, plus screenshots to look at.
*
* Run: node .claude/tooling/rtl.mjs <router> [lang] — sets uci luci.main.lang, restores it at the end.
* Writes PNGs next to the JSON in the scratchpad dir given by $SHOT (default /tmp).
*/
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { chromium } from 'playwright';
import { login, PORTS } from './lib.mjs';
const exec = promisify(execFile);
const router = process.argv[2] || 'owrt2512';
const lang = process.argv[3] || 'he';
const SHOT = process.env.SHOT || '/tmp';
const BASE = `http://localhost:${PORTS[router]}`;
const U = (p) => '/cgi-bin/luci/' + p;
const owlab = async (...a) => (await exec(`${process.env.HOME}/go/bin/owlab`, a)).stdout;
const setLang = async (l) => { await owlab('exec', router, '--', 'uci', 'set', `luci.main.lang=${l}`);
await owlab('exec', router, '--', 'uci', 'commit', 'luci'); };
/* Every fact below is stated as "which EDGE", so the same expression describes both directions and
* the LTR pass is a control rather than a separate probe. */
const GEO = `(() => {
const de = document.documentElement, W = de.clientWidth;
const box = (sel) => { const e = document.querySelector(sel); if (!e) return null;
const r = e.getBoundingClientRect();
return { l: Math.round(r.left), r: Math.round(r.right), w: Math.round(r.width),
atStart: Math.round(r.left) <= 2, atEnd: Math.round(r.right) >= W - 2 }; };
const cs = (sel, p) => { const e = document.querySelector(sel); return e ? getComputedStyle(e)[p] : null; };
return {
dir: de.getAttribute('dir'),
computedDir: getComputedStyle(de).direction,
W,
hOverflow: de.scrollWidth - de.clientWidth,
sidebar: box('.fs-sidebar'),
content: box('.fs-content'),
brand: box('.fs-brand'),
cluster: box('#fs-appearance'),
tabs: box('#tabmenu ul.tabs'),
firstTab: box('#tabmenu ul.tabs > li'),
firstMenuLink: box('#topmenu > li > a'),
textAlign: cs('.fs-content', 'textAlign'),
labelAlign: cs('#view .cbi-value-title', 'textAlign'),
actionsAlign: cs('#view .cbi-section-actions', 'textAlign'),
alertBorder: (() => { const e = document.querySelector('.alert-message'); if (!e) return null;
const s = getComputedStyle(e); return { l: s.borderLeftWidth, r: s.borderRightWidth }; })()
};
})()`;
const out = { router, lang, passes: {} };
const b = await chromium.launch();
for (const [name, code] of [['ltr', 'auto'], ['rtl', lang]]) {
await setLang(code);
const p = await b.newPage({ viewport: { width: 1500, height: 950 } });
await login(p, BASE);
out.passes[name] = {};
for (const [pname, path] of [['system', 'admin/system/system'], ['banip', 'admin/services/banip/overview'],
['zones', 'admin/network/firewall/zones'], ['overview', 'admin/status/overview']]) {
for (const w of [1500, 900, 390]) {
await p.setViewportSize({ width: w, height: 950 });
await p.goto(BASE + U(path), { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2200);
out.passes[name][`${pname}@${w}`] = await p.evaluate(GEO);
if (w !== 900) await p.screenshot({ path: `${SHOT}/${name}-${pname}-${w}.png`, fullPage: false });
}
}
/* the popover and a rail flyout, which are absolutely positioned and the likeliest to escape */
await p.setViewportSize({ width: 1500, height: 950 });
await p.goto(BASE + U('admin/system/system'), { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2200);
await p.evaluate(() => document.getElementById('fs-appearance').click());
await p.waitForTimeout(600);
out.passes[name].popover = await p.evaluate(() => {
const e = document.getElementById('fs-appearance-pop'); if (!e) return null;
const r = e.getBoundingClientRect(), W = document.documentElement.clientWidth;
return { l: Math.round(r.left), r: Math.round(r.right), inside: r.left >= -1 && r.right <= W + 1 };
});
await p.screenshot({ path: `${SHOT}/${name}-popover.png` });
await p.keyboard.press('Escape');
/* rail: collapse the sidebar and hover a section so the flyout renders */
await p.evaluate(() => document.getElementById('fs-rail-toggle')?.click());
await p.waitForTimeout(500);
const flySel = '#topmenu > li:nth-child(2)';
await p.evaluate((s) => document.querySelector(s)?.querySelector(':scope > a')?.click(), flySel);
await p.waitForTimeout(600);
out.passes[name].flyout = await p.evaluate((s) => {
const li = document.querySelector(s); if (!li) return null;
const ul = li.querySelector(':scope > ul'); if (!ul) return null;
const r = ul.getBoundingClientRect(), lr = li.getBoundingClientRect();
const W = document.documentElement.clientWidth;
return { l: Math.round(r.left), r: Math.round(r.right), liL: Math.round(lr.left), liR: Math.round(lr.right),
inside: r.left >= -1 && r.right <= W + 1, visible: !!ul.getClientRects().length };
}, flySel);
await p.screenshot({ path: `${SHOT}/${name}-flyout.png` });
await p.evaluate(() => document.getElementById('fs-rail-toggle')?.click());
await p.close();
process.stderr.write(name + ' ');
}
process.stderr.write('\n');
await setLang('auto');
console.log(JSON.stringify(out, null, 1));
await b.close();
@@ -0,0 +1,42 @@
/* Ask the engine which rules actually match the offending button, in the broken state and in the
* fresh one. CDP answers with the stylesheet and the line, so the file is named rather than guessed. */
import { chromium } from 'playwright';
import { login, PORTS } from './lib.mjs';
const BASE = `http://localhost:${PORTS[process.argv[2] || 'owrt2512']}`;
const PATH = 'admin/network/firewall/zones';
const b = await chromium.launch();
const p = await b.newPage({ viewport: { width: 1000, height: 900 } });
const cdp = await p.context().newCDPSession(p);
await cdp.send('DOM.enable'); await cdp.send('CSS.enable');
await login(p, BASE);
const settle = async () => { await p.waitForTimeout(400); await p.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))); await p.waitForTimeout(500); };
async function dig(label) {
const { root } = await cdp.send('DOM.getDocument', { depth: -1 });
const { nodeId } = await cdp.send('DOM.querySelector', { nodeId: root.nodeId,
selector: '.table.cbi-section-table .tr:not(.cbi-section-table-titles) .td.cbi-section-actions' });
if (!nodeId) return { label, err: 'cell not found' };
const cell = await cdp.send('CSS.getMatchedStylesForNode', { nodeId });
const { nodeId: btnId } = await cdp.send('DOM.querySelector', { nodeId, selector: 'button.cbi-button-edit' });
const btn = btnId ? await cdp.send('CSS.getMatchedStylesForNode', { nodeId: btnId }) : null;
const fmt = (m) => (m?.matchedCSSRules || [])
.filter((r) => /width|flex|white-space/.test(r.rule.style.cssText || ''))
.map((r) => ({
sel: r.rule.selectorList.text.slice(0, 78),
media: (r.rule.media || []).map((x) => x.text).join(' && ') || null,
decl: (r.rule.style.cssProperties || []).filter((c) => /width|flex|white-space/.test(c.name))
.map((c) => c.name + ':' + c.value).join('; ')
}));
return { label, cell: fmt(cell), button: fmt(btn),
inline: (cell.inlineStyle?.cssText || '') + ' | btn: ' + (btn?.inlineStyle?.cssText || '') };
}
await p.setViewportSize({ width: 1000, height: 900 });
await p.goto(BASE + '/cgi-bin/luci/' + PATH, { waitUntil: 'domcontentloaded' }); await p.waitForTimeout(3000);
await p.setViewportSize({ width: 1280, height: 900 }); await settle();
const broken = await dig('BROKEN (1000 -> 1280)');
await p.goto(BASE + '/cgi-bin/luci/' + PATH, { waitUntil: 'domcontentloaded' }); await p.waitForTimeout(3000);
const fresh = await dig('FRESH @1280');
console.log(JSON.stringify([broken, fresh], null, 1));
await b.close();
@@ -0,0 +1,22 @@
import { chromium } from 'playwright';
const BASE = 'http://localhost:8025';
const b = await chromium.launch(); const p = await b.newPage();
await p.goto(BASE + '/cgi-bin/luci/', { waitUntil: 'domcontentloaded' });
if (await p.locator('input[name="luci_password"]').count()) {
await p.fill('input[name="luci_username"]', 'root'); await p.fill('input[name="luci_password"]', '');
await Promise.all([ p.waitForNavigation({ waitUntil: 'domcontentloaded' }), p.click('input[type=submit]') ]);
}
await p.waitForTimeout(2500);
await p.click('#fs-appearance'); await p.waitForTimeout(400);
await p.evaluate(() => [...document.querySelectorAll('.fs-ap-wall .fs-seg button')].find((b) => b.dataset.val === 'file').click());
await p.waitForTimeout(200);
await p.evaluate(() => [...document.querySelectorAll('.fs-appearance-pop button')].find((x) => /^Save as default$/.test(x.textContent.trim())).click());
await p.waitForTimeout(3000);
console.log(JSON.stringify(await p.evaluate(() => {
const e = document.querySelector('.fs-appearance-pop .fs-ap-err');
const s = [...document.querySelectorAll('.fs-appearance-pop button')].find((x) => /^Saved? as default$/.test(x.textContent.trim()));
return { errHidden: e?.hidden, errText: e?.textContent, errTitle: e?.title,
button: s ? { text: s.textContent.trim(), disabled: s.disabled } : null,
sd: window.__fsSD };
}), null, 2));
await b.close();
@@ -0,0 +1,64 @@
/* Scrolling is where this theme differs from stock, so it is where an engine difference would show.
* In the SIDEBAR layout .fs-shell is exactly 100dvh with overflow:hidden and .fs-main owns overflow-y,
* so #maincontent is the scroller and the DOCUMENT must not scroll at all (a document scrollbar there
* is a double scrollbar). In the TOP layout the document scrolls instead. Back must restore the inner
* scroller, which no browser does for a same-document traversal — fs-router records it itself. */
import { chromium, firefox, webkit } from 'playwright';
import { login, PORTS } from './lib.mjs';
const ENGINES = { chromium, firefox, webkit };
const engine = process.argv[3] || 'chromium';
const BASE = `http://localhost:${PORTS[process.argv[2] || 'owrt2512']}`;
const LONG = 'admin/status/processes'; /* a page tall enough to scroll */
const S = `(() => {
const de = document.documentElement, main = document.getElementById('maincontent');
const shell = document.querySelector('.fs-shell'), side = document.querySelector('.fs-sidebar');
return {
layout: de.getAttribute('data-layout'),
docScrolls: de.scrollHeight - de.clientHeight,
mainScrolls: main.scrollHeight - main.clientHeight,
mainOverflowY: getComputedStyle(main).overflowY,
shellH: shell ? Math.round(shell.getBoundingClientRect().height) : null,
viewportH: de.clientHeight,
shellOverflow: shell ? getComputedStyle(shell).overflow : null,
sidebarScrolls: side ? side.scrollHeight - side.clientHeight : null,
docScrollTop: de.scrollTop || document.body.scrollTop,
mainScrollTop: main.scrollTop
};
})()`;
const b = await ENGINES[engine].launch();
const p = await b.newPage({ viewport: { width: 1400, height: 700 } });
await login(p, BASE);
const out = {};
for (const layout of ['sidebar', 'top']) {
await p.evaluate((l) => { try { localStorage.setItem('fs-layout', l); } catch (e) {} }, layout);
await p.goto(BASE + '/cgi-bin/luci/' + LONG, { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(3500);
const at0 = await p.evaluate(S);
/* scroll the thing that is supposed to scroll */
await p.evaluate(() => { const m = document.getElementById('maincontent');
if (m.scrollHeight > m.clientHeight) m.scrollTop = 400; else window.scrollTo(0, 400); });
await p.waitForTimeout(400);
const at1 = await p.evaluate(S);
out[layout] = { at0, scrolled: { doc: at1.docScrollTop, main: at1.mainScrollTop } };
}
/* Back must restore the inner scroller after an SPA hop (fs-router's _scrollMem) */
await p.evaluate(() => { try { localStorage.setItem('fs-layout', 'sidebar'); } catch (e) {} });
await p.goto(BASE + '/cgi-bin/luci/' + LONG, { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(3500);
await p.evaluate(() => { document.getElementById('maincontent').scrollTop = 500; });
await p.waitForTimeout(400);
const before = await p.evaluate(() => document.getElementById('maincontent').scrollTop);
await p.evaluate(() => document.querySelector('a[href="/cgi-bin/luci/admin/system/system"]')?.click());
await p.waitForTimeout(2500);
const afterNav = await p.evaluate(() => document.getElementById('maincontent').scrollTop);
await p.goBack();
await p.waitForTimeout(3500);
const afterBack = await p.evaluate(() => document.getElementById('maincontent').scrollTop);
out.backRestore = { before, afterNav, afterBack, ok: Math.abs(afterBack - before) < 60 };
console.log(JSON.stringify({ engine, ...out }, null, 1));
await b.close();
@@ -0,0 +1,70 @@
/* Does visiting a page that injects an invasive <link> turn the SPA router off?
*
* banIP's view/banip/feeds.js appends <link href=.../view/banip/custom.css> to <head> at MODULE
* EVAL, and that sheet is `.cbi-input-text{width:90%!important}` — a stock widget class, unpinned,
* so fs-sheets judges it invasive, re-hosts it as a fenced @import shim and silences the original.
*
* The measurement is the user-facing one: set a sentinel on `window`, click a menu link, read the
* sentinel back. It survives a client-side navigation and dies with a full page load. */
import { chromium } from 'playwright';
const BASE = 'http://localhost:8025';
const b = await chromium.launch();
const p = await b.newPage({ viewport: { width: 1600, height: 1000 } });
const log = [];
p.on('console', (m) => { const t = m.text(); if (/footstrap|Unhandled|error/i.test(t)) log.push(t); });
await p.goto(BASE + '/cgi-bin/luci/', { waitUntil: 'domcontentloaded' });
if (await p.locator('input[name="luci_password"]').count()) {
await p.fill('input[name="luci_username"]', 'root');
await p.fill('input[name="luci_password"]', '');
await Promise.all([ p.waitForNavigation({ waitUntil: 'domcontentloaded' }), p.click('input[type=submit]') ]);
}
const sheets = () => p.evaluate(() => {
const out = { links: [], styles: 0, shims: 0 };
for (const el of document.querySelectorAll('link[rel~="stylesheet"], style')) {
if (el.tagName === 'LINK') {
let sd = null; try { sd = el.sheet ? el.sheet.disabled : null; } catch (e) { sd = 'unreadable'; }
out.links.push({ href: el.getAttribute('href'), elDisabled: el.disabled, sheetDisabled: sd,
layered: el.dataset.fsLayered === '1' });
} else {
out.styles++;
if ((el.textContent || '').startsWith('@import')) out.shims++;
}
}
return out;
});
/* 1. the banIP page that carries the injector */
await p.goto(BASE + '/cgi-bin/luci/admin/services/banip/feeds', { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2500);
const after = await sheets();
const banip = after.links.filter((l) => /banip/.test(l.href || ''));
/* 2. sentinel, then a menu click to a plain `view` node */
await p.evaluate(() => { window.__fsSentinel = 'alive'; });
/* Clicked from INSIDE the page: the target sits in a folded sidebar section, and el.click() reaches
* the router's document-level handler exactly as the search palette's Enter does (fs-search.js).
* detail 0 and button 0, no modifiers — the same shape the handler filters on. */
const target = '/cgi-bin/luci/admin/system/system';
const how = await p.evaluate((t) => {
const a = document.querySelector(`a[href="${t}"]`);
if (!a) return 'no menu link for ' + t;
a.click();
return 'in-page click on the menu <a>';
}, target);
await p.waitForTimeout(3000);
const survived = await p.evaluate(() => window.__fsSentinel === 'alive');
const landed = await p.evaluate(() => location.pathname + ' | data-page=' + document.body.getAttribute('data-page'));
console.log(JSON.stringify({
banipLinks: banip,
importShims: after.shims,
navigatedBy: how,
landedOn: landed,
sentinelSurvived: survived,
verdict: survived ? 'SPA navigation (document not spent)' : 'FULL PAGE LOAD (document spent)',
console: log.slice(0, 8)
}, null, 2));
await b.close();
@@ -0,0 +1,28 @@
import { chromium } from 'playwright';
import { login, PORTS } from './lib.mjs';
const BASE = `http://localhost:${PORTS[process.argv[2] || 'owrt2512']}`;
const b = await chromium.launch();
const p = await b.newPage({ viewport: { width: 1280, height: 900 } });
await login(p, BASE);
for (const [name, path, w] of [
['zones-1280', 'admin/network/firewall/zones', 1280],
['zones-1600', 'admin/network/firewall/zones', 1600],
['zones-1000', 'admin/network/firewall/zones', 1000],
['dhcp-1280', 'admin/network/dhcp', 1280],
]) {
await p.setViewportSize({ width: w, height: 900 });
await p.goto(BASE + '/cgi-bin/luci/' + path, { waitUntil: 'domcontentloaded' }).catch(() => {});
await p.waitForTimeout(3000);
const m = await p.evaluate(() => {
const t = document.querySelector('.table.cbi-section-table');
const c = document.querySelector('.fs-content'); const main = document.getElementById('maincontent');
const tr = t?.getBoundingClientRect(), cr = c.getBoundingClientRect();
return { table: tr ? Math.round(tr.width) : null, tableNeeds: t ? t.scrollWidth : null,
content: Math.round(cr.width), contentRight: Math.round(cr.right),
tableRight: tr ? Math.round(tr.right) : null,
mainOverflow: main.scrollWidth - main.clientWidth };
});
console.log(name.padEnd(12), JSON.stringify(m));
await p.screenshot({ path: `/tmp/shot-${name}.png`, fullPage: false });
}
await b.close();
@@ -0,0 +1,57 @@
/* Two heap snapshots, N navigations apart, aggregated by object name — what grew is what leaks. */
import { chromium } from 'playwright';
import { login, PORTS } from './lib.mjs';
const BASE = `http://localhost:${PORTS[process.argv[2] || 'owrt2512']}`;
const WARM = 70, SPAN = Number(process.argv[3] || 280);
const RING = ['admin/network/firewall/zones', 'admin/network/firewall/forwards',
'admin/network/firewall/rules', 'admin/network/dhcp', 'admin/network/wireless',
'admin/status/processes', 'admin/system/system'];
const b = await chromium.launch({ args: ['--enable-precise-memory-info'] });
const p = await b.newPage({ viewport: { width: 1400, height: 900 } });
const cdp = await p.context().newCDPSession(p);
await cdp.send('HeapProfiler.enable');
await login(p, BASE);
await p.evaluate(() => document.querySelectorAll('#topmenu > li.has-sub:not(.open) > a').forEach((a) => a.click()));
await p.waitForTimeout(600);
async function snapshot() {
await cdp.send('HeapProfiler.collectGarbage'); await p.waitForTimeout(500);
let buf = '';
const on = (e) => { buf += e.chunk; };
cdp.on('HeapProfiler.addHeapSnapshotChunk', on);
await cdp.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false, captureNumericValue: false });
cdp.off('HeapProfiler.addHeapSnapshotChunk', on);
const j = JSON.parse(buf);
const fields = j.snapshot.meta.node_fields;
const NF = fields.length, iName = fields.indexOf('name'), iSize = fields.indexOf('self_size'), iType = fields.indexOf('type');
const types = j.snapshot.meta.node_types[0];
const agg = new Map();
for (let i = 0; i < j.nodes.length; i += NF) {
const name = j.strings[j.nodes[i + iName]] || '(anon)';
const key = types[j.nodes[i + iType]] + ' ' + name;
const a = agg.get(key) || { n: 0, bytes: 0 };
a.n++; a.bytes += j.nodes[i + iSize];
agg.set(key, a);
}
return agg;
}
async function hop(i) {
await p.evaluate((h) => document.querySelector(`a[href="${h}"]`)?.click(), '/cgi-bin/luci/' + RING[i % RING.length]);
await p.waitForTimeout(850);
}
for (let i = 0; i < WARM; i++) await hop(i);
const a = await snapshot();
for (let i = WARM; i < WARM + SPAN; i++) { await hop(i); if (i % 50 === 0) process.stderr.write('.'); }
const c = await snapshot();
process.stderr.write('\n');
const rows = [];
for (const [k, v] of c) {
const o = a.get(k) || { n: 0, bytes: 0 };
const dn = v.n - o.n, db = v.bytes - o.bytes;
if (db > 20000 || dn > 200) rows.push({ k, dn, dbKB: Math.round(db / 1024), nowN: v.n, nowKB: Math.round(v.bytes / 1024) });
}
rows.sort((x, y) => y.dbKB - x.dbKB);
console.log(JSON.stringify({ span: SPAN, top: rows.slice(0, 22) }, null, 1));
await b.close();
@@ -0,0 +1,54 @@
/* A long SPA walk over everything reachable from the chrome — sidebar items AND the tab strip, so
* third-level pages (banip/feeds, ssclash/log, mwan3/*) are actually reached the way a user reaches
* them. Watches every accumulation shape at once: sheets, DOM nodes, live intervals, the poll queue
* and the JS heap. Anything that grows monotonically over hundreds of hops is a leak. */
import { chromium } from 'playwright';
import { login, PORTS } from './lib.mjs';
const router = process.argv[2] || 'owrt2512';
const HOPS = Number(process.argv[3] || 150);
const BASE = `http://localhost:${PORTS[router]}`;
const COUNT = `(() => ({
styles: document.querySelectorAll('style').length,
links: document.querySelectorAll('link[rel~="stylesheet"]').length,
shims: [...document.querySelectorAll('style')].filter((s) => (s.textContent||'').startsWith('@import')).length,
layerStmts: [...document.querySelectorAll('style')].filter((s) => (s.textContent||'').trim().startsWith('@layer tokens')).length,
nodes: document.getElementsByTagName('*').length,
intervals: window.__fsViewIntervals ? window.__fsViewIntervals.size : 0,
pollQueue: (window.L && L.Poll && L.Poll.queue) ? L.Poll.queue.length : 0,
notifications: document.querySelectorAll('#maincontent > .alert-message').length,
strayInView: document.querySelectorAll('.fs-content > *:not(#view):not(#tabmenu):not(.alert-message):not(noscript)').length,
heap: performance.memory ? Math.round(performance.memory.usedJSHeapSize / 1048576) : null
}))()`;
const b = await chromium.launch({ args: ['--enable-precise-memory-info'] });
const p = await b.newPage({ viewport: { width: 1600, height: 1000 } });
const errs = new Map();
p.on('pageerror', (e) => errs.set('pageerror: ' + e.message.split('\n')[0], (errs.get('pageerror: ' + e.message.split('\n')[0]) || 0) + 1));
p.on('console', (m) => { if (m.type() === 'error') { const k = 'console: ' + m.text().split('\n')[0].slice(0, 120); errs.set(k, (errs.get(k) || 0) + 1); } });
await login(p, BASE);
/* unfold every sidebar section once, so its leaves are clickable for the rest of the run */
await p.evaluate(() => document.querySelectorAll('#topmenu > li.has-sub:not(.open) > a').forEach((a) => a.click()));
await p.waitForTimeout(600);
const series = [];
let fullLoads = 0;
for (let i = 0; i < HOPS; i++) {
/* every same-origin chrome link on screen: sidebar leaves plus the current page's tab strip */
const hrefs = await p.evaluate(() => [...document.querySelectorAll('#topmenu a[href^="/cgi-bin/luci/"], #tabmenu a[href^="/cgi-bin/luci/"]')]
.map((a) => a.getAttribute('href')).filter((h) => !/logout/.test(h)));
if (!hrefs.length) break;
const href = hrefs[(i * 7 + 3) % hrefs.length]; /* deterministic stride, no Math.random */
await p.evaluate(() => { window.__fsSentinel = 1; });
await p.evaluate((h) => document.querySelector(`a[href="${h}"]`)?.click(), href);
await p.waitForTimeout(1400);
if (!(await p.evaluate(() => window.__fsSentinel === 1))) fullLoads++;
if (i % 10 === 9) series.push({ hop: i + 1, ...(await p.evaluate(COUNT)) });
if (i % 25 === 24) process.stderr.write(String(i + 1) + ' ');
}
process.stderr.write('\n');
console.log(JSON.stringify({ router, hops: HOPS, fullLoads, series,
errors: [...errs.entries()].map(([k, v]) => ({ n: v, k })).sort((a, b) => b.n - a.n).slice(0, 10) }, null, 1));
await b.close();
@@ -0,0 +1,139 @@
/* SPA nuances a full load gets for free, batch 1:
* A. Back/Forward scroll restoration in the TOP layout, where the DOCUMENT is the scroller and the
* router deliberately leaves the restore to the browser (fs-router.js §_scrollMem comment).
* The swap empties #view, so the document height collapses right after the traversal — the same
* argument that broke #maincontent restoration in the sidebar layout.
* B. history entry integrity: N SPA clicks must cost exactly N Back presses.
* C. #tabmenu links (a third-level page is a TAB, not a sidebar item) navigate by SPA and leave a
* correct strip behind.
* D. <title> host prefix: navigate() re-derives it from the live title on every hop.
*/
import { chromium } from 'playwright';
import { login, PORTS } from './lib.mjs';
const router = process.argv[2] || 'owrt2512';
const BASE = `http://localhost:${PORTS[router]}`;
const U = (p) => '/cgi-bin/luci/' + p;
const b = await chromium.launch();
const p = await b.newPage({ viewport: { width: 1400, height: 800 } });
await login(p, BASE);
const setLayout = async (v) => {
await p.evaluate((l) => localStorage.setItem('fs-layout', l), v);
await p.reload({ waitUntil: 'domcontentloaded' });
await p.waitForTimeout(1800);
};
/* click a sidebar/topmenu link; returns false if there was no such link (=> not an SPA hop) */
const click = async (path) => {
await p.evaluate(() => { window.__s = 1; });
const ok = await p.evaluate((h) => {
const a = document.querySelector(`a[href="${h}"]`);
if (!a) return false;
a.click(); return true;
}, U(path));
if (!ok) return { clicked: false, spa: false };
await p.waitForTimeout(2200);
return { clicked: true, spa: await p.evaluate(() => window.__s === 1) };
};
const state = () => p.evaluate(() => ({
path: location.pathname,
page: document.body.getAttribute('data-page'),
title: document.title,
win: Math.round(window.scrollY),
main: Math.round(document.getElementById('maincontent')?.scrollTop ?? -1),
docH: document.documentElement.scrollHeight,
tabs: [...document.querySelectorAll('#tabmenu ul.tabs')].map((ul) =>
[...ul.children].map((li) => li.className.replace('tabmenu-item-', '')).join(',')),
viewKids: document.getElementById('view')?.children.length ?? null
}));
const out = { router, A: {}, B: {}, C: {}, D: {} };
/* ---- A. scroll restoration, both layouts ---- */
for (const layout of ['top', 'sidebar']) {
await setLayout(layout);
await p.goto(BASE + U('admin/status/processes'), { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(3000);
const scroller = layout === 'top' ? 'window' : 'main';
const before = await p.evaluate(() => {
const sc = document.getElementById('maincontent');
const docScrolls = document.documentElement.scrollHeight - window.innerHeight > 300;
if (docScrolls) window.scrollTo(0, 400); else if (sc) sc.scrollTop = 400;
return { docScrolls, win: Math.round(window.scrollY), main: Math.round(sc?.scrollTop ?? -1),
h: document.documentElement.scrollHeight, mh: sc?.scrollHeight ?? -1 };
});
const hop = await click('admin/system/system');
await p.goBack();
await p.waitForTimeout(3000);
const back = await state();
/* and forward again */
await p.goForward();
await p.waitForTimeout(2000);
await p.goBack();
await p.waitForTimeout(3000);
const back2 = await state();
out.A[layout] = { scroller, before, hop, back: { win: back.win, main: back.main, page: back.page },
back2: { win: back2.win, main: back2.main } };
}
/* ---- B. history depth ---- */
await setLayout('sidebar');
await p.goto(BASE + U('admin/status/overview'), { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2500);
const chain = ['admin/system/system', 'admin/network/routes', 'admin/status/processes', 'admin/system/admin'];
const hops = [];
for (const c of chain) hops.push({ c, ...(await click(c)) });
const backs = [];
for (let i = 0; i < chain.length; i++) {
await p.goBack();
await p.waitForTimeout(1600);
const s = await state();
backs.push(s.path);
}
out.B = { hops, backs, landedOnStart: backs[backs.length - 1].endsWith('admin/status/overview') };
/* ---- C. tab strip ---- */
await p.goto(BASE + U('admin/system/system'), { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2500);
const beforeTabs = await state();
const tabHop = await p.evaluate(() => {
window.__s = 1;
const a = document.querySelector('#tabmenu ul.tabs li:not(.active) a');
if (!a) return null;
const href = a.getAttribute('href');
a.click();
return href;
});
await p.waitForTimeout(2500);
const afterTabs = await state();
const spaTab = await p.evaluate(() => window.__s === 1);
/* leave the app entirely: the strip must not survive onto a page with no tabs */
const away = await click('admin/status/processes');
const afterAway = await state();
out.C = { beforeTabs: beforeTabs.tabs, tabHop, spaTab,
afterTabs: { tabs: afterTabs.tabs, page: afterTabs.page, path: afterTabs.path },
away, afterAway: { tabs: afterAway.tabs, page: afterAway.page } };
/* ---- D. title host prefix over many hops, and after a view renames the document ---- */
await p.goto(BASE + U('admin/status/overview'), { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2500);
const t0 = (await state()).title;
const titles = [];
for (const c of ['admin/system/system', 'admin/network/routes', 'admin/system/system']) {
await click(c);
titles.push((await state()).title);
}
/* now a page that renames the tab, as several third-party apps do (log viewers, dashboards) */
await p.evaluate(() => { document.title = 'ACME Dashboard'; });
const afterRename = [];
for (const c of ['admin/network/routes', 'admin/system/system']) {
await click(c);
afterRename.push((await state()).title);
}
out.D = { t0, titles, afterRename };
console.log(JSON.stringify(out, null, 1));
await b.close();
@@ -0,0 +1,128 @@
/* SPA nuances, batch 2 — the ones a full load answers by construction:
* E. menu-level tabs (#tabmenu): navigate BY tab, and leave the app; the strip must follow.
* F. F5 on a URL reached by SPA hops, and a deep link straight to a tab.
* G. a rapid click storm, then a rapid Back storm: URL, <body data-page>, the menu highlight and
* the rendered view must all agree afterwards, with one poller set and no duplicate content.
* H. the session dying mid-SPA: a full load lands on the login form; what does a click do?
*/
import { chromium } from 'playwright';
import { login, PORTS } from './lib.mjs';
const router = process.argv[2] || 'owrt2512';
const BASE = `http://localhost:${PORTS[router]}`;
const U = (p) => '/cgi-bin/luci/' + p;
const b = await chromium.launch();
const p = await b.newPage({ viewport: { width: 1500, height: 900 } });
const errs = [];
p.on('pageerror', (e) => errs.push(String(e).slice(0, 140)));
await login(p, BASE);
const state = () => p.evaluate(() => ({
path: location.pathname,
page: document.body.getAttribute('data-page'),
title: document.title,
dispatch: (L.env.dispatchpath || []).join('/'),
tabs: [...document.querySelectorAll('#tabmenu ul.tabs')].map((ul) => ({
items: [...ul.children].map((li) => li.className.replace(/tabmenu-item-/, '')).join(' '),
active: [...ul.children].filter((li) => li.classList.contains('active'))
.map((li) => li.textContent.trim()).join(',')
})),
current: [...document.querySelectorAll('#topmenu a[aria-current="page"], .fs-sidebar a[aria-current="page"]')]
.map((a) => a.getAttribute('href')),
viewKids: document.getElementById('view')?.children.length ?? null,
forms: document.querySelectorAll('#view form').length,
poll: L.Poll?.queue?.length ?? null,
ivals: window.__fsViewIntervals?.size ?? null,
h1: [...document.querySelectorAll('#view h2, #view h3')].slice(0, 2).map((h) => h.textContent.trim())
}));
const click = async (sel) => {
await p.evaluate(() => { window.__s = 1; });
const ok = await p.evaluate((s) => { const a = document.querySelector(s); if (!a) return false; a.click(); return true; }, sel);
if (!ok) return { clicked: false };
await p.waitForTimeout(2200);
return { clicked: true, spa: await p.evaluate(() => window.__s === 1) };
};
const out = { router, E: {}, F: {}, G: {}, H: {} };
/* ---- E. find a page whose menu really produces a #tabmenu, then walk it by tab ---- */
const CANDIDATES = ['admin/services/banip', 'admin/network/firewall', 'admin/services/adblock',
'admin/system/admin', 'admin/services/ssclash', 'admin/status/nlbwmon'];
let tabbed = null;
for (const c of CANDIDATES) {
await p.goto(BASE + U(c), { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2500);
const s = await state();
if (s.tabs.length && s.tabs[0].items.split(' ').length > 1) { tabbed = { path: c, s }; break; }
}
if (tabbed) {
const first = await state();
const hop = await click('#tabmenu ul.tabs li:not(.active) a');
const afterTab = await state();
/* second hop, back to the first tab */
const hop2 = await click('#tabmenu ul.tabs li:not(.active) a');
const afterTab2 = await state();
/* leave the app for a page with no tabs at all */
const away = await click(`a[href="${U('admin/status/processes')}"]`);
const afterAway = await state();
out.E = { app: tabbed.path, first: first.tabs, hop, afterTab: { tabs: afterTab.tabs, page: afterTab.page, path: afterTab.path, kids: afterTab.viewKids },
hop2, afterTab2: { tabs: afterTab2.tabs, page: afterTab2.page }, away,
afterAway: { tabs: afterAway.tabs, page: afterAway.page } };
} else out.E = { app: null, note: 'no candidate produced a #tabmenu' };
/* ---- F. F5 after SPA hops, and a deep link straight to a tab ---- */
await p.goto(BASE + U('admin/status/overview'), { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2500);
for (const c of ['admin/system/system', 'admin/network/routes'])
await click(`a[href="${U(c)}"]`);
const beforeF5 = await state();
await p.reload({ waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2800);
const afterF5 = await state();
const deep = tabbed ? (out.E.afterTab?.path || null) : null;
let deepState = null;
if (deep) {
await p.goto(BASE + deep, { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2800);
deepState = await state();
}
out.F = { beforeF5: { path: beforeF5.path, page: beforeF5.page, title: beforeF5.title },
afterF5: { path: afterF5.path, page: afterF5.page, title: afterF5.title, kids: afterF5.viewKids },
deep, deepState: deepState && { page: deepState.page, tabs: deepState.tabs, kids: deepState.viewKids } };
/* ---- G. click storm, then Back storm ---- */
await p.goto(BASE + U('admin/status/overview'), { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2500);
const STORM = ['admin/system/system', 'admin/network/routes', 'admin/status/processes',
'admin/system/admin', 'admin/network/dhcp', 'admin/system/mounts'];
const warn = [];
p.on('console', (m) => { if (m.type() === 'warning' && /footstrap/.test(m.text())) warn.push(m.text().slice(0, 120)); });
for (let i = 0; i < STORM.length; i++) {
await p.evaluate((h) => { const a = document.querySelector(`a[href="${h}"]`); if (a) a.click(); }, U(STORM[i]));
await p.waitForTimeout(60 + (i % 3) * 90); /* 60-240 ms: mid-require, deliberately */
}
await p.waitForTimeout(4000);
const afterStorm = await state();
for (let i = 0; i < 4; i++) { await p.goBack(); await p.waitForTimeout(120); }
await p.waitForTimeout(4000);
const afterBackStorm = await state();
out.G = { afterStorm, afterBackStorm, warn, errs: errs.slice(-6) };
/* ---- H. session expiry mid-SPA ---- */
await p.goto(BASE + U('admin/status/overview'), { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2500);
await p.context().clearCookies();
const dead = await click(`a[href="${U('admin/system/system')}"]`);
await p.waitForTimeout(3000);
const afterDead = await p.evaluate(() => ({
path: location.pathname,
modal: document.querySelector('.modal, #modal_overlay.active')?.textContent?.trim().slice(0, 120) ?? null,
loginForm: !!document.querySelector('input[name="luci_password"]'),
viewText: (document.getElementById('view')?.textContent || '').trim().slice(0, 120)
}));
out.H = { dead, afterDead };
console.log(JSON.stringify(out, null, 1));
await b.close();
@@ -0,0 +1,157 @@
/* SPA nuances, batch 3 — chrome that is OPEN when the navigation happens, and view-owned widgets
* that a full load initialises but an SPA arrival may not.
* I. a view's OWN tab strip (.cbi-tabmenu, System → General/Logging/Time) after an SPA arrival:
* does clicking an internal tab still switch panes?
* J. the search palette: Enter navigates — does the overlay close, does focus land somewhere sane,
* and does the body keep any scroll lock?
* K. the Appearance popover left open across a navigation.
* L. the phone bar (390px): a section dropdown open, then a page tap — the panel must not survive.
* M. a modal opened by a view, then Back.
*/
import { chromium } from 'playwright';
import { login, PORTS } from './lib.mjs';
const router = process.argv[2] || 'owrt2512';
const BASE = `http://localhost:${PORTS[router]}`;
const U = (p) => '/cgi-bin/luci/' + p;
const b = await chromium.launch();
const p = await b.newPage({ viewport: { width: 1500, height: 900 } });
const errs = [];
p.on('pageerror', (e) => errs.push(String(e).split('\n')[0].slice(0, 120)));
await login(p, BASE);
const out = { router, I: {}, J: {}, K: {}, L: {}, M: {} };
const spa = () => p.evaluate(() => window.__s === 1);
const mark = () => p.evaluate(() => { window.__s = 1; });
const clickHref = async (path) => {
await mark();
const ok = await p.evaluate((h) => { const a = document.querySelector(`a[href="${h}"]`); if (!a) return false; a.click(); return true; }, U(path));
if (!ok) {
await p.evaluate((h) => { const a = document.createElement('a'); a.href = h; document.getElementById('view').appendChild(a); a.click(); }, U(path));
}
await p.waitForTimeout(2400);
return { spa: await spa() };
};
/* ---- I. a view's own tab strip after an SPA arrival ---- */
await p.goto(BASE + U('admin/status/processes'), { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2400);
await clickHref('admin/system/system');
const tabState = async () => p.evaluate(() => {
const strip = document.querySelector('#view .cbi-tabmenu');
const panes = [...document.querySelectorAll('#view .cbi-tabcontainer')];
return {
strip: strip ? [...strip.children].map((li) => (li.classList.contains('cbi-tab') ? '*' : '') + li.textContent.trim()).join('|') : null,
visible: panes.filter((x) => x.getClientRects().length).map((x) => x.getAttribute('data-tab')).join(','),
panes: panes.length
};
});
const tBefore = await tabState();
const tClicked = await p.evaluate(() => {
const li = [...document.querySelectorAll('#view .cbi-tabmenu > li')].find((x) => !x.classList.contains('cbi-tab'));
if (!li) return null;
const a = li.querySelector('a') || li;
a.click();
return li.textContent.trim();
});
await p.waitForTimeout(900);
const tAfter = await tabState();
out.I = { tBefore, tClicked, tAfter, switched: tBefore.visible !== tAfter.visible };
/* ---- J. search palette ---- */
await p.goto(BASE + U('admin/status/processes'), { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2400);
await p.keyboard.press('Control+k');
await p.waitForTimeout(500);
const palOpen = await p.evaluate(() => !!document.getElementById('fs-search-ov'));
await p.keyboard.type('routes');
await p.waitForTimeout(600);
await mark();
await p.keyboard.press('Enter');
await p.waitForTimeout(2600);
out.J = {
palOpen,
spa: await spa(),
after: await p.evaluate(() => ({
path: location.pathname,
ovPresent: !!document.getElementById('fs-search-ov'),
ovVisible: !!document.getElementById('fs-search-ov')?.getClientRects().length,
bodyClass: document.body.className,
bodyOverflow: getComputedStyle(document.body).overflow,
active: document.activeElement ? (document.activeElement.id || document.activeElement.className || document.activeElement.tagName) : null
}))
};
/* ---- K. Appearance popover open across a navigation ---- */
await p.goto(BASE + U('admin/status/processes'), { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2400);
const appOpened = await p.evaluate(() => {
const b = document.getElementById('fs-appearance');
if (b) { b.click(); return b.id || b.className; }
return null;
});
await p.waitForTimeout(600);
const popSel = '.fs-pop, .fs-appearance-pop, [role="dialog"]';
const popBefore = await p.evaluate((s) => document.querySelectorAll(s).length
+ '/' + [...document.querySelectorAll(s)].filter((e) => e.getClientRects().length).length, popSel);
const kHop = await clickHref('admin/system/system');
const popAfter = await p.evaluate((s) => document.querySelectorAll(s).length
+ '/' + [...document.querySelectorAll(s)].filter((e) => e.getClientRects().length).length, popSel);
out.K = { appOpened, popBefore, kHop, popAfter };
/* ---- L. phone bar: a section dropdown open, then a page tap ---- */
await p.setViewportSize({ width: 390, height: 780 });
await p.goto(BASE + U('admin/status/processes'), { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2600);
const opened = await p.evaluate(() => {
const a = document.querySelector('#topmenu > li > a');
if (!a) return null;
a.click();
return { txt: a.textContent.trim(), open: document.querySelectorAll('#topmenu > li.open').length };
});
await p.waitForTimeout(500);
const openBefore = await p.evaluate(() => document.querySelectorAll('#topmenu > li.open').length);
const target = await p.evaluate(() => {
const a = document.querySelector('#topmenu > li.open a[href*="/cgi-bin/luci/admin/"][href$="system"], #topmenu > li.open ul a[href]');
return a ? a.getAttribute('href') : null;
});
await mark();
if (target) await p.evaluate((h) => document.querySelector(`#topmenu li.open a[href="${h}"]`)?.click(), target);
await p.waitForTimeout(2600);
out.L = { opened, openBefore, target, spa: await spa(),
after: await p.evaluate(() => ({
path: location.pathname,
open: document.querySelectorAll('#topmenu > li.open').length,
expanded: document.querySelectorAll('#topmenu > li > a[aria-expanded="true"]').length,
scrollLock: getComputedStyle(document.body).overflow,
hOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth
})) };
/* ---- M. a modal open, then Back ---- */
await p.setViewportSize({ width: 1500, height: 900 });
await p.goto(BASE + U('admin/status/processes'), { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2400);
await clickHref('admin/system/system');
await p.evaluate(() => L.ui.showModal('probe', [ E('p', {}, 'modal body') ]));
await p.waitForTimeout(400);
const mBefore = await p.evaluate(() => ({
modal: document.querySelectorAll('body > .modal, #modal_overlay .modal').length,
overlayActive: document.getElementById('modal_overlay')?.classList.contains('active') ?? null,
bodyClass: document.body.className
}));
await p.goBack();
await p.waitForTimeout(2600);
const mAfter = await p.evaluate(() => ({
path: location.pathname,
modal: document.querySelectorAll('body > .modal, #modal_overlay .modal').length,
overlayActive: document.getElementById('modal_overlay')?.classList.contains('active') ?? null,
bodyClass: document.body.className,
overflow: getComputedStyle(document.body).overflow
}));
out.M = { mBefore, mAfter };
out.errs = [...new Set(errs)].slice(0, 8);
console.log(JSON.stringify(out, null, 1));
await b.close();
@@ -0,0 +1,106 @@
/* Batch 3, redone where the selectors missed:
* I'. a view's OWN tabs after an SPA arrival — measured by which FIELDS are on screen, not by a
* pane class that page may not use.
* K'. what is still visible after navigating with the Appearance popover open.
* L'. the phone bar: open a section that actually HAS a submenu, then tap a page inside it.
*/
import { chromium } from 'playwright';
import { login, PORTS } from './lib.mjs';
const BASE = `http://localhost:${PORTS[process.argv[2] || 'owrt2512']}`;
const U = (p) => '/cgi-bin/luci/' + p;
const b = await chromium.launch();
const p = await b.newPage({ viewport: { width: 1500, height: 900 } });
await login(p, BASE);
const out = {};
const clickHref = async (path) => {
await p.evaluate(() => { window.__s = 1; });
await p.evaluate((h) => {
const a = document.querySelector(`a[href="${h}"]`);
if (a) return a.click();
const s = document.createElement('a'); s.href = h; document.getElementById('view').appendChild(s); s.click();
}, U(path));
await p.waitForTimeout(2400);
return p.evaluate(() => window.__s === 1);
};
/* fields actually on screen, as a fingerprint of which tab pane is showing */
const fields = () => p.evaluate(() => {
const vis = [...document.querySelectorAll('#view .cbi-value-title, #view label')]
.filter((el) => el.getClientRects().length).map((el) => el.textContent.trim()).slice(0, 12);
return { n: vis.length, sample: vis.slice(0, 5) };
});
/* ---- I'. view-owned tabs, SPA arrival vs full load ---- */
const tabRun = async (spaArrival) => {
if (spaArrival) {
await p.goto(BASE + U('admin/status/processes'), { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2400);
await clickHref('admin/system/system');
} else {
await p.goto(BASE + U('admin/system/system'), { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2800);
}
const before = await fields();
const clicked = await p.evaluate(() => {
const li = [...document.querySelectorAll('#view .cbi-tabmenu > li')].find((x) => !x.classList.contains('cbi-tab'));
if (!li) return null;
(li.querySelector('a') || li).click();
return li.textContent.trim();
});
await p.waitForTimeout(900);
const after = await fields();
return { clicked, before, after, switched: JSON.stringify(before.sample) !== JSON.stringify(after.sample) };
};
out.I_full = await tabRun(false);
out.I_spa = await tabRun(true);
/* ---- K'. the Appearance popover across a navigation ---- */
await p.goto(BASE + U('admin/status/processes'), { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2400);
await p.evaluate(() => document.getElementById('fs-appearance')?.click());
await p.waitForTimeout(600);
const popDump = () => p.evaluate(() => [...document.querySelectorAll('.fs-pop, .fs-appearance-pop, [role="dialog"], .fs-search-ov')]
.map((e) => ({ id: e.id, cls: e.className, vis: !!e.getClientRects().length, hidden: e.hidden,
expanded: document.getElementById('fs-appearance')?.getAttribute('aria-expanded') })));
out.K_before = await popDump();
out.K_spa = await clickHref('admin/system/system');
out.K_after = await popDump();
/* the same thing on the way BACK, which takes the popstate path */
await p.evaluate(() => document.getElementById('fs-appearance')?.click());
await p.waitForTimeout(500);
await p.goBack();
await p.waitForTimeout(2400);
out.K_afterBack = await popDump();
/* ---- L'. phone bar section dropdown ---- */
await p.setViewportSize({ width: 390, height: 780 });
await p.goto(BASE + U('admin/status/processes'), { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2800);
const sect = await p.evaluate(() => {
const li = [...document.querySelectorAll('#topmenu > li')].find((x) => x.querySelector('ul a[href]'));
if (!li) return null;
(li.querySelector(':scope > a') || li).click();
return { name: li.textContent.trim().slice(0, 20),
open: li.classList.contains('open'),
href: li.querySelector('ul a[href]')?.getAttribute('href') };
});
await p.waitForTimeout(600);
const openedCount = await p.evaluate(() => document.querySelectorAll('#topmenu > li.open').length);
let lSpa = null;
if (sect?.href) {
await p.evaluate(() => { window.__s = 1; });
await p.evaluate((h) => document.querySelector(`#topmenu li.open a[href="${h}"]`)?.click(), sect.href);
await p.waitForTimeout(2600);
lSpa = await p.evaluate(() => window.__s === 1);
}
out.L = { sect, openedCount, lSpa, after: await p.evaluate(() => ({
path: location.pathname,
open: document.querySelectorAll('#topmenu > li.open').length,
expanded: document.querySelectorAll('#topmenu > li > a[aria-expanded="true"]').length,
hOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth
})) };
console.log(JSON.stringify(out, null, 1));
await b.close();
@@ -0,0 +1,123 @@
/* Verify the popover/palette close-on-traversal fix, and drive the phone bar by what the DOM
* actually is rather than by a guessed selector. */
import { chromium } from 'playwright';
import { login, PORTS } from './lib.mjs';
const BASE = `http://localhost:${PORTS[process.argv[2] || 'owrt2512']}`;
const U = (p) => '/cgi-bin/luci/' + p;
const b = await chromium.launch();
const p = await b.newPage({ viewport: { width: 1500, height: 900 } });
await login(p, BASE);
const out = {};
const dump = () => p.evaluate(() => ({
pop: (() => { const e = document.getElementById('fs-appearance-pop');
return e ? { hidden: e.hidden, vis: !!e.getClientRects().length,
exp: document.getElementById('fs-appearance')?.getAttribute('aria-expanded') } : null; })(),
ov: (() => { const e = document.getElementById('fs-search-ov');
return e ? { hidden: e.hidden, vis: !!e.getClientRects().length,
exp: document.getElementById('fs-search-btn')?.getAttribute('aria-expanded') } : null; })(),
active: document.activeElement ? (document.activeElement.id || document.activeElement.className || document.activeElement.tagName) : null,
path: location.pathname
}));
const hop = async (path) => {
await p.evaluate((h) => {
const a = document.querySelector(`a[href="${h}"]`);
if (a) return a.click();
const s = document.createElement('a'); s.href = h; document.getElementById('view').appendChild(s); s.click();
}, U(path));
await p.waitForTimeout(2400);
};
/* --- Appearance popover across Back and Forward --- */
await p.goto(BASE + U('admin/status/processes'), { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2400);
await hop('admin/system/system');
await p.evaluate(() => document.getElementById('fs-appearance').click());
await p.waitForTimeout(500);
out.popOpen = await dump();
await p.goBack();
await p.waitForTimeout(2400);
out.popAfterBack = await dump();
await p.evaluate(() => document.getElementById('fs-appearance').click());
await p.waitForTimeout(500);
await p.goForward();
await p.waitForTimeout(2400);
out.popAfterForward = await dump();
/* it must still OPEN afterwards */
await p.evaluate(() => document.getElementById('fs-appearance').click());
await p.waitForTimeout(500);
out.popReopen = await dump();
await p.keyboard.press('Escape');
await p.waitForTimeout(300);
out.popEscape = await dump();
/* --- search palette across Back --- */
await p.goto(BASE + U('admin/status/processes'), { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2400);
await hop('admin/system/system');
await p.keyboard.press('Control+k');
await p.waitForTimeout(500);
out.ovOpen = await dump();
await p.goBack();
await p.waitForTimeout(2400);
out.ovAfterBack = await dump();
/* still usable: open, type, Enter */
await p.keyboard.press('Control+k');
await p.waitForTimeout(400);
await p.keyboard.type('routes');
await p.waitForTimeout(500);
await p.keyboard.press('Enter');
await p.waitForTimeout(2500);
out.ovAfterPick = await dump();
/* --- the phone bar, described rather than guessed --- */
await p.setViewportSize({ width: 390, height: 780 });
await p.goto(BASE + U('admin/status/processes'), { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2800);
out.bar = await p.evaluate(() => ({
narrow: document.documentElement.hasAttribute('data-narrow'),
layout: document.documentElement.getAttribute('data-layout'),
topmenuItems: [...document.querySelectorAll('#topmenu > li')].map((li) => ({
t: li.textContent.trim().slice(0, 14),
sub: li.querySelectorAll('ul a[href]').length,
href: li.querySelector(':scope > a')?.getAttribute('href') || null
})),
sidebarItems: document.querySelectorAll('.fs-sidebar > ul.nav > li').length
}));
const secIdx = (out.bar.topmenuItems || []).findIndex((x) => x.sub > 0);
if (secIdx >= 0) {
const opened = await p.evaluate((i) => {
const li = document.querySelectorAll('#topmenu > li')[i];
const a = li.querySelector(':scope > a');
a.click();
return { open: li.classList.contains('open'), exp: a.getAttribute('aria-expanded'),
href: li.querySelector('ul a[href]')?.getAttribute('href') };
}, secIdx);
await p.waitForTimeout(600);
const openCount = await p.evaluate(() => document.querySelectorAll('#topmenu > li.open').length);
await p.evaluate(() => { window.__s = 1; });
if (opened.href)
await p.evaluate((h) => document.querySelector(`#topmenu li.open a[href="${h}"]`)?.click(), opened.href);
await p.waitForTimeout(2600);
out.phone = { opened, openCount, spa: await p.evaluate(() => window.__s === 1),
after: await p.evaluate(() => ({
path: location.pathname,
open: document.querySelectorAll('#topmenu > li.open').length,
exp: document.querySelectorAll('#topmenu > li > a[aria-expanded="true"]').length,
hOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
bodyOverflow: getComputedStyle(document.body).overflow
})) };
/* and the same section opened, then BACK */
await p.evaluate((i) => document.querySelectorAll('#topmenu > li')[i].querySelector(':scope > a').click(), secIdx);
await p.waitForTimeout(500);
const beforeBack = await p.evaluate(() => document.querySelectorAll('#topmenu > li.open').length);
await p.goBack();
await p.waitForTimeout(2500);
out.phoneBack = { beforeBack, after: await p.evaluate(() => ({
path: location.pathname, open: document.querySelectorAll('#topmenu > li.open').length })) };
}
console.log(JSON.stringify(out, null, 1));
await b.close();
@@ -0,0 +1,200 @@
/* Issue #51: luci-app-splify2's page under this theme — the Rail `aside` (`hidden lg:flex`) stays
* hidden, and the Home grid's right column is clipped off the right edge.
*
* Per viewport: was splify-index.css re-hosted; computed display of each `aside` and every CSS rule
* (with its cascade layers, via CDP) that declares `display` on it; the `.grid` carrying
* `xl:grid-cols-*` — its grid-template-columns, its width against #splify-root/.fs-content/#view,
* scrollWidth vs clientWidth up the ancestor chain; the element sticking out furthest past the right
* edge and the rules that size it. Then variant A applied in the CSSOM (cascade.css's `.hidden.hidden`
* gets `:not([class*=":"])`, no repo file touched) and everything measured again.
*
* node .claude/tooling/splify2.mjs owrt2512 [label] # label names the artefacts (footstrap|bootstrap)
*/
import { chromium } from 'playwright';
import { writeFileSync, mkdirSync } from 'node:fs';
import { PORTS, login } from './lib.mjs';
const stand = process.argv[2] || 'owrt2512';
const label = process.argv[3] || 'footstrap';
const BASE = 'http://localhost:' + PORTS[stand];
const OUT = new URL('../../../../../../tmp/task-0051-splify2-hidden/', import.meta.url).pathname;
mkdirSync(OUT, { recursive: true });
const PROPS = ['display', 'width', 'min-width', 'max-width', 'grid-template-columns', 'flex', 'flex-shrink',
'flex-basis', 'overflow', 'overflow-x', 'white-space', 'margin', 'margin-left', 'margin-right', 'padding',
'inline-size', 'min-inline-size', 'box-sizing', 'contain', 'position', 'left', 'right', 'inset'];
const MEASURE = () => {
const path = (el) => {
if (!el) return null;
let s = el.tagName.toLowerCase();
if (el.id) s += '#' + el.id;
const c = typeof el.className === 'string' ? el.className.trim() : '';
if (c) s += '.' + c.split(/\s+/).slice(0, 8).join('.');
return s;
};
const r = (el) => { const b = el.getBoundingClientRect(); return { l: Math.round(b.left), r: Math.round(b.right), w: Math.round(b.width) }; };
const cs = (el, p) => getComputedStyle(el).getPropertyValue(p);
const vw = document.documentElement.clientWidth;
const out = { vw, theme: (document.querySelector('link[href*="cascade.css"]') ? 'footstrap' : 'other') };
out.layered = [...document.querySelectorAll('style[data-fs-layered]')].map((s) => (s.textContent || '').slice(0, 160));
out.splifyLinks = [...document.querySelectorAll('link[rel~=stylesheet]')].filter((l) => /splify/.test(l.href))
.map((l) => ({ href: l.href.replace(location.origin, ''), disabled: l.disabled, media: l.media }));
out.asides = [...document.querySelectorAll('#splify-root aside, aside')].map((a, i) => {
a.setAttribute('data-probe', 'aside' + i);
return { i, path: path(a), display: cs(a, 'display'), ...r(a) };
});
const grid = [...document.querySelectorAll('[class*="xl:grid-cols"]')][0];
if (grid) {
grid.setAttribute('data-probe', 'grid');
out.grid = { path: path(grid), gtc: cs(grid, 'grid-template-columns'), display: cs(grid, 'display'), ...r(grid),
children: [...grid.children].map((c, i) => { c.setAttribute('data-probe', 'gridkid' + i); return { i, path: path(c), ...r(c), minW: cs(c, 'min-width'), sw: c.scrollWidth, cw: c.clientWidth }; }) };
const chain = [];
for (let e = grid; e; e = e.parentElement)
chain.push({ path: path(e), ...r(e), cw: e.clientWidth, sw: e.scrollWidth, ox: cs(e, 'overflow-x'), minW: cs(e, 'min-width'), w: cs(e, 'width') });
out.chain = chain;
}
for (const [k, sel] of [['splifyRoot', '#splify-root'], ['fsContent', '.fs-content'], ['view', '#view'], ['maincontent', '#maincontent']]) {
const e = document.querySelector(sel); out[k] = e ? { ...r(e), cw: e.clientWidth, sw: e.scrollWidth } : null;
}
// Deepest-and-furthest element past the right edge of the viewport (or of #splify-root's box).
// `.sp-root` is overflow:hidden, so "clipped" means past ITS right edge, not the viewport's.
const root = document.querySelector('#splify-root') || document.body;
const clip = document.querySelector('.sp-root') || root;
const rootR = Math.min(vw, clip.getBoundingClientRect().right);
let worst = null;
for (const e of root.querySelectorAll('*')) {
if (!e.getClientRects().length) continue;
const b = e.getBoundingClientRect();
if (b.right > rootR + 1 && (!worst || b.right > worst.right + 0.5 || (Math.abs(b.right - worst.right) < 0.5 && e.contains && worst.el.contains(e)))) worst = { el: e, right: b.right };
}
if (worst) { worst.el.setAttribute('data-probe', 'worst'); out.worst = { path: path(worst.el), ...r(worst.el), minW: cs(worst.el, 'min-width'), w: cs(worst.el, 'width') }; }
out.rootRight = Math.round(rootR);
const btn = [...document.querySelectorAll('button, a')].find((b) => /Добавить правило|Add rule/i.test(b.textContent));
if (btn) { btn.setAttribute('data-probe', 'addrule'); out.addRule = { path: path(btn), ...r(btn), visible: b2v(btn) }; }
const outs = [...document.querySelectorAll('h1,h2,h3,h4,div,span')].find((h) => h.children.length === 0 && /^(Выходы|Outbounds|Outputs)$/.test(h.textContent.trim()));
if (outs) out.outputsHeading = { path: path(outs), ...r(outs) };
function b2v(el) { const b = el.getBoundingClientRect(); return b.right <= vw && b.width > 0; }
out.docOverflow = document.documentElement.scrollWidth - document.documentElement.clientWidth;
out.bodyOverflow = document.body.scrollWidth - document.body.clientWidth;
return out;
};
async function cdpRules(cdp, probe) {
const { root } = await cdp.send('DOM.getDocument', { depth: 0 });
const { nodeId } = await cdp.send('DOM.querySelector', { nodeId: root.nodeId, selector: `[data-probe="${probe}"]` });
if (!nodeId) return null;
const m = await cdp.send('CSS.getMatchedStylesForNode', { nodeId });
const rows = [];
for (const mr of m.matchedCSSRules || []) {
const rule = mr.rule;
const decl = rule.style.cssProperties.filter((p) => PROPS.includes(p.name) && !p.disabled && p.value !== undefined && (p.text || p.implicit === false || true));
const uniq = [...new Map(decl.map((p) => [p.name + p.value, p])).values()].filter((p) => !p.implicit || p.name === 'display');
if (!uniq.length) continue;
rows.push({
sel: rule.selectorList.text.slice(0, 140), origin: rule.origin,
sheet: (sheets.get(rule.styleSheetId) || '').replace(BASE, ''),
layers: (rule.layers || []).map((l) => l.text).join(' > '),
media: (rule.media || []).map((x) => x.text).join(' | '),
decl: uniq.map((p) => `${p.name}:${p.value}${p.important ? '!' : ''}`).join('; ')
});
}
return rows;
}
// cascade.css's `.hidden.hidden` → `.hidden.hidden:not([class*=":"])`, found by walking the CSSOM.
const VARIANT_A = () => {
const hits = [];
const walk = (rules, where) => {
for (const rule of rules) {
if (rule.selectorText === '.hidden.hidden') { rule.selectorText = '.hidden.hidden:not([class*=":"])'; hits.push({ where, now: rule.selectorText }); }
if (rule.cssRules) walk(rule.cssRules, where);
if (rule.styleSheet) { try { walk(rule.styleSheet.cssRules, where + ' @import ' + rule.href); } catch (e) {} }
}
};
for (const s of document.styleSheets) { try { walk(s.cssRules, s.href || (s.ownerNode && s.ownerNode.tagName)); } catch (e) {} }
return hits;
};
const sheets = new Map();
const report = { stand, label, when: new Date().toISOString(), runs: [] };
const b = await chromium.launch();
for (const [W, H] of [[1920, 1080], [1440, 900]]) {
const ctx = await b.newContext({ viewport: { width: W, height: H } });
const p = await ctx.newPage();
const errs = []; p.on('pageerror', (e) => errs.push(String(e).slice(0, 200)));
/* Without steer the app renders its "engine not installed" gate (`r.build && !r.build.present`),
* not Rail + Home. Answer `splify2.engine` as present in the browser only — nothing is installed. */
await p.route('**/ubus/**', async (route) => {
const req = route.request();
let body; try { body = JSON.parse(req.postData() || 'null'); } catch (e) { body = null; }
const calls = Array.isArray(body) ? body : body ? [body] : [];
// DATA=1 also answers `status` with outputs, so the Home grid's right column ("Выходы") has content.
const FAKE = { engine: { present: true, vless: true, enabled: true, running: true } };
if (process.env.DATA === '1') FAKE.status = { ok: true, outputs: {
direct: { kind: 'direct' },
'wg-home-office-backup-tunnel': { kind: 'interface', iface: 'wg-home-office-backup-tunnel', up: true },
awg0: { kind: 'interface', iface: 'awg0', up: false },
'vless-reality-frankfurt-de-01': { kind: 'vless', up: true, latency_ms: 42 },
'vless-nl-amsterdam-02': { kind: 'vless', up: true, latency_ms: 71 }
}, warnings: [] };
// The store reads Home's data from `live` (`t.status`), not from `status`.
if (FAKE.status) FAKE.live = { ok: true, status: FAKE.status };
const hit = (c) => c && c.params && c.params[1] === 'splify2' && FAKE[c.params[2]];
if (!calls.some(hit)) return route.continue();
const resp = await route.fetch();
let json = await resp.json();
const arr = Array.isArray(json) ? json : [json];
for (const r of arr) {
const c = calls.find((x) => x.id === r.id);
if (hit(c)) r.result = [0, FAKE[c.params[2]]];
}
await route.fulfill({ response: resp, body: JSON.stringify(Array.isArray(json) ? arr : arr[0]) });
});
const cdp = await ctx.newCDPSession(p);
cdp.on('CSS.styleSheetAdded', (e) => sheets.set(e.header.styleSheetId, e.header.sourceURL || (e.header.isInline ? 'inline' : e.header.origin)));
// lib.mjs's login clicks `input[type=submit]`, which stock bootstrap's sysauth form does not have.
if (label.startsWith('bootstrap')) {
await p.goto(BASE + '/cgi-bin/luci/', { waitUntil: 'domcontentloaded' });
if (await p.locator('input[name="luci_password"]').count()) {
await p.fill('input[name="luci_username"]', 'root');
await p.fill('input[name="luci_password"]', '');
await Promise.all([p.waitForNavigation({ waitUntil: 'domcontentloaded' }), p.press('input[name="luci_password"]', 'Enter')]);
}
await p.waitForTimeout(2000);
} else await login(p, BASE);
await p.goto(BASE + '/cgi-bin/luci/admin/services/splify2', { waitUntil: 'domcontentloaded' });
const ok = await p.waitForFunction(() => { const r = document.querySelector('#splify-root'); return r && r.querySelectorAll('*').length > 30; }, null, { timeout: 30000 }).then(() => true, () => false);
await p.waitForTimeout(3000);
await cdp.send('DOM.enable'); await cdp.send('CSS.enable');
const run = { W, H, rendered: ok, errs };
const tag = `${label}-${W}`;
run.before = await p.evaluate(MEASURE);
run.before.rules = {};
for (const k of ['aside0', 'aside1', 'grid', 'gridkid0', 'gridkid1', 'worst', 'addrule']) run.before.rules[k] = await cdpRules(cdp, k);
await p.screenshot({ path: OUT + `${tag}-before.png` });
run.variantA = await p.evaluate(VARIANT_A);
await p.waitForTimeout(800);
run.after = await p.evaluate(MEASURE);
run.after.rules = {};
for (const k of ['aside0', 'grid', 'gridkid1', 'worst', 'addrule']) run.after.rules[k] = await cdpRules(cdp, k);
await p.screenshot({ path: OUT + `${tag}-variantA.png` });
report.runs.push(run);
await ctx.close();
}
await b.close();
const file = OUT + `probe-${label}.json`;
writeFileSync(file, JSON.stringify(report, null, 1));
for (const run of report.runs) {
const s = (m) => ({ asides: m.asides.map((a) => `${a.display}/${a.w}`).join(','), gtc: m.grid && m.grid.gtc, gridW: m.grid && m.grid.w,
root: m.splifyRoot && m.splifyRoot.w, fs: m.fsContent && m.fsContent.w, worst: m.worst && `${m.worst.path.slice(0, 80)} r=${m.worst.r}`, addRule: m.addRule && `r=${m.addRule.r} vis=${m.addRule.visible}`, docOv: m.docOverflow, layered: m.layered.length });
console.log(run.W, 'rendered', run.rendered, 'errs', run.errs.length, '\n before', JSON.stringify(s(run.before)), '\n variantA hits', run.variantA.length, '\n after ', JSON.stringify(s(run.after)));
}
console.log('->', file);
@@ -0,0 +1,47 @@
/* Catch the two anomalies the soak turned up, with the page and the offending elements attached:
* - a sibling of #view left inside .fs-content (fs-router sweeps those on every navigation, so a
* survivor is either the incoming view's own or a hole in the sweep);
* - a runtime notification in #maincontent outliving the page that raised it;
* - and the stack of every pageerror, to tell a theme fault from an app's own. */
import { chromium } from 'playwright';
import { login, PORTS } from './lib.mjs';
const router = process.argv[2] || 'owrt2512';
const HOPS = Number(process.argv[3] || 200);
const BASE = `http://localhost:${PORTS[router]}`;
const PROBE = `(() => ({
page: document.body.getAttribute('data-page'),
stray: [...document.querySelectorAll('.fs-content > *')]
.filter((e) => e.id !== 'view' && e.id !== 'tabmenu' && !e.classList.contains('alert-message') && e.nodeName !== 'NOSCRIPT')
.map((e) => e.nodeName + (e.id ? '#' + e.id : '') + (e.className && typeof e.className === 'string' ? '.' + e.className.trim().split(/\\s+/).join('.') : '') + ' :: ' + (e.textContent || '').trim().slice(0, 50)),
notif: [...document.querySelectorAll('#maincontent > .alert-message')].map((e) => (e.textContent || '').trim().slice(0, 70))
}))()`;
const b = await chromium.launch();
const p = await b.newPage({ viewport: { width: 1600, height: 1000 } });
let current = '(start)';
const faults = [];
p.on('pageerror', (e) => faults.push({ kind: 'pageerror', page: current, msg: e.message.split('\n')[0], stack: (e.stack || '').split('\n').slice(1, 4).map((s) => s.trim()) }));
await login(p, BASE);
await p.evaluate(() => document.querySelectorAll('#topmenu > li.has-sub:not(.open) > a').forEach((a) => a.click()));
await p.waitForTimeout(600);
const hits = [];
for (let i = 0; i < HOPS; i++) {
const hrefs = await p.evaluate(() => [...document.querySelectorAll('#topmenu a[href^="/cgi-bin/luci/"], #tabmenu a[href^="/cgi-bin/luci/"]')]
.map((a) => a.getAttribute('href')).filter((h) => !/logout/.test(h)));
if (!hrefs.length) break;
const href = hrefs[(i * 7 + 3) % hrefs.length];
const from = current;
await p.evaluate((h) => document.querySelector(`a[href="${h}"]`)?.click(), href);
await p.waitForTimeout(1400);
const r = await p.evaluate(PROBE);
current = r.page;
if (r.stray.length || r.notif.length) hits.push({ hop: i, from, to: r.page, href, stray: r.stray, notif: r.notif });
if (i % 25 === 24) process.stderr.write(String(i + 1) + ' ');
}
process.stderr.write('\n');
console.log(JSON.stringify({ router, hops: HOPS, hits, faults }, null, 1));
await b.close();
@@ -0,0 +1,79 @@
/* Does a page's polling really stop when you leave it?
*
* navigate() flushes L.Poll's queue and clears every setInterval a view set. Neither reaches a
* RECURSIVE setTimeout — the other way an app tails a log — and a full load would have killed that
* too. So measure it from the outside: sit on a neutral page and count the RPCs the browser actually
* sends, first as a baseline, then after having visited each app.
*
* Attribution is by ubus method + object out of the POST body, so a stray poller names itself.
*/
import { chromium } from 'playwright';
import { login, PORTS } from './lib.mjs';
const BASE = `http://localhost:${PORTS[process.argv[2] || 'owrt2512']}`;
const U = (p) => '/cgi-bin/luci/' + p;
const WATCH_MS = Number(process.argv[3] || 15000);
const NEUTRAL = 'admin/network/routes'; /* no poller of its own */
const APPS = ['admin/services/podkop', 'admin/services/banip/overview', 'admin/services/adblock/overview',
'admin/services/ssclash/log', 'admin/system/filemanager', 'admin/status/realtime/load',
'admin/status/processes', 'admin/modem/3ginfo-lite/3gdetail', 'admin/network/mwan3/globals',
'admin/services/https-dns-proxy'];
const b = await chromium.launch();
const p = await b.newPage({ viewport: { width: 1500, height: 950 } });
await login(p, BASE);
const calls = [];
let recording = false;
p.on('request', (r) => {
if (!recording) return;
const url = r.url();
let tag = url.replace(BASE, '');
const post = r.postData();
if (post && post.indexOf('"method"') >= 0) {
const m = [...post.matchAll(/"method"\s*:\s*"([^"]+)"/g)].map((x) => x[1]);
const o = [...post.matchAll(/"object"\s*:\s*"([^"]+)"/g)].map((x) => x[1]);
tag = 'ubus:' + (o.length ? o.join('+') + '/' : '') + m.join('+');
}
calls.push(tag.slice(0, 90));
});
const hop = async (path) => {
await p.evaluate((h) => {
const a = document.querySelector(`a[href="${h}"]`);
if (a) return a.click();
const s = document.createElement('a'); s.href = h; document.getElementById('view').appendChild(s); s.click();
}, U(path));
await p.waitForTimeout(2600);
};
const watch = async (label) => {
calls.length = 0;
recording = true;
await p.waitForTimeout(WATCH_MS);
recording = false;
const agg = {};
for (const c of calls) agg[c] = (agg[c] || 0) + 1;
return { label, total: calls.length, agg };
};
const out = { router: process.argv[2] || 'owrt2512', watchMs: WATCH_MS, runs: [] };
/* baseline: land on the neutral page by a full load and just sit there */
await p.goto(BASE + U(NEUTRAL), { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(3000);
out.baseline = await watch('baseline');
for (const app of APPS) {
await hop(app);
await p.waitForTimeout(5000); /* let its pollers get going */
await hop(NEUTRAL);
const r = await watch(app);
r.extra = r.total - out.baseline.total;
out.runs.push(r);
process.stderr.write(app.split('/').pop() + ' ');
}
process.stderr.write('\n');
console.log(JSON.stringify(out, null, 1));
await b.close();
@@ -0,0 +1,46 @@
/* Walk every page the dispatcher can render, then leave it, and report what the chrome looked like
* and whether the document survived. The adversaries are the point: openclash/ssclash/justclash/
* podkop/filemanager/banip/adblock/mwan3/3ginfo all put CSS into a document they do not own. */
import { chromium } from 'playwright';
import { login, PAGES, SNAP, navAndCheck, PORTS } from './lib.mjs';
const router = process.argv[2] || 'owrt2512';
const BASE = `http://localhost:${PORTS[router]}`;
const only = process.argv[3]; /* optional substring filter */
const b = await chromium.launch();
const p = await b.newPage({ viewport: { width: 1600, height: 1000 } });
const errs = [];
p.on('pageerror', (e) => errs.push({ where: 'pageerror', text: e.message.split('\n')[0] }));
p.on('console', (m) => { if (m.type() === 'error') errs.push({ where: 'console', text: m.text().split('\n')[0] }); });
await login(p, BASE);
let pages = await p.evaluate(PAGES);
if (only) pages = pages.filter((x) => x.path.includes(only));
console.error(`# ${router}: ${pages.length} pages`);
const base = await p.evaluate(SNAP); /* the chrome before any app has touched it */
const rows = [];
for (const pg of pages) {
const href = '/cgi-bin/luci/' + pg.path;
errs.length = 0;
await p.goto(href, { waitUntil: 'domcontentloaded' }).catch(() => {});
await p.waitForTimeout(2200);
const on = await p.evaluate(SNAP);
/* leave it for a plain stock page and see whether the document survived */
const away = await navAndCheck(p, '/cgi-bin/luci/admin/status/overview');
rows.push({
path: pg.path, type: pg.type,
hOverflow: on.hOverflow,
sidebarPad: on.sidebarPad === base.sidebarPad ? 'ok' : `CHANGED ${on.sidebarPad}`,
menuItems: on.menuItems === base.menuItems ? 'ok' : `${on.menuItems} vs ${base.menuItems}`,
brand: on.brandVisible ? 'ok' : 'HIDDEN',
sheets: `${on.sheets.styles}s/${on.sheets.links}l/${on.sheets.shims}i/${on.sheets.disabledLinks}d`,
leaving: away.clicked ? (away.spa ? 'SPA' : 'FULL LOAD') : 'no-link',
errors: errs.length
});
process.stderr.write('.');
}
process.stderr.write('\n');
console.log(JSON.stringify({ router, baseline: base, rows }, null, 1));
await b.close();
@@ -0,0 +1,75 @@
/* Sweep viewport widths over pages of every shape and look for the things a width breaks: the
* document scrolling sideways, an element wider than the column it sits in, the sidebar/bar
* transition, and the tab strip. Third-party pages are included because their tables and widgets
* are the ones with no width contract at all. */
import { chromium, firefox, webkit } from 'playwright';
import { login, PORTS } from './lib.mjs';
const ENGINES = { chromium, firefox, webkit };
const engineName = process.argv[3] || 'chromium';
const BASE = `http://localhost:${PORTS[process.argv[2] || 'owrt2512']}`;
const WIDTHS = [320, 360, 390, 480, 600, 768, 800, 900, 1024, 1280, 1440, 1857, 2560];
const PAGES = [
['overview', 'admin/status/overview'],
['processes', 'admin/status/processes'],
['fw/zones', 'admin/network/firewall/zones'],
['wireless', 'admin/network/wireless'],
['dhcp', 'admin/network/dhcp'],
['pkg-manager', 'admin/system/package-manager'],
['podkop', 'admin/services/podkop'],
['ssclash', 'admin/services/ssclash/config'],
['filemanager', 'admin/system/filemanager'],
['statistics', 'admin/statistics/graphs'],
['openclash', 'admin/services/openclash/config'],
['banip', 'admin/services/banip/overview'],
];
const CHECK = `(() => {
const de = document.documentElement;
const vw = de.clientWidth;
const wide = [];
/* every element sticking out past the viewport's right edge, deepest-first so the report names the
* actual offender rather than each of its ancestors */
for (const el of document.querySelectorAll('.fs-content *, .fs-sidebar *')) {
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) continue;
if (r.right > vw + 1.5 || r.left < -1.5) {
wide.push({ el, d: Math.round(Math.max(r.right - vw, -r.left)),
sel: el.nodeName.toLowerCase() + (el.id ? '#' + el.id : '') + (typeof el.className === 'string' && el.className ? '.' + el.className.trim().split(/\\s+/).slice(0,2).join('.') : '') });
}
}
const main = document.getElementById('maincontent');
return {
vw,
docOverflow: de.scrollWidth - de.clientWidth,
mainOverflow: main ? main.scrollWidth - main.clientWidth : null,
narrow: de.hasAttribute('data-narrow'),
layout: de.getAttribute('data-layout'),
barStack: !!document.querySelector('.fs-sidebar.fs-bar-stack'),
actRow: !!document.querySelector('.fs-sidebar.fs-bar-actrow'),
sidebarW: Math.round(document.querySelector('.fs-sidebar')?.getBoundingClientRect().width || 0),
contentW: Math.round(document.querySelector('.fs-content')?.getBoundingClientRect().width || 0),
tabsWrapped: (() => { const ul = document.querySelector('#tabmenu ul.tabs'); if (!ul) return null;
const k = [...ul.children].filter((c) => c.getClientRects().length); if (k.length < 2) return false;
return k[0].offsetTop !== k[k.length - 1].offsetTop; })(),
worst: wide.sort((a, b) => b.d - a.d).slice(0, 3).map((w) => w.sel + ' +' + w.d)
};
})()`;
const b = await ENGINES[engineName].launch();
const p = await b.newPage({ viewport: { width: 1280, height: 900 } });
await login(p, BASE);
const rows = [];
for (const [name, path] of PAGES) {
await p.goto(BASE + '/cgi-bin/luci/' + path, { waitUntil: 'domcontentloaded' }).catch(() => {});
await p.waitForTimeout(2600);
for (const w of WIDTHS) {
await p.setViewportSize({ width: w, height: 900 });
await p.waitForTimeout(450);
rows.push({ page: name, w, ...(await p.evaluate(CHECK)) });
}
process.stderr.write('.');
}
process.stderr.write('\n');
console.log(JSON.stringify({ engine: engineName, rows }, null, 1));
await b.close();
@@ -0,0 +1,33 @@
/* Count listeners on window across navigations. luci-base's stabilizeActionColumnWidth registers
* `window.addEventListener('resize', () => ... tableEl ...)` once per TABLE ELEMENT, guarded by an
* expando ON that element — so every re-render of a config page adds another one, and each closes
* over the table it was created for. Nothing ever removes them. */
import { chromium } from 'playwright';
import { login, PORTS } from './lib.mjs';
const BASE = `http://localhost:${PORTS[process.argv[2] || 'owrt2512']}`;
const HOPS = Number(process.argv[3] || 120);
const RING = ['admin/network/firewall/zones', 'admin/network/firewall/forwards',
'admin/network/dhcp', 'admin/system/system'];
const b = await chromium.launch();
const p = await b.newPage({ viewport: { width: 1400, height: 900 } });
const cdp = await p.context().newCDPSession(p);
await cdp.send('Runtime.enable');
await login(p, BASE);
await p.evaluate(() => document.querySelectorAll('#topmenu > li.has-sub:not(.open) > a').forEach((a) => a.click()));
await p.waitForTimeout(600);
async function winListeners() {
const { result } = await cdp.send('Runtime.evaluate', { expression: 'window' });
const { listeners } = await cdp.send('DOMDebugger.getEventListeners', { objectId: result.objectId });
const by = {}; for (const l of listeners) by[l.type] = (by[l.type] || 0) + 1;
await cdp.send('Runtime.releaseObject', { objectId: result.objectId }).catch(() => {});
return by;
}
console.log('hop window listeners');
for (let i = 0; i <= HOPS; i++) {
if (i % 20 === 0) console.log(String(i).padEnd(5), JSON.stringify(await winListeners()));
await p.evaluate((h) => document.querySelector(`a[href="${h}"]`)?.click(), '/cgi-bin/luci/' + RING[i % RING.length]);
await p.waitForTimeout(850);
}
await b.close();
+36
View File
@@ -0,0 +1,36 @@
# Indentation and line endings, for editors that read this before eslint/stylelint can say anything.
# The gates own everything else: `npm run lint` is the authority on JS and CSS style.
#
# Tabs are the tree's indent, including the shell that ends up in the package: strip-shell.sh and
# strip-templates.sh rewrite those files line by line, and a mixed indent shows up as a diff nobody
# meant to make.
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = tab
insert_final_newline = true
trim_trailing_whitespace = true
# npm rewrites both on every install and uses two spaces; matching it keeps the diff to what changed.
[{package.json,package-lock.json}]
indent_style = space
indent_size = 2
[*.{yml,yaml}]
indent_style = space
indent_size = 2
# Trailing whitespace is significant in Markdown (two spaces = a line break) and the tables in
# docs/ are aligned with spaces.
[*.md]
indent_style = space
indent_size = 2
trim_trailing_whitespace = false
# Generated by gettext tooling; leave its formatting alone.
[*.{po,pot}]
indent_style = space
trim_trailing_whitespace = false
+32
View File
@@ -0,0 +1,32 @@
#!/bin/sh
# Strip AI-attribution trailers from a commit message, whatever produced them.
#
# The project forbids Co-Authored-By, "Generated with", and any Claude/AI attribution in a commit or
# a PR body. A settings key (`attribution`) says the same thing to one client; this hook says it to
# every client, including a client whose default changes in an update. openwrt/luci refuses a
# `@users.noreply.github.com` address on a sign-off, so Signed-off-by is deliberately NOT touched.
#
# Enable with: git config core.hooksPath .githooks
set -eu
MSG=$1
[ -f "$MSG" ] || exit 0
TMP="$MSG.footstrap"
# One pass, three shapes: the trailer, the generator line, and the session link. `Co-authored-by` is
# case-insensitive in git's own trailer parsing, so match it that way here too.
sed \
-e '/^[Cc][Oo]-[Aa]uthored-[Bb]y:/d' \
-e '/^Claude-Session:/d' \
-e '/[Gg]enerated with \[*Claude/d' \
-e '/🤖 Generated with/d' \
-e '/^Co-authored by Claude/d' \
"$MSG" >"$TMP"
# A stripped trailer block leaves the message ending in a blank paragraph. Drop trailing blank lines
# only — an interior blank separates subject from body and is load-bearing.
awk '{l[NR]=$0} END{n=NR; while(n>0 && l[n] ~ /^[[:space:]]*$/) n--; for(i=1;i<=n;i++) print l[i]}' \
"$TMP" >"$TMP.2"
mv "$TMP.2" "$MSG"
rm -f "$TMP"
+32
View File
@@ -0,0 +1,32 @@
#!/bin/sh
# `npm run check` must exit 0 before a push. That is a requirement on everyone, not only on an
# agent, and a gate that only the agent's rule file states is a gate the next contributor never
# runs. Deliberate bypass: git push --no-verify.
#
# Enable with: git config core.hooksPath .githooks
set -eu
# A delete-only push (`git push origin :branch`) changes no tree, so there is nothing to check. The
# stdin format is: <local ref> <local sha> <remote ref> <remote sha>, one line per ref.
ALL_DELETES=1
while read -r _local_ref local_sha _remote_ref _remote_sha; do
[ -z "${local_sha:-}" ] && continue
case "$local_sha" in
0000000000000000000000000000000000000000) ;;
*) ALL_DELETES=0 ;;
esac
done
[ "$ALL_DELETES" = 1 ] && exit 0
if ! command -v npm >/dev/null 2>&1; then
echo "pre-push: npm not found, and npm run check is the gate this push needs." >&2
echo "Install node, or push with --no-verify and say in the PR that the gates did not run." >&2
exit 1
fi
echo "pre-push: npm run check ..." >&2
if ! npm run check; then
echo >&2
echo "pre-push: npm run check failed. Fix it, or push with --no-verify if you know why." >&2
exit 1
fi
+3
View File
@@ -0,0 +1,3 @@
buy_me_a_coffee: vizzletf
ko_fi: vizzletf
custom: ['https://boosty.to/vizzletf/donate']
@@ -0,0 +1,167 @@
name: 🐞 Баг / Bug report
description: Что-то сломано или выглядит неправильно / Something is broken or looks wrong
title: "[Bug]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Обязательных полей шесть, и почти все — в один клик. Остальное заполняйте, только если знаете:
короткий отчёт лучше ненаписанного.
Six required fields, most of them one click. Fill in the rest only if you know it — a short
report beats one you didn't send.
- type: checkboxes
id: preflight
attributes:
label: Проверки перед отправкой / Pre-flight checks
options:
- label: Поискал среди [существующих issue](https://github.com/VizzleTF/luci-theme-footstrap/issues?q=is%3Aissue) — дубликата нет / I searched existing issues, no duplicate
required: true
- label: Стоит последняя версия темы из [релизов](https://github.com/VizzleTF/luci-theme-footstrap/releases), сделал жёсткую перезагрузку (Ctrl+Shift+R) — баг остался / I am on the latest release and did a hard reload, the bug is still there
required: true
- label: Расширения, меняющие вид страниц (Dark Reader, Stylus…), отключены — баг остался / Extensions that restyle pages (Dark Reader, Stylus…) are off, the bug persists
- type: dropdown
id: release
attributes:
label: Релиз OpenWrt / OpenWrt release
description: |
Написан в подвале LuCI, на любой странице — ssh не нужен. Это важно: часть страниц LuCI
рисует на 24.10 и 25.12 разной разметкой, а тема отдаёт один стиль на обе, так что баг может
быть только на вашей ветке.
It is in the LuCI footer on any page — no ssh needed. This matters: LuCI renders some pages
with different markup on 24.10 and 25.12 while the theme ships one stylesheet for both, so a
bug can exist on your branch only.
options:
- "24.10"
- "25.12 или новее / or newer"
- Snapshot / master
- Не знаю / Not sure
validations:
required: true
- type: input
id: theme-version
attributes:
label: Версия темы / Theme version
description: Показана в поповере Appearance. / Shown in the Appearance popover.
placeholder: "0.9.1"
validations:
required: true
- type: input
id: page-path
attributes:
label: Адрес страницы / Page path
placeholder: "/cgi-bin/luci/admin/network/firewall/forwards"
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: Что не так и как повторить / What's wrong and how to reproduce
description: Что видите, что ожидали и куда нажать, чтобы увидеть то же самое. / What you see, what you expected, and what to click to see it too.
placeholder: |
Вижу: у правил не отображаются названия — первый столбец пустой.
Ожидал: имя правила, как на стоковой теме.
Повторить: Сеть → Межсетевой экран → Перенаправление портов.
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Скриншоты / Screenshots
description: |
Перетащите картинки прямо в это поле. По описанию вёрстку не воспроизвести.
Если можно — такой же кадр на стоковой теме `luci-theme-bootstrap` для сравнения.
Drag & drop images here — a layout bug cannot be reproduced from prose alone. If you can,
add the same shot on the stock `luci-theme-bootstrap` for comparison.
validations:
required: true
- type: dropdown
id: stock-theme
attributes:
label: Есть ли баг на стоковой теме luci-theme-bootstrap? / Does it also happen on stock luci-theme-bootstrap?
description: Если да — это, скорее всего, баг самого LuCI или пакета, а не темы. / If yes, it is most likely a LuCI or package bug, not a theme bug.
options:
- Нет, только на footstrap / No, footstrap only
- Да, на стоковой теме тоже / Yes, on the stock theme too
- Не проверял / Not checked
validations:
required: true
- type: dropdown
id: appearance
attributes:
label: Раскладка, палитра, режим / Layout, palette, mode
description: Всё это переключается в поповере Appearance. Отметьте, на чём видно баг. / All of these live in the Appearance popover. Tick whatever the bug shows on.
multiple: true
options:
- "Раскладка / Layout: боковое меню / sidebar"
- "Раскладка / Layout: верхнее меню / top"
- "Палитра / Palette: Footstrap"
- "Палитра / Palette: Hi-Contrast"
- "Палитра / Palette: Rvht (коты / cats)"
- "Режим / Mode: тёмный / dark"
- "Режим / Mode: светлый / light"
- type: dropdown
id: viewport
attributes:
label: Устройство / экран / Device & viewport
multiple: true
options:
- Десктоп / Desktop (>1200px)
- Ноутбук / Laptop (992–1200px)
- Планшет / Tablet (768–992px)
- Телефон / Phone (<768px)
- type: input
id: package-link
attributes:
label: Ссылка на сторонний пакет / Third-party package link
description: Только если страница из стороннего `luci-app-*` — без исходников виджет нечем воспроизвести. Пусто = стоковая страница LuCI. / Only if the page comes from a third-party `luci-app-*` — the widget cannot be reproduced without its source. Empty = a stock LuCI page.
placeholder: "luci-app-podkop — https://github.com/itdoginfo/podkop"
- type: input
id: browser
attributes:
label: Браузер / Browser
placeholder: "Chrome 150 / Firefox 152 / Safari 18 / Edge 150"
- type: input
id: luci-lang
attributes:
label: Язык интерфейса LuCI / LuCI UI language
description: Длинные подписи, CJK и RTL ломают вёрстку иначе, чем английские. / Long labels, CJK and RTL break layout differently than English.
placeholder: "Русский / English / 中文 …"
- type: textarea
id: board
attributes:
label: Роутер и прошивка / Router & firmware
description: |
Если есть доступ по ssh — вывод `ubus call system board`. Показывает модель, ядро и сборку
(OpenWrt, iStoreOS, ImmortalWrt…), а на форках поведение отличается.
If you have ssh access — the output of `ubus call system board`. It shows the model, the
kernel and the build (OpenWrt, iStoreOS, ImmortalWrt…), and forks behave differently.
render: json
- type: textarea
id: extra
attributes:
label: Что-нибудь ещё / Anything else
description: |
Красные строки из консоли (F12 → Console), регрессия (на какой версии темы работало),
кастомный CSS, LuCI за реверс-прокси, необычный zoom — всё, что может влиять.
Red lines from the console (F12 → Console), a regression (which theme version was the last
good one), custom CSS, LuCI behind a reverse proxy, unusual zoom — anything that could matter.
+11
View File
@@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: 📖 Документация и установка / Docs & installation
url: https://github.com/VizzleTF/luci-theme-footstrap#readme
about: Как установить, выбрать раскладку и палитру. / How to install, pick a layout and a palette.
- name: 💬 Вопрос, а не баг — чат в Telegram / Question, not a bug — Telegram chat
url: https://t.me/onlyyaml/92257
about: Вопросы по использованию и обсуждения — сюда. / Usage questions and general discussion go here.
- name: 🛠 Проблема в самом LuCI или OpenWrt / Bug in LuCI or OpenWrt itself
url: https://forum.openwrt.org/
about: Если баг виден и на стоковой теме luci-theme-bootstrap — это не тема. / If the bug also happens on the stock luci-theme-bootstrap, it is not a theme bug.
@@ -0,0 +1,75 @@
name: 💡 Предложение / Feature request
description: Идея, улучшение вёрстки, поддержка стороннего пакета / Idea, design improvement, third-party package support
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Тема стилизует **общие виджеты LuCI**, а не отдельные страницы. Чем точнее укажете, какой виджет
или какая страница выглядит плохо, тем выше шанс, что правка почините заодно десяток других страниц.
The theme styles **generic LuCI widgets**, not individual pages. The more precisely you name the widget
or page, the more likely one fix covers a dozen other pages too.
- type: checkboxes
id: preflight
attributes:
label: Проверки / Pre-flight
options:
- label: Поискал среди [существующих issue](https://github.com/VizzleTF/luci-theme-footstrap/issues?q=is%3Aissue) — такого предложения нет / I searched existing issues, no duplicate
required: true
- type: dropdown
id: kind
attributes:
label: Тип предложения / Kind of request
options:
- Улучшить вёрстку/стиль существующей страницы / Improve the styling of an existing page
- Поддержать сторонний luci-app, который выглядит плохо / Support a third-party luci-app that looks broken
- Новая палитра / тема оформления / New palette or color scheme
- Новая возможность темы / New theme feature
- Другое / Other
validations:
required: true
- type: textarea
id: problem
attributes:
label: Какую проблему решаем / What problem does this solve
description: Опишите ситуацию, а не сразу решение. / Describe the situation, not just the fix you have in mind.
placeholder: "На странице X виджет Y занимает половину экрана, из-за чего…"
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Что предлагаете / Proposed change
validations:
required: true
- type: input
id: page-path
attributes:
label: Адрес страницы / Page path
description: Где это видно / Where it can be seen
placeholder: "/cgi-bin/luci/admin/status/nftables"
- type: input
id: package-link
attributes:
label: Ссылка на сторонний пакет / Third-party package link
description: Обязательно, если речь про сторонний `luci-app-*` — иначе нечем воспроизвести виджет. / Required if this is about a third-party `luci-app-*` — otherwise the widget cannot be reproduced.
placeholder: "luci-app-podkop — https://github.com/itdoginfo/podkop"
- type: textarea
id: mockups
attributes:
label: Скриншоты, макеты, референсы / Screenshots, mockups, references
description: Как выглядит сейчас и как должно / How it looks now and how it should look. Drag & drop images here.
- type: textarea
id: extra
attributes:
label: Что-нибудь ещё / Anything else
+66
View File
@@ -0,0 +1,66 @@
# Security Policy
`luci-theme-footstrap` is a theme for LuCI, the web interface of OpenWrt. It runs in the
browser tab that administers a router, and it is installed on the device from a signed
package feed, so this project treats security reports seriously.
## Supported versions
Only the latest published release receives security fixes. The project ships as a rolling
line of `0.x` releases; there are no long-term or maintenance branches. If you are
running an older version, upgrade to the current release before reporting an issue, since
it may already be fixed.
| Version | Supported |
| ------------------ | --------- |
| Latest release | ✅ |
| Any older release | ❌ |
The current release is shown on the [Releases page](https://github.com/VizzleTF/luci-theme-footstrap/releases).
## Reporting a vulnerability
**Do not open a public issue for a security vulnerability.**
Report privately through GitHub's private vulnerability reporting:
1. Go to the [Security tab](https://github.com/VizzleTF/luci-theme-footstrap/security).
2. Click **Report a vulnerability**.
3. Fill in the advisory form.
This keeps the report confidential until a fix is available.
If you cannot use GitHub's private reporting, email **vizzlef@gmail.com** with the
subject line `SECURITY: luci-theme-footstrap`.
### What to include
A useful report has:
- The affected version (or commit) and the OpenWrt / LuCI version it runs on.
- A description of the issue and the security impact (e.g. stored/reflected XSS,
privilege escalation, tampering with the install or feed channel).
- Steps to reproduce, ideally with a minimal proof of concept.
- Any relevant configuration, logs, or screenshots.
### What is in scope
- The theme assets served to the browser (JavaScript, CSS, `ucode` templates) in
`luci-theme-footstrap/`.
- `install.sh` — how it resolves, verifies and installs the feed and the release, since it
runs as root from a piped shell.
Vulnerabilities in OpenWrt, LuCI itself, `rpcd`, `uhttpd`, or third-party `luci-app`
packages are out of scope here — report those to their respective projects.
## Response
- The report will be acknowledged, typically within a few days.
- If accepted, a fix will be prepared and released, and a GitHub Security Advisory will be
published crediting the reporter (unless anonymity is requested).
- If the report is declined, the reasoning will be explained.
## Coordinated disclosure
Please give a reasonable window to release a fix before disclosing details publicly.
Credit for the discovery is given in the advisory to anyone who wants it.
+23
View File
@@ -0,0 +1,23 @@
# SHA pins rot without this. A pinned action is only a decision anyone made if
# something proposes the update as a diff; otherwise it is a version nobody chose
# that stops receiving fixes -- the failure mode of pinning without automation.
#
# This repository sat two owfeed releases behind for exactly that reason: a pin
# nobody was reminded about.
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
groups:
actions:
patterns: ["*"]
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
groups:
dev:
patterns: ["*"]
+629
View File
@@ -0,0 +1,629 @@
# luci-theme-footstrap is noarch: one build, two containers (apk for 25.12+, ipk for 24.10), both
# out of owfeed rather than an OpenWrt SDK. docs/conventions.md has why.
#
# The release pipeline itself is owfeed's `package.yml`, shared by every repository in this
# ecosystem. What is here is this theme's own gates: each one line, each a script under tools/ whose
# header says what it is for.
name: Build LuCI theme packages
on:
push:
tags: ['v*']
branches: [main]
pull_request:
workflow_dispatch:
inputs:
# Off by default: the ordinary path is a v* tag through the `pages` job below, and this
# exists to preview a run's OWN recording before one exists. The github-pages environment
# accepts only `main` or a `v*` tag as a deployment branch (checked via `gh api
# repos/…/environments/github-pages`, 2026-09-14: custom_branch_policies [main, v*]), so a
# dispatch on any other ref reaches `pages-manual` but `deploy` inside it still refuses.
publish-pages:
description: >-
Deploy GitHub Pages with THIS run's own playground instead of the latest release's.
type: boolean
default: false
permissions:
contents: read
# EVERY JOB CARRIES A TIMEOUT, because the default is six hours and a hung step is not a failed one:
# an apt mirror and a playwright download stalled for 68 minutes on two jobs of the 0.13.2 tag with
# GitHub reporting everything operational. The values are the green-run times with room for a slow
# runner — 18s, 1m24s, 1m14s, 1m28s, and the live job, which is the only long one — so a stall goes
# red in minutes and a re-run is one click.
jobs:
check:
name: Static checks
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- run: sh tools/check-shell.sh
- run: sh tools/scan-marker.sh
- run: sh tools/check-acl.sh
- run: luci-theme-footstrap/build-css.sh /tmp/cascade.css
- run: python3 tools/audit.py --strict
# gettext for update-po; the ucode gate needs only docker. Asked for only when the image does
# not already carry it, and under tools/ci-retry.sh: apt does not fail when its mirror is
# unwell, it stalls, and a stalled step is nothing's problem until the job timeout.
- run: |
if command -v msgfmt >/dev/null && command -v msgmerge >/dev/null && command -v xgettext >/dev/null; then
echo 'gettext already on this image'
else
sh tools/ci-retry.sh 300 sudo -n apt-get update
sh tools/ci-retry.sh 300 sudo -n apt-get install -y gettext
fi
- run: luci-theme-footstrap/update-po.sh --check
# npm-based gates, and they live here and nowhere else: the OpenWrt buildbot has no node, luci.mk
# never needs one, and nothing in package.json is shipped.
lint:
name: Lint + accessibility
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: npm
- run: npm ci
# Through package.json, not a second copy of the globs: a glob restated here goes on linting
# the old path after package.json moves.
- run: npm run lint:js
- run: npm run lint:css
# The unit suite: pure logic, no browser, ~100 ms. Before the browser install because it is
# the fastest thing here and the first to fail on a broken module.
- run: npm test
# The browser off Playwright's CDN, then a launch to prove it works; apt only if that
# fails. `--with-deps` went through apt unconditionally, and apt is the half that stalls.
- run: sh tools/ci-playwright.sh
- run: node tools/a11y-gallery.mjs
# What the router actually sends, weighed on the real artefact: build-css plus the token mangle
# for the sheet, terser over a copy of the resources for the JS. And the app icons, which are
# committed rasters — this proves they still match logo.svg.
- run: node tools/size-budget.mjs --show
- run: node tools/build-icons.mjs --check
- run: node tools/export-tier.mjs
- run: node tools/css-metrics.mjs
# The browser floor, derived from the built sheet rather than asserted in a doc: it fails
# when docs/css.md and the stylesheet disagree, when a `:has()` compound shares a selector
# list with one that has none (the list is not forgiving, so the whole rule dies), and when
# a CSS feature nobody has classified appears.
- run: node tools/css-floor.mjs
- run: node tools/fs-orphans.mjs
- run: node tools/css-dup.mjs
- run: node tools/css-i18n.mjs
- run: node tools/mirror.mjs
- run: node tools/axes.mjs
- run: node tools/chrome-fence.mjs
# Two derived contracts that were in `npm run check` and in no workflow. Both fail the same way
# if nobody runs them: the stylesheet's "an unanswered table takes no room" rule stops naming a
# root fs-select scans, or the page-module map names a page that no longer exists — and the
# page then renders with something missing and says nothing about it.
- run: node tools/table-contract.mjs
- run: node tools/page-modules.mjs
# These three were enforced only when a human remembered to run the whole suite locally. Each
# guards a failure nothing observable reports: a conffile the package manager replaces on
# somebody else's upgrade, an `!important` that drifted out of the allowlist, a changelog
# bullet with no bold lead, which release-notes.sh drops from the release page in silence.
- run: node tools/conffiles.mjs
# po/, tools/stage.sh and owfeed.yml each repeat the language list, and a language
# missing from the last one stages into a directory nothing packages: the build stays
# green and that translation simply does not ship.
- run: node tools/i18n-packages.mjs
- run: node tools/bang-ok.mjs
- run: node tools/changelog.mjs
# jsmin-verify finds the shipped JS itself; JSMIN names the binary to test.
- run: echo "JSMIN=$(sh tools/build-jsmin.sh)" >> "$GITHUB_ENV"
- run: node tools/jsmin-verify.mjs
# No key anywhere in this job. `owfeed check` is build, sign, index and doctor on keys owfeed
# generates and throws away, so a pull request exercises the pipeline that publishes rather than a
# second one that drifts from it.
build:
name: Build the packages
timeout-minutes: 25
# `check` alone, not `lint`. Nothing the linter says changes what this job packs, and it sat in
# front of every live gate: 107 seconds of the critical path for an answer none of them reads.
# `release` names `lint` itself, so a red linter still publishes nothing — that guarantee moved
# rather than went.
needs: check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: npm
# --ignore-scripts: a dev dependency's lifecycle script has no business running in the job that
# produces the released bytes.
- run: npm ci --ignore-scripts
- uses: owfeed/owfeed/setup@v0.5.1
with:
version: v0.5.1
# The commit's timestamp, not the job's: a package's identity is a hash over its payload and
# mkpkg records mtimes, so re-running a release over the same commit must produce the same
# bytes.
- run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
- run: ./tools/stage.sh
- run: owfeed plan
- run: owfeed check
- run: owfeed build
- run: sh tools/check-packages.sh
# dist/root and dist/scripts are staging, not output.
- uses: actions/upload-artifact@v7
with:
name: owfeed-packages
path: |
dist/noarch
dist/all
if-no-files-found: error
# Everything above proves the package was BUILT correctly. None of it proves what a user cares
# about: that installing it produces a router whose pages render. owlab boots a real OpenWrt,
# installs the package and logs into LuCI to fetch the theme's own pages — an unauthenticated
# request is a redirect to the login form, which is why a hand-written curl reports 403 on a
# healthy page.
#
# No key exists anywhere in this job: it asserts that a package works, never that anyone should
# trust it.
verify:
name: The package works on a real router
timeout-minutes: 25
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/download-artifact@v8
with:
name: owfeed-packages
path: dist/
# ONE RELEASE PER STEP: 25.12 is apk and 24.10 is opkg, and neither installs the other's file.
- name: Install this build on 25.12 and render its pages
uses: owfeed/owlab/action@v0.6.1
with:
version: v0.6.1
releases: "25.12.4"
install: dist/noarch/luci-theme-footstrap-*.apk
# `exec ucode -T -c` is LuCI's own trycompile, run against the templates as INSTALLED
# and with the router's real luci.core and uci, so it needs no stubs. A stray brace here
# is the one way this repository can ship a completely broken UI with everything else
# green: LuCI falls back to another theme and says nothing.
assert: |
package luci-theme-footstrap
file /www/luci-static/footstrap/cascade.css
http 200 /cgi-bin/luci/admin/status/overview
http 200 /cgi-bin/luci/admin/system/system
exec for f in /usr/share/ucode/luci/template/themes/footstrap/*.ut; do ucode -T -c -o /dev/null "$f" || exit 1; done
- name: Install this build on 24.10 and render its pages
uses: owfeed/owlab/action@v0.6.1
with:
version: v0.6.1
releases: "24.10.8"
install: dist/all/luci-theme-footstrap_*.ipk
# The same compile as the 25.12 leg, and not redundant: the templates are identical but
# the interpreters are not, and what 25.12's ucode accepts 24.10's may not.
assert: |
package luci-theme-footstrap
file /www/luci-static/footstrap/cascade.css
http 200 /cgi-bin/luci/admin/status/overview
http 200 /cgi-bin/luci/admin/system/system
exec for f in /usr/share/ucode/luci/template/themes/footstrap/*.ut; do ucode -T -c -o /dev/null "$f" || exit 1; done
# And the published channel, by NAME, out of the signed index — a different claim: that the
# index parses, the URL does not redirect somewhere apk will not follow, and the key on the
# router matches the one that signed it.
#
# It tests what is ALREADY LIVE rather than this build, so it never fails the job: a feed
# mid-DNS-migration would otherwise block a theme release on a channel this repository does
# not own.
- id: feed-key
name: Fetch the published feed's key
if: github.event_name != 'pull_request'
run: sh tools/feed-key.sh
- id: feed-assert
name: Assert the feed serves a working theme
if: github.event_name != 'pull_request' && steps.feed-key.outputs.reachable == 'true'
continue-on-error: true
uses: owfeed/owlab/action@v0.6.1
with:
version: v0.6.1
releases: "25.12.4"
feed: https://repo.owfeed.org/releases/25.12/x86_64/packages.adb
feed-key: ${{ runner.temp }}/owfeed-packages.pem
feed-name: owfeed-packages
install: luci-theme-footstrap
assert: |
package luci-theme-footstrap
http 200 /cgi-bin/luci/admin/status/overview
# The step above is the only one in this file whose `if` can turn it off from OUTSIDE the
# repository — an unreachable feed switches it off, and a skipped step leaves nothing behind
# except a green job that looks exactly like one where the feed was measured and passed. This
# step always runs on a push and writes which of the three it was into the run's annotations
# and job summary, where a green run is actually read. It never fails either: the whole point
# of the pair is that a channel this repository does not own cannot redden a theme build.
- name: Report whether the published feed was measured
if: always() && github.event_name != 'pull_request'
env:
FEED_REACHABLE: ${{ steps.feed-key.outputs.reachable }}
FEED_OUTCOME: ${{ steps.feed-assert.outcome }}
run: sh tools/feed-key.sh report
# Everything above measures a FILE. This one measures a PAGE, which is where every field report has
# come from: a column shredded to one character per line (#11), a clipped submenu title (#22), a
# doubled scrollbar in one engine (#12), a third-party app laid out wrong (#8, #33, #36), a client
# navigation that painted less than a full load (upstream review). None can fail a static gate.
#
# owlab boots the same containers a developer runs locally, so the gate and the local command are
# the same command. Three routers on a push, one on a pull request: the other release lines double
# the wall clock and have never been the one to catch something first.
live:
# THREE SLICES, because this job was the release's critical path once `anchors` stopped being it.
# Each slice boots its own routers — 157s it does not share — so the job's wall clock is its
# longest slice rather than their sum, and fail-fast is off: a parity failure says nothing about
# whether the reader stays put.
#
# Re-measured on the last run where every slice finished (7ff9e56, push, three routers): the
# gates are 485s (spa-parity), 450s (live-audit) and 754s (everything `motion` still runs), on
# top of 157s of checkout/npm/playwright/boot each slice pays for itself. `motion` used to carry
# a fourth number — chromium's own scroll-anchor sweep, 1434s once `--full` crossed the density
# axis (e48434c) — which alone put the slice at 39 of its 45 minutes. That sweep is now a shard
# of `anchors`, where the one-job-per-engine rule already lives and the cap is 75 minutes; the
# cells did not change, only which runner draws them.
#
# These numbers only hold while a page still answers. A page that pins the browser's main thread
# costs a slice its whole budget rather than a page's worth of seconds, because `page.evaluate()`
# has no deadline of its own — task liveslice, `/admin/system/filemanager` under `13e9864`, both
# measuring slices cancelled at 45 minutes for a day. A slice that runs long is read by looking
# at WHICH page each gate last named, not by raising this cap.
strategy:
fail-fast: false
matrix:
slice: [ parity, audit, motion ]
name: The pages behave on a real router (${{ matrix.slice }})
timeout-minutes: 45
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: npm
- run: npm ci
# The browser off Playwright's CDN, then a launch to prove it works; apt only if that
# fails. `--with-deps` went through apt unconditionally, and apt is the half that stalls.
- run: sh tools/ci-playwright.sh
- uses: actions/download-artifact@v8
with:
name: owfeed-packages
path: dist/
- uses: owfeed/owlab/setup@v0.6.1
with:
version: v0.6.1
- name: Boot the routers and install this build
run: |
set -eu
# NOT `. "$GITHUB_ENV"` — that file is a KEY=VALUE list, not a script, so a value with a
# space in it ("owrt2512 owrt2410") runs its second word as a command.
# The three OpenWrt lines the theme supports: both package managers plus the snapshot
# box, which tracks luci-base master and so fails on an upstream change before a user
# reports it. ImmortalWrt is deliberately absent — same luci-base, different brand,
# never the leg that caught something first; run it locally with --all when curious.
if [ "${{ github.event_name }}" = "pull_request" ]; then ROUTERS="owrt2512"; else ROUTERS="owrt2512 owrt2410 owrtsnap"; fi
echo "ROUTERS=$ROUTERS" >> "$GITHUB_ENV"
# Where a gate leaves the page shapes it read, so a second one in the same slice does not
# walk the whole menu again — one load and a 1200ms settle per page. Set here rather than
# in the job's `env`, where $RUNNER_TEMP is not available: the `runner` context exists
# inside a step. Under the runner's temp, so it cannot outlive the install it describes
# (tools/lib/page-shapes.mjs).
echo "FS_SHAPES=$RUNNER_TEMP/page-shapes" >> "$GITHUB_ENV"
owlab up $ROUTERS
for r in $ROUTERS; do
case "$r" in
*2512|*snap) owlab install "$r" dist/noarch/luci-theme-footstrap-*.apk ;;
*) owlab install "$r" dist/all/luci-theme-footstrap_*.ipk ;;
esac
done
# Each gate measures one representative per PAGE SHAPE by default (tools/lib/page-shapes.mjs)
# and names the pages it stood in for; a release takes the whole menu on all four routers
# instead (`--all --pages-all`, docs/releasing.md).
#
# `motion` opens with upstream-contract at 7s, so a broken assumption about luci-base is still
# reported before anything spends minutes rendering pages against it — it cannot come before
# the other two slices any more, since they are on runners of their own.
- name: The gates of this slice
run: |
set -eu
O="$(echo $ROUTERS | tr ' ' ,)"
case "${{ matrix.slice }}" in
parity)
node tools/spa-parity.mjs --only "$O"
;;
audit)
node tools/live-audit.mjs --only "$O"
;;
motion)
node tools/upstream-contract.mjs --only "$O"
# Nothing may re-decide or jump while the page moves. Chromium only here: WebKit needs
# system libraries (gtk-4, gstreamer) this job would install on every run, and the
# engine differences it covers are checked locally before a release.
node tools/scroll-jank.mjs --only "$O"
# The two things a poll tick can do to a page nobody is touching: lay out a replaced
# table before anything answered for it, and move the page under the reader.
node tools/table-tick.mjs --only "$O"
# …and the third: hold the page open with a floor that is the wrong height, or that
# nothing ever takes off. Both shipped (0.14.4 and 0.14.6) and neither is visible in a
# file or on a page that is only loaded.
node tools/floor-contract.mjs --only "$O"
# …and the fourth is the chrome doing it to itself: the bar strips its layout classes
# to measure whether the menu fits a row, and is a row shorter while it asks. Reported
# from an iPhone, where no engine anchoring puts the reader back.
node tools/fit-quiet.mjs --only "$O"
# Chromium's own scroll-anchor sweep is NOT here any more: it is a shard of `anchors`
# below, beside firefox and webkit. It sat here while it was the cheap engine — 145s
# against the other two's 52 minutes — and stopped being one when `--full` started
# crossing chromium's DENSITIES axis too (e48434c): 34 runs became 184 and 145s became
# 1434s, which is 65% of everything this slice does and put it at 39 of its 45 minutes.
# Moving it changes no cell and costs the pipeline nothing, because `anchors` already
# runs one job per engine and its other two shards are longer than this one.
# LAST, because it installs the PUBLISHED release over the build everything above
# measured — and that is the claim: what a user runs is install.sh against the feed,
# not `apk add` against a local file. Three field reports (#16, #28, #30) were this
# script and nothing else, all on the re-install-over-an-existing-copy path, which a
# fresh install cannot exercise. It replaces the build, so it may not share a router
# with a slice still measuring one — which is exactly what a slice of its own buys.
sh tools/install-check.sh $ROUTERS
;;
esac
- if: always()
run: owlab down || true
# EVERY ENGINE'S ANCHOR SWEEP, on a clock of its own.
#
# This was one more step inside `live` and it did not fit: three engines across two routers, two
# page shapes, two widths, both layouts and all three densities is ~290 measurements, and the job
# was cancelled at its 45-minute limit. Splitting it runs the whole matrix beside the rest rather
# than inside somebody else's budget.
#
# Why the other engines at all: the fault this gate hunts lives on the path an engine takes when it
# DOES anchor. WebKit shipped anchoring, so chromium-with-anchoring-suppressed no longer stands in
# for Safari, and on that path WebKit overshoots a section swap by 60px per tick. Measured on
# 24.10, both layouts, 390 and 1440.
anchors:
# ONE JOB PER ENGINE, and it is the release's critical path that pays for it: this gate ran
# 52 minutes against live's 17 on the same three routers, so the whole pipeline was as slow as
# its slowest job. The three engines share nothing — separate browsers, separate runs of the
# same sweep — so a job each measures exactly as much at a third of the wall clock.
#
# Chromium joined them in task liveslice. It had stayed in `live`'s `motion` slice as the cheap
# engine (145s), and `--full` crossing its DENSITIES axis (e48434c) took it to 1434s — 65% of
# that slice, which was then at 39 of its 45 minutes. Here it is the SHORTEST of the three and
# the job's wall clock does not move.
strategy:
fail-fast: false
matrix:
engine: [ chromium, firefox, webkit ]
name: The reader stays put (${{ matrix.engine }})
timeout-minutes: 75
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: npm
- run: npm ci
- run: sh tools/ci-playwright.sh
# Only the engine this shard drives: installing all three cost ~1 minute of every run for
# browsers it never launches. Skipped for chromium, which the step above has already fetched
# off the CDN and proved launches — `--with-deps` goes through apt, and apt is the half that
# stalls (see ci-retry.sh); the other two genuinely need gtk-4/gstreamer from it.
- if: matrix.engine != 'chromium'
run: npx playwright install --with-deps ${{ matrix.engine }}
- uses: actions/download-artifact@v8
with:
name: owfeed-packages
path: dist/
- uses: owfeed/owlab/setup@v0.6.1
with:
version: v0.6.1
- name: Boot the routers and install this build
run: |
# The three OpenWrt lines the theme supports: both package managers plus the snapshot
# box, which tracks luci-base master and so fails on an upstream change before a user
# reports it. ImmortalWrt is deliberately absent — same luci-base, different brand,
# never the leg that caught something first; run it locally with --all when curious.
if [ "${{ github.event_name }}" = "pull_request" ]; then ROUTERS="owrt2512"; else ROUTERS="owrt2512 owrt2410 owrtsnap"; fi
echo "ROUTERS=$ROUTERS" >> "$GITHUB_ENV"
owlab up $ROUTERS
for r in $ROUTERS; do
case "$r" in
*2512|*snap) owlab install "$r" dist/noarch/luci-theme-footstrap-*.apk ;;
*) owlab install "$r" dist/all/luci-theme-footstrap_*.ipk ;;
esac
done
# A pull request crosses the axes that were measured to change this gate's answer — three
# width/layout cells (1440 side and 390 side, plus 390 top for the one WebKit-only tick
# misfire that measures 0px at 390 side and 41px at 390 top, task wkanchor), both anchoring
# modes, all three page shapes. A push and a tag add back the ones that were measured not to
# (every width against every layout, all three densities), because that measurement is of
# today's theme and a release is where being wrong about it is expensive. 18 cells per router
# against 72.
- name: The sweep
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then FULL=""; else FULL="--full"; fi
node tools/scroll-anchor.mjs --engines ${{ matrix.engine }} \
--only "$(echo $ROUTERS | tr ' ' ,)" $FULL
- if: always()
run: owlab down || true
# Builds a real router's `fs-*.js` and `luci.js` into a static site (capture → build → verify) and
# proves it in a real Chromium — never the theme's OWN JS against fixtures, since capture.mjs talks
# to nothing but the router. Runs on a pull request too, not only a tag: the earliest point a
# regression against a real ubus answer can be caught is before a tag exists to publish from.
# `--base` matches the path `pages.yml` serves it under, `/<repo>/playground`, so a link the built
# HTML carries resolves the same way here and there.
playground:
name: Record and verify the playground
timeout-minutes: 20
needs: build
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: npm
- run: npm ci
# The browser off Playwright's CDN, then a launch to prove it works; apt only if that
# fails. `--with-deps` went through apt unconditionally, and apt is the half that stalls.
- run: sh tools/ci-playwright.sh
- uses: actions/download-artifact@v8
with:
name: owfeed-packages
path: dist/
- uses: owfeed/owlab/setup@v0.6.1
with:
version: v0.6.1
- run: owlab up owrt2512
- run: owlab install owrt2512 dist/noarch/luci-theme-footstrap-*.apk
# Under $RUNNER_TEMP, the same reason FS_SHAPES lives there in the `live` job: it cannot
# outlive the install it describes, and it keeps the recording out of the checkout.
- name: Record, build and verify
run: |
set -eu
PG="$RUNNER_TEMP/playground"
node tools/playground/capture.mjs --recording "$PG/recording"
node tools/playground/build.mjs --recording "$PG/recording" --out "$PG/out" \
--base "/${GITHUB_REPOSITORY#*/}/playground"
# 600 KB: the tar.gz measured 479 KB on owrt2512 with all 29 icons, ~25% headroom.
node tools/playground/verify.mjs --out "$PG/out" --base "/${GITHUB_REPOSITORY#*/}/playground" \
--budget-kb 600
- if: always()
run: owlab down || true
# Only the built tarball, not recording/: its raw server documents carry pre-scrub session
# tokens and the stand's hostname, and this artifact is downloadable on a public repo.
- uses: actions/upload-artifact@v7
with:
name: playground
path: ${{ runner.temp }}/playground/playground.tar.gz
if-no-files-found: error
# The only job that holds a key, and its `needs` is what keeps a tag from publishing a package no
# router has installed. `lint` is named here rather than inherited through `build`, which stopped
# waiting for it: the gate has to hold a release, not the packing step. `playground` joined the
# list for the same reason as the others: a recording that fails to build or verify is a real fault
# in the theme's own `fs-*.js` against a real router, caught by capture.mjs talking to nothing else.
release:
name: Sign and publish
needs: [build, verify, live, anchors, lint, playground]
if: startsWith(github.ref, 'refs/tags/v')
permissions:
contents: write
uses: owfeed/owfeed/.github/workflows/package.yml@v0.5.1
secrets:
# The repository already has this key, and owfeed-packages pins its public half.
sign-key: ${{ secrets.OWFEED_AUTHOR_KEY }}
usign-key: ${{ secrets.FOOTSTRAP_USIGN_KEY }}
with:
owfeed-version: v0.5.1
# Writes dist/install.sh and release-notes.md. The installer is an asset because
# raw.githubusercontent is rate-limited for the very user who most needs it (issue #17) and
# because a signed copy is the only one checkable before it runs as root; the notes fill the
# release page and are not an asset, hence a path beside dist/ rather than in it.
#
# A WORKSPACE PATH, not `${{ runner.temp }}`: at job level a reusable workflow's `with:` may
# only read github/needs/vars/inputs, and naming `runner` there is not an error at edit time —
# it fails the whole run at STARTUP, with zero jobs and no log to read (measured: run
# 31903926488, "failure" with an empty job list).
pre-release: sh tools/stage-release.sh
sign-also: install.sh
notes-file: release-notes.md
verify-with: release.pub
# A separate job, not a `package.yml` input: `dist/*` rides the manifest `release` signs, and
# playground.tar.gz is a demo site, never router payload — owfeed's own pipeline has no input for
# a foreign artifact anyway, and putting it in `dist/` would sign it as if it were one. Needs both
# `release` (the tag it attaches to) and `playground` (this run's own recording).
playground-asset:
name: Attach the playground to the release
needs: [release, playground]
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v8
with:
name: playground
path: pg/
- env:
GH_TOKEN: ${{ github.token }}
run: gh release upload "$GITHUB_REF_NAME" pg/playground.tar.gz --clobber --repo "$GITHUB_REPOSITORY"
# Refresh the Pages mirror of the release metadata. Called rather than triggered: `on: release`
# never fires for a release created with GITHUB_TOKEN. Waits on `playground-asset`, not `release`
# directly, so the mirror step pages.yml runs next (releases/latest/download/playground.tar.gz)
# never races the upload that puts it there.
pages:
name: Refresh the Pages mirror
needs: playground-asset
if: startsWith(github.ref, 'refs/tags/v')
uses: ./.github/workflows/pages.yml
permissions:
contents: read
pages: write
id-token: write
# Lets a maintainer see a run's OWN recording on Pages before a tag exists to publish
# `playground-asset` from — `publish-pages` defaults to false, so the tag path above is
# unchanged. `uses: ./…` runs a local reusable workflow's jobs as jobs of THIS SAME run, so
# `playground-source: artifact` can fetch the `playground` artifact `playground` just uploaded
# here — no `actions: read` needed beyond the permissions below: `download-artifact` reads a
# same-run artifact through the runner's own token, not a `permissions:` scope, which is why the
# `build`/`verify`/`live`/`anchors`/`playground` jobs above never declare it either despite each
# downloading `owfeed-packages`.
#
# A LATER PAGES DEPLOY OVERWRITES THIS: pages.yml's own push trigger (a push to `main` touching its
# `paths:`) or the tag `pages` job both run with `playground-source` at its `release` default and
# refresh the site from the latest RELEASE. This job previews a recording; it does not pin one.
pages-manual:
name: Publish Pages from this run
needs: playground
if: github.event_name == 'workflow_dispatch' && inputs.publish-pages
uses: ./.github/workflows/pages.yml
with:
playground-source: artifact
permissions:
contents: read
pages: write
id-token: write
+172
View File
@@ -0,0 +1,172 @@
# Publishes the developer portal to GitHub Pages. devkit.html is generated (like cascade.css), so it
# is built here, never committed. The gallery is published too — it needs a real cascade.css next to
# it, so that is built in. The playground is not built here at all: it is recorded from a real router
# by build.yml's `playground` job and fetched below from the latest release, the same way the release
# mirror is.
#
# One-time setup by a maintainer: repo Settings -> Pages -> Source = "GitHub Actions".
name: pages
on:
push:
branches: [main]
paths:
- 'docs/devkit.src.html'
- 'docs/gallery.html'
- 'luci-theme-footstrap/styles/**'
- 'tools/devkit-build.mjs'
- 'tools/lib/gallery.mjs'
- '.github/workflows/pages.yml'
workflow_dispatch:
# Called by build.yml's release job once the release is published, which is what refreshes the
# release-metadata mirror below. It cannot be `on: release`: an event raised by GITHUB_TOKEN does
# not trigger another workflow. Also called by build.yml's `pages-manual` job, a manual publish
# from a single run rather than a tag.
workflow_call:
inputs:
# 'release' (default): mirror releases/latest/download/playground.tar.gz, same as a direct
# workflow_dispatch or push trigger of this file — `inputs` is populated only for
# workflow_call, so those two read it empty and the `!= 'artifact'` checks below still pick
# the release path. 'artifact': build.yml's `pages-manual` job asking for THIS run's own
# `playground` artifact instead of the newest tag's.
playground-source:
type: string
default: release
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# 22, matching build.yml: eslint 10 / stylelint 17 need Node >= 20.19, and a bare `20` floats
# on the 20.x line that straddles that floor. Nothing here lints, but `npm ci` installs them
# and would EBADENGINE.
- uses: actions/setup-node@v7
with: { node-version: '22' }
- run: npm ci
- name: Build the portal
run: |
set -e
npm run devkit
MEDIA=luci-theme-footstrap/htdocs/luci-static/footstrap
# `mkdir -p _site` is not decoration: nothing else here creates the directory. It used to
# be a side effect of `mkdir -p _site/fonts`, and removing that line with the webfonts
# took the portal's whole output directory with it.
mkdir -p _site
cp docs/devkit.html _site/index.html # portal is the Pages root
cp docs/devkit.html _site/devkit.html
cp docs/gallery.html _site/gallery.html
luci-theme-footstrap/build-css.sh _site/cascade.css # gallery loads this by <link>
# No fonts to copy: the theme carries none. The stylesheet names Manrope and JetBrains
# Mono first and the system stack after, so the portal renders in whatever the visitor
# has — the same thing a router does.
cp "$MEDIA"/logo.svg _site/
# A FULL MIRROR OF THE LATEST RELEASE — the manifest, its signature AND THE PACKAGES, on a
# different host from github.com, so a block or an outage covering one need not cover the
# other.
#
# The packages are the point: a release asset URL redirects THROUGH github.com, so a router
# that cannot reach github.com cannot fetch the package either, and mirroring only the
# manifest moves the failure two lines later. What makes mirroring the packages safe is that
# the manifest is signed and carries their sha256, so mirror bytes are held to the same hash
# as GitHub's: the mirror is never trusted, only used.
#
# Pulled from the release on every Pages build, never committed and never assembled here:
# this workflow also fires on a docs push, and a build that did not carry the release forward
# would silently empty the mirror.
#
# Fails OPEN — a repo with no manifest release yet publishes the portal without a mirror.
- name: Mirror the latest release
run: |
set -eu
if ! curl -fsSL --retry 2 -o _site/manifest.txt \
"https://github.com/$GITHUB_REPOSITORY/releases/latest/download/manifest.txt"; then
rm -f _site/manifest.txt
echo "the latest release publishes no manifest — mirror skipped"
exit 0
fi
curl -fsSL --retry 2 -o _site/manifest.txt.sig \
"https://github.com/$GITHUB_REPOSITORY/releases/latest/download/manifest.txt.sig"
# The installer too: a router that cannot reach github.com cannot fetch install.sh from
# there either, so a mirror without it leaves the user with a verified way to install and
# no way to obtain the installer. Its signature rides along so the script can be checked
# before it runs as root — the one link the trust chain cannot cover on its own.
for f in install.sh install.sh.sig; do
curl -fsSL --retry 2 -o "_site/$f" \
"https://github.com/$GITHUB_REPOSITORY/releases/latest/download/$f" || rm -f "_site/$f"
done
# Every package the manifest names, under the name the manifest gives it — the same
# string a router asks this mirror for. Reading the file list out of the manifest rather
# than globbing the release is what keeps the two in step.
tag=$(awk '$1=="tag"{print $2; exit}' _site/manifest.txt)
awk '$1=="pkg"{print $4}' _site/manifest.txt | while read -r f; do
curl -fsSL --retry 2 -o "_site/$f" \
"https://github.com/$GITHUB_REPOSITORY/releases/download/$tag/$f"
echo "mirrored: $f ($(wc -c < "_site/$f") bytes)"
done
echo "mirror of $tag complete."
# The playground is recorded from a real router by build.yml's `playground` job, not built
# here — Pages has no owlab stand. Same fail-open shape as the mirror step above: a repo with
# no release-attached playground.tar.gz yet (or one still mid-upload the run before its own
# `pages` call) publishes the rest of the portal rather than failing the whole build.
- name: Fetch the latest playground
if: inputs.playground-source != 'artifact'
run: |
set -eu
if ! curl -fsSL --retry 2 -o /tmp/playground.tar.gz \
"https://github.com/$GITHUB_REPOSITORY/releases/latest/download/playground.tar.gz"; then
echo "the latest release publishes no playground — playground skipped"
exit 0
fi
mkdir -p _site/playground
tar -xzf /tmp/playground.tar.gz -C _site/playground
echo "playground: $(wc -c < /tmp/playground.tar.gz) bytes"
# A MANUAL PUBLISH (build.yml's `pages-manual`), and it FAILS CLOSED, unlike the fetch above:
# that step exists to preview THIS run's own recording, so falling back to the release's (or
# to none) on a miss would publish a preview that previewed nothing and say it succeeded.
# `download-artifact` reads the artifact `playground` uploaded earlier in this same run — see
# build.yml's `pages-manual` for why no `actions: read` is needed for that.
- name: Fetch this run's own playground
if: inputs.playground-source == 'artifact'
uses: actions/download-artifact@v8
with:
name: playground
path: ${{ runner.temp }}/pg-artifact
- name: Unpack this run's own playground
if: inputs.playground-source == 'artifact'
run: |
set -eu
mkdir -p _site/playground
tar -xzf "$RUNNER_TEMP/pg-artifact/playground.tar.gz" -C _site/playground
echo "playground (this run): $(wc -c < "$RUNNER_TEMP/pg-artifact/playground.tar.gz") bytes"
# Old links (README, devkit.src.html) pointed at playground.html directly; keep them working.
- run: |
printf '%s' '<!doctype html><meta http-equiv="refresh" content="0; url=playground/"><a href="playground/">Continue</a>' \
> _site/playground.html
- uses: actions/upload-pages-artifact@v5
with: { path: _site }
deploy:
needs: build
runs-on: ubuntu-latest
# `github-pages` accepts only `main` or a `v*` tag as a deployment branch (repo Settings ->
# Environments -> github-pages; `gh api repos/…/environments/github-pages/deployment-branch-
# policies`, checked 2026-09-14: custom_branch_policies [main, v*]) — a `pages-manual` dispatch
# from any other ref reaches this job but is refused HERE, after `build` already spent its time.
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
steps:
- id: deploy
uses: actions/deploy-pages@v5
+41
View File
@@ -0,0 +1,41 @@
# build artifacts
*.apk
*.ipk
/bin/
# generated by luci-theme-footstrap/build-css.sh from the styles/ tree
/luci-theme-footstrap/htdocs/luci-static/footstrap/cascade.css
# generated by tools/devkit-build.mjs from devkit.src.html + gallery.html + 02-tokens.css (built in CI for Pages)
/docs/devkit.html
# editor
.vscode/
.idea/
*.swp
# macOS finder droppings — one had already been committed under docs/
.DS_Store
# page review screenshots
page-review/
# built packages
dist/
# lint/a11y tooling only — never shipped (see package.json).
# package-lock.json IS committed on purpose: it pins the transitive dependency
# tree, so CI lints with exactly what was verified here.
node_modules/
# python bytecode from the audit tooling
__pycache__/
# local overrides for the dev-router containers (public key path, DNS) — machine-specific
/docker/.env
# built by docker/hwsim-up.sh for the WSL host kernel; not portable, not source
/docker/hwsim/*.ko
# written by tools/stage-release.sh in the release job — the release BODY, not an asset
release-notes.md
# personal Claude Code state: skills pulled by `npx skills add` (a symlink into .agents/), the
# installer's lock, the playwright venv behind .claude/tooling/, and the per-machine settings override
/.agents/
/.claude/skills/minimalist-ui
/skills-lock.json
/.claude/tooling/preview-venv/
/.claude/settings.local.json
+139
View File
@@ -0,0 +1,139 @@
{
"//": [
"Correctness rules only — deliberately NOT `extends: stylelint-config-standard`.",
"That preset is largely a formatter: it would demand its own indentation, quoting",
"and case conventions across the whole styles/ tree and bury the two or three",
"findings that actually matter under a thousand cosmetic ones. The house style",
"here is tabs + one-liners for short rules, and it is deliberate.",
"",
"What is switched on below is the set that can catch a real bug: a typo'd",
"property or pseudo-class, a broken hex, the same property twice in one block,",
"an empty block, a duplicate selector. Plus the project's own invariants from",
"docs/conventions.md — no stray !important, and no colour literal outside the palettes.",
"",
"audit.py stays: it checks things stylelint cannot see (var() defined-vs-used",
"across files, declarations shadowed within a cascade layer).",
"",
"TWO formatting rules are the deliberate exception, and they are @stylistic/* because",
"stylelint 16 removed every stylistic rule from core. The objection above is to the",
"PRESET, not to the idea: a preset imposes a hundred opinions nobody chose, while these",
"two each close a measured drift the house style already had a majority on — quotes were",
"309 double vs 47 single in attribute selectors, leading zeros 97 bare vs 5. Picking the",
"majority cost 77 autofixed characters and changed the built cascade.css by 0 bytes",
"(116548 both ways; the awk squeeze is string-aware and eats either quote identically).",
"string-quotes leaves a string that CONTAINS the preferred quote alone rather than escape",
"it — which is why the four <svg xmlns=\"…\"> data URIs in 03-palettes.css stay single.",
"That is the rule's own definition, not an exemption we wrote.",
"",
"What still CANNOT be gated, so it is on review alone: attribute-quote PRESENCE",
"(input[type=file]) and `0px` — stylelint 16 removed selector-attribute-quotes and",
"length-zero-no-unit and @stylistic never ported either.",
"",
"The second block of rules below is the same argument one layer deeper: all of them",
"are correctness, none is formatting, and every one found ZERO violations in the",
"current tree — so they cost nothing today and catch the next mistake for free.",
"declaration-property-value-no-unknown is the one that matters most: it is the only",
"check that reads a declaration's VALUE against the property's grammar, i.e. the only",
"thing that can catch a value which is invalid at computed-value time and therefore",
"vanishes in SILENCE — the exact failure mode this project tore the --*-rgb component",
"bridges out for. custom-property-no-missing-var-function catches `color: --fs-accent`",
"(a bare custom-property name where var() was meant), which parses and does nothing."
],
"plugins": ["@stylistic/stylelint-plugin"],
"rules": {
"@stylistic/string-quotes": "double",
"@stylistic/number-leading-zero": "never",
"color-no-invalid-hex": true,
"function-calc-no-unspaced-operator": true,
"string-no-newline": true,
"unit-no-unknown": true,
"property-no-unknown": true,
"declaration-block-no-duplicate-properties": [
true,
{ "ignore": ["consecutive-duplicates-with-different-values"] }
],
"declaration-block-no-shorthand-property-overrides": true,
"block-no-empty": true,
"selector-pseudo-class-no-unknown": true,
"selector-pseudo-element-no-unknown": [
true,
{
"ignorePseudoElements": [
"picker",
"picker-icon",
"checkmark",
"-webkit-slider-thumb",
"-moz-range-thumb",
"-webkit-search-decoration",
"file-selector-button"
]
}
],
"selector-type-no-unknown": true,
"media-feature-name-no-unknown": true,
"at-rule-no-unknown": true,
"comment-no-empty": true,
"no-duplicate-at-import-rules": true,
"no-duplicate-selectors": true,
"no-empty-source": true,
"no-invalid-double-slash-comments": true,
"no-invalid-position-at-import-rule": true,
"no-irregular-whitespace": true,
"declaration-property-value-no-unknown": true,
"custom-property-no-missing-var-function": true,
"declaration-block-no-duplicate-custom-properties": true,
"function-no-unknown": true,
"media-query-no-invalid": true,
"selector-anb-no-unmatchable": true,
"keyframe-declaration-no-important": true,
"no-invalid-position-declaration": true,
"font-family-no-missing-generic-family-keyword": true,
"syntax-string-no-invalid": true,
"no-descending-specificity": null,
"declaration-no-important": true
},
"overrides": [
{
"//": [
"The only files allowed an !important, and each for a documented reason.",
"theme/90-responsive + pages/20-overview outrank an inline style= attribute",
"written by 29_ports.js / ui.js — no cascade layer can do that, only an author",
"!important can. theme/45-misc is the same case: the realtime graphs write",
"style='width:100%' on the box they draw into, and the theme has to widen it or",
"the drawing (sized from #view) is clipped by the card's gutter. theme/65-dropdown",
"carries the widget's ul-margin flag (ui.js writes margin inline on an open list),",
"moved in with the machinery absorbed from base/80-dropdown.css. styles/base",
"keeps the .left/.right/.center forcing utilities and the two inline-style fighters",
"in 95-luci. theme/95-a11y-media is the reduced-motion block: !important INVERTS",
"the layer order, so it is the only way one rule can stop animations declared in",
"base as well as in theme.",
"theme/30-tables carries THREE flags, one per adversary a layer cannot reach.",
"`word-break: normal` fights two of them: luci-mod-status's processes.js writes",
"style='word-break: break-word' on its Command span, the deprecated alias for",
"`overflow-wrap: anywhere`, erasing the column's min-content from an inline declaration",
"— measured on the router, the column sat at 126px against a 353px token and no layered",
"rule could move it; and luci-mod-dashboard ships `word-break: break-all` UNLAYERED,",
"which splits a token that would have fitted. The other two hold a carded cell's width",
"against that same unlayered sheet's `flex: 2 2 10%`, two because a meter cell needs the",
"whole line where a label/value pair needs half.",
"This list is the contract. Adding a file to it needs a reason in the file.",
"The same list lives in tools/audit.py (BANG_OK); tools/bang-ok.mjs (npm run bang-ok)",
"fails if the two drift, so a file added here but not there (or vice versa) is caught."
],
"files": [
"luci-theme-footstrap/styles/base/*.css",
"luci-theme-footstrap/styles/theme/30-tables.css",
"luci-theme-footstrap/styles/theme/45-misc.css",
"luci-theme-footstrap/styles/theme/65-dropdown.css",
"luci-theme-footstrap/styles/theme/90-responsive.css",
"luci-theme-footstrap/styles/theme/95-a11y-media.css",
"luci-theme-footstrap/styles/pages/20-overview.css"
],
"rules": { "declaration-no-important": null }
}
]
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+180
View File
@@ -0,0 +1,180 @@
# CLAUDE.md
`luci-theme-footstrap` — a LuCI theme for **OpenWrt 24.10 and newer** (and ImmortalWrt). Standalone:
it ships no framework and depends on nothing but `luci-base`. Page content is rendered client-side
by app view-JS, so the theme is server chrome (`ucode/template/themes/footstrap/*.ut`) + one
generated `cascade.css` + `fs-*.js` in `htdocs/luci-static/resources/`.
**Communicate in Russian.** Code, comments, commit messages and PR text stay in English.
**The floor is 24.10.** 23.05 support ended at 0.14.2 (`ui.RangeSlider` is 24.10-only,
openwrt/luci#8978); `install.sh` serves a 23.05 router that pin. `docs/releasing.md`.
`styles/base/` is footstrap's code: **do not call it "the fork" or reintroduce the word bootstrap**
into filenames, comments or docs. That name belongs to the *other, real* package only — the
`/luci-static/bootstrap` fallback, the `bench/` baseline, the attribution in `styles/00-header.css`.
**Repo root is the workspace** (`package.json` gates, `tools/`, `docs/`, `owlab.yaml`,
`install.sh`); the shipped package is `luci-theme-footstrap/` one level down — same name, one level
apart, so a path is ambiguous unless it is absolute or rooted. Nothing in the root ships. **Work
from the repo root, not from its parent**: the parent holds `luci-fork/` and `tmp/`, is not a git
repository, and loads none of this file. `.claude/hooks/session-start.sh` says so at startup.
## Read the doc first
`docs/` is the reference and every page carries the measurement behind each rule.
| Touching | Read |
|---|---|
| what LuCI expects of a theme, where the boundary runs | `docs/architecture.md` |
| the rule list with the gate that holds each one, the comment rules | `docs/conventions.md` |
| dev routers, pushing a change, proving it, the probe rig, hardware | `docs/development.md` |
| the lead thread, the roles, the card, the loop | `docs/crew.md` |
| `styles/`, cascade layers, `build-css.sh`, `@mirror` | `docs/css.md` |
| tokens, palettes, type, the Appearance axes | `docs/design-system.md` |
| sidebar / bar / rail, the menu renderer, the fit | `docs/chrome.md` |
| the reader's place through a poll tick — floor, reference, the three corrections | `docs/anchoring.md` |
| every anchoring finding, its numbers and the attempts that were reverted | `docs/anchoring-log.md` |
| client navigation | `docs/spa-router.md` |
| foreign `luci-app-*`, the fence | `docs/third-party-apps.md` |
| Makefile, uci-defaults, postinst/postrm, ACL | `docs/package.md` |
| CI job graph, owfeed, packaging, the trust chain | `docs/ci.md` |
| pre-release checklist, changelog contract, runbook | `docs/releasing.md` |
| the navigation benchmark | `docs/benchmark.md` |
`docs/luci-app-styling-guide.md` (+ `_ru`) is outward-facing. `docs/gallery.html` renders every
widget LuCI or any app can emit — it is what `a11y`, `export-tier`, `computed-diff` and `smoke`
measure, and how the theme is checked without a router.
**The rules for one area load with that area** — `.claude/rules/css.md` (`styles/**`),
`js.md` (`htdocs/**/*.js`), `chrome.md` (`*.ut`, `fs-*.js`), `package.md` (Makefile, `root/**`,
`po/**`), `changelog.md` (`CHANGELOG*.md`). Cutting a release is `/release`; openwrt/luci work is
`/upstream-pr`. Read the matching file before editing there; the rules below apply everywhere.
## Commands
```sh
npm run check # T1: every gate; exit 0 before pushing
npm run check:fast # T1: the static subset, no browser and no CSS build
npm test # T0: the unit suite alone (node --test, no browser)
npm run smoke # T1: modules come up in a real DOM (~1.4 s)
npm run computed-diff # T1: worktree vs HEAD, computed styles (~4 s)
tools/bg.sh <cmd> # T2: detach, log into ../tmp/ (refuses ssh, dev-sync, push)
tools/bg-wait.sh <run-id> # the waiter; stops on status, a dead pid, or the 2 h cap
tools/ci-local.sh --list # T2: build.yml's jobs, locally, in the same order; --force for the router legs
node tools/build-icons.mjs # re-raster the app icons after a logo.svg change
owlab up | owlab sync --watch | owlab open owrt2512
./tools/stage.sh && owfeed build # both formats into dist/
owlab test --release 25.12.4 --install 'dist/noarch/luci-theme-footstrap-*.apk' --assert …
ucode -T -c -o /dev/null <template>.ut # syntax-check a template the way LuCI does
luci-theme-footstrap/dev-sync.sh <host> # deploy to a HARDWARE router over ssh (ask)
npm run fork-drift # what the two trees disagree about
```
## Verifying
Every gate sits in exactly one tier; the tier says who runs it and whether waiting on it is
allowed. Tiering changes nothing in the release matrix (`/release`, `docs/releasing.md`), and CI
runs every job on every pull request regardless of what a session ran.
Who runs which tier: T0 is the `developer`'s, before it returns; T1 and T2 are the `tester`'s.
The lead runs none of them.
**T0 — always, foreground, 60 s.** `npm test`; the ONE `node tools/<name>.mjs` that covers the file
just edited (the map is in each `.claude/rules/` file and in `docs/conventions.md`);
`ucode -T -c -o /dev/null` over each template touched. Nothing else runs by reflex.
**T1 — on an explicit request, 5 min.** `npm run check` once, when the branch is otherwise finished.
`npm run smoke` and `npm run computed-diff` after a JS or CSS change: seconds each.
**T2 — never blocked on.** `owlab up|test`, the live gates, `bench/nav-benchmark.py`,
`jsmin-verify`, the full token-stream compare. Started detached through `tools/bg.sh` and paired,
in the same turn, with `tools/bg-wait.sh <run-id>` as a background command; the result is reported
unprompted when it arrives. "I started it" is not a status. `docs/development.md`.
- **No bash call runs longer than 5 minutes** (`BASH_MAX_TIMEOUT_MS` in `.claude/settings.json`).
One that would is T2 by definition; one that hits its timeout is never re-run with a larger one.
- **A tier is lowered by the maintainer, never by the agent.** The gate is started detached, or
waived out loud.
- **Done is "has run on a real userland on both package managers"** — 25.12/apk and 24.10/opkg —
held by CI's `verify` job and by the maintainer. Say which of the two a run actually covered; a
stubbed harness proves a module initialises, nothing more.
- **CI's `lint`, `build`, `verify`, `live` and `anchors` are not reproduced locally**; read them
with `gh run view`. On a PR `live` and `anchors` boot owrt2512 only, so the 24.10 half of a
behaviour claim comes from a push, a tag, or a detached local run.
- **A finding about the stands goes in `docs/development.md`, "The stand's own traps", in the same
session** — what it looked like, what it was, and the command that tells the two apart.
- **A fault in the tooling itself gets an issue in the tool's own repository** — `owlab`
(`owfeed.org/owlab` → github.com/owfeed/owlab) and `owfeed` (github.com/owfeed/owfeed), both with
issues open. The trap note says how to work around it here; the issue is how it gets fixed there,
and without one the next session rediscovers it from scratch. Draft it with the command, the exact
error text, the tool version (`owlab version`) and the host — then it is **published only on the
maintainer's explicit word, like every other publication**; drafting is the agent's, posting is
not.
- **One fault, one mechanism — prove that one holds alone.** A spare added "to be safe" is measured
with the first on its own; if the probe still passes, it does not ship (openwrt/luci#8981).
- **`/security-review` before every release and every upstream PR**, on the final branch diff.
Surface: the installer's signature chain, shell over a build tree, the login template, sinks in
the browser JS, the packaging pipeline.
- **Prove a CSS change with a computed-style diff, not screenshots.** Cheap half:
`npm run computed-diff`; the live half is T2 and not finished until its numbers are read.
- Screenshots, `tools/bg.sh` logs and any other scratch artefact go in `../tmp/`, never inside
the checkout.
## Comments
The reader is a model with the file open and no session history. One comment is the invariant, the
number behind it and one pointer, in that order. Attempts and their order, task names, `../tmp`
paths and CI run ids go to `docs/<area>.md`, never into shipped code. Limits: inline 1-2 lines, a
block 8, a header 15, under 40 % of a file's bytes. Full rule with the example behind each line:
`docs/conventions.md`, "Comments". Two of them break something silently:
- **Some comments are code** — `@mirror`/`@endmirror`, `/* fs:probe */`, the eslint `'require …'`
pragmas, the Makefile's buildroot marker, the literal `dataset.fsFit` write. Reword one and a gate
or the build breaks.
- **A `#` line inside a quoted `ssh "$R" "…"` string is part of the string**: `sh -n` after editing.
## Crew
The main thread is the lead. It reads no screenshots, runs no gate, edits nothing beyond a
one-sentence diff, and uses Bash for `git status`, `git diff --stat` and `git log` only. The work
goes to a role in `.claude/agents/`: `developer` (writes inside the card's file list, runs T0),
`tester` (the full cycle, read-only, PASS/NEEDS_WORK with evidence), `security` (`security-review`
on the task diff), `researcher` (sourced answers, ranked), and `caveman:cavecrew-investigator`
to locate code. Delegate when a task touches more than one file, needs any gate beyond T0, or would
put more than ~50 lines of tool output into this thread. Protocol and schemas: `docs/crew.md`.
Every delegation carries four fields: **objective** (one sentence + `../tmp/task-<id>/card.json`),
**output format** (the role's return block, ≤25 lines, paths not contents, no diffs; the role works
in caveman full throughout like this thread, carrying that rule in its own "Voice" section, since no
session hook reaches a subagent), **tools and sources** (the `.claude/rules/` file for the area by
path, the acceptance commands with expected output), **boundaries** (the file list; anything outside it is reported, not edited). A subagent
inherits no history: what is not in the prompt or the card does not exist for it.
Loop: developer → tester → at most 2 fix rounds. Stop when T0 is green, the verdict has no blocking
finding and the last round touched no file outside the previous round's set. Round N+1 gets the
inter-round diff, the previous findings and fresh gate output — never the full diff again. A stall
— the same blocking finding id twice, or the same `diff_hash` twice — goes to the human, not into
another round. `security` runs per task except CSS-only or one-line diffs; any finding goes to the
human and is never auto-fixed. The Opus `/security-review` before a release or PR stays.
Always through the human: `git commit`, `git push`, a hardware router (`dev-sync.sh` is `ask`),
lowering a tier, waiving a gate, a security finding, a stall. The lead quotes the verdict block, not
the log; a log is read by the role that produced it.
## Commits
**Conventional Commits, message in English. Never commit OR PUSH without an explicit instruction for
that action, each time.** Finished work, green gates, a verified fix or an answered review is not
authorization. This holds for BOTH remotes — `origin`, and the openwrt/luci fork behind a PR, where
amend + `push --force-with-lease` is a push like any other. Leave the tree dirty and say what would
go in; wait to be told. No co-author / "Generated with" / AI attribution trailers.
**Nothing is published without an explicit instruction, each time**: no PR comment, no review, no
issue comment, no reply on an upstream thread. A review finding is answered in the DIFF and by
resolving the thread (`/upstream-pr`). This is a rule, not a matcher: `permissions` in
`.claude/settings.json` fence none of it, on purpose.
`git commit` and `git push` are `ask` in `.claude/settings.json`; trailers are stripped by
`attribution` there and by `.githooks/commit-msg`. Every commit carries its changelog entry in both
files — `.claude/rules/changelog.md`, enforced by `.claude/hooks/precommit-gate.sh`.
+61
View File
@@ -0,0 +1,61 @@
# Contributing
Patches are welcome. This page is the short version; every rule below is written out, with the
measurement behind it, in [`docs/`](docs/README.md).
## Before you start
Read [`docs/architecture.md`](docs/architecture.md) — what LuCI expects of a theme and where this
one plugs in. Then the page for the area you are touching:
| Touching | Read |
|---|---|
| `styles/` | [css.md](docs/css.md), [design-system.md](docs/design-system.md) |
| `fs-*.js`, `*.ut` | [chrome.md](docs/chrome.md), [spa-router.md](docs/spa-router.md) |
| `Makefile`, `root/`, `po/` | [package.md](docs/package.md) |
| a foreign `luci-app-*` looking wrong | [third-party-apps.md](docs/third-party-apps.md) |
The rule list itself is [`docs/conventions.md`](docs/conventions.md), and each rule names the gate
that holds it.
## The loop
```sh
npm ci
npm run check:fast # the static subset: no browser, no CSS build
npm run check # all of them; must exit 0 before you open a pull request
```
A change that alters behaviour is not finished until it has run on a real OpenWrt userland — both
package managers, 25.12/apk and 24.10/opkg. [`docs/development.md`](docs/development.md) sets that
up with `owlab` in a few minutes. If you cannot run it, say so in the pull request rather than
describing the change as verified; an untested change described as tested is worse than an untested
one.
Nothing in `package.json` ships. `luci.mk` copies `htdocs/` and `ucode/` verbatim, and the OpenWrt
buildbot has no node — so a gate may need node, and the package never may.
## What a pull request needs
- **One concern per branch.** A layout fix and a packaging fix are two branches.
- **Conventional Commits**, in English, present tense: `fix(chrome): …`, `feat(appearance): …`.
- **A changelog entry in both files** — `CHANGELOG.md` and `CHANGELOG_ru.md`, same version, same
order. `npm run changelog` checks it.
- **Green gates.** CI re-runs everything plus the jobs that need a router; a red `check` locally
will be red there.
- **A comment that says why.** The codebase's comments carry the constraint, the alternative that
failed and the number that was measured — not a restatement of the line below them. A negative
result is worth one line and saves the next person the experiment.
## Style
`.editorconfig` covers indentation and line endings; `eslint` and `stylelint` cover the rest and
both run in `npm run check:fast`. Do not reformat code you are not otherwise changing.
Translations go through the `po/` catalogues — run `luci-theme-footstrap/update-po.sh` after
touching any `_()` string.
## Reporting things
A bug or an idea: open an issue. A vulnerability: [SECURITY.md](SECURITY.md) — privately, not in an
issue.
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+87
View File
@@ -0,0 +1,87 @@
# LUCI-THEME-FOOTSTRAP
**English** · [Русский](README_ru.md) ·
**[Playground — try the whole thing with no router](https://vizzletf.github.io/luci-theme-footstrap/playground/)**
[![owfeed](https://img.shields.io/endpoint?url=https://repo.owfeed.org/badge/luci-theme-footstrap.json)](https://owfeed.org/install/)
[![owfeed](https://img.shields.io/endpoint?url=https://repo.owfeed.org/badge/luci-theme-footstrap-releases.json)](https://owfeed.org/install/)
A LuCI theme for OpenWrt 24.10 and newer. No framework, `luci-base` is the only dependency.
Browsers: **Chrome 108, Firefox 101, Safari 15.4** and newer. Older ones render a plainer page
rather than a broken one; the floor is derived from the stylesheet by a gate and written down in
[docs/css.md](docs/css.md#the-browser-floor).
> **23.05** is end-of-life and the theme stops there: **0.14.2** is the last version that runs on it.
> The installer serves that release to a 23.05 router — pinned, signed and verified like any other —
> and says so. Newer versions need 24.10 or later, where `ui.RangeSlider` exists.
<picture>
<source media="(max-width: 767px)" srcset="assets/readme/phone-menu-dark.png">
<img src="assets/readme/overview-top-dark.png" width="100%" alt="The same overview in dark with the top bar: the menu sits on the brand's row and the content runs full width.">
</picture>
<details>
<summary>Appearance settings</summary>
<img src="assets/readme/appearance-dark.png" width="100%" alt="The Footstrap tab on System → System: layout, theme, palette, density and rounding; the colour fields for accent, the status colours and the surfaces, each with the contrast it lands at in words; the wallpaper picker with the cats doodle behind the page; and Save as default next to the two resets.">
</details>
## Install
```sh
wget -qO- https://raw.githubusercontent.com/VizzleTF/luci-theme-footstrap/main/install.sh | sh
```
The script adds its own package feed and installs from it. After that the theme upgrades with the
router: `apk update && apk upgrade` (or `opkg`). Running it again upgrades the theme and prints what
it did — installed, upgraded from which version, or already current.
`raw.githubusercontent.com` is rate-limited per address, so if it answers 429 (a shared exit, CGNAT),
the same script is attached to every release and served from a CDN with no such budget — signed, so
it can be checked before it is run:
```sh
wget -qO- https://github.com/VizzleTF/luci-theme-footstrap/releases/latest/download/install.sh | sh
```
Then pick **Footstrap** in **System → System → Language and Style**, field "Design".
[More screenshots →](docs/screenshots/)
## What it does
- **Styles every page, stock or not** — but never overwrites what an app styles itself
- **Works on a phone**, and installs to its home screen — icon, own window, no address bar
- **Faster than bootstrap** — the numbers are below
- **Upgrades with the router**, from the package feed
- **Twenty-one appearance axes**, applied instantly, in one tab
## Measured, not claimed
Time to first paint, same router, same pages.
| Page | bootstrap | footstrap |
|---|---:|---:|
| Wireless status | 271 ms | **54 ms** |
| Interfaces | 374 ms | **111 ms** |
| DNS | 329 ms | **108 ms** |
| Firewall zones | 311 ms | **79 ms** |
| 38-page run | 11 306 ms | **4933 ms** |
| Requests/page | 15–47 | **0–7** |
Median page **3.03× faster**, the whole run **2.29×**. Router CPU for the same tour: 37.3 s against
**18.4 s**. Measured on real hardware, five runs — method and full data in
[docs/benchmark.md](docs/benchmark.md).
## Documentation
Developer documentation is in **[docs/](docs/README.md)** — architecture, the design system, the
stylesheet build, the SPA router, packaging, the release runbook. Start with
[architecture.md](docs/architecture.md) for what the theme is, or
[conventions.md](docs/conventions.md) for the rules a patch has to follow.
Writing a `luci-app`? Read
[how to style it so it works under any theme](docs/luci-app-styling-guide.md), and paste your CSS
into the [devkit](https://vizzletf.github.io/luci-theme-footstrap/) — token grid, component markup,
style checker.
+88
View File
@@ -0,0 +1,88 @@
# LUCI-THEME-FOOTSTRAP
[English](README.md) · **Русский** ·
**[Песочница — всё можно потрогать без роутера](https://vizzletf.github.io/luci-theme-footstrap/playground/)**
[![owfeed](https://img.shields.io/endpoint?url=https://repo.owfeed.org/badge/luci-theme-footstrap.json)](https://owfeed.org/install/ru/)
[![owfeed](https://img.shields.io/endpoint?url=https://repo.owfeed.org/badge/luci-theme-footstrap-releases.json)](https://owfeed.org/install/ru/)
Тема LuCI для OpenWrt 24.10 и новее. Без фреймворка, единственная зависимость — `luci-base`.
Браузеры: **Chrome 108, Firefox 101, Safari 15.4** и новее. Более старые получают страницу проще
оформленную, а не сломанную; порог выводится из таблицы стилей гейтом и записан в
[docs/css.md](docs/css.md#the-browser-floor).
> **23.05** объявлена EOL, и тема на ней останавливается: **0.14.2** — последняя версия, которая там
> работает. Установщик ставит на роутер с 23.05 именно её — по фиксированному тегу, с той же
> проверкой подписи и суммы — и говорит об этом прямо. Более новые версии требуют 24.10 и новее,
> где есть `ui.RangeSlider`.
<picture>
<source media="(max-width: 767px)" srcset="assets/readme/phone-menu-dark.png">
<img src="assets/readme/overview-top-dark.png" width="100%" alt="Тот же обзор в тёмной теме с верхней панелью: меню стоит в строке бренда, контент идёт во всю ширину.">
</picture>
<details>
<summary>Настройки внешнего вида</summary>
<img src="assets/readme/appearance-dark.png" width="100%" alt="Вкладка Footstrap в «Система → Система»: раскладка, тема, палитра, плотность и скругление; поля цвета для акцента, статусов и поверхностей — каждое со словесной оценкой контраста; выбор обоев с котами на фоне страницы; и «Сохранить как умолчание» рядом с двумя сбросами.">
</details>
## Установка
```sh
wget -qO- https://raw.githubusercontent.com/VizzleTF/luci-theme-footstrap/main/install.sh | sh
```
Скрипт добавляет свой фид пакетов и ставит тему из него. Дальше тема обновляется вместе с роутером:
`apk update && apk upgrade` (или `opkg`). Повторный запуск обновляет тему и печатает, что именно
сделал — поставил, обновил с какой версии или всё уже актуально.
`raw.githubusercontent.com` лимитируется по адресу, поэтому если он ответил 429 (общий выход, CGNAT),
тот же скрипт приложен к каждому релизу и раздаётся с CDN без такого лимита — с подписью, так что его
можно проверить до запуска:
```sh
wget -qO- https://github.com/VizzleTF/luci-theme-footstrap/releases/latest/download/install.sh | sh
```
После этого выберите **Footstrap** в **System → System → Language and Style**, поле «Design».
[Ещё скриншоты →](docs/screenshots/)
## Что умеет
- **Стилизует любую страницу, стоковую и нет** — но не перебивает то, что приложение красит само
- **Работает на телефоне** и ставится на его домашний экран — иконка, своё окно, без адресной строки
- **Быстрее bootstrap** — цифры ниже
- **Обновляется вместе с роутером**, из фида пакетов
- **Двадцать одна ось внешнего вида**, применяются сразу, всё в одной вкладке
## Замерено, а не заявлено
Время до первой отрисовки, тот же роутер, те же страницы.
| Страница | bootstrap | footstrap |
|---|---:|---:|
| Wireless status | 271 мс | **54 мс** |
| Interfaces | 374 мс | **111 мс** |
| DNS | 329 мс | **108 мс** |
| Firewall zones | 311 мс | **79 мс** |
| Прогон 38 страниц | 11 306 мс | **4933 мс** |
| Запросов/стр. | 15–47 | **0–7** |
Медианная страница — **в 3.03 раза быстрее**, весь прогон — **в 2.29 раза**. Процессорное время
роутера на тот же обход: 37.3 с против **18.4 с**. Замерено на железе, пять прогонов; методика и
полные данные — [docs/benchmark.md](docs/benchmark.md).
## Документация
Документация для разработчика — в **[docs/](docs/README.md)**: архитектура, дизайн-система, сборка
стилей, SPA-роутер, упаковка, релизный ранбук. Она **на английском**; на русском остаются только
этот README и `CHANGELOG_ru.md`. Начните с [architecture.md](docs/architecture.md) — что такое
тема, — или с [conventions.md](docs/conventions.md) — правила, которым обязана следовать правка.
Пишете `luci-app`? Прочитайте,
[как стилизовать его, чтобы он работал под любой темой](docs/luci-app-styling-guide_ru.md), и
вставьте свой CSS в [devkit](https://vizzletf.github.io/luci-theme-footstrap/) — сетка токенов,
разметка компонентов, проверялка стилей.
+50
View File
@@ -0,0 +1,50 @@
# Security policy
## Reporting a vulnerability
Report privately through GitHub — **Security → [Report a vulnerability](https://github.com/VizzleTF/luci-theme-footstrap/security/advisories/new)**.
If that is unavailable to you, email <vizzlef@gmail.com> with `luci-theme-footstrap` in the
subject. Please do not open a public issue for a suspected vulnerability.
Include what you have: the OpenWrt release and package manager (25.12/apk or 24.10/opkg), the theme
version from *System → System → Appearance*, the request or the page, and what an attacker gains.
A proof of concept is welcome but not required.
You will get an acknowledgement within **7 days** and an assessment within **14**. A confirmed fix
ships in the next release; you will be credited in `CHANGELOG.md` unless you ask otherwise.
## Supported versions
| Version | Supported |
|---|---|
| latest release | yes |
| earlier releases | no — upgrade first, then report if it persists |
| 0.14.2 (the last for OpenWrt 23.05) | no. 23.05 is end-of-life upstream |
## What this package can actually do
The theme is server chrome plus browser assets. It ships no daemon, opens no port, registers no
dispatcher node and depends on `luci-base` alone. The parts worth your attention:
- **The login page.** `ucode/template/themes/footstrap/sysauth.ut` renders *before* a session
exists, so it is the one unauthenticated surface. The form is server-rendered on purpose.
- **The rpcd ACL**, `root/usr/share/rpcd/acl.d/luci-theme-footstrap.json`. It grants uci
read/write on the `footstrap` config, `cgi-io` upload, and `file` write/remove/exec on two fixed
paths. The two `exec` grants are whole command lines (`/bin/chmod 644 /etc/footstrap/…`), never an
interpreter — `npm run acl` fails the build if that changes.
- **The uploads.** Appearance accepts a login background and a pattern tile. Both are inspected in
the browser (an SVG is parsed, a photo is redrawn on a canvas, which also drops EXIF) and land
under `/etc/footstrap`, reached from `/www` through a symlink.
- **The browser JS.** HTML is parsed from a string in four places, all literal; the
`fs/no-unsanitized-html` lint rule keeps it that way.
- **The installer**, `install.sh`, verifies a usign signature and a checksum before it installs
anything, and pins 23.05 routers to 0.14.2.
Out of scope: anything requiring an existing root shell on the router (writing
`/etc/config/footstrap` by hand is a root action, and the template sanitises it anyway), and the
behaviour of third-party `luci-app-*` packages the theme merely styles.
## What we do on our side
Every release and every upstream pull request goes through a security review of the branch diff
before the tag is cut, and CI installs each build on a real 25.12 and a real 24.10 userland.
Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

+649
View File
@@ -0,0 +1,649 @@
#!/usr/bin/env python3
"""
nav-benchmark.py — per-page navigation time of luci-theme-footstrap (client-side SPA router) against
the stock luci-theme-bootstrap (full page reload), walking the standard LuCI pages one by one.
WHAT IT MEASURES
Wall-clock milliseconds from navigation intent to "view fully rendered": for footstrap a real
in-app click on the menu link, for bootstrap a full navigation to the page URL. Both themes render
into the SAME #view element (the dispatcher emits it regardless of theme), so the rendered
condition is identical and the comparison is user-perceived click-to-usable time.
METHOD (per theme)
1. Activate the theme (uci luci.main.mediaurlbase), clear the LuCI caches.
2. Fresh browser context, log in.
3. WARM pass: walk every page once, unmeasured, so the HTTP and module caches are populated.
4. MEASURED passes (--runs, default 5): walk again, timing each arrival; report the MEDIAN.
Network requests per transition are counted too — a full reload re-requests the shell and scripts
even when every answer is a 304.
CPU — TWO TABLES, BECAUSE "CPU LOAD" IS TWO QUESTIONS
Wall-clock answers "how long did I wait", not "what did this cost", and the two can disagree: a
theme can be fast because it makes the ROUTER do the work, or fast on the router while burning the
client's battery. Both ends are measured, by different means, and reported apart.
ROUTER. Read on the router from /proc at the edges of each theme's measured passes, never per
navigation: an ssh round trip per nav would add latency to the timing table and CPU to the thing
being measured.
A view POLLS while it is open, and that is router CPU nobody navigated for. Measured naively the
window mixes the two and the mixture flatters the fast theme twice — less CPU for the tour AND a
shorter tour, so less polling. So the polling rate is measured separately, parked on one fixed
page, and the table prints the tour's total, the polling rate, and the remainder per navigation.
Two ways of counting, the first being the attributable one:
- web stack: utime+stime+cutime+cstime of uhttpd, rpcd and ubusd. The cutime/cstime halves are
what make it work: uhttpd forks a CGI per request and a reaped child's CPU lands in its
parent's counters, so the ucode that renders the shell IS counted (measured: 10 shell renders
moved uhttpd's cutime+cstime by 41 jiffies, while rpcd did not move at all).
- whole box: the /proc/stat busy delta. Includes everything else the router is doing, so an IDLE
BASELINE is measured before and after and printed beside it. Do not read one without the other.
The window length comes from the ROUTER's own /proc/uptime, so a host/router clock offset cannot
skew it. Jiffies convert at 100 Hz — USER_HZ, a fixed kernel ABI constant for /proc, not CONFIG_HZ.
In a container /proc/stat is the HOST's, so the "whole box" figure would include this benchmark's
own browser; the per-process web-stack figure stays correct. The published numbers come from real
hardware for that reason.
CLIENT. Per navigation, from CDP Performance.getMetrics deltas: TaskDuration with a ScriptDuration
/ RecalcStyleDuration / LayoutDuration / V8CompileDuration breakdown. These counters are monotonic
across a full navigation — the renderer process is reused for a same-origin load — so a negative
delta means the process was replaced and the sample is dropped rather than counted as 0.
Two caveats before quoting them. TaskDuration is ALL main-thread task time in the window, so the
named parts sum to a fraction of it and the rest is HTML parsing, network dispatch, GC and timers.
And V8CompileDuration is ~0.1 ms in both themes on a warm cache: compiling luci.js/cbi.js is NOT
where a full reload loses, V8's code cache makes it nearly free, and the cost is the shell, the
re-fetch and the re-render. Printed anyway, because "this is not the reason" is worth seeing.
STANDARD PAGES
Discovered live from /admin/menu: every node the menu can turn into a link (satisfied + titled) at
depth >= 3 whose RESOLVED target is a standard LuCI page. Third-party pages are excluded by
construction (STANDARD_VIEWS).
Three things it deliberately does not do, each of which used to hide pages:
- it does not stop at depth 3. The tab leaves (Realtime, Logs, Administration, Firewall) are
click-navigable, just in #tabmenu, and they are 17 of the 39 pages.
- it does not require action.type == 'view'. A menu link can be an `alias`, a `firstchild` or the
overview `template`; the router resolves all of them and users click them most. Benchmarking
only `view` nodes measured the theme everywhere except its front door.
- it does not filter on the view path alone: package-manager's view path has no module prefix, so
a prefix filter silently dropped it.
Excluded on purpose: admin/status/channel_analysis (its time is a 5 s radio scan, and 12 runs would
disrupt the router's wifi) and attendedsysupgrade (it times the internet, not the theme).
USAGE
python3 -m venv .venv && .venv/bin/pip install playwright && \
.venv/bin/python -m playwright install chromium
LUCI_PW=<router-root-password> .venv/bin/python bench/nav-benchmark.py \
[--ssh-host router] [--runs 5] [--headful]
See docs/benchmark.md for the full recipe.
"""
import argparse, json, os, re, statistics, subprocess, sys, time
FOOTSTRAP = "/luci-static/footstrap" # sidebar variant (simple menu)
BOOTSTRAP = "/luci-static/bootstrap" # stock baseline
PROTON = "/luci-static/proton2025" # luci-theme-proton2025, a third-party theme
# Every theme in the run, baseline first. Only footstrap has a client router, so it is the only one
# navigated by clicking a link; the others get a real full navigation, which is what a click does in
# them anyway. A theme added here must already be registered in `luci.themes` on the router.
THEMES = [BOOTSTRAP, PROTON, FOOTSTRAP]
BASELINE = BOOTSTRAP
# A page counts as standard if the view it resolves to belongs to a module that ships with a stock
# LuCI: luci-mod-status / -system / -network (whose firewall views live under firewall/) and the
# package manager. Matching the resolved VIEW path rather than the menu path keeps a third-party app
# out even when it hangs itself under admin/system.
STANDARD_VIEWS = ("status/", "system/", "network/", "firewall/", "package-manager")
OVERVIEW_TPL = "admin_status/index" # the one template node that is a page
EXCLUDE_PATHS = ("admin/status/channel_analysis",) # radio scan — see docstring
# Tag the outgoing view's nodes; LuCI renders with dom.content(#view, …), which REPLACES the
# children, so "no tagged node left" means the new page is up.
STALE = ("(()=>{const v=document.getElementById('view'); if(!v) return;"
"for (const c of v.children) c.setAttribute('data-bench-old','');})()")
# Rendered = #view has content, nothing is spinning, and none of it is the page we navigated away
# from.
#
# The stale check is not a nicety. A SPA nav leaves the old view on screen until the new one renders,
# so a condition that only asks "does #view have children" is true the instant the click lands and
# would time the previous page. Waiting for a spinner instead does not work: a view whose module is
# cached renders with no spinner frame at all, so the wait ran its full 3 s timeout and reported
# ~3017 ms for the FASTEST pages — eight of them.
RENDERED = (
"(()=>{const v=document.getElementById('view');"
"if(!v || v.children.length===0) return false;"
"if(v.querySelector('.spinning')) return false;"
"if(v.querySelector('[data-bench-old]')) return false;"
"return !/Loading view/.test(v.innerText);})()"
)
def sh(host, cmd):
return subprocess.run(["ssh", host, cmd], check=True,
capture_output=True, text=True).stdout.strip()
# ---------------------------------------------------------------------------
# router-side CPU
# ---------------------------------------------------------------------------
# ONE ssh round trip per sample. Everything is read from /proc, including the clock: the window
# length comes from the router's own uptime, so a host/router clock offset cannot become a bogus
# percentage.
CPU_PROBE = (
"for p in $(pgrep uhttpd) $(pgrep rpcd) $(pgrep ubusd); do "
" awk '{print $14+$15+$16+$17}' /proc/$p/stat 2>/dev/null; "
"done | awk '{s+=$1} END{printf \"stack %d\\n\", s+0}'; "
# $1 is the literal "cpu": 2 user, 3 nice, 4 system, 5 idle, 6 iowait, 7 irq, 8 softirq,
# 9 steal. busy excludes idle AND iowait (waiting is not burning). The double space after "cpu"
# collapses under awk's default FS.
"awk '/^cpu /{printf \"busy %d\\nidle %d\\n\", $2+$3+$4+$7+$8+$9, $5+$6}' /proc/stat; "
"awk '{printf \"up %s\\n\", $1}' /proc/uptime; "
"awk '{printf \"load %s\\n\", $1}' /proc/loadavg; "
"grep -c ^processor /proc/cpuinfo | awk '{printf \"ncpu %s\\n\", $1}'"
)
USER_HZ = 100 # /proc/<pid>/stat is in USER_HZ, a fixed ABI constant (not CONFIG_HZ)
# Where to park to measure the polling rate, and for how long. The overview is the right page: it is
# the one users leave open and it polls the most, so the rate sits well above the jiffy floor.
POLL_PAGE = "admin/status/overview"
POLL_SECS = 10
def cpu_sample(host):
out = {}
for line in sh(host, CPU_PROBE).splitlines():
k, _, v = line.partition(" ")
out[k] = float(v)
return out
def cpu_window(a, b, navs=None):
"""Turn two samples into the numbers the CPU tables print."""
secs = b["up"] - a["up"]
if secs <= 0: # router rebooted mid-run; refuse to invent a number
return None
ncpu = max(b.get("ncpu", 1), 1)
stack_ms = (b["stack"] - a["stack"]) * 1000.0 / USER_HZ
busy_j, idle_j = b["busy"] - a["busy"], b["idle"] - a["idle"]
total_j = busy_j + idle_j
w = {"secs": secs, "ncpu": ncpu, "stack_ms": stack_ms,
# as a share of the WHOLE box (all cores), which is what "load" means to a reader
"box_pct": (100.0 * busy_j / total_j) if total_j > 0 else float("nan"),
"stack_pct": 100.0 * (stack_ms / 1000.0) / (secs * ncpu),
"load": b.get("load", float("nan"))}
if navs:
w["stack_ms_per_nav"] = stack_ms / navs
return w
def node_weight(n):
return min(n.get("order", 9999), 9999) + (10000 if (n.get("auth") or {}).get("login") else 0)
def first_child(node):
"""resolve_firstchild() from dispatcher.uc: eligible child of lowest weight."""
best = best_name = None
for name, c in (node.get("children") or {}).items():
if not c.get("satisfied") or not c.get("title") or not isinstance(c.get("action"), dict):
continue
if c["action"].get("type") == "firstchild":
if (best is None or node_weight(best) > node_weight(c)) and first_child(c):
best, best_name = c, name
elif not c.get("firstchild_ineligible"):
if best is None or node_weight(best) > node_weight(c):
best, best_name = c, name
return (best_name, best) if best else None
def resolve(tree, segs):
"""Follow alias/firstchild to the page the dispatcher would render."""
node = tree
for s in segs:
node = (node.get("children") or {}).get(s)
if not node:
return None
for _ in range(8):
a = node.get("action") or {}
if a.get("type") == "alias":
return resolve(tree, str(a["path"]).split("/"))
if a.get("type") == "firstchild":
pick = first_child(node)
if not pick:
return None
node = pick[1]
continue
return node
return None
def discover_pages(base, ctx):
"""Return ordered [(dispatch_path, view_path, title)] of standard pages.
Tree order matters and is preserved: a tab leaf is only clickable once its
section is open, and the section's own menu link (the alias/firstchild parent)
always precedes its children in the tree — so walking in this order guarantees
every link exists in the DOM by the time we click it.
"""
tree = ctx.request.get(f"{base}/cgi-bin/luci/admin/menu").json()
rows, seen = [], set()
def walk(node, path):
for k, v in (node.get("children") or {}).items():
p = path + [k]
dp = "/".join(p)
# a link the menu can actually render: satisfied + titled (this is
# exactly ui.menu.getChildren()'s filter). Depth < 3 is a section
# header — a disclosure toggle in this theme, not a link.
if v.get("satisfied") and v.get("title") and len(p) >= 3 and dp not in seen \
and dp not in EXCLUDE_PATHS:
target = resolve(tree, p)
a = (target or {}).get("action") or {}
vp = str(a.get("path", ""))
is_page = (a.get("type") == "view" and vp.startswith(STANDARD_VIEWS)) or \
(a.get("type") == "template" and vp == OVERVIEW_TPL)
if is_page:
seen.add(dp)
rows.append((dp, vp, v["title"]))
walk(v, p)
walk(tree, [])
return rows
def wait_render(page, t_start):
"""Block until #view holds the NEW page, fully rendered; ms elapsed, None on timeout."""
try:
page.wait_for_function(RENDERED, timeout=20000)
except Exception:
return None
return (time.perf_counter() - t_start) * 1000.0
def nav_footstrap(page, base, dp):
"""SPA: real in-app click on the menu link (fires the theme's click handler)."""
sel = f'a[href$="/cgi-bin/luci/{dp}"], a[href$="/{dp}"]'
# the link may sit inside a collapsed section; a programmatic click still bubbles to the
# document handler, so visibility is irrelevant
found = page.evaluate(
"(sel)=>{const a=document.querySelector(sel); if(!a) return false;"
" a.click(); return true;}", sel)
return found
def nav_bootstrap(page, http, dp):
page.goto(f"{http}/cgi-bin/luci/{dp}", wait_until="commit")
return True
# The renderer's own CPU counters, in the order the breakdown is printed. TaskDuration is the
# headline (all main-thread task time); the rest are the parts of it worth naming.
CPU_METRICS = ("TaskDuration", "ScriptDuration", "V8CompileDuration",
"RecalcStyleDuration", "LayoutDuration")
def client_cpu(cdp):
m = {x["name"]: x["value"] for x in cdp.send("Performance.getMetrics")["metrics"]}
return {k: m.get(k, 0.0) for k in CPU_METRICS}
def run_theme(p, http, base, media, pages, runs, login):
browser = p.chromium.launch(args=["--no-sandbox"], headless=not login["headful"])
ctx = browser.new_context(ignore_https_errors=True)
ctx.request.post(f"{http}/cgi-bin/luci/",
form={"luci_username": "root", "luci_password": login["pw"]})
page = ctx.new_page()
cdp = ctx.new_cdp_session(page)
cdp.send("Performance.enable")
reqs = {"n": 0}
page.on("request", lambda r: reqs.__setitem__("n", reqs["n"] + 1))
is_foot = (media == FOOTSTRAP)
dps = [dp for dp, _, _ in pages]
# start on the first page (full load either way)
page.goto(f"{http}/cgi-bin/luci/{dps[0]}", wait_until="load")
wait_render(page, time.perf_counter())
skipped, nolink = set(), set()
spa = {}
def go(dp):
reqs["n"] = 0
cpu0 = client_cpu(cdp)
# a marker on `window` dies with a full page load and survives an in-place swap: the only
# honest way to tell a SPA nav from a router fallback. Without it a 1.0x row reads as a slow
# SPA instead of a full reload.
page.evaluate("() => { window.__benchmark = 1; }")
page.evaluate(STALE)
t = time.perf_counter()
ok = nav_footstrap(page, base, dp) if is_foot else nav_bootstrap(page, http, dp)
if not ok:
if dp not in nolink:
nolink.add(dp)
print(f" - excl {dp}: no link in the DOM at this point in the walk")
return None, 0
ms = wait_render(page, t)
if ms is None and dp not in skipped:
skipped.add(dp)
txt = page.evaluate("(()=>{var v=document.getElementById('view');"
"return v?(v.querySelector('.spinning')?'<spinning>':v.innerText.slice(0,60)):'<no #view>'})()")
print(f" ! skip {dp}: not rendered in time (#view={txt!r})")
if is_foot:
spa[dp] = bool(page.evaluate("() => window.__benchmark === 1"))
# A negative delta means the renderer process was replaced mid-run, so the
# counters restarted from zero and this sample's history is gone. Drop it
# rather than record the post-restart absolute as if it were a delta —
# that would report a suspiciously CHEAP navigation, which is the wrong
# direction to be wrong in.
cpu1 = client_cpu(cdp)
d = {k: (cpu1[k] - cpu0[k]) * 1000.0 for k in CPU_METRICS}
cpu = None if any(v < 0 for v in d.values()) else d
return ms, reqs["n"], cpu
# WARM pass (unmeasured)
for dp in dps:
go(dp)
# MEASURED passes. The router-side window opens AFTER the warm pass and
# closes before the browser does, so it covers the measured navigations and
# nothing else — no page discovery, no login, no browser startup.
host = login["host"]
navs = 0
rt0 = cpu_sample(host)
times = {dp: [] for dp in dps}
nreq = {dp: [] for dp in dps}
ccpu = {dp: [] for dp in dps}
for _ in range(runs):
for dp in dps:
ms, n, cpu = go(dp)
if ms is not None:
times[dp].append(ms)
nreq[dp].append(n)
navs += 1
if cpu:
ccpu[dp].append(cpu)
rt1 = cpu_sample(host)
# POLLING RATE, same theme, same browser, one fixed page, nobody navigating.
# Parked on the SAME page for every theme (a view that polls a lot, so the
# figure is not rounded down to nothing) — otherwise the three rates would
# describe three different pages. This is what the pass total has to be
# discounted by before "per navigation" means anything.
park = POLL_PAGE if POLL_PAGE in dps else dps[0]
go(park)
pk0 = cpu_sample(host)
time.sleep(POLL_SECS)
poll = cpu_window(pk0, cpu_sample(host))
browser.close()
return times, nreq, spa, ccpu, cpu_window(rt0, rt1, navs), navs, poll
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--ssh-host", default=os.environ.get("FOOTSTRAP_SSH", "router"))
ap.add_argument("--runs", type=int, default=5)
ap.add_argument("--headful", action="store_true")
ap.add_argument("--out", default=None, help="write JSON results here")
args = ap.parse_args()
pw = os.environ.get("LUCI_PW")
if not pw:
sys.exit("set LUCI_PW env (router root password)")
host = args.ssh_host
ip = re.search(r"^hostname (.+)$",
subprocess.check_output(["ssh", "-G", host]).decode(), re.M).group(1).strip()
http = f"http://{ip}"
login = {"pw": pw, "headful": args.headful, "host": host}
from playwright.sync_api import sync_playwright
orig = sh(host, "uci get luci.main.mediaurlbase") or BOOTSTRAP
print(f"router={http} original-theme={orig} runs={args.runs}")
# Idle baseline: what the router burns with nobody browsing. The "whole box"
# percentage below is meaningless without it — this box also routes traffic.
# Taken before AND after the whole run, so a reader can see whether the box
# stayed quiet; a single sample cannot show that.
def idle_baseline(secs=5):
a = cpu_sample(host)
time.sleep(secs)
return cpu_window(a, cpu_sample(host))
print(f"measuring idle baseline ({5} s, nobody browsing) …")
idle_pre = idle_baseline()
results = {}
try:
with sync_playwright() as p:
for media in THEMES:
sh(host, f"uci set luci.main.mediaurlbase={media}; uci commit luci; "
f"rm -f /tmp/luci-indexcache*")
# discover pages once (theme-independent), via a throwaway ctx
b = p.chromium.launch(args=["--no-sandbox"])
c = b.new_context(ignore_https_errors=True)
c.request.post(f"{http}/cgi-bin/luci/",
form={"luci_username": "root", "luci_password": pw})
pages = discover_pages(http, c)
b.close()
print(f"\n=== {media} ({len(pages)} standard pages) ===")
times, nreq, spa, ccpu, rcpu, navs, poll = run_theme(
p, http, media, media, pages, args.runs, login)
results[media] = {"pages": pages, "times": times, "nreq": nreq, "spa": spa,
"ccpu": ccpu, "rcpu": rcpu, "navs": navs, "poll": poll}
finally:
sh(host, f"uci set luci.main.mediaurlbase={orig}; uci commit luci; rm -f /tmp/luci-indexcache*")
print(f"\nreverted theme -> {orig}")
idle_post = idle_baseline()
# ---- report ----
def med(x): return statistics.median(x) if x else float("nan")
def name(media): return media.rsplit("/", 1)[-1]
pages = results[FOOTSTRAP]["pages"]
spa = results[FOOTSTRAP]["spa"]
width = 42 + 13 * len(THEMES) + 24
hdr = f"{'page':40s}" + "".join(f"{name(m):>12s}" for m in THEMES)
hdr += f"{'speedup':>9s}{'req':>9s}{'nav':>6s}"
print("\n" + "=" * width)
print(hdr)
print("-" * width)
totals = {m: 0.0 for m in THEMES}
ratios = []
rows_out = []
for dp, vp, title in pages:
vals = {m: med(results[m]["times"][dp]) for m in THEMES}
if any(v != v for v in vals.values()): # nan in any theme => not comparable
continue
for m in THEMES:
totals[m] += vals[m]
sp = vals[BASELINE] / vals[FOOTSTRAP] if vals[FOOTSTRAP] else float("nan")
ratios.append(sp)
kind = "spa" if spa.get(dp) else "full"
reqs = "/".join(f"{med(results[m]['nreq'][dp]):.0f}" for m in THEMES)
print(f"{dp:40s}" + "".join(f"{vals[m]:10.0f}ms" for m in THEMES)
+ f"{sp:8.2f}x{reqs:>9s}{kind:>6s}")
row = {"page": dp, "view": vp, "title": title,
"speedup_vs_" + name(BASELINE): round(sp, 2), "footstrap_nav": kind}
row.update({name(m) + "_ms": round(vals[m], 1) for m in THEMES})
row.update({name(m) + "_client_cpu_ms":
(round(c, 1) if c == c else None)
for m in THEMES
for c in [med([s["TaskDuration"] for s in results[m]["ccpu"][dp]])]})
rows_out.append(row)
print("-" * width)
print(f"{'TOTAL (sum of medians)':40s}" + "".join(f"{totals[m]:10.0f}ms" for m in THEMES)
+ f"{totals[BASELINE] / totals[FOOTSTRAP]:8.2f}x")
print(f"{'median per-page speedup':40s}" + " " * (12 * len(THEMES))
+ f"{statistics.median(ratios):8.2f}x")
print(f"{'pages navigated in-place (SPA)':40s} "
f"{sum(1 for r in rows_out if r['footstrap_nav'] == 'spa')}/{len(rows_out)}")
print("\nvs each theme (sum of medians / median per-page):")
for m in THEMES:
if m == FOOTSTRAP:
continue
per = statistics.median([med(results[m]["times"][dp]) / med(results[FOOTSTRAP]["times"][dp])
for dp, _, _ in pages
if med(results[FOOTSTRAP]["times"][dp]) == med(results[FOOTSTRAP]["times"][dp])
and med(results[m]["times"][dp]) == med(results[m]["times"][dp])])
print(f" footstrap vs {name(m):14s} {totals[m] / totals[FOOTSTRAP]:5.2f}x total, {per:5.2f}x median page")
print("=" * width)
# ---- CPU: two tables, because the two ends answer different questions ----
def cmed(media, dp, key):
v = [s[key] for s in results[media]["ccpu"][dp]]
return med(v)
cw = 42 + 13 * len(THEMES) + 10
print("\n" + "=" * cw)
print("CLIENT CPU — browser main-thread time per navigation (median, ms)")
print("-" * cw)
print(f"{'page':40s}" + "".join(f"{name(m):>12s}" for m in THEMES) + f"{'saved':>10s}")
print("-" * cw)
ctot = {m: 0.0 for m in THEMES}
ccov = 0
for dp, vp, title in pages:
vals = {m: cmed(m, dp, "TaskDuration") for m in THEMES}
if any(v != v for v in vals.values()):
continue
ccov += 1
for m in THEMES:
ctot[m] += vals[m]
r = vals[BASELINE] / vals[FOOTSTRAP] if vals[FOOTSTRAP] else float("nan")
print(f"{dp:40s}" + "".join(f"{vals[m]:10.0f}ms" for m in THEMES) + f"{r:9.2f}x")
print("-" * cw)
print(f"{'TOTAL (sum of medians)':40s}" + "".join(f"{ctot[m]:10.0f}ms" for m in THEMES)
+ (f"{ctot[BASELINE] / ctot[FOOTSTRAP]:9.2f}x" if ctot[FOOTSTRAP] else ""))
print(f"{'per navigation (mean over pages)':40s}"
+ "".join(f"{(ctot[m] / ccov if ccov else float('nan')):10.1f}ms" for m in THEMES))
# COVERAGE, and it decides whether the table above may be quoted at all. A sample is
# dropped when the renderer process was replaced mid-navigation (negative delta, see go()),
# and that does not fall evenly: a full-load theme restarts the renderer far more often than
# an in-place swap does, so the surviving samples for it are the cheap navigations. Comparing
# two themes across such a gap reads as "the SPA theme burns more CPU" when what actually
# happened is that the other theme's expensive navigations were thrown away. Measured on
# 2026-08-01: footstrap kept 38 of 38 pages, bootstrap 18, proton2025 16.
print(f"\n{'pages with a sample from EVERY theme':40s}{ccov:6d} of {len(pages)}")
for m in THEMES:
kept = sum(1 for dp, _, _ in pages if cmed(m, dp, "TaskDuration") == cmed(m, dp, "TaskDuration"))
print(f" {name(m):20s} kept {kept:3d} of {len(pages)} "
f"({len(pages) - kept} dropped: renderer restarted mid-navigation)")
if ccov < len(pages) * 0.9:
print(" ^ COVERAGE IS PARTIAL AND UNEVEN — do not quote the cross-theme CPU ratio above.\n"
" Compare like with like instead (same nav kind), or re-run until coverage is full.")
print("\nbreakdown — median per navigation over all pages (ms):")
for key in CPU_METRICS[1:]:
row = {m: med([cmed(m, dp, key) for dp, _, _ in pages
if cmed(m, dp, key) == cmed(m, dp, key)]) for m in THEMES}
label = {"ScriptDuration": "script", "V8CompileDuration": "v8 compile",
"RecalcStyleDuration": "style recalc", "LayoutDuration": "layout"}[key]
print(f" {label:38s}" + "".join(f"{row[m]:10.1f}ms" for m in THEMES))
print("=" * cw)
print("\n" + "=" * cw)
print("ROUTER CPU — measured on the router itself, over each theme's measured passes")
print("-" * cw)
print(f"{'':40s}" + "".join(f"{name(m):>12s}" for m in THEMES))
print("-" * cw)
rc = {m: results[m]["rcpu"] for m in THEMES}
pl = {m: results[m]["poll"] for m in THEMES}
def nav_ms(m):
"""What one navigation cost the router, once the open page's own polling is out."""
if not rc[m] or not pl[m] or not results[m]["navs"]:
return float("nan")
rate = pl[m]["stack_ms"] / pl[m]["secs"] # ms of CPU per second, parked
return max(rc[m]["stack_ms"] - rate * rc[m]["secs"], 0.0) / results[m]["navs"]
if all(rc[m] for m in THEMES):
print(f"{'navigations (tour of every page x runs)':40s}"
+ "".join(f"{results[m]['navs']:12d}" for m in THEMES))
print(f"{'tour duration':40s}" + "".join(f"{rc[m]['secs']:11.0f}s" for m in THEMES))
print(f"{'web stack CPU for the whole tour':40s}"
+ "".join(f"{rc[m]['stack_ms'] / 1000.0:11.1f}s" for m in THEMES))
print(f"{' polling rate, parked on one page':40s}"
+ "".join((f"{pl[m]['stack_ms'] / pl[m]['secs']:8.0f}ms/s" if pl[m] else f"{'n/a':>11s}")
for m in THEMES))
print(f"{' => per navigation, polling removed':40s}"
+ "".join(f"{nav_ms(m):10.0f}ms" for m in THEMES))
# RATES, not costs. Both rows are per-second averages over a window whose
# LENGTH is what the theme changed, so a faster theme compresses the same
# work into less time and its percentage goes UP while its CPU-seconds go
# down. Measured here: footstrap 26.0% box vs bootstrap 22.4%, on 19.3s of
# CPU against 37.6s. Anyone reading the percentage as the cost reads it
# backwards, so the rows say so.
print(f"{'web stack, share of the box (RATE)':40s}"
+ "".join(f"{rc[m]['stack_pct']:11.1f}%" for m in THEMES))
print(f"{'whole box busy (RATE, incl. routing)':40s}"
+ "".join(f"{rc[m]['box_pct']:11.1f}%" for m in THEMES))
print(f"{'load average, end of tour':40s}" + "".join(f"{rc[m]['load']:12.2f}" for m in THEMES))
print(f"{'':40s}" + " <- rates over a window whose length the theme changed;")
print(f"{'':40s}" + " compare CPU-SECONDS above, not these percentages.")
print("-" * cw)
for lbl, w in (("idle baseline, before the run", idle_pre),
("idle baseline, after the run", idle_post)):
if w:
print(f"{lbl:40s}{w['box_pct']:11.1f}% box busy, "
f"{w['stack_ms'] / w['secs']:.0f} ms/s web stack, load {w['load']:.2f}")
print(f"\n{rc[FOOTSTRAP]['ncpu']:.0f} cores. 'web stack' is uhttpd+rpcd+ubusd, counting "
"utime+stime+cutime+cstime, so\nthe CPU of a reaped CGI child lands in its parent "
f"and the ucode that renders the shell\nIS counted. A LuCI view polls while it is open "
f"(parked on {POLL_PAGE},\n{POLL_SECS} s), which is why the tour total is discounted by "
"that rate before dividing by\nthe navigation count.\n"
"\nCheck the parked rate against the idle baseline before trusting the discount: when "
"the\ntwo are within noise of each other, that theme's polling is too cheap to "
"measure and the\ndiscount is mostly removing the router's own background — which "
"makes the per-navigation\nfigure conservative, not flattering. A rate well above the "
"baseline is real polling.")
else:
print(" (unavailable: the router's uptime went backwards — rebooted mid-run?)")
print("=" * cw)
if args.out:
out = {"router": http, "runs": args.runs, "themes": [name(m) for m in THEMES],
"baseline": name(BASELINE),
"spa_pages": sum(1 for r in rows_out if r["footstrap_nav"] == "spa"),
"median_speedup": round(statistics.median(ratios), 2),
"pages": rows_out}
out.update({"total_" + name(m) + "_ms": round(totals[m], 1) for m in THEMES})
out["total_speedup"] = round(totals[BASELINE] / totals[FOOTSTRAP], 2)
out["cpu"] = {
"note": ("client = CDP Performance deltas per navigation (main thread). "
"router = /proc on the router; the web-stack figure is "
"utime+stime+cutime+cstime of uhttpd+rpcd+ubusd, so reaped CGI "
"children count. 'box' is /proc/stat and includes everything else "
"the router does — compare it against idle_baseline."),
"client_total_ms": {name(m): round(ctot[m], 1) for m in THEMES},
"client_per_nav_ms": {name(m): round(ctot[m] / ccov, 2) for m in THEMES} if ccov else {},
"client_breakdown_median_ms": {
name(m): {k: round(med([cmed(m, dp, k) for dp, _, _ in pages
if cmed(m, dp, k) == cmed(m, dp, k)]), 2)
for k in CPU_METRICS}
for m in THEMES},
"router": {name(m): ({k: round(v, 2) for k, v in rc[m].items()} if rc[m] else None)
for m in THEMES},
"router_navs": {name(m): results[m]["navs"] for m in THEMES},
"router_poll_page": POLL_PAGE,
"router_poll_ms_per_s": {name(m): (round(pl[m]["stack_ms"] / pl[m]["secs"], 1)
if pl[m] else None) for m in THEMES},
"router_nav_ms_polling_removed": {name(m): round(nav_ms(m), 1) for m in THEMES},
"idle_baseline": {w: ({k: round(v, 2) for k, v in s.items()} if s else None)
for w, s in (("before", idle_pre), ("after", idle_post))},
}
json.dump(out, open(args.out, "w"), indent=2)
print(f"wrote {args.out}")
if __name__ == "__main__":
main()
@@ -0,0 +1,611 @@
{
"router": "http://10.11.12.1",
"runs": 5,
"themes": [
"bootstrap",
"proton2025",
"footstrap"
],
"baseline": "bootstrap",
"spa_pages": 38,
"median_speedup": 3.03,
"pages": [
{
"page": "admin/system/package-manager",
"view": "package-manager",
"title": "Software",
"speedup_vs_bootstrap": 1.12,
"footstrap_nav": "spa",
"bootstrap_ms": 862.6,
"proton2025_ms": 877.3,
"footstrap_ms": 767.5,
"bootstrap_client_cpu_ms": 176.8,
"proton2025_client_cpu_ms": 159.3,
"footstrap_client_cpu_ms": 240.2
},
{
"page": "admin/system/system",
"view": "system/system",
"title": "System",
"speedup_vs_bootstrap": 2.98,
"footstrap_nav": "spa",
"bootstrap_ms": 414.9,
"proton2025_ms": 382.6,
"footstrap_ms": 139.0,
"bootstrap_client_cpu_ms": null,
"proton2025_client_cpu_ms": null,
"footstrap_client_cpu_ms": 29.1
},
{
"page": "admin/system/admin",
"view": "system/password",
"title": "Administration",
"speedup_vs_bootstrap": 14.16,
"footstrap_nav": "spa",
"bootstrap_ms": 134.5,
"proton2025_ms": 149.3,
"footstrap_ms": 9.5,
"bootstrap_client_cpu_ms": null,
"proton2025_client_cpu_ms": null,
"footstrap_client_cpu_ms": 6.3
},
{
"page": "admin/system/admin/password",
"view": "system/password",
"title": "Router Password",
"speedup_vs_bootstrap": 17.15,
"footstrap_nav": "spa",
"bootstrap_ms": 139.1,
"proton2025_ms": 152.0,
"footstrap_ms": 8.1,
"bootstrap_client_cpu_ms": 1.7,
"proton2025_client_cpu_ms": 0.9,
"footstrap_client_cpu_ms": 5.3
},
{
"page": "admin/system/admin/dropbear",
"view": "system/dropbear",
"title": "SSH Access",
"speedup_vs_bootstrap": 5.51,
"footstrap_nav": "spa",
"bootstrap_ms": 321.8,
"proton2025_ms": 319.1,
"footstrap_ms": 58.4,
"bootstrap_client_cpu_ms": 18.9,
"proton2025_client_cpu_ms": 20.6,
"footstrap_client_cpu_ms": 9.2
},
{
"page": "admin/system/admin/sshkeys",
"view": "system/sshkeys",
"title": "SSH-Keys",
"speedup_vs_bootstrap": 2.69,
"footstrap_nav": "spa",
"bootstrap_ms": 143.3,
"proton2025_ms": 167.4,
"footstrap_ms": 53.3,
"bootstrap_client_cpu_ms": null,
"proton2025_client_cpu_ms": null,
"footstrap_client_cpu_ms": 8.0
},
{
"page": "admin/system/admin/uhttpd",
"view": "system/uhttpd",
"title": "HTTP(S) Access",
"speedup_vs_bootstrap": 2.52,
"footstrap_nav": "spa",
"bootstrap_ms": 141.8,
"proton2025_ms": 165.9,
"footstrap_ms": 56.3,
"bootstrap_client_cpu_ms": null,
"proton2025_client_cpu_ms": null,
"footstrap_client_cpu_ms": 7.5
},
{
"page": "admin/system/admin/repokeys",
"view": "system/repokeys",
"title": "Repo Public Keys",
"speedup_vs_bootstrap": 1.87,
"footstrap_nav": "spa",
"bootstrap_ms": 289.2,
"proton2025_ms": 287.9,
"footstrap_ms": 155.0,
"bootstrap_client_cpu_ms": null,
"proton2025_client_cpu_ms": 8.5,
"footstrap_client_cpu_ms": 13.0
},
{
"page": "admin/system/startup",
"view": "system/startup",
"title": "Startup",
"speedup_vs_bootstrap": 1.12,
"footstrap_nav": "spa",
"bootstrap_ms": 1136.3,
"proton2025_ms": 1188.4,
"footstrap_ms": 1015.9,
"bootstrap_client_cpu_ms": 22.1,
"proton2025_client_cpu_ms": 26.0,
"footstrap_client_cpu_ms": 28.0
},
{
"page": "admin/system/crontab",
"view": "system/crontab",
"title": "Scheduled Tasks",
"speedup_vs_bootstrap": 3.33,
"footstrap_nav": "spa",
"bootstrap_ms": 178.2,
"proton2025_ms": 167.6,
"footstrap_ms": 53.5,
"bootstrap_client_cpu_ms": null,
"proton2025_client_cpu_ms": null,
"footstrap_client_cpu_ms": 7.4
},
{
"page": "admin/system/leds",
"view": "system/leds",
"title": "LED Configuration",
"speedup_vs_bootstrap": 1.97,
"footstrap_nav": "spa",
"bootstrap_ms": 207.4,
"proton2025_ms": 238.4,
"footstrap_ms": 105.5,
"bootstrap_client_cpu_ms": 8.7,
"proton2025_client_cpu_ms": 13.6,
"footstrap_client_cpu_ms": 10.4
},
{
"page": "admin/system/flash",
"view": "system/flash",
"title": "Backup / Flash Firmware",
"speedup_vs_bootstrap": 2.3,
"footstrap_nav": "spa",
"bootstrap_ms": 238.0,
"proton2025_ms": 211.9,
"footstrap_ms": 103.6,
"bootstrap_client_cpu_ms": null,
"proton2025_client_cpu_ms": null,
"footstrap_client_cpu_ms": 11.9
},
{
"page": "admin/system/reboot",
"view": "system/reboot",
"title": "Reboot",
"speedup_vs_bootstrap": 2.22,
"footstrap_nav": "spa",
"bootstrap_ms": 146.6,
"proton2025_ms": 167.5,
"footstrap_ms": 65.9,
"bootstrap_client_cpu_ms": null,
"proton2025_client_cpu_ms": null,
"footstrap_client_cpu_ms": 7.0
},
{
"page": "admin/status/realtime",
"view": "status/load",
"title": "Realtime Graphs",
"speedup_vs_bootstrap": 9.6,
"footstrap_nav": "spa",
"bootstrap_ms": 105.7,
"proton2025_ms": 142.2,
"footstrap_ms": 11.0,
"bootstrap_client_cpu_ms": 0.1,
"proton2025_client_cpu_ms": 2.2,
"footstrap_client_cpu_ms": 7.8
},
{
"page": "admin/status/realtime/cpu",
"view": "status/cpu",
"title": "CPU",
"speedup_vs_bootstrap": 2.89,
"footstrap_nav": "spa",
"bootstrap_ms": 188.5,
"proton2025_ms": 205.3,
"footstrap_ms": 65.1,
"bootstrap_client_cpu_ms": 4.6,
"proton2025_client_cpu_ms": null,
"footstrap_client_cpu_ms": 15.1
},
{
"page": "admin/status/realtime/load",
"view": "status/load",
"title": "Load",
"speedup_vs_bootstrap": 13.46,
"footstrap_nav": "spa",
"bootstrap_ms": 138.8,
"proton2025_ms": 146.2,
"footstrap_ms": 10.3,
"bootstrap_client_cpu_ms": null,
"proton2025_client_cpu_ms": null,
"footstrap_client_cpu_ms": 6.7
},
{
"page": "admin/status/realtime/bandwidth",
"view": "status/bandwidth",
"title": "Bandwidth",
"speedup_vs_bootstrap": 10.39,
"footstrap_nav": "spa",
"bootstrap_ms": 206.6,
"proton2025_ms": 286.3,
"footstrap_ms": 19.9,
"bootstrap_client_cpu_ms": 16.1,
"proton2025_client_cpu_ms": 19.6,
"footstrap_client_cpu_ms": 16.3
},
{
"page": "admin/status/realtime/wireless",
"view": "status/wireless",
"title": "Wireless",
"speedup_vs_bootstrap": 4.98,
"footstrap_nav": "spa",
"bootstrap_ms": 270.9,
"proton2025_ms": 303.9,
"footstrap_ms": 54.4,
"bootstrap_client_cpu_ms": null,
"proton2025_client_cpu_ms": 4.0,
"footstrap_client_cpu_ms": 12.2
},
{
"page": "admin/status/realtime/connections",
"view": "status/connections",
"title": "Connections",
"speedup_vs_bootstrap": 2.73,
"footstrap_nav": "spa",
"bootstrap_ms": 188.2,
"proton2025_ms": 149.7,
"footstrap_ms": 69.1,
"bootstrap_client_cpu_ms": null,
"proton2025_client_cpu_ms": null,
"footstrap_client_cpu_ms": 10.7
},
{
"page": "admin/status/overview",
"view": "admin_status/index",
"title": "Overview",
"speedup_vs_bootstrap": 2.23,
"footstrap_nav": "spa",
"bootstrap_ms": 875.4,
"proton2025_ms": 893.3,
"footstrap_ms": 392.1,
"bootstrap_client_cpu_ms": 36.9,
"proton2025_client_cpu_ms": 56.6,
"footstrap_client_cpu_ms": 19.9
},
{
"page": "admin/status/routesj",
"view": "status/routesj",
"title": "Routing",
"speedup_vs_bootstrap": 1.33,
"footstrap_nav": "spa",
"bootstrap_ms": 232.0,
"proton2025_ms": 265.5,
"footstrap_ms": 174.5,
"bootstrap_client_cpu_ms": null,
"proton2025_client_cpu_ms": null,
"footstrap_client_cpu_ms": 27.8
},
{
"page": "admin/status/nftables",
"view": "status/nftables",
"title": "Firewall",
"speedup_vs_bootstrap": 1.67,
"footstrap_nav": "spa",
"bootstrap_ms": 213.8,
"proton2025_ms": 245.2,
"footstrap_ms": 128.1,
"bootstrap_client_cpu_ms": 16.1,
"proton2025_client_cpu_ms": 23.3,
"footstrap_client_cpu_ms": 48.2
},
{
"page": "admin/status/logs",
"view": "status/syslog",
"title": "System Log",
"speedup_vs_bootstrap": 4.88,
"footstrap_nav": "spa",
"bootstrap_ms": 198.8,
"proton2025_ms": 254.4,
"footstrap_ms": 40.7,
"bootstrap_client_cpu_ms": null,
"proton2025_client_cpu_ms": null,
"footstrap_client_cpu_ms": 27.9
},
{
"page": "admin/status/logs/syslog",
"view": "status/syslog",
"title": "System Log",
"speedup_vs_bootstrap": 3.54,
"footstrap_nav": "spa",
"bootstrap_ms": 183.8,
"proton2025_ms": 221.8,
"footstrap_ms": 51.9,
"bootstrap_client_cpu_ms": 3.6,
"proton2025_client_cpu_ms": null,
"footstrap_client_cpu_ms": 21.4
},
{
"page": "admin/status/logs/dmesg",
"view": "status/dmesg",
"title": "Kernel Log",
"speedup_vs_bootstrap": 7.57,
"footstrap_nav": "spa",
"bootstrap_ms": 150.9,
"proton2025_ms": 161.7,
"footstrap_ms": 19.9,
"bootstrap_client_cpu_ms": null,
"proton2025_client_cpu_ms": null,
"footstrap_client_cpu_ms": 10.8
},
{
"page": "admin/status/processes",
"view": "status/processes",
"title": "Processes",
"speedup_vs_bootstrap": 1.85,
"footstrap_nav": "spa",
"bootstrap_ms": 290.8,
"proton2025_ms": 314.5,
"footstrap_ms": 156.9,
"bootstrap_client_cpu_ms": 6.6,
"proton2025_client_cpu_ms": 10.9,
"footstrap_client_cpu_ms": 23.6
},
{
"page": "admin/network/firewall",
"view": "firewall/zones",
"title": "Firewall",
"speedup_vs_bootstrap": 3.23,
"footstrap_nav": "spa",
"bootstrap_ms": 290.0,
"proton2025_ms": 385.6,
"footstrap_ms": 89.9,
"bootstrap_client_cpu_ms": null,
"proton2025_client_cpu_ms": null,
"footstrap_client_cpu_ms": 26.8
},
{
"page": "admin/network/firewall/zones",
"view": "firewall/zones",
"title": "General Settings",
"speedup_vs_bootstrap": 3.93,
"footstrap_nav": "spa",
"bootstrap_ms": 310.8,
"proton2025_ms": 333.2,
"footstrap_ms": 79.1,
"bootstrap_client_cpu_ms": 1.7,
"proton2025_client_cpu_ms": null,
"footstrap_client_cpu_ms": 20.3
},
{
"page": "admin/network/firewall/forwards",
"view": "firewall/forwards",
"title": "Port Forwards",
"speedup_vs_bootstrap": 3.0,
"footstrap_nav": "spa",
"bootstrap_ms": 342.5,
"proton2025_ms": 358.2,
"footstrap_ms": 114.3,
"bootstrap_client_cpu_ms": null,
"proton2025_client_cpu_ms": null,
"footstrap_client_cpu_ms": 14.8
},
{
"page": "admin/network/firewall/rules",
"view": "firewall/rules",
"title": "Traffic Rules",
"speedup_vs_bootstrap": 3.49,
"footstrap_nav": "spa",
"bootstrap_ms": 335.8,
"proton2025_ms": 361.7,
"footstrap_ms": 96.3,
"bootstrap_client_cpu_ms": 18.3,
"proton2025_client_cpu_ms": 10.1,
"footstrap_client_cpu_ms": 22.0
},
{
"page": "admin/network/firewall/snats",
"view": "firewall/snats",
"title": "NAT Rules",
"speedup_vs_bootstrap": 2.09,
"footstrap_nav": "spa",
"bootstrap_ms": 202.1,
"proton2025_ms": 256.4,
"footstrap_ms": 96.9,
"bootstrap_client_cpu_ms": null,
"proton2025_client_cpu_ms": null,
"footstrap_client_cpu_ms": 11.8
},
{
"page": "admin/network/firewall/ipsets",
"view": "firewall/ipsets",
"title": "IP Sets",
"speedup_vs_bootstrap": 3.7,
"footstrap_nav": "spa",
"bootstrap_ms": 201.4,
"proton2025_ms": 222.0,
"footstrap_ms": 54.4,
"bootstrap_client_cpu_ms": null,
"proton2025_client_cpu_ms": null,
"footstrap_client_cpu_ms": 7.8
},
{
"page": "admin/network/wireless",
"view": "network/wireless",
"title": "Wireless",
"speedup_vs_bootstrap": 2.49,
"footstrap_nav": "spa",
"bootstrap_ms": 389.4,
"proton2025_ms": 390.6,
"footstrap_ms": 156.5,
"bootstrap_client_cpu_ms": 12.9,
"proton2025_client_cpu_ms": 26.4,
"footstrap_client_cpu_ms": 18.4
},
{
"page": "admin/network/network",
"view": "network/interfaces",
"title": "Interfaces",
"speedup_vs_bootstrap": 3.37,
"footstrap_nav": "spa",
"bootstrap_ms": 374.3,
"proton2025_ms": 386.3,
"footstrap_ms": 111.1,
"bootstrap_client_cpu_ms": 15.7,
"proton2025_client_cpu_ms": null,
"footstrap_client_cpu_ms": 27.0
},
{
"page": "admin/network/routes",
"view": "network/routes",
"title": "Routing",
"speedup_vs_bootstrap": 3.54,
"footstrap_nav": "spa",
"bootstrap_ms": 317.4,
"proton2025_ms": 339.2,
"footstrap_ms": 89.7,
"bootstrap_client_cpu_ms": null,
"proton2025_client_cpu_ms": null,
"footstrap_client_cpu_ms": 14.9
},
{
"page": "admin/network/dhcp",
"view": "network/dhcp",
"title": "DHCP",
"speedup_vs_bootstrap": 2.76,
"footstrap_nav": "spa",
"bootstrap_ms": 361.3,
"proton2025_ms": 367.4,
"footstrap_ms": 130.9,
"bootstrap_client_cpu_ms": 11.3,
"proton2025_client_cpu_ms": 11.5,
"footstrap_client_cpu_ms": 27.9
},
{
"page": "admin/network/dns",
"view": "network/dns",
"title": "DNS",
"speedup_vs_bootstrap": 3.05,
"footstrap_nav": "spa",
"bootstrap_ms": 328.9,
"proton2025_ms": 370.3,
"footstrap_ms": 107.7,
"bootstrap_client_cpu_ms": 10.7,
"proton2025_client_cpu_ms": 18.0,
"footstrap_client_cpu_ms": 30.4
},
{
"page": "admin/network/diagnostics",
"view": "network/diagnostics",
"title": "Diagnostics",
"speedup_vs_bootstrap": 12.25,
"footstrap_nav": "spa",
"bootstrap_ms": 253.6,
"proton2025_ms": 305.4,
"footstrap_ms": 20.7,
"bootstrap_client_cpu_ms": null,
"proton2025_client_cpu_ms": null,
"footstrap_client_cpu_ms": 10.6
}
],
"total_bootstrap_ms": 11305.6,
"total_proton2025_ms": 12141.9,
"total_footstrap_ms": 4932.9,
"total_speedup": 2.29,
"cpu": {
"note": "client = CDP Performance deltas per navigation (main thread). router = /proc on the router; the web-stack figure is utime+stime+cutime+cstime of uhttpd+rpcd+ubusd, so reaped CGI children count. 'box' is /proc/stat and includes everything else the router does \u2014 compare it against idle_baseline.",
"client_total_ms": {
"bootstrap": 357.3,
"proton2025": 399.1,
"footstrap": 507.6
},
"client_per_nav_ms": {
"bootstrap": 25.52,
"proton2025": 28.51,
"footstrap": 36.26
},
"client_breakdown_median_ms": {
"bootstrap": {
"TaskDuration": 12.1,
"ScriptDuration": 0.49,
"V8CompileDuration": 0.0,
"RecalcStyleDuration": 0.6,
"LayoutDuration": 0.43
},
"proton2025": {
"TaskDuration": 15.8,
"ScriptDuration": 0.89,
"V8CompileDuration": 0.01,
"RecalcStyleDuration": 1.16,
"LayoutDuration": 0.82
},
"footstrap": {
"TaskDuration": 14.84,
"ScriptDuration": 2.25,
"V8CompileDuration": 0.0,
"RecalcStyleDuration": 2.33,
"LayoutDuration": 0.87
}
},
"router": {
"bootstrap": {
"secs": 62.18,
"ncpu": 4.0,
"stack_ms": 37290.0,
"box_pct": 21.81,
"stack_pct": 14.99,
"load": 0.74,
"stack_ms_per_nav": 196.26
},
"proton2025": {
"secs": 67.49,
"ncpu": 4.0,
"stack_ms": 45660.0,
"box_pct": 26.62,
"stack_pct": 16.91,
"load": 1.17,
"stack_ms_per_nav": 240.32
},
"footstrap": {
"secs": 28.84,
"ncpu": 4.0,
"stack_ms": 18450.0,
"box_pct": 23.29,
"stack_pct": 15.99,
"load": 0.94,
"stack_ms_per_nav": 97.11
}
},
"router_navs": {
"bootstrap": 190,
"proton2025": 190,
"footstrap": 190
},
"router_poll_page": "admin/status/overview",
"router_poll_ms_per_s": {
"bootstrap": 12.5,
"proton2025": 88.4,
"footstrap": 14.0
},
"router_nav_ms_polling_removed": {
"bootstrap": 192.2,
"proton2025": 208.9,
"footstrap": 95.0
},
"idle_baseline": {
"before": {
"secs": 5.44,
"ncpu": 4.0,
"stack_ms": 0.0,
"box_pct": 2.85,
"stack_pct": 0.0,
"load": 0.05
},
"after": {
"secs": 5.36,
"ncpu": 4.0,
"stack_ms": 10.0,
"box_pct": 0.94,
"stack_pct": 0.05,
"load": 0.81
}
}
}
}
@@ -0,0 +1,331 @@
{
"router": "http://172.31.0.3",
"runs": 3,
"themes": [
"bootstrap",
"footstrap"
],
"baseline": "bootstrap",
"spa_pages": 34,
"median_speedup": 2.4,
"pages": [
{
"page": "admin/system/filemanager",
"view": "system/filemanager",
"title": "File Manager",
"speedup_vs_bootstrap": 1.53,
"footstrap_nav": "spa",
"bootstrap_ms": 190.2,
"footstrap_ms": 124.2
},
{
"page": "admin/system/package-manager",
"view": "package-manager",
"title": "Software",
"speedup_vs_bootstrap": 1.07,
"footstrap_nav": "full",
"bootstrap_ms": 140.2,
"footstrap_ms": 130.8
},
{
"page": "admin/system/system",
"view": "system/system",
"title": "System",
"speedup_vs_bootstrap": 1.38,
"footstrap_nav": "spa",
"bootstrap_ms": 282.3,
"footstrap_ms": 205.2
},
{
"page": "admin/system/admin",
"view": "system/password",
"title": "Administration",
"speedup_vs_bootstrap": 3.79,
"footstrap_nav": "spa",
"bootstrap_ms": 69.5,
"footstrap_ms": 18.4
},
{
"page": "admin/system/admin/password",
"view": "system/password",
"title": "Router Password",
"speedup_vs_bootstrap": 5.61,
"footstrap_nav": "spa",
"bootstrap_ms": 88.5,
"footstrap_ms": 15.8
},
{
"page": "admin/system/admin/dropbear",
"view": "system/dropbear",
"title": "SSH Access",
"speedup_vs_bootstrap": 6.2,
"footstrap_nav": "spa",
"bootstrap_ms": 239.6,
"footstrap_ms": 38.6
},
{
"page": "admin/system/admin/sshkeys",
"view": "system/sshkeys",
"title": "SSH-Keys",
"speedup_vs_bootstrap": 4.04,
"footstrap_nav": "spa",
"bootstrap_ms": 138.1,
"footstrap_ms": 34.2
},
{
"page": "admin/system/admin/uhttpd",
"view": "system/uhttpd",
"title": "HTTP(S) Access",
"speedup_vs_bootstrap": 1.84,
"footstrap_nav": "spa",
"bootstrap_ms": 119.9,
"footstrap_ms": 65.1
},
{
"page": "admin/system/admin/repokeys",
"view": "system/repokeys",
"title": "Repo Public Keys",
"speedup_vs_bootstrap": 1.66,
"footstrap_nav": "spa",
"bootstrap_ms": 224.7,
"footstrap_ms": 135.0
},
{
"page": "admin/system/startup",
"view": "system/startup",
"title": "Startup",
"speedup_vs_bootstrap": 1.28,
"footstrap_nav": "spa",
"bootstrap_ms": 441.7,
"footstrap_ms": 343.7
},
{
"page": "admin/system/crontab",
"view": "system/crontab",
"title": "Scheduled Tasks",
"speedup_vs_bootstrap": 3.42,
"footstrap_nav": "spa",
"bootstrap_ms": 119.6,
"footstrap_ms": 35.0
},
{
"page": "admin/system/flash",
"view": "system/flash",
"title": "Backup / Flash Firmware",
"speedup_vs_bootstrap": 2.01,
"footstrap_nav": "spa",
"bootstrap_ms": 172.2,
"footstrap_ms": 85.6
},
{
"page": "admin/system/reboot",
"view": "system/reboot",
"title": "Reboot",
"speedup_vs_bootstrap": 3.44,
"footstrap_nav": "spa",
"bootstrap_ms": 124.3,
"footstrap_ms": 36.2
},
{
"page": "admin/network/firewall",
"view": "firewall/zones",
"title": "Firewall",
"speedup_vs_bootstrap": 2.61,
"footstrap_nav": "spa",
"bootstrap_ms": 237.6,
"footstrap_ms": 91.0
},
{
"page": "admin/network/firewall/zones",
"view": "firewall/zones",
"title": "General Settings",
"speedup_vs_bootstrap": 3.48,
"footstrap_nav": "spa",
"bootstrap_ms": 258.2,
"footstrap_ms": 74.1
},
{
"page": "admin/network/firewall/forwards",
"view": "firewall/forwards",
"title": "Port Forwards",
"speedup_vs_bootstrap": 2.47,
"footstrap_nav": "spa",
"bootstrap_ms": 268.5,
"footstrap_ms": 108.8
},
{
"page": "admin/network/firewall/rules",
"view": "firewall/rules",
"title": "Traffic Rules",
"speedup_vs_bootstrap": 2.4,
"footstrap_nav": "spa",
"bootstrap_ms": 294.4,
"footstrap_ms": 122.9
},
{
"page": "admin/network/firewall/snats",
"view": "firewall/snats",
"title": "NAT Rules",
"speedup_vs_bootstrap": 2.16,
"footstrap_nav": "spa",
"bootstrap_ms": 223.9,
"footstrap_ms": 103.7
},
{
"page": "admin/network/firewall/ipsets",
"view": "firewall/ipsets",
"title": "IP Sets",
"speedup_vs_bootstrap": 4.01,
"footstrap_nav": "spa",
"bootstrap_ms": 167.9,
"footstrap_ms": 41.9
},
{
"page": "admin/network/sqm",
"view": "network/sqm",
"title": "SQM QoS",
"speedup_vs_bootstrap": 2.27,
"footstrap_nav": "spa",
"bootstrap_ms": 317.1,
"footstrap_ms": 139.8
},
{
"page": "admin/network/network",
"view": "network/interfaces",
"title": "Interfaces",
"speedup_vs_bootstrap": 2.8,
"footstrap_nav": "spa",
"bootstrap_ms": 262.2,
"footstrap_ms": 93.6
},
{
"page": "admin/network/routes",
"view": "network/routes",
"title": "Routing",
"speedup_vs_bootstrap": 2.21,
"footstrap_nav": "spa",
"bootstrap_ms": 203.4,
"footstrap_ms": 92.1
},
{
"page": "admin/network/dhcp",
"view": "network/dhcp",
"title": "DHCP and DNS",
"speedup_vs_bootstrap": 1.97,
"footstrap_nav": "spa",
"bootstrap_ms": 220.1,
"footstrap_ms": 111.9
},
{
"page": "admin/network/diagnostics",
"view": "network/diagnostics",
"title": "Diagnostics",
"speedup_vs_bootstrap": 5.18,
"footstrap_nav": "spa",
"bootstrap_ms": 166.2,
"footstrap_ms": 32.1
},
{
"page": "admin/status/overview",
"view": "admin_status/index",
"title": "Overview",
"speedup_vs_bootstrap": 1.29,
"footstrap_nav": "spa",
"bootstrap_ms": 342.8,
"footstrap_ms": 265.3
},
{
"page": "admin/status/routes",
"view": "status/routes",
"title": "Routing",
"speedup_vs_bootstrap": 3.66,
"footstrap_nav": "spa",
"bootstrap_ms": 151.6,
"footstrap_ms": 41.4
},
{
"page": "admin/status/nftables",
"view": "status/nftables",
"title": "Firewall",
"speedup_vs_bootstrap": 1.93,
"footstrap_nav": "spa",
"bootstrap_ms": 116.9,
"footstrap_ms": 60.4
},
{
"page": "admin/status/logs",
"view": "status/syslog",
"title": "System Log",
"speedup_vs_bootstrap": 1.36,
"footstrap_nav": "spa",
"bootstrap_ms": 170.0,
"footstrap_ms": 124.9
},
{
"page": "admin/status/logs/syslog",
"view": "status/syslog",
"title": "System Log",
"speedup_vs_bootstrap": 2.28,
"footstrap_nav": "spa",
"bootstrap_ms": 185.9,
"footstrap_ms": 81.4
},
{
"page": "admin/status/logs/dmesg",
"view": "status/dmesg",
"title": "Kernel Log",
"speedup_vs_bootstrap": 4.46,
"footstrap_nav": "spa",
"bootstrap_ms": 120.1,
"footstrap_ms": 26.9
},
{
"page": "admin/status/processes",
"view": "status/processes",
"title": "Processes",
"speedup_vs_bootstrap": 1.4,
"footstrap_nav": "spa",
"bootstrap_ms": 186.4,
"footstrap_ms": 132.9
},
{
"page": "admin/status/realtime",
"view": "status/load",
"title": "Realtime Graphs",
"speedup_vs_bootstrap": 6.21,
"footstrap_nav": "spa",
"bootstrap_ms": 116.8,
"footstrap_ms": 18.8
},
{
"page": "admin/status/realtime/load",
"view": "status/load",
"title": "Load",
"speedup_vs_bootstrap": 7.64,
"footstrap_nav": "spa",
"bootstrap_ms": 134.1,
"footstrap_ms": 17.6
},
{
"page": "admin/status/realtime/bandwidth",
"view": "status/bandwidth",
"title": "Bandwidth",
"speedup_vs_bootstrap": 2.12,
"footstrap_nav": "spa",
"bootstrap_ms": 137.7,
"footstrap_ms": 64.9
},
{
"page": "admin/status/realtime/connections",
"view": "status/connections",
"title": "Connections",
"speedup_vs_bootstrap": 7.45,
"footstrap_nav": "spa",
"bootstrap_ms": 120.3,
"footstrap_ms": 16.1
}
],
"total_bootstrap_ms": 6753.0,
"total_footstrap_ms": 3130.4,
"total_speedup": 2.16
}
@@ -0,0 +1,340 @@
{
"router": "http://172.31.0.2",
"runs": 3,
"themes": [
"bootstrap",
"footstrap"
],
"baseline": "bootstrap",
"spa_pages": 34,
"median_speedup": 3.04,
"pages": [
{
"page": "admin/system/filemanager",
"view": "system/filemanager",
"title": "File Manager",
"speedup_vs_bootstrap": 1.85,
"footstrap_nav": "spa",
"bootstrap_ms": 167.7,
"footstrap_ms": 90.9
},
{
"page": "admin/system/package-manager",
"view": "package-manager",
"title": "Software",
"speedup_vs_bootstrap": 1.15,
"footstrap_nav": "full",
"bootstrap_ms": 279.7,
"footstrap_ms": 242.9
},
{
"page": "admin/system/system",
"view": "system/system",
"title": "System",
"speedup_vs_bootstrap": 1.4,
"footstrap_nav": "spa",
"bootstrap_ms": 300.6,
"footstrap_ms": 214.2
},
{
"page": "admin/system/admin",
"view": "system/password",
"title": "Administration",
"speedup_vs_bootstrap": 3.79,
"footstrap_nav": "spa",
"bootstrap_ms": 84.1,
"footstrap_ms": 22.2
},
{
"page": "admin/system/admin/password",
"view": "system/password",
"title": "Router Password",
"speedup_vs_bootstrap": 5.48,
"footstrap_nav": "spa",
"bootstrap_ms": 88.7,
"footstrap_ms": 16.2
},
{
"page": "admin/system/admin/dropbear",
"view": "system/dropbear",
"title": "SSH Access",
"speedup_vs_bootstrap": 6.82,
"footstrap_nav": "spa",
"bootstrap_ms": 239.6,
"footstrap_ms": 35.1
},
{
"page": "admin/system/admin/sshkeys",
"view": "system/sshkeys",
"title": "SSH-Keys",
"speedup_vs_bootstrap": 4.05,
"footstrap_nav": "spa",
"bootstrap_ms": 135.0,
"footstrap_ms": 33.3
},
{
"page": "admin/system/admin/uhttpd",
"view": "system/uhttpd",
"title": "HTTP(S) Access",
"speedup_vs_bootstrap": 4.04,
"footstrap_nav": "spa",
"bootstrap_ms": 128.4,
"footstrap_ms": 31.8
},
{
"page": "admin/system/admin/repokeys",
"view": "system/repokeys",
"title": "Repo Public Keys",
"speedup_vs_bootstrap": 1.64,
"footstrap_nav": "spa",
"bootstrap_ms": 221.2,
"footstrap_ms": 134.7
},
{
"page": "admin/system/startup",
"view": "system/startup",
"title": "Startup",
"speedup_vs_bootstrap": 1.18,
"footstrap_nav": "spa",
"bootstrap_ms": 453.7,
"footstrap_ms": 383.2
},
{
"page": "admin/system/crontab",
"view": "system/crontab",
"title": "Scheduled Tasks",
"speedup_vs_bootstrap": 3.4,
"footstrap_nav": "spa",
"bootstrap_ms": 119.7,
"footstrap_ms": 35.2
},
{
"page": "admin/system/flash",
"view": "system/flash",
"title": "Backup / Flash Firmware",
"speedup_vs_bootstrap": 2.34,
"footstrap_nav": "spa",
"bootstrap_ms": 190.4,
"footstrap_ms": 81.3
},
{
"page": "admin/system/reboot",
"view": "system/reboot",
"title": "Reboot",
"speedup_vs_bootstrap": 3.96,
"footstrap_nav": "spa",
"bootstrap_ms": 121.0,
"footstrap_ms": 30.6
},
{
"page": "admin/network/firewall",
"view": "firewall/zones",
"title": "Firewall",
"speedup_vs_bootstrap": 2.85,
"footstrap_nav": "spa",
"bootstrap_ms": 254.0,
"footstrap_ms": 89.2
},
{
"page": "admin/network/firewall/zones",
"view": "firewall/zones",
"title": "General Settings",
"speedup_vs_bootstrap": 3.22,
"footstrap_nav": "spa",
"bootstrap_ms": 254.5,
"footstrap_ms": 79.0
},
{
"page": "admin/network/firewall/forwards",
"view": "firewall/forwards",
"title": "Port Forwards",
"speedup_vs_bootstrap": 2.8,
"footstrap_nav": "spa",
"bootstrap_ms": 288.5,
"footstrap_ms": 103.2
},
{
"page": "admin/network/firewall/rules",
"view": "firewall/rules",
"title": "Traffic Rules",
"speedup_vs_bootstrap": 3.04,
"footstrap_nav": "spa",
"bootstrap_ms": 291.7,
"footstrap_ms": 95.8
},
{
"page": "admin/network/firewall/snats",
"view": "firewall/snats",
"title": "NAT Rules",
"speedup_vs_bootstrap": 1.86,
"footstrap_nav": "spa",
"bootstrap_ms": 197.3,
"footstrap_ms": 106.2
},
{
"page": "admin/network/firewall/ipsets",
"view": "firewall/ipsets",
"title": "IP Sets",
"speedup_vs_bootstrap": 5.58,
"footstrap_nav": "spa",
"bootstrap_ms": 171.3,
"footstrap_ms": 30.7
},
{
"page": "admin/network/sqm",
"view": "network/sqm",
"title": "SQM QoS",
"speedup_vs_bootstrap": 2.13,
"footstrap_nav": "spa",
"bootstrap_ms": 324.2,
"footstrap_ms": 152.4
},
{
"page": "admin/network/network",
"view": "network/interfaces",
"title": "Interfaces",
"speedup_vs_bootstrap": 3.34,
"footstrap_nav": "spa",
"bootstrap_ms": 280.0,
"footstrap_ms": 83.8
},
{
"page": "admin/network/routes",
"view": "network/routes",
"title": "Routing",
"speedup_vs_bootstrap": 2.16,
"footstrap_nav": "spa",
"bootstrap_ms": 189.0,
"footstrap_ms": 87.4
},
{
"page": "admin/network/dhcp",
"view": "network/dhcp",
"title": "DHCP",
"speedup_vs_bootstrap": 1.97,
"footstrap_nav": "spa",
"bootstrap_ms": 230.4,
"footstrap_ms": 116.8
},
{
"page": "admin/network/dns",
"view": "network/dns",
"title": "DNS",
"speedup_vs_bootstrap": 2.95,
"footstrap_nav": "spa",
"bootstrap_ms": 300.4,
"footstrap_ms": 101.9
},
{
"page": "admin/network/diagnostics",
"view": "network/diagnostics",
"title": "Diagnostics",
"speedup_vs_bootstrap": 4.56,
"footstrap_nav": "spa",
"bootstrap_ms": 161.3,
"footstrap_ms": 35.4
},
{
"page": "admin/status/realtime",
"view": "status/load",
"title": "Realtime Graphs",
"speedup_vs_bootstrap": 7.84,
"footstrap_nav": "spa",
"bootstrap_ms": 136.7,
"footstrap_ms": 17.4
},
{
"page": "admin/status/realtime/load",
"view": "status/load",
"title": "Load",
"speedup_vs_bootstrap": 1.1,
"footstrap_nav": "full",
"bootstrap_ms": 140.5,
"footstrap_ms": 128.1
},
{
"page": "admin/status/realtime/bandwidth",
"view": "status/bandwidth",
"title": "Bandwidth",
"speedup_vs_bootstrap": 2.0,
"footstrap_nav": "spa",
"bootstrap_ms": 138.4,
"footstrap_ms": 69.3
},
{
"page": "admin/status/realtime/connections",
"view": "status/connections",
"title": "Connections",
"speedup_vs_bootstrap": 7.73,
"footstrap_nav": "spa",
"bootstrap_ms": 137.4,
"footstrap_ms": 17.8
},
{
"page": "admin/status/overview",
"view": "admin_status/index",
"title": "Overview",
"speedup_vs_bootstrap": 5.19,
"footstrap_nav": "spa",
"bootstrap_ms": 406.1,
"footstrap_ms": 78.2
},
{
"page": "admin/status/routesj",
"view": "status/routesj",
"title": "Routing",
"speedup_vs_bootstrap": 3.89,
"footstrap_nav": "spa",
"bootstrap_ms": 153.3,
"footstrap_ms": 39.4
},
{
"page": "admin/status/nftables",
"view": "status/nftables",
"title": "Firewall",
"speedup_vs_bootstrap": 1.31,
"footstrap_nav": "spa",
"bootstrap_ms": 172.4,
"footstrap_ms": 131.5
},
{
"page": "admin/status/logs",
"view": "status/syslog",
"title": "System Log",
"speedup_vs_bootstrap": 3.05,
"footstrap_nav": "spa",
"bootstrap_ms": 190.5,
"footstrap_ms": 62.6
},
{
"page": "admin/status/logs/syslog",
"view": "status/syslog",
"title": "System Log",
"speedup_vs_bootstrap": 3.8,
"footstrap_nav": "spa",
"bootstrap_ms": 206.0,
"footstrap_ms": 54.2
},
{
"page": "admin/status/logs/dmesg",
"view": "status/dmesg",
"title": "Kernel Log",
"speedup_vs_bootstrap": 4.17,
"footstrap_nav": "spa",
"bootstrap_ms": 114.5,
"footstrap_ms": 27.5
},
{
"page": "admin/status/processes",
"view": "status/processes",
"title": "Processes",
"speedup_vs_bootstrap": 1.38,
"footstrap_nav": "spa",
"bootstrap_ms": 189.3,
"footstrap_ms": 136.7
}
],
"total_bootstrap_ms": 7457.6,
"total_footstrap_ms": 3195.9,
"total_speedup": 2.33
}
+62
View File
@@ -0,0 +1,62 @@
# Documentation
Developer documentation for `luci-theme-footstrap`. Installing and using the theme is in the
[repository README](../README.md).
Every page here stands on its own — start wherever your question is.
## Start here
| Page | Answers |
|---|---|
| [architecture.md](architecture.md) | What LuCI expects from a theme, and where footstrap plugs into it |
| [conventions.md](conventions.md) | The rules a patch must follow, and which gate holds each one |
| [development.md](development.md) | Bring up a dev router, push a change, prove it works |
| [crew.md](crew.md) | How a session is split between the lead thread and the role subagents |
## How it is built
| Page | Answers |
|---|---|
| [design-system.md](design-system.md) | Tokens, palettes, typography, scales, the appearance axes |
| [css.md](css.md) | The `styles/` tree, cascade layers, `build-css.sh`, proving a CSS change |
| [chrome.md](chrome.md) | Sidebar, icon rail, top bar, the menu renderer, the fit measurement |
| [spa-router.md](spa-router.md) | Client-side navigation: how it works and what it refuses to do |
| [third-party-apps.md](third-party-apps.md) | Coexisting with foreign `luci-app-*` |
## Shipping
| Page | Answers |
|---|---|
| [package.md](package.md) | Source tree, Makefile, uci-defaults, postinst/postrm |
| [ci.md](ci.md) | The CI job graph, packaging, installation and the trust chain |
| [releasing.md](releasing.md) | The pre-release checklist, the changelog contract, the runbook |
| [benchmark.md](benchmark.md) | The navigation benchmark: method and results |
## For authors of other packages
| Page | Answers |
|---|---|
| [luci-app-styling-guide.md](luci-app-styling-guide.md) | How to style a `luci-app-*` so it works under any theme |
| [luci-app-styling-guide_ru.md](luci-app-styling-guide_ru.md) | The same guide in Russian — the one page here that is mirrored |
There is also a self-contained developer portal with the colour-token grid, the component markup
and a style checker: <https://vizzletf.github.io/luci-theme-footstrap/>.
## Not documentation
| File | What it is |
|---|---|
| `gallery.html` | every widget LuCI can emit, with the real class names — what the a11y gate runs against |
| `devkit.src.html` | source of the developer portal |
| `devkit.html` | built from `devkit.src.html`, `gallery.html` and `02-tokens.css` by the `pages` job. Generated, gitignored, never committed |
| the playground | not a page here at all — recorded from a real router by `tools/playground/*` and CI's `playground` job, published as a release asset and fetched into Pages by `pages.yml`. [ci.md](ci.md) |
| `design/`, `screenshots/`, `img/` | reference mock-ups and README assets |
## Conventions of these pages
- One page, one job. If a page needs its own table of contents, it is probably two pages.
- One fact lives in one place. Pages link to each other rather than repeating.
- Every measurement carries the number it was made with, and every rule says what it protects
against — the alternative was usually tried and was worse.
- Documentation that has stopped being true is deleted, not annotated.
File diff suppressed because it is too large. Load diff
+336
View File
@@ -0,0 +1,336 @@
# Keeping the reader's place
A poll tick changes the height of something above the reader and the page under them moves. This
page is every mechanism the theme has for that: what each one is, the invariant it holds, and the
one number that justifies it. `fs-fit.js` cites a heading here.
**The findings are next door.** [anchoring-log.md](anchoring-log.md) carries every measurement, every
disproven diagnosis and every attempt that was measured and rejected, one heading per finding. This
page states the rule; that page says how it was learned. The chrome's half — which element a
correction may take, why data tables are excluded — is in [chrome.md](chrome.md).
The measurements are `tools/scroll-anchor.mjs` on the stands, `tools/floor-contract.mjs` for the
floor and `tools/fit-quiet.mjs` for the chrome.
## Who is responsible: `ENGINE_ANCHORS`
Chromium, Firefox and — since WebKit 26 — Safari implement **scroll anchoring**: the engine picks an
element the reader can see and moves the offset itself so that element stays put. The theme asks the
platform, `CSS.supports('overflow-anchor', 'auto')`, and never a browser name.
**The answer decides which half of `fs-fit.js` runs.** Where the engine anchors, growth above the
reader is its job and the theme only cleans up the residual (`lateDrift()`); where it does not, the
theme owns the whole correction (`anchorFor()` → `scheduleAnchor()` → `applyAnchor()`). Running both
is not a safety net: two corrections throw the page the other way, which is the fault the detection
exists to avoid.
The property no longer separates one engine from another — all three answer `true`. The 21-41px
WebKit was once read as mis-anchoring was the bar's own box walking 230 → 123px inside one
`fitChrome()` pass; with that pin held both ways the same probe reads 0px with no suppression at
all, and `ENGINE_MISANCHORS`/`data-fs-anchor-suppress` are gone from the tree
(anchoring-log.md, "Why WebKit looked like it was mis-anchoring").
## When the platform check is not enough: `_engineTrusted`
`ENGINE_ANCHORS` answers once, at load, whether the property EXISTS. An engine that has it and
declines to use it on a given refill is invisible to it, and that happens: `lateDrift()` had to
write the residual back on two passes of a real, trusting engine.
So the evidence accumulates instead of being guessed at. Each residual `lateDrift()` actually writes
increments `_lateMisses`; at `LATE_MISS_LIMIT` (2 — one is headroom for a one-off, a second is a
count) `_engineTrusted` goes false and every later refill takes the `anchorFor()`/`scheduleAnchor()`
path. `TRUST_RECOVERY_LIMIT` (2) refills where the remembered reference holds its own position put
it back. Never a browser name, only a count: Chromium and Firefox measure 0 residuals on the pages
the sweep covers, so the switch cannot trip for them.
| number | what it buys |
|---|---|
| `lateDrift()` on a page that was already still: 8-61ms; on one that was not: 419-420ms | the slow road is the price of telling a flick from a still page |
| `applyAnchor()`: 7-36ms | why distrust switches paths rather than waiting harder |
| `LATE_ROUND_TOLERANCE` 16px | the largest table-row rounding measured is 12.25px; 16 leaves 30% over it and stays far under half a 120px growth |
## How long to wait is a question about the reader
`lateDrift()` waits one frame and then decides. Where `scrolling()` and `_userUntil` both say nobody
is driving and nothing has moved, the only thing that can have touched the offset since the
reference was taken is the engine, whose window is 7-36ms — so it settles in the frame it already
has. Where the page is, or might be, in motion it waits `SCROLL_IDLE` instead.
**The fork decides HOW LONG to wait, never WHETHER to write.** The `scrollTop() !== seen` check runs
on both roads. Measured over nine `engine DECLINES` cells, three engines against three stands:
404-422ms → 8-61ms, with the engine-on (4-19ms) and engine-off (6-44ms) cells unmoved and `mid-flick
surprises` at 0 on all 27 (anchoring-log.md, "How long to wait: the fork that closed the 419 ms
road").
## Is the page moving: `scrolling()`
Asked of the scroll POSITION, one read per frame, never of the event stream: on iOS momentum carries
the page long after the finger has gone and events do not reliably arrive through it, so a timer off
the last event declares the reader still and drops a deferred pass into the middle of the glide.
`SCROLL_IDLE` is 400ms, and it is the fix for the shaking rather than a tuning knob: against an
imitated slow rock, 137-256px of roughness at 200ms and 59px — the floor, one pixel of rounding per
frame, the same as switching the fitters off — at 250ms and above.
Three markers keep this file from reading its own writes as the reader:
| marker | question it answers | who reads it |
|---|---|---|
| `_ownWrite` / `sawOwnWrite()` | is this pixel where my own last write landed? | the motion sampler and the `scroll` handler, both, for the same pixel |
| `_clampedTo` / `sawClamp()` | is the motion blocking this correction the clamp the correction is FOR? | `applyAnchor()` alone — `scrolling()` itself is untouched |
| `_userUntil` | is the reader DRIVING, as opposed to the page moving? | `lateDrift()` and `settleDeferredFloor()` |
`writeOffset()` is the one door either correction writes through, and it reads the offset back: a
write near either end of the document is clamped, and the pixel that lands is the one the next
`scroll` event reports.
## The document may not get shorter: `holdFloor()`
`dom.content()` — what every LuCI poll calls — empties a container before it refills it. A layout
taken while it is empty clamps the reader's offset into a document that was never really that short,
and nothing puts that back. So each container a poll empties carries a `min-height` at the height it
had at the last settled moment, written BEFORE the tick rather than during it.
Every floor is cleared, the box re-measured, and the floor written back where that answer is above
zero. Nine things are load-bearing, and each was a measured failure first:
- **On the containers, not on the column.** `min-height` on an ancestor of the engine's own anchor
suppresses its anchoring (css-scroll-anchoring-1 §2.2.2), so a floor on the column bought the
clamp back: 120px of growth moved the page all 120px, Chromium and Firefox alike.
- **Not on a table box.** `min-height` is undefined there (CSS 2.1 §10.7) and WebKit acts on it: a
`.table` wearing a 313px floor still collapsed to 30px and the document lost 284px. The floor
climbs to the first box that is not a table.
- **Written before the tick.** Pinning from inside the same statement sequence does nothing —
`dom.content()` performs no layout, so no layout ever sees the pin: 1882px still clamped away.
- **Cleared and re-measured, not read off the content.** The clear is what makes every other question
answer itself: a collapsed tab pane measures 0 and keeps no floor, where a content reader pinned
the collapse open (893px, issue #41). `tools/floor-contract.mjs` holds it: 175 floors, worst 0px
against the box.
- **Written at the box's own rect height, not `offsetHeight`.** An integer floor stands up to half a
pixel taller than its content; 22 such boxes made the document 2px taller with the floors than
without, and the next clear handed that back as a clamp — 6 of 12 refills corrected against 12 of
12 (anchoring-log.md, "The floor is written half a pixel too tall").
- **Found by its own mark.** A box qualifies through its CHILDREN and the climb can floor a box that
is not in `SHRINKS` at all, so emptying the table under it takes the section out of the sweep:
927px still standing 13 s later. Every floor carries `data-fs-floor` and the sweep looks for its
own mark.
- **Only the boxes a mutation touched are re-cleared.** 725 clears and 625 writes per 25 s of real
polling, of which 610 wrote back the value already standing; scoping to the mutation's own targets
reads 70/70 with `changed` unmoved at 15 (anchoring-log.md, "A box nothing touched must not be
re-cleared"). Every other caller passes no records and still gets the full sweep.
- **A tab switch, a fold and a `depends()` row wake the sweep too.** None of them moves a node, and
`min-height` beats the `height: 0` a hidden pane collapses with: 1299px of blank left standing on
Network → Interfaces, 258px on an unticked NTP client. `_moTabs` watches
`data-tab-active`/`hidden`/`aria-expanded` plus `class` where the element carries `data-field`.
- **A write that changed nothing is not a change.** The fitters re-apply their classes every pass by
design, so an unguarded `class` watch is a feedback loop: 391 callbacks in 432ms, 3910 records, the
tab never returning. `_moTabs` compares `oldValue` against what the attribute reads now — 2
callbacks, 20 records, 0 sweeps (anchoring-log.md, "A same-value class write is a feedback loop").
**The sweep puts back the offset its own clear pass took.** For the length of the measure pass the
document stands without its floors; 8 sweeps of 38 took it down one pixel and the offset with it,
and one pixel is enough for `lateDrift()` to read the page as moving and discard a 60px correction
whole. The restore is conditional on the scroller being as tall again as it was, which is what
separates the transient from a real shrink; a real shrink records the pixel the browser's clamp
landed on for `applyAnchor()` instead.
## A floor cleared late: `_deferredFloor` and `settleDeferredFloor()`
`holdFloor()` refuses while the reader is moving, so a floored box whose content really shrank loses
the shrink only until the next successful sweep — which used to run unwired to any correction. The
box is stashed by the mutation callback and consumed once the reader is still: the correction is the
OFFSET's own response to that one write, `wanted = offsetBefore - shrink` against the offset two
frames later, and not a reference element's drift — a reference re-established on an already-wrong
offset shows zero drift for ever. Without it the reader ended 58-60px off on WebKit @390, both
layouts (anchoring-log.md, "A floor that shrinks with nobody watching").
## The growth witness: `grew`
`lateDrift()`'s drift is read off whatever `anchorRef()` hit-tested at the fold, and that element
need not sit below the container this tick refilled. `grew` is the second witness: the refilled
box's height now against the `min-height` `holdFloor()` pinned it at, read in the mutation callback
where the height is already final.
- **The floored box the record sits IN**, through `closest(FLOORED)` — `dom.content()` refills a node
a level or two inside the pinned box on 1 of the 12 nodes a poll refills on the Overview.
- **Only growth ABOVE the reader's reference.** A box whose top is at or below the reference cannot
move it; counting it wrote the whole growth back and threw the reader 120px the other way, 8 cells
of 12 (anchoring-log.md, "Growth below the reader").
- **Not a number to write on its own.** Where the offset already moved (`compensated` non-zero) the
engine has done the work and the gap is this witness's own rounding on a many-row table; only a gap
over `LATE_ROUND_TOLERANCE` is a miss (anchoring-log.md, "The witness is not safe to write on its
own").
## What the reader was looking at: `anchorRef()` and the memo
`anchorRef()` is one hit test at the fold, below `[data-fs-chrome]` (the bar is sticky; a test at
y=1 returns the chrome and the page gets no anchor at all). It refuses `#view` itself — a host's own
top does not move when something inside it grows, so a drift measured against it is zero for ever —
and it refuses data tables, whose layout the fit pass deliberately falsifies mid-pass.
`rememberRest()` stores that element with its top, the offset it was taken at and the page stamp
(`_rest`, `_restAt`, `_restPage`), from the last moment the page was still. The section around it
travels too, as the fallback for the ordinary case where the tick replaces the element itself.
`rememberRest(true)` skips the `scrolling()` guard, and only the caller that has just written the
offset itself may use it: the engine's own compensation for the same mutation settles inside the
same `SCROLL_IDLE` window, so an ordinary call a millisecond after a write is refused and leaves the
memo describing the page from before the tick — the 47-60px of fabricated drift the next refill then
measures (anchoring-log.md, "The reference after this file's own write must be re-taken forced").
`forgetRest()` voids the memo: the router calls it on a client navigation, where the offset reset is
not a clamp to undo.
## The corrections
| | runs when | what it does |
|---|---|---|
| `applyAnchor()` via `scheduleAnchor()` | the engine does NOT anchor | the whole correction, one rAF after the mutation, against `anchorFor()`'s reference |
| `lateDrift()` | the engine anchors | only what the engine left behind, against the memo from before the tick — the frame it already has where the page was still, a frame plus `SCROLL_IDLE` where it was not |
| `settleDeferredFloor()` | a floored box shrank while the reader was moving | the part of that shrink the browser's clamp did not take |
Each ends in one write through `writeOffset()` and reads nothing back. A correction that repairs
another correction is what the 0.14.4-0.14.6 reports were about; two such mechanisms were measured
and removed (anchoring-log.md, "Two mechanisms measured and removed").
## What must never be corrected
- **A page the reader is moving.** `scrolling()` reads the offset over frames rather than trusting
the event stream. A correction landing inside a flick is itself a jump (161px, webkit/Overview).
- **A page the reader just acted on.** `_userUntil` holds the correction off after a deliberate act.
- **A page that is not the one the memo belongs to.** `_restPage` travels with the memo.
- **A batch that only took nodes away.** The empty half of a refill is a page about to stop
existing: arming on it wrote -834px for a refill that grew the page by 120. Superseding the armed
call with the later batch is worse — a real tick delivers around twenty records and every arm
cancels the last: 12 findings of ordinary drift (anchoring-log.md, "The later batch must not win").
- **A batch that emptied and refilled `#view` itself.** That is a page swap, not a refill — see "The
commit is not a refill" below.
## What the engine is told
`theme/30-tables.css` sets `overflow-anchor: none` on `.table.fs-dt` — the data tables the fit pass
re-lays. Without it the engine anchors inside a table whose layout the theme is about to falsify.
`theme/20-shell.css` carries no anchoring rule at all.
## The dev switches
Each is read at the point it gates and nowhere else, so a device that misbehaves can be asked which
half of this file is responsible.
| switch | effect |
|---|---|
| `localStorage.fsFit = 'off'` | no fitter runs, and no floor is written |
| `localStorage.fsAnchor = 'off'` | the theme writes the scroll offset nowhere |
| `localStorage.fsEngineAnchor = 'off'` | any engine takes the non-anchoring path — how the sweep reaches it, and how a Safari-only report is reproduced on a machine with no Safari |
## Navigation is a different question
`fs-router.js` keeps its own scroll memory (`_scrollMem`, `saveScroll`/`restoreScroll`) so Back
returns the reader where they were. That is per history entry, not per tick, and none of the above
applies to it — see [spa-router.md](spa-router.md).
### The scroll reset
A forward click resets both scrollers to the top — `window.scrollTo(0, 0)` and the same on
`#maincontent` — because a full load starts the new page there and the in-place swap has to match
it. That write runs in the same synchronous turn as `commitStage()`, at the swap, not at the click.
**Measured with the incoming module's fetch held open 1.2 s** (`owrt2512`): at the click, `y` reached
0 within 12 ms and stayed there for the whole staging window — the reader, still looking at the
OUTGOING page, found themselves at its top before they had any reason to be. Moved to the commit,
`y` holds the reader's own offset (1878px in the measured run) for the entire window and reaches 0 in
the same frame the incoming content replaces it, which is what a full load looks like.
**Why the corrections above are untouched by that move.** They all correct a scroller that is meant
to STAY PUT while the document under it changes. A forward navigation is the one case deliberately
moving the reader, to a page they chose. What does NOT move is `fit.forgetRest()`, still called at
the click: from that point the reader is committed to leaving, and a mutation the outgoing page's
poller makes during the staging window must not be read against a reference belonging to a page
about to go away.
### The commit is not a refill
`fit.forgetRest()` at the click is not enough: the commit arriving seconds later reaches `fs-fit.js`
as an ordinary content mutation and re-establishes a reference of its own. The browser delivers
`dom.content()` on the live `#view` as TWO batches; `run()`'s own `rememberRest()` fires between them
and adopts a reference measured mid-swap, which `lateDrift()` reads 431px out 420ms later and writes
back over a correct `restoreScroll()` (2723 → 2292.21875, chromium, Back to the Overview).
**The page stamp cannot see it, in any form.** `navigate()` restamps `body[data-page]` seconds
before the commit and `commitStage()` restamps `#view` and `.fs-content` before `dom.content()`, so
every stamp in the document already names the incoming page; carrying the stamp on the reference
instead is the same number twice.
**What holds.** A batch that both removes and adds children of the live `#view` is `dom.content()`'s
own shape on the one node only `commitStage()` calls it on. On such a batch the observer calls
`forgetRest()` and returns: the memo is void and the next tick re-takes one on a settled page. 3 of
3 red before, 3 of 3 green after, no `writeOffset()` logged at all. Testing the target alone does
not hold — `HOLD` grows the page by inserting a pad as `#view`'s own first child and matched too,
leaving 120px uncorrected on all three twins (anchoring-log.md, "What the sweep itself could not
see"; `spa-parity`'s `back-scroll` case is the gate).
## Is each one still needed
One mechanism disabled at a time, on the agent's own stand so nothing else moves, over the axes that
mechanism is supposed to hold. `tools/scroll-anchor.mjs`, `--width`/`--layout` to reach the cell and
`--full` to cross the rest; every number below is what the sweep printed. A cell is only counted
when it repeats — a lone finding on one pass is the parallel-stand noise `development.md` describes.
**The poll belongs to one case of the sweep, and that is part of the measurement.** `Poll.start()`
performs a tick synchronously (luci-base), so a case that restores the poll on its way out fires a
real tick into the case that runs next — HOLD doing that had SWAP measuring the floor under a live
tick, and CI reported 120px of unheld floor on a cell every other build calls green. HOLD and SWAP
therefore leave it stopped, and QUIET — whose subject IS ticks landing mid-flick — starts it before
it parks, with a tick's worth of time to land: started later, that first tick lands inside the very
flick being timed and the sweep reports the jump it came to look for (145.5px, firefox/owrt2410
@390 top compact). Both shapes are in [development.md](development.md), with what tells them apart
from a theme fault.
| mechanism | without it | needed |
|---|---|---|
| `holdFloor()` | reader moved 568px @390 top and 610px @1440 side; the clamp took 444px and 610px | yes — the largest effect of any of them |
| `holdFloor()` putting back the offset its own clear pass lost | `REPEAT`'s refill 2 or 3 on the same section left the reader -47px off, chromium `@390 top` on `/admin/network/dhcp`, `corrected never` and `engineTrusted` true throughout | yes — one pixel taken by the sweep is enough for `lateDrift()` to read the page as moving and discard a 60px correction whole |
| the floor written at the box's OWN height rather than `offsetHeight`'s rounding of it | 22 floors each stand up to half a pixel taller than their content, the document with them is 2px taller than without, and the next sweep's own clear hands that back: 6 of 12 refills corrected on `owrt2410b`/webkit `@390 top normal` against 12 of 12 | yes — it is what makes the clamp `holdFloor()`'s own restore is about, and the clamp with the theme out of the loop is harmless (12/12) |
| `applyAnchor()` seeing past the clamp's own scroll event | `REPEAT`'s refills 2 and 3 left the reader -60px off, `corrected never`, webkit `@1440 side compact` with the engine's anchoring ablated away — refused on `scrolling()` 11ms after the clamp that caused it, holding a correct -60px correction | yes — the shrink that is not fully clamped is invisible to every other mechanism here, and the reference is re-taken on top of it |
| `settleDeferredFloor()` | `REPEAT`'s refill 2 or 3 left the reader 58-60px off on WebKit @390, side and top, every other mechanism green throughout | yes, and narrowly: the ablation is `holdFloor()`'s own `scrolling()` guard being reached at all |
| `scheduleAnchor()` / `applyAnchor()` | 3 findings per scroller with the engine's anchoring off, every one the full 120px of growth: nobody corrects at all | yes, and it is the whole correction on Safari < 26 |
| `lateDrift()` | 120px on Overview and on Processes, both scrollers, with the engine anchoring | yes — the engine's residual is not small |
| `ENGINE_ANCHORS` | forcing "no engine anchors" on an engine that does: 120px on Processes | yes — the detection picks the path, and running both corrections is what throws the page the other way |
| `_engineTrusted` | an engine that anchors but declines on a given refill leaves every later correction on `lateDrift()`'s 419-420ms path instead of `applyAnchor()`'s 7-36ms one | yes, once `LATE_MISS_LIMIT` (2) residuals have been measured on the page — never trips where the engine keeps the reference itself (0 residuals on chromium/firefox) |
| `lateDrift()` settling in the frame it already has where the page was still | every refill a trusted engine declines is corrected 419ms after it happened instead of 8-61ms: 9 findings of 9 `engine DECLINES` cells, three engines, three twins | yes, and the ablation IS the third cell of that axis — nothing else can put the theme on `lateDrift()` as the only corrector on demand |
| the growth witness seeing past a blind reference (`closest(FLOORED)`, the positional guard) | a fold above the growing block leaves both witnesses blind and nothing is corrected (13 of 13 refills, 120px off); counting a box below the reader throws them 120px the other way (8 cells of 12) | yes — the two guards are one mechanism and each was a separate live failure |
| the guards on a page in motion (`scrollTop() !== seen`, `_userUntil`) | 6 findings per scroller, on BOTH engines and all three pages: the offset moved on its own mid-flick, worst 185-520px | yes, and it is the only mechanism here that fails on Chromium-class engines too |
| `anchorRef()` refusing to run while scrolling | nothing measurable | **not measurable here** — a cost guard, not a correctness one: every rect read there is a forced layout and this runs on every content mutation |
| `anchorRef()` refusing `#view` as the reference | nothing on the current pages | **not measurable here.** The hit test is retried across the viewport, so it finds real content where it used to land in a grid gap; the refusal keeps a future layout from anchoring on the host, whose own top never moves |
And four parts that carry the machinery rather than decide anything, so there is nothing to ablate:
| part | why it is not in the table above |
|---|---|
| `rememberRest()` and `_rest` / `_restAt` / `_restPage` | the memo itself. Removing it removes every correction at once, which is what the rows above already measure one at a time |
| `forgetRest()` | belongs to navigation, not to a tick: the router calls it when it resets both scrollers, and `spa-parity` is what covers that |
| `.table.fs-dt { overflow-anchor: none }` (`theme/30-tables.css`) | tells the ENGINE not to anchor inside a table whose layout the fit pass falsifies mid-pass. The sweep measures the theme's corrections, not the engine's choice of anchor |
| `_scrollMem` / `saveScroll` / `restoreScroll` (`fs-router.js`) | per history entry, not per tick — see [spa-router.md](spa-router.md) |
**Two of them the sweep cannot reach**, and that is a finding about the sweep. Both are held by the
measurement in their own comment rather than by a gate, so a change there is not caught by CI:
extend `tools/scroll-anchor.mjs` before touching one, or accept that the proof is historical.
## What the gate itself cannot answer
`lateWhy()`, `lateTrail()`, `anchorWhy()` and `anchorTrail()` exist because a correction that did
not write reads from outside as one symptom, `writes: []`, and `lateDrift()` has eight ways to reach
it. Each exit sets one short string with `performance.now()` beside it, the clock the sweep measures
the refill on, and the last eight are kept: a correction whose settle has not run yet and one that
ran and was re-armed both read `armed` at the end of the window otherwise (anchoring-log.md, "The
last eight exits, with the clock").
Nothing else can answer "has a reference been taken yet": `scrolling()` says no both before the
motion sampler starts and after it finishes, **1.5 s apart in WebKit**, and a flat wait instead made
`tools/scroll-anchor.mjs` report a jump on every WebKit run and none on the other two engines with
the theme identical on all three.
`restAt()` and `engineTrusted()` carry no `/* fs:probe */` marker on purpose: they are read by a
browser sweep against the INSTALLED package, and a probe marker is what packaging strips. Marked,
`restAt()` was stripped out and the sweep fell back to a flat wait — 14 findings on one router, every
one of them WebKit, and not a word about the missing method.
+216
View File
@@ -0,0 +1,216 @@
# How LuCI renders a theme
What LuCI expects from a theme on OpenWrt 24.10 and 25.12+, and where footstrap plugs into it.
Read this first if you are new to the codebase.
> Verified against `openwrt/luci` at the commit pinned in
> `luci-theme-footstrap/luci-upstream.pin`, against the `openwrt-24.10` branch, and on a live
> OpenWrt 25.12 router.
## A theme is a server shell plus client assets
LuCI runs on ucode, not Lua, since 23.05: dispatcher in `/usr/share/ucode/luci/dispatcher.uc`,
templates as `*.ut` under `/usr/share/ucode/luci/template/`.
**Page content is rendered on the client.** The server sends a shell — header, an empty
`<div id="view">`, footer — and `luci.js` instantiates the page's view module from
`/www/luci-static/resources/view/**`.
So a theme is **a server shell (header/footer in ucode) + CSS + client JS for the menu**. No Lua,
no server-side controllers.
```
view.ut
├─ include('header') → luci-base header.ut
│ ├─ include(`themes/${theme}/header`) ← the theme's header.ut
│ └─ <script src="…/luci.js"> + L = new LuCI({…env…})
├─ <div id="view"> + ui.instantiateView('<view>')
└─ include('footer') → luci-base footer.ut
└─ include(`themes/${theme}/footer`) ← the theme's footer.ut
```
The theme is never called directly: luci-base wraps it, and `luci.js` loads *after* the theme's
header.
## Both release lines, one template API
**24.10 and 25.12+ share the template API**, checked rather than assumed. 24.10 is already ucode
(`modules/luci-base/ucode/` exists there) and carries everything the theme uses: `ctx.path`,
`ctx.request_path`, `entityencode`, `striptags`, `dispatcher.build_url/lookup/lang`, `ubus.call`,
`pkgs_update_time`. The `L.env` blob luci-base's own `header.ut` prints is **byte-identical**
between branches, so `L.env.dispatchpath` — which the menu and the SPA router depend on — exists in
both.
**The one difference is the package manager**: apk on 25.12+, opkg/`.ipk` on 24.10. CI builds both
formats; `install.sh` detects which one the router runs.
## How LuCI picks a theme
From `runtime.uc`:
```ucode
let media = uci.get('luci', 'main', 'mediaurlbase'); // e.g. '/luci-static/footstrap'
let status = self.trycompile(`themes/${basename(media)}/header`);
if (status !== true) {
// fall back to the first theme in luci.themes whose header compiles
}
self.env.media = media; self.env.theme = basename(media);
```
Three things follow:
1. The theme name is `basename(mediaurlbase)`, and its templates must live in
`/usr/share/ucode/luci/template/themes/<name>/header.ut`.
2. **A header that does not compile does not brick the UI.** LuCI falls back to the first working
theme and shows a "Theme fallback" indicator carrying the error. Convenient while developing —
and the reason CI compiles every `.ut` with `ucode -T -c`: a stray brace would otherwise ship
green and quietly move every user to another theme.
3. `resource` is always `/luci-static/resources`, shared with luci-base. Themes must not touch it.
## What the header must do
1. `http.prepare_content('text/html; charset=UTF-8')`.
2. Emit `<!DOCTYPE html><html><head>…` with its own CSS.
3. Include `{{ resource }}/cbi.js` and the translations script.
4. Honour `node.css` and `css` — individual pages ask for extra stylesheets.
5. Open the content container and leave it **unclosed**; the footer closes it.
6. Honour `blank_page` — draw no chrome when it is true.
7. Emit the empty menu containers: `#topmenu`, `#tabmenu`, `#modemenu`, `#indicators`.
**`data-page` on `<body>` matters**: luci-base and app CSS target pages through it
(`[data-page="admin-status-overview"]`).
Variables a theme template gets: `theme`, `media`, `resource`, `ctx`, `dispatcher`, `dispatched`,
`node`, `version`, `config`, `blank_page`, `css`, `lua_active`, `http`, `ubus`.
### Where footstrap differs
- **One template directory**, `ucode/template/themes/footstrap/`. `header.ut` is thin; `<head>`,
brand, notices, logout and search live in `partials/*.ut`. Sidebar and top bar are **not a second
template** — they are `:root[data-layout]` plus CSS.
- **`data-page` is built from the dispatch path**, `join('-', length(ctx.path) ? ctx.path :
ctx.request_path)` — the reverse of bootstrap's order. On a firstchild route (`/admin/status`
renders overview) `request_path` is only `['admin','status']`, so `body[data-page=
'admin-status-overview']` rules and the overview include silently did not apply.
- The first element in `<body>` is a skip link; the content container is
`<main class="fs-main" id="maincontent" tabindex="-1">`; the menu is `<nav class="fs-sidebar">`.
Not `<aside>` — that carries the `complementary` role, which no landmark jump reaches.
- The document heading is `<h1 class="fs-title-main">` inside `.fs-title.fs-sr`, **clipped, not
`hidden`**: `hidden` is `display:none`, which drops the element out of the accessibility tree, so
the `<h1>` effectively did not exist while the SPA router dutifully updated it.
- `partials/head.ut` sends `cascade.css`, `node.css` and `cbi.js` with `?v={{ pkgs_update_time }}`
(uhttpd sends no `Cache-Control`) and passes every
interpolation through `entityencode(striptags(...))`.
## `sysauth.ut` is mandatory for a theme with its own chrome
luci-base's generic `sysauth.ut` opens with `{% include('header') %}` — **without `blank_page`** —
while the theme's header hides its whole shell behind `{% if (!blank_page) %}`. Without a
theme-local `sysauth.ut`, sidebar, menu and footer draw around the login form and every control in
them is dead: there is no session yet.
The theme's `sysauth.ut` exists for exactly one line:
```
{% include('header', { blank_page: true }) %}
```
**The form is rendered by the SERVER.** Bootstrap's pattern — a hidden `<section>` plus a client
view module — was tried here and produced a **blank page you could not log in from**: the view
bootstraps before a session exists, its RPCs answer "Access denied", the promise rejects and
`render()` never runs. Server rendering works with JS off, cannot be broken by a rejected promise,
and needs no `luci-theme-bootstrap` installed. Do not go back.
Login strings deliberately carry **no `msgctxt`**, so luci-base translates them in the ~40 languages
this theme has no catalogue for — see [conventions.md](conventions.md).
## The menu is entirely client-side
The server header emits empty containers by id; client JS fills them. The footer loads the renderer
with `L.require('menu-footstrap')`, then `L.require('fs-select')`.
Menu JS lives in `htdocs/luci-static/resources/` — **not** in the theme directory — because
`L.require()` resolves against `resourcebase`. From there it pulls its dependencies through
`'require <module> as <name>'` pragmas:
```
menu-footstrap → ui, fs-fit, fs-prefs, fs-widgets, menu-footstrap-common
menu-footstrap-common → ui, fs-fit, fs-menutree, fs-chrome, fs-router,
fs-prefs, fs-sheets, fs-search
fs-select (no requirer — the footer loads it directly)
fs-overview (PAGE MODULE — required by menu-footstrap-common when
body[data-page] says the page is theirs, see below)
```
The graph is acyclic and the runtime enforces it: `require()` throws `DependencyError` on a cycle.
**One module is loaded by PAGE, not by pragma.** `fs-overview` reshapes Status → Overview, watches
`body[data-page]` and does nothing anywhere else, because that page is `luci-mod-status`'s own — a
theme may register a dispatcher node of its own (`fs-appearance` does, below), but that adds a
route, it does not hand over one another package already owns. A `'require'` pragma, though, is a
hard dependency — luci.js evaluates the module before the requirer's factory runs, on every admin
page — so `menu-footstrap-common.js`
carries a map from `data-page` to module name and requires the module when that page appears
(server-stamped or router-stamped, the same attribute either way). The module is unchanged, including
its own observer: `wire()` re-checks the page synchronously, so one that arrives after the stamp
still starts watching. The map repeats a page name the module also tests, and `npm run page-modules`
derives both sides and fails if they drift.
`fs-appearance` (the Appearance controls) used to be the map's other entry, stapled onto System →
System the same way. It now owns a route of its own, `/admin/system/footstrap`
(`root/usr/share/luci/menu.d/luci-theme-footstrap.json`, dispatched to
`view/footstrap/appearance.js`), which requires it by pragma like any ordinary view — an ordinary
dependency, not a page-watching module, because a theme with a route no longer needs the
`body[data-page]` workaround for the page it owns.
**There is no update checker, and nothing that loads one.** The installer adds the owfeed-packages
feed, so `apk upgrade` / `opkg upgrade` carries the theme forward, and a settings page has no
business reaching GitHub to reimplement that. `fs-version.js` reports the installed version with no
request at all. Nothing globs for an optional file at render time either: the wallpaper pattern is an
upload, so the server hands the client its cache-bust token like any other saved value.
Renderer and shell in detail: [chrome.md](chrome.md).
## Registration in UCI
```
config core 'main'
option mediaurlbase '/luci-static/footstrap' # the active theme
option resourcebase '/luci-static/resources'
config internal 'themes'
option Footstrap '/luci-static/footstrap' # the list the dropdown is built from
```
**Exactly one entry.** Bootstrap registers a symlinked theme per mode (`BootstrapDark`, …);
footstrap does not, because mode, palette and layout are client axes. Details:
[package.md](package.md).
## The runtime layout on the router
`luci.mk` maps `ucode/` → `/usr/share/ucode/luci`, `htdocs/` → `/www`, `root/` → `/`:
```
/usr/share/ucode/luci/template/themes/footstrap/
header.ut, footer.ut, sysauth.ut, partials/*.ut
/www/luci-static/footstrap/ cascade.css (generated), fonts/, logo.svg
(+ pattern.svg — a symlink to /etc/footstrap, uploaded)
/www/luci-static/resources/ menu-footstrap.js, menu-footstrap-common.js, fs-*.js
/usr/lib/lua/luci/i18n/footstrap-theme.<lang>.lmo catalogue, bundled INSIDE the theme package
/usr/share/rpcd/acl.d/luci-theme-footstrap.json ACL: uci footstrap + login-bg upload
/etc/uci-defaults/30_luci-theme-footstrap registration
```
## Where the boundary runs
The theme supplies the **chrome and the design language**. Page content is drawn by the view JS of
`luci-mod-*`, whose structure the app fixes.
The theme **can** style every stock widget through `cascade.css` and its tokens, own the sidebar,
top bar, logo and indicators, and re-order the stock overview sections.
The theme **cannot** turn the overview into a KPI dashboard with a memory ring and sparklines —
that is a view module in a separate `luci-app-*`.
The single exception is `fs-overview.js`: a layout-only, additive include that moves stock overview
sections (System left, Memory/Storage right) and draws no content of its own.
+379
View File
@@ -0,0 +1,379 @@
# Navigation benchmark: footstrap vs bootstrap vs proton2025
`luci-theme-footstrap` ships a client-side router, so clicking a menu item swaps the view in
place instead of reloading the whole page. This measures what that is worth, on real hardware,
against stock `luci-theme-bootstrap` and against a third-party theme.
Script: `bench/nav-benchmark.py`. Runs against any OpenWrt 25.12+ router.
## What it measures
For every page, wall-clock milliseconds from "I want to go there" to "the view is fully
rendered":
- footstrap: a real click on the menu link, which the theme's router handles in place;
- the others: a full navigation to the URL, because that is what a click does in them.
Both themes render into the same `#view` element, which the LuCI dispatcher emits regardless of
theme, so the finish line is identical and the comparison is fair. "Rendered" means `#view` has
children, nothing is spinning, no "Loading view…" text, and none of the content belongs to the
page you just left.
Pages come from `/admin/menu` at runtime: everything the menu can turn into a link whose
resolved target is a stock LuCI page. Third-party app pages are excluded by construction, so an
app that hangs itself under `admin/system` cannot skew the numbers. That gives 38 pages,
including the tab leaves (Realtime, Logs, Administration, Firewall) and the alias/firstchild
entries like Firewall and Realtime Graphs, which are the most-clicked items in the menu.
Per theme: activate it, clear the LuCI caches, log in with a fresh browser context, walk every
page once unmeasured to warm the HTTP and module caches, then walk it `--runs` more times and
take the median per page. Network requests per transition are counted too.
## CPU: two tables, because "load" is two questions
Time tells you how long you waited. It does not tell you what that cost, and the two can
disagree: a theme can be quick because it pushed the work onto the router, or easy on the
router while burning the client's battery. So both ends are measured and reported apart.
Router CPU is read from `/proc` on the router itself, at the edges of each theme's measured
passes. The attributable figure is `utime+stime+cutime+cstime` of `uhttpd`, `rpcd` and `ubusd`.
The `cutime/cstime` halves matter: uhttpd forks a CGI per request, and a reaped child's CPU
lands in its parent, so the ucode that renders the page shell is counted. There is also a
whole-box figure from `/proc/stat`, printed next to an idle baseline taken before and after,
since the router also routes traffic.
An open LuCI view polls the router once a second, which is CPU nobody navigated for. That rate
is measured separately, parked on one page, and subtracted before dividing by the navigation
count. Two things to keep in mind when reading the table: the percentage rows are rates over a
window whose length the theme changes, so a faster theme can show a higher percentage on less
CPU and only CPU-seconds compare; and `/proc/stat` inside a Docker container is the host's, so
the whole-box figure there would include the benchmark's own browser. That is why the published
numbers come from real hardware.
Client CPU comes from CDP `Performance.getMetrics` deltas per navigation: `TaskDuration` for
main-thread task time, with a script / style-recalc / layout / v8-compile breakdown.
## Why footstrap comes out ahead
Nothing exotic. A full page load throws away a working runtime and rebuilds it: the shell, the
menu, `luci.js` and `cbi.js`, the translation catalogue, the theme's CSS and JS. Then it renders
the page. The client router keeps all of that and renders only the page.
You can see it in the request counts. bootstrap fires 15 to 47 requests per navigation and
proton2025 27 to 72, against 0 to 7 for footstrap, which fetches only the RPC the view needs.
Zero on some pages, because the view is already in memory.
That is also why the win is uneven. It is biggest (8x and up) on light pages, where the page
itself has almost nothing to do and a reload spends its whole time restarting the runtime. It
is smallest (around 1.1x) on Startup, Software and Overview, where the time goes into rendering
content rather than navigating. The router cannot speed up a package list. That is a ceiling,
not a defect.
One more thing the numbers show: a third-party theme can be slower than the stock theme it
repaints. proton2025 loses to bootstrap on total time, with roughly twice the requests and four
times the CSS. Pretty is not fast, which is the argument for measuring.
## The remaining LAN time is not the theme's, and it is not the router's either
On a LAN a warm navigation still costs 90–140 ms while the router answers most of its own RPCs in
1–4 ms. That gap is worth writing down, because the obvious suspects are all innocent and the real
cause sits two layers below this theme.
**The theme is 4.9% of it.** CPU-profiling the main thread across 14 warm navigations on real
hardware (`Profiler.setSamplingInterval 100µs`, self time by script): every `fs-*.js` file together
is **6.8 ms per navigation** out of ~139 ms — `fs-chrome` 4.6, `fs-fit`'s `roomFor` 1.2, `fs-router`
0.4, `fs-select` 0.2 — against 95.9 ms in which the main thread is *idle* and 21.5 ms of engine work
(style, layout, paint). Another 3.7 ms/nav is `scrollTo`, which is ours. So zeroing the theme
entirely would buy under 8%, and the chrome re-render everyone suspects (`renderChrome`, which does
rebuild the menu on every navigation) measures **0.55 ms**.
**The router is not slow either.** Per-request timings from the CDP *network service* (immune to
renderer busyness) show server time of 1.0–4.0 ms for almost everything, including a 22 KB
`network.interface.dump` batch at 1.4 ms — but **42–44 ms, every single time, for `session.access`
(205 bytes) and for a lone `luci.getUnixtime` (211 bytes)**. Run the same call with the main thread
idle and it takes 2.8 ms; `ubus call session access` on the router itself takes 0 ms.
**It is Nagle plus delayed ACK, and uhttpd is where it lands.** uhttpd writes a response's headers
and body as separate `write()`s and never sets `TCP_NODELAY`, so on a *reused keep-alive* connection
a small body waits for the ACK of the previous response — which the client's kernel delays by ~40 ms.
Reproduced with curl, no browser involved, four sequential POSTs on one connection:
```
req1 uci.changes 52 B 7.2 ms
req2 session.access 53 B 44.0 ms <-- small body, previous response unacked
req3 getNetworkDevices 8913 B 9.6 ms <-- a full MSS goes out immediately
req4 session.access 53 B 42.8 ms
fresh connection each time: 1.4 / 1.5 / 1.4 ms
```
That also explains the shape: TTFB is 0.37 ms (the headers are prompt), the *body* is late; the first
request of a connection never stalls; large responses never stall.
**A workaround exists, it is NOT theme-specific, and over HTTPS it is a trap.**
`uci set uhttpd.main.http_keepalive=0` removes the stall (uhttpd then answers `Connection: close` on
both protocols). Measured on the production router, aarch64 25.12.2:
| | keep-alive 20 | keep-alive 0 | |
|---|--:|--:|---|
| footstrap, warm navigation, 6 pages, LAN | 754 ms | **453 ms** | **1.66×** |
| footstrap, cold full load, LAN | 292 ms | **155 ms** | **1.88×** |
| footstrap, warm navigation, 120 ms RTT | 1825 ms | 1834 ms | unchanged |
| footstrap, cold full load, 120 ms RTT | 1551 ms | 1551 ms | unchanged |
| **stock bootstrap**, full-load nav, 6 pages, LAN | 2126 ms | **1705 ms** | **1.25×** |
| **stock bootstrap**, full-load nav, 120 ms RTT | 4782 ms | 4814 ms | unchanged |
| **HTTPS**, full-load nav, 120 ms RTT | 9819 ms | **29 424 ms** | **3× WORSE** |
Stock bootstrap gains too, which is the point: this is uhttpd's, not the theme's. Plain HTTP loses
nothing even at 120 ms RTT, because the handshakes overlap across the six connections a browser opens
while the Nagle stall is serial. **But keep-alive is load-bearing for TLS**: without it every request
pays a TCP handshake plus a TLS handshake, and a page is ~49 requests — measured at 20 ms per fresh
TLS handshake even on the LAN, and 3× the total page time at 120 ms RTT. So never set this on a router
whose admin UI is reached over HTTPS or over a slow link.
What did *not* materialise, so do not repeat these as objections: socket churn cost nothing measurable
— uhttpd burned 3 jiffies per 100 GETs with keep-alive off against 4 with it on, TIME_WAIT sockets
stayed at 0 in both, and conntrack did not grow. The proper fix is `TCP_NODELAY` in uhttpd, which
keeps keep-alive *and* removes the stall, and helps every LuCI theme rather than this one.
**Dead ends, so nobody re-derives them:** HTTPS does not avoid it (56.8 / 44.3 / 44.3 ms over TLS);
`uhttpd -h` exposes no socket-option knob beyond `-k` (keep-alive timeout) and `-A` (TCP keepalive
probes); and **the theme cannot work around it from JS** — it issues no ubus call of its own during a
navigation (the calls are `form.js`'s `session.access` and the views' own data), `Connection: close`
is a forbidden request header, and the connection pool is keyed by origin rather than by URL, so no
cache-buster changes which socket a request lands on.
## Running it yourself
```sh
# once: environment
python3 -m venv .venv
.venv/bin/pip install playwright
.venv/bin/python -m playwright install chromium
# run (the original theme is restored at the end)
LUCI_PW=<router-root-password> .venv/bin/python bench/nav-benchmark.py \
--ssh-host router --runs 5 --out bench/results-25.12.json
```
Options: `--ssh-host` is a host from `ssh -G` (default `router`), `--runs N` sets the number of
measured passes, `--out FILE` writes JSON, `--headful` shows the browser. To add a theme,
install it, register it in `luci.themes`, and append it to `THEMES` in the script; `BASELINE`
decides which column the speedup is computed against.
Prepare the router or the numbers will lie:
- Every theme in `THEMES` must be installed and registered in `luci.themes`. The script only
writes `luci.main.mediaurlbase`, and nothing validates that path: if the theme's template is
missing, LuCI quietly falls back to another registered theme, and the column will honestly
measure the wrong theme. The run will not fail. It will lie.
- An app with invasive CSS changes what is being measured. If a `luci-app-*` injects a
`<style>` that paints stock widgets, footstrap contains it per app, but a sheet it cannot
attribute still forces a real page load (see [spa-router.md](spa-router.md)). Check `spa_pages`
in the JSON: if it is not 38/38, that is why.
- The script switches the active theme while it runs and restores it in a `finally`. Kill it
with `kill -9` and you may have to set `luci.main.mediaurlbase` back by hand.
A run takes `themes × (1 warm-up + runs) × pages` navigations. With five runs, three themes and
38 pages that is about 680 transitions, or 20 minutes.
## Results
Absolute numbers depend on the hardware. The ratios and the request counts are the point.
### Real hardware, with CPU (footstrap 0.12.0, 2026-08-01)
OpenWrt 25.12.2, mediatek/filogic, 4 cores, luci-base 26.209. Five runs, median, 38 pages, three
themes, **wallpaper off** on all of them. Full data in `bench/results-25.12.json`.
| | bootstrap | proton2025 | **footstrap** |
|---|--:|--:|--:|
| Time, sum of medians | 11 306 ms | 12 142 ms | **4933 ms** |
| vs bootstrap | | | **2.29×** |
| median per-page speedup | | | **3.03×** |
| pages navigated in place | 0 | 0 | **38 / 38** |
| Router CPU, web stack, whole tour | 37.3 s | 45.7 s | **18.4 s** |
| per navigation (polling removed) | 192 ms | 209 ms | **95 ms** |
| less router CPU | | | **2.02×** |
| tour duration | 62 s | 67 s | **29 s** |
| polling rate, parked on Overview | 12 ms/s | **88 ms/s** | 14 ms/s |
| load average, end of tour | 0.74 | 1.17 | 0.94 |
| idle baseline, box busy (before / after) | 2.9% / 0.9% | | |
The router CPU columns answer the obvious suspicion: the speed is not bought with the router's
processor. The same tour of 190 navigations costs it 18.4 s of web-stack CPU against 37.3 s.
**Client CPU is missing from this run, and the reason is worth knowing.** A sample is dropped when
the renderer process is replaced mid-navigation, and that does not fall evenly: footstrap kept 38
pages of 38, bootstrap 18 and proton2025 16, because a full page load restarts the renderer far
more often than an in-place swap does. What survives for a full-load theme is its *cheap*
navigations, so the cross-theme ratio computed over the 14-page intersection reads as "the SPA
theme burns more CPU" when it only means the other themes' expensive samples were thrown away. The
script prints that coverage now and says outright that the ratio must not be quoted below 90%. The
July run below had full coverage and measured **26.6 ms per navigation against bootstrap's
45.8 ms**; that is the figure to use until a run with full coverage replaces it.
Per page, sorted by how much the client router buys:
| page | bootstrap | proton2025 | **footstrap** | ×bootstrap | requests boot/prot/foot |
|---|--:|--:|--:|--:|--:|
| `system/admin/password` | 139 ms | 152 ms | **8 ms** | **17.15×** | 15/28/0 |
| `system/admin` | 134 ms | 149 ms | **10 ms** | **14.16×** | 15/28/0 |
| `status/realtime/load` | 139 ms | 146 ms | **10 ms** | **13.46×** | 16/28/1 |
| `network/diagnostics` | 254 ms | 305 ms | **21 ms** | **12.25×** | 27/38/1 |
| `status/realtime/bandwidth` | 207 ms | 286 ms | **20 ms** | **10.39×** | 29/39/3 |
| `status/realtime` | 106 ms | 142 ms | **11 ms** | **9.60×** | 17/28/1 |
| `status/logs/dmesg` | 151 ms | 162 ms | **20 ms** | **7.57×** | 16/28/1 |
| `system/admin/dropbear` | 322 ms | 319 ms | **58 ms** | **5.51×** | 32/46/1 |
| `status/realtime/wireless` | 271 ms | 304 ms | **54 ms** | **4.98×** | 29/41/3 |
| `status/logs` | 199 ms | 254 ms | **41 ms** | **4.88×** | 17/29/1 |
| `network/firewall/zones` | 311 ms | 333 ms | **79 ms** | **3.93×** | 32/48/2 |
| `network/firewall/ipsets` | 201 ms | 222 ms | **54 ms** | **3.70×** | 20/33/1 |
| `status/logs/syslog` | 184 ms | 222 ms | **52 ms** | **3.54×** | 17/30/1 |
| `network/routes` | 317 ms | 339 ms | **90 ms** | **3.54×** | 30/41/3 |
| `network/firewall/rules` | 336 ms | 362 ms | **96 ms** | **3.49×** | 30/42/2 |
| `network/network` | 374 ms | 386 ms | **111 ms** | **3.37×** | 38/56/4 |
| `system/crontab` | 178 ms | 168 ms | **54 ms** | **3.33×** | 15/28/1 |
| `network/firewall` | 290 ms | 386 ms | **90 ms** | **3.23×** | 32/48/2 |
| `network/dns` | 329 ms | 370 ms | **108 ms** | **3.05×** | 35/53/2 |
| `network/firewall/forwards` | 342 ms | 358 ms | **114 ms** | **3.00×** | 30/42/2 |
| `system/system` | 415 ms | 383 ms | **139 ms** | **2.98×** | 33/50/3 |
| `status/realtime/cpu` | 188 ms | 205 ms | **65 ms** | **2.89×** | 17/29/4 |
| `network/dhcp` | 361 ms | 367 ms | **131 ms** | **2.76×** | 35/52/3 |
| `status/realtime/connections` | 188 ms | 150 ms | **69 ms** | **2.73×** | 17/28/2 |
| `system/admin/sshkeys` | 143 ms | 167 ms | **53 ms** | **2.69×** | 15/27/1 |
| `system/admin/uhttpd` | 142 ms | 166 ms | **56 ms** | **2.52×** | 16/28/1 |
| `network/wireless` | 389 ms | 391 ms | **156 ms** | **2.49×** | 34/50/4 |
| `system/flash` | 238 ms | 212 ms | **104 ms** | **2.30×** | 16/28/2 |
| `status/overview` | 875 ms | 893 ms | **392 ms** | **2.23×** | 47/72/2 |
| `system/reboot` | 147 ms | 168 ms | **66 ms** | **2.22×** | 15/27/1 |
| `network/firewall/snats` | 202 ms | 256 ms | **97 ms** | **2.09×** | 21/34/2 |
| `system/leds` | 207 ms | 238 ms | **106 ms** | **1.97×** | 26/38/2 |
| `system/admin/repokeys` | 289 ms | 288 ms | **155 ms** | **1.87×** | 18/30/3 |
| `status/processes` | 291 ms | 314 ms | **157 ms** | **1.85×** | 15/27/1 |
| `status/nftables` | 214 ms | 245 ms | **128 ms** | **1.67×** | 18/30/7 |
| `status/routesj` | 232 ms | 266 ms | **174 ms** | **1.33×** | 20/33/3 |
| `system/package-manager` | 863 ms | 877 ms | **768 ms** | **1.12×** | 17/31/3 |
| `system/startup` | 1136 ms | 1188 ms | **1016 ms** | **1.12×** | 15/31/1 |
| **TOTAL (sum of medians)** | **11306 ms** | **12142 ms** | **4933 ms** | **2.29×** | |
| **median per-page speedup** | | | | **3.03×** | |
| **pages navigated in place** | 0 | 0 | **38 / 38** | | |
### The July 2026 run, and why the totals moved
Same hardware, five runs, footstrap 0.11.3, measured 2026-07-26: **11 814 / 12 644 / 4545 ms**,
2.60× total and 4.31× median page. So footstrap's total looks 388 ms worse now, while bootstrap's
looks 508 ms better.
**It is not the theme, and that was measured rather than argued.** Both versions were installed on
this router in turn, against the same LuCI, the same 38 pages and five runs each:
| | footstrap 0.11.7 | footstrap 0.12.0 |
|---|--:|--:|
| Time, sum of medians | 4932 ms | **4930 ms** |
| Client CPU per navigation | 21.8 ms | **22.5 ms** |
| Router CPU per navigation | 97.6 ms | **98.9 ms** |
| median per-page difference | | **2.8 ms** |
**What moved is the 40 ms uhttpd stall**, the one documented above. Turning it off on this router
(`http_keepalive=0`, uhttpd restarted, everything else identical) and re-running the same 38 pages
five times:
| footstrap 0.12.0 | sum of medians | client CPU per navigation |
|---|--:|--:|
| `http_keepalive=20` (stock) | 4930 ms | 22.5 ms |
| `http_keepalive=0` | **3886 ms** | **20.8 ms** |
| | **1.27×** | |
So a fifth of the theme's whole tour is a TCP stall that belongs to the web server. The growth since
July tracks it, and tracks the number of requests a navigation makes:
| requests during the navigation | pages | July | keep-alive 20 | keep-alive 0 |
|---|--:|--:|--:|--:|
| ≤ 1 | 16 | 1498 ms | 1680 ms | **1475 ms** |
| all 38 | 38 | 4545 ms | 4930 ms | **3886 ms** |
Read the first row: on the light pages the July run behaves like a router with no stall, and
today's behaves like one with it. On the heavier pages July already paid it — Wireless was 127 ms
then, 158 ms now and **58 ms** with the stall gone; Repository keys 132 / 156 / **58**. So July was
a mixed state, not a clean baseline, which is the honest reading of it.
`luci-base` was upgraded on this router on 2026-07-31 (26.134 → 26.209), which is the obvious
suspect and is not the cause: the compare between those two commits touches 300 files and **none of
them is `luci.js`, `form.js`, `uci.js`, `rpc.js` or `ui.js`**, so the set of calls a navigation makes
did not change. Measured during the run with CDP initiator stacks: `file.read` 42.7 ms on Crontab,
`uci.changes` 44.6 ms on Dropbear, `session.access` 44.2 ms on IP Sets — every one issued by
`luci.js`, none by the theme, against a router that answers such a call in 1–4 ms.
**What decides whether a given small call stalls is which socket it lands on**, and that is the
browser's connection pool, not anything either side of this comparison controls. Why a single-call
navigation landed on a fresh socket in July and on a reused one now is not established here; the
candidates are the Chromium build the harness downloads and the state of the LAN. It is not the
theme (4932 vs 4930 above) and not LuCI's JS (unchanged).
The remedy remains `TCP_NODELAY` in uhttpd, which keeps keep-alive and helps every LuCI theme.
`http_keepalive=0` buys the 1044 ms back today, and **must not be set on a router reachable over
HTTPS** — this one listens on 443, where the same setting measured 3× worse.
### Earlier baseline on the same hardware (footstrap 0.7.16)
Three runs, 38 pages. Kept for comparison: the theme has gained modules and CSS since, and the
ratio held. Full data was overwritten by the run above; this table is the record.
| page | bootstrap | proton2025 | footstrap | ×bootstrap |
|---|--:|--:|--:|--:|
| `status/realtime/wireless` | 288 ms | 254 ms | 16 ms | **17.48×** |
| `network/diagnostics` | 189 ms | 288 ms | 21 ms | **9.15×** |
| `status/realtime/load` | 138 ms | 154 ms | 15 ms | **8.96×** |
| `system/admin` | 136 ms | 153 ms | 16 ms | **8.82×** |
| `system/admin/sshkeys` | 155 ms | 155 ms | 19 ms | **8.15×** |
| `system/admin/password` | 108 ms | 155 ms | 14 ms | **7.74×** |
| `status/realtime` | 106 ms | 156 ms | 15 ms | **7.04×** |
| `status/realtime/cpu` | 190 ms | 168 ms | 28 ms | **6.69×** |
| `system/admin/dropbear` | 223 ms | 303 ms | 37 ms | **6.09×** |
| `network/network` | 367 ms | 370 ms | 63 ms | **5.81×** |
| `system/crontab` | 182 ms | 199 ms | 39 ms | **4.61×** |
| `network/firewall/zones` | 302 ms | 306 ms | 66 ms | **4.54×** |
| `network/routes` | 350 ms | 308 ms | 78 ms | **4.47×** |
| `system/admin/uhttpd` | 153 ms | 172 ms | 35 ms | **4.40×** |
| `status/realtime/bandwidth` | 238 ms | 204 ms | 58 ms | **4.12×** |
| `network/firewall/forwards` | 343 ms | 356 ms | 87 ms | **3.96×** |
| `network/dns` | 328 ms | 398 ms | 84 ms | **3.92×** |
| `status/logs/dmesg` | 101 ms | 156 ms | 26 ms | **3.82×** |
| `network/firewall/rules` | 300 ms | 353 ms | 87 ms | **3.45×** |
| `network/firewall` | 300 ms | 307 ms | 88 ms | **3.41×** |
| `system/system` | 348 ms | 393 ms | 113 ms | **3.09×** |
| `network/dhcp` | 279 ms | 363 ms | 91 ms | **3.07×** |
| `status/logs/syslog` | 189 ms | 221 ms | 64 ms | **2.97×** |
| `network/firewall/snats` | 225 ms | 222 ms | 80 ms | **2.81×** |
| `status/logs` | 186 ms | 277 ms | 70 ms | **2.64×** |
| `status/routesj` | 200 ms | 255 ms | 77 ms | **2.60×** |
| `status/realtime/connections` | 139 ms | 154 ms | 56 ms | **2.47×** |
| `network/firewall/ipsets` | 168 ms | 219 ms | 69 ms | **2.43×** |
| `network/wireless` | 325 ms | 433 ms | 137 ms | **2.36×** |
| `status/nftables` | 210 ms | 246 ms | 90 ms | **2.32×** |
| `system/reboot` | 140 ms | 154 ms | 63 ms | **2.21×** |
| `system/flash` | 244 ms | 221 ms | 113 ms | **2.15×** |
| `system/leds` | 237 ms | 269 ms | 117 ms | **2.04×** |
| `system/admin/repokeys` | 276 ms | 303 ms | 154 ms | **1.79×** |
| `status/processes` | 290 ms | 308 ms | 170 ms | **1.70×** |
| `status/overview` | 676 ms | 754 ms | 574 ms | **1.18×** |
| `system/startup` | 1076 ms | 1091 ms | 941 ms | **1.14×** |
| `system/package-manager` | 814 ms | 881 ms | 765 ms | **1.06×** |
| **TOTAL (sum of medians)** | **10518 ms** | **11680 ms** | **4638 ms** | **2.27×** |
| **median per-page speedup** | | | | **3.43×** |
| **pages navigated in place** | 0 | 0 | **38 / 38** | |
### Dev containers (footstrap 0.10.0, 2026-07-24)
Both dev containers (`owlab.yaml`), x86 under WSL, so absolute numbers are not
comparable with router hardware. Two themes only: proton2025 is not installed there. Three runs,
median. Data in `bench/results-container-2512.json` and `bench/results-container-2410.json`.
| container | release / package manager | pages | footstrap vs bootstrap (sum of medians) | median per page | in place |
|---|---|--:|--:|--:|--:|
| `router2512` | 25.12 / apk | 36 | **2.33×** (7458 → 3196 ms) | **3.04×** | 34/36 |
| `router2410` | 24.10 / opkg | 35 | **2.16×** (6753 → 3130 ms) | **2.40×** | 34/35 |
The page set differs from the hardware run: container fixtures produce different menu entries,
and `status/realtime/temperature` is skipped because there are no sensors. The ratios line up
with the hardware baseline anyway.
+482
View File
@@ -0,0 +1,482 @@
# The chrome: sidebar, rail, bar
The spec for what `header.ut` and `menu-footstrap.js` draw. Tokens and palettes:
[design-system.md](design-system.md). The stylesheet build: [css.md](css.md). Client navigation:
[spa-router.md](spa-router.md).
## Layout is a CLIENT axis, not a theme entry
`luci.themes` holds one entry, `Footstrap` → `/luci-static/footstrap`. The sidebar and the top
bar are the same markup and the same renderer; CSS morphs them by `:root[data-layout]`.
- The attribute **always carries an explicit value** (`sidebar` | `top`) and is **stamped by the
server** in `partials/head.ut`; an inline script overrides it from `localStorage` `fs-layout`
before first paint. The explicit value is not cosmetic: every layout rule matches
**positively**, so a future third layout must opt into rules rather than inherit the sidebar's
by merely not being `top` — which is what `:not([data-layout="top"])` would do, and why that
guard must not be written. It also keeps the chrome correct with JS disabled.
- Switching layout re-renders nothing: the DOM serves both, CSS changes the chrome, and a
`MutationObserver` on `data-layout` in `menu-footstrap.js` folds the accordion into dropdowns
and back.
- **The router may carry a default**: `luci.main.footstrap_layout=top` makes the bar the layout a
browser starts on, `head.ut` stamps it, and the reader's own choice overrides it forever. A
shell script cannot write `localStorage`, which is why the server side of the axis exists at
all. Layout is the axis this pattern started with; every axis now records its choice
explicitly, for the same reason — see the three layers in [design-system.md](design-system.md).
**The bar is the base; the vertical sidebar is the exception.** The bar is written once with no
guards (`styles/theme/20-shell.css`) and serves the top layout at any width *and* the sidebar
layout on a phone. The vertical column is the single guarded override, winning on specificity
(`0,4,0` against `0,1,0`) rather than on order. `50-toplayout.css` is a pure desktop-bar delta.
## Target layout
```
┌──────────────────────────────────────────────────┐
│ .fs-sidebar 224px │ .fs-main (flex:1) │
│ (rail: 68px) │ ┌──────────────────────────┐ │
│ [logo] hostname ◂│ │ .fs-content: │ │
│ #indicators │ │ warnings │ │
│ MENU │ │ #tabmenu │ │
│ ▸ Status ● │ │ #view │ │
│ ▸ System │ ├──────────────────────────┤ │
│ ▸ Network │ │ footer.fs-footer │ │
│ [spacer] │ └──────────────────────────┘ │
│ [search] │ │
│ ▸ Log out │ │
└──────────────────────────────────────────────────┘
```
The widths are quoted at normal density: `--fs-sidebar-w` and `--fs-rail-w` are `calc()` over
`--fs-density-box`, which the Density axis sets, so Compact and Large move them — and `fitShell()`
follows for free because it reads the computed token rather than a copy.
There is no separate topbar inside `main`: the page title is a visually hidden `<h1>` inside
`<div class="fs-title fs-sr">`, and `#indicators` (the "Refreshing" poll pill, unsaved changes)
lives in the sidebar. The `◂` button (`#fs-rail-toggle`) collapses the sidebar to a 68 px icon rail
with flyouts.
**`.fs-sr` clips, it does not hide.** This was `hidden` once — that is `display:none`, which drops
the element out of the accessibility tree: the `<h1>` effectively did not exist while the SPA
router dutifully updated the title inside it. Clipping (as `.fs-skip` does) keeps it in the tree.
Beside it sits `#fs-nav-status` (`role="status"`, `aria-live="polite"`), where the router writes
the page title after every SPA navigation — without it, a page change without a reload does not
happen at all for a screen reader.
## LuCI container mapping
| LuCI container | In bootstrap | In footstrap |
|---|---|---|
| `#topmenu` | horizontal top-level menu | `ul.nav` in `.fs-sidebar`: vertical list / bar / rail flyouts — **one markup** |
| `#modemenu` | admin/status breadcrumb switch | `.fs-modemenu` in the sidebar; empty or single mode → `display:none` |
| `#tabmenu` | tabs under the header | stays in main, above `#view` (emitted by `partials/notices.ut`) |
| `#indicators` | header's right corner | `.fs-indicators` in the sidebar under the brand; empty → hidden |
| `#view` | content | content (main column) |
The set of top-level sections comes from the menu tree and depends on the installed packages —
**do not hard-code it**.
## `data-narrow`: "the sidebar became a bar", and it is a MEASUREMENT
The sidebar gives way to the bar when the remaining content column has nothing left to show. A
viewport breakpoint cannot say that: the slice is not a constant (224 px expanded, 68 px rail), so
one breakpoint gave both states the same answer and the rail collapsed at the same width as the
full sidebar.
`fitShell()` (`fs-chrome.js`) subtracts the slice from the root's `clientWidth` and compares the
remainder with `--fs-content-min`; it **reads the widths from the CSS tokens** (`--fs-sidebar-w`,
`--fs-rail-w`, `--fs-content-min`, `--fs-content-pad`) through `getComputedStyle` rather than
keeping copies — otherwise narrowing the rail in the styles would leave the measurement subtracting
the old number, and no gate would notice. The result is `data-narrow` on `:root`, read by both the
CSS and `flyoutMode()` in the JS.
**The subtraction itself is `columnWidth()`, and there is exactly one of it.** Two callers need the
width of the content column — `fitShell()`, deciding whether the sidebar still fits beside it, and
`contentWidth()`, which answers `fitTables()` mid-scroll where reading layout is forbidden — and
while the arithmetic was written twice it drifted twice: `--fs-content-pad` is ONE side's gutter and
`shellGeometry()` already doubles it, so one copy subtracted it twice, and the top-BAR layout has no
sidebar to subtract although it carries no `data-narrow` either (`fitShell()` removes it on the way
out). Both errors are invisible in a screenshot and both are worth enough to push a table across
`fitTables()`'s cramped threshold. The gutter it subtracts is MEASURED off `.fs-content` rather than read from
`--fs-content-pad`: the same sheet re-paddings that element to 16px a side below 767px while the
token stays 28, and a media query has no `data-*` to key on — nor may its breakpoint be copied into
the JS. So the model asks the element what it actually got.
The arithmetic is unit-tested per combination (`tests/chrome-geometry.test.mjs`); whether its inputs
still describe the real page is checked on a stand, where `live-audit` compares `contentWidth()`
against the live `.fs-content` box. That check is what caught the gutter, on every page at 320, 390
and 568.
**`contentWidth()`'s width is compared fresh on every call, not just cached from the last fitter.**
`_shellOuter` (the window width `columnWidth()` subtracts from) is written only by `measureShell()`,
which only `fitShell()` calls, and `fitChrome()` steps aside for the whole `SCROLL_IDLE` window
(400 ms, `fs-fit.js`) whenever `fit.scrolling()` answers yes — a resize starts that window the same
way a flick does. A caller landing inside it — `fitTables()`'s, most of all, since it asks mid-scroll
by design — got the PREVIOUS viewport's width: a resize from 568 to 390 still answered 568 for up to
220 of the 400 ms, and the same shape held at every step of `live-audit`'s width list (−70 at
320→390, −178 at 568→768 read backwards, −256 at 1024→1440 — always exactly the gap between the two
adjacent widths). `contentWidth()` now compares `document.documentElement.clientWidth` against
`_shellOuter` on every call and re-runs `measureShell()` when they differ, rather than only when
`_shellOuter` was still unset. That comparison is the one layout read the function's "no layout
read" promise was already conditional on (the bootstrap branch made the same call); doing it on
every call costs nothing when nothing moved, since nothing invalidated layout between one read and
the next.
The measurement, the observer and the coalescing live in `fs-fit.js`, the theme's one "does it
still fit?" engine (also used by `fitTables` in `fs-select.js`). **Add fit logic there; do not grow
a second observer.**
The one literal is `@media (min-width: 521px)` around the vertical override in `20-shell.css`: the
floor below which no slice leaves a readable 500 px, and simultaneously the safety net with JS
disabled — no JS means no `data-narrow`, and a phone would otherwise draw the desktop sidebar.
The top bar measures too: it first squeezes the pills (`.fs-dense1/2`), and only if the menu still
wraps at the tightest step does it move to a second row (`.fs-bar-stack`). Whether it fits
depends on the number of sections on that particular router, not on the screen.
**And it pins its own height while it measures.** Rule 1 says take the class off and ask the engine,
but the bar is the one fitter whose element sits ABOVE the reader on every page: `fs-bar-stack` is
what gives the menu its own row, so for the widths where the menu does not need one the bar answers
a row shorter than it stands — 98px against 65px at 767px, 123px against 115px at 480px, on
owrt2512. That height is what the engine lays the rest of the page out against for the length of the
pass. Chromium and Firefox put the reader back afterwards; Safari implements no scroll anchoring on
any platform, and an iPhone reported the Overview creeping upward once per poll tick, bisected there
to this pass. So `fitChrome()` writes `min-height` at the settled height before it strips the
classes and takes it off after — the measurement is unchanged, and the box the page is laid out
against does not move. `tools/fit-quiet.mjs` (`npm run quiet`, in `live`) hands the bar a
`classList` of its own and reads the height back at the instant the classes come off: 0px of dip
with the pin, 8px and 33px without it. The symptom itself does not reproduce headless — the pass is
one synchronous task and both engines restore the offset once the bar is back, measured at half the
page and at its bottom — which is why the gate watches the cause.
## `header.ut`
- Shared parts live in `partials/` (`head`, `brand`, `logout`, `notices`, `notice`, `search`,
`icon`, `footer`). There is no second template directory.
- `<body>` → a `.fs-shell` flex wrapper. Before it, the skip link `.fs-skip`
("Skip to content" → `#maincontent`), the first tab stop on the page.
- **Sidebar** — `<nav class="fs-sidebar" aria-label="Menu">`, deliberately `<nav>`: `<aside>` gives
the `complementary` role, and no landmark jump reaches the menu through it.
- `.fs-brandrow`: brand (gradient square + wifi SVG on `currentColor` + hostname wordmark) and
the `#fs-rail-toggle` button;
- `<div id="indicators">`;
- `.fs-navlabel` + `<ul class="nav" id="topmenu">` (filled by the menu JS) + `<ul id="modemenu">`;
- `.fs-spacer` (`flex:1`);
- the search button (`#fs-search-btn`, the command palette below) and Log out. An Appearance
button used to sit between them; the axes are their own menu entry now
(`/admin/system/footstrap`, previously a tab stapled onto System → System), so the chrome
carries one control fewer — see [design-system.md](design-system.md).
- **Main** — `<main class="fs-main" id="maincontent" tabindex="-1">`: `.fs-title.fs-sr` with the
`<h1>`, `#fs-nav-status`, and `.fs-content` (warnings, `#tabmenu`, `#view`, footer).
- Must be preserved: `http.prepare_content`, `cbi.js`, the translations script, `node.css`, `css`,
`blank_page`, `noscript`, and the no-root-password / initramfs warnings.
`partials/footer.ut` emits `<footer class="fs-footer" role="contentinfo">` — the role is explicit
because `<footer>` only gets `contentinfo` implicitly when its nearest ancestor is `<body>`, and
this one sits inside `<main>`. It hard-loads `L.require('menu-footstrap')` and then
`L.require('fs-select')`; the `menu_module` parameter that used to pick a renderer went away with
the second renderer.
## `menu-footstrap.js` — the ONLY renderer
There is no `menu-footstrap-top.js`. **A second layout was never a second design**: the sidebar
renderer already produced markup its own CSS turns into a horizontal bar on a phone, and it already
had a flyout mode where a section behaves exactly like a top-menu dropdown. The top layout is
that mode at desktop width. The one piece of unique logic the deleted file carried, `clampDropdown`,
moved here.
- `renderMainMenu` fills `#topmenu`: an item is
`<li><a><icon><span class="fs-label">title</span><chevron></a></li>`, active by
`L.env.dispatchpath`. Icons are mapped by section name with a regex fallback and a generic SVG,
inlined as strings in the JS (`E()` cannot build SVG — see [conventions.md](conventions.md)).
Everything shared — tabs, modes, the rail, the SPA router, chrome
measurement — lives in the `fs-*.js` modules that `menu-footstrap-common.js` wires together. Only
`renderMainMenu` is layout-specific, and it is passed into `common.init()`: composition, not
inheritance, because LuCI makes a singleton of every baseclass.
- The top-level `admin/logout` node is dropped from the tree — the chrome draws its own Log out
(`partials/logout.ut`), otherwise it appears twice.
- **A section with children is a W3C APG disclosure pattern**, not a link: `role="button"`,
`aria-expanded`, `aria-controls`, Enter/Space, and Escape closes the flyout and returns focus.
Deliberately not `role="menu"` — APG explicitly says site navigation should not take menubar
semantics. `aria-current="page"` goes on the leaf only; a section header is a button, not a link
to the current page.
- **`.open` has two meanings**: in the expanded sidebar it is an accordion (several sections at
once, the set remembered in `localStorage` `fs-menu-open`); in the rail, in the bar, or on a
narrow screen it is an exclusive flyout. `flyoutMode()` decides, and it reads **exactly what the
stylesheet reads**: `data-rail` / `data-layout=top` / `data-narrow`. Leaving flyout mode restores
the accordion (`restoreAccordion()`) — a plain `closeFlyouts()` was not enough, because it
stripped `.open` from everything while the markup is not rebuilt on a rail toggle, so "Keep open"
stopped meaning anything.
- `clampDropdown` pushes a dropdown back into the viewport at the right edge. Whenever the chrome is
a BAR (`barDropdown()` — the top layout at any width, or a `data-narrow` sidebar), because that is
when a panel hangs off its own `<li>`; the rail is excluded, where a flyout is anchored sideways
instead. It keeps one scheduled `rAF` **per `<li>`** so it can cancel an
unfinished measurement when the pointer has moved on; the shared `fit.frame()` coalescer cannot
express that, and this is a documented exception.
- Hover opening is pure CSS.
## Dark mode and outward compatibility
Mode, palette and layout are all client settings; the pre-paint blocks in `partials/head.ut` stamp
`:root` before the first frame, one block per axis.
Dark mode: a stored value beats the OS, otherwise `prefers-color-scheme`. The `change` subscription
is registered always (it re-reads storage), so Auto keeps following the system if the user
switches to it after load.
**`set()` stamps THREE attributes:** `data-darkmode` (which this theme's CSS reads), plus
`data-theme` and `data-bs-theme` as outbound compatibility for third-party apps that sniff
them — `luci-app-justclash` hangs 21 rules on `data-theme`, `ssclash` checks `data-bs-theme`
first. Nothing inside `styles/` may read the latter two. `tools/axes.mjs` holds the set.
`<meta name="darkreader-lock">` stops the Dark Reader extension repainting the theme into mush.
More on how foreign apps detect dark mode: [third-party-apps.md](third-party-apps.md).
## The command palette
`fs-search.js` finds a page by name instead of by remembering which section owns it. A loaded
router carries ~200 reachable menu nodes across 11 sections, and some pages appear in no menu list
at all until you are already there — "Port Forwards" is a tab of Network → Firewall.
- **It costs no request.** The index is built from the same ACL-filtered `/admin/menu` blob the
chrome already loaded (`fs-menutree`), so the palette knows exactly the pages this session may
open: nothing to leak, nothing to 403 on. It is built on the first open, not at init — a user
who never searches pays nothing, and only a full load can change the tree.
- **It indexes tabs**, to `admin/<section>/<page>/<tab>` — four levels, every path the dispatcher
renders.
- **It does not call the router.** Every result is a real `<a href>`, so a click bubbles to the
router's own document-level handler and takes the SPA path (or falls back to a full load when the
node is not SPA-able) with no second copy of that decision. Enter synthesises the same click.
- **Recently visited rows are kept in `localStorage` `fs-recent`, one key each.** A page's key is
its menu path; a row from a companion source carries its own `key`, because what it points at has
no dispatcher node — the sections source keys one `admin/system/system#Footstrap`, the page plus
the section's own heading. Only the key is stored: the title is resolved through the pool on
every render, so it follows the UI language and a row nothing produces any more is dropped
instead of shown dead. `menu-footstrap-common` owns the writer (`remember()`, exported for a
source that has to record its own row) and `warmRecent()` prefetches the page half of each key —
see [spa-router.md](spa-router.md).
**Trap it was built around: do not index through `ui.menu.getChildren()`.** On an alias node it
returns a copy whose `children` are the alias *target's*. That is right for drawing a menu and
wrong for indexing: Network → Firewall is an alias onto the `firewall/zones` view, a leaf, so its
five tabs came back as an empty list and "port" found nothing on a router that plainly has a Port
Forwards page. Measured on the dev router: 78 indexed nodes through `getChildren()`, with every tab
of every aliased page missing.
## Keeping the reader's place when the engine will not
**Every mechanism, what each is for and what the sweep measures without it, is in
[anchoring.md](anchoring.md).** What follows here is the chrome's own half: which element a
correction may take, and why.
A poll tick changes the height of things above the reader — a station joins the associated list, a
lease expires, an interface box grows a line — and everything below it moves. Chromium and Firefox
hide that with **scroll anchoring**: they pick an element the reader can see and compensate the
scroll offset so it stays put. **WebKit did not implement it until recently**, so on an older Safari
and on every iPhone of that vintage the same tick moves the page under the reader's thumb. That is
what "the Overview jitters" was, and it is why it was reported from Safari and an iPhone while the
same router was still in Chrome. A current WebKit does anchor — and gets a DIFFERENT half of this
wrong; see "What an engine that anchors still gets wrong" below.
`fs-fit.js` therefore does the job **only where nobody else is doing it**: `ENGINE_ANCHORS` asks the
platform (`CSS.supports('overflow-anchor', 'auto')` — an engine that does not know the property does
not have the feature) rather than a browser name, because correcting an offset that the engine also
corrects means two corrections and a page that jumps the other way.
Three details carry it, and each one was a measured failure first:
- **The reference is what sits at the top of the CONTENT, not the frame the fold cuts through.** A
tick that grows something inside that frame leaves the frame's own top exactly where it was —
drift 0, measured — while everything after it moves. One hit test below `[data-fs-chrome]` (the
bar is sticky and owns the first rows of the viewport; a test at y=1 returns the chrome and the
page gets no anchor at all) gives the same element the engine's own algorithm would pick.
- **It is captured while the page is still, not in the mutation callback.** That callback runs after
the DOM changed, so a reference taken there has already moved with it: right for the fitters,
which have not run yet, and blind to the tick itself. The resting reference is refreshed after
every settled pass, after every correction, and when a scroll stops.
- **It carries the offset it was taken at.** The reader scrolling and the page growing look the same
from the element alone; correcting against a reference from another offset would drag the page
back to where the reader had scrolled from.
- **…and an offset that DROPPED with nobody scrolling, on the page it was taken on, is the engine.**
`dom.content()` — what every LuCI poll calls to refresh a section — empties the container before it
refills it, and for that moment the document is shorter than the offset the reader is at. The
engine clamps the offset into what is left, the section fills again and nothing puts it back. Three
conditions separate that from a reader who moved: a clamp only ever moves the offset **down**; a
reader who moved is one `scrolling()` still answers for (their scroll starts the sampler, while the
clamp's own scroll event arrives in the rendering step after the mutation callback); and the page
stamp has not changed, because the router resets both scrollers on a navigation and replays them on
a Back, and neither is a clamp to undo. The clamped amount is also what raises the one-viewport
ceiling on a correction — a drift that big normally means the view replaced its subtree, and a
clamp is the one that comes with a receipt.
- **The correction does not need the reference to have survived.** `dom.content()` replaces a
section's children with new nodes, so the element that happened to sit at the top of the content
area usually does not survive the tick that moved the reader — measured on a 24.10 router, where it
was disconnected on exactly the tick that mattered and the fallback was left measuring a fresh
reference against nothing. With no element there is no drift, but there is still a number known
exactly: the offset dropped by this much and nothing else happened, so giving that back **is** the
correction. It cannot run away with the page either — if the document really did get shorter, the
browser clamps the write back and the reader keeps the offset they already had.
**Two halves: the document does not shrink, and the reader is put back if it did.** Every container
a poll empties carries a floor — `min-height` at the height it had at the last settled moment, held
until the next one (`holdFloor()`, over `.cbi-section > div, .table > .tbody, .table`: what
`dom.content()` is called on and nothing wider). A container emptying takes nothing off the document,
so there is no shorter document for the engine to clamp into and the tick is invisible; the floors
are re-measured after every settled batch, so a page that genuinely got shorter is shorter one frame
later. Measured on a 25.12 stand with the correction switched off, a real poll tick clamped 1882px
away without the floor and 0px with it; on 24.10, where the theme cannot reach the poll at all, the
same park went from a 1206px clamp to no offset change at all.
**The floor is on the containers because it may not be on the column.** `min-height` on an ancestor
of the engine's own anchor is a scroll-anchoring suppression trigger — css-scroll-anchoring-1 §2.2.2
lists it, and Blink's list (`css_properties.json5`, `invalidate: [..., "scroll-anchor"]`) is wider
still — so a floor on `.fs-content`, where this used to live, turned the engine's anchoring off in
exchange for holding the document up. Measured with it there: 120px grew above the reader and the
page moved all 120px under them, on Chromium and Firefox alike; and the 15px this file used to quote
for the same configuration was taken before `47e636d` gave anchoring engines a live reference, and
was never re-taken. The suppression walks only the path from the anchor to the scroller, and a
container that empties is never on it — either the anchor was inside it, in which case the engine has
lost the anchor anyway, or the anchor is elsewhere and this container is its sibling. So the floor
now runs on every engine, and the engine's own anchoring keeps working beside it.
**A pin on the container itself does not work, and the shape that does was reported from the field.**
`fs-overview.js` briefly pinned each container across `dom.content()` and released it in the same
statement sequence — `dom.content()` performs no layout, so nothing ever observed the pin (measured:
1882px still clamped away with it in place). The report that named it wraps `dom.content()` itself
and releases two frames later, which does work — at the price of patching a luci-base API every app
on the router shares, and up to seven read/write pairs per call. One element the theme owns, measured
once per settled batch, is the same protection: on a live router with the correction off, the wrapper
and the floor both held the reader at 0px against a 337px drift without either, at 16 measurements
against 154 wrapped calls.
**The router owns the offset when it navigates.** `fs-router` resets both scrollers for an incoming
page and stamps `body[data-page]` a require later, so between the two there is a window in which a
poll tick from the OUTGOING page still fires — an offset of 0, a remembered offset from where the
reader was, nobody scrolling and the old stamp: every term of "the engine clamped this" is true. The
router calls `fit.forgetRest()` at the reset rather than leaving the memo to be inferred from a stamp
written afterwards.
**What an engine that anchors still gets wrong.** `ENGINE_ANCHORS` asks whether the platform supports
`overflow-anchor`, and every current engine answers yes, WebKit included — so the theme steps aside
from the growth case. Anchoring is not the same promise as "a section can vanish and come back",
which is what `dom.content()` does on every tick: the container empties, the offset is clamped into a
document that is briefly shorter, and what the engine does on the way back is its own business.
Chromium lands where it started. WebKit OVERSHOOTS — measured on both stands, both layouts and both
widths, a swap that grew a section by 120px moved the offset by 180, leaving the reader 60px up the
page **on every tick**.
**The memo is a pair, and a clamped correction splits it.** `putBack()` is the one write either
correction makes: it moves the offset by the drift it measured, and it used to update `_restAt`
alone, on the reasoning that the element is now back at the top it was remembered at. It is not,
whenever the write was clamped short — a page already at its end cannot scroll the last pixels — and
then `_rest.top` names a position nothing can reach while `_restAt` has already moved on. The next
tick corrects to that impossible top and takes the reader with it: measured on webkit/Overview at
390 wide in the top layout, `_rest` claiming 93.02 for an element standing at 105, and the reader
12px off after the tick (52px on the density beside it). Where the element actually landed is
re-read after the write — for the reference and for the section it falls back to — which is two
rects on a layout the write has already forced. `_rest.at` is left alone: it is read only by
`anchorFor()`, on the path where the engine does no anchoring, and the sweep is green on all 18
cells of the failing axis without touching it. **This only became visible when WebKit
gained anchoring**: on the engine-anchoring path the theme corrects from a memo it keeps across
ticks, where before it took a fresh reference on every mutation.
**And the pages where nothing held the reader at all.** Two faults sat behind the same symptom, both
of them in which ELEMENT the theme takes as its reference. `elementFromPoint` answers with `#view`
itself wherever the hit lands in a gap between two sections — and the host's own top does not move
when a poll changes something inside it, so the drift measured against it is zero on every tick, for
ever. And a page that is ONE TABLE (Processes, Routes, the realtime lists) has that table as a direct
child of `#view`: the climb out of the table — data tables are excluded as anchors, `30-tables.css`
explains why — landed on the host and gave up, leaving no reference at all. Since the stylesheet also
(correctly) tells the engine not to anchor inside those tables, nobody was holding the reader there on
ANY engine: measured at 390px, 120px of growth above the fold moved the page 120px on chromium,
firefox and webkit alike. The host is now refused as a reference and the hit is retried down the
viewport; where the climb would reach the host, the TABLE ITSELF is the reference — its height is
falsified by the fit pass, its top is not.
`lateDrift()` closes the overshoot without asking who the engine is. Two frames after a mutation — long enough
for the engine to have finished its own correction — the reference the theme was holding is asked
where it is now. An engine that got it right reports zero and nothing happens; what is left over is
what nobody put back, and that is what is given back. It answers GROWTH only: the collapse is the
floor's, and the page it still matters on is the one that is a single data table, where the
stylesheet has told the engine not to anchor and nobody else is holding the reader. Two things it is
deliberately NOT: a browser test (`CSS.supports` can no longer separate the two behaviours), and a
synthetic probe that performs the collapse itself (it calls Firefox broken, because a real page puts
layout and a frame between the collapse and the refill; gating on it cost Chromium and Firefox 15px
of drift they did not have).
The price is a resting reference on every engine — a hit test and a rect per settled pass, measured
on the stands at **0.2 ms typical and 6 ms worst** on a poll-dirtied WebKit layout, never during a
flick, and skipped entirely while the page is at offset 0 (nothing to be put back to). It fires only
where THE OFFSET HAS NOT MOVED since the reference was taken — `scrollTop() !== ref.at` and it sits
the tick out — plus a user gesture keeping it out, so a correction can never land inside a scroll.
Asking `scrolling()` instead does not work here and asking the sampler's step count is not enough:
the engine's own compensation moves the offset and starts the sampler, and in WebKit a programmatic
scroll's event arrives up to 1.2 s late, so the sampler is often not running at all while a flick is.
Both of those let a correction through mid-flick — 161px on `@390 side`, 320px on `@1440 side`, both
on webkit/Overview. `ref.at` and not `_restAt`, because `run()` re-remembers between the mutation and
this frame, and where the sampler has not started that re-take records the offset the reader has
already flicked to.
**The theme's own put-off pass moves the page too, and nothing was looking.** A tick landing while
the offset is in motion leaves its measurements to the pass that runs once the page holds still, and
that pass re-lays the tables and rewrites every floor. `min-height` is itself a suppression trigger
on the path to the anchor, so the engine's compensation can be off in exactly the frame that pass
needs it: on ImmortalWrt 24.10/WebKit the pass's 88 `min-height` writes and a 58px jump of the offset
land in the SAME frame, 429 ms after the mutation — `@390`, top layout, large density, Overview, one
cell out of 48, and the offset moved with no write of the theme's own (every programmatic scroll
write was intercepted and none came). `lateDrift()` is blind to it: scheduled from the mutation on
the same `SCROLL_IDLE`, it read a drift of zero three milliseconds before the page moved.
`settleDrift()` measures around that pass instead, and SYNCHRONOUSLY — the reader cannot scroll
between two statements, so what it sees is the pass's own doing, and `getBoundingClientRect()` is the
operation an engine must flush a pending adjustment before (css-scroll-anchoring-1 §2.2: the
suppression window ends at the end of the event loop iteration, or before the next operation whose
result would differ, whichever is sooner), so the read after the pass is a flush rather than a race.
Measured two frames later instead, it closed that cell and corrected INSIDE A FLICK on three others,
160px each.
`tools/scroll-anchor.mjs` holds all of it: it grows 120px above the reader and requires the page to
stay within two pixels, once with the engine's anchoring suppressed and once without, on both
scrollers; it refills a section the way a poll does — emptied, then filled again, with the router's own
poll held for the duration — and requires the same; and it flicks the page up and down to prove the
theme corrects nothing while the reader is moving. With the fallback removed the Safari case reports
exactly the reported symptom — 120px of page moved under the reader, 255px on the swap measured in
WebKit with the engine's own anchoring off, and 690px on a real Overview on the stands. It runs on
`chromium,firefox,webkit` in CI now, because the fault above lives on the path a stand-in engine does
not take, and it waits on `fit.restAt()` rather than on a stopwatch: WebKit starts its motion sampler
late enough that a flat wait measured the theme before it had a reference at all, which reported a
jump on every WebKit run with the theme identical on all three engines.
**Findings print as they are found, and one cell can be run alone.** A `--full` sweep is 144 cells
and tens of minutes, so a run that only speaks at the end costs a whole sweep per fix attempt — five
attempts at the fault above were measured that way before the flags existed. Each finding is printed
the moment it is made (`FINDING …`), `--bail` ends the run at the first one, and `--width 390
--layout top` narrows the sweep to the cell a finding names: the same measurement then takes a
minute. CI passes none of them — how WIDE a fault is is half of what the sweep says.
The sweep crosses three page shapes and two scrollers, and both sets are what a measurement left
standing rather than what looked thorough. The shapes: the Overview refills section BODIES, the
realtime Connections table has its ROWS replaced under a poll, and Processes is one table that is a
direct child of `#view` — the climb out of that one used to land on the host and give up. Processes
proves the climb and never the swap: its single block is 6397px against 6108px of scroll on
owrt2512, so it can never sit entirely above the reader, which is what a swap needs. Wireless was
measured and dropped (812px of scroll at 390px wide, 0 at 1440). The scrollers: of `1440 side`,
`1440 top`, `390 side` and `390 top`, only the first scrolls `#maincontent` and the other three
scroll the window, so the sweep keeps the first and the narrowest. Density is one value for the same
reason — across 72 Overview cells, normal, compact and large reported the same two outcomes and
nothing else. `--full` crosses all of them anyway, and CI does that on a push and a tag.
## `fs-select.js`
Turns every stock `<select>` into a styled `ui.Dropdown`, because a native `<select>` popup cannot
be styled with CSS. The native `<select>` remains the form field and must stay
`frameEl.firstChild` — `ui.Select.getValue()` returns `this.node.firstChild.value`.
It also owns `fitTables()`, which folds data tables into cards. Why config tables are handled
differently, and why that difference is not an unfinished job: [css.md](css.md).
## Translation
Every label on the Footstrap tab carries the `footstrap` `msgctxt`; the chrome (Menu, Logout,
Skip to content) and the login/warning strings deliberately do not. The reasoning — `msgid` is a
global name shared with every app on the router — is in [conventions.md](conventions.md).
Loaded 100 of 864 files, more files were not shown because too many files have changed in this diff. Show more