update 2026-09-25 19:24:22
marry-jell / merge (push) Canceled after 0s

This commit is contained in:
action committed 2026-09-25 19:24:22 +08:00
1 parent e8f9e08c4a
commit ba0a17863f
145 files changed
+23807 -5960

No files matched your search

@@ -73,37 +73,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 (tools/export-tier.mjs uses the same method). 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 +132,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 +146,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. */
@@ -207,7 +194,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 +207,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 +298,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;
@@ -344,7 +334,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 +345,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'),
@@ -370,11 +360,11 @@ function build() {
* --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 +382,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 +394,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 +407,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 +427,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 +481,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 +515,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 +567,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')
@@ -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: verified-safe residual finding,
* docs/architecture.md "The SVG sanitizer's residual, verified-safe findings". */
for (const n of [ ...doc.childNodes ])
if (n.nodeType === Node.PROCESSING_INSTRUCTION_NODE) { n.remove(); elements++; }
@@ -265,21 +253,9 @@ 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:
* verified-safe residual finding, docs/architecture.md "The SVG sanitizer's residual,
* verified-safe findings". */
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 +272,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: verified-safe residual finding, docs/architecture.md "The SVG
* sanitizer's residual, verified-safe findings". */
if (n === 'style') {
const cleaned = _sanitizeStyleValue(v);
if (cleaned.removed) {
@@ -337,17 +300,6 @@ 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
@@ -490,7 +442,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 +462,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 +490,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')));
@@ -121,7 +121,6 @@ function propAxis(key, sdKey, prop, min, max, dfl, fmt, after) {
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 +133,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 +143,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,25 +193,16 @@ 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
* 1280px. A propAxis like Rounding, pointed at that one token — the range comment lives in
@@ -228,7 +215,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 +242,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 +257,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,26 +275,26 @@ 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
@@ -341,15 +322,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,26 +386,26 @@ 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 ----
@@ -438,31 +419,6 @@ function resetToBuiltin() {
* 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 +427,79 @@ 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 tab 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 exported as the OBJECT itself — `{ current, apply, def? }` — not a flat
* currentX()/applyX() pair per axis: a caller wanting both already had to carry them as a pair
* (fs-appearance.js's colourGroup() takes `{ current, apply }`, built ad hoc from two separate
* lookups before this), and a flat name is one more thing every consumer of THIS FILE has to keep
* spelled the same way fs-axes.js does — 18 axis names doubled to 36 lookups, now 18. `pattern`
* and `loginBg` are not axes (no AXIS_KEYS entry, no router-default `def()`) but the same
* `tokenAsset()` shape, so they export the same way. `tokenOk`, `matchesSavedDefault`,
* `saveAsDefault`, `resetToSaved` and `resetToBuiltin` stay flat: none of them is a
* `{current, apply}` pair. `snapshotAxes` stays module-private: fs-appearance.js, its only would-be
* caller, builds the Appearance form from the per-axis exports above, not from the router-default
* snapshot matchesSavedDefault()/saveAsDefault() read internally.
*
* No flat `currentX`/`applyX` fallback kept: grepped `../luci-app-footstrap-*` (the sibling
* checkouts) and this repo's docs for either spelling — nothing outside fs-appearance.js and
* fs-assets.js, both updated alongside this file, ever called one. */
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 ----
@@ -150,17 +146,16 @@ 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
@@ -401,6 +396,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 +405,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 +423,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 +455,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();
@@ -494,38 +488,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 +520,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);
}
});
@@ -65,23 +65,34 @@ 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 purely for the O(1) "already have this one" check .indexOf() was doing in
* O(n). */
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 +374,28 @@ 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, for tools/scroll-anchor.mjs to read back after a probe run. One factory,
* so the cap and the timestamp format cannot drift between the two. */
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 +405,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 +626,11 @@ return baseclass.extend({
},
scrolling,
/* unmarked, for tools/scroll-anchor.mjs — see `_lateWhy` */
lateWhy: () => _lateWhy,
lateTrail: () => _lateTrail.slice(),
anchorWhy: () => _anchorWhy,
anchorTrail: () => _anchorTrail.slice(),
/* unmarked, for tools/scroll-anchor.mjs — see `makeTrail()` above */
lateWhy: () => _late.last(),
lateTrail: () => _late.entries().slice(),
anchorWhy: () => _anchor.last(),
anchorTrail: () => _anchor.entries().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
@@ -50,23 +49,11 @@ function sectionTitle(sec) {
* 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');
widgets.syncAttr(label, 'aria-hidden', 'true');
if (h.dataset.fsWired) return;
h.dataset.fsWired = '1';
@@ -112,16 +99,12 @@ 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
@@ -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,6 +1,5 @@
'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
@@ -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';
}
@@ -169,18 +169,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 +185,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 +206,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 */
},
@@ -258,22 +228,6 @@ 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,
@@ -283,11 +237,6 @@ const currentDensity = DENSITY.current, applyDensity = DENSITY.apply,
* Density it sets no attribute, so nothing here or in fs-chrome.js needs to read one, and
* currentContentWidth()/applyContentWidth() have no caller outside the Appearance tab — the one
* 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 +291,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,
@@ -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 '
@@ -338,11 +329,9 @@ 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();
@@ -479,7 +468,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() {
@@ -784,11 +773,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);
}
@@ -1434,7 +1419,7 @@ function wireRouter() {
* "Paused" is always shown with `handler: null`), and which of the two ran first here used to decide
* everything — luci.js registers its listener from `setupDOM()`, after an async chain
* (DOMContentLoaded + ui/rpc/form + probeRPCBaseURL), this module at eval, from the inline
* `L.require('menu-footstrap')` in `partials/footer.ut`, so network/cache timing picked the order.
* `L.require('menu-footstrap')` in `footer.ut`, so network/cache timing picked the order.
* Run with this listener first, its hide removed the span before luci.js re-created it for "Paused"
* with no handler, and the next `poll-start` found that span already there and only changed its
* text — clickless for the rest of the document. A microtask runs only once the whole synchronous
@@ -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.
@@ -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. */
/* tools/table-contract.mjs reads these two names and shapes by regex, so a `display` rule on any of
* these classes/tags is checked against a matching role — keep both names, and each entry a
* `'.class'` or `'tag'` string literal, even though roleTables() below now reads them as one list. */
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
@@ -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))));
}
@@ -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,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 tab's colour axes. Nothing here knows what it is used
* for, so the menu and the Appearance tab 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,25 +53,15 @@ 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. */
const RECENT_KEY = 'fs-recent';
const RECENT_MAX = 8;
* and nothing else — the constant itself is named once, in fs-search.js (RECENT_KEY, RECENT_MAX),
* since that module owns the "recently visited" feature; the literal below has to match it exactly. */
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);
const recent = prefs.lsGetArr('fs-recent').filter((x) => typeof x === 'string');
prefs.lsSet('fs-recent', JSON.stringify([ key ].concat(recent.filter((p) => p !== key)).slice(0, 8)));
}
/* ---- warm the pages this admin actually uses ----
@@ -87,10 +78,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. */
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);
/* already de-duplicated: remember() drops the key from its old slot before re-adding it */
const keys = prefs.lsGetArr('fs-recent').filter((p) => typeof p === 'string');
const 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 +90,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 +129,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 +164,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
@@ -256,14 +218,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 +240,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 +328,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,10 +344,6 @@ 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,
@@ -424,9 +382,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));
@@ -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
@@ -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