mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-11-05 18:44:06 +08:00
update 2026-09-20 01:22:33
This commit is contained in:
1 parent
42e0cfbec4
commit
deb27aee9d
864 files changed
+180566
-42
No files matched your search
@@ -0,0 +1,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
@@ -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
@@ -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`.
|
||||
@@ -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".
|
||||
@@ -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`.
|
||||
@@ -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();
|
||||
@@ -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
|
||||
Executable
+32
@@ -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"
|
||||
Executable
+32
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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/)**
|
||||
|
||||
[](https://owfeed.org/install/)
|
||||
[](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.
|
||||
@@ -0,0 +1,88 @@
|
||||
# LUCI-THEME-FOOTSTRAP
|
||||
|
||||
[English](README.md) · **Русский** ·
|
||||
**[Песочница — всё можно потрогать без роутера](https://vizzletf.github.io/luci-theme-footstrap/playground/)**
|
||||
|
||||
[](https://owfeed.org/install/ru/)
|
||||
[](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/) — сетка токенов,
|
||||
разметка компонентов, проверялка стилей.
|
||||
@@ -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 |
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
Reference in new issue
Block a user