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