🐶 Sync 2026-10-20 22:40:23

This commit is contained in:
github-actions[bot] committed 2026-10-20 22:40:23 +08:00
1 parent e813f5cc54
commit 64cea2243a
216 files changed
+11913 -2968

No files matched your search

@@ -52,11 +52,14 @@ 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. */
_probe = E('span', { 'aria-hidden': 'true' });
/* 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' });
_probe.style.cssText = 'position:fixed!important;left:-9999px!important;top:0!important;'
+ 'width:0!important;height:0!important;overflow:hidden!important;'
+ 'pointer-events:none!important;';
@@ -73,37 +76,28 @@ 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). The string parse remains only as
* the fallback for an engine with no 2D context, where only the legacy `rgb()`/`color(srgb …)`
* forms can appear. */
* 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() {
if (_cx !== null) return _cx;
try {
if (!_cx) {
const cv = document.createElement('canvas');
cv.width = cv.height = 1;
_cx = cv.getContext('2d', { willReadFrequently: true }) || false;
} catch (e) { _cx = false; }
_cx = cv.getContext('2d', { willReadFrequently: true });
}
return _cx;
}
function parseColor(s) {
const str = String(s || '');
const cx = rasterCtx();
if (cx) {
/* fillStyle keeps the last value it could parse, so a colour this engine rejects would
* report the previous one as a fresh reading — the trap probeColor() clears for */
cx.fillStyle = '#000';
cx.fillStyle = str;
cx.clearRect(0, 0, 1, 1);
cx.fillRect(0, 0, 1, 1);
const d = cx.getImageData(0, 0, 1, 1).data;
if (d[3] === 255) return [ d[0], d[1], d[2] ];
/* translucent: composite over nothing is meaningless for a readout, so fall through */
}
const nums = str.match(/[\d.]+/g);
if (!nums || nums.length < 3) return null;
const unit = (/^color\(/i).test(str) ? 255 : 1;
return nums.slice(0, 3).map((n) => Math.max(0, Math.min(255, parseFloat(n) * unit)));
/* fillStyle keeps the last value it could parse, so a colour this engine rejects would
* report the previous one as a fresh reading — the trap probeColor() clears for */
cx.fillStyle = '#000';
cx.fillStyle = String(s || '');
cx.clearRect(0, 0, 1, 1);
cx.fillRect(0, 0, 1, 1);
const d = cx.getImageData(0, 0, 1, 1).data;
/* translucent: composite over nothing is meaningless for a readout */
return d[3] === 255 ? [ d[0], d[1], d[2] ] : null;
}
/* WCAG 2.x relative luminance and contrast ratio, on sRGB. Used only to report: the theme states
@@ -141,8 +135,8 @@ function toHex(s) {
* `opts.probe` is the live token the effective colour is read back from, so the field shows the
* palette's colour while the axis is off without a copy of the palette in JS. `opts.contrast` is
* the pair whose ratio is reported under the row. */
function colorControl(current, onPick, label, opts) {
const o = opts || {};
function colorControl(onPick, label, opts) {
const o = opts;
/* type=color leaves the picker to the browser: accessible without reimplementing a colour
* wheel, and native on a phone. The text field beside it takes a pasted hex and is the
@@ -155,10 +149,6 @@ function colorControl(current, onPick, label, opts) {
const clear = E('button', { 'class': 'btn fs-color-clear', 'type': 'button' }, [ _('Palette', 'footstrap') ]);
const ratio = o.contrast ? E('div', { 'class': 'cbi-value-description fs-color-contrast' }) : null;
/* what the axis holds right now: the page can change it behind this control (a preset, Reset
* to default), so a private copy would go stale. `current` is only the build-time value. */
const currentOf = o.read || (() => current);
/* Repaint everything that mirrors the axis. Called after every edit, and through the returned
* refresh() after a preset, palette switch or dark-mode flip — each changes what the palette's
* own colour is while this axis stays off. */
@@ -180,8 +170,8 @@ function colorControl(current, 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)
@@ -207,7 +197,9 @@ function colorControl(current, onPick, label, opts) {
const commit = () => {
const v = field.value.trim().toLowerCase();
if ((/^#[0-9a-f]{6}$/).test(v)) pick(v);
else reflect(currentOf());
/* re-read rather than a cached copy: the page can change what the axis holds behind this
* control (a preset, Reset to default), which a snapshot taken at build time would miss */
else reflect(o.read());
};
field.addEventListener('blur', commit);
field.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); commit(); } });
@@ -218,7 +210,7 @@ function colorControl(current, onPick, label, opts) {
].concat(ratio ? [ ratio ] : []));
/* the caller decides when this runs: probeColor() needs the document, and this control is not
* in it yet */
wrap.fsRefresh = () => reflect(currentOf());
wrap.fsRefresh = () => reflect(o.read());
return wrap;
}
@@ -309,14 +301,15 @@ function build() {
return node;
};
/* one colour axis: `probe` is the live token the control reads the effective colour back from,
* `contrast` the pair it reports */
const colourGroup = (label, axis, probe, contrast, opts) => group(label, (lbl) => {
const ctl = colorControl(axis.current(), bump(axis.apply), lbl, {
/* one colour axis: `axis` is the `{current, apply}` object fs-axes.js exports it as; `probe` is
* the live token the control reads the effective colour back from, `contrast` the pair it
* reports */
const colourGroup = (label, axis, probe, contrast, opts = {}) => group(label, (lbl) => {
const ctl = colorControl(bump(axis.apply), lbl, {
probe: probe,
read: axis.current,
contrast: contrast,
cls: (opts && opts.cls) || ''
cls: opts.cls
});
colourCtls.push(ctl);
return ctl;
@@ -326,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. */
@@ -344,7 +337,7 @@ function build() {
dark: _('Dark', 'footstrap')
}, bump(repaint(prefs.applyMode)), label)),
group(_('Palette', 'footstrap'), (label) => selectCtl(axes.currentPalette(), {
group(_('Palette', 'footstrap'), (label) => selectCtl(axes.palette.current(), {
footstrap: 'Footstrap',
hicontrast: 'Hi-Contrast',
/* names the OTHER package, luci-theme-bootstrap, whose colours this palette is —
@@ -355,7 +348,7 @@ function build() {
/* names the OpenWrt forum (forum.openwrt.org), whose Discourse colourway this is —
* a proper noun like the three above it, not the English common noun "forum" */
forum: 'Forum'
}, bump(repaint(axes.applyPalette)), label)),
}, bump(repaint(axes.palette.apply)), label)),
group(_('Density', 'footstrap'), (label) => selectCtl(prefs.currentDensity(), {
compact: _('Compact', 'footstrap'),
@@ -363,18 +356,18 @@ 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
* --fs-content-min (500px, what the sidebar-to-bar fold is measured against) out of reach
* by construction rather than by a rule someone has to remember. */
group(_('Content width', 'footstrap'),
(label) => sliderCtl(axes.currentContentWidth(), 1280, 3840,
bump(axes.applyContentWidth), label, { step: 40, live: false })),
(label) => sliderCtl(axes.contentWidth.current(), 1280, 3840,
bump(axes.contentWidth.apply), label, { step: 40, live: false })),
group(_('Rounding', 'footstrap'),
(label) => sliderCtl(axes.currentRadius(), 0, 20, bump(axes.applyRadius), label)),
(label) => sliderCtl(axes.radius.current(), 0, 20, bump(axes.radius.apply), label)),
/* The top layout has no accordion, so this switch is meaningless there: always built,
* hidden by CSS (:root[data-layout="top"] .fs-ap-submenus). Do not wrap it in an
@@ -392,9 +385,7 @@ function build() {
const colours = [
/* the caption says what the axis is for: "Tint" alone reads as decoration, and nobody
* would look for the router-identity cue under it */
colourGroup(_('Tint (router identification)', 'footstrap'), {
current: axes.currentTint, apply: axes.applyTint
}, 'var(--fs-bg)', {
colourGroup(_('Tint (router identification)', 'footstrap'), axes.tint, 'var(--fs-bg)', {
/* the canvas is the one axis with no derived ink: its text is --fs-text, a palette
* token this axis must not move, so the ratio is reported instead of corrected */
fg: 'var(--fs-text)', bg: 'var(--fs-bg)', label: _('on the canvas', 'footstrap')
@@ -406,7 +397,7 @@ function build() {
* Not called "Density": that is the select above, and this string is both the caption and
* the aria-label, so a screen reader would announce two rows under one name. */
group(_('Tint strength', 'footstrap'),
(label) => sliderCtl(axes.currentTintStrength(), 0, 200, bump(repaint(axes.applyTintStrength)), label, {
(label) => sliderCtl(axes.tintStrength.current(), 0, 200, bump(repaint(axes.tintStrength.apply)), label, {
step: 5
}), { cls: 'fs-ap-tint fs-ap-tintstr' }),
@@ -419,12 +410,12 @@ function build() {
* eight times between here and the surfaces below, they cost their repeated literals in
* full — a string is not mangled — so the rows are data and the row is stated once. */
...[
[ _('Accent', 'footstrap'), axes.currentAccent, axes.applyAccent, 'var(--fs-accent)' ],
[ _('Good', 'footstrap'), axes.currentGood, axes.applyGood, 'var(--fs-good)' ],
[ _('Warning', 'footstrap'), axes.currentWarn, axes.applyWarn, 'var(--fs-warn)' ],
[ _('Danger', 'footstrap'), axes.currentDanger, axes.applyDanger, 'var(--fs-danger)' ]
].map(([ label, current, apply, ink ]) =>
colourGroup(label, { current, apply }, ink, { fg: ink, bg: CARD_BG, label: ON_CARD }))
[ _('Accent', 'footstrap'), axes.accent, 'var(--fs-accent)' ],
[ _('Good', 'footstrap'), axes.good, 'var(--fs-good)' ],
[ _('Warning', 'footstrap'), axes.warn, 'var(--fs-warn)' ],
[ _('Danger', 'footstrap'), axes.danger, 'var(--fs-danger)' ]
].map(([ label, axis, ink ]) =>
colourGroup(label, axis, ink, { fg: ink, bg: CARD_BG, label: ON_CARD }))
];
/* ---- the surfaces: the sheet the UI is drawn on ----
@@ -439,12 +430,12 @@ function build() {
/* Same rows, one column wider: a surface reports the ink read ON it, which is --fs-text
* for the three that carry body text and the hairline itself for the border. */
...[
[ _('Cards', 'footstrap'), axes.currentCard, axes.applyCard, CARD_BG, INK, CARD_BG, ON_CARD ],
[ _('Controls', 'footstrap'), axes.currentControl, axes.applyControl, 'var(--fs-panel2)', INK, 'var(--fs-panel2)', _('on a control', 'footstrap') ],
[ _('Sidebar and bar', 'footstrap'), axes.currentBar, axes.applyBar, 'var(--fs-bar-bg)', INK, 'var(--fs-bar-bg)', _('in the sidebar', 'footstrap') ],
[ _('Borders', 'footstrap'), axes.currentLine, axes.applyLine, 'var(--fs-border)', 'var(--fs-border)', CARD_BG, ON_CARD, 'shape' ]
].map(([ label, current, apply, probe, fg, bg, where, kind ]) =>
colourGroup(label, { current, apply }, probe, { fg, bg, label: where, kind }))
[ _('Cards', 'footstrap'), axes.card, CARD_BG, INK, CARD_BG, ON_CARD ],
[ _('Controls', 'footstrap'), axes.control, 'var(--fs-panel2)', INK, 'var(--fs-panel2)', _('on a control', 'footstrap') ],
[ _('Sidebar and bar', 'footstrap'), axes.bar, 'var(--fs-bar-bg)', INK, 'var(--fs-bar-bg)', _('in the sidebar', 'footstrap') ],
[ _('Borders', 'footstrap'), axes.line, 'var(--fs-border)', 'var(--fs-border)', CARD_BG, ON_CARD, 'shape' ]
].map(([ label, axis, probe, fg, bg, where, kind ]) =>
colourGroup(label, axis, probe, { fg, bg, label: where, kind }))
];
/* ---- section 3: the wallpaper and the rows each value brings ----
@@ -493,30 +484,30 @@ function build() {
group(_('Pattern', 'footstrap'),
() => E('div', { 'class': 'fs-ap-bgrow' }, [ patChoose, patRemove ]),
{ extra: [ patInput, patPreview, patErr ] }),
group(scaleLabel, (lbl) => sliderCtl(axes.currentPatternSize(), 40, 1600,
bump(axes.applyPatternSize), lbl, { step: 20 })),
group(strengthLabel, (lbl) => sliderCtl(axes.currentPatternStrength(), 0, 100,
bump(axes.applyPatternStrength), lbl, { step: 5 })),
group(inkLabel, (lbl) => selectCtl(axes.currentPatternInk(), {
group(scaleLabel, (lbl) => sliderCtl(axes.patternSize.current(), 40, 1600,
bump(axes.patternSize.apply), lbl, { step: 20 })),
group(strengthLabel, (lbl) => sliderCtl(axes.patternStrength.current(), 0, 100,
bump(axes.patternStrength.apply), lbl, { step: 5 })),
group(inkLabel, (lbl) => selectCtl(axes.patternInk.current(), {
theme: _('Theme', 'footstrap'),
original: _('As in file', 'footstrap')
}, bump(axes.applyPatternInk), lbl))
}, bump(axes.patternInk.apply), lbl))
];
/* …and the rows the FILE photo brings. */
const fileRows = [
group(_('File', 'footstrap'),
() => E('div', { 'class': 'fs-ap-bgrow' }, [ chooseBtn, removeBtn ]),
{ extra: [ fileInput, preview, err ] }),
group(dimLabel, (lbl) => sliderCtl(axes.currentPhotoDim(), 0, 100,
bump(axes.applyPhotoDim), lbl, { step: 5 }))
group(dimLabel, (lbl) => sliderCtl(axes.photoDim.current(), 0, 100,
bump(axes.photoDim.apply), lbl, { step: 5 }))
];
function reflect(tok) {
if (tok) { preview.src = axes.loginBgUrl(tok); preview.hidden = false; removeBtn.hidden = false; }
if (tok) { preview.src = axes.loginBg.url(tok); preview.hidden = false; removeBtn.hidden = false; }
else { preview.removeAttribute('src'); preview.hidden = true; removeBtn.hidden = true; }
}
function reflectPattern(tok) {
if (tok) { patPreview.src = axes.patternUrl(tok); patPreview.hidden = false; patRemove.hidden = false; }
if (tok) { patPreview.src = axes.pattern.url(tok); patPreview.hidden = false; patRemove.hidden = false; }
else { patPreview.removeAttribute('src'); patPreview.hidden = true; patRemove.hidden = true; }
}
/* `hidden` on the row, which 80-appearance.css restates at a specificity beating
@@ -527,11 +518,11 @@ function build() {
patRows.forEach((r) => { r.hidden = (v !== 'pattern'); });
fileRows.forEach((r) => { r.hidden = (v !== 'file'); });
}
reflect(axes.currentLoginBg());
reflectPattern(axes.currentPattern());
togglePanel(axes.currentWallpaper());
reflect(axes.loginBg.current());
reflectPattern(axes.pattern.current());
togglePanel(axes.wallpaper.current());
const setWallpaper = (v) => { axes.applyWallpaper(v); refreshSave(); togglePanel(v); refreshColours(); };
const setWallpaper = (v) => { axes.wallpaper.apply(v); refreshSave(); togglePanel(v); refreshColours(); };
/* Both uploads present the same three controls and the same four states — pick, upload,
* report, remove — so the wiring is stated once. What differs is `after`: the pattern also
@@ -579,7 +570,7 @@ function build() {
let seg;
const wallRow = group(_('Wallpaper', 'footstrap'), (label) => {
seg = selectCtl(axes.currentWallpaper(), {
seg = selectCtl(axes.wallpaper.current(), {
off: _('Off', 'footstrap'),
pattern: _('Pattern', 'footstrap'),
file: _('File', 'footstrap')
@@ -729,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
@@ -837,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;
@@ -12,7 +12,7 @@
* write fails after the bytes have landed.
*
* It is a module of its own because of WHERE it is needed: this machinery is reached only from the
* Appearance page, one out of nearly two hundred, and it was ~4 KB of DOMParser, canvas and rpc
* Appearance tab, one page out of nearly two hundred, and it was ~4 KB of DOMParser, canvas and rpc
* plumbing downloaded to a router's browser on the way to the DHCP page.
*
* The token accessors and the two live appliers live in `fs-axes` beside the axes themselves — the
@@ -212,20 +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. */
/* Residual, verified NOT exploitable here (2026-09 security review): a prologue of
* `<!DOCTYPE svg SYSTEM "http://evil.example/x.dtd">` is a document-level construct this walk
* never visits — `doc.doctype` is left as parsed, the same as everything else here that is not an
* element or an attribute — and it survives `XMLSerializer` byte for byte on the same round trip
* that runs below when anything else on the document needs cleaning. It costs nothing today
* because no shipping engine resolves an external DTD for any document at all, `image/svg+xml`
* included (external entity resolution has been off since the XXE era); a browser that ever did
* would only reach that fetch by opening the file directly, which the CGI answers with
* `Content-Security-Policy: default-src 'none'; sandbox` — every other consumer sees this bundle
* only as a `mask-image`/`background-image`/`<img>` source, none of which parses a DOCTYPE at all.
* Not closed: dropping `doc.doctype` would carve out one more node type in a walk that otherwise
* judges only elements and their attributes, for a fetch nothing in this project's support matrix
* performs. Starts mattering the day some consumer of this bundle DOES resolve an external DTD, or
* the file is ever served as a navigable document without that header. */
/* 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++; }
@@ -265,21 +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; }
/* Residual, verified NOT exploitable here (2026-09 security review): XML attribute-value
* normalisation does not fold a numeric character reference the way HTML does, so
* `href="javas&#9;cript:alert(1)"` reaches `v` as a DOM string with a LITERAL TAB still in
* it — `/^javascript:/i` does not match a value starting with a control character, and the
* fixture round-trips through `XMLSerializer` byte for byte, so it survives both this
* filter and the serializer. It still resolves to `javascript:` in a real browser, because
* a URL parser strips TAB/LF/CR from the scheme before comparing it (WHATWG URL, "scheme
* start state") — the same class of hole every naive `^scheme:` regex has. Not closed:
* matching every control character a URL parser discards is the grammar-guessing this file
* exists to avoid, for a value that never resolves as a URL here in the first place — this
* bundle only ever leaves the router as `mask-image`/`background-image`/`<img>`, none of
* which reads `href` as navigable, and the CGI serving it back for a direct open sends
* `Content-Security-Policy: default-src 'none'; sandbox` regardless. Starts mattering the
* day the tile is served without that header, or inlined into a page rather than
* referenced as an image. */
/* 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
@@ -296,22 +271,9 @@ function _sanitizeSvg(text) {
* prefix to the XML namespace just to carry it is a narrower attack this does not chase,
* consistent with this file avoiding a second grammar it would have to maintain. */
if (n === 'xml:base') { el.removeAttribute(a.name); refs++; continue; }
/* Residual, verified NOT exploitable here (2026-09 security review): the branch below is
* the ONLY place a `url(` is ever inspected. A PRESENTATION ATTRIBUTE that takes the same
* function — `fill`, `stroke`, `filter`, `clip-path`, `mask`, `cursor` (SVG 1.1 §11, §15) —
* is a plain attribute value to this walk, not a CSS declaration, so `<rect
* fill="url(//evil.example/x.svg#g)"/>` matches nothing above and passes with its ORIGINAL
* bytes untouched. What it can do is start a second fetch — no script, no navigation,
* exactly the reach `_externalStyleRef` already closes for `style` — and that fetch runs
* only where the SVG is rendered with its own resource-loading context, which this bundle
* never is: it is only ever a `mask-image`/`background-image`/`<img>` source, and a direct
* open is answered by the CGI's `Content-Security-Policy: default-src 'none'; sandbox`
* regardless. Not closed: doing here what `_sanitizeStyleValue` does for `style` means the
* same check against every presentation attribute the SVG spec lets carry a `url()`, a list
* this file would then have to track as the spec grows it — the grammar this file exists to
* avoid guessing at, moved from one attribute to a dozen. Starts mattering the day a
* presentation `url()` gets a fetch path this parser-only pass does not already block by
* other means, or the tile stops being image-only. */
/* A presentation attribute's `url(` (`fill`, `stroke`, `filter`, …) is only checked below
* 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) {
@@ -337,22 +299,11 @@ function _sanitizeSvg(text) {
return { text: new XMLSerializer().serializeToString(doc), elements, refs };
}
/* read the picked file as text so it can be inspected before upload, and so what reaches the
* router is exactly the bytes that were checked */
function _readText(file) {
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(String(fr.result || ''));
fr.onerror = () => reject(new Error(_('That file could not be read.', 'footstrap')));
fr.readAsText(file);
});
}
/* ---- login/page background upload: router-side, and deliberately not an axis ----
* 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
@@ -490,7 +441,7 @@ function assetAxis(o) {
.catch((e) => _rollbackUpload(o.path, e))
.then(() => {
/* switch this browser onto it: the ordinary axis path, localStorage only */
axes.applyWallpaper(o.wallpaper);
axes.wallpaper.apply(o.wallpaper);
o.apply(tok);
return tok;
});
@@ -510,13 +461,16 @@ function assetAxis(o) {
* back a raster, so the parsed-document check above stands in for it. */
const PATTERN = assetAxis({
path: PAT_PATH, filename: 'pattern.svg', field: 'pattern', wallpaper: 'pattern',
apply: (tok) => axes.applyPattern(tok),
apply: (tok) => axes.pattern.apply(tok),
prepare: (file) => {
if (!file) return Promise.reject(new Error(MSG_PICK_SVG));
const isSvg = (/(^image\/svg\+xml$)/i).test(file.type || '') || (/\.svg$/i).test(file.name || '');
if (!isSvg) return Promise.reject(new Error(MSG_PICK_SVG));
if (file.size > PAT_MAX) return Promise.reject(new Error(_('That file is too large.', 'footstrap')));
return _readText(file).then((text) => {
/* the catch keeps the same message, so what reaches the router is still exactly the bytes
* that were checked */
const readErr = () => Promise.reject(new Error(_('That file could not be read.', 'footstrap')));
return file.text().catch(readErr).then((text) => {
const cleaned = _sanitizeSvg(text);
if (cleaned.error) return Promise.reject(new Error(cleaned.error));
/* Told once, here, regardless of whether the upload that follows succeeds — what left
@@ -535,7 +489,7 @@ const PATTERN = assetAxis({
* grant for BG_PATH. */
const LOGIN_BG = assetAxis({
path: BG_PATH, filename: 'login-bg', field: 'login_bg', wallpaper: 'file',
apply: (tok) => axes.applyLoginBg(tok),
apply: (tok) => axes.loginBg.apply(tok),
prepare: (file) => {
if (!file || !(/^image\//).test(file.type || ''))
return Promise.reject(new Error(_('Please choose an image file.', 'footstrap')));
@@ -10,20 +10,19 @@
* chrome, the menu and the search palette, so it is fetched on every admin page — and of its
* sixty-one exports the cold path called eight. Everything here is reached only from
* `fs-appearance` (the form) and `fs-assets` (the two uploads), both page modules, so the router
* fetches it on the Appearance page and nowhere else. Measured: 6.6 KB off what every page
* fetches it on the Appearance tab and nowhere else. Measured: 6.6 KB off what every page
* downloads.
*
* 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,11 +116,10 @@ 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');
const currentPalette = PALETTE.current, applyPalette = PALETTE.apply;
/* Wallpaper is a multi-value axis: off (bare canvas), pattern (the admin-uploaded SVG, tiled and
* recoloured — 15-wallpaper.css) or file (the admin-uploaded photo, 16-login-bg.css).
@@ -134,7 +132,6 @@ const currentPalette = PALETTE.current, applyPalette = PALETTE.apply;
* in the list falls back to 'off'. */
const WALLPAPERS = [ 'pattern', 'file' ]; /* the non-off values; 'off' = bare :root */
const WALLPAPER = prefs.listAxis('fs-wallpaper', 'data-wallpaper', WALLPAPERS, 'off');
const currentWallpaper = WALLPAPER.current, applyWallpaper = WALLPAPER.apply;
/* Density: how much air the UI uses. A three-value axis like wallpaper, and a pure token axis —
* 02-tokens.css multiplies the type and space ladders and every size follows, with no layout switch
@@ -145,14 +142,12 @@ const currentWallpaper = WALLPAPER.current, applyWallpaper = WALLPAPER.apply;
* otherwise the bar stays stacked — or stays unstacked and overflows — until the next resize. */
const TINT = colorAxis('fs-tint', 'data-tint', '--fs-tint-h', '--fs-bg');
const currentTint = TINT.current, applyTint = TINT.apply;
/* Accent axis: the UI accent (solid buttons, toggle knobs, sliders, focus rings, accented links)
* while canvas, cards and status colours stay put. On a hue, CSS rotates --fs-accent and keeps the
* palette's lightness and chroma, so --fs-on-accent stays legible unrecomputed; on a hex the ink is
* recomputed from the entered colour's lightness (03-palettes.css). 0 = off. */
const ACCENT = colorAxis('fs-accent', 'data-accent', '--fs-accent-h', '--fs-accent');
const currentAccent = ACCENT.current, applyAccent = ACCENT.apply;
/* The three status colours are the same axis pointed at --fs-good / --fs-warn / --fs-danger, kept
* separate because they carry separate meanings and every derived tint is a color-mix() of the
@@ -197,27 +192,18 @@ function surfaceAxis(key, sdKey, prop) {
};
}
const CARD = surfaceAxis('fs-card', 'card', '--fs-panel-base');
const currentCard = CARD.current, applyCard = CARD.apply;
const CONTROL = surfaceAxis('fs-control', 'control', '--fs-panel2-base');
const currentControl = CONTROL.current, applyControl = CONTROL.apply;
const BAR = surfaceAxis('fs-bar', 'bar', '--fs-bar-bg');
const currentBar = BAR.current, applyBar = BAR.apply;
const LINE = surfaceAxis('fs-line', 'line', '--fs-border-base');
const currentLine = LINE.current, applyLine = LINE.apply;
const GOOD = colorAxis('fs-good', 'data-good', '--fs-good-h', '--fs-good');
const currentGood = GOOD.current, applyGood = GOOD.apply;
const WARN = colorAxis('fs-warn', 'data-warn', '--fs-warn-h', '--fs-warn');
const currentWarn = WARN.current, applyWarn = WARN.apply;
const DANGER = colorAxis('fs-danger', 'data-danger', '--fs-danger-h', '--fs-danger');
const currentDanger = DANGER.current, applyDanger = DANGER.apply;
/* 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'));
const currentRadius = RADIUS.current, applyRadius = RADIUS.apply, radiusDefault = RADIUS.def;
/* 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.
*
@@ -228,7 +214,6 @@ const currentRadius = RADIUS.current, applyRadius = RADIUS.apply, radiusDefault
const FS_CWIDTH_DEFAULT = 1280;
const CWIDTH = propAxis('fs-content-width', 'content_width', '--fs-content-max', 1280, 3840,
FS_CWIDTH_DEFAULT, (v) => (v + 'px'), () => fit.schedule());
const currentContentWidth = CWIDTH.current, applyContentWidth = CWIDTH.apply, contentWidthDefault = CWIDTH.def;
/* Layout axis: horizontal top bar (the default) vs vertical sidebar. One template, one renderer —
* CSS morphs the chrome off :root[data-layout] and toggling re-renders nothing; menu-footstrap.js
@@ -256,14 +241,12 @@ const AXIS_KEYS = [
* module throws, taking the chrome and the menu with it. */
const FS_TSTR_DEFAULT = 100;
const TSTR = propAxis('fs-tint-strength', 'tint_strength', '--fs-tint-strength', 0, 200, FS_TSTR_DEFAULT, (v) => String(v / 100));
const currentTintStrength = TSTR.current, applyTintStrength = TSTR.apply, tintStrengthDefault = TSTR.def;
/* Photo dim: the scrim opacity over the FILE photo (0–100%). The photo is shared; how strongly
* this browser dims it is not, and it reaches the router through Save-as-default. Only bites while
* the wallpaper is 'file'. Declared up here for the TDZ reason above. */
const FS_PDIM_DEFAULT = 74;
const PDIM = propAxis('fs-photo-dim', 'photo_dim', '--fs-photo-dim', 0, 100, FS_PDIM_DEFAULT, (v) => (v + '%'));
const currentPhotoDim = PDIM.current, applyPhotoDim = PDIM.apply, photoDimDefault = PDIM.def;
/* The pattern's two live knobs, and the third that is an enum. All three bite only while the
* wallpaper is 'pattern'; the FILE is shared, how this browser draws it is not.
@@ -273,14 +256,11 @@ const currentPhotoDim = PDIM.current, applyPhotoDim = PDIM.apply, photoDimDefaul
* the file would put out of CSS's reach. Declared up here for the TDZ reason above. */
const FS_PSIZE_DEFAULT = 440;
const PSIZE = propAxis('fs-pattern-size', 'pattern_size', '--fs-pattern-size', 40, 1600, FS_PSIZE_DEFAULT, (v) => (v + 'px'));
const currentPatternSize = PSIZE.current, applyPatternSize = PSIZE.apply, patternSizeDefault = PSIZE.def;
const FS_PSTR_DEFAULT = 20;
const PSTR = propAxis('fs-pattern-strength', 'pattern_strength', '--fs-pattern-strength', 0, 100, FS_PSTR_DEFAULT, (v) => String(v / 100));
const currentPatternStrength = PSTR.current, applyPatternStrength = PSTR.apply, patternStrengthDefault = PSTR.def;
/* Ink: 'theme' (the file's alpha, the theme's colour) or 'original' (the file's own colours, no
* mask). Two-valued with the default as a bare :root, i.e. the enumAxis shape. */
const PINK = prefs.enumAxis('fs-pattern-ink', 'data-pattern-ink', 'original', 'theme');
const currentPatternInk = PINK.current, applyPatternInk = PINK.apply;
/* `reject: true` is load-bearing: without it a refused write arrives as SUCCESS. rpc.js raises on
* the ubus status code only when the declaration asks it to, and otherwise hands the code back as
* the resolved value — measured on the router, a per-config ACL refusal resolves with 6
@@ -294,30 +274,30 @@ function snapshotAxes() {
return {
layout: prefs.currentLayout(),
darkmode: prefs.currentMode(),
palette: currentPalette(),
wallpaper: currentWallpaper(),
tint: String(currentTint()),
accent: String(currentAccent()),
good: String(currentGood()),
warn: String(currentWarn()),
danger: String(currentDanger()),
card: String(currentCard()),
control: String(currentControl()),
bar: String(currentBar()),
line: String(currentLine()),
rounding: String(currentRadius()),
palette: PALETTE.current(),
wallpaper: WALLPAPER.current(),
tint: String(TINT.current()),
accent: String(ACCENT.current()),
good: String(GOOD.current()),
warn: String(WARN.current()),
danger: String(DANGER.current()),
card: String(CARD.current()),
control: String(CONTROL.current()),
bar: String(BAR.current()),
line: String(LINE.current()),
rounding: String(RADIUS.current()),
autocollapse: prefs.currentAutoCollapse() ? 'on' : 'off',
tint_strength: String(currentTintStrength()),
tint_strength: String(TSTR.current()),
density: prefs.currentDensity(),
photo_dim: String(currentPhotoDim()),
pattern_size: String(currentPatternSize()),
pattern_strength: String(currentPatternStrength()),
pattern_ink: currentPatternInk(),
content_width: String(currentContentWidth())
photo_dim: String(PDIM.current()),
pattern_size: String(PSIZE.current()),
pattern_strength: String(PSTR.current()),
pattern_ink: PINK.current(),
content_width: String(CWIDTH.current())
};
}
/* The resolved router default (the uci value if set, else the built-in) in snapshotAxes() string
* form, so the Appearance page can grey the Save button when this browser already shows exactly it.
* form, so the Appearance tab can grey the Save button when this browser already shows exactly it.
* Seeded from window.__fsSD at load and replaced with the just-saved snapshot, so a save flips the
* match without a reload.
*
@@ -341,15 +321,15 @@ function _resolvedDefault() {
control: String(CONTROL.def()),
bar: String(BAR.def()),
line: String(LINE.def()),
rounding: String(radiusDefault()),
rounding: String(RADIUS.def()),
autocollapse: (prefs.autoCollapseDefault() ? 'on' : 'off'),
tint_strength: String(tintStrengthDefault()),
tint_strength: String(TSTR.def()),
density: prefs.densityDefault(),
photo_dim: String(photoDimDefault()),
pattern_size: String(patternSizeDefault()),
pattern_strength: String(patternStrengthDefault()),
photo_dim: String(PDIM.def()),
pattern_size: String(PSIZE.def()),
pattern_strength: String(PSTR.def()),
pattern_ink: PINK.def(),
content_width: String(contentWidthDefault())
content_width: String(CWIDTH.def())
};
}
let _savedDefault = _resolvedDefault();
@@ -405,64 +385,39 @@ function resetToBuiltin() {
* first click — the one state no static gate can see and the live check does. */
prefs.applyLayout('top');
prefs.applyMode('auto');
applyPalette('footstrap');
applyWallpaper('off');
applyTint(0);
applyAccent(0);
applyGood(0);
applyWarn(0);
applyDanger(0);
applyCard(0);
applyControl(0);
applyBar(0);
applyLine(0);
applyRadius(FS_RADIUS_DEFAULT);
PALETTE.apply('footstrap');
WALLPAPER.apply('off');
TINT.apply(0);
ACCENT.apply(0);
GOOD.apply(0);
WARN.apply(0);
DANGER.apply(0);
CARD.apply(0);
CONTROL.apply(0);
BAR.apply(0);
LINE.apply(0);
RADIUS.apply(FS_RADIUS_DEFAULT);
prefs.applyAutoCollapse('off');
applyTintStrength(FS_TSTR_DEFAULT);
TSTR.apply(FS_TSTR_DEFAULT);
prefs.applyDensity('normal');
applyPhotoDim(FS_PDIM_DEFAULT);
applyPatternSize(FS_PSIZE_DEFAULT);
applyPatternStrength(FS_PSTR_DEFAULT);
applyPatternInk('theme');
applyContentWidth(FS_CWIDTH_DEFAULT);
PDIM.apply(FS_PDIM_DEFAULT);
PSIZE.apply(FS_PSIZE_DEFAULT);
PSTR.apply(FS_PSTR_DEFAULT);
PINK.apply('theme');
CWIDTH.apply(FS_CWIDTH_DEFAULT);
}
/* ---- the two uploaded wallpapers, browser side ----
*
* What the router last saved, what URL that is, and how to paint it. Putting the file THERE is
* fs-assets.js: a DOMParser pass, a canvas re-encode, a chmod and a rollback, reached only from the
* Appearance page and so not worth downloading on every admin page. The token accessors stay here
* Appearance tab and so not worth downloading on every admin page. The token accessors stay here
* because `prefs.sd()` is private to this module and because head.ut's pre-paint reads the same fields.
*
* Neither is an axis: an axis is per-browser with a router default, and these have no browser
* layer — one admin uploads once and every device sees it, pre-login included. So they are absent
* from AXIS_KEYS, snapshotAxes() and matchesSavedDefault(), and must not move the Save button. */
/* NOT a /www symlink, unlike BG_SERVE below: an SVG served as a plain static file is a same-origin
* document that runs script opened directly (OpenWrt forum thread 251930). This CGI handler reads
* the fixed /etc/footstrap/pattern.svg path itself and answers with CSP 'none' + sandbox and
* nosniff, headers uhttpd cannot attach to a static file — see the uci-default and the handler for
* the rest of the reasoning. Painting stays exactly as before: a URL is a URL to a mask-image. */
const PAT_SERVE = '/cgi-bin/luci-theme-footstrap-pattern';
function currentPattern() {
const t = prefs.sd('pattern');
return (typeof t === 'string' && BG_TOKEN_RE.test(t)) ? t : '';
}
function patternUrl(tok) { return PAT_SERVE + '?v=' + tok; }
/* set/clear the tile URL live. This only supplies the url(); whether it PAINTS is the Wallpaper
* axis (data-wallpaper="pattern"). Exported because fs-assets.js applies the token it just wrote. */
function applyPattern(tok) {
const root = document.documentElement;
if (tok) root.style.setProperty('--fs-pattern-url', 'url("' + patternUrl(tok) + '")');
else root.style.removeProperty('--fs-pattern-url');
prefs.setSD('pattern', tok || '');
}
/* Still the plain uhttpd symlink the uci-default makes, unlike PAT_SERVE above: fs-assets.js
* re-encodes every login-bg upload to a JPEG on a canvas before it is sent, so the stored bytes
* are pixels only — there is no script grammar left in a raster for a direct hit to run. */
const BG_SERVE = '/luci-static/footstrap/bg';
/* the cache-bust token charset, an md5/sha hex string. One copy here; head.ut's ucode sanitiser
* and the pre-paint inline script keep their own identical copies unavoidably, running before this
* module — see the axes contract in head.ut. */
@@ -471,45 +426,67 @@ const BG_TOKEN_RE = /^[a-f0-9]{6,64}$/;
* A predicate rather than the pattern itself, so the charset stays stated once. */
function tokenOk(t) { return BG_TOKEN_RE.test(t); }
/* the token the server last saved, validated to the same hex charset head.ut's sanitiser and
* pre-paint use, so the Appearance page can build a cache-busted preview src. '' = none. */
function currentLoginBg() {
const t = prefs.sd('login_bg');
return (typeof t === 'string' && BG_TOKEN_RE.test(t)) ? t : '';
}
function loginBgUrl(tok) { return BG_SERVE + '?v=' + tok; }
/* applyPattern's twin for the photo; data-wallpaper="file" decides whether it paints. */
function applyLoginBg(tok) {
const root = document.documentElement;
if (tok) root.style.setProperty('--fs-login-bg-url', 'url("' + loginBgUrl(tok) + '")');
else root.style.removeProperty('--fs-login-bg-url');
prefs.setSD('login_bg', tok || '');
/* The pattern and the login-bg photo are the same shape twice: a router-saved cache-bust TOKEN,
* read back through prefs.sd(), that sets or clears one url() custom property. Neither is an axis
* (see the comment above) — this factory is what a bare url() setter shares with a real axis's
* apply(), not membership in AXIS_KEYS. `serve` differs for a reason stated at each call below. */
function tokenAsset(sdKey, serve, prop) {
const current = () => {
const t = prefs.sd(sdKey);
return (typeof t === 'string' && BG_TOKEN_RE.test(t)) ? t : '';
};
const url = (tok) => serve + '?v=' + tok;
/* set/clear the url() live; whether it PAINTS is the Wallpaper axis (data-wallpaper="pattern"
* or "file"). Exported because fs-assets.js applies the token it just wrote. */
const apply = (tok) => {
const root = document.documentElement;
if (tok) root.style.setProperty(prop, 'url("' + url(tok) + '")');
else root.style.removeProperty(prop);
prefs.setSD(sdKey, tok || '');
};
return { current, url, apply };
}
/* NOT a /www symlink, unlike LOGIN_BG's plain static path below: an SVG served as a plain static
* file is a same-origin document that runs script opened directly (OpenWrt forum thread 251930).
* This CGI handler reads the fixed /etc/footstrap/pattern.svg path itself and answers with CSP
* 'none' + sandbox and nosniff, headers uhttpd cannot attach to a static file — see the uci-default
* and the handler for the rest of the reasoning. Painting stays exactly as before: a URL is a URL
* to a mask-image. */
const PATTERN = tokenAsset('pattern', '/cgi-bin/luci-theme-footstrap-pattern', '--fs-pattern-url');
/* Still the plain uhttpd symlink the uci-default makes, unlike PATTERN's CGI handler above:
* fs-assets.js re-encodes every login-bg upload to a JPEG on a canvas before it is sent, so the
* stored bytes are pixels only — there is no script grammar left in a raster for a direct hit to
* run. */
const LOGIN_BG = tokenAsset('login_bg', '/luci-static/footstrap/bg', '--fs-login-bg-url');
/* 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({
currentPalette, applyPalette,
currentWallpaper, applyWallpaper,
currentTint, applyTint,
currentAccent, applyAccent,
currentGood, applyGood,
currentWarn, applyWarn,
currentDanger, applyDanger,
currentCard, applyCard,
currentControl, applyControl,
currentBar, applyBar,
currentLine, applyLine,
currentRadius, applyRadius,
currentTintStrength, applyTintStrength,
currentPhotoDim, applyPhotoDim,
currentPatternSize, applyPatternSize,
currentPatternStrength, applyPatternStrength,
currentPatternInk, applyPatternInk,
currentContentWidth, applyContentWidth, contentWidthDefault,
palette: PALETTE,
wallpaper: WALLPAPER,
tint: TINT,
accent: ACCENT,
good: GOOD,
warn: WARN,
danger: DANGER,
card: CARD,
control: CONTROL,
bar: BAR,
line: LINE,
radius: RADIUS,
tintStrength: TSTR,
photoDim: PDIM,
patternSize: PSIZE,
patternStrength: PSTR,
patternInk: PINK,
contentWidth: CWIDTH,
pattern: PATTERN,
loginBg: LOGIN_BG,
currentPattern, patternUrl, applyPattern,
currentLoginBg, loginBgUrl, applyLoginBg,
tokenOk,
snapshotAxes, matchesSavedDefault, saveAsDefault, resetToSaved, resetToBuiltin
matchesSavedDefault, saveAsDefault, resetToSaved, resetToBuiltin
});
@@ -4,6 +4,7 @@
'require fs-fit as fit';
'require fs-prefs as prefs';
'require fs-menutree as tree';
'require fs-widgets as widgets';
/* The chrome around the content: the mode menu, the section tabs, the rail toggle and the
* measurements deciding how much room each gets. The main menu is injected by menu-footstrap.js as
@@ -18,11 +19,8 @@ function setRenderMain(fn) {
/* section tabs -> #tabmenu (horizontal) */
function renderTabMenu(node, url, level) {
/* #tabmenu is emitted whenever this module loads (notices.ut, !blank_page) */
const container = document.querySelector('#tabmenu');
/* a template without the container must not reject: an unhandled rejection here kills the
* whole ui.menu.load() chain, i.e. every menu */
if (!container)
return E([]);
const ul = E('ul', { 'class': 'tabs' });
const children = ui.menu.getChildren(node);
let activeNode = null;
@@ -39,15 +37,13 @@ function renderTabMenu(node, url, level) {
});
if (ul.children.length === 0)
return E([]);
return;
container.appendChild(ul);
container.style.display = '';
if (activeNode)
renderTabMenu(activeNode, url + '/' + activeNode.name, (level || 0) + 1);
return ul;
}
/* ---- tab-strip auto-fit ----
@@ -107,13 +103,19 @@ 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() 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
* every navigation off this page into a full load. */
_probe.id = 'fs-chrome-geom-probe';
_probe.setAttribute('aria-hidden', 'true');
/* out of flow, no box, no ink: it must never affect layout, scroll extent or hit-testing */
_probe.style.cssText = 'position:absolute!important;visibility:hidden!important;' +
@@ -150,21 +152,20 @@ function shellGeometry() {
/* the gutter is re-asked even on a memo hit: it moves with the width, not the density */
if (_geom && _geomDensity === key) return _geom;
_geomDensity = key;
const px = (name, dflt) => resolveLen(name, dflt);
const g = {
contentMin: px('--fs-content-min', GEOM_DFLT.contentMin),
sidebarW: px('--fs-sidebar-w', GEOM_DFLT.sidebarW),
railW: px('--fs-rail-w', GEOM_DFLT.railW),
contentMin: resolveLen('--fs-content-min', GEOM_DFLT.contentMin),
sidebarW: resolveLen('--fs-sidebar-w', GEOM_DFLT.sidebarW),
railW: resolveLen('--fs-rail-w', GEOM_DFLT.railW),
/* the token is one side's padding; the column loses it twice. It is only the fallback —
* measureShell() overwrites this with the gutter the column actually got, which nothing
* has measured before the first fitter (and the login page has no `.fs-content`). */
contentPad: px('--fs-content-pad', GEOM_DFLT.contentPad / 2) * 2,
contentPad: resolveLen('--fs-content-pad', GEOM_DFLT.contentPad / 2) * 2,
/* where the column stops growing, i.e. where surplus becomes margin — see columnWidth() */
contentMax: px('--fs-content-max', GEOM_DFLT.contentMax)
contentMax: resolveLen('--fs-content-max', GEOM_DFLT.contentMax)
};
/* 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);
@@ -230,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);
}
@@ -278,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.
@@ -401,6 +402,7 @@ function clusterFitsBrandRow(bar, menu) {
/* modes -> #modemenu; drives the injected renderMainMenu for the active mode */
function renderModeMenu(node, renderMainMenu) {
/* #modemenu is emitted whenever this module loads (header.ut, !blank_page) */
const ul = document.querySelector('#modemenu');
const children = ui.menu.getChildren(node);
@@ -409,18 +411,14 @@ function renderModeMenu(node, renderMainMenu) {
? child.name === L.env.requestpath[0]
: index === 0;
/* the main menu must render even where a template has no #modemenu */
if (ul)
ul.appendChild(E('li', { 'class': isActive ? 'active' : '' }, [
E('a', { 'href': L.url(child.name) }, [ _(child.title) ])
]));
ul.appendChild(E('li', { 'class': isActive ? 'active' : '' }, [
E('a', { 'href': L.url(child.name) }, [ _(child.title) ])
]));
if (isActive)
renderMainMenu(child, child.name);
});
if (!ul)
return;
if (children.length <= 1)
ul.classList.add('single');
if (ul.children.length > 1)
@@ -431,13 +429,15 @@ function renderModeMenu(node, renderMainMenu) {
* every SPA nav. Containers are cleared first so a re-render does not stack duplicates. */
function renderChrome() {
const root = tree.tree();
/* #modemenu/#topmenu/#tabmenu are emitted whenever this module loads (header.ut/notices.ut,
* !blank_page) */
const modemenu = document.querySelector('#modemenu');
const topmenu = document.querySelector('#topmenu');
const tabmenu = document.querySelector('#tabmenu');
if (modemenu) { modemenu.innerHTML = ''; modemenu.style.display = 'none'; modemenu.classList.remove('single'); }
if (topmenu) topmenu.innerHTML = '';
if (tabmenu) { tabmenu.innerHTML = ''; tabmenu.style.display = 'none'; }
modemenu.innerHTML = ''; modemenu.style.display = 'none'; modemenu.classList.remove('single');
topmenu.innerHTML = '';
tabmenu.innerHTML = ''; tabmenu.style.display = 'none';
renderModeMenu(root, _renderMain);
@@ -461,8 +461,8 @@ function renderChrome() {
* <html data-rail> (head.ut re-applies it before paint) and in localStorage; everything else is
* CSS keyed off that attribute. */
function wireRail() {
/* #fs-rail-toggle is emitted whenever this module loads (header.ut, !blank_page) */
const btn = document.getElementById('fs-rail-toggle');
if (!btn) return;
function sync() {
const on = prefs.currentRail();
@@ -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.
*
@@ -494,38 +494,23 @@ function wireRail() {
* stays in the label for screen readers, and in `title` for the pointer. */
const IND_DOT = '•';
/* Idempotent attribute write, so a poll tick that finds nothing changed touches no DOM — same
* shape as `fsSyncAttr` in menu-footstrap-common.js, restated rather than imported (that file does
* not export it). */
function syncIndAttr(el, name, value) {
if (value === null) {
if (el.hasAttribute(name)) el.removeAttribute(name);
} else if (el.getAttribute(name) !== value) {
el.setAttribute(name, value);
}
}
/* A CLICKABLE `[data-indicator]` (the poll pill, "Unsaved Changes: N", …) ships as a bare
* span: no role, name or tabindex, so Tab skips it and a screen reader
* announces a run of text with no name, role or state (WCAG 2.1.1, 4.1.2). ui.showIndicator's own
* click handler already lives on this exact element — this only adds the second, W3C-APG way to
* reach it; it does not add a competing one. The name is the pill's own prose ("Refreshing"),
* never invented. Enter/Space call el.click() because a <span>, unlike an <a>, gets neither key
* for free (contrast fs-widgets.js's wireSpaceKey, written for an <a role="button">); calling
* click() cannot double-fire the mouse handler, since a keydown is not a click. */
* never invented. widgets.wireActivate() calls el.click() because a <span>, unlike an <a>, gets
* neither Enter nor Space for free; calling click() cannot double-fire the mouse handler, since a
* keydown is not a click. */
function wireIndicatorKeyboard(el) {
if (el.dataset.fsWired) return;
el.dataset.fsWired = '1';
el.addEventListener('keydown', (ev) => {
if (ev.key !== 'Enter' && ev.key !== ' ' && ev.key !== 'Spacebar') return;
ev.preventDefault();
el.click();
});
widgets.wireActivate(el, () => el.click());
}
function wireIndicatorCounts() {
/* #indicators is emitted whenever this module loads (header.ut, !blank_page) */
const box = document.getElementById('indicators');
if (!box) return;
function stamp() {
box.querySelectorAll('[data-indicator]').forEach((el) => {
@@ -541,9 +526,9 @@ function wireIndicatorCounts() {
* replaces. The ring and the pointer cursor in theme/20-shell.css are scoped to
* `[data-clickable]` for the same reason; the two must not disagree. */
if (el.hasAttribute('data-clickable')) {
syncIndAttr(el, 'role', 'button');
syncIndAttr(el, 'tabindex', '0');
syncIndAttr(el, 'aria-label', txt.trim() || null);
widgets.syncAttr(el, 'role', 'button');
widgets.syncAttr(el, 'tabindex', '0');
widgets.syncAttr(el, 'aria-label', txt.trim() || null);
wireIndicatorKeyboard(el);
}
});
@@ -601,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
@@ -621,8 +605,5 @@ return baseclass.extend({
});
},
/* exported for tests/chrome-geometry.test.mjs (no tests ship in the package): driving the
* arithmetic directly is the only way to cover every combination of layout, rail and width
* without a browser */
wireRail
});
@@ -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,28 +59,38 @@ 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 */
const boxes = [], hs = [];
/* boxes stays an array — order feeds the parallel `hs` below and dirty's own .filter() — with a
* 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);
while (box && box !== host && cs.display.startsWith('table')) {
box = box.parentElement;
if (box) cs = window.getComputedStyle(box);
}
if (!box || box === host || boxes.indexOf(box) !== -1) return;
if (!box || box === host || boxSeen.has(box)) return;
boxSeen.add(box);
boxes.push(box);
});
host.querySelectorAll(FLOORED).forEach((box) => {
if (boxSeen.has(box)) return;
boxSeen.add(box);
boxes.push(box);
});
host.querySelectorAll(FLOORED).forEach((box) => { if (boxes.indexOf(box) === -1) boxes.push(box); });
let dirty = boxes;
if (records && records.length) {
const targets = [];
for (const r of records) if (r.target && targets.indexOf(r.target) === -1) targets.push(r.target);
dirty = boxes.filter((box) => targets.some((t) => box.contains(t) || t.contains(box)));
const targets = new Set();
for (const r of records) if (r.target) targets.add(r.target);
dirty = boxes.filter((box) => {
for (const t of targets) if (box.contains(t) || t.contains(box)) return true;
return false;
});
}
if (!dirty.length) return;
@@ -363,14 +372,29 @@ function anchorRef() {
sec: keep, secTop: keep ? keep.getBoundingClientRect().top : 0 };
}
let _anchorPending = null;
let _anchorWhy = null;
const _anchorTrail = [];
function awhy(w) {
_anchorWhy = w;
_anchorTrail.push(w + '@' + Math.round(performance.now()));
if (_anchorTrail.length > 8) _anchorTrail.shift();
/* 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. 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;
const entries = [];
return {
record(w) {
last = w;
entries.push(w + '@' + Math.round(performance.now()));
if (entries.length > TRAIL_MAX) entries.shift();
},
last: () => last,
entries: () => entries
};
}
let _anchorPending = null;
const _anchor = makeTrail();
function awhy(w) { _anchor.record(w); }
let _anchorFrame = 0;
function anchorEnabled() {
try { return localStorage.getItem('fsAnchor') !== 'off'; }
@@ -380,14 +404,8 @@ function anchorEnabled() {
* It keeps a reference still; it does not promise that a section can vanish and come back. An
* older WebKit moved 180px for 120px of growth, so the element is asked where it is now. */
let _lateFrame = 0;
/* why the last late correction did or did not write — eight exits, one symptom from outside */
let _lateWhy = null;
const _lateTrail = [];
function why(w) {
_lateWhy = w;
_lateTrail.push(w + '@' + Math.round(performance.now()));
if (_lateTrail.length > 8) _lateTrail.shift();
}
const _late = makeTrail();
function why(w) { _late.record(w); }
function lateDrift(ref, grow, floorShrink) {
if (_lateFrame) return why('busy');
@@ -607,11 +625,6 @@ return baseclass.extend({
},
scrolling,
/* unmarked, for tools/scroll-anchor.mjs — see `_lateWhy` */
lateWhy: () => _lateWhy,
lateTrail: () => _lateTrail.slice(),
anchorWhy: () => _anchorWhy,
anchorTrail: () => _anchorTrail.slice(),
deferMeasurement,
/* -> the offset this file last took a reference at, so a probe does not measure the guard instead
@@ -19,7 +19,7 @@ function setTree(tree) {
* reserved for a path outside LuCI's scriptname. */
function segsFromPath(pathname) {
const base = L.env.scriptname || '';
if (base && pathname.indexOf(base) !== 0)
if (base && !pathname.startsWith(base))
return null;
const rest = pathname.slice(base.length).replace(/^\/+|\/+$/g, '');
return rest.length ? rest.split('/') : [];
@@ -159,10 +159,6 @@ function currentNode() {
return baseclass.extend({
setTree,
tree: () => _tree,
/* raw presence, no alias or firstchild resolution: fs-commands gates each command on the menu
* node whose `depends.acl` names the group that command needs, and a node that resolves
* elsewhere would answer for a permission the session may not hold */
nodeForSegs,
segsFromPath,
currentNode,
resolveSegs,
@@ -3,6 +3,7 @@
'require dom';
'require network';
'require fs-fit as fit';
'require fs-widgets as widgets';
'require menu-footstrap-common as common';
/* Overview layout only: renders nothing of its own, it re-arranges the STOCK System / Memory /
@@ -21,8 +22,6 @@
/* section title -> grid role. _() with no msgctxt on purpose: these must resolve to exactly what
* luci-mod-status resolves to, or the titles stop matching. Built once, not per poll tick. */
const ROLES = { [_('System')]: 'sys', [_('Memory')]: 'mem', [_('Storage')]: 'sto' };
/* the data-page value four call sites compare against; a string literal is not mangled, so a
* repeat is paid in full on flash every time (measured: 24 B x4 -> 37 B, 59 B saved) */
function headerEl(sec) {
/* two title markups, one per release: 25.12 wraps the heading (`.cbi-title > h3`), 24.10 emits
@@ -46,27 +45,15 @@ 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).
* docs/findings.md, "A card cannot be collapsed from the keyboard".
*
* index.js's own pill keeps doing the actual show/hide — untouched, so mouse behaviour for anyone
* clicking it does not change. The header becomes a second, W3C-APG way to reach the SAME handler,
* not a competing one. */
/* Idempotent attribute write, so a poll tick that finds nothing changed touches no DOM and fires
* no mutation record. Same shape as `fsSyncAttr` in menu-footstrap-common.js — restated rather than
* imported, since that file does not export it. */
function syncAttr(el, name, value) {
if (value === null) {
if (el.hasAttribute(name)) el.removeAttribute(name);
} else if (el.getAttribute(name) !== value) {
el.setAttribute(name, value);
}
}
/* index.js's own attribute: "inactive" is expanded (the pill reads "Hide"), "active" is collapsed
* (it reads "Show") — the chevron mirror in pages/20-overview.css reads the same attribute the
* same way. */
@@ -96,14 +83,14 @@ function wireDisclosure(sec) {
const panel = panelFor(h);
if (panel && !panel.id) panel.id = 'fs-ovl-panel-' + (_panelSeq++);
syncAttr(h, 'role', 'button');
syncAttr(h, 'tabindex', '0');
syncAttr(h, 'aria-controls', panel ? panel.id : null);
syncAttr(h, 'aria-expanded', pillExpanded(label) ? 'true' : 'false');
widgets.syncAttr(h, 'role', 'button');
widgets.syncAttr(h, 'tabindex', '0');
widgets.syncAttr(h, 'aria-controls', panel ? panel.id : null);
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 */
syncAttr(label, 'aria-hidden', 'true');
* reasoning as the aria-hidden on svgIcon()'s output, fs-widgets.js */
widgets.syncAttr(label, 'aria-hidden', 'true');
if (h.dataset.fsWired) return;
h.dataset.fsWired = '1';
@@ -112,22 +99,18 @@ function wireDisclosure(sec) {
/* a click landing on the pill itself already ran index.js's own handler; forwarding here
* too would toggle the card twice */
if (ev.target.closest?.('[data-indicator="poll-status"]') !== label) label.click();
syncAttr(h, 'aria-expanded', pillExpanded(label) ? 'true' : 'false');
widgets.syncAttr(h, 'aria-expanded', pillExpanded(label) ? 'true' : 'false');
});
/* the pill is an <a>-less <span>, so neither key is native here — contrast
* fs-widgets.js's wireSpaceKey, written for an <a role="button">, which gets Enter for free
* and needs only Space added */
h.addEventListener('keydown', (ev) => {
if (ev.key !== 'Enter' && ev.key !== ' ' && ev.key !== 'Spacebar') return;
ev.preventDefault();
label.click();
});
* menu-footstrap.js's wireSpaceKey, written for an <a role="button">, which gets Enter for
* free and needs only Space added */
widgets.wireActivate(h, () => label.click());
}
/* A `.cbi-section` LuCI still renders when a stock include has nothing to show this tick: title
* "-", the poll pill its only content, 72px tall on the live Overview — seventh there (a fixture is
* not proof of position: docs/playground.html puts it first). Real and cosmetic, so it is
* suppressed rather than left as a rung in the tab order with nothing behind its own name. */
* not proof of position). Real and cosmetic, so it is suppressed rather than left as a rung in the
* tab order with nothing behind its own name. */
function hideEmptyCard(sec) {
if (sectionTitle(sec) === '-') sec.classList.add('fs-ovl-empty');
}
@@ -165,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;
@@ -216,15 +199,13 @@ function arrange() {
_wrapEl = wrap;
}
/* Stock sections render async and repaint every poll, so watch #view and re-run arrange(),
* coalesced and one observer per #view node. The SPA router may replace #view between visits, so
* re-attach when the observed node is no longer the current one — a singleton bound to the first
* #view would watch a detached tree and the grid would never apply again. */
let _observer = null, _observedRoot = null, _routeObserver = null;
/* Stock sections render async and repaint every poll, so watch #maincontent and re-run arrange(),
* coalesced. #maincontent (unlike #view) outlives every SPA swap, so one observer, attached once,
* is never stale and never needs to be re-attached. */
let _observer = null, _routeObserver = null;
function stopWatch() {
if (_observer) _observer.disconnect();
_observer = null;
_observedRoot = null;
_wrapEl = null; /* the grid belongs to the #view we are leaving */
}
function watch() {
@@ -234,16 +215,12 @@ function watch() {
* first — so the observer bound here read `isConnected: false` after one round trip and the
* grid stopped being re-arranged on every poll tick, silently, until the next full load. The
* shell's column outlives every swap. Same fault, same fix as fs-appearance.js. */
const root = document.getElementById('maincontent') || view;
if (_observer && _observedRoot !== root)
stopWatch();
const root = document.getElementById('maincontent');
arrange();
/* a chrome module is alive on every page, so without the route check an observer would attach
* to #view on, say, the firewall page and re-run arrange() for every table mutation */
if (_observer || !view ||
(document.body.getAttribute('data-page') || '') !== 'admin-status-overview')
/* the caller (onOverview(), via wire()'s data-page observer) already confirmed the overview
* route before reaching here; !view alone covers the mid-navigation race above */
if (_observer || !view)
return;
_observedRoot = root;
/* one arrange() per frame, however many mutations a poll tick delivers (fit.frame — the
* theme's shared coalescer, fs-fit.js) */
_observer = new MutationObserver(fit.frame(arrange));
@@ -1,14 +1,13 @@
'use strict';
'require baseclass';
'require rpc';
'require fs-fit as fit';
/* The Appearance axes this file owns; the controls that present them are fs-appearance.js. The
* 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
@@ -44,13 +43,15 @@ function lsGetArr(k) {
}
/* the router-wide defaults the server stamped (head.ut), read at runtime so current*() reports the
* effective default when this browser has no localStorage */
function sd(k) { try { return (window.__fsSD || {})[k]; } catch (e) { return undefined; } }
* effective default when this browser has no localStorage. No try/catch: unlike localStorage,
* reading or writing a plain object property cannot throw, and window.__fsSD is always one (head.ut
* stamps it as an object literal). */
function sd(k) { return (window.__fsSD || {})[k]; }
/* …and the write back: an applier that persists to the router must update the blob the server
* stamped, or current*() keeps reporting the old router default until the next full load and
* matchesSavedDefault() lies about whether anything is left to save */
function setSD(field, val) { try { (window.__fsSD = window.__fsSD || {})[field] = val; } catch (e) {} }
function setSD(field, val) { (window.__fsSD = window.__fsSD || {})[field] = val; }
/* ---- every axis owns its ROUTER DEFAULT, and nothing else may restate it ----
* `def()` is the sd() branch of current() alone: the effective value with no localStorage. Exposed
@@ -65,7 +66,6 @@ function currentMode() {
const s = lsGet('fs-darkmode');
if (s === 'true') return 'dark';
if (s === 'false') return 'light';
if (s === 'auto') return 'auto';
if (s === null) return modeDefault();
return 'auto';
}
@@ -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');
@@ -169,18 +168,11 @@ function paintThemeColor() {
}
function watchThemeColor() {
let queued = false;
const paint = () => {
queued = false;
paintThemeColor();
};
const schedule = () => {
if (queued) return;
queued = true;
window.requestAnimationFrame(paint);
};
paintThemeColor();
new MutationObserver(schedule).observe(document.documentElement,
/* fit.frame() is the same next-frame coalescer every geometry fitter schedules through: the
* tint and strength sliders write on every input event, and getComputedStyle forces style
* resolution, so a dozen mutations in one frame must still repaint the meta tag once. */
new MutationObserver(fit.frame(paintThemeColor)).observe(document.documentElement,
{ attributes: true, attributeFilter: [ 'style', 'class', 'data-darkmode', 'data-palette', 'data-wallpaper' ] });
}
@@ -192,28 +184,6 @@ _mqDark.addEventListener('change', () => {
stampDark(root, intendedDark());
}
});
/* Corner radius: the card radius (0–20px) as an inline --fs-radius-base on :root, from which
* 02-tokens derives every other radius. head.ut pre-paints it and tools/axes.mjs holds JS/CSS/head
* to this one number, hence the named const. */
/* ---- the four axis shapes, each written once ----
*
* Sixteen axes are four shapes, so the shape lives in a factory and each instance is one line:
* enumAxis (pattern ink), colorAxis (tint, accent, good, warn, danger), surfaceAxis (cards,
* controls, bar, borders), propAxis (rounding, tint strength, photo dim, pattern size, pattern
* strength, content width). Same contract throughout: `current()` is localStorage ?? def(), `def()` is the router
* default alone, `apply()` stores the choice explicitly. None use `this` — every export is a
* detached method reference, so a `this` here would throw on the first call.
*
* Each factory takes its localStorage key as the first argument, and tools/axes.mjs matches the
* call by its literal args: an axis built by a factory has no lsGet('fs-…') call site for the gate
* to find.
*
* The remaining axes stay separate, each with a quirk a shared table would need an option for:
* `mode` stores a value it does not apply and owns an MQL listener, `layout` reads the attribute,
* `wallpaper` and `density` are three-valued, `palette` outgrew the two-value shape when the third
* one landed, `autoCollapse` has no :root attribute. */
/* An axis whose values are a list, with one of them stamped as nothing.
*
* `values` are the names that become `attr="<name>"`; `dflt` is the one that leaves :root bare, and
@@ -235,7 +205,6 @@ function listAxis(key, attr, values, dflt, after) {
current() {
const s = lsGet(key);
if (ok(s)) return s;
if (s === dflt) return dflt;
if (s === null) return def();
return dflt; /* a stray value reads as the built-in default */
},
@@ -252,42 +221,21 @@ 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);
}
/* A colour axis — Tint, Accent and the three status colours are one axis pointed at five tokens:
* same validation, same "0 is off", same ordering rule (set the custom property BEFORE the
* attribute, or a fresh load paints one frame in the previous colour).
*
* A value is one of three things, and the attribute says which (03-palettes.css matches on it):
*
* 0 off — no attribute, the palette as it shipped
* 1–360 a HUE: CSS rotates the palette's own colour through oklch(from … l c H), so
* lightness, chroma and every contrast margin stay the palette's
* '#rrggbb' a COLOUR, stamped inline on :root as the live token; the ink over it is derived
* from its lightness in CSS
*
* Both live in one localStorage key rather than a colour key beside a hue key: two keys would need
* a third to say which is in effect, and that third is the one a pre-paint script forgets.
* `hueProp` carries the degrees, `colorProp` the live token a hex value overwrites; each mode
* clears the other's property, so the two can never both be half-applied. */
const DENSITIES = [ 'compact', 'large' ]; /* the two non-default values; 'normal' = bare :root */
const DENSITY = listAxis('fs-density', 'data-density', DENSITIES, 'normal', () => fit.schedule());
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 page — 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. */
/* Background-tint axis: the canvas the cards float on (--fs-bg), so a whole install reads as one
* colour and a tab or a screenshot says which router it belongs to. Cards, chrome and the status
* colours keep the palette's values — the cue colours the paper, not the UI. On a hue it is mixed
* in CSS (03-palettes.css explains why that stays contrast-safe at every angle); on a hex it IS the
* canvas. 0 is off rather than red, a hue wheel wrapping, so one end of the range is free. */
function currentLayout() {
return document.documentElement.getAttribute('data-layout') === 'top' ? 'top' : 'sidebar';
}
@@ -342,23 +290,6 @@ function currentRail() {
return document.documentElement.getAttribute('data-rail') === 'true';
}
/* ---- Save to router: write the current effective axes to /etc/config/footstrap ----
* The scoped rpcd ACL (config 'footstrap' only) lets the admin's session set and commit those
* options; rpcd validates the config/section/option names, so no value reaches a shell. The server
* reads them back on the next load and head.ut's sanitiser clamps each before it becomes
* window.__fsSD.
*
* snapshotAxes() reads the effective values, which already fold in this browser's localStorage, so
* Save captures what the user sees. It does not touch localStorage: this browser keeps overriding,
* and the saved default is for other devices. resetToSaved() drops this browser back onto it. */
/* Every saved axis's localStorage key: what Save-as-default clears and what a reset walks.
*
* Tried as one table of [key, field, def] with the resolved defaults derived from it: correct,
* and 188 B larger after minification — three lists of short literals compress better than
* twenty-one rows of data, because a function name is mangled and a row is not. The copies are
* held together by tools/axes.mjs instead, which reads snapshotAxes()'s body and holds every
* field against header.ut's FS_AXES. */
return baseclass.extend({
/* the storage wrappers and the router-default reader: fs-axes.js is built on these */
lsGet, lsSet, lsDel, lsGetArr, storageBroken, sd, setSD,
@@ -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. */
@@ -43,9 +43,9 @@ const _viewIntervals = (window.__fsViewIntervals || (window.__fsViewIntervals =
* keyed by that first id and the entry carries `live`, the id armed right now (null while
* paused), so a view holding its handle can still stop its own poller after a trip through a
* hidden tab. The arguments are kept because a `setInterval` id carries none of them back. */
window.setInterval = function (fn, ms) {
window.setInterval = function (fn, ms, ...rest) {
const id = _si.apply(window, arguments);
_viewIntervals.set(id, { fn, ms, rest: Array.prototype.slice.call(arguments, 2), live: id });
_viewIntervals.set(id, { fn, ms, rest, live: id });
return id;
};
window.clearInterval = function (id) {
@@ -102,28 +102,19 @@ const _viewIntervals = (window.__fsViewIntervals || (window.__fsViewIntervals =
*
* `L.Poll.timer` is that id, and it is private state: `add`/`remove`/`start`/`stop`/`active` are the
* documented surface, and the whole `L.Poll` alias is already deprecated (`'require poll'` replaces
* it, but no supported release ships poll.js yet). Read blind, a renamed field would make LuCI's
* tick look like a view's — cleared on the next navigation, every poll on every later page silently
* dead. So a missing field is a reason to do nothing, once, loudly.
*
* Asked through the documented half first: `active()` says whether the tick is running, and `timer`
* is deleted by `stop()`, so an absent field is the ordinary "nothing to protect" case. The anomaly
* worth reporting is the pair disagreeing — a tick running while the id it runs on has no name we
* know.
*
* The alias itself is guarded for the same reason: the sweep runs inside the staged render, and a
* TypeError there would leave every click showing the previous page's content under the new page's
* title. */
* it, but no supported release ships poll.js yet). `active()` below is called unguarded: this
* caller runs from the visibilitychange listener at module eval (:69-81), before CONTRACT_FNS ever
* checks anything, so what backs it is the floor, not the boot contract — `L.Poll.active` is
* unconditional in every 24.10+ luci.js. Read blind, a renamed `timer` field would make LuCI's tick
* look like a view's — cleared on the next navigation, every poll on every later page silently
* dead. So a missing field is a reason to do nothing, once, loudly: `active()` says whether the
* tick is running, and `timer` is deleted by `stop()`, so an absent field is the ordinary "nothing
* to protect" case. The anomaly worth reporting is the pair disagreeing — a tick running while the
* id it runs on has no name we know. */
/* -> the tick's id; null when LuCI is not polling; false when the two cannot be told apart, which
* every caller reads as "leave every interval alone" */
function pollTickId() {
if (!L.Poll) {
warnPollUnreadable('footstrap: L.Poll is gone from this luci-base, so LuCI\'s own tick cannot be '
+ 'told apart from a view\'s timers — leaving view intervals alone. fs-router.js needs '
+ 'updating for this luci-base.');
return false;
}
const running = (typeof L.Poll.active === 'function') ? L.Poll.active() : (L.Poll.timer != null);
const running = L.Poll.active();
if (running && L.Poll.timer == null) {
warnPollUnreadable('footstrap: LuCI is polling but L.Poll.timer is not readable — leaving view '
+ 'intervals alone rather than risking its tick. fs-router.js needs updating for this '
@@ -138,6 +129,13 @@ function clearViewIntervals() {
/* Map, not Set: the key is the timer id and the value is what it takes to re-arm it */
_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 its only caller. */
function hidePollIndicatorIfEmpty() {
if (!(L.Poll && L.Poll.queue && L.Poll.queue.length === 0)) return;
try { ui.hideIndicator('poll-status'); }
catch (e) { console.error('footstrap: hideIndicator threw on the poll-status teardown', e); }
}
/* one line per document: this runs on every navigation, and a router that cannot read L.Poll
* cannot read it on the next click either */
let _pollWarned = false;
@@ -198,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.
@@ -328,17 +325,69 @@ function watchSession() {
* which also takes it out of the live tree. Public API only; no reaching into `dom.registry`. */
function discard(el) {
try {
const dom = window.L ? window.L.dom : null;
if (!dom || typeof dom.content !== 'function') { el.remove(); return; }
const bin = document.createElement('div');
bin.appendChild(el);
dom.content(bin, null);
window.L.dom.content(bin, null);
}
catch (e) {
el.remove();
}
}
/* ---- a page that parks nodes on <body> makes the next navigation a full load ----
*
* 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. 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 */
switch (el.nodeName) {
case 'SCRIPT': case 'STYLE': case 'LINK': case 'TEMPLATE': case 'NOSCRIPT': case 'META':
return false;
}
/* 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 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;
/* stock LuCI's own ui.js parents these to <body> at its own __init__ and keeps them for the life
* of the document: #modal_overlay (showModal's host) and the shared .cbi-tooltip */
if (el.id === 'modal_overlay') return false;
if (el.classList.contains('cbi-tooltip')) return false;
/* ui.js's handleDownload() appends a hidden `<a download>`, clicks it once and revokes its object
* URL, but never removes the element itself. Every app that downloads the same way (wireguard,
* filemanager, snmpd, banip/adblock's feed export) calls a.remove() of its own accord and is
* pruned by bodyLittered() below before ever reaching this function. */
if (el.nodeName === 'A' && el.hasAttribute('download') && el.style.display === 'none') return false;
return true;
}
/* -> true iff the document holds a stray body node right now. A recording only ever grows — the
* recorder cannot know a node was later removed or moved inside #view — so this is also the one
* place that prunes it: an entry whose element is no longer a direct child of <body> is dropped for
* good, or the list would grow for the life of the document and re-judge nodes nobody can reach. */
function bodyLittered() {
const adds = window.__fsBodyAdds;
if (!adds || !adds.length) return false;
let littered = false;
for (let i = adds.length - 1; i >= 0; i--) {
if (!document.body || adds[i].parentNode !== document.body) { adds.splice(i, 1); continue; }
if (strayBodyNode(adds[i])) littered = true;
}
return littered;
}
let _wired = false;
/* the pathname whose view is currently rendered; popstate compares against it to tell a real
* navigation from a fragment change */
@@ -438,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
@@ -469,7 +518,7 @@ function restoreScroll(pos, gen) {
stop();
};
/* the keys that scroll, and only those: typing in a field must not cancel anything */
const SCROLL_KEYS = new Set([ 'PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowDown', ' ', 'Spacebar' ]);
const SCROLL_KEYS = new Set([ 'PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowDown', ' ' ]);
const onKey = (ev) => { if (SCROLL_KEYS.has(ev.key)) stop(); };
const opts = { passive: true, capture: true };
function off() {
@@ -774,11 +823,7 @@ function commitStage(stage, contentHost) {
if (contentHost) contentHost.setAttribute('data-page', page);
}
const nodes = Array.from(stage.view.childNodes);
const dom = window.L ? window.L.dom : null;
if (live && dom && typeof dom.content === 'function')
dom.content(live, nodes);
else if (live)
live.replaceChildren(...nodes);
window.L.dom.content(live, nodes);
dropStage(stage);
}
@@ -792,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
@@ -892,6 +937,10 @@ 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 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
* correctly is the login form, and only a real navigation gets there (watchSession) */
if (_expired) return false;
@@ -952,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.
@@ -1002,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
@@ -1095,6 +1144,11 @@ function navigate(pathname, push, kbd) {
if (L.Poll && L.Poll.queue) {
L.Poll.queue.length = 0;
L.Poll.stop();
/* The "Refreshing"/"Paused" pill's own teardown rides this `stop()`'s `poll-stop` event —
* 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. */
L.Poll.start();
}
/* kill the outgoing view's plain setInterval pollers too, as a full load would; L.Poll's own
@@ -1249,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
@@ -1379,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;
@@ -1401,24 +1454,21 @@ function wireRouter() {
});
}
/* ---- the poll indicator must not outlive the poll ----
/* ---- the poll-status pill must not outlive the poll it reports on ----
*
* LuCI shows the "Refreshing" pill on `poll-start`, flips it to "Paused" on `poll-stop` and never
* hides it again (core calls ui.hideIndicator() only for `uci-changes`). That is invisible on a full
* load, since Poll.start() dispatches `poll-start` only for a non-empty queue — but this router
* flushes the queue and calls stop() on every navigation, so walking from a polled page to an
* unpolled one leaves a "Paused" pill reporting on a poll that does not exist. The pill exists iff
* there is something to poll. Registered at module eval, i.e. after luci.js's own listener, so this
* runs second. */
* 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', () => {
if (L.Poll && L.Poll.queue && L.Poll.queue.length === 0) {
try { ui.hideIndicator('poll-status'); }
catch (e) { console.error('footstrap: hideIndicator threw on poll-stop', e); }
}
queueMicrotask(hidePollIndicatorIfEmpty);
});
/* At module eval, like the listener above: the session can die during the first view's own data
* calls, before anything has called wire(), and an interceptor registered later never sees it. */
/* At module eval: the session can die during the first view's own data calls, before anything has
* called wire(), and an interceptor registered later never sees it. */
watchSession();
/* Pause LuCI's 1 s poll loop while the tab is hidden: LuCI has no visibilitychange handler, so an
@@ -1452,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); }
@@ -1462,11 +1511,6 @@ 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 */
/* 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. */
/* 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. */
@@ -99,48 +99,6 @@ function index() {
return _index;
}
/* ---- extra sources -------------------------------------------------------
*
* An optional package can add rows to the same list — one indexes the SECTION titles inside each
* page, so "footstrap" finds System -> Appearance. A source hands over entries in the shape
* buildIndex() produces and nothing else: the matching, the ranking and the rendering stay here,
* or two lists would disagree about what a hit is. Two fields are the source's alone: `onTake`,
* called when the row is chosen, and `key`, what the recents list stores it under when its `path`
* is not its own (see keyOf).
*
* Registration is a GLOBAL ARRAY, not an export a package requires. This module is fetched on the
* first gesture and most sessions never make it; a package that had to `require` it to register
* would pull it onto every page and pay its 4.5 KB for a palette nobody opened. Pushing a function
* onto `window.__fsSearchSources` costs the package nothing and names no one in either direction.
*
* `window.__fsSearchGen` is how a source says its data grew — a harvester fills in over a session
* — and the stamp below is what rebuilds the pool when it does. */
const _sources = [];
let _pool = null, _stamp = -1;
function globalSources() {
return Array.isArray(window.__fsSearchSources) ? window.__fsSearchSources : [];
}
function addSource(fn) {
_sources.push(fn);
_pool = null;
}
function refresh() {
_pool = null;
}
function pool() {
const all = _sources.concat(globalSources());
const stamp = all.length + (window.__fsSearchGen || 0);
if (_pool && stamp === _stamp) return _pool;
_stamp = stamp;
_pool = all.reduce((rows, fn) => {
try { return rows.concat(fn() || []); }
catch (e) { console.error('footstrap: a search source threw', e); return rows; }
}, index().slice());
return _pool;
}
/* ---- matching ----------------------------------------------------------- */
/* Every whitespace-separated token must hit something: a second word means AND. Deliberately not
@@ -160,7 +118,7 @@ function search(q, limit) {
if (!toks.length) return [];
const hits = [];
for (const e of pool()) {
for (const e of index()) {
let sum = 0;
for (const tok of toks) {
const s = tokenScore(e, tok);
@@ -179,31 +137,21 @@ function search(q, limit) {
/* What the palette shows before anything is typed: an admin lives in three or four pages, so the
* empty state is its most-used view.
*
* Only the KEY is stored, never the title — the title is resolved through the pool on every
* render, so it follows the UI language and a row whose package went away drops out instead of
* lingering as a dead row. A page's key is its menu path; a row from a source carries its own
* `key`, because a section has no dispatcher node and therefore no path that is only its own —
* the sections source keys one `admin/system/system#Footstrap`, the page it is on plus its own
* heading. */
* Only the PATH is stored, never the title — the title is resolved through the index on every
* render, so it follows the UI language and a page an ACL change hides drops out instead of
* lingering as a dead row. RECENT_KEY is the one name menu-footstrap-common.js's remember() also
* writes; the two modules stay decoupled (see there), so the string is the only thing shared. */
const RECENT_KEY = 'fs-recent';
const RECENT_MAX = 8;
/* the string a row is remembered under, and the one menu-footstrap-common's remember() writes */
function keyOf(e) {
return e.key || e.path;
}
/* The list is WRITTEN by menu-footstrap-common.js, which is on every page — this module is not any
* more, and a palette that only loads when it is opened cannot be what records where the admin has
* been. Read here, at open time, so it is always current. `prefs.lsGetArr` owns the parse, the
* corruption guard and the Array check; only the "these are keys" filter belongs here. */
function recentEntries() {
const recent = prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string');
/* pool(), not index(): a section is recalled exactly as a page is. Against the index alone a
* section key resolved to nothing and the row silently vanished, so taking "Footstrap" left
* only "System" in the list — the page path is all either row carries. */
const byKey = new Map(pool().map((e) => [ keyOf(e), e ]));
return recent.map((k) => byKey.get(k)).filter(Boolean).slice(0, RECENT_MAX);
const byPath = new Map(index().map((e) => [ e.path, e ]));
return recent.map((k) => byPath.get(k)).filter(Boolean).slice(0, RECENT_MAX);
}
/* ---- the palette -------------------------------------------------------- */
@@ -311,16 +259,8 @@ function build() {
e.trail.length ? E('span', { 'class': 'fs-search-opt-path' }, [ e.trail.join(' › ') ]) : ''
]);
/* close before the click reaches the router, which re-renders the chrome underneath;
* no focus return, the user is going elsewhere.
*
* `onTake` is how a row from an extra source finishes the job the href cannot: a
* section row's href can only reach the PAGE, so the source that produced it opens the
* tab and scrolls to the section itself. It fires for a click and for Enter alike —
* Enter synthesises this very click. */
a.addEventListener('click', () => {
close(false);
if (typeof e.onTake === 'function') e.onTake();
});
* no focus return, the user is going elsewhere */
a.addEventListener('click', () => close(false));
a.addEventListener('pointermove', () => { if (at !== i) setActive(i); });
list.appendChild(a);
return a;
@@ -421,7 +361,5 @@ function openPalette() {
}
return baseclass.extend({
open: openPalette,
/* the seam an optional package registers through; see addSource() */
addSource, refresh
open: openPalette
});
@@ -5,6 +5,7 @@
'require fs-fit as fit';
/* for the content column's width without a layout read — see the mid-scroll branch in fitTables */
'require fs-chrome as chrome';
'require fs-widgets as widgets';
/* Theme plain LuCI <select> fields (ui.Select, widget:'select') by rendering a styled
* cbi-dropdown beside them — a native <select> popup cannot be CSS-styled.
@@ -19,14 +20,14 @@
function readChoices(sel) {
const choices = {};
Array.prototype.forEach.call(sel.options, (o) => { choices[o.value] = o.textContent; });
for (const o of sel.options) choices[o.value] = o.textContent;
return choices;
}
/* cheap identity of the option list, to detect a script rebuilding it
* (select.replaceChildren, dependency-driven re-population, …) */
function choicesKey(sel) {
return Array.prototype.map.call(sel.options, (o) => o.value + '\u0000' + o.textContent).join('\u0001');
return Array.from(sel.options, (o) => o.value + '\u0000' + o.textContent).join('\u0001');
}
/* Undo enhance(): drop the widget, unhide the select and cut every listener enhance() installed.
@@ -36,8 +37,7 @@ function choicesKey(sel) {
* anonymous listener. */
function teardown(sel) {
if (sel._fsAbort) sel._fsAbort.abort();
if (sel._fsNode && sel._fsNode.parentNode)
sel._fsNode.parentNode.removeChild(sel._fsNode);
sel._fsNode?.remove();
delete sel.dataset.fsSelect;
sel._fsDd = sel._fsNode = sel._fsKey = sel._fsAbort = null;
sel.removeAttribute('aria-hidden');
@@ -132,7 +132,7 @@ function enhance(sel) {
sel._fsAbort = ac;
/* after the select: it must stay frameEl.firstChild for ui.Select to read its value on save */
sel.parentNode.insertBefore(node, sel.nextSibling);
sel.after(node);
/* stops our own dd->sel dispatch from echoing back through the sel->dd listener */
let syncing = false;
@@ -184,11 +184,12 @@ function enhance(sel) {
* is the same fact the CSS uses. The flag flips AFTER the content is written, so this is asked per
* PASS rather than fixed at load, and fs-fit.js watches that class. */
const ROOTS = [ '#view', '#modal_overlay' ];
const inRoots = (sel, roots) => roots.map((r) => `${r} ${sel}`).join(', ');
const liveRoots = () => (document.body.classList.contains('modal-overlay-active') ? ROOTS : ROOTS.slice(0, 1));
/* every call passes liveRoots(); folded in here rather than threaded as a parameter */
const inRoots = (sel) => liveRoots().map((r) => `${r} ${sel}`).join(', ');
const foreignTables = () => inRoots('.table:not(.cbi-section-table)', liveRoots()) + ', ' +
inRoots('table:not(.table):not(.cbi-section-table)', liveRoots());
const foreignTables = () => inRoots('.table:not(.cbi-section-table)') + ', ' +
inRoots('table:not(.table):not(.cbi-section-table)');
/* The fourth header markup, and the one only a foreign table produces: `<table><tr><th>…`, with no
* `<thead>` for the parser to imply and none of LuCI's class names.
@@ -331,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
@@ -341,6 +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. */
/* 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' ],
@@ -356,23 +360,12 @@ const TABLE_ROLE_TAGS = [
[ 'tfoot', 'rowgroup' ],
];
/* idempotent, the same idiom as menu-footstrap-common.js's fsSyncAttr: a node that already carries
* the right role is a no-op read, so a poll tick re-rendering the same table on every tick touches
* no DOM and wakes no attribute observer. Not imported for one line — this file has none of that
* module's other exports to justify the dependency. */
function setRole(el, role) {
if (el.getAttribute('role') !== role) el.setAttribute('role', role);
}
/* A hidden row/cell (`.tr.cbi-section-table-descr`, `.td.hide-xs`/`.hide-sm` under `.fs-stacked`/
* `.fs-drop-xs`) drops out of the accessibility tree on `display: none` alone — the role written
* here changes nothing about that, since a role is inert on a box the engine never generates. */
function roleTables() {
TABLE_ROLE_CLASSES.forEach(([ sel, role ]) => {
document.querySelectorAll(inRoots(sel, liveRoots())).forEach((el) => setRole(el, role));
});
TABLE_ROLE_TAGS.forEach(([ sel, role ]) => {
document.querySelectorAll(inRoots(sel, liveRoots())).forEach((el) => setRole(el, role));
[ ...TABLE_ROLE_CLASSES, ...TABLE_ROLE_TAGS ].forEach(([ sel, role ]) => {
document.querySelectorAll(inRoots(sel)).forEach((el) => widgets.syncAttr(el, 'role', role));
});
}
@@ -388,7 +381,7 @@ function roleTables() {
* NOT be measured: its rows hold widgets, and a widget bakes in the width of the layout it was laid
* out in, so un-collapsing it to read it changes what is read — the firewall zone table then
* reported needing 1747px against a real 1190px and overflowed its section by 557px. */
const stackables = () => inRoots('.table.fs-dt', liveRoots());
const stackables = () => inRoots('.table.fs-dt');
/* "Too cramped to be a table any more" — a design judgement, and the only threshold the fit
* decision takes on trust rather than measuring. A four-column table of short values still fits at
@@ -441,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
@@ -527,7 +520,7 @@ function fitTables() {
/* mid-scroll, only the tables with no answer yet: one that has an answer needs nothing, one
* that does not is held out of the layout by the stylesheet and cannot wait. Neither branch
* reads layout while `scrolling()` is true. */
const sel = fit.scrolling() ? inRoots('.table.fs-dt:not(.fs-fitted)', liveRoots()) : stackables();
const sel = fit.scrolling() ? inRoots('.table.fs-dt:not(.fs-fitted)') : stackables();
document.querySelectorAll(sel).forEach((t) => {
/* While the reader scrolls this pass only WRITES: every layout read here forces a
* synchronous layout, and a poll tick can land mid-flick, so on a phone with ten tables that
@@ -694,7 +687,7 @@ function fitTables() {
* Ping button was simply unreachable past `.fs-main`'s clip. The shape is not that page's, and the
* width that matters is the room rather than the viewport, so this measures instead of naming a
* page. */
const scrollables = () => inRoots('.table:not(.fs-dt):not(.cbi-section-table)', liveRoots());
const scrollables = () => inRoots('.table:not(.fs-dt):not(.cbi-section-table)');
const HOLDS_CONTROLS = '.cbi-dropdown, .cbi-dynlist, .cbi-tooltip-container, .cbi-progressbar, select, input, textarea, [data-tooltip]';
/* ---- making a scroll box reachable, and handing the markup back as it was found ----
@@ -795,7 +788,7 @@ function fitScrollables() {
function unpinActionColumn() {
/* a layout read here lands mid-flick once per poll tick — see fitTables() */
if (fit.scrolling()) { fit.deferMeasurement(); return; }
for (const t of document.querySelectorAll(inRoots('.table.cbi-section-table', liveRoots()))) {
for (const t of document.querySelectorAll(inRoots('.table.cbi-section-table'))) {
if (!t.querySelector('.cbi-section-actions')) continue;
/* Claim upstream's resize hook, which under SPA navigation is a leak:
* stabilizeActionColumnWidth attaches a `resize` listener once per TABLE ELEMENT, guarded by
@@ -864,7 +857,10 @@ const TYPEAHEAD_RESET_MS = 1000;
let _taBuf = '', _taTimer = null, _taLast = null;
function typeaheadItems(sb) {
const ul = sb.querySelector('ul.dropdown') || sb.querySelector('ul');
/* sb is always `.cbi-dropdown[open]` here, and stock ui.js sets that attribute and adds
* `ul.dropdown` in the same openDropdown() call (openwrt/luci ui.js), so the two never
* disagree — verified on openwrt-24.10. */
const ul = sb.querySelector('ul.dropdown');
if (!ul) return [];
return [...ul.children].filter((li) =>
li.tagName === 'LI' &&
@@ -923,10 +919,7 @@ function wireTypeahead() {
/* the widget's own highlighter: adds .focus, scrolls the item into view and focuses it,
* so Enter (ui.Dropdown's handler) commits exactly what is highlighted */
const inst = dom.findClassInstance(sb);
if (inst && typeof inst.setFocus === 'function')
inst.setFocus(sb, hit, true);
else
hit.focus();
if (inst) inst.setFocus(sb, hit, true);
ev.preventDefault();
ev.stopPropagation();
@@ -301,8 +301,7 @@ function documentCarries(path) {
* original is deliberately not one. */
function documentPoisoned() {
const names = themeNames();
return Array.prototype.some.call(
document.querySelectorAll(VIEW_SHEETS),
return Array.from(document.querySelectorAll(VIEW_SHEETS)).some(
(el) => outlivesPage(el)
&& (!names || (invasiveSheet(el, names) && !_owner.has(el) && !_silenced.has(el))));
}
@@ -313,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:
@@ -417,7 +416,7 @@ function fenceImported(styleEl, names, until) {
/* what a sheet IS, as text: the rules that are applying, not the markup that may have produced
* them. Serialised only to compare, never re-parsed. */
const serializeRules = (rules) => Array.prototype.map.call(rules, (r) => r.cssText).join('\n');
const serializeRules = (rules) => Array.from(rules, (r) => r.cssText).join('\n');
/* ---- a <style>'s textContent is NOT its sheet ----
*
@@ -536,13 +535,12 @@ function ownerKey() {
return (_ownerHint !== null) ? _ownerHint : currentKey();
}
/* L.env.dispatchpath (ctx.path) is stamped by luci-base's own header.ut before any theme script
* runs, on every page including blank_page — verified on openwrt-24.10, no location.pathname
* fallback needed. */
function currentKey() {
if (_curKey !== null) return _curKey;
const dp = L.env && L.env.dispatchpath;
if (dp && dp.length) return appKey(dp);
/* no env to read (a document that never got the bootstrap): the URL is all there is */
const p = location.pathname.replace(/^.*\/cgi-bin\/luci\/?/, '').replace(/\/+$/, '');
return appKey(p ? p.split('/') : []);
return appKey(L.env.dispatchpath);
}
/* Both halves, for the reason silence() gives: el.disabled is the element's flag, el.sheet.disabled
@@ -1,11 +1,11 @@
'use strict';
'require baseclass';
/* The installed version, shown on the Appearance page with no network call; which version is
/* 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';
@@ -1,9 +1,10 @@
'use strict';
'require baseclass';
/* The theme's UI primitives: the inline-SVG wrapper, the disclosure pair the menu is built on, and
* the colour control behind the Appearance page's colour axes. Nothing here knows what it is used
* for, so the menu and the Appearance page share it without requiring each other. */
/* The theme's UI primitives: the inline-SVG wrapper and two small idioms every module that draws a
* control on its own repeats — an idempotent attribute write and Enter/Space activation for an
* element with no native key handling. Nothing here knows what it is used for, so the menu, the
* chrome and the Overview grid share it without requiring each other. */
/* The one inline-SVG wrapper: every theme icon is a 24x24 stroked outline differing only in path
* data, so stroke width and linecaps are stated once and cannot drift between call sites.
@@ -15,49 +16,27 @@ function svgIcon(body, cls) {
+ body + '</svg>';
}
/* ---- disclosure primitives, shared by the menu ----
* A section header is a W3C-APG disclosure control: an <a role="button"> owning a panel it shows
* and hides. The trigger selector stays a parameter. */
/* Every open and close goes through here so `.open` and aria-expanded cannot disagree: `.open`
* alone tells a sighted user everything and a screen-reader user nothing. `linkSel` is the
* layout's trigger (the menu's `:scope > a`). */
function setOpen(li, on, linkSel) {
li.classList.toggle('open', on);
li.querySelector(linkSel)?.setAttribute('aria-expanded', on ? 'true' : 'false');
/* Idempotent attribute write, so a poll tick that finds nothing changed touches no DOM and fires
* no mutation record. `value === null` removes the attribute instead of writing the string
* "null". Three call sites (menu-footstrap-common.js's meter annotator, fs-chrome.js's indicator
* pills, fs-overview.js's card disclosure) carried this same body separately; held here once. */
function syncAttr(el, name, value) {
if (value === null) {
if (el.hasAttribute(name)) el.removeAttribute(name);
} else if (el.getAttribute(name) !== value) {
el.setAttribute(name, value);
}
}
/* An <a role="button"> is given Enter by the browser but not Space, and a disclosure control has
* to answer both. */
function wireSpaceKey(link) {
link.addEventListener('keydown', (ev) => {
if (ev.key !== ' ' && ev.key !== 'Spacebar') return;
/* Enter/Space activation for an element with no native key handling of its own — a `<span>`
* `role="button"`, unlike an `<a role="button">`, gets neither key for free. Calling `activate()`
* rather than `el.click()` directly lets a caller forward the activation to a different element
* (fs-overview.js's card header activates the pill it labels). */
function wireActivate(el, activate) {
el.addEventListener('keydown', (ev) => {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
ev.preventDefault();
link.click();
});
}
/* A click outside closes; WCAG 2.2 SC 1.4.13 also requires a hover/focus panel to be dismissible
* from the keyboard, with focus handed back to the trigger. `when` restricts both to flyout mode,
* where `.open` means "popup panel" — an unfolded accordion must not close on an outside click. */
function wireDismiss(opts) {
const active = () => (opts.when ? opts.when() : true);
document.addEventListener('click', (ev) => {
/* `closest?.`: a document-level listener sees any dispatched click, including one whose
* target is not an Element and has no closest(). The throw would kill this listener for
* the rest of the session. */
if (active() && !ev.target.closest?.(opts.inside))
opts.close();
});
document.addEventListener('keydown', (ev) => {
if (ev.key !== 'Escape' || !active()) return;
const open = document.querySelector(opts.open);
if (!open) return;
const trigger = open.querySelector(opts.trigger);
opts.close();
trigger?.focus();
activate();
});
}
@@ -68,7 +47,6 @@ function wireDismiss(opts) {
return baseclass.extend({
svgIcon,
setOpen,
wireSpaceKey,
wireDismiss
syncAttr,
wireActivate
});
@@ -7,6 +7,7 @@
'require fs-router as router';
'require fs-prefs as prefs';
'require fs-sheets as sheets';
'require fs-widgets as widgets';
/* Page modules: `fs-overview` (Status -> Overview) adds to a stock page rather than owning a
* route, so it is required only there. A `require` pragma would make it a hard dependency —
@@ -52,27 +53,19 @@ 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. */
* 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, or a page path plus the heading of a section inside it
* (`admin/system/system#Footstrap`) — a section has no dispatcher node to name it, and only the
* source that produced the row can build that half. Exported for exactly that: the writer stays
* one function, or the two halves would drift on the cap and the de-duplication. */
/* 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(RECENT_KEY).filter((x) => typeof x === 'string');
prefs.lsSet(RECENT_KEY, JSON.stringify([ key ].concat(recent.filter((p) => p !== key)).slice(0, RECENT_MAX)));
}
/* the page half of a key: what the router can navigate to and what warmRecent() prefetches */
function pageOf(key) {
const h = key.indexOf('#');
return h < 0 ? key : key.slice(0, h);
}
/* ---- warm the pages this admin actually uses ----
*
* The router's per-link prefetch needs a hover, tap or focus first, so a session's first visit to a
@@ -87,10 +80,9 @@ function pageOf(key) {
function warmRecent() {
try { if (navigator.connection && navigator.connection.saveData) return; } catch (e) {}
const here = (L.env.dispatchpath || []).join('/');
/* Keys, not paths: a section key names the page it sits on, and two sections of one page must
* warm it once — the module chain is the page's. */
/* already de-duplicated: remember() drops the key from its old slot before re-adding it */
const keys = prefs.lsGetArr(RECENT_KEY).filter((p) => typeof p === 'string');
const paths = [ ...new Set(keys.map(pageOf)) ].filter((p) => p !== here).slice(0, RECENT_WARM);
const paths = keys.filter((p) => p !== here).slice(0, RECENT_WARM);
if (!paths.length) return;
const go = () => paths.forEach((p) => router.prefetchSegs(p.split('/')));
if (typeof window.requestIdleCallback === 'function')
@@ -100,8 +92,8 @@ function warmRecent() {
}
function wireSearch() {
/* #fs-search-btn is emitted whenever this module loads (search.ut, !blank_page) */
const btn = document.getElementById('fs-search-btn');
if (!btn) return;
const RT = window.L;
/* the page this full load landed on; onNavigate covers the SPA path afterwards */
@@ -139,23 +131,6 @@ function wireSearch() {
});
}
/* ---- optional companion packages ----
*
* header.ut prints `window.__fsPlugins` from `footstrap.settings.plugin`, a list a package writes
* from its own uci-defaults; each entry is a LuCI module name, already whitelisted there. The
* chrome requires each one after everything below is wired — a plugin registers itself through the
* seams the theme exports (`fs-router.onNavigate`, `fs-search.addSource`) and the theme names
* nobody. A plugin that throws costs only itself.
*
* No plugin, no cost: an empty list is the shipped state and this loop does nothing. */
function loadPlugins() {
const RT = window.L;
const names = Array.isArray(window.__fsPlugins) ? window.__fsPlugins : [];
names.forEach((name) => {
RT.require(name).catch((e) => console.error('footstrap: plugin ' + name + ' did not load', e));
});
}
/* warn/danger split for the meter fill (theme/25-progressbar.css): a DECISION, not a measurement —
* 20% of an 84 MiB overlay and 20% of an 8 GiB disk are different news at the same reading, and a
* plain percentage is kept anyway so every meter on the page reads the same way. */
@@ -191,17 +166,6 @@ const FS_METER_INVERTED_DANGER = 100 - FS_METER_DANGER;
const FS_METER_INVERTED = new Set([ _('Total Available'), _('Swap free') ]);
const FS_METER_NEUTRAL = new Set([ _('Buffered'), _('Cached') ]);
/* Write an attribute only when the value actually changes, so a poll tick that reads the same
* numbers back touches no DOM and fires no attribute-mutation observer. `value === null` removes
* the attribute instead of writing the string "null". */
function fsSyncAttr(el, name, value) {
if (value === null) {
if (el.hasAttribute(name)) el.removeAttribute(name);
} else if (el.getAttribute(name) !== value) {
el.setAttribute(name, value);
}
}
/* The meter's name, if the markup already states one — never invented. A `.cbi-value` row's own
* label (the RSSI/RSRP gallery shape) or a key/value table row's first cell (Memory, Storage, CPU
* load on Overview) each stand for the whole row; a bare meter with neither (Software's disk-space
@@ -237,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);
@@ -256,14 +219,14 @@ function annotateMeter(pg) {
const pc = parseMeterPercent(title);
if (pc == null) return;
const level = pc < 0 ? 0 : (pc > 100 ? 100 : pc);
fsSyncAttr(pg, 'role', 'progressbar');
fsSyncAttr(pg, 'aria-valuemin', '0');
fsSyncAttr(pg, 'aria-valuemax', '100');
fsSyncAttr(pg, 'aria-valuenow', String(level));
fsSyncAttr(pg, 'aria-valuetext', title);
widgets.syncAttr(pg, 'role', 'progressbar');
widgets.syncAttr(pg, 'aria-valuemin', '0');
widgets.syncAttr(pg, 'aria-valuemax', '100');
widgets.syncAttr(pg, 'aria-valuenow', String(level));
widgets.syncAttr(pg, 'aria-valuetext', title);
const label = findProgressbarLabel(pg);
const name = label ? label.textContent.trim() : '';
fsSyncAttr(pg, 'aria-label', label ? name : null);
widgets.syncAttr(pg, 'aria-label', label ? name : null);
/* Polarity: an unrecognised bar (a third-party app's own meter included — see the Sets above)
* keeps the plain fill-based rule, on purpose. A wrong red is worse than a missing colour, but a
* MISSING colour on the common case — a used-based fill, which is what an app most often draws —
@@ -278,12 +241,12 @@ function annotateMeter(pg) {
} else {
dataLevel = level >= FS_METER_DANGER ? 'danger' : (level >= FS_METER_WARN ? 'warn' : null);
}
fsSyncAttr(pg, 'data-fs-level', dataLevel);
widgets.syncAttr(pg, 'data-fs-level', dataLevel);
}
/* Every `.cbi-progressbar[title]` under `root` — the markup itself, not who last drew it. Called
* from fs-overview.js's own poll-tick observer (see there for why this file does not run a second
* one); `fsSyncAttr` above makes a re-run over an unchanged bar a no-op read. */
* one); `widgets.syncAttr` above makes a re-run over an unchanged bar a no-op read. */
function annotateMeters(root) {
(root || document).querySelectorAll('.cbi-progressbar[title]').forEach(annotateMeter);
}
@@ -366,7 +329,7 @@ ensureOverviewHelpers();
*
* fs-menutree path <-> menu node, alias/firstchild resolution (a port of dispatcher.uc)
* fs-prefs the Appearance axes and their localStorage
* fs-widgets the inline-SVG wrapper, the disclosure primitives, the colour control
* fs-widgets the inline-SVG wrapper, an idempotent attribute write, Enter/Space activation
* fs-chrome mode menu, section tabs, the rail toggle, the "does it still fit" measurements
* fs-router the SPA client router (docs/spa-router.md)
* fs-sheets the guard against a view's injected CSS repainting every later page
@@ -382,17 +345,10 @@ ensureOverviewHelpers();
* halves (fs-menutree, fs-prefs) are separate modules. */
return baseclass.extend({
/* the seam a companion package writes its own rows into the recents list through; see
* remember() for what a key is */
remember,
/* the seam fs-overview.js calls, on its own already-coalesced poll-tick observer, to annotate
* the meters a stock Status -> Overview include draws with its own local progressbar() */
annotateMeters,
/* module-private otherwise: tests/meter.test.mjs drives the percent parse and the clamp/threshold
* split directly rather than through a live `.cbi-progressbar[title]` walk */
init(renderMainMenu) {
/* First, and outside the promise: a third-party sheet that outranks the chrome is already
* painting (fs-sheets: openclash's `* { margin: 0; padding: 0 }`). Deferring this to
@@ -422,9 +378,6 @@ return baseclass.extend({
wirePageModules();
router.wire();
router.wireVisibility();
/* last: a plugin registers against the parts above, and a broken one must not be able
* to take the chrome with it */
loadPlugins();
/* no sane partial recovery — a throw above loses the menu, the router and the Appearance
* tab together — so this fails loudly rather than silently */
}).catch((e) => console.error('footstrap: chrome init failed', e));
@@ -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 */
@@ -53,10 +53,33 @@ function flyoutMode() {
document.documentElement.hasAttribute('data-narrow');
}
/* The trigger — a bare <a>. widgets.setOpen keeps `.open` and aria-expanded in step. */
/* The trigger — a bare <a>. setOpen() below keeps `.open` and aria-expanded in step. */
const TRIGGER = ':scope > a';
const OPEN_LI = '#topmenu > li.open';
/* ---- disclosure primitives ----
* A section header is a W3C-APG disclosure control: an <a role="button"> owning a panel it shows
* and hides. The one consumer of all three is this file, so they live here rather than behind a
* `linkSel`/`opts` parameter in fs-widgets.js — TRIGGER above is the only trigger selector either
* ever sees. */
/* Every open and close goes through here so `.open` and aria-expanded cannot disagree: `.open`
* alone tells a sighted user everything and a screen-reader user nothing. */
function setOpen(li, on) {
li.classList.toggle('open', on);
li.querySelector(TRIGGER)?.setAttribute('aria-expanded', on ? 'true' : 'false');
}
/* An <a role="button"> is given Enter by the browser but not Space, and a disclosure control has
* to answer both — contrast fs-widgets.js's wireActivate(), written for an element with neither. */
function wireSpaceKey(link) {
link.addEventListener('keydown', (ev) => {
if (ev.key !== ' ') return;
ev.preventDefault();
link.click();
});
}
/* ---- dropdown edge-clamp (the bar, at every width) ----
* A bar panel hangs off its own item (li position:relative, ul left:0 — theme/20-shell.css), so an
* item near the right edge would push its panel past the viewport. The rail flies panels out
@@ -66,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');
}
@@ -97,10 +120,6 @@ function clearClamps() {
document.querySelectorAll('#topmenu ul').forEach((m) => { m.style.left = ''; });
}
function setOpen(li, on) {
widgets.setOpen(li, on, TRIGGER);
}
function closeFlyouts(except) {
document.querySelectorAll(OPEN_LI).forEach((o) => {
if (o !== except) setOpen(o, false);
@@ -139,7 +158,8 @@ function renderMainMenu(tree, url, level) {
const ul = level ? E('ul', {}) : document.querySelector('#topmenu');
const children = ui.menu.getChildren(tree);
if (!ul || children.length === 0 || level > 1)
/* #topmenu is emitted whenever this module loads (header.ut, !blank_page) */
if (children.length === 0 || level > 1)
return E([]);
/* dispatchpath = [mode, section, subsection, …]; sections sit at
@@ -239,7 +259,7 @@ function renderMainMenu(tree, url, level) {
saveOpenSections();
});
widgets.wireSpaceKey(link);
wireSpaceKey(link);
/* hybrid devices: once a real mouse enters, drop the tap-opened panel so hover is
* authoritative and two panels never stack. Guarded on pointerType, since a touch
@@ -265,13 +285,23 @@ return baseclass.extend({
common.init(renderMainMenu);
/* click-outside and Escape close an open flyout, gated on flyoutMode(): outside it
* `.open` means unfolded accordion, which must not fold on a click elsewhere */
widgets.wireDismiss({
when: flyoutMode,
inside: '#topmenu > li.has-sub',
open: OPEN_LI,
trigger: TRIGGER,
close: () => closeFlyouts()
* `.open` means unfolded accordion, which must not fold on a click elsewhere. WCAG 2.2
* SC 1.4.13 also requires a hover/focus panel to be dismissible from the keyboard, with
* focus handed back to the trigger. */
document.addEventListener('click', (ev) => {
/* `closest?.`: a document-level listener sees any dispatched click, including one
* whose target is not an Element and has no closest(). The throw would kill this
* listener for the rest of the session. */
if (flyoutMode() && !ev.target.closest?.('#topmenu > li.has-sub'))
closeFlyouts();
});
document.addEventListener('keydown', (ev) => {
if (ev.key !== 'Escape' || !flyoutMode()) return;
const open = document.querySelector(OPEN_LI);
if (!open) return;
const trigger = open.querySelector(TRIGGER);
closeFlyouts();
trigger?.focus();
});
/* Entering flyout mode folds everything, or a section left open as an accordion