update 2026-10-08 02:18:41

This commit is contained in:
action committed 2026-10-08 02:18:41 +08:00
1 parent bd061545ed
commit 170f9b0957
110 files changed
+22312 -644

No files matched your search

@@ -1,8 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-9 -1 100 100">
<!-- The OpenWrt mark, and it stays OpenWrt's on purpose: the tab icon says which DEVICE this
is, not which theme is painting it. What it does not need is the flat cyan tile the mark
used to be pasted on — that tile was the "outdated" part (issue #7), and it fought every
browser's tab strip. Transparent now, so the icon sits in whatever the browser draws.
is, not which theme is painting it. The mark carries no flat cyan tile: a tile fights every
browser's tab strip, so the background is transparent and the icon sits in whatever the
browser draws.
Colours are the mark's own: cyan arcs, dark-blue ring. On a DARK tab strip that ring is
nearly invisible, so it lightens under prefers-color-scheme — an SVG favicon is a live

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -52,11 +52,11 @@ function probeColor(expr) {
* element computing `color` in every engine. It has no text and no size, so it paints
* nothing.
*
* Every declaration is !important (issue #19): this is an unmarked element in a document
* Every declaration is !important: this is an unmarked element in a document
* shared with `luci-app-*`, and an app's unlayered `span { color: … !important }` outranks
* a layer and a plain inline style alike. A probe that loses its own colour reports the
* app's, which then becomes the admin's saved axis on the next confirm. */
/* the id is this probe's escape from fs-router.js's strayBodyNode() (issue #56): it is
/* the id is this probe's escape from fs-router.js's strayBodyNode(): it is
* appended once and never removed, so without an fs-* id it would read as body litter and
* force every navigation away from this page into a full load. */
_probe = E('span', { 'id': 'fs-appearance-probe', 'aria-hidden': 'true' });
@@ -76,7 +76,7 @@ function probeColor(expr) {
* the space it was authored in, so `oklch(0.54 0.19 300)` would parse as three numbers in the
* wrong units and produce a colour nobody chose — measured: #010078, graded "Too faint to read",
* in the hex field, the swatch and the contrast readout alike. Painting one pixel makes the engine
* convert instead (tools/export-tier.mjs uses the same method). Canvas 2D is universal on every
* convert instead (the export tier is checked the same way). Canvas 2D is universal on every
* engine 24.10 ships against, so there is no second path. */
let _cx = null;
function rasterCtx() {
@@ -170,8 +170,8 @@ function colorControl(onPick, label, opts) {
* hairline is graded on the second (`kind: 'shape'`) and warns rather than fails — a faint
* border is a legitimate choice.
*
* Class names are written out whole: tools/fs-orphans.mjs sweeps dead CSS by matching
* fs-* tokens in the source, and a concatenated name is invisible to it. */
* Class names are written out whole: dead CSS is found by matching fs-* tokens in the
* source, and a concatenated name is invisible to that match. */
const where = o.contrast.label;
const grade = (o.contrast.kind === 'shape')
? ((r >= 3)
@@ -319,7 +319,7 @@ function build() {
* one merged catalogue — load_catalog() loads every *.<lang>.lmo and a lookup returns the first
* archive holding the hash — so a bare msgid is a global name any luci-app may take, and
* readdir order picks the winner: the layout toggle rendered "Максимум" on a Russian router
* because another catalogue translates "Top" as "maximum" (issue #6). Contexting cannot be
* because another catalogue translates "Top" as "maximum". Contexting cannot be
* selective. The chrome, the login/notice sentences and the System/Memory/Storage headings are
* deliberately bare — inheriting luci-base's translation covers the ~40 languages this theme
* has no catalogue for. */
@@ -356,7 +356,7 @@ function build() {
large: _('Large', 'footstrap')
}, bump(prefs.applyDensity), label)),
/* issue #44: the content column's own cap, separate from Density (which moves type and
/* the content column's own cap, separate from Density (which moves type and
* air, not the column's ceiling). The slider STARTS at the 1280px the theme has always
* shipped and only ever widens — there is no reason to offer a column narrower than the
* one every page was designed against, and keeping 1280 as the left end also keeps
@@ -720,7 +720,7 @@ function build() {
* ships them, and nothing in a package manager can read `uci luci.main.lang` to fetch the right
* one: apk learns it from `install-if` against `luci-i18n-base-<lang>` (owfeed.yml), opkg has no
* conditional form of that at all, and a router upgraded from 0.14.3 through the feed simply
* loses the catalogue that used to ride inside the theme (issue #41). `install.sh` covers its
* loses the catalogue that used to ride inside the theme. `install.sh` covers its
* own path; this covers the one nobody ran a script on.
*
* ASKED OF THE PAGE, not of the package list: the theme has no ubus call of its own and must not
@@ -828,7 +828,7 @@ function build() {
]);
/* The first fill, deferred one microtask so the tree above is finished. It does not wait for
* the form to be in the document: every readout resolves inside fs-widgets against a hidden
* the form to be in the document: every readout resolves against a hidden
* probe attached to <body>, so a detached form still reads the live palette. */
Promise.resolve().then(refreshColours);
return page;
@@ -212,8 +212,8 @@ function _sanitizeSvg(text) {
* (Chromium and WebKit decline to run XSLT on an image/svg+xml document). A tile has no use for
* one, and without the PI the embedded stylesheet is never applied. */
/* An external-DTD DOCTYPE prologue is left unvisited on purpose: verified-safe residual finding,
* docs/architecture.md "The SVG sanitizer's residual, verified-safe findings". */
/* An external-DTD DOCTYPE prologue is left unvisited on purpose: no engine resolves an external DTD, and
* the CGI answers a direct open with CSP `default-src 'none'; sandbox`. */
for (const n of [ ...doc.childNodes ])
if (n.nodeType === Node.PROCESSING_INSTRUCTION_NODE) { n.remove(); elements++; }
@@ -253,9 +253,8 @@ function _sanitizeSvg(text) {
* one the spec gives them — a change to how SVG itself is interpreted, not something a
* check here could see coming or catch. */
if ((/^on[a-z]+$/).test(n)) { el.removeAttribute(a.name); refs++; continue; }
/* A TAB-obfuscated `javascript:` (`href="javas&#9;cript:alert(1)"`) passes this regex:
* verified-safe residual finding, docs/architecture.md "The SVG sanitizer's residual,
* verified-safe findings". */
/* A TAB-obfuscated `javascript:` (`href="javas&#9;cript:alert(1)"`) passes this regex but
* is only ever an image source, never a navigable `href`, and a direct open is CSP-sandboxed. */
if ((/^javascript:/i).test(v)) { el.removeAttribute(a.name); refs++; continue; }
/* Security review finding (LOW): `xml:base` (XML Base, W3C) is not itself a reference —
* it changes what every RELATIVE one in the subtree UNDER IT resolves against. A perfectly
@@ -273,8 +272,8 @@ function _sanitizeSvg(text) {
* consistent with this file avoiding a second grammar it would have to maintain. */
if (n === 'xml:base') { el.removeAttribute(a.name); refs++; continue; }
/* A presentation attribute's `url(` (`fill`, `stroke`, `filter`, …) is only checked below
* for `style`, not here: verified-safe residual finding, docs/architecture.md "The SVG
* sanitizer's residual, verified-safe findings". */
* for `style`, not here: the bundle is only an image source, so the
* worst reach is a second fetch with no script and no navigation. */
if (n === 'style') {
const cleaned = _sanitizeStyleValue(v);
if (cleaned.removed) {
@@ -304,7 +303,7 @@ function _sanitizeSvg(text) {
* The other axes are per-browser with a router default; this one has no browser layer. An admin
* uploads an image once, it becomes the router-wide background for every device and shows
* pre-login, so it is absent from AXIS_KEYS, snapshotAxes() and matchesSavedDefault() — it must not
* move the Save button — and needs no factory, so tools/axes.mjs never sees it.
* move the Save button — and needs no factory, so axis-contract checks never see it.
*
* The image is a served file, uhttpd having no gzip to make inlining it in every <head> viable;
* only its cache-bust token lives in uci -> window.__fsSD -> the url() head.ut stamps. The path is
@@ -16,14 +16,13 @@
* What stayed behind in `fs-prefs`, and why:
* - the localStorage wrappers and `sd()`, which everything here calls through `prefs.`
* - dark mode, because `guardDarkStamp` defends against a third-party app on every page and
* `tools/chrome-fence.mjs` holds `stampDark()` to that file by path
* `stampDark()` is checked against that file by path
* - layout, density, rail and auto-collapse, because the chrome and the menu apply them live —
* and because `tools/scroll-anchor.mjs` and `tools/scroll-jank.mjs` stamp layout and density
* through `L.require('fs-prefs')` to sweep their matrix
* and because external probes stamp layout and density through `L.require('fs-prefs')`
*
* The pre-paint in head.ut has already stamped every axis before the first frame, so nothing here
* is needed to PAINT a page correctly — only to change one from the form. tools/axes.mjs reads the
* whole resources directory rather than a path, precisely so an axis may live in a second file. */
* is needed to PAINT a page correctly — only to change one from the form. The axis list is read
* from the whole resources directory rather than one path, so an axis may live in a second file. */
const FS_RADIUS_DEFAULT = 12;
@@ -117,7 +116,7 @@ function propAxis(key, sdKey, prop, min, max, dfl, fmt, after) {
* it and the first touch of any other control takes it away.
*
* Legacy names ('rvht'/'roman'/'github') are migrated by head.ut before paint, so they never reach
* currentPalette() on a loaded page; the stray fallthrough covers them anyway. */
* PALETTE.current() on a loaded page; the stray fallthrough covers them anyway. */
const PALETTES = [ 'hicontrast', 'bootstrap', '2020', 'forum' ]; /* the non-default values; 'footstrap' = bare :root */
const PALETTE = prefs.listAxis('fs-palette', 'data-palette', PALETTES, 'footstrap');
@@ -204,7 +203,7 @@ const DANGER = colorAxis('fs-danger', 'data-danger', '--fs-danger-h', '--fs-dang
/* Rounding: the propAxis instance (default const and rationale up top), --fs-radius-base in px. */
const RADIUS = propAxis('fs-radius', 'rounding', '--fs-radius-base', 0, 20, FS_RADIUS_DEFAULT, (v) => (v + 'px'));
/* Content width (issue #44): how far the reader lets the column grow past --fs-content-max's own
/* Content width: how far the reader lets the column grow past --fs-content-max's own
* 1280px. A propAxis like Rounding, pointed at that one token — the range comment lives in
* 02-tokens.css, next to the token it bounds, and is not restated here.
*
@@ -462,21 +461,9 @@ const PATTERN = tokenAsset('pattern', '/cgi-bin/luci-theme-footstrap-pattern', '
* run. */
const LOGIN_BG = tokenAsset('login_bg', '/luci-static/footstrap/bg', '--fs-login-bg-url');
/* Every axis exported as the OBJECT itself — `{ current, apply, def? }` — not a flat
* currentX()/applyX() pair per axis: a caller wanting both already had to carry them as a pair
* (fs-appearance.js's colourGroup() takes `{ current, apply }`, built ad hoc from two separate
* lookups before this), and a flat name is one more thing every consumer of THIS FILE has to keep
* spelled the same way fs-axes.js does — 18 axis names doubled to 36 lookups, now 18. `pattern`
* and `loginBg` are not axes (no AXIS_KEYS entry, no router-default `def()`) but the same
* `tokenAsset()` shape, so they export the same way. `tokenOk`, `matchesSavedDefault`,
* `saveAsDefault`, `resetToSaved` and `resetToBuiltin` stay flat: none of them is a
* `{current, apply}` pair. `snapshotAxes` stays module-private: fs-appearance.js, its only would-be
* caller, builds the Appearance form from the per-axis exports above, not from the router-default
* snapshot matchesSavedDefault()/saveAsDefault() read internally.
*
* No flat `currentX`/`applyX` fallback kept: grepped `../luci-app-footstrap-*` (the sibling
* checkouts) and this repo's docs for either spelling — nothing outside fs-appearance.js and
* fs-assets.js, both updated alongside this file, ever called one. */
/* Every axis is exported as the object itself — `{ current, apply, def? }` — not a flat
* currentX()/applyX() pair. `pattern` and `loginBg` have the same tokenAsset() shape and export the
* same way; the router-default helpers (tokenOk, matchesSavedDefault, ...) stay flat. */
return baseclass.extend({
palette: PALETTE,
wallpaper: WALLPAPER,
@@ -103,14 +103,14 @@ let _probe = null;
* it carries no chrome mark and the fence deliberately does not spare it. Every declaration is
* therefore !important, which a style-attribute wins outright: an app carrying
* `div { min-width: 500px !important }` otherwise wins every read and the sidebar folds into a bar
* on a 1857px desktop (issue #19) — the cut becomes 500 + 500 + 1000 = 2000 CSS px, which is why it
* on a 1857px desktop — the cut becomes 500 + 500 + 1000 = 2000 CSS px, which is why it
* was reported as a zoom bug: Chrome at 90% gives 2063 CSS px and passes, 100% gives 1857 and
* fails. box-sizing is stated for the same reason — getComputedStyle().width is
* the content box, so a foreign `border-box` plus padding would shave the reading. */
function resolveLen(token, dflt) {
if (!_probe) {
_probe = document.createElement('div');
/* the id is fs-router.js's strayBodyNode() (issue #56) reading an fs-* id as "ours", which is
/* the id is fs-router.js's strayBodyNode() reading an fs-* id as "ours", which is
* a different question from the chrome mark above: this stays unmarked (no data-fs-chrome,
* so the fence still does not spare it from a hostile !important) but still needs SOME fs-*
* name, or an element appended once and never removed would read as body litter and force
@@ -165,7 +165,7 @@ function shellGeometry() {
};
/* Plausibility, at the cost of one comparison: the rail is the sidebar collapsed, so
* 0 < railW < sidebarW holds by construction. Both known failures destroy it — a hijacked probe
* reports one foreign width for all four (issue #19), a renamed or absent token reports 0 for
* reports one foreign width for all four, a renamed or absent token reports 0 for
* all four (an abs-positioned empty div shrinks to 0, which is finite, so the per-read fallback
* never fires). Only the relation between the numbers gives either away. */
const sane = (g.railW > 0 && g.railW < g.sidebarW && g.contentMin > 0);
@@ -231,7 +231,7 @@ function columnWidth(g, state) {
/* The column stops growing: `.fs-content` is `max-width: var(--fs-content-max); margin: 0
* auto`, so past ~1500px the surplus becomes margin, and without the cap this answers ~2280 on
* a 2560px window for a column that is 1224 wide. No caller can reach that today (both ask a
* lower bound), but this is the exported answer to "how wide is the content column". */
* lower bound), but this is the answer to "how wide is the content column". */
const room = Math.min(state.outerW - cut, g.contentMax);
return Math.max(0, room - g.contentPad);
}
@@ -279,11 +279,11 @@ function fitChrome() {
* currently needs — not only shorter. A `min-height` floor alone stops the shrink but not the
* grow: with the classes off and `fs-dense1`/`fs-dense2` stripped by `fitTabStrips()`, this pass
* measured the bar walking 230 -> 202 -> 164 -> 144 -> 123 -> 131 -> 123px against a settled
* 123px on owrt2512 at 767px — 107px of growth a floor never sees, on top of the shrink it does
* 123px at 767px — 107px of growth a floor never sees, on top of the shrink it does
* — each step landing between two of the poll's own separate section refreshes, so the browser
* paints in between and the reader is moved by exactly as much, on Chromium and Firefox as well
* as Safari (`tools/fit-quiet.mjs`, plus a probe that pauses the layout mid-pass). `min-height`
* alone was measured to `accc451`'s WebKit-only diagnosis instead — WebKit's own scroll anchoring
* as Safari (measured with a probe that pauses the layout mid-pass). `min-height`
* alone was measured against an earlier WebKit-only diagnosis instead — WebKit's own scroll anchoring
* looked like the whole story only because it is the one engine with no anchoring at all to hide
* this walk behind; Chromium and Firefox absorb it the same way they absorb any other layout
* change, which is not the same as not producing it.
@@ -484,7 +484,7 @@ function wireRail() {
}
/* An indicator pill carries its meaning as prose ("Unsaved Changes: 2") and the collapsed rail is
* 68px wide, so the pill wraps onto three lines and hangs past the rail's edge (issue #14). CSS
* 68px wide, so the pill wraps onto three lines and hangs past the rail's edge. CSS
* squares the pill there and draws this attribute instead of the label — a text node cannot be
* reached by a selector, so the badge is lifted into an attribute here.
*
@@ -586,8 +586,7 @@ return baseclass.extend({
* motion sampler `scrolling()` reads). A caller landing in that window, most of all
* fs-select's, got the width the PREVIOUS viewport had: at 568px settling to 390px, model
* stayed 568 for up to 220ms of the 400 (measured: −178px, exactly 568−390, against a probe
* resizing the window mid-settle, and live-audit's `geometry|fs-content` finding on
* owrt2410, CI run 34364446910).
* resizing the window mid-settle, and a live geometry audit on 24.10).
*
* So the window's own width is compared fresh on every call, not only when `_shellOuter`
* is still zero. `clientWidth` is the one read the old "no layout read" promise here was
@@ -1,6 +1,5 @@
'use strict';
'require baseclass';
'require ui';
/* fs-fit — the theme's one "does it still fit?" engine and the one place the reader's scroll
* position is corrected. Add fit logic here, never a second observer: no CSS query can ask what the
@@ -15,9 +14,9 @@
* without it left every data table invisible. docs/anchoring-log.md, "The arm belongs to the disarm". */
function armGate() {
if (!fittersEnabled()) return;
/* WRITTEN AS THE LITERAL `dataset.fsFit`, never through a helper: tools/table-contract.mjs
* reads this file for exactly that spelling to prove the gate rule is still armed, and an
* indirection hides the write from it. The behaviour survives being factored out; the
/* WRITTEN AS THE LITERAL `dataset.fsFit`, never through a helper: the theme's own
* repository checks this file for exactly that spelling to prove the gate rule is still armed,
* and an indirection hides the write from it. The behaviour survives being factored out; the
* contract does not. */
try { document.documentElement.dataset.fsFit = '1'; }
catch (e) { /* no document, no flag to write */ }
@@ -60,14 +59,13 @@ const FLOORED = '[data-fs-floor]';
/* One floor per container, cleared and re-measured in one batched pass: not while the reader
* scrolls, and not on a table box — `min-height` is undefined there (CSS 2.1 §10.7) and a 313px
* floor still collapsed to 30px on WebKit. The clear is what keeps the answer honest (issue #41). */
* floor still collapsed to 30px on WebKit. The clear is what keeps the answer honest. */
function holdFloor(records) {
if (scrolling()) return;
const host = document.getElementById('view');
if (!host) return; /* the login page has no view */
/* boxes stays an array — order feeds the parallel `hs` below and dirty's own .filter() — with a
* Set alongside it purely for the O(1) "already have this one" check .indexOf() was doing in
* O(n). */
* Set alongside it for the O(1) "already have this one" check. */
const boxes = [], boxSeen = new Set(), hs = [];
host.querySelectorAll(SHRINKS).forEach((el) => {
let box = el, cs = window.getComputedStyle(el);
@@ -376,8 +374,9 @@ function anchorRef() {
/* Two independent diagnostic trails — one per correction path (the frame-coalesced anchor below,
* the late drift correction further down) — same shape twice: the last reason recorded, and the
* last 8 with a timestamp, for tools/scroll-anchor.mjs to read back after a probe run. One factory,
* so the cap and the timestamp format cannot drift between the two. */
* last 8 with a timestamp. They are read back only by the theme repository's
* development build; the package exports none of it. One factory, so the cap and the timestamp
* format cannot drift between the two. */
const TRAIL_MAX = 8;
function makeTrail() {
let last = null;
@@ -626,11 +625,11 @@ return baseclass.extend({
},
scrolling,
/* unmarked, for tools/scroll-anchor.mjs — see `makeTrail()` above */
lateWhy: () => _late.last(),
lateTrail: () => _late.entries().slice(),
anchorWhy: () => _anchor.last(),
anchorTrail: () => _anchor.entries().slice(),
/* probe-only: the two trails of `makeTrail()` above */
lateWhy: () => _late.last(), /* fs:probe */
lateTrail: () => _late.entries().slice(), /* fs:probe */
anchorWhy: () => _anchor.last(), /* fs:probe */
anchorTrail: () => _anchor.entries().slice(), /* fs:probe */
deferMeasurement,
/* -> the offset this file last took a reference at, so a probe does not measure the guard instead
@@ -45,7 +45,7 @@ function sectionTitle(sec) {
/* ---- keyboard disclosure: the card header becomes the toggle, the pill becomes its glyph ----
*
* Live on owrt2512 (25.12.4), Status -> Overview carries 14 `[data-clickable]` elements — the
* On 25.12, Status -> Overview carries 14 `[data-clickable]` elements — the
* topbar poll pill plus one Hide/Show toggle per card — and every one is a bare <span>: no
* tabindex, no role, no aria-expanded. Tab never reaches one and a screen reader announces a run
* of text with no name, role or state. WCAG 2.1.1 Keyboard (A), 4.1.2 Name, Role, Value (A).
@@ -89,7 +89,7 @@ function wireDisclosure(sec) {
widgets.syncAttr(h, 'aria-expanded', pillExpanded(label) ? 'true' : 'false');
/* the header now carries the pill's name, role and state; a screen reader user tabbing past it
* to a second, unlabelled clickable span would hear an unexplained duplicate control — same
* reasoning as the aria-hidden on svgIcon()'s output, fs-widgets.js:12 */
* reasoning as the aria-hidden on svgIcon()'s output, fs-widgets.js */
widgets.syncAttr(label, 'aria-hidden', 'true');
if (h.dataset.fsWired) return;
@@ -148,8 +148,8 @@ function arrange() {
/* an SPA nav can leave the observer wired while another page renders into #view: detach as soon
* as the route stops being the overview. body[data-page] carries the DISPATCH path from both
* the server template and the router, so /admin/status (firstchild -> overview) matches. */
if ((document.body.getAttribute('data-page') || '') !== /* spelled out, not hoisted: tools/page-modules.mjs reads this value out of the module's
* SOURCE to check it against the map in menu-footstrap-common.js */
if ((document.body.getAttribute('data-page') || '') !== /* spelled out, not hoisted: this value is read out of the module's
* SOURCE and checked against the map in menu-footstrap-common.js */
'admin-status-overview') {
stopWatch();
return;
@@ -6,8 +6,8 @@
* axis list is AXIS_KEYS, which is exactly the fields of snapshotAxes() — what Save-as-default
* writes.
* All client-side, instant and persisted in localStorage, with head.ut's inline script re-applying
* them before paint so a reload never flashes the wrong one; tools/axes.mjs derives the contract
* from this file and holds the two copies to it.
* them before paint so a reload never flashes the wrong one; the contract is derived
* from this file and both copies are held to it.
*
* ---- three layers, and the browser always wins ----
* Every axis resolves as localStorage ?? router-default ?? built-in. The router default is
@@ -79,14 +79,13 @@ function currentMode() {
*
* `data-darkmode` is the name the theme's own CSS keys off. The other two are outbound
* compatibility, like the `--*-color-*` export tier: nothing in `styles/` may read them, and
* tools/axes.mjs fails the build if it does. */
* a check fails if one does. */
/* the attribute name reused below by the writer, the guard's reader and both MutationObserver
* filters (measured: 15 B x4 -> 28 B, 32 B saved) */
function stampDark(root, dark) {
/* the literal stays spelled out HERE: tools/axes.mjs reads the attribute names out of
* this function's SOURCE, so a hoisted const reads as no attribute at all and the gate
* reports the pre-paint and the live applier as drifted. The 45 B a const would save are
* not worth teaching a gate to resolve them. */
/* the literal stays spelled out HERE: the attribute names are read out of this
* function's SOURCE, so a hoisted const reads as no attribute at all and the pre-paint and the
* live applier look drifted. The 45 B a const would save are not worth resolving them. */
root.setAttribute('data-darkmode', dark ? 'true' : 'false');
root.setAttribute('data-theme', dark ? 'dark' : 'light');
root.setAttribute('data-bs-theme', dark ? 'dark' : 'light');
@@ -222,7 +221,7 @@ function listAxis(key, attr, values, dflt, after) {
}
/* A two-value axis: `on` is stamped as the attribute's value, `off` is a bare :root. The list shape
* with a list of one — kept as its own name because tools/axes.mjs matches the call, and because
* with a list of one — kept as its own name because the call is matched by name, and because
* "two-valued" is what most of these axes are. */
function enumAxis(key, attr, on, off) {
return listAxis(key, attr, [ on ], off);
@@ -233,9 +232,9 @@ const DENSITY = listAxis('fs-density', 'data-density', DENSITIES, 'normal', () =
const currentDensity = DENSITY.current, applyDensity = DENSITY.apply,
densityDefault = DENSITY.def;
/* Content width lives entirely in fs-axes.js now (a propAxis like Rounding, issue #44): unlike
/* Content width lives entirely in fs-axes.js (a propAxis like Rounding): unlike
* Density it sets no attribute, so nothing here or in fs-chrome.js needs to read one, and
* currentContentWidth()/applyContentWidth() have no caller outside the Appearance tab — the one
* contentWidth.current()/apply() have no caller outside the Appearance tab — the one
* thing that kept Density's shape in this cold-path file. */
function currentLayout() {
return document.documentElement.getAttribute('data-layout') === 'top' ? 'top' : 'sidebar';
@@ -25,7 +25,7 @@
* The path->node half lives in fs-menutree.js (the chrome needs it too); the "has a view poisoned
* this document with its CSS?" half in fs-sheets.js.
*
* `docs/…`, `tools/…` and `tests/…` in these comments name the theme's own repository
* `docs/…` in these comments names the theme's own repository
* (https://github.com/VizzleTF/luci-theme-footstrap), not the tree this file is read in: the
* package ships the runtime and nothing else. */
@@ -130,10 +130,7 @@ function clearViewIntervals() {
_viewIntervals.forEach((spec, id) => { if (id !== keep) window.clearInterval(id); });
}
/* The "Refreshing"/"Paused" pill must not outlive the poll it reports on — see the `poll-stop`
* listener below, which is the only caller in the shipped file. Exported (fs:probe) too, because
* navigate()'s own async chain (require(), staging into a real `.fs-content`) needs more DOM than
* tests/lib's fakes give it; this is the one check that actually matters for the ordering bug and is
* exercised directly, without the listener's deferral, by tests/poll-status.test.mjs. */
* listener below, which is its only caller. */
function hidePollIndicatorIfEmpty() {
if (!(L.Poll && L.Poll.queue && L.Poll.queue.length === 0)) return;
try { ui.hideIndicator('poll-status'); }
@@ -199,8 +196,7 @@ function flushUciCache() {
* is `uci.sections('wireless', 'wifi-device')`). So dropping those packages does not make
* network.js refetch them, it makes every consumer read an EMPTY config until the next full
* load: Channel Analysis with no band tabs, Network -> Switch with no VLAN sections, both
* correct again after F5. tools/spa-parity.mjs reproduces it; tools/upstream-contract.mjs
* notices if the list of three moves.
* correct again after F5.
*
* navigate() waits for the refill, because a cached module resolves within a microtask and the
* view would read the cache we just emptied. Only when network.js is really in the document.
@@ -338,19 +334,15 @@ function discard(el) {
}
}
/* ---- a page that leaves nodes as direct children of <body> hands the next page over by full load,
* the same way an invasive foreign sheet does (docs/third-party-apps.md, Rule 2 extended from CSS to
* the DOM — issue #56) ----
/* ---- a page that parks nodes on <body> makes the next navigation a full load ----
*
* luci-app-bandix's render() appends 7 tooltip/modal nodes to document.body on every visit,
* unconditionally (reproduced on owrt2512 and owrt2410) — nothing here sweeps them; the reasoning is
* docs/spa-router.md, "Foreign view DOM". The recorder is header.ut's first <body> <script>, filling
* window.__fsBodyAdds; this file only reads it. */
* Some apps' render() appends tooltip/modal nodes to document.body on every visit (one leaves 7)
* and nothing here sweeps them, so the document is spent, as with an invasive foreign sheet. The
* recorder is header.ut's first <body> <script>, filling window.__fsBodyAdds; this file only reads it. */
/* -> true if `el` is a node the theme must treat as stray body litter: not the theme's own chrome,
* not something stock LuCI parks there itself, not a node type that can never paint. Exported and
* pure (no DOM writes, no window.__fsBodyAdds read) so tests/body-litter.test.mjs can drive it
* without a router or a recorder. */
* not something stock LuCI parks there itself, not a node type that can never paint. Pure: no DOM
* writes, no window.__fsBodyAdds read. */
function strayBodyNode(el) {
if (!el || el.nodeType !== 1) return false;
/* never rendered on their own, wherever a script parks them */
@@ -358,16 +350,14 @@ function strayBodyNode(el) {
case 'SCRIPT': case 'STYLE': case 'LINK': case 'TEMPLATE': case 'NOSCRIPT': case 'META':
return false;
}
/* zone 1, ours (docs/third-party-apps.md): the chrome mark, or an fs-* id/class. All of these
/* ours: the chrome mark, or an fs-* id/class. All of these
* parent straight to <body> and must never be read as litter: this file's own #fs-nav-progress,
* fs-search.js's #fs-search-ov (which also carries the mark), and the geometry/colour probes
* fs-chrome.js and fs-appearance.js each park on <body> once and never remove — an fs-* id is
* what tells them from bandix's own unmarked nodes below. */
/* read through `dataset`, not a literal `hasAttribute()` call: `tools/chrome-fence.mjs` counts
* every single-quoted occurrence of the chrome mark's own name (as a JS string) across this
* whole tree as a JS-BUILT chrome root (EXPECT_JS_ROOTS) — right for fs-search.js's palette,
* which mounts one, and a false positive here, which only READS the mark on somebody else's
* node and builds nothing. camelCase avoids the exact spelling the gate scans for. */
* what tells them from a foreign app's own unmarked nodes below. */
/* read through `dataset`, not a literal `hasAttribute()` call: the build counts every
* single-quoted spelling of the chrome mark's name as a JS-built chrome root, a false positive
* for code that only READS the mark on somebody else's node. camelCase avoids that spelling. */
if (el.dataset && el.dataset.fsChrome !== undefined) return false;
if (el.id && el.id.indexOf('fs-') === 0) return false;
for (const c of el.classList) if (c.indexOf('fs-') === 0) return false;
@@ -497,7 +487,7 @@ function restoreScroll(pos, gen) {
* their own RPCs. The engine then clamps the scroller BACK to whatever height exists NOW, firing
* an ordinary `scroll` event that looks exactly like a reader's, and it lands before this tick has
* ever written anything (`wroteWin`/`wroteMain` still -1), so the "our own write coming back"
* check above cannot catch it either. Measured live (owrt2512b @1440, `/admin/status/overview` <-
* check above cannot catch it either. Measured live (1440px wide, `/admin/status/overview` <-
* package-manager, Back): the UA restores `window.scrollY` to the parked 2684 in the same tick
* `popstate` fires, `commitStage()` leaves the document ~900px tall for one frame, and the next
* native `scroll` event reports `y=0` a whole 5 s before this function's own deadline — cancelling
@@ -847,7 +837,7 @@ function commitStage(stage, contentHost) {
* with the transition and 213 ms without it, and the swap itself landed at 3,728 ms against 206 ms.
* The reader spends that time looking at the page they navigated away from, under the new URL —
* which is what a report of "the section is not where I expect it, F5 fixes it" looks like from the
* outside (issue #42), F5 being a full load and starting no transition.
* outside (F5 being a full load and starting no transition.
*
* A deadline was tried and does not hold: `skipTransition()` runs the update callback at once
* (3-5 ms on WebKit, Chromium and Firefox alike), but it has to be called from a timer, and the
@@ -947,8 +937,8 @@ function navigate(pathname, push, kbd) {
* only exit leaving both pages correct is a real navigation (fs-sheets.js) */
if (sheets.documentPoisoned()) return false;
/* …and a page that left its own nodes as direct children of <body> is spent the same way — Rule 2
* extended to the DOM, issue #56. See strayBodyNode()/bodyLittered() above. */
/* …and a page that parked its own nodes on <body> is spent the same way: see
* strayBodyNode()/bodyLittered() above. */
if (bodyLittered()) return false;
/* …and a document whose session has died is spent the same way: the only page it can render
@@ -1011,8 +1001,8 @@ function navigate(pathname, push, kbd) {
/* the outgoing page's links are about to become a detached tree — do not hold one of them */
_lastHovered = null;
/* Run every registered navigation callback. The seam is inverted on purpose: a registrant calls
* onNavigate() and the router names nobody, so an optional module that is not installed cannot
* be a DependencyError taking out the whole chrome.
* onNavigate() and the router names nobody, so the router keeps no static dependency
* (a DependencyError would take out the whole chrome).
*
* The RESOLVED segments are passed in, because this runs before L.env is re-pointed below and a
* callback reading L.env.dispatchpath would record the page being left.
@@ -1061,7 +1051,7 @@ function navigate(pathname, push, kbd) {
/* A full load starts at the top and the in-place swap must too, or navigating away from a long
* page opens the next one mid-scroll. Both scrollers are reset, since which one scrolls depends
* on the layout — in the sidebar layout the window does not scroll, `.fs-shell` being 100dvh
* with `.fs-main` owning overflow-y (issue #7) — and scrollTo on the other is a no-op.
* with `.fs-main` owning overflow-y — and scrollTo on the other is a no-op.
*
* The WRITE is at commitStage now, not here — see there. `_rest` is forgotten here regardless:
* it is the reference fs-fit tells a reader-caused scroll from an engine's clamp with, and the
@@ -1158,7 +1148,7 @@ function navigate(pathname, push, kbd) {
* see the listener below, not a call here. A page can also empty the queue on its own, with
* no navigation at all (`L.Poll.remove()`, e.g. `luci-mod-status`'s graphs.js on unload,
* `luci-app-banip`'s log view), and `stop()` dispatches the same event for that call too;
* a call placed only here would miss it, exactly the finding this once shipped without. */
* a call placed only here would miss it. */
L.Poll.start();
}
/* kill the outgoing view's plain setInterval pollers too, as a full load would; L.Poll's own
@@ -1313,9 +1303,8 @@ function bootDocumentIsOurs() {
* Every module here is written against parts of somebody else's code that were never an API:
* `L.Poll` is a deprecated alias, `L.dom.content` and `ui.instantiateView` are what `view.ut`
* happens to use, `Request.addInterceptor` is how the session probe hears a 403.
* tools/upstream-contract.mjs asks whether they still BEHAVE as assumed, but only against the two
* userlands this repo owns. On a router carrying a luci-base that moved, the first anyone learns of
* it is a click that opens nothing.
* Existence is not behaviour: a name can survive and act differently. On a router carrying a
* luci-base that moved, the first anyone learns of it is a click that opens nothing.
*
* So existence is checked at boot, once, and a missing name turns the router OFF rather than
* on-and-broken: the page is then the server-dispatched MPA the theme was before the router
@@ -1443,7 +1432,7 @@ function wireRouter() {
/* A fragment change is not a navigation. Chrome fires `popstate` for a same-document
* fragment nav, so an `<a href="#">` inside a view — a common idiom for in-page controls —
* arrives here as if Back had been pressed, and re-running navigate() re-instantiates the
* view and wipes the state the click just set (issue #3). The view changed only if the PATH
* view and wipes the state the click just set. The view changed only if the PATH
* changed. */
if (window.location.pathname === _curPath)
return;
@@ -1467,34 +1456,13 @@ function wireRouter() {
/* ---- the poll-status pill must not outlive the poll it reports on ----
*
* LuCI shows the pill on `poll-start`, flips it to "Paused" on `poll-stop`, and never hides it
* again. Two different callers empty the queue and reach `L.Poll.stop()`: this router's own
* navigate() teardown, and a VIEW emptying its own queue mid-page with no navigation at all
* (`L.Poll.remove()` — `luci-mod-status`'s Realtime Graphs on unload, `luci-app-banip`'s log
* template) — both dispatch the same `poll-stop`, so one listener here covers both, where a call
* placed only in navigate()'s teardown covered the first and silently missed the second: reproduced
* live, owrt2410b and owrt2512b, add-then-remove on Statistics — `{"active":false,"q":0,"pill":
* {"text":"Paused","clickable":true}}`, unchanged by a click, because nothing had ever asked to hide
* a pill for a poll that a `remove()` rather than a navigation had ended.
*
* The HIDE itself is deferred past the synchronous dispatch, one microtask, rather than run inline
* in this listener: `stop()` dispatches `poll-stop` synchronously to every listener registered at
* that moment, including luci.js's own (`showIndicator('poll-status', 'Paused', null, 'inactive')` —
* "Paused" is always shown with `handler: null`), and which of the two ran first here used to decide
* everything — luci.js registers its listener from `setupDOM()`, after an async chain
* (DOMContentLoaded + ui/rpc/form + probeRPCBaseURL), this module at eval, from the inline
* `L.require('menu-footstrap')` in `footer.ut`, so network/cache timing picked the order.
* Run with this listener first, its hide removed the span before luci.js re-created it for "Paused"
* with no handler, and the next `poll-start` found that span already there and only changed its
* text — clickless for the rest of the document. A microtask runs only once the whole synchronous
* turn that dispatched the event has unwound, i.e. after every `poll-stop` listener already fired
* regardless of which ran first, so the check sees the state stock's listener actually left rather
* than racing it. It still reads `L.Poll.queue.length` at that later moment, not at dispatch time, so
* a queue the SAME navigation's incoming page has already refilled by then is left alone — the
* incoming page's own `require()`/render() is real async work (network, RPCs), always later than a
* microtask queued during the synchronous `stop()` call that preceded it. `tests/poll-status.test.mjs`
* drives both paths (a navigation's queue flush and a bare `Poll.remove()` to empty) in both listener
* orders. docs/spa-router.md, "Teardown". */
* LuCI shows the pill on `poll-start`, flips it to "Paused" on `poll-stop` and never hides it. The
* queue is emptied by this router's navigate() teardown and also by a view calling `L.Poll.remove()`
* mid-page; both reach `L.Poll.stop()`, so one `poll-stop` listener covers both. The hide runs one
* microtask later: luci.js registers its own `poll-stop` listener (which shows "Paused" with no
* click handler) after an async chain while this one registers at eval, so the order is not
* deterministic, and hiding inline could remove the span before luci.js re-created it. The queue
* length is read at that later moment, so a queue the incoming page has already refilled is left alone. */
document.addEventListener('poll-stop', () => {
queueMicrotask(hidePollIndicatorIfEmpty);
});
@@ -1534,8 +1502,7 @@ function wireVisibility() {
/* Callbacks to run on every SPA navigation, each handed the resolved segments of the INCOMING page
* (they run before L.env is re-pointed). The registry is inverted on purpose: a registrant calls in
* and the router names nobody, so it cannot grow a static dependency on a module that may not be
* installed. */
* and the router names nobody, so it cannot grow a static dependency on one. */
const _navCbs = [];
function onNavigate(fn) { if (typeof fn === 'function') _navCbs.push(fn); }
@@ -1544,28 +1511,22 @@ return baseclass.extend({
wire: wireRouter,
wireVisibility,
onNavigate,
/* exported for tests/router-contract.test.mjs (no tests ship in the package), where it is driven
* against a hand-broken `L`: the one way to see the off branch */
/* test-only, driven against a hand-broken `L`: the one way to see the off branch */
contractBreaks, /* fs:probe */
/* likewise out-of-package: interval-pause.test.mjs drives the navigation sweep around a
* visibilitychange and session-expiry.test.mjs reads the verdict the interceptors reached.
* navigate() is the real caller of the first and `_expired` gates the second. */
/* test-only: the navigation sweep around a visibilitychange, and the session verdict the
* interceptors reached */
clearViewIntervals, /* fs:probe */
sessionExpired, /* fs:probe */
/* tests/poll-status.test.mjs: the deferred `poll-stop` listener's own check, called directly so a
* test need not also fake a microtask tick to see it fire (see the comment at its definition and
* at the listener above) */
/* test-only: the deferred `poll-stop` listener's own check, called directly so a test need not
* fake a microtask tick */
hidePollIndicatorIfEmpty, /* fs:probe */
/* fs-search warms its recents and the arrow-key-highlighted result, neither of which the
* pointer/focus triggers above can see. The edge points search -> router, because the router
* must keep no dependency on the palette. */
prefetchSegs,
/* out-of-package, like the three probe exports above (contractBreaks, clearViewIntervals,
* sessionExpired) — prefetchSegs just above is not one of them, it is a real in-package export
* fs-search.js calls. tests/body-litter.test.mjs drives the classifier and the counter directly,
* and navigate() itself to prove the early return actually reaches them rather than just sitting
* beside it unused */
/* test-only: the body-litter classifier and counter, and navigate() to prove the early return
* reaches them */
strayBodyNode, /* fs:probe */
bodyLittered, /* fs:probe */
navigate, /* fs:probe */
/* fs-search warms its recents and the arrow-key-highlighted result, neither of which the
* pointer/focus triggers above can see. The edge points search -> router, because the router
* must keep no dependency on the palette. */
prefetchSegs
});
@@ -332,8 +332,8 @@ function labelCells(t, head) {
* Rather than pair each of those twenty-odd rules with a role write timed to its own trigger — a
* race with every one of them — the role is written UNCONDITIONALLY, matching what a plain
* `<table>` already carries implicitly. That costs nothing while the table IS a table and survives
* the moment any of those rules land. tools/table-contract.mjs holds the pairing: a future
* `display` rule on one of these classes with no role to match is what it is written to catch.
* the moment any of those rules land. A `display` rule on one of these classes with no role to
* match is a defect.
*
* The whole chain, every pass — a `role="cell"` with nothing above it saying `row`, or a `row` with
* nothing above IT saying `table`/`rowgroup`, reads worse than no roles at all. One flat query per
@@ -342,9 +342,9 @@ function labelCells(t, head) {
* a class query alone reaches the whole subtree. `.thead`/`.tbody`/`.tfoot` are the same for a
* div-based table; a REAL `<thead>`/`<tbody>`/`<tfoot>` this file never classes (20_lan.js's and
* 30_wifi.js's `<tfoot>`, theme/30-tables.css:570/634) is reached by tag name instead. */
/* tools/table-contract.mjs reads these two names and shapes by regex, so a `display` rule on any of
* these classes/tags is checked against a matching role — keep both names, and each entry a
* `'.class'` or `'tag'` string literal, even though roleTables() below now reads them as one list. */
/* These two names and shapes are read by regex, so a `display` rule on any of these classes/tags
* is checked against a matching role — keep both names, and each entry a `'.class'` or `'tag'`
* string literal, even though roleTables() below reads them as one list. */
const TABLE_ROLE_CLASSES = [
[ '.table', 'table' ],
[ '.thead', 'rowgroup' ],
@@ -434,7 +434,7 @@ function markBreakColumn(t, rows, col) {
}
/* Rung 3. The widest column that can be shredded: never the first, which is the row's identity
* (issue #36 is that column being starved), and never a `nowrap`/`pre` one, where `overflow-wrap`
* (that column being starved is a known fault), and never a `nowrap`/`pre` one, where `overflow-wrap`
* is inert so the mark would buy a layout and no width.
*
* With honest floors nothing is starved, so an overflowing table has every column at its own
@@ -312,7 +312,7 @@ function documentPoisoned() {
* already applying. Every footstrap rule lives in a @layer and an unlayered normal declaration
* beats a layered one at any specificity, so a third-party `* { margin: 0; padding: 0 }` owns the
* chrome outright — on a layer-less theme the same `*` (0,0,0) loses to any class selector. The
* layers are what hand it the win (issue #8).
* layers are what hand it the win.
*
* So put the sheet back on specificity footing by re-hosting it into the EXISTING `theme` layer;
* only same-layer arbitrates by specificity. Measured on the real cascade:
@@ -4,8 +4,8 @@
/* The installed version, shown on the Appearance tab with no network call; which version is
* available is the package manager's question.
*
* The Makefile (Build/Prepare), dev-sync.sh and tools/stage.sh sed the literal below BY FILE
* NAME, so it cannot move to another file without changing all three. An unstamped checkout
* The version stamp is written into the literal below BY FILE NAME, so it must
* stay in this file. An unstamped checkout
* stays 'dev'. */
const FS_VERSION = '0.0.0-dev';
@@ -53,15 +53,17 @@ function wirePageModules() {
* and the warm pass that uses it, so both live here, in the file every page already loads.
*
* The palette reads the list back from localStorage when it opens, so the two halves share the key
* and nothing else — the constant itself is named once, in fs-search.js (RECENT_KEY, RECENT_MAX),
* since that module owns the "recently visited" feature; the literal below has to match it exactly. */
* and nothing else — fs-search.js owns the "recently visited" feature, so RECENT_KEY and RECENT_MAX
* below have to match its constants of the same name exactly. */
const RECENT_KEY = 'fs-recent';
const RECENT_MAX = 8;
const RECENT_WARM = 5;
/* A key is a menu path — what the router can navigate to and what warmRecent() prefetches. */
function remember(key) {
if (typeof key !== 'string' || !key) return;
const recent = prefs.lsGetArr('fs-recent').filter((x) => typeof x === 'string');
prefs.lsSet('fs-recent', JSON.stringify([ key ].concat(recent.filter((p) => p !== key)).slice(0, 8)));
const recent = prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string');
prefs.lsSet(RECENT_KEY, JSON.stringify([ key ].concat(recent.filter((p) => p !== key)).slice(0, RECENT_MAX)));
}
/* ---- warm the pages this admin actually uses ----
@@ -79,7 +81,7 @@ function warmRecent() {
try { if (navigator.connection && navigator.connection.saveData) return; } catch (e) {}
const here = (L.env.dispatchpath || []).join('/');
/* already de-duplicated: remember() drops the key from its old slot before re-adding it */
const keys = prefs.lsGetArr('fs-recent').filter((p) => typeof p === 'string');
const keys = prefs.lsGetArr(RECENT_KEY).filter((p) => typeof p === 'string');
const paths = keys.filter((p) => p !== here).slice(0, RECENT_WARM);
if (!paths.length) return;
const go = () => paths.forEach((p) => router.prefetchSegs(p.split('/')));
@@ -199,8 +201,7 @@ function findProgressbarLabel(pg) {
* cannot pick a wrong number out of a title carrying several. An empty title or one with no
* percentage in any of the three shapes yields null, on purpose — nothing to annotate. `%d` is the
* unclamped percentage, so the result can still read past 100 or under 0 and is clamped by the
* caller, the same way `window.progressbar` clamps its own `level`, below. Exported for
* tests/meter.test.mjs, which is the only caller that needs the parse on its own. */
* caller, the same way `window.progressbar` clamps its own `level`, below. */
function parseMeterPercent(title) {
if (title == null) return null;
const m = (/\((-?\d+)%\)\s*$/).exec(title) || (/(-?\d+)%\s*$/).exec(title) || (/^(-?\d+)%/).exec(title);
@@ -7,8 +7,8 @@
'require menu-footstrap-common as common';
/* The theme's one menu renderer: a vertical #topmenu that the CSS also turns into the top bar and
* the rail flyouts — same markup, no second renderer. Disclosure primitives come from fs-widgets,
* the auto-collapse preference from fs-prefs; the rest of the chrome is bootstrapped by
* the rail flyouts — same markup, no second renderer. The disclosure primitives live in this file,
* the auto-collapse preference comes from fs-prefs; the rest of the chrome is bootstrapped by
* menu-footstrap-common, which this file composes with by injecting renderMainMenu into
* common.init — a callback, not an override, since a required LuCI module is a singleton and
* cannot be subclassed. Spec: docs/chrome.md */
@@ -89,7 +89,7 @@ const EDGE_GAP = 8;
/* Is this panel a bar dropdown (anchored under its item) rather than a rail flyout (anchored
* beside it)? Same input as the stylesheet: `data-narrow` turns the sidebar into a bar and also
* disables the rail (its rules are scoped `:not([data-narrow])`), so a narrow window is a bar even
* with the rail on. Gating on isTopLayout() alone leaves such a panel unclamped (issue #19). */
* with the rail on. Gating on isTopLayout() alone leaves such a panel unclamped. */
function barDropdown() {
return prefs.isTopLayout() || document.documentElement.hasAttribute('data-narrow');
}