🌴 Sync 2026-08-05 03:17:09

This commit is contained in:
github-actions[bot] committed 2026-08-05 03:17:09 +08:00
1 parent 3c5b341e92
commit d96dc08674
91 files changed
+18955 -44

No files matched your search

@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-9 -1 100 100">
<!-- The OpenWrt mark, and it stays OpenWrt's on purpose: the tab icon says which DEVICE this
is, not which theme is painting it. What it does not need is the flat cyan tile the mark
used to be pasted on — that tile was the "outdated" part (issue #7), and it fought every
browser's tab strip. Transparent now, so the icon sits in whatever the browser draws.
Colours are the mark's own: cyan arcs, dark-blue ring. On a DARK tab strip that ring is
nearly invisible, so it lightens under prefers-color-scheme — an SVG favicon is a live
document and both Chrome and Firefox honour the query. logo_48.png cannot: it is the
fallback for browsers with no SVG favicon support, and it carries the light variant. -->
<style>
@media (prefers-color-scheme: dark) { .ring { fill: #e6f4fb } }
</style>
<path d="m40.5 50.8a6.6 6.6 0 1 0 0 13.2 6.6 6.6 0 0 0 0-13.2m-40.5-33.9 7 6.8a47.5 47.5 0 0 1 67.2 0l7-6.8a57.2 57.2 0 0 0-81.2 0" fill="#00b5e2"/>
<path d="m12.5 29.2 6.8 7a30 30 0 0 1 42.6 0l6.8-7a39.7 39.7 0 0 0-56.3 0" fill="#00b5e2"/>
<path d="m24.8 41.6 6.8 6.9a12.6 12.6 0 0 1 18 0l6.8-6.9a22.3 22.3 0 0 0-31.6 0" fill="#00b5e2"/>
<path class="ring" d="m64.9 39.7a30.2 30.2 0 1 1-48.7 0l-6.9-7a39.9 39.9 0 1 0 62.5 0z" fill="#002b49"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 B

@@ -0,0 +1,781 @@
'use strict';
'require baseclass';
'require ui';
'require dom';
'require fs-prefs as prefs';
'require fs-widgets as widgets';
'require fs-version as ver';
/* The Appearance CONTROLS: the DOM that presents the axes. It owns no preference — fs-prefs.js
* holds the axes, fs-version.js the version string; this file is the form they are shown in.
*
* WHERE IT LIVES. It used to be a popover hanging off a button in the chrome; it is now a TAB on
* System -> System (admin/system/system), beside General Settings / Logging / Time Synchronization
* / Language and Style — the page an admin already opens to set the things that are not network. The axes had outgrown a floating panel — eighteen of them, nine
* carrying a colour field, a swatch and a contrast readout — and a dialog that has to trap Tab,
* place itself against a viewport edge and stay inside a 320px column is the wrong container for
* that. Keeping BOTH would have meant every axis rendered twice, which is the failure this file's
* own history is made of.
*
* It is appended by a MutationObserver rather than by a route of its own, and that is the same
* boundary fs-overview.js sits on: a THEME may not own a dispatcher node, because a node outlives
* the theme that registered it — switch to another theme and the menu keeps an entry whose view is
* gone. So the theme owns no menu.d and no view; it watches for the stock page and adds one
* section to it, additively, and removes nothing. Off the footstrap theme, or on any other page,
* nothing runs at all.
*
* THE VERSION LINE MAKES NO REQUEST and must not grow one. Which version is INSTALLED is what this
* page answers; which version is available is the package manager's question, and a theme polling a
* release API to re-answer it from a settings page is the wrong shape twice over — it reaches the
* network from a page that has no business doing so, and it reimplements `apk upgrade`. */
/* THE COLOUR PRESETS WERE HERE — eight chips that wrote the Accent axis, each painted in the
* colour it would set. They are gone, and what they were for is not: the request behind them
* (#20, "the blue theme is cool but sometimes you want grey or black") is answered by the Accent
* row itself, which takes any #rrggbb and sits three rows below where the chips used to be. A
* preset only ever wrote that one axis, so the chips were a second, prettier way to do the thing
* the field already does — and they were the one control on this page that looked like nothing
* else in LuCI: a bare row of coloured pills starting at the card's edge rather than at the field
* column, which is what made the tab read as ragged. */
/* Build the whole form. Returns a promise for one element wire() appends to the stock page.
*
* Everything applies IMMEDIATELY — there is no Save button for the axes themselves, because there
* is nothing to save: every axis is this browser's, in localStorage, and the page repaints under
* the control as it moves. The one button that writes anything is "Save as default", which pushes
* the current look to the ROUTER for other browsers. That distinction is the whole model
* (docs/design-system.md) and it is why this page has no Save/Reset footer of LuCI's own. */
function render() {
/* still a promise: the view awaits it, and keeping the shape means a future asynchronous step
* (or the caller) needs no change. */
return Promise.resolve(build());
}
function build() {
/* every saved axis re-checks the Save button after it applies, so the button greys the moment
* this browser matches the saved default again and un-greys the moment it diverges. Wrapped
* around the appliers because the seg/slider/colour controls call them directly and have no
* other seam back to here. refreshSave is a hoisted function declaration; saveBtn it reads is
* assigned below, before any of these fire (all are user events). */
const bump = (fn) => (v) => { fn(v); refreshSave(); };
/* Every colour control mirrors something it does not own — the PALETTE's colour, while its own
* axis is off, and the contrast that colour lands at. A palette switch, a dark-mode flip or a
* preset changes all of that under controls nobody touched, so they are refreshed together
* rather than each listening for what might have moved. */
const colourCtls = [];
const refreshColours = () => colourCtls.forEach((c) => c.fsRefresh());
/* wrap an applier so the colour readouts follow it: mode and palette change what every axis is
* measured against, and a preset changes the axes themselves */
const repaint = (fn) => (v) => { fn(v); refreshColours(); };
/* One captioned row, in LuCI's OWN row shape: `.cbi-value` > `label.cbi-value-title` +
* `.cbi-value-field`. This tab used to draw its own two-column grid of stacked cards — an
* uppercase eyebrow above each control — which made the theme's settings the one page in LuCI
* that did not look like LuCI. Sitting beside General Settings / Logging / Time Synchronization /
* Language and Style, the odd one out was ours.
*
* Nothing here styles those class names: `.cbi-value` is stock, base/30-forms.css and
* theme/60-inputs.css already lay it out (title column, field column, hairline under each row),
* so this page now inherits every future fix to the form layout instead of keeping a private
* copy of it. That also means the row lives on a SHARED surface — Zone 2, where a third-party
* app is entitled to win on specificity — which is exactly right for a page rendered inside
* #view rather than for chrome.
*
* `make` is handed the SAME label string the caption renders, because every control in here
* needs it a second time as its aria-label (segControl/sliderControl/colorControl take it as
* their last argument) — and stating it twice is how the visible caption and what a screen
* reader announces drift apart. One literal per axis, used by both, with nothing to keep in
* sync. `extra` is for the rows that carry more than a control (the Save row's error line),
* `opts.cls` for the rows CSS has to be able to single out. */
const group = (label, make, opts) => {
const o = opts || {};
return E('div', { 'class': 'cbi-value' + (o.cls ? ' ' + o.cls : '') }, [
E('label', { 'class': 'cbi-value-title' }, [ label ]),
E('div', { 'class': 'cbi-value-field' }, [ make(label) ].concat(o.extra || []))
]);
};
/* ---- the CONTROLS are LuCI's own, not this theme's ------------------------------------------
*
* Every enum axis is a `ui.Select` and every number is a `ui.RangeSlider` — the same widgets the
* form on the other tabs is built from, so a dropdown here is the dropdown an admin already knows
* and the theme's own stylesheet already dresses (`select` in base/30-forms.css,
* `.cbi-range-slider` in theme/60-inputs.css). Both classes exist on the whole range this theme
* supports — checked against luci's own openwrt-24.10 branch, not only against master.
*
* What this replaces is two primitives of ours: a segmented radiogroup with a roving tabindex and
* a range wrapper with a live readout. They were written when this page was a floating popover
* and a `<select>` inside it read as a hole in the card. On a page there is no such argument, and
* a control LuCI maintains is one this theme cannot get wrong on its own.
*
* THE EVENT IS `widget-change`, dispatched by UIElement, not a listener on the inner element —
* that is the seam the widget publishes, and reaching past it to the `<select>` would tie us to
* how it happens to be built today. RangeSlider also emits `widget-update` while the handle
* moves, which is what makes the tile resize UNDER the drag rather than on release; both are
* wired, and the appliers are idempotent so the pair costs nothing. */
const selectCtl = (current, choices, apply, label) => {
const w = new ui.Select(String(current), choices, { widget: 'select', sort: Object.keys(choices) });
const node = w.render();
node.setAttribute('aria-label', label);
node.addEventListener('widget-change', () => apply(w.getValue()));
return node;
};
const sliderCtl = (current, min, max, apply, label, opts) => {
const o = opts || {};
const w = new ui.RangeSlider(String(current), {
min: min, max: max, step: o.step || 1, calcunits: o.unit || null
});
const node = w.render();
node.setAttribute('aria-label', label);
const push = () => apply(parseInt(w.getValue(), 10));
node.addEventListener('widget-update', push);
node.addEventListener('widget-change', push);
return node;
};
/* one colour axis: the shared shape of the rows below. `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 = widgets.colorControl(axis.current(), bump(axis.apply), lbl, {
probe: probe,
read: axis.current,
contrast: contrast,
cls: (opts && opts.cls) || ''
});
colourCtls.push(ctl);
return ctl;
}, opts);
/* EVERY LABEL IN HERE CARRIES THE 'footstrap' CONTEXT (`_(str, ctx)`, key `ctx\1str`). LuCI
* serves ONE MERGED catalogue — load_catalog() loads every *.<lang>.lmo in
* /usr/lib/lua/luci/i18n and a lookup returns the first archive holding the hash — so a msgid is
* a GLOBAL name shared with every luci-app, and readdir order picks the winner: the layout
* toggle rendered "Максимум" on a Russian router (issue #6), because another catalogue
* translates the msgid "Top" as "maximum". Contexting cannot be selective — whatever we leave
* bare is a name anyone may take. The chrome and the login/notice sentences are deliberately
* bare (inheriting luci-base's translation is a feature in the ~40 languages we have no
* catalogue for), as are System/Memory/Storage in fs-overview.js, which MATCH the stock
* headings. */
/* ---- section 1: the shell ---- */
const shell = [
group(_('Layout', 'footstrap'), (label) => selectCtl(prefs.currentLayout(), {
sidebar: _('Sidebar', 'footstrap'),
top: _('Top', 'footstrap')
}, bump(prefs.applyLayout), label)),
group(_('Theme', 'footstrap'), (label) => selectCtl(prefs.currentMode(), {
auto: _('Auto', 'footstrap'),
light: _('Light', 'footstrap'),
dark: _('Dark', 'footstrap')
}, bump(repaint(prefs.applyMode)), label)),
group(_('Palette', 'footstrap'), (label) => selectCtl(prefs.currentPalette(), {
footstrap: 'Footstrap',
hicontrast: 'Hi-Contrast',
/* names the OTHER package, luci-theme-bootstrap, whose colours this palette is —
* so it is a proper noun and stays untranslated, like the two above it */
bootstrap: 'Bootstrap'
}, bump(repaint(prefs.applyPalette)), label)),
/* Density: how much air the UI uses. Pure token axis — 02-tokens.css multiplies the type and
* space ladders, so every size, gap and padding in the theme follows at once. */
group(_('Density', 'footstrap'), (label) => selectCtl(prefs.currentDensity(), {
compact: _('Compact', 'footstrap'),
normal: _('Normal', 'footstrap'),
large: _('Large', 'footstrap')
}, bump(prefs.applyDensity), label)),
group(_('Rounding', 'footstrap'),
(label) => sliderCtl(prefs.currentRadius(), 0, 20, bump(prefs.applyRadius), label, { unit: 'px' })),
/* The top layout has no accordion (its sections are hover dropdowns, already exclusive), so
* this switch is meaningless there. ALWAYS BUILT, HIDDEN BY CSS (:root[data-layout="top"]
* .fs-ap-submenus). Do NOT put an `if (currentLayout() !== 'top')` around it: the page is
* built once, so the branch would freeze the control to the layout the page LOADED in — it
* would stay on screen after a switch to the bar and never appear after a switch away from
* it. Toggling the layout re-renders nothing; CSS morphs the chrome. */
group(_('Submenus', 'footstrap'), (label) => selectCtl(
prefs.currentAutoCollapse() ? 'on' : 'off', {
off: _('Keep open', 'footstrap'),
on: _('Auto-collapse', 'footstrap')
}, bump(prefs.applyAutoCollapse), label),
{ cls: 'fs-ap-submenus' })
];
/* ---- section 2: colours ---- */
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: prefs.currentTint, apply: prefs.applyTint
}, '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')
}, { cls: 'fs-ap-tint' }),
/* the STRENGTH half of the Tint — how strong the hue reads. Only meaningful in hue mode: a
* hex canvas IS the colour asked for, with no chroma of ours to scale. CSS hides it in the
* other two states (no tint at all, or a hex one). */
/* NOT "Density": that is the UI-density segment above, and this string is both the visible
* caption AND the control's aria-label, so two rows would read "Density" and a screen reader
* would announce "Density, radio group" and "Density, slider" with nothing to tell them
* apart. */
group(_('Tint strength', 'footstrap'),
(label) => sliderCtl(prefs.currentTintStrength(), 0, 200, bump(repaint(prefs.applyTintStrength)), label, {
step: 5,
fmt: (v) => v + '%'
}), { cls: 'fs-ap-tint fs-ap-density' }),
/* recolours the accented CONTROLS (buttons/toggles/sliders/focus rings), not the canvas the
* way Tint does. Measured as TEXT on a card, which is the use that fails first: as a fill it
* carries derived ink, as a link or a status label it carries only itself. */
colourGroup(_('Accent', 'footstrap'), {
current: prefs.currentAccent, apply: prefs.applyAccent
}, 'var(--fs-accent)', {
fg: 'var(--fs-accent)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap')
}),
colourGroup(_('Good', 'footstrap'), {
current: prefs.currentGood, apply: prefs.applyGood
}, 'var(--fs-good)', {
fg: 'var(--fs-good)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap')
}),
colourGroup(_('Warning', 'footstrap'), {
current: prefs.currentWarn, apply: prefs.applyWarn
}, 'var(--fs-warn)', {
fg: 'var(--fs-warn)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap')
}),
colourGroup(_('Danger', 'footstrap'), {
current: prefs.currentDanger, apply: prefs.applyDanger
}, 'var(--fs-danger)', {
fg: 'var(--fs-danger)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap')
})
];
/* ---- the SURFACES: the sheet the UI is drawn on ----
* Cards, inset controls, the chrome bar and the hairlines between them. Every one of these is a
* surface that body text is read ON, so what each reports is --fs-text against itself — the one
* measurement that says whether the page is still readable. There is no ink to derive here and
* none is: --fs-text is the palette's, and an axis that silently moved it would be recolouring
* the very thing it is being measured against.
*
* The hairline is the exception and takes the 3:1 UI-component threshold rather than the text
* one, which is what its readout comparing --fs-border to --fs-panel means: a border is a shape,
* not a label, and AA asks 3:1 of it. Below that it is decoration — which a hairline is entitled
* to be, so the readout says the number and leaves the call to the admin. */
const surfaces = [
colourGroup(_('Cards', 'footstrap'), {
current: prefs.currentCard, apply: prefs.applyCard
}, 'var(--fs-panel)', {
fg: 'var(--fs-text)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap')
}),
colourGroup(_('Controls', 'footstrap'), {
current: prefs.currentControl, apply: prefs.applyControl
}, 'var(--fs-panel2)', {
fg: 'var(--fs-text)', bg: 'var(--fs-panel2)', label: _('on a control', 'footstrap')
}),
colourGroup(_('Sidebar and bar', 'footstrap'), {
current: prefs.currentBar, apply: prefs.applyBar
}, 'var(--fs-bar-bg)', {
fg: 'var(--fs-text)', bg: 'var(--fs-bar-bg)', label: _('in the sidebar', 'footstrap')
}),
colourGroup(_('Borders', 'footstrap'), {
current: prefs.currentLine, apply: prefs.applyLine
}, 'var(--fs-border)', {
fg: 'var(--fs-border)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap'), kind: 'shape'
})
];
/* ---- section 3: the wallpaper and everything that depends on which one is picked ----
*
* Wallpaper is THREE-valued: Off, Pattern (an uploaded SVG, tiled and recoloured) and File (an
* uploaded photo). Each value brings rows with it — the SVG plus Scale/Strength/Colours, or the
* photo plus Dim — and those rows are SIBLINGS of the Wallpaper row, not children of its field.
*
* That is the whole point of this shape. They were nested inside the field at first, which put a
* second `.cbi-value` inside a `.cbi-value-field` and therefore a second 180px caption column
* inside the first: measured on the router, Scale and Strength started 216px right of every
* other control on the page. LuCI has no such construct anywhere, and the eye reads it as two
* forms interleaved. Flat rows, hidden as a group, is what the stock pages do with a dependent
* field — one caption column, one field column, top to bottom.
*
* The select is the per-browser switch that decides whether to paint an image, so it is what
* keeps the Save button honest (refreshSave); Choose/Remove only swap the picture behind
* whoever is in that mode and never touch the axis. Both native file inputs stay hidden — the
* styled buttons trigger them. */
const wallpaper = (() => {
const err = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' });
const preview = E('img', { 'class': 'fs-ap-bgprev', 'alt': '', 'hidden': '' });
/* display:none, not the `hidden` attribute — a bare `hidden=""` still rendered the native
* "Choose File / No file chosen" control; only the styled button below should be visible. */
const fileInput = E('input', { 'type': 'file', 'accept': 'image/*', 'style': 'display:none' });
const chooseLabel = _('Choose image', 'footstrap');
const chooseBtn = E('button', { 'class': 'btn cbi-button', 'type': 'button' }, [ chooseLabel ]);
const removeBtn = E('button', { 'class': 'btn cbi-button-remove', 'type': 'button', 'hidden': '' }, [ _('Remove', 'footstrap') ]);
const patErr = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' });
const patPreview = E('img', { 'class': 'fs-ap-bgprev', 'alt': '', 'hidden': '' });
const patInput = E('input', { 'type': 'file', 'accept': 'image/svg+xml,.svg', 'style': 'display:none' });
const patChooseLabel = _('Choose SVG', 'footstrap');
const patChoose = E('button', { 'class': 'btn cbi-button', 'type': 'button' }, [ patChooseLabel ]);
const patRemove = E('button', { 'class': 'btn cbi-button-remove', 'type': 'button', 'hidden': '' }, [ _('Remove', 'footstrap') ]);
/* Dim: the scrim opacity over the photo. An ORDINARY per-browser axis — it is in AXIS_KEYS
* and in snapshotAxes(), so it moves this browser toward or away from the router default and
* must therefore be bump()-ed like every other saved axis. It was not, on the strength of a
* comment that said it wrote straight to uci: true until "keep every axis per-browser until
* Save as default" made it a propAxis and did not reach this file. The symptom is the one
* thing the Save button IS — its own status. Separate from the Tint's strength above. */
const dimLabel = _('Dim', 'footstrap');
const scaleLabel = _('Scale', 'footstrap');
const strengthLabel = _('Strength', 'footstrap');
const inkLabel = _('Colours', 'footstrap');
/* The rows the PATTERN brings. Scale and Strength are live: the appliers write a custom
* property, so the tile behind the page resizes and fades under the drag with nothing to
* reload. Colours decides whether the file's own palette is kept or thrown away for the
* theme's — a mask uses the alpha only, which is right for line art and wrong for artwork
* that carries its own colours, and only whoever picked the file knows which it is. */
const patRows = [
group(_('Pattern', 'footstrap'),
() => E('div', { 'class': 'fs-ap-bgrow' }, [ patChoose, patRemove ]),
{ extra: [ patInput, patPreview, patErr ] }),
group(scaleLabel, (lbl) => sliderCtl(prefs.currentPatternSize(), 40, 1600,
bump(prefs.applyPatternSize), lbl, { step: 20, unit: 'px' })),
group(strengthLabel, (lbl) => sliderCtl(prefs.currentPatternStrength(), 0, 100,
bump(prefs.applyPatternStrength), lbl, { step: 5, unit: '%' })),
group(inkLabel, (lbl) => selectCtl(prefs.currentPatternInk(), {
theme: _('Theme', 'footstrap'),
original: _('As in file', 'footstrap')
}, bump(prefs.applyPatternInk), 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(prefs.currentPhotoDim(), 0, 100,
bump(prefs.applyPhotoDim), lbl, { step: 5, unit: '%' }))
];
function reflect(tok) {
if (tok) { preview.src = prefs.loginBgUrl(tok); preview.hidden = false; removeBtn.hidden = false; }
else { preview.removeAttribute('src'); preview.hidden = true; removeBtn.hidden = true; }
}
function reflectPattern(tok) {
if (tok) { patPreview.src = prefs.patternUrl(tok); patPreview.hidden = false; patRemove.hidden = false; }
else { patPreview.removeAttribute('src'); patPreview.hidden = true; patRemove.hidden = true; }
}
/* `hidden` on the ROW, which is why 80-appearance.css restates it at a specificity that beats
* `.cbi-value`'s own display — the UA's bare `[hidden]` rule loses to it. Hidden and not
* removed: the rows are built once and each holds a live control whose value is this
* browser's, so rebuilding them on every switch would be the popover's old bug (a control
* frozen to the state it was constructed in) in a new place. */
function togglePanel(v) {
patRows.forEach((r) => { r.hidden = (v !== 'pattern'); });
fileRows.forEach((r) => { r.hidden = (v !== 'file'); });
}
reflect(prefs.currentLoginBg());
reflectPattern(prefs.currentPattern());
togglePanel(prefs.currentWallpaper());
const setWallpaper = (v) => { prefs.applyWallpaper(v); refreshSave(); togglePanel(v); refreshColours(); };
patChoose.addEventListener('click', () => { patErr.hidden = true; patInput.click(); });
patInput.addEventListener('change', () => {
const f = patInput.files && patInput.files[0];
patInput.value = ''; /* so re-picking the same file fires change again */
if (!f) return;
patErr.hidden = true; patChoose.disabled = true;
patChoose.textContent = _('Uploading…', 'footstrap');
prefs.uploadPattern(f)
.then((tok) => {
reflectPattern(tok);
/* uploadPattern already switched THIS browser onto the pattern, so the control has
* to catch up or the page paints the tile while the dropdown still reads Off.
* `dom.callClassMethod` is how LuCI moves one of its own widgets from the outside;
* setWallpaper is then called directly, because a programmatic setValue does NOT
* emit `widget-change` — the event is the user's, and relying on it here would
* leave the rows and the Save button behind. */
dom.callClassMethod(seg, 'setValue', 'pattern');
setWallpaper('pattern');
})
.catch((e) => { patErr.textContent = String((e && e.message) || e); patErr.hidden = false; })
.finally(() => { patChoose.disabled = false; patChoose.textContent = patChooseLabel; });
});
patRemove.addEventListener('click', () => {
patErr.hidden = true; patRemove.disabled = true;
prefs.removePattern()
.then(() => reflectPattern(''))
.catch((e) => { patErr.textContent = String((e && e.message) || e); patErr.hidden = false; })
.finally(() => { patRemove.disabled = false; });
});
chooseBtn.addEventListener('click', () => { err.hidden = true; fileInput.click(); });
fileInput.addEventListener('change', () => {
const f = fileInput.files && fileInput.files[0];
fileInput.value = ''; /* so re-picking the same file fires change again */
if (!f) return;
err.hidden = true; chooseBtn.disabled = true;
chooseBtn.textContent = _('Uploading…', 'footstrap');
prefs.uploadLoginBg(f)
.then(reflect)
.catch((e) => { err.textContent = String((e && e.message) || e); err.hidden = false; })
.finally(() => { chooseBtn.disabled = false; chooseBtn.textContent = chooseLabel; });
});
removeBtn.addEventListener('click', () => {
err.hidden = true; removeBtn.disabled = true;
prefs.removeLoginBg()
.then(() => reflect(''))
.catch((e) => { err.textContent = String((e && e.message) || e); err.hidden = false; })
.finally(() => { removeBtn.disabled = false; });
});
let seg;
const wallRow = group(_('Wallpaper', 'footstrap'), (label) => {
seg = selectCtl(prefs.currentWallpaper(), {
off: _('Off', 'footstrap'),
pattern: _('Pattern', 'footstrap'),
file: _('File', 'footstrap')
}, setWallpaper, label);
return seg;
});
return [ wallRow ].concat(patRows, fileRows);
})();
/* ---- section 4: the router default and the version ---- */
/* the version line: read from fs-version.js, which the Makefile stamps at package time. No
* request, no check — `apk upgrade` is what tells this router about a new one. */
/* Save the current look as the ROUTER-WIDE default (fs-prefs writes it to /etc/config/footstrap
* via the scoped uci ACL). It does NOT change this browser — localStorage keeps overriding, so
* the saved default only shows on a fresh browser/device. "Reset" is the escape hatch: it clears
* this browser's overrides and reloads onto the saved default (a two-click confirm, since it
* discards local tweaks).
*
* No status text — the Save BUTTON itself is the status: enabled "Save as default" when this
* browser diverges from the saved default, disabled "Saved as default" when it already matches
* (nothing to save). refreshSave() below drives that from prefs.matchesSavedDefault(). */
const saveBtn = E('button', { 'class': 'btn cbi-button-action', 'type': 'button' }, [ _('Save as default', 'footstrap') ]);
/* TWO resets, because there are two things underneath a browser's tweaks (fs-prefs.js):
* "Reset to saved" clears them and lets every axis fall back through the layers — to whatever
* Save as default put on the ROUTER; "Reset to default" writes the THEME's own built-ins
* explicitly, which is the only way to say "as the theme ships" on a router that has a saved
* default of its own. Neither touches /etc/config/footstrap. */
const resetSavedBtn = E('button', { 'class': 'btn', 'type': 'button' }, [ _('Reset to saved', 'footstrap') ]);
/* The stock destructive class, so the button that throws away every local tweak is the red one
* on the page — LuCI paints .cbi-button-negative/.cbi-button-remove from --fs-danger
* (theme/55-buttons.css). "Reset to saved" stays neutral on purpose: it drops this browser back
* onto whatever the router says, which is a step BACK to a shared state rather than a discard. */
const resetBtn = E('button', { 'class': 'btn cbi-button-negative', 'type': 'button' }, [ _('Reset to default', 'footstrap') ]);
/* Save's only visible failure surface. saveAsDefault() writes /etc/config/footstrap over the
* scoped uci ACL; the realistic failure is the rpc REJECTING — an expired session (403), a
* missing ACL, ubus down — which the old code buried in a title tooltip nobody sees. (A DELETED
* config is NOT caught here: rpcd stages the set in the session and commit then silently no-ops
* without writing the file, returning success — measured on the router. The package owns that
* file and the read side falls back to built-in defaults, so that edge is left to the package.) */
const saveErr = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' });
/* the Save button IS the status: match -> disabled "Saved as default", diverged -> enabled
* "Save as default". Called after every axis change (via bump). */
function refreshSave() {
const saved = prefs.matchesSavedDefault();
saveBtn.disabled = saved;
saveBtn.textContent = saved ? _('Saved as default', 'footstrap') : _('Save as default', 'footstrap');
}
saveBtn.addEventListener('click', () => {
saveBtn.disabled = true;
saveErr.hidden = true;
prefs.saveAsDefault()
.then(() => { saveErr.hidden = true; })
/* On failure re-enable (refreshSave, below) so the user can retry. The usual cause is a
* stale session, which a reload fixes — so say that. The raw rpc error — the one string
* here neither the theme nor LuCI composed — stays in a title tooltip for debugging. */
.catch((e) => {
saveErr.textContent = _('Could not save the default. Reload the page and try again.', 'footstrap');
saveErr.title = String((e && e.message) || e);
saveErr.hidden = false;
})
.finally(refreshSave);
});
/* two-click confirm on BOTH: the first click arms, the second resets — discarding this browser's
* tweaks is destructive of local work, and a native confirm() is banned in this UI. Arming one
* disarms the other, so a primed button can never be fired by a click meant for its neighbour.
*
* Each reload lands on this tab rather than back on General Settings: a reset is a change to
* what is on THIS tab, and being thrown to the top of the page to find it again is the kind of
* small rudeness that makes a setting feel unfinished. See armReturn() / the mount() flag. */
const armed = new Map();
function disarm(btn, label) {
armed.delete(btn);
btn.textContent = label;
btn.classList.remove('fs-ap-armed');
}
function twoClick(btn, label, run) {
btn.addEventListener('click', () => {
if (!armed.has(btn)) {
[ ...armed.keys() ].forEach((other) => disarm(other, armed.get(other)));
armed.set(btn, label);
btn.textContent = _('Confirm reset', 'footstrap');
btn.classList.add('fs-ap-armed');
return;
}
disarm(btn, label);
run();
armReturn();
location.reload();
});
}
twoClick(resetSavedBtn, _('Reset to saved', 'footstrap'), prefs.resetToSaved);
twoClick(resetBtn, _('Reset to default', 'footstrap'), prefs.resetToBuiltin);
refreshSave(); /* correct label/enabled state before the first paint */
const versionLink = E('a', {
'class': 'fs-ap-version',
'href': ver.REPO_URL,
'target': '_blank',
'rel': 'noopener noreferrer'
}, [ ver.label() ]);
const defaults = [
/* the one row whose "control" is a pair of buttons, each already named by its own text — so
* the caption is not re-used as an aria-label here and `make` ignores it */
group(_('Router default', 'footstrap'),
() => E('div', { 'class': 'fs-ap-actrow' }, [ saveBtn, resetSavedBtn, resetBtn ]),
{ extra: saveErr })
];
defaults.push(E('div', { 'class': 'fs-ap-footer' }, [
E('div', { 'class': 'fs-ap-verrow' }, [ versionLink ])
]));
/* NOT .cbi-section: inside a tab pane that class is a card drawn within a card, and the stock
* tabs (General Settings, Logging, …) put their rows straight into the pane. These are grouping
* headings within one pane, so they are the theme's own class and take their rule from
* styles/pages/80-appearance.css. */
const section = (title, rows) => E('div', { 'class': 'fs-ap-section' }, [
E('div', { 'class': 'fs-ap-head' }, [ E('h4', {}, [ title ]) ])
].concat(rows));
/* ---- the folded groups ------------------------------------------------------------------
* Recolouring is a thing most admins never do, and these are the widest rows on the page —
* nine colour fields and an uploader, which used to sit permanently open in front of someone
* who came here to change the layout. Each is a DISCLOSURE now: the heading is the control,
* and both start closed.
*
* A disclosure and not a switch, which is what these were first. A switch answers "is this
* feature on", and that is the wrong question — turning it off would either revert nine colours
* (destructive, from a control that looks like a disclosure) or change nothing at all, which is
* a switch that lies. Folding answers the question that is actually being asked: am I looking
* at this right now. Nothing is applied, un-applied or disabled by opening or closing one.
*
* It is the W3C APG disclosure pattern, the same one the menu's sections use: a <button> owning
* the region it shows, `aria-expanded` on the button and `aria-controls` pointing at the panel.
* `hidden` on the panel rather than a class, so a closed group is out of the tab order and out
* of the accessibility tree for free.
*
* The open/closed state is remembered per browser but is NOT an axis: it changes nothing about
* how the page looks, so it is absent from AXIS_KEYS, from snapshotAxes() and from the
* pre-paint. Closed is the default, including on a router that already has colours set — the
* fold says where things are, not whether they are in use. */
let foldSeq = 0;
function foldable(title, rows, key) {
const id = 'fs-ap-fold-' + (++foldSeq);
let open = (prefs.lsGet(key) === 'on');
const body = E('div', { 'class': 'fs-ap-body', 'id': id }, rows);
const btn = E('button', {
'type': 'button', 'class': 'fs-ap-fold', 'aria-expanded': String(open), 'aria-controls': id
}, [
E('h4', {}, [ title ]),
/* The chevron is the affordance, and it is the SAME one the overview's card toggles
* draw: an empty box whose ::after is two borders rotated 45° (styles/pages/
* 20-overview.css). Not an <svg> — this theme has one chevron for "this panel opens",
* and a second drawing of it would be a second thing to keep looking like the first.
* Empty and aria-hidden: the STATE is on the button's aria-expanded, which is also what
* CSS rotates it off, so what a screen reader is told and what the eye sees cannot
* disagree. */
E('span', { 'class': 'fs-ap-chev', 'aria-hidden': 'true' })
]);
const paint = () => {
body.hidden = !open;
btn.setAttribute('aria-expanded', String(open));
};
btn.addEventListener('click', () => {
open = !open;
prefs.lsSet(key, open ? 'on' : 'off');
paint();
/* the colour controls read the COMPUTED cascade, and a hidden element computes nothing
* useful — so the readouts are filled when the group becomes visible, not before */
if (open) refreshColours();
});
paint();
return E('div', { 'class': 'fs-ap-section' }, [
E('div', { 'class': 'fs-ap-head' }, [ btn ]), body
]);
}
/* Colours and Surfaces are ONE fold: they are the same job — "make this router a different
* colour" — split into two headings only because a figure and the sheet it sits on are read
* differently. Two folds for one decision would be two things to open. */
const page = E('div', { 'class': 'fs-ap' }, [
section(_('Interface', 'footstrap'), shell),
foldable(_('Colours', 'footstrap'),
colours.concat([ E('div', { 'class': 'fs-ap-head fs-ap-sub' }, [ E('h4', {}, [ _('Surfaces', 'footstrap') ]) ]) ], surfaces),
'fs-ui-colours'),
foldable(_('Background', 'footstrap'), wallpaper, 'fs-ui-background'),
section(_('Defaults', 'footstrap'), defaults)
]);
/* The colour controls read the COMPUTED cascade, which needs them in the document — so the first
* fill cannot happen while the tree is still being assembled above. The view appends this
* element synchronously on return, so a microtask is late enough and early enough: late enough
* that the probe resolves, early enough that nothing has painted a blank field. */
Promise.resolve().then(refreshColours);
return page;
}
/* ---- mounting it on the stock System page ---------------------------------------------------
*
* The same shape as fs-overview.js's, and for the same reason: a chrome module is instantiated once
* per PAGE LOAD, so it has to notice SPA navigation itself. `body[data-page]` is the signal — both
* the server template and fs-router stamp it with the dispatch path — so one attribute observer
* covers arriving at System, leaving it, and coming back. */
const PAGE = 'admin-system-system';
/* A reset reloads the page, and a reload opens the stock page on the tab LuCI remembers — which is
* never this one, because ui.tabs only knows the tabs it built itself. So the reset says where it
* came from and mount() puts the user back. sessionStorage and not a URL fragment: the fragment is
* the stock page's own business, and a stale one would keep re-opening this tab on every later
* visit. The key is read once and removed, so it survives exactly one reload. */
const RETURN_KEY = 'fs-ap-return';
function armReturn() { try { sessionStorage.setItem(RETURN_KEY, '1'); } catch (e) {} }
function takeReturn() {
try {
if (sessionStorage.getItem(RETURN_KEY) === null) return false;
sessionStorage.removeItem(RETURN_KEY);
return true;
} catch (e) { return false; }
}
const MARK = 'fs-ap'; /* the built form's own class; also how mount() knows it is already there */
const TAB = 'fs-appearance'; /* the pane's data-tab, which ui.tabs' click handler matches on */
let _routeObserver = null, _viewObserver = null, _observedView = null, _building = false;
function onPage() { return (document.body.getAttribute('data-page') || '') === PAGE; }
function stopWatch() {
if (_viewObserver) _viewObserver.disconnect();
_viewObserver = null;
_observedView = null;
}
/* Append the form once the stock view has rendered. LuCI's system.js resolves its own promises
* before it puts anything in #view, so there is nothing to hook but the DOM — hence the observer,
* which also covers the view being re-rendered under us (a Save & Apply redraws the map).
*
* Idempotent through the marker: an observer fires for every mutation, and the form's own
* construction is a mutation. Without the check it would append itself for as long as it kept
* noticing itself. */
/* The stock tab GROUP: the element whose children are the panes, which ui.tabs marks
* data-initialized when it builds the menu — and the menu it inserted is that element's previous
* sibling. Both are read from the DOM rather than assumed, because a group that is not initialised
* yet is a page still rendering, not a page without tabs.
*
* The flag and the sibling are the whole test, deliberately: the panes themselves are NOT required
* to be found here. On the stock System page they are not direct children of the marked element at
* all — the map nests them a level deeper — so a `:scope > .cbi-tabcontainer` check found none and
* the tab was never added, silently, on a page that plainly has tabs. */
function tabGroup(view) {
for (const g of view.querySelectorAll('[data-initialized="true"]')) {
const menu = g.previousElementSibling;
if (menu?.classList.contains('cbi-tabmenu'))
return { group: g, menu };
}
return null;
}
/* Append the pane and its tab once the stock view has rendered. LuCI's system.js resolves its own
* promises before it puts anything in #view, so there is nothing to hook but the DOM — hence the
* observer, which also covers the view being re-rendered under us (a Save & Apply redraws the map).
*
* The tab is added BY HAND rather than by calling ui.tabs.initTabGroup again: that function returns
* immediately when the group carries data-initialized, and clearing the flag to re-run it would
* build a SECOND menu beside the first (it inserts one unconditionally) and drop the stock tabs'
* own click bindings. One <li>, the same click handler ui.tabs binds to every other tab, and the
* pane the handler expects to find.
*
* Idempotent through the marker: an observer fires for every mutation, and the form's own
* construction is a mutation. Without the check it would append itself for as long as it kept
* noticing itself. */
function mount() {
const view = document.getElementById('view');
if (!view || view.querySelector('.' + MARK) || _building) return;
const tabs = tabGroup(view);
if (!tabs) return;
_building = true;
render()
.then((form) => {
/* re-check: render() resolves on a microtask and the view can have been replaced, or
* navigated away from, in the meantime */
const v = document.getElementById('view');
if (!onPage() || !v || v.querySelector('.' + MARK)) return;
const t = tabGroup(v);
if (!t) return;
/* The tab is named after the THEME, not after what it does: it sits between four stock
* tabs that are all "what this page configures" (General Settings, Logging, …), and a
* fifth called Appearance reads as another facet of the router rather than as one
* package's settings. "Footstrap" says whose these are — and it is a proper noun, so it
* is deliberately NOT translated, like the palette name in the form below. */
const title = 'Footstrap';
/* data-tab-active is deliberately absent: the stock page opens on whichever tab it
* opened on before, and a theme has no business taking that over. */
t.group.appendChild(E('div', {
'class': 'cbi-tabcontainer',
'data-tab': TAB,
'data-tab-title': title
}, [ form ]));
const link = E('a', { 'href': '#' }, [ title ]);
link.addEventListener('click', ui.tabs.switchTab.bind(ui.tabs));
t.menu.appendChild(E('li', { 'class': 'cbi-tab-disabled', 'data-tab': TAB }, [ link ]));
/* …and if this load is the one a reset asked for, open on it. Clicking the link we just
* built goes through ui.tabs' own switchTab, so the stock panes are hidden exactly the
* way they are for any other tab — nothing here reimplements the switch. */
if (takeReturn()) link.click();
})
.catch((e) => console.error('footstrap: the Appearance tab failed to build', e))
.finally(() => { _building = false; });
}
function watch() {
const view = document.getElementById('view');
if (_viewObserver && _observedView !== view) stopWatch();
if (_viewObserver || !view || !onPage()) return;
_observedView = view;
_viewObserver = new MutationObserver(mount);
_viewObserver.observe(view, { childList: true, subtree: true });
mount();
}
/* Called by menu-footstrap-common's init, once. Everything route-dependent hangs off the data-page
* observer inside. */
function wire() {
if (_routeObserver || !document.body) return;
_routeObserver = new MutationObserver(() => (onPage() ? watch() : stopWatch()));
_routeObserver.observe(document.body, { attributes: true, attributeFilter: [ 'data-page' ] });
if (onPage()) watch();
}
return baseclass.extend({
wire,
render
});
@@ -0,0 +1,429 @@
'use strict';
'require baseclass';
'require ui';
'require fs-fit as fit';
'require fs-prefs as prefs';
'require fs-menutree as tree';
/* The chrome AROUND the content: the mode menu, the section tabs, the rail toggle, and the
* measurements that decide how much room any of it gets. The MAIN menu is not here — it is injected
* by menu-footstrap.js as a callback (renderMainMenu), because LuCI instantiates every required
* module into a singleton and so a renderer cannot be a subclass of the chrome (docs/conventions.md). */
/* the injected main-menu renderer; handed over once by the theme's init() */
let _renderMain = null;
function setRenderMain(fn) {
_renderMain = fn;
}
/* section tabs -> #tabmenu (horizontal) */
function renderTabMenu(node, url, level) {
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;
children.forEach((child) => {
const isActive = (L.env.dispatchpath[3 + (level || 0)] === child.name);
/* aria-current="page", not just the `active` class: the class is paint, which a screen
* reader cannot see. E() drops a null attribute value, so inactive tabs carry nothing. */
ul.appendChild(E('li', { 'class': 'tabmenu-item-%s %s'.format(child.name, isActive ? 'active' : '') }, [
E('a', { 'href': L.url(url, child.name), 'aria-current': isActive ? 'page' : null }, [ _(child.title) ])
]));
if (isActive)
activeNode = child;
});
if (ul.children.length === 0)
return E([]);
container.appendChild(ul);
container.style.display = '';
if (activeNode)
renderTabMenu(activeNode, url + '/' + activeNode.name, (level || 0) + 1);
return ul;
}
/* ---- tab-strip auto-fit ----
* A tab strip (#tabmenu, or a view's own .cbi-tabmenu) can carry ~11 pills (luci-app-justclash)
* that overflow one row. Rather than wrap, shrink: two density classes (styles/theme/40-tabs.css)
* trim padding, then gap+font. Floored so a pill never gets tighter than its label — past the
* floor the strip is allowed to wrap. */
function stripFitsOneRow(ul) {
/* Only laid-out children count: a display:none child has offsetTop 0, so taking it as `last`
* read as "one row" while the strip had in fact wrapped, and the density fit never fired. */
const items = [...ul.children].filter((el) => el.getClientRects().length > 0);
const first = items[0], last = items[items.length - 1];
/* one row iff first and last item share a top edge */
return !first || !last || first.offsetTop === last.offsetTop;
}
function fitTabStrips() {
/* `.fs-sidebar > ul.nav` is the main menu in EVERY layout — the same list — so the
* flexDirection check below is what tells a bar (row) from a vertical sidebar (column),
* where a one-row measure is meaningless. */
document.querySelectorAll('.tabs, .cbi-tabmenu, .fs-sidebar > ul.nav').forEach((ul) => {
if (ul.children.length < 2) return;
if (ul.matches('.fs-sidebar > ul.nav') && getComputedStyle(ul).flexDirection !== 'row') {
/* vertical list: the measure would floor it at fs-dense2 forever. Clear and skip. */
if (ul.classList.contains('fs-dense1') || ul.classList.contains('fs-dense2'))
ul.classList.remove('fs-dense1', 'fs-dense2');
return;
}
/* steady state (poll tick on an already-fitting strip): one measure, no class writes —
* the write-measure-write dance below forces a reflow per strip, every second. */
if (!ul.classList.contains('fs-dense1') && !ul.classList.contains('fs-dense2') && stripFitsOneRow(ul))
return;
ul.classList.remove('fs-dense1', 'fs-dense2');
if (stripFitsOneRow(ul)) return;
ul.classList.add('fs-dense1');
if (stripFitsOneRow(ul)) return;
ul.classList.remove('fs-dense1');
ul.classList.add('fs-dense2'); /* floor: leave wrapped if it still overflows */
});
}
/* ---- does the CONTENT column still have room, once the sidebar has taken its cut? ----
*
* The sidebar gives way to the bar when what is LEFT for the content would be too narrow to read.
* A viewport breakpoint (`@media (max-width: 767px)`) cannot say that: the cut is not a constant —
* 224px expanded, 68px collapsed to the rail — so one breakpoint gave both states the same answer,
* and the rail folded away at the same width as the full sidebar, the ~156px it had just freed
* buying the user nothing. Do NOT measure the RENDERED sidebar either: the answer would depend on
* the state it is deciding (once it is a bar there is no cut, so the content "fits", so it
* un-narrows, so it cuts again) — oscillation.
*
* The widths come from the STYLESHEET (02-tokens.css), which is what lays the sidebar out; never
* restate them here, or narrowing the rail in CSS leaves this subtracting the old width with no gate
* able to see it. Memoised because fitShell runs on every resize and mutation and getComputedStyle
* forces a style recalc; the fallbacks stop an empty custom property making the measurement NaN
* (`NaN < NaN` is false, so the sidebar would simply never yield). */
/* A CUSTOM PROPERTY IS UNTYPED, so its computed value is a token stream and not a length.
* `parseFloat(getComputedStyle(root).getPropertyValue('--fs-sidebar-w'))` therefore reads
* `calc(224px * 1)` — a string starting with `c` — and returns NaN, which the fallbacks below turn
* straight back into the literals this function exists to stop restating. It read correctly until
* the Density axis wrapped three of the four tokens in `calc(… * var(--fs-density-box))`; only
* `--fs-content-min`, still a bare `500px`, kept working, which is why the failure was one-sided
* and silent. Measured on the router: content-min 500, and sidebar/rail/pad all NaN.
*
* Resolve them the way the platform actually offers without registering the property: assign the
* token to a REAL length property on a throwaway element and read the used value back. */
let _probe = null;
/* THE PROBE IS A PLAIN <div> IN THE SHARED DOCUMENT, so a third-party app's CSS can style it, and
* every one of its declarations is !important for that reason alone. It carries no chrome mark, so
* `fs-sheets`'s fence deliberately does not spare it (the fence protects the chrome's own elements),
* and even a fenced sheet still matches an unmarked div. Issue #19: an app whose stylesheet carried
* `div { min-width: 500px !important }` won every read — all four tokens came back 500 — so the cut
* the sidebar was said to take became 500 + 2x500 and `fitShell` folded the sidebar into a bar on a
* 1857px desktop. The threshold that produces is exactly 500 + 500 + 1000 = 2000 CSS px, which is
* why it was reported as a ZOOM bug: Chrome at 90% gives 2063 CSS px and passed, 100% gives 1857 and
* failed. Inline !important is what answers it — a style-attribute declaration outranks any author
* rule at the same importance, so there is nothing left for the app to out-rank.
* 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 (the padding/border resets below are
* important, but only a stated box-sizing makes them provably irrelevant). */
function resolveLen(token, dflt) {
if (!_probe) {
_probe = document.createElement('div');
_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;' +
'pointer-events:none!important;height:0!important;box-sizing:content-box!important;' +
'min-width:0!important;max-width:none!important;border:0!important;' +
'padding:0!important;margin:0!important;';
document.body.appendChild(_probe);
}
_probe.style.setProperty('width', 'var(' + token + ')', 'important');
const v = parseFloat(getComputedStyle(_probe).width);
return Number.isFinite(v) ? v : dflt;
}
/* Memoised because fitShell runs on every resize and mutation and resolving forces a style recalc —
* but keyed on the DENSITY, because that is the one thing that changes these widths at runtime
* (`prefs.applyDensity()` stamps `:root[data-density]` and calls fit.schedule() precisely so they
* are re-measured). Reading one attribute is free; the memo without the key meant a density change
* re-measured against the widths of the density before it. */
/* The last resort, stated ONCE so the fallbacks and the sanity net below cannot restate the
* stylesheet's widths in two different places. Reaching for these means the measurement failed. */
const GEOM_DFLT = { contentMin: 500, sidebarW: 224, railW: 68, contentPad: 56 };
let _geom = null, _geomDensity = null;
function shellGeometry() {
const density = document.documentElement.getAttribute('data-density') || '';
if (_geom && _geomDensity === density) return _geom;
_geomDensity = density;
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),
/* the token is ONE side's padding; the column loses it twice */
contentPad: px('--fs-content-pad', GEOM_DFLT.contentPad / 2) * 2
};
/* Plausibility, and it costs one comparison: the rail IS the sidebar collapsed, so
* 0 < railW < sidebarW holds by construction. Both known ways this measurement fails destroy
* that — a hijacked probe reports ONE foreign width for all four (issue #19), a renamed or
* absent token reports 0 for all four (an abs-positioned empty div with `width:auto` shrinks to
* 0, and 0 is finite, so the per-read fallback above never fires). Neither can be seen in the
* numbers one at a time; the RELATION between them is what gives it away. */
_geom = (g.railW > 0 && g.railW < g.sidebarW && g.contentMin > 0) ? g : Object.assign({}, GEOM_DFLT);
return _geom;
}
function fitShell() {
const root = document.documentElement;
if (prefs.currentLayout() === 'top') { /* no sidebar, no cut, nothing to decide */
root.removeAttribute('data-narrow');
return;
}
const g = shellGeometry();
const cut = prefs.currentRail() ? g.railW : g.sidebarW;
/* clientWidth, not innerWidth: the column the content actually gets excludes a classic
* scrollbar, and innerWidth includes it — 15-17px of phantom room on Linux/Windows, i.e. the
* 500px floor really fired at ~484. */
const content = document.documentElement.clientWidth - cut - g.contentPad;
/* toggleAttribute, NOT setAttribute: a same-value setAttribute still QUEUES a mutation record
* (measured in Chromium: 5 identical setAttribute('data-narrow','') -> 5 records; toggleAttribute
* on an already-present attribute -> 0). fitShell runs from fitChrome, which fs-fit calls on every
* mutation batch inside #view — i.e. once a second on any polled page. menu-footstrap observes
* data-narrow and treats each record as a mode CHANGE, so on a phone (390 - 224 - 56 = 110 < 500,
* so the attribute is permanently set) every poll tick re-fired closeFlyouts() and the section the
* user had just tapped open snapped shut, forever. The bug was one-sided and therefore invisible
* on a desktop: the else-branch removeAttribute on an absent attribute already fires 0 records. */
root.toggleAttribute('data-narrow', content < g.contentMin);
}
function fitChrome() {
fitShell();
const bar = document.querySelector('.fs-sidebar');
const menu = document.getElementById('topmenu');
/* The top bar is MEASURED at every width — no 768 floor. It used to bail below 768 and hand
* the job to a phone-bar media query, which left the sub-768 bar pinning its dropdowns to the
* left edge and never collapsing "Refreshing"; the shrink/compact/stack escalation below now
* runs at any width for the top layout. (The SIDEBAR layout still has its own phone bar,
* decided by fitShell's data-narrow, and is untouched here.) */
const topBar = !!bar && !!menu && prefs.isTopLayout();
if (bar) bar.classList.remove('fs-bar-stack', 'fs-ind-compact', 'fs-bar-actrow');
fitTabStrips();
/* ---- does the main menu fit on the brand's row? ----
* Whether it fits depends on how many sections THIS router has (stock 5, a loaded box 11), not
* on the viewport — so it is measured, not a breakpoint. `@media (max-width: 1199px)` stacked
* it on every laptop: a stock bar's contents come to ~683px, i.e. one row fits down to ~723px.
* Measured UNSTACKED (the remove above): a stacked menu owns a whole row and would "fit",
* flipping straight back — oscillation.
*
* The menu's own pills wrapping IS the "does not fit" signal, but only because the unstacked
* top bar is flex-wrap: nowrap (50-toplayout.css); otherwise the BAR wraps, hands the menu
* a whole row, and it always "fits". Do NOT measure the bar's children by offsetTop instead:
* the bar is align-items:center with children of differing heights, so their offsetTop differs
* even on one row (that read as "wrapped" for a 5-section menu). */
if (topBar && !stripFitsOneRow(menu)) {
/* First step before stacking: collapse the poll pill ("Refreshing", ~90px) to an icon
* square and re-measure — that width alone is often enough to keep the menu on the
* brand's row and skip the second row entirely (styles/theme/50-toplayout.css). */
bar.classList.add('fs-ind-compact');
fitTabStrips();
if (!stripFitsOneRow(menu)) {
bar.classList.add('fs-bar-stack');
fitTabStrips();
}
}
/* The cluster's own escalation, for EVERY bar — the top layout at any width, and the sidebar
* layout once fitShell has stamped data-narrow. It runs after the menu's, because on the top
* layout .fs-bar-stack has by then given the menu its own row and taken it out of the sum. */
if (bar && (topBar || document.documentElement.hasAttribute('data-narrow')))
fitCluster(bar, menu);
}
/* ---- does the right-hand cluster still fit beside the brand? ----
*
* The same question as the menu's, one row up, and it cannot be asked the same way. The cluster is
* four SIBLINGS — the indicators, Search, Appearance, Log out — so flexbox wraps them ONE AT A
* TIME: measured on a 380px phone bar, Log out alone dropped onto a second row and sat at its LEFT
* edge under the hostname, while the other three stayed up top. Either the whole cluster shares the
* brand's row or it takes a row of its own, right-aligned; there is no useful state in between.
*
* Two steps, cheapest first: collapse the pills to icon squares (~200px of prose on a bar showing
* both), and only if that still overflows give the cluster a row. .fs-ind-compact may already be
* set by the menu's escalation above — adding it twice is free, and it must NOT be cleared here:
* whoever asked for it still needs it. */
function fitCluster(bar, menu) {
bar.classList.remove('fs-bar-actrow');
if (clusterFitsBrandRow(bar, menu))
return;
bar.classList.add('fs-ind-compact');
if (clusterFitsBrandRow(bar, menu))
return;
bar.classList.add('fs-bar-actrow');
}
/* Add the widths up rather than read positions: the bar is align-items:center over children of
* differing heights, so offsetTop differs even on ONE row — the same trap the menu measurement
* documents above. The menu is excluded because it owns a full-width row of its own in every bar
* (`ul.nav { flex: 1 1 100% }`), so it is never what the cluster competes with. */
function clusterFitsBrandRow(bar, menu) {
const cs = getComputedStyle(bar);
const gap = parseFloat(cs.columnGap) || 0;
const room = bar.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
let need = -gap;
for (const el of bar.children) {
/* offsetParent is null for a display:none child, which is most of them in a bar:
* .fs-navlabel, .fs-spacer and #modemenu never show there, and #indicators is empty
* (and so hidden) until the first poll pill lands. */
if (el === menu || el.offsetParent === null)
continue;
need += el.offsetWidth + gap;
}
return need <= room;
}
/* No observer and no resize listener of our own: fs-fit owns both, and this file used to grow the
* second one docs/conventions.md warns against. A view renders its .cbi-tabmenu into #view, which fs-fit's
* MutationObserver already watches — and it re-fits SYNCHRONOUSLY (rule 2), where the copy here
* deferred through fit.schedule(), i.e. the duplicate was strictly the slower path into the same
* work. #tabmenu is a sibling of #view rather than inside it, but nothing writes it except
* renderChrome(), which schedules a fit itself. Resize is fs-fit's ResizeObserver on #view (with a
* window-resize fallback where there is no RO). */
/* modes -> #modemenu; drives the injected renderMainMenu for the active mode */
function renderModeMenu(node, renderMainMenu) {
const ul = document.querySelector('#modemenu');
const children = ui.menu.getChildren(node);
children.forEach((child, index) => {
const isActive = L.env.requestpath.length
? child.name === L.env.requestpath[0]
: index === 0;
/* the main menu must render even if a template has no #modemenu — only the mode
* list itself is skippable chrome */
if (ul)
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)
ul.style.display = '';
}
/* rebuild mode menu + main menu + section tabs from the current L.env; on first load and after
* every SPA nav. Containers are cleared first so a re-render does not stack duplicates. */
function renderChrome() {
const root = tree.tree();
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'; }
renderModeMenu(root, _renderMain);
if (L.env.dispatchpath.length >= 3) {
let node = root, url = '';
/* `node.children &&`, exactly as fs-menutree's nodeForSegs() walks it: a node without
* children is an ordinary leaf, and reading `.children[…]` off one is a TypeError that
* escapes renderChrome() — i.e. it takes out the mode menu, the tabs and, on the init path,
* everything menu-footstrap-common wires after it. The walk already tests `node` on each
* step; testing only half of what it dereferences is what left the hole. */
for (let i = 0; i < 3 && node; i++) {
node = node.children && node.children[L.env.dispatchpath[i]];
url = url + (url ? '/' : '') + L.env.dispatchpath[i];
}
if (node)
renderTabMenu(node, url);
}
fit.schedule();
}
/* Sidebar rail toggle: collapse the sidebar to an icon-only strip. The state lives on
* <html data-rail> (head.ut re-applies it before paint) and in localStorage; everything else —
* flyout submenus, hidden labels — is CSS keyed off that attribute. */
function wireRail() {
const btn = document.getElementById('fs-rail-toggle');
if (!btn) return;
function sync() {
const on = prefs.currentRail();
btn.setAttribute('aria-expanded', on ? 'false' : 'true');
const label = on ? _('Expand menu') : _('Collapse menu');
btn.setAttribute('aria-label', label);
btn.setAttribute('title', label);
}
btn.addEventListener('click', () => {
prefs.applyRail(!prefs.currentRail());
sync();
/* the sidebar's cut just changed by ~156px, so the content column may now clear (or fall
* below) --fs-content-min: re-measure rather than wait for a resize that is not coming */
fit.schedule();
});
sync();
}
/* An indicator pill carries its whole meaning as prose — LuCI writes "Unsaved Changes: 2" — and the
* collapsed rail is 68px wide. Measured on the router: the pill wants 86px, wraps onto three lines
* and hangs 34px past the rail's edge over the content (issue #14). The rail is an icon strip, so
* CSS squares the pill there and draws this attribute instead of the label; a text node cannot be
* reached by a selector, which is why the badge has to be lifted into one here.
*
* The COUNT is what it shows — the only part that changes and the only part worth reading at that
* size. A pill with no trailing number (a third-party app's "Backup pending") falls back to a
* neutral dot: clipping the prose was tried and rendered "up pen", because a centred pill gives an
* ellipsis no start to anchor to. Choosing between the two is a decision, so it lives here rather
* than as a second CSS rule. The full prose stays in the label either way — a screen reader still
* reads it, and `title` keeps it reachable by pointer. */
const IND_DOT = '•';
function wireIndicatorCounts() {
const box = document.getElementById('indicators');
if (!box) return;
function stamp() {
box.querySelectorAll('[data-indicator]').forEach((el) => {
const txt = el.textContent || '';
const m = txt.match(/(\d+)\s*$/);
el.setAttribute('data-fs-badge', m ? m[1] : IND_DOT);
/* the rail hides the prose; the tooltip is where it stays reachable by pointer */
el.setAttribute('title', txt);
});
}
/* ui.showIndicator REPLACES the label's text node on an update ("Unsaved Changes: 1" -> ": 2")
* and appends the span on the first change of the session, so both matter — and characterData
* too, for the in-place rewrite. Our own attribute writes do not re-enter: attributes are not
* observed. */
new MutationObserver(stamp).observe(box, { childList: true, subtree: true, characterData: true });
stamp();
}
return baseclass.extend({
setRenderMain,
renderChrome,
wireIndicatorCounts,
/* registered with fs-fit by the theme's init(): the bar's "does the menu fit beside the brand"
* measurement rides the same engine as the data tables' */
fitChrome,
wireRail
});
@@ -0,0 +1,153 @@
'use strict';
'require baseclass';
/* fs-fit — the theme's ONE "does it still fit?" engine; add fit logic here, do not grow a second
* observer. No CSS query can ask what the CONTENT needs (media = viewport, container =
* container): does the menu fit beside the brand, is a table still readable? Both were once
* breakpoints (one @media, five @container thresholds) — guessed numbers that real routers got
* wrong, useless for a third-party luci-app-* table of unknown column count.
*
* THREE RULES, each a bug that was hit:
* 1. MEASURE UNCOLLAPSED — a collapsed thing always "fits" (a stacked table is a pile of flex
* rows): read it as it stands and it un-collapses, next frame re-collapses. Oscillation.
* 2. RE-FIT SYNCHRONOUSLY ON A MUTATION — the poll re-renders content once a second and the
* fresh element has lost our class. A MutationObserver callback is a microtask (pre-paint),
* rAF runs AT paint: deferring there painted a stacked table one frame at full width —
* 19-109px of overflow, once a second, on Firewall/DHCP/Wireless.
* 3. COALESCE ON RESIZE — every fit forces a synchronous layout.
*
* ResizeObserver, not onresize: a rail collapse and a layout toggle change the content width
* without resizing the window. */
const _fitters = [];
let _rafPending = false;
let _ro = null, _mo = null;
/* Run every fitter NOW, synchronously. A fitter must be idempotent — this fires on every
* relevant mutation. */
function run() {
for (const fit of _fitters) {
try { fit(); }
/* one broken fitter must take neither the others nor the poll's MutationObserver
* callback with it — that would silently stop ALL re-fitting */
catch (e) { console.error('fs-fit: a fitter threw', e); }
}
}
/* Next frame, at most once per frame (rule 3). */
function schedule() {
if (_rafPending) return;
_rafPending = true;
requestAnimationFrame(() => { _rafPending = false; run(); });
}
/* Watch an element's size. Any change re-fits everything — the fitters are cheap and few. */
function watch(el) {
if (!el) return;
if (!_ro) {
if (!window.ResizeObserver) { /* no RO: fall back to the window */
window.addEventListener('resize', schedule);
return;
}
_ro = new ResizeObserver(schedule);
}
_ro.observe(el);
}
/* Rule 2's mutation side. Deliberately NOT filtered by node type: a filter is a second place to
* get wrong (the table fitter's own once said `table.table`, and LuCI renders most of its tables
* as DIVs — so the poll never re-measured at all), and run() is a handful of measurements. */
function observeContent() {
if (_mo) return;
const host = document.getElementById('view') || document.body;
_mo = new MutationObserver(run);
_mo.observe(host, { childList: true, subtree: true });
watch(host);
}
return baseclass.extend({
/* Register a fitter and run it once. A fitter selects its own elements, strips its class
* (rule 1), measures, re-applies. */
add(fit) {
if (typeof fit !== 'function') return;
_fitters.push(fit);
observeContent();
fit();
},
/* Re-fit on the next frame, coalesced. (There is no exported `run`: everything that changes
* the available room — the layout toggle, the rail collapse — schedules. Only the mutation
* observer re-fits synchronously, and that is rule 2's whole point.) */
schedule,
/* Coalesce ANY callback into one call per frame (rule 3, for non-fitters): schedule() runs
* EVERY fitter, so a caller wanting only its own work batched cannot use it — three had
* hand-rolled the identical five lines. NOT for the per-element case: menu-footstrap.js's
* clamp keeps a rAF handle per <li> so it can CANCEL a pending measure, which a one-flag
* coalescer cannot express. */
frame(fn) {
let pending = false;
return () => {
if (pending) return;
pending = true;
requestAnimationFrame(() => { pending = false; fn(); });
};
},
/* Did this batch add anything matching `sel`? The poll rewrites content once a second, so a
* MutationObserver here needs that cheap question before its document-wide queries. */
touches(mutations, sel) {
for (const m of mutations)
for (const n of m.addedNodes) {
if (n.nodeType !== 1) continue;
if (n.matches(sel) || n.querySelector(sel)) return true;
}
return false;
},
/* Room for `el` = its PARENT's content box. Measuring against ITSELF does not work: a
* `display: table` box with width:100% still grows past it when min-content needs more (auto
* layout beats the declared width), so scrollWidth and clientWidth grow together and the
* overflow is invisible. The parent is an ordinary block and does not grow. */
roomFor(el) {
const p = el && el.parentElement;
if (!p) return Infinity;
const cs = getComputedStyle(p);
return p.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
},
/* Does `el` need more width than it has been given? */
overflows(el) {
return el.scrollWidth > this.roomFor(el) + 1; /* +1: sub-pixel rounding */
},
/* How many LINE BOXES of text does `el` render? The fact behind "this column has been
* squeezed into a tower" — a cell that has to break its own words is a column that has run
* out of width, which is a thing no viewport query can ask.
*
* NOT height / line-height: the assoclist's first cell is an `.ifacebadge`, a flex COLUMN
* with a 32px icon over the text, so a third of that height is not text at all. Ranges over
* the TEXT NODES only, so an <img> cannot be counted as a line.
*
* Cluster by the rect's TOP, not by vertical overlap: consecutive lines OVERLAP. Measured on
* the router — tops 15-16px apart while each rect is 17-18px tall (the font's box is taller
* than the line advance), so an overlap test merged an 8-line tower into ONE line and the
* whole check silently never fired. Half a line of tolerance also merges what belongs on one
* line: a `<small>` shares the baseline but sits a few px lower, and is not a new line. */
textLines(el) {
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
const range = document.createRange();
const rects = [];
for (let n; (n = walker.nextNode());) {
if (!n.nodeValue.trim()) continue;
range.selectNodeContents(n);
for (const r of range.getClientRects())
if (r.width > 0.5 && r.height > 0.5) rects.push(r);
}
rects.sort((a, b) => a.top - b.top);
let lines = 0, top = -Infinity;
for (const r of rects)
if (r.top - top > r.height * 0.5) { lines++; top = r.top; }
return lines;
}
});
@@ -0,0 +1,166 @@
'use strict';
'require baseclass';
/* The menu tree as a ROUTING TABLE: path <-> node, with LuCI's alias/firstchild resolution.
*
* Pure lookup — it renders nothing and touches no DOM. Both the chrome (which walks the tree to
* draw the mode menu and the section tabs) and the SPA router (which asks "what view does this URL
* open?") need it, and a module they both require is the only way to give them one copy without a
* cycle: LuCI's require() raises DependencyError on a dependency loop, so the shared half has to
* come OUT rather than be reached across. */
/* the ACL-filtered tree from /admin/menu, handed over once by the chrome's init() */
let _tree = null;
function setTree(tree) {
_tree = tree;
}
/* /cgi-bin/luci/admin/status/overview -> ['admin','status','overview'].
* The bare base (what build_url() emits for the brand wordmark) yields an EMPTY seg list, NOT null:
* the dispatcher's root node is itself a `firstchild`, so resolveSegs([]) walks to the overview
* exactly as the server does — returning null made the wordmark un-routable and full-reload. null
* stays reserved for a path outside LuCI's scriptname. */
function segsFromPath(pathname) {
const base = L.env.scriptname || '';
if (base && pathname.indexOf(base) !== 0)
return null;
const rest = pathname.slice(base.length).replace(/^\/+|\/+$/g, '');
return rest.length ? rest.split('/') : [];
}
/* walk the (scrubbed, ACL-filtered) menu tree to the node for a path */
function nodeForSegs(segs) {
let node = _tree;
for (let i = 0; i < segs.length; i++) {
node = node && node.children && node.children[segs[i]];
if (!node) return null;
}
return node;
}
/* ---- alias / firstchild resolution ----
*
* 7 of the 27 menu links are redirects, not pages: 4 `alias` (Firewall, System Log, Realtime
* Graphs) and 3 `firstchild` (Administration, Terminal, Attended Sysupgrade) — i.e. the
* most-clicked entries were the ones still doing a full load.
*
* The server does not redirect them: a full GET of /admin/status/logs answers 200 at that URL and
* stamps the RESOLVED leaf into requestpath/dispatchpath/nodespec, keeping `pathinfo` as requested.
* The client must resolve EXACTLY as dispatcher.uc does, or a click and an F5 on the same URL would
* open different pages — nodeWeight() and firstChildOf() are ports, not approximations. Only the
* ACL check is skipped: the tree from /admin/menu is already ACL-filtered for this session.
*
* `rewrite` is deliberately NOT followed: the tree has none, and a wrong guess at its splice
* semantics would silently open the WRONG page — worse than the full load it falls back to. */
/* node_weight() from dispatcher.uc: lower wins; a login node sorts last. */
function nodeWeight(node) {
return Math.min(node.order ?? 9999, 9999) + (node.auth && node.auth.login ? 10000 : 0);
}
/* resolve_firstchild() from dispatcher.uc: the eligible child of lowest weight. Ties go to tree
* order (the comparison is strict, as upstream's is, and JSON.parse preserves key order). A
* `firstchild` child is eligible only if it resolves to something itself — recursively. */
function firstChildOf(node) {
let bestName = null, best = null;
const kids = node.children || {};
for (const name in kids) {
const child = kids[name];
if (!child.satisfied || !child.title || !child.action || typeof child.action !== 'object')
continue;
if (child.action.type === 'firstchild') {
if ((!best || nodeWeight(best) > nodeWeight(child)) && firstChildOf(child)) {
best = child; bestName = name;
}
} else if (!child.firstchild_ineligible) {
if (!best || nodeWeight(best) > nodeWeight(child)) {
best = child; bestName = name;
}
}
}
return best ? { name: bestName, node: best } : null;
}
/* Follow alias/firstchild to the real page: {segs, node} of the leaf the dispatcher would have
* rendered, or null when nothing resolves (the server would 404 — let it). The hop cap is a cycle
* guard: an alias loop in some app's menu.d must not hang the UI. */
function resolveSegs(segs) {
let node = nodeForSegs(segs);
for (let hops = 0; node && node.action && hops < 8; hops++) {
const type = node.action.type;
if (type === 'alias') {
segs = String(node.action.path).split('/');
node = nodeForSegs(segs);
} else if (type === 'firstchild') {
const pick = firstChildOf(node);
if (!pick) return null;
segs = segs.concat([ pick.name ]);
node = pick.node;
} else {
return { segs, node };
}
}
return null;
}
/* ---- readonly is a property of the PATH, not of the leaf ----
*
* The dispatcher decides it twice over, from two different inputs. apply_tree_acls()
* (dispatcher.uc:442) walks the menu JSON handed to the client and marks a NODE readonly when its
* OWN `depends.acl` resolves to read-without-write. The request path instead accumulates every
* ancestor's acls into ctx.acls and stamps the leaf from the accumulation
* (`resolved.node.readonly = !perm`, :1003). So a leaf that declares no acl of its own still comes
* back readonly from a real GET, while its node in the tree carries nothing at all.
*
* Reading the leaf alone therefore loses it, and did: measured on the stand, a full load of
* admin/status/logs/syslog reports nodespec.readonly true — the flag sits on `logs`, two levels up —
* against false on an SPA arrival, and the same for dmesg and all four realtime graphs. luci.js
* implements hasViewPermission() as `!env.nodespec.readonly`, which is what views and luci.js's own
* Save/Apply footer key their disabled state off, so on a session with narrower ACLs than root's
* this is any page whose SECTION is read-only.
*
* OR down the path, exactly as check_acl_depends() folds the accumulated list: one read-only acl
* anywhere on it is enough. Feed it the RESOLVED segments, since that is the path the dispatcher
* accumulates over. */
function readonlyForSegs(segs) {
let node = _tree;
if (node && node.readonly === true)
return true;
for (let i = 0; i < segs.length; i++) {
node = node && node.children && node.children[segs[i]];
if (!node)
return false;
if (node.readonly === true)
return true;
}
return false;
}
/* The view class a menu node instantiates, or null if the node isn't SPA-able. The Status→Overview
* `template` node maps to view.status.index (its server template just instantiates that — see
* ensureOverviewHelpers in fs-router.js). Shared by navigate() and the hover prefetch. */
function viewClassFor(node) {
if (!node || !node.action || node.satisfied === false)
return null;
if (node.action.type === 'view')
return 'view.' + String(node.action.path).replace(/\//g, '.');
if (node.action.type === 'template' && node.action.path === 'admin_status/index')
return 'view.status.index';
return null;
}
/* The node the CURRENT full-load landed on, i.e. what L.env.dispatchpath points at. */
function currentNode() {
return nodeForSegs(L.env.dispatchpath || []);
}
return baseclass.extend({
setTree,
tree: () => _tree,
segsFromPath,
currentNode,
resolveSegs,
readonlyForSegs,
viewClassFor
});
@@ -0,0 +1,334 @@
'use strict';
'require baseclass';
'require dom';
'require network';
'require fs-fit as fit';
/* Footstrap overview LAYOUT-only module: renders NOTHING of its own, only re-arranges the STOCK
* sections — wrapping System / Memory / Storage in a grid so Memory and Storage sit in a right
* column beside System. Content, data and styling stay luci-mod-status's. (Do not go back to the
* old 05_footstrap_dashboard.js: re-rendering a custom tree every poll flickered and reset mobile
* scroll.) The stock poll updates each section IN PLACE via dom.content() and never rebuilds the
* .cbi-section wrapper, so once moved into our grid the wrappers stay put across polls.
*
* IT USED TO LIVE IN LuCI'S GLOBAL INCLUDE DIR (view/status/include/05_footstrap_overview_layout.js)
* and that was a real defect, not a filing preference: luci-mod-status loads EVERY *.js in that
* directory, so this file was fetched, parsed and evaluated on the overview of every router running
* a DIFFERENT theme. Measured with a headless browser against the dev container with `bootstrap`
* active: the request is right there beside 10_system.js and 20_memory.js. The `L.env.media` gate
* silenced it, but only after it had already been downloaded and run — a theme package reaching
* into another module's namespace, which is exactly what this project refuses to do to third-party
* apps (docs/conventions.md, the three zones). It is a chrome module now, loaded by the chrome, so a router on
* another theme never sees it at all.
*
* WHAT THAT COST, and why the code below looks the way it does: the old location bought two timing
* guarantees for free, because LuCI evaluated the file INSIDE index.load(). Both had to be paid for
* explicitly — see ensureOverviewHelpers() and patchOverview() at the bottom. */
function isFootstrapTheme() {
return String(L.env.media || '').indexOf('footstrap') >= 0;
}
/* 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 — it used to cost an
* allocation plus three _() lookups per poll tick. */
const ROLES = { [_('System')]: 'sys', [_('Memory')]: 'mem', [_('Storage')]: 'sto' };
function sectionTitle(sec) {
/* TWO title markups, one per release: 25.12 wraps the heading (`.cbi-title > h3`), 24.10
* emits a bare `<h3>` as the section's first child. Matching only the wrapped one meant the
* grid never applied on 24.10 at all — measured on the dev container: every section tagged
* `none`, `.fs-ovl` never built, silently. */
const h = sec.querySelector('.cbi-title h3, :scope > h3');
return (h && h.firstChild) ? String(h.firstChild.nodeValue || '').trim() : '';
}
/* the wrapper we built, so the poll-tick fast path costs one property read */
let _wrapEl = null;
function arrange() {
/* the SPA nav can leave this _observer wired while another page renders into #view — detach as
* soon as the route stops being the overview. Both the server template and the SPA router
* stamp body[data-page] with the DISPATCH path, so /admin/status (firstchild -> overview)
* matches too. */
if ((document.body.getAttribute('data-page') || '') !== 'admin-status-overview') {
stopWatch();
return;
}
const view = document.getElementById('view');
if (!view) return;
/* Fast path — the poll lands here once a second, forever. The stock poll never rebuilds the
* .cbi-section wrappers, so the grid survives and there is nothing to do; proving that used
* to cost a querySelectorAll over #view plus a sectionTitle() dig per section, every tick.
* Deliberately NOT a disconnect(): if a future luci-mod-status ever DOES rebuild a section,
* the wrapper loses its children and the slow path below rebuilds the grid — self-healing. */
if (_wrapEl && _wrapEl.isConnected && _wrapEl.parentElement === view && _wrapEl.children.length === 3)
return;
const found = {};
view.querySelectorAll(':scope > .cbi-section').forEach((sec) => {
const r = ROLES[sectionTitle(sec)];
if (r && !found[r]) found[r] = sec;
});
/* wait until all three stock sections exist */
if (!(found.sys && found.mem && found.sto)) return;
/* already wrapped? (first tick after a rebuild re-finds the existing grid) */
if (found.sys.parentElement && found.sys.parentElement.classList.contains('fs-ovl')) {
_wrapEl = found.sys.parentElement;
return;
}
const wrap = document.createElement('div');
wrap.className = 'fs-ovl';
found.sys.parentNode.insertBefore(wrap, found.sys);
found.sys.classList.add('fs-ovl-sys'); wrap.appendChild(found.sys);
found.mem.classList.add('fs-ovl-mem'); wrap.appendChild(found.mem);
found.sto.classList.add('fs-ovl-sto'); wrap.appendChild(found.sto);
_wrapEl = wrap;
}
/* Stock sections render async and repaint every poll, so watch #view and re-run arrange()
* (coalesced, ONE _observer per #view node — a per-poll _observer leak would slow the page down).
* The SPA router may REPLACE the #view element between visits, so re-attach when the node we
* observed is no longer the current one: a singleton bound to the first #view would silently
* watch a detached tree and the grid would never apply on a later SPA visit. */
let _observer = null, _observedView = null, _routeObserver = null;
function stopWatch() {
if (_observer) _observer.disconnect();
_observer = null;
_observedView = null;
_wrapEl = null; /* the grid belongs to the #view we are leaving */
}
function watch() {
const view = document.getElementById('view');
if (_observer && _observedView !== view)
stopWatch();
arrange();
/* The route check is what the stock include's render() used to provide implicitly — it only ran
* when luci-mod-status rendered THIS page. A chrome module is alive on every page, so without
* this an observer would be attached to #view on, say, the firewall page and re-run arrange()
* for every mutation of a table it has no business watching. */
if (_observer || !view ||
(document.body.getAttribute('data-page') || '') !== 'admin-status-overview')
return;
_observedView = view;
/* 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));
_observer.observe(view, { childList: true, subtree: true });
}
/* WHAT REPLACED render(). The include was instantiated by luci-mod-status once per overview render,
* which is how it knew a visit had happened; a chrome module is instantiated once per PAGE LOAD and
* then has to notice SPA navigation itself. `body[data-page]` is the signal — both the server
* template and fs-router stamp it with the dispatch path — so one attribute observer covers arriving
* at the overview, leaving it, and coming back.
*
* The empty `.cbi-section` wrapper stock used to build around this include is gone with it, and so
* is the `.fs-ovl-marker` element that existed only to let CSS hide that wrapper. */
function wire() {
if (_routeObserver || !isFootstrapTheme() || !document.body)
return;
_routeObserver = new MutationObserver(() => {
if ((document.body.getAttribute('data-page') || '') === 'admin-status-overview')
onOverview();
else
stopWatch();
});
_routeObserver.observe(document.body, { attributes: true, attributeFilter: [ 'data-page' ] });
if ((document.body.getAttribute('data-page') || '') === 'admin-status-overview')
onOverview();
}
/* Everything that must happen on ARRIVAL at the overview, from either direction: a full page load
* that lands here, or an SPA navigation that restamps data-page. patchOverview() is idempotent
* (the __fsProgressive flag), so the two paths cannot double-patch. */
function onOverview() {
patchOverview();
watch();
}
/* ---- progressive paint -----------------------------------------------------
*
* Stock `view.status.index` calls poll_status(first_load=true), which Promise.all's over EVERY
* include's load(), and render() does not return the tree until it resolves — so #view stays
* EMPTY for as long as the slowest include takes. Measured on the dev router (warm SPA nav):
* 182 ms of blank page, of which System/CPU/Memory/Storage/DHCP/Network were ready at 88 ms and
* were simply waiting on 29_ports and 60_wifi (180 ms each).
*
* Replacing poll_status does two things:
* 1. Each section paints when ITS OWN data lands: first content halves, 182 -> ~90 ms. Nothing
* jumps — the frames are already in the DOM (built before poll_status is called), a section
* just goes hidden -> filled, exactly as on a stock poll tick.
* 2. Kills the redundant re-fetch: stock adds the poller only after the first load completes
* and Poll.add() steps at once, so the overview re-fetched EVERYTHING (~250 ms of ubus)
* right after the first paint. The in-flight guard joins that to the run already going.
*
* NOT a re-implementation — frames, toggles, includes and their render() stay upstream's.
* fillSection() transcribes stock's own loop in the same order so it can be diffed against
* index.js when luci-mod-status changes; if that shape is gone, the patch is skipped and the
* page runs stock. */
function fillSection(inc, container, res) {
if (inc.failed)
return;
let content = null;
if (typeof inc.render === 'function')
content = inc.render(res);
else if (inc.content != null)
content = inc.content;
if (typeof inc.oneshot === 'function') {
inc.oneshot(res);
inc.oneshot = null;
}
if (content != null) {
container.parentNode.style.display = '';
container.parentNode.classList.add('fade-in');
if (!inc.hide)
dom.content(container, content);
}
}
let _inflight = null;
function pollProgressive(includes, containers, first_load) {
/* A run is already fetching exactly this data — join it instead of starting a second
* stampede of the same RPCs. This is what kills the duplicate load. */
if (_inflight)
return first_load ? Promise.resolve() : _inflight;
const run = network.flushCache().then(() => Promise.all(
includes.map((inc, i) => {
if (inc.hide && !first_load)
return null;
const loaded = (typeof inc.load === 'function')
? Promise.resolve(inc.load()).catch(() => { inc.failed = true; })
: Promise.resolve(null);
/* the point of the patch: fill THIS section the moment ITS data is here,
* not at the end of a Promise.all over all of them */
return loaded.then((res) => {
try { fillSection(inc, containers[i], res); }
catch (e) { console.error('footstrap: overview section failed', e); }
});
}).filter(Boolean)
)).then(() => {
const ssi = document.querySelector('div.includes');
if (ssi) { ssi.style.display = ''; ssi.classList.add('fade-in'); }
});
_inflight = run.finally(() => { _inflight = null; });
/* NOBODY AWAITS THIS ON THE FIRST LOAD — the line below hands the caller a fresh
* Promise.resolve() so index.render() can return at once — so a rejection here has no handler
* and surfaces as an unhandled rejection in the console. `run` rejects for one ordinary reason:
* network.flushCache() failing on an expired session, i.e. exactly when the user is already
* being redirected to the login page and the noise is least useful. The sections' own failures
* cannot reach it (fillSection is called inside a try/catch and inc.load() has its own .catch),
* so there is nothing to report that the page has not reported already. */
_inflight.catch(() => {});
/* First load: resolve NOW so index.render() returns its tree and the frames reach #view
* immediately; the sections fill themselves. A poll tick resolves when the data is in —
* that is what the poller expects. */
return first_load ? Promise.resolve() : _inflight;
}
/* Patch the stock overview view: replace poll_status so each section paints when its own data lands.
*
* TIMING, AND WHAT MOVING THE FILE COST. As an include this ran at module eval — which LuCI performs
* inside index.load(), i.e. after the view instance exists and before render() calls poll_status:
* the exact window the patch needs, for free, on both a full load and an SPA nav.
*
* A chrome module evaluates much earlier, so that window has to be aimed at rather than inherited,
* and it is called from the ROUTE (see wire()) instead of at eval. Two reasons it is not called at
* module eval any more: `L.require('view.status.index')` would pull the whole stock overview view
* into memory on every page, including pages that are not the overview; and on a full page load the
* require would race index.load() — which is why the patch is idempotent, guarded by the
* `__fsProgressive` flag on the prototype, and why failing to land is harmless. If it misses, the
* page simply renders the stock way: one Promise.all, ~90 ms later. Never broken, sometimes slower.
*/
function patchOverview() {
/* `window.L`, NEVER the bare `L` this factory was handed — and the reason is NOT this module's
* own convenience, it is what the STOCK view ends up holding. `require()` passes the object it
* was called on into the loaded module's factory (`const L = this` in luci.js), and
* `view/status/index.js` then loads its own includes with that same `L` inside `load()`
* (`L.require('view.status.include.' + …)`), so whichever `L` reaches index.js reaches
* 30_network.js too — which calls `L.itemlist(...)` directly. `ui` hangs itemlist/showModal/…
* on the RUNTIME INSTANCE that the dispatcher builds (`window.L = new LuCI()`), and a chrome
* module like this one is loaded as a dependency, i.e. through `LuCI.prototype.require`, so our
* `L` is the PROTOTYPE and has none of them.
*
* `require()` caches by class name, so the FIRST caller decides this for everybody: patching
* through the bare `L` cached view.status.index bound to the prototype, and the overview then
* died mid-render on "L.itemlist is not a function" with the page stuck on "Loading view…"
* (issue #22 follow-up). It only bit on an SPA arrival — a full page load has the dispatcher
* require the view through `window.L` before this observer ever fires — and only when this
* patch won the race against fs-router's own `RT.require`, which is exactly the "sometimes,
* coming from another page" the report described. Same trap as the itemlist calls below
* (docs/spa-router.md), reached from the other side. */
window.L.require('view.status.index').then((idx) => {
const proto = idx ? Object.getPrototypeOf(idx) : null;
if (!proto || proto.__fsProgressive || typeof proto.poll_status !== 'function')
return;
proto.__fsProgressive = true;
proto.poll_status = function(includes, containers, first_load) {
return pollProgressive(includes, containers, first_load);
};
}).catch((e) => console.error('footstrap: overview progressive paint not applied', e));
}
/* Status→Overview is a `template` node whose server template (admin_status/index.ut) defines 3
* globals the stock status includes use (18_cpu/20_memory/25_storage/…) and then instantiates
* view.status.index. Arriving by the theme's SPA router never runs that inline <script>, so define
* them here, guarded, so a full load's copies (any theme) are not clobbered.
*
* This is the OTHER thing the include location used to give for free, and the one that survived the
* move intact: the definitions had to exist before any include renders, which module eval inside
* index.load() guaranteed. A chrome module evaluates once, at chrome init — i.e. before any SPA
* navigation can possibly happen — so the guarantee is now stronger rather than weaker. On a full
* load of the overview the template's own copies win the race and these are no-ops, as before.
*
* Bodies are verbatim from upstream except L.itemlist → window.L.itemlist (the two-L trap,
* docs/spa-router.md). */
function ensureOverviewHelpers() {
/* eslint-disable no-var -- these three bodies are copied VERBATIM from LuCI's
admin_status/index.ut so they can be diffed against upstream when it changes.
Modernising the `var`s would silently break that property, which is the whole
reason the copies are safe to carry. */
if (typeof window.progressbar !== 'function')
window.progressbar = function(query, value, max, byte) {
var pg = document.querySelector(query),
vn = parseInt(value) || 0,
mn = parseInt(max) || 100,
fv = byte ? String.format('%1024.2mB', value) : value,
fm = byte ? String.format('%1024.2mB', max) : max,
pc = Math.floor((100 / mn) * vn);
if (pg) {
pg.firstElementChild.style.width = pc + '%';
pg.setAttribute('title', '%s / %s (%d%%)'.format(fv, fm, pc));
}
};
if (typeof window.renderBox !== 'function')
window.renderBox = function(title, active, childs) {
childs = childs || [];
childs.unshift(window.L.itemlist(E('span'), [].slice.call(arguments, 3)));
return E('div', { class: 'ifacebox' }, [
E('div', { class: 'ifacebox-head center ' + (active ? 'active' : '') },
E('strong', title)),
E('div', { class: 'ifacebox-body left' }, childs)
]);
};
if (typeof window.renderBadge !== 'function')
window.renderBadge = function(icon, title) {
return E('span', { class: 'ifacebadge' }, [
E('img', { src: icon, title: title || '' }),
window.L.itemlist(E('span'), [].slice.call(arguments, 2))
]);
};
/* eslint-enable no-var */
}
/* Unconditional: the typeof guards make it a no-op wherever the real definitions already exist. */
ensureOverviewHelpers();
return baseclass.extend({
/* Called by menu-footstrap-common's init, once. Everything route-dependent hangs off the
* data-page observer inside. */
wire,
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,836 @@
'use strict';
'require baseclass';
'require ui';
'require fs-menutree as tree';
'require fs-chrome as chrome';
'require fs-sheets as sheets';
/* ---- SPA client router ----
*
* Kills the full page reload for `view`-type menu nodes — 54 of 74 menu leaves (~73%) on the dev
* router; the rest are call/function/template. LuCI already renders every page client-side into
* #view; only NAVIGATION is server-dispatched. So intercept link clicks and re-instantiate the
* target view in place — what the dispatcher's view.ut does via ui.instantiateView(), minus the
* reload. Purely additive: anything that is not a satisfied `view` node (call/function/template/
* alias/firstchild, external, download, cross-origin, modified click) or any error falls through to
* a normal navigation, and deep links / F5 keep working because we pushState the real URL.
*
* Re-instantiation: L.require('view.x') returns a cached SINGLETON whose __init__ (the render)
* already ran, so calling it again repaints nothing. Take the class off the instance
* (prototype.constructor) and `new v.constructor()` for a fresh __init__ → load()+render(), which is
* what a full load does anyway. docs/spa-router.md.
*
* 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. */
/* --- stray-interval teardown for SPA nav ---
* A full load kills every window.setInterval the outgoing page set; SPA nav does not, so a view's
* poller keeps firing against a page that is gone (luci-app-podkop's log tailer runs
* `podkop check_logs` forever after you navigate away). Track view-set ids and clear them on nav,
* keeping L.Poll's own 1s tick (also a setInterval); L.Poll's queue is flushed in navigate().
* Hooked at module eval — before any view render can set a timer, and LuCI resolves a module's
* dependencies BEFORE running the dependent's factory, so this runs no later than it used to when
* it sat in menu-footstrap-common.js itself. */
const _viewIntervals = (window.__fsViewIntervals || (window.__fsViewIntervals = new Set()));
(function hookIntervals() {
if (window.__fsIntervalsHooked) return;
window.__fsIntervalsHooked = true;
const _si = window.setInterval, _ci = window.clearInterval;
window.setInterval = function () {
const id = _si.apply(window, arguments);
_viewIntervals.add(id);
return id;
};
window.clearInterval = function (id) {
_viewIntervals.delete(id);
return _ci.apply(window, arguments);
};
})();
function clearViewIntervals() {
const keep = (L.Poll && L.Poll.timer) || null;
_viewIntervals.forEach((id) => { if (id !== keep) window.clearInterval(id); });
}
let _wired = false;
/* The pathname whose view is CURRENTLY rendered — popstate compares against it to tell a real
* navigation from a mere fragment change (see there). Seeded from the served page. */
let _curPath = window.location.pathname;
/* nav generation token: two quick clicks race their async require()s, and without it the FIRST
* view could render into #view after the second, leaving stale content under the newer
* URL/title/chrome. A resolved require whose generation is stale renders nothing. */
let _navGen = 0;
/* ---- Back must restore the scroll of WHICHEVER element is the scroller ----
* The two layouts scroll different elements: the sidebar layout pins .fs-shell to 100dvh and gives
* overflow-y to .fs-main (#maincontent), the top layout lets the document scroll. A browser restores
* an INNER scrollable region only across full loads, never on a same-document traversal — measured
* (docs/spa-router.md §2): Back opened the incoming page at 0, because the swap empties #view,
* scrollHeight collapses and the browser clamps scrollTop.
*
* The DOCUMENT scroller was left to the browser's own scrollRestoration ('auto') on the grounds that
* it is the case a UA does handle — and that is wrong for the same reason, measured on the stand: in
* the top layout, scroll Processes to 400, open System, press Back, and the page opens at 0 (the
* sidebar layout, restored here, comes back at 400). The UA restores at the traversal, i.e. BEFORE
* this handler swaps #view; the height then collapses under the restored offset and the clamp takes
* it back to 0, with nothing left to re-apply it. So record and replay BOTH offsets; the one that is
* not this layout's scroller is 0 and skipped.
*
* NOT by replaceState on scroll: Safari rate-limits history writes (100 per 30 s) and a scroll
* listener trips it. Each SPA entry instead carries a session-unique id (fsid) in history.state, and
* the offsets live in this in-memory Map — lost on a full load, which is exactly when the browser's
* own scrollable-region restoration takes over. The id is session-prefixed because a bare counter
* restarts with every document: an entry stamped by a PREVIOUS document of this tab would collide
* with a fresh one and restore another page's offset. */
const _scrollMem = new Map();
const _scrollSess = Date.now().toString(36) + Math.floor(Math.random() * 1e6).toString(36);
let _histN = 0;
let _curId = null;
/* …and it is BOUNDED, because this document outlives every page in it. One entry per history entry
* per session, never evicted, is the same shape as the leaks this file already fixes elsewhere (the
* action-column resize listener, the view pollers) — small enough that nobody would see it and
* unbounded all the same. A browser keeps ~50 entries per tab and the ones past that cannot be
* traversed back to, so remembering more offsets than that can never be read. Least-recently-SAVED
* goes first: a Map iterates in insertion order and `set` on an existing key does not refresh it,
* so the re-save below is delete-then-set. */
const SCROLL_MEM_MAX = 50;
function newEntryId() { return _scrollSess + ':' + (++_histN); }
/* adopt the entry we are standing on: reuse its fsid if it has one, stamp one otherwise (entries
* created by a full load carry state === null). replaceState keeps the entry's own scroll record. */
function adoptEntry() {
const st = history.state;
if (st && st.fsid) { _curId = st.fsid; return; }
_curId = newEntryId();
try { history.replaceState(Object.assign({}, st, { fsid: _curId }), '', window.location.href); } catch (e) {}
}
/* the outgoing DOM is still on screen at both call sites (the click, the popstate), so this must
* run BEFORE _curId moves on to the incoming entry */
function saveScroll() {
if (!_curId) return;
const sc = document.getElementById('maincontent');
_scrollMem.delete(_curId); /* re-insert, so this entry counts as the most recently saved */
_scrollMem.set(_curId, { win: Math.round(window.scrollY) || 0, main: sc ? sc.scrollTop : 0 });
while (_scrollMem.size > SCROLL_MEM_MAX)
_scrollMem.delete(_scrollMem.keys().next().value);
}
/* Put the scrollers back where the entry left them — but only once the incoming view has grown that
* much height (docs/spa-router.md §5: restoring before the content exists is clamped to 0 and reads as
* "worked"). The view renders behind an RPC, so poll by frame; a newer navigation cancels via the
* generation, and a page that never reaches the old height again is simply left at the top. Each
* offset is waited for on its OWN scroller, so a layout switched between the two entries restores
* whichever half it can rather than blocking on the half that no longer scrolls. */
function restoreScroll(pos, gen) {
if (!pos || (!pos.win && !pos.main)) return;
let tries = 300; /* ~5 s at 60 fps — outlasts a slow RPC without polling forever */
(function tick() {
if (gen !== _navGen || --tries < 0) return;
const de = document.documentElement;
const sc = document.getElementById('maincontent');
let pending = false;
if (pos.main) {
if (sc && sc.scrollHeight - sc.clientHeight >= pos.main) sc.scrollTop = pos.main;
else pending = true;
}
if (pos.win) {
if (de.scrollHeight - de.clientHeight >= pos.win) window.scrollTo(0, pos.win);
else pending = true;
}
if (pending) requestAnimationFrame(tick);
})();
}
/* ---- the host half of "<host> | <page>" is read ONCE ----
* head.ut stamps it and it cannot change within a document, but navigate() used to re-derive it from
* the LIVE document.title on every hop — so any page that renames the tab became the host for every
* page after it, until a full load. Third-party views do rename it (log viewers, dashboards), and it
* takes only one: measured on the stand, a view setting `document.title = 'ACME Dashboard'` left the
* next hops reading "ACME Dashboard | Routing", "ACME Dashboard | System". Captured at seed(), i.e.
* at chrome init on the full load that started the session; the lazy branch is for a document whose
* chrome came up without seed() having run. */
let _titleHost = null;
function titleHost() {
if (_titleHost === null)
_titleHost = (document.title.split('|')[0] || '').trim();
return _titleHost;
}
/* The view class the page CURRENTLY on screen wants (what _curPath resolves to). Read by the
* stale-render repair below to tell "the superseded render happened to paint the right view
* anyway" from "it painted the wrong one". */
function currentViewClass() {
const segs = tree.segsFromPath(_curPath);
const res = segs && tree.resolveSegs(segs);
return tree.viewClassFor(res && res.node);
}
/* ---- a superseded FIRST render cannot be cancelled, so undo it ----
*
* _navGen stops a stale require() from calling `new view.constructor()` — but only on the CACHED
* path. On a FIRST visit the require() IS the render (see navigate()): it constructs the view, whose
* __init__ runs load() → render() → dom.content(#view) and registers its pollers, inside a promise
* we do not own. Nothing to cancel.
*
* So the fast double-click is a real bug: click Firewall (uncached), click Wireless 100 ms later.
* navigate(Wireless) flushes L.Poll's queue BEFORE Firewall's poller is added; Firewall then paints
* into the #view that now belongs to Wireless and registers a poller the flush can no longer catch —
* leaving Wireless's URL/title/menu/data-page, Firewall's content, and Firewall's poller running on
* every page afterwards.
*
* Repair by re-running the current navigation: navigate() is exactly the "put the document back the
* way a fresh load leaves it" routine. push=false — the URL never moved, only the DOM under it; if
* it declines (the superseded view injected CSS), the reload does it the hard way. The className
* check terminates this: if the superseded render painted the class the current path wants anyway
* (A → B → A while A was still loading), the DOM and its poller are correct — and with two uncached
* views racing it is also what stops a repair triggering a repair. */
function repairStaleRender(className) {
if (className === currentViewClass())
return;
console.warn('footstrap: a superseded view (' + className + ') rendered into the live page; re-rendering ' + _curPath);
if (!navigate(_curPath, false))
window.location.reload();
}
/* ---- the generation must be checked at the PAINT, not at the dispatch ----
*
* _navGen is checked once, in the require() callback, i.e. BEFORE `new view.constructor()`. But a
* view's __init__ is async — `ready.then(this.load).then(this.render).then(nodes =>
* dom.content(document.getElementById('view'), nodes))` — so the DOM write happens two awaits later,
* and every await is a point at which the whole event loop runs, another navigation included. The
* dispatch-time check has expired by the time it matters.
*
* Measured on the router: leave the (cached) package manager for System after 150 ms, and the paints
* into #view land 16010 System, 16490 package-manager — the view we walked away from paints LAST and
* wins, permanently. URL, <title>, data-page and the menu highlight all say System; #view shows
* Software. Only a reload clears it. The cached path is the COMMON one — after warm-up every
* navigation takes it — and it had no guard at all: repairStaleRender() is called only when !cached.
*
* The fix has to be a paint-time check, and there are two constraints on where it can live.
* ClassConstructor DISCARDS __init__'s return value (`this.__init__.apply(this, arguments)`, no
* return), so the construction promise is unreachable — nothing to await. But __init__ resolves
* `this.render` while it builds its chain, i.e. during `new`, so a wrapper installed on the
* prototype BEFORE the construct is the one that gets bound. `new` then returns synchronously (the
* first load() is already a microtask), which is what makes stamping the generation on the INSTANCE
* right after it safe — and it must be the instance, not a map keyed by class name, or A → B → A
* with everything cached would have the second construct's generation overwrite the first's.
*
* A superseded render resolves to a promise that never settles: the chain simply stops before
* dom.content(), so nothing paints and addFooter() never runs. Returning empty nodes instead would
* paint the emptiness over the live page, and throwing would hand LuCI's own .catch an error box to
* render into the page we just opened.
*
* A view instance we did not construct (the singleton require() builds on a FIRST visit — the render
* IS the require, so there is no window in which to arm anything) carries no stamp and is left alone:
* that path is repairStaleRender()'s, and stays its. */
const _guarded = new WeakSet();
function armRenderGuard(cls) {
if (_guarded.has(cls)) return;
_guarded.add(cls);
const orig = cls.prototype.render;
cls.prototype.render = function () {
/* unstamped: not ours to judge (see above) */
const stale = () => this.__fsGen !== undefined && this.__fsGen !== _navGen;
if (stale())
return new Promise(() => {});
return Promise.resolve(orig.apply(this, arguments)).then((nodes) => {
/* re-check: render() itself awaits (an RPC, usually), which re-opens the window */
return stale() ? new Promise(() => {}) : nodes;
});
};
}
/* The exact URL LuCI.require() will fetch for a class name, cache-bust and all. Matching it
* byte-for-byte is what makes a hover prefetch a warm cache hit for the later require(). */
function moduleUrl(className) {
const v = L.env.resource_version ? ('?v=' + L.env.resource_version) : '';
return (L.env.base_url || '') + '/' + className.replace(/\./g, '/') + '.js' + v;
}
/* ---- link prefetch: warm the module cache for a page the user is about to open ----
*
* A plain fetch(), NOT require(): require() instantiates, and a view's __init__ IS its render, so it
* would paint another page into #view. fetch() only fills the browser's HTTP cache, which the later
* require()'s XHR then hits. Deduped per class; failures are silent (a pure optimisation).
*
* TRANSITIVE, and that is where most of the win is: warming the view class alone leaves its own
* `require` pragmas one round-trip behind it. view/network/routes.js pulls tools/network.js (40.5 KB),
* and measured on the dev router at 120 ms RTT a first visit cost 418 ms with the view warmed against
* 296 ms with its deps warmed too — one RTT exactly. Over six pages, 1713 ms cold → 1184 warmed →
* 1052 warmed transitively. The bytes are in hand either way, so the scan is free.
*
* The scan MUST NOT be line-anchored. The shipped files are MINIFIED and every pragma sits on one
* line (`'use strict';'require view';'require fs';…`), so /^'require …'$/m matches nothing at all —
* silently, which is how the first attempt at this measured a win of zero. luci.js lexes the leading
* string literals; this reads the same head of the file with one regex. */
const PRAGMA_HEAD = 2000; /* bytes of leading literals to scan — luci.js stops at the first non-string token */
const PREFETCH_DEPTH = 3;
function pragmaDeps(src) {
const re = /(['"])require[ \t]+([^'"]+?)\1/g;
const head = src.slice(0, PRAGMA_HEAD);
const out = [];
let m;
while ((m = re.exec(head)))
out.push(m[2].split(/[ \t]+as[ \t]+/)[0]);
return out;
}
/* SIX CLASS NAMES HAVE NO FILE, and fetching one is a guaranteed 404 in the user's console. luci.js
* seeds its class registry with them at load — `const classes = { baseclass: Class, dom: DOM,
* poll: Poll, request: Request, session: Session, view: View }` — so require() answers them from
* memory and never asks the network, while `ls /www/luci-static/resources` has no baseclass.js,
* dom.js, poll.js, request.js or session.js at all. Every view file's pragmas name `view` and
* `baseclass`, so a dependency walk hits this on its very first step: measured, warming the recents
* at idle put 404s for view.js and poll.js in the console of every page load, and the first hover
* added baseclass.js. A require() that 404s is noise this theme refuses to make, and the same
* standard applies here.
*
* A future LuCI adding a seventh built-in would cost one 404 per session — `_prefetched` makes it
* at most one — and the list is a literal from luci.js, not a guess about it. */
const BUILTIN_CLASSES = new Set([ 'baseclass', 'dom', 'poll', 'request', 'session', 'view' ]);
/* Is this class already instantiated? require() attaches its singleton to the LuCI prototype
* (`ptr[parts[idx]] = instance`), so a SINGLE-segment name reads back as L[name] — which covers the
* libs a loaded page has already pulled in (ui, form, uci, rpc, fs, validation, and network/firewall
* once some network page has been open). Skipping them spends no request on a cache hit nobody needs.
* `instanceof L.Class` rather than a truthiness test: L.env, L.url and L.get are members too, and a
* dep whose name collided with one of those would otherwise be skipped without ever being loaded —
* which is why this cannot be the guard for the six above either: they are seeded as CONSTRUCTORS
* (and under other names — L.Poll, L.Request, L.Class), so no `instanceof` probe sees them. A DOTTED
* name (tools.network) is not attached unless its parent already exists, so it is simply fetched —
* and those are the ones the win comes from. */
function classLoaded(name) {
if (BUILTIN_CLASSES.has(name)) return true;
try { return name.indexOf('.') < 0 && window.L[name] instanceof window.L.Class; }
catch (e) { return false; }
}
/* view classes already required, i.e. the ones LuCI has an instance cached for. A class NOT in
* here is rendered by the require() itself (see navigate). */
const _seen = new Set();
const _prefetched = new Set();
/* className -> the promise of ITS OWN body being in the HTTP cache; navigate() waits on that.
* Deliberately the body and not the subtree, see _committed below. */
const _warming = new Map();
/* Roots a navigation has taken over. Speculation below them STOPS: require() is now fetching the same
* graph and pipelines its parse and eval against those fetches, so descending would only race it —
* measured at 120 ms RTT, waiting for the whole subtree instead cost 658 ms against 525 ms racing,
* for the sake of a duplicate that stopping avoids outright. Deps have not been asked for yet when a
* click arrives (they start only once the root body lands), so there is nothing in flight to collide
* with: this leaves zero duplicated bytes AND require()'s pipelining. */
const _committed = new Set();
function warmClass(name, depth, root) {
if (_prefetched.has(name)) return;
_prefetched.add(name);
if (classLoaded(name)) return;
let req;
try { req = fetch(moduleUrl(name), { credentials: 'same-origin' }); }
catch (e) { return; }
const body = req.then((res) => (res.ok ? res.text() : '')).catch(() => '');
_warming.set(name, body.then(() => {}, () => {}));
/* The visited set is global and the depth capped, so the walk terminates regardless of what the
* pragmas say — require() raises DependencyError on a cycle, but only for classes it actually
* loads, and this walks files it may never hand to require() at all. */
if (depth < PREFETCH_DEPTH)
body.then((src) => {
if (_committed.has(root)) return;
for (const d of pragmaDeps(src)) warmClass(d, depth + 1, root);
});
}
/* Warm the view a menu path resolves to, plus its dependency tree. `segs` is the menu path
* (`admin/network/routes`), the shape fs-search stores its recents in. */
function prefetchSegs(segs) {
if (!Array.isArray(segs) || !segs.length) return;
const res = tree.resolveSegs(segs);
const className = tree.viewClassFor(res && res.node);
if (className) warmClass(className, 0, className);
}
function prefetchView(pathname) {
const segs = tree.segsFromPath(pathname);
if (segs) prefetchSegs(segs);
}
/* Wait for an in-flight prefetch of `className` instead of racing it — see the call site. Capped,
* because a wedged prefetch must never wedge a navigation: on a stalled connection require()'s own
* XHR and its error path are the better place to end up. */
const WARM_WAIT_MS = 5000;
function warmedThen(className) {
_committed.add(className);
const body = _warming.get(className);
if (!body) return Promise.resolve();
/* the loser of the race is cancelled: a prefetch that lands in 40 ms otherwise leaves a 5 s
* timer armed behind every single navigation, doing nothing but keeping its closure alive */
let t = 0;
return Promise.race([ body, new Promise((r) => { t = window.setTimeout(r, WARM_WAIT_MS); }) ])
.finally(() => window.clearTimeout(t));
}
/* The page we are standing on arrived as a full load, so LuCI has ALREADY required — hence
* instantiated and rendered — its view. Seed `_seen`, or the first SPA nav BACK to this page would
* take require()'s cached instance, skip the re-instantiation and render nothing at all. */
function seed() {
const here = tree.viewClassFor(tree.currentNode());
if (here)
_seen.add(here);
titleHost(); /* before any view can rename the tab — see there */
/* the served page's entry needs an id too, or the first Back TO it has nothing to look up */
adoptEntry();
}
/* Attempt an in-place navigation to `pathname`. Returns true if handled as a
* SPA nav (caller should preventDefault), false to let the browser do a normal
* full navigation. `push` adds a history entry (false when replaying popstate).
* `kbd` — the navigation was activated from the keyboard (see the focus block). */
function navigate(pathname, push, kbd) {
const segs = tree.segsFromPath(pathname);
if (!segs) return false;
/* The view on screen injected CSS that can repaint any page: this document is spent, and
* the only exit that leaves BOTH pages correct is a real navigation. See fs-sheets.js. */
if (sheets.documentPoisoned()) return false;
/* `segs` is what the user clicked, `rsegs` the leaf it resolves to; they differ for an
* alias/firstchild link, and a full load keeps BOTH — URL and pathinfo as requested,
* requestpath/dispatchpath/nodespec/title resolved. Mirror that split exactly, or an F5
* lands somewhere the click did not. */
const res = tree.resolveSegs(segs);
const node = res && res.node;
const className = tree.viewClassFor(node);
if (!className)
return false;
const rsegs = res.segs;
/* from here on the navigation is committed */
const gen = ++_navGen;
_curPath = pathname; /* what is on screen from now on — read by the popstate handler */
/* Ensure a #view, and clear what the OUTGOING page left as a SIBLING of #view inside .fs-content:
* dom.content() replaces only #view's OWN children, so anything a page emitted next to it rides
* along — the Status→Overview template emits <h2 name="content">Status</h2> there, hidden only
* by a body[data-page='admin-status-overview'] rule, so after an SPA nav the orphan showed on
* EVERY page until a full reload. Keep only the chrome that legitimately outlives a page (tabs,
* server notices, <noscript>); this also gives a template page that emits no #view a fresh one. */
const contentHost = document.querySelector('.fs-content');
if (!contentHost) return false;
Array.from(contentHost.children).forEach((c) => {
if (c.id !== 'view' && c.id !== 'tabmenu' &&
!c.classList.contains('alert-message') && c.nodeName !== 'NOSCRIPT')
c.remove();
});
/* …and the RUNTIME notifications, which live one level up and therefore survived every
* navigation. `ui.addNotification()` does `mc.insertBefore(msg, mc.firstElementChild)` on
* #maincontent — this theme's <main class="fs-main"> — while the sweep above only reaches
* children of .fs-content, a descendant of it. A full load clears them; SPA never did, so
* "Upload request failed" and every third-party banner stacked up over each following page for
* the rest of the session (reproduced: the banner outlives a nav, and only F5 removes it).
* The .fs-content banners kept above are the SERVER's notices (notices.ut) and legitimately
* outlive a page; these are not the same thing. */
const mainHost = document.getElementById('maincontent');
if (mainHost)
Array.from(mainHost.children).forEach((c) => {
if (c.classList.contains('alert-message')) c.remove();
});
if (!document.getElementById('view')) {
const v = document.createElement('div');
v.id = 'view';
contentHost.appendChild(v);
}
/* SAY THAT SOMETHING IS LOADING, on a route whose module has never been fetched.
*
* The chrome switches instantly — title, URL, body[data-page], the menu highlight — but the
* sweep above deliberately does not touch the children of #view, and LuCI's own spinner is
* written by `View.__init__`, i.e. only once the view module has ARRIVED. So between the click
* and the last round-trip of the module chain the user reads the PREVIOUS page's content under
* the new page's title, with nothing moving. Measured on the router at 600 ms latency: at 150 ms
* and 400 ms #view still held the System page while everything else said Processes; the
* "Loading view…" spinner appeared at 900 ms and the content at 1800 ms. A full load would have
* shown the browser's own progress for that whole window.
*
* Only for a COLD route: `_seen` means the class has already been through require(), where
* View.__init__ paints its spinner synchronously and there is no gap to fill.
*
* Deliberately the SAME markup and the SAME msgid luci-base uses, not a skeleton of our own:
* when View.__init__ replaces it there is nothing to see, and the string arrives already
* translated in the ~40 languages this theme ships no catalogue for (the chrome rule — a
* context-free msgid is how we inherit luci-base's translation). */
if (!_seen.has(tree.viewClassFor(node))) {
const vp = document.getElementById('view');
if (vp) {
const s = document.createElement('div');
s.className = 'spinning';
s.textContent = _('Loading view…');
vp.replaceChildren(s);
}
}
/* teardown: drop the outgoing view's pollers, then put the poll loop back into the state a FRESH
* LOAD leaves it in. The only non-view poller LuCI adds is the transient apply/reboot
* reachability check, so flushing the queue is safe.
*
* The re-arm matters: LuCI runs one 1 s tick and fires a queue entry only when
* `tick % interval == 0`, so leaving the OUTGOING page's tick running makes the incoming poller
* wait for the next multiple of its interval — up to `pollinterval`, 5 s. Wireless draws its
* station list from the first poll and sat spinning for 4950 ms against ~360 ms on a full load.
*
* stop() alone is NOT the fix: it deletes `tick`, and Poll.add() only auto-starts when
* `tick != null`, so the incoming pollers would never start at all. stop()+start() on an EMPTY
* queue leaves what a fresh document has (`tick = 0`, no timer armed); the view's first
* poll.add() then starts it and steps immediately — upstream's own sequence, since on a full
* load initDOM() runs Poll.start() on an empty queue before the view renders. */
if (L.Poll && L.Poll.queue) {
L.Poll.queue.length = 0;
L.Poll.stop();
L.Poll.start();
}
/* kill the outgoing view's plain setInterval pollers too (podkop's log tailer) — a full load
* would have. L.Poll's own tick survives. */
clearViewIntervals();
/* 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 — today the search palette's recent-pages record and
* its close-on-navigate. The seam is deliberately inverted: a registrant calls onNavigate()
* (below) and the router names nobody, so an optional module that is not installed is not a
* DependencyError that takes out the whole chrome.
*
* The RESOLVED segments are passed in, and that is not convenience: this runs BEFORE L.env is
* re-pointed a few lines down, so a callback reading L.env.dispatchpath for "where are we going"
* would silently record the page being left. */
/* Logged, not swallowed. A registrant that throws is still isolated — the loop must finish, or a
* broken registrant would take the palette's recents and its close-on-navigate with it — but the
* empty catch made a registrant that throws on EVERY navigation indistinguishable from one that
* was never registered, which is the failure mode the .catch at the bottom of this file was
* given a console.error for. */
for (const fn of _navCbs) {
try { fn(rsegs); }
catch (e) { console.error('footstrap: a navigation callback threw', e); }
}
try { if (typeof ui.hideModal === 'function') ui.hideModal(); } catch (e) {}
/* point the runtime env at the new node so views, tabs and highlighting read the right
* path. For a fully-matched leaf, request == dispatch path. */
L.env.requestpath = rsegs.slice();
L.env.dispatchpath = rsegs.slice();
L.env.pathinfo = '/' + segs.join('/');
/* `readonly` is not decoration: luci.js implements hasViewPermission() as
* `!env.nodespec.readonly`, and views (network/interfaces, wireless, the package manager)
* plus luci.js's Save/Apply footer key their disabled state off it. Dropping it handed a
* read-only user LIVE Save/Apply buttons on an SPA nav, where a full load disabled them —
* and reading it off the LEAF was still dropping it for two thirds of the readonly pages,
* because the dispatcher folds the acls of the whole path into that one flag. See
* fs-menutree's readonlyForSegs(). */
L.env.nodespec = { satisfied: true, action: node.action, title: node.title,
depends: node.depends, readonly: tree.readonlyForSegs(rsegs) };
/* Keep <body data-page> in sync with the route: the server stamps the dispatch path
* (`ctx.path`) on every full load, and page-scoped CSS keys off it. `rsegs` is the RESOLVED
* leaf, so a firstchild URL like /admin/status yields the same "admin-status-overview" whether
* it arrives as a full load or a client nav. Without the re-stamp the incoming page keeps the
* previous page's data-page and its scoped styles silently do not apply. */
document.body.setAttribute('data-page', rsegs.join('-'));
/* …and immediately hand the new page to fs-sheets, which darkens every foreign sheet that
* belongs to a DIFFERENT page and re-lights the ones that belong to this one. This is what lets
* an invasive sheet stay in the document without spending it — the alternative was a full load
* on the way out of any page carrying one, which stock LuCI's five realtime views all do (see
* the page-ownership block in fs-sheets.js). It must run AFTER the stamp above and BEFORE the
* view renders, so nothing paints through a sheet that no longer owns the page. */
sheets.scopeToCurrentPage(rsegs);
/* Re-navigating to the page already on screen must REPLACE its history entry, not push a
* second one. Clicking the active menu item is ordinary, and a duplicate entry makes Back do
* nothing: popstate fires, `location.pathname === _curPath`, and the fragment guard below
* correctly returns — one dead Back press per stray click. A full load has no such trap. */
if (push) {
const same = pathname === window.location.pathname;
if (_curId == null) _curId = newEntryId();
/* a NEW entry gets a new id; re-navigating in place keeps the entry and therefore its id */
if (!same) _curId = newEntryId();
history[same ? 'replaceState' : 'pushState']({ fsnav: true, fsid: _curId }, '', pathname);
}
/* titles: <host> | <page> */
document.title = node.title ? (titleHost() + ' | ' + _(node.title)) : titleHost();
const tmain = document.querySelector('.fs-title-main');
if (tmain && node.title)
tmain.textContent = _(node.title);
chrome.renderChrome();
/* a full load starts at the top; the in-place swap must too, or navigating away from a long
* page opens the next one mid-scroll. In the desktop sidebar layout the window does NOT scroll —
* .fs-shell is exactly 100dvh with overflow:hidden and .fs-main owns overflow-y, so the sidebar
* can be static rather than a composited sticky layer (issue #7) — so reset it too; scrollTo on
* whichever of the two is not the scroller is a harmless no-op.
*
* A popstate replay resets nothing on purpose: BOTH scrollers are restored there from _scrollMem —
* see restoreScroll(). scrollRestoration is left at 'auto': the UA's own attempt lands before the
* swap and is undone by it (§2), so it neither helps nor hurts, and 'manual' would only take away
* the case that does work — a genuine full load. */
if (push) {
window.scrollTo(0, 0);
const sc = document.getElementById('maincontent');
if (sc) sc.scrollTo(0, 0);
}
/* ---- what a full load does for a keyboard/screen-reader user, and the SPA did not ----
* renderChrome() has just done `#topmenu.innerHTML = ''`, so the very <a> the user activated with
* Enter no longer exists: focus falls back to <body>, the next Tab restarts at the skip link, and
* nothing says the page changed — URL, title and #view all moved in silence. So do what a real
* navigation would, and where matters (Sutton's five-prototype study, docs/spa-router.md §3): a KEYBOARD
* activation (ev.detail === 0) moves focus to the skip link — a small target whose :focus overlay
* tells a sighted keyboard user where they are, with Enter jumping straight to the content; its
* text differs from the live region's announcement below, so the double announcement complements
* rather than repeats. A pointer activation (and a popstate replay, whose modality is unknowable)
* keeps the wrapper focus: focusing the skip link there would flash its overlay on every mouse
* click. <main> keeps tabindex="-1" and its outline-less :focus for exactly that path.
* preventScroll because the scroll position is decided just above — focus() would otherwise drag
* a popstate replay back to the top and undo the restoration. */
const skip = kbd ? document.querySelector('.fs-skip') : null;
const main = skip || document.getElementById('maincontent');
if (main) main.focus({ preventScroll: true });
const live = document.getElementById('fs-nav-status');
if (live) live.textContent = node.title ? _(node.title) : '';
/* Require through the runtime singleton `window.L`, NOT the bare `L` a module factory is handed:
* the dispatcher builds `window.L = new LuCI()` and `ui` augments THAT instance with
* itemlist/showModal/…, so a view required via the bare `L` throws "L.itemlist is not a
* function" mid-render (the two-L trap, docs/spa-router.md). require/instanceof errors fall back to a real
* navigation; render-time errors are handled inside LuCI.view, as on a full load.
*
* WHEN to re-instantiate is the subtle part. require() does not hand back a class — it caches an
* INSTANCE, so requiring a class not seen before CONSTRUCTS it, and a view's __init__ IS its
* render. On a first visit the require has therefore already painted the page, and a
* `new view.constructor()` after it painted a SECOND time — two renders, two pollers, double
* RPCs for as long as the user stayed. Only on a REVISIT does require() return the cached
* singleton whose __init__ already ran. `_seen` is that distinction, and it must be read BEFORE
* the require resolves, since the require is what fills LuCI's cache. */
/* Status→Overview needs the 3 template globals (progressbar/renderBox/renderBadge) an SPA
* arrival never defines. fs-overview.js defines them at its own module eval — which, as a
* chrome module, happens at chrome init, i.e. before any SPA navigation can occur. Not here:
* the router has no business owning luci-mod-status's globals. */
const RT = window.L;
const cached = _seen.has(className);
/* WAIT for an in-flight prefetch of this class rather than racing it. Two requests for the same
* URL do not coalesce, so a click landing before the prefetch does downloads the module TWICE,
* both at full latency, and gains nothing — measured at 120 ms RTT: the prefetch ran 2664→2788 ms
* and the require's XHR 2682→2788 ms for the same 8.6 KB. That is the NORMAL case on a touch
* device, where pointerover fires the same moment as the tap. Waiting costs nothing: the XHR
* would have waited for exactly those bytes.
*
* `_seen` is marked here and not before, because it means "this class has been through require()"
* and the wait introduces a window in which we may never get there. Marking it up front would
* make the NEXT navigation take the cached branch — `new view.constructor()` on a class whose
* require() is what renders it — i.e. two renders and two pollers for one page. */
warmedThen(className).then(() => {
/* superseded while waiting: never start the require. On a first visit the require IS the
* render, so starting it here would paint a page the user has already left and hand
* repairStaleRender() a mess that only exists because a require in flight cannot be stopped. */
if (gen !== _navGen) return null;
_seen.add(className);
return RT.require(className);
}).then((view) => {
if (view == null) return;
if (!(view instanceof RT.view))
throw new TypeError('Loaded class ' + className + ' is not a view');
if (gen !== _navGen) {
/* A newer navigation superseded this one. On the CACHED path nothing has happened
* yet — skipping the constructor below is the whole cancellation. On the FIRST-visit
* path the require() has ALREADY rendered into the live page and registered its
* pollers, with nothing to cancel: undo it. See repairStaleRender(). */
if (!cached)
repairStaleRender(className);
return;
}
if (cached) {
/* singleton: its __init__ already ran, re-run it. Arm the paint-time guard BEFORE the
* construct (__init__ binds this.render as it builds its chain) and stamp the
* generation right after it (the first load() is a microtask, so this lands first) —
* this navigation can still be superseded while that render awaits its RPC. */
const cls = view.constructor;
armRenderGuard(cls);
const inst = new cls();
inst.__fsGen = gen;
}
}).catch((e) => {
/* the full reload is a correct fallback, but swallowing the reason made every SPA-router
* regression look like "the page is just slow to load". Log, then fall back. */
console.error('footstrap: SPA nav to ' + className + ' failed, falling back to a full load', e);
if (gen === _navGen) window.location = pathname;
});
return true;
}
/* The same-origin nav URL an event's link points at, or null when the link is not ours to handle
* (new-tab target, download, bare #hash, cross-origin, unparsable). Shared by the click router and
* the hover prefetch, which used to carry drifting copies of this filter. */
function linkUrlFrom(ev) {
const a = ev.target.closest?.('a[href]');
if (!a || (a.target && a.target !== '_self') || a.hasAttribute('download'))
return null;
const raw = a.getAttribute('href');
if (!raw || raw.charAt(0) === '#') return null;
let url;
try { url = new URL(a.href, window.location.href); } catch (e) { return null; }
return url.origin === window.location.origin ? url : null;
}
/* Warm the view module behind an event's link — ONE filter for all three prefetch triggers, and the
* same one the click router applies two lines further down: navigate() pushes a bare path, so a link
* carrying ?query or #hash full-loads, and warming its module spends a request on a page the SPA
* path can never open. The click handler declined those from the start; the three triggers below had
* each grown a copy of the URL test without that half. */
function prefetchFrom(ev) {
const url = linkUrlFrom(ev);
if (url && !url.search && !url.hash)
prefetchView(url.pathname);
}
/* The last <a> a pointer crossed, kept only to stop `pointerover` re-firing per child span (see the
* listener). Cleared on every navigation: an element holds its parent, so retaining one anchor
* retains the whole detached tree the content swap has just thrown away — a small leak, but one that
* lasts until the pointer happens to cross some other link. */
let _lastHovered = null;
function wireRouter() {
if (_wired) return;
_wired = true;
document.addEventListener('click', (ev) => {
if (ev.defaultPrevented || ev.button !== 0 ||
ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)
return;
const url = linkUrlFrom(ev);
if (!url) return;
/* navigate() carries only the pathname: pushState-ing a bare path for a link that
* promised ?query= / #hash would strip both from the URL and from the view, which
* reads location.search. Let those links full-load. */
if (url.search || url.hash) return;
/* record the outgoing page's offset under the entry we are still ON; harmless when
* navigate() declines (a full load throws the whole Map away anyway) */
saveScroll();
if (navigate(url.pathname, true, ev.detail === 0))
ev.preventDefault();
}, false);
/* Warm the view module cache when the pointer enters a nav link. `pointerover` bubbles from
* EVERY element the pointer crosses — dragging across the process table fires it hundreds of
* times — so bail on the element first: the same <a> re-fires this for every child span it
* contains, and a non-link target is the overwhelmingly common case. */
document.addEventListener('pointerover', (ev) => {
const a = ev.target.closest?.('a[href]');
if (!a || a === _lastHovered) return;
_lastHovered = a;
prefetchFrom(ev);
}, { passive: true });
/* The pointer is not the only way a link gets chosen, and the other two ways got no prefetch at
* all. A KEYBOARD user Tabs to the link and presses Enter — no pointer event ever fires, so the
* whole optimisation was invisible to them; focusin is the keyboard's hover, and the Tab→Enter gap
* is human-scale, so the module is usually there by the time Enter lands. A TOUCH user gets a
* pointerover, but at the same moment as the tap, which is what the in-flight wait in navigate()
* is for rather than an earlier trigger. pointerdown adds the one pointer case pointerover cannot
* see: a link that scrolled UNDER a stationary pointer crosses no boundary and fires nothing.
* Neither needs the lastHovered guard above — they fire once per interaction, not per element
* crossed — and warmClass() dedupes per class anyway. */
document.addEventListener('focusin', prefetchFrom, { passive: true });
document.addEventListener('pointerdown', prefetchFrom, { passive: true });
window.addEventListener('popstate', () => {
/* an entry carrying a query belongs to a full load (we only ever push bare paths):
* replaying it as a bare-path SPA nav would drop the query the view expects */
if (window.location.search) {
window.location.reload();
return;
}
/* A FRAGMENT CHANGE IS NOT A NAVIGATION. Chrome fires `popstate` for a same-document
* fragment nav, so clicking an `<a href="#">` inside a view — a very common idiom for
* in-page controls — arrived here as if the user had pressed Back, and we re-ran navigate()
* for the path already on screen, RE-INSTANTIATING the view and wiping the state the click
* had just set (issue #3, "luci-app-filemanager does not work": its tab strip is four
* `<a href="#">` links whose handler does not preventDefault). The view changed only if the
* PATH changed; if just the fragment moved, the page owns it. */
if (window.location.pathname === _curPath)
return;
/* the outgoing DOM is still up: record its offset under the entry we are LEAVING, then
* adopt the entry we arrived on and look up what IT recorded when it was left */
saveScroll();
adoptEntry();
const target = _scrollMem.get(_curId);
if (!navigate(window.location.pathname, false))
window.location.reload();
else
restoreScroll(target, _navGen);
});
}
/* ---- the poll indicator must not outlive the poll ----
*
* 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`). Invisible on a full load,
* because Poll.start() dispatches `poll-start` only when the queue is non-empty — an unpolled page
* never grows a pill. But our router flushes the queue and calls stop() on every nav, and stop()
* DOES dispatch `poll-stop`, so walking from a polled page to an unpolled one left a "Paused" pill
* reporting on a poll that does not exist there. Rule: the pill exists iff there is something to
* poll. Registered at module eval, i.e. AFTER luci.js's own listener, so ours runs second and can
* take back what that one just painted. */
document.addEventListener('poll-stop', () => {
if (L.Poll && L.Poll.queue && L.Poll.queue.length === 0) {
try { ui.hideIndicator('poll-status'); } catch (e) {}
}
});
/* Pause LuCI's 1s poll loop while the tab is hidden: LuCI has no visibilitychange handler, so an
* overview left open in a background tab hammers ubus 24/7 (notably the pricey iwinfo getAssocList)
* on a low-power router. stop() only clearInterval()s (the queue survives); start() re-arms and runs
* one immediate step(), so data is fresh on refocus. A poller added while hidden will not auto-start
* (stop() deletes the tick) — start() picks it up on show: deferred, not lost. docs/spa-router.md. */
let _visWired = false;
function wireVisibility() {
if (_visWired) return;
_visWired = true;
/* respect a manual pause: the user can stop polling from the "Refreshing" indicator, and an
* unconditional start() on tab-show would silently undo it. Resume only what we paused. */
let wasActive = true;
document.addEventListener('visibilitychange', () => {
if (!L.Poll) return;
try {
if (document.hidden) {
wasActive = L.Poll.active();
if (wasActive) L.Poll.stop();
}
else if (wasActive) {
L.Poll.start();
}
} catch (e) {}
});
}
/* Callbacks to run on every SPA navigation, each handed the resolved segments of the INCOMING page
* (see navigate() — 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 can never grow a static dependency on a
* module that may not be installed. Registrant today: fs-search's recent-pages record and its
* close-on-navigate. */
const _navCbs = [];
function onNavigate(fn) { if (typeof fn === 'function') _navCbs.push(fn); }
return baseclass.extend({
seed,
wire: wireRouter,
wireVisibility,
onNavigate,
/* fs-search warms the pages this admin actually uses (its recents) and the arrow-key-highlighted
* result, both of which the pointer/focus triggers above cannot see. The edge points that way
* round — search → router — because the router must keep no dependency on the palette. */
prefetchSegs
});
@@ -0,0 +1,406 @@
'use strict';
'require baseclass';
'require fs-menutree as tree';
'require fs-prefs as prefs';
'require fs-router as router';
'require fs-widgets as widgets';
/* Find a page by typing its name, instead of remembering which section owns it.
*
* A loaded router carries ~200 reachable menu nodes across 11 sections, and the only way to reach
* one was to know its parent — "Attended Sysupgrade" is under System, "Crontab" under System too,
* "Port Forwards" is a TAB of Network -> Firewall and appears in no menu list at all until you are
* already there. This indexes every node the dispatcher would render, tabs included.
*
* It costs no request: the tree is the SAME ACL-filtered /admin/menu blob the chrome already
* loaded (fs-menutree), so the palette knows exactly the pages this session is allowed to open —
* nothing to leak, nothing to 403 on. The index is built on the FIRST open, not at init: a user
* who never searches pays nothing, and a full load is the only thing that can change the tree.
*
* Navigation is deliberately NOT a call into the router: every result is a real <a href>, so a
* click bubbles to the router's own document-level handler and takes the SPA path (or falls back
* to a full load when the node is not SPA-able) with no second copy of that decision here. Enter
* synthesises the same click. */
/* ---- the index ---------------------------------------------------------- */
/* admin/<section>/<page>/<tab> — four levels is every path LuCI's dispatcher renders. */
const MAX_DEPTH = 4;
let _index = null;
/* The node's own children, ACL- and title-filtered exactly as ui.menu.getChildren() filters them
* — but NOT through getChildren() itself, and that is the whole reason this function exists.
*
* On an ALIAS node getChildren() returns a copy whose `children` are the alias TARGET's, which is
* right for drawing a menu and wrong for indexing: Network -> Firewall is an alias onto the
* `firewall/zones` VIEW, a leaf, so its five tabs (Port Forwards, Traffic Rules, NAT Rules, IP
* Sets, Custom Rules) came back as an empty list and "port" found nothing on a router that plainly
* has a Port Forwards page. Measured on the dev router: 78 indexed nodes through getChildren(),
* with every tab of every aliased page missing.
*
* Order does not matter here (search ranks by score, not by menu weight), so getChildren()'s sort
* is not reimplemented — only its two filters, which are what "this session may open it" means. */
function childrenOf(node) {
const kids = node.children || {};
const out = [];
for (const name in kids) {
const c = kids[name];
if (!c || !c.satisfied || !c.title) continue;
out.push({ name: name, node: c });
}
return out;
}
function walk(node, segs, trail, out, depth) {
childrenOf(node).forEach((entry) => {
const child = entry.node;
/* the chrome carries its own Logout (partials/logout.ut) — searching for it would open a
* confirmation the user did not ask for */
if (depth === 1 && entry.name === 'logout')
return;
const title = _(child.title);
const csegs = segs.concat([ entry.name ]);
out.push({
segs: csegs,
path: csegs.join('/'),
title: title,
trail: trail,
depth: depth,
/* Three haystacks, searched in this order of preference (see score()). `name` is the
* ENGLISH node name and is indexed on purpose: a Russian UI translates "Firewall" to
* "Межсетевой экран", and an admin who knows OpenWrt by its English docs types
* "firewall". Both find it. */
t: title.toLowerCase(),
p: trail.join(' ').toLowerCase(),
/* …minus the ROOT segment, which is `admin` on every single node. Indexed, it made
* every substring of "admin" — a, ad, adm, dmi, min, in — a hit on all ~200 pages, so
* `min` returned "Terminal", "Administration", "Attended Sysupgrade" and stopped at the
* 20-result cap sorted only by depth (measured). It carries no information precisely
* because it is shared by everything. */
n: csegs.slice(1).join(' ').toLowerCase()
});
if (depth < MAX_DEPTH)
walk(child, csegs, trail.concat([ title ]), out, depth + 1);
});
}
function buildIndex() {
const root = tree.tree();
const out = [];
if (!root) return out;
/* the mode (admin) is a container, not a destination: start one level in */
childrenOf(root).forEach((mode) => {
walk(mode.node, [ mode.name ], [], out, 1);
});
return out;
}
function index() {
if (!_index) _index = buildIndex();
return _index;
}
/* ---- matching ----------------------------------------------------------- */
/* Every whitespace-separated token must hit SOMETHING, so "fire port" finds Firewall -> Port
* Forwards while "fire xyz" finds nothing — an AND is what a user typing a second word means.
*
* Deliberately not a fuzzy subsequence match (the "fzf" kind): on a two-letter query it matches
* almost every entry and the ranking then decides everything, which reads as random. A substring
* ladder is predictable — what you typed is visibly in what you got. */
const HIT_NONE = 99;
function tokenScore(e, tok) {
if (e.t.startsWith(tok)) return 0; /* the title begins with it */
if (e.t.includes(tok)) return 1; /* somewhere in the title */
if (e.n.includes(tok)) return 2; /* the English path segment */
if (e.p.includes(tok)) return 3; /* an ancestor's title */
return HIT_NONE;
}
function search(q, limit) {
const toks = q.toLowerCase().split(/\s+/).filter(Boolean);
if (!toks.length) return [];
const hits = [];
for (const e of index()) {
let sum = 0;
for (const tok of toks) {
const s = tokenScore(e, tok);
if (s === HIT_NONE) { sum = HIT_NONE; break; }
sum += s;
}
if (sum !== HIT_NONE)
hits.push({ e: e, score: sum + (e.depth * 0.1) }); /* ties: the shallower page first */
}
hits.sort((a, b) => a.score - b.score);
return hits.slice(0, limit).map((h) => h.e);
}
/* ---- recently visited --------------------------------------------------- */
/* What the palette shows before a single character is typed. A router admin lives in three or four
* pages, so the empty state is the most-used view of this thing — an empty box would waste it.
*
* Only the PATH is stored, never the title: the title is resolved back through the index on every
* render, so it follows the UI language and a page that disappeared with its package simply drops
* out instead of lingering as a dead row. */
const RECENT_KEY = 'fs-recent';
const RECENT_MAX = 8;
/* prefs.lsGetArr owns the parse, the corruption guard and the Array check; only the
* "these are paths" filter is this module's business. */
function loadRecent() {
return prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string');
}
let _recent = loadRecent();
function remember(segs) {
if (!Array.isArray(segs) || !segs.length) return;
const path = segs.join('/');
_recent = [ path ].concat(_recent.filter((p) => p !== path)).slice(0, RECENT_MAX);
prefs.lsSet(RECENT_KEY, JSON.stringify(_recent));
}
function recentEntries() {
const byPath = new Map(index().map((e) => [ e.path, e ]));
return _recent.map((p) => byPath.get(p)).filter(Boolean).slice(0, RECENT_MAX);
}
/* ---- warm the pages this admin actually uses ----
*
* The router's per-link prefetch needs a hover, a tap or a focus first, so the FIRST visit of a
* session to a page still pays for its module chain. The recents list is the best predictor of that
* page available anywhere in the theme — an admin lives in three or four of them — and it is already
* on disk. Walking the whole menu instead would pull every view module on the box, which is the cost
* docs/spa-router.md warns about; five recents is a handful of files.
*
* The current page is skipped: wire() has just remembered it, so it heads the list, and it is loaded
* by definition. saveData is the user saying "not over this link", and speculation is exactly what
* has to go then — the per-link prefetch stays, because that one follows a deliberate hover or tap. */
const RECENT_WARM = 5;
function warmRecent() {
try { if (navigator.connection && navigator.connection.saveData) return; } catch (e) {}
const here = (L.env.dispatchpath || []).join('/');
const paths = _recent.filter((p) => p !== here).slice(0, RECENT_WARM);
if (!paths.length) return;
/* Nothing waits on this, so it belongs after the page has settled — at idle, with a timeout for a
* page that never goes idle (a busy poll). The fallback is deliberately a LONG timeout, unlike
* fs-appearance's ~1 ms one: that wires a button the user may click immediately, this competes
* with the view's own module fetches and RPCs and must lose that race on purpose. */
const go = () => paths.forEach((p) => router.prefetchSegs(p.split('/')));
if (typeof window.requestIdleCallback === 'function')
window.requestIdleCallback(go, { timeout: 4000 });
else
window.setTimeout(go, 2000);
}
/* ---- the palette -------------------------------------------------------- */
const MAX_RESULTS = 20;
function wire() {
const btn = document.getElementById('fs-search-btn');
if (!btn) return;
/* Remember the page this full load landed on. The SPA path is covered by onNavigate below;
* this covers an F5, a non-SPA-able node and the very first page of a session. */
remember(L.env.dispatchpath || []);
/* The callback is handed the RESOLVED segments of the incoming page: reading L.env here would
* give the OUTGOING one, since the router fires its callbacks before it re-points L.env. */
router.onNavigate(remember);
/* after remember(), so the page we are standing on is the one head of the list that gets skipped */
warmRecent();
const input = E('input', {
'type': 'text',
'class': 'fs-search-input',
'role': 'combobox',
'aria-controls': 'fs-search-list',
'aria-expanded': 'true',
'aria-autocomplete': 'list',
'aria-label': _('Search pages', 'footstrap'),
'placeholder': _('Search pages…', 'footstrap'),
'autocomplete': 'off',
'autocapitalize': 'off',
'spellcheck': 'false'
});
const list = E('div', { 'id': 'fs-search-list', 'class': 'fs-search-list', 'role': 'listbox', 'aria-label': _('Pages', 'footstrap') });
const note = E('div', { 'class': 'fs-search-note' });
const ico = E('span', { 'class': 'fs-search-ico' });
ico.innerHTML = widgets.svgIcon('<circle cx="11" cy="11" r="7"/><path d="M16.5 16.5 21 21"/>');
const box = E('div', { 'class': 'fs-search-box' }, [
E('div', { 'class': 'fs-search-row' }, [ ico, input ]),
/* the note is a CAPTION for the rows below it ("Recently visited") and the box's EMPTY
* STATE when there are none, so it belongs above the list in both readings — it sat after
* the list at first, which put the caption under the rows it captions */
note,
list
]);
/* data-fs-chrome marks a Zone 1 ROOT (docs/third-party-apps.md): this is a `position: fixed` overlay parented
* to <body>, i.e. outside the <nav> that carries the mark in header.ut — exactly the shape that
* once left the Appearance popover unfenced while every test said the chrome was defended. */
const ov = E('div', {
'id': 'fs-search-ov',
'class': 'fs-search-ov',
'data-fs-chrome': '',
'role': 'dialog',
'aria-modal': 'true',
'aria-label': _('Search pages', 'footstrap'),
'hidden': ''
}, [ box ]);
document.body.appendChild(ov);
let opts = [], ents = [], at = -1;
/* Warm the highlighted page's module chain, DEBOUNCED. render() re-runs setActive(0) on every
* keystroke, so warming immediately would pull the top result of "w", "wi", "wir"… and only the
* last of those is a page anyone asked for. The delay also matches how the list is used: the
* highlight settles, then Enter. Only the ARROW KEYS and typing need this — the rows are real
* anchors in the document, so a mouse moving over one already reaches the router's own pointerover
* listener. warmClass() dedupes per class, so a row revisited costs nothing. */
let warmT = null;
function warmActive() {
if (warmT) window.clearTimeout(warmT);
warmT = window.setTimeout(() => {
warmT = null;
if (ents[at]) router.prefetchSegs(ents[at].segs);
}, 200);
}
function setActive(i) {
if (!opts.length) { at = -1; input.removeAttribute('aria-activedescendant'); return; }
at = (i + opts.length) % opts.length;
opts.forEach((o, n) => {
const on = (n === at);
o.classList.toggle('active', on);
o.setAttribute('aria-selected', on ? 'true' : 'false');
});
input.setAttribute('aria-activedescendant', opts[at].id);
opts[at].scrollIntoView({ block: 'nearest' });
warmActive();
}
function render(q) {
const entries = q ? search(q, MAX_RESULTS) : recentEntries();
ents = entries;
list.innerHTML = '';
opts = entries.map((e, i) => {
/* role="option" on the <a> rather than a <div> wrapping one: an option may not contain
* an interactive element, and the anchor has to stay a real link — it is what carries
* the click to the router, and what keeps middle-click and "copy link" working. */
const a = E('a', {
'class': 'fs-search-opt',
'role': 'option',
'id': 'fs-search-opt-' + i,
'aria-selected': 'false',
'href': L.url.apply(L, e.segs)
}, [
E('span', { 'class': 'fs-search-opt-title' }, [ e.title ]),
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),
* and without taking focus back — the user is going somewhere else */
a.addEventListener('click', () => close(false));
a.addEventListener('pointermove', () => { if (at !== i) setActive(i); });
list.appendChild(a);
return a;
});
note.textContent = opts.length
? (q ? '' : _('Recently visited', 'footstrap'))
: (q ? _('No pages found', 'footstrap') : _('Start typing to find a page', 'footstrap'));
note.hidden = !note.textContent;
setActive(0);
}
function open() {
if (!ov.hidden) return;
ov.hidden = false;
btn.setAttribute('aria-expanded', 'true');
input.value = '';
render('');
input.focus();
}
function close(returnFocus = true) {
if (ov.hidden) return;
ov.hidden = true;
btn.setAttribute('aria-expanded', 'false');
if (returnFocus) btn.focus();
}
input.addEventListener('input', () => render(input.value.trim()));
/* Keys are handled on the OVERLAY, not the input: the scrim is part of the dialog and a click
* on it moves focus to the overlay itself, where an Escape must still close. */
ov.addEventListener('keydown', (ev) => {
switch (ev.key) {
case 'Escape':
ev.preventDefault();
close();
return;
case 'ArrowDown':
ev.preventDefault(); setActive(at + 1); return;
case 'ArrowUp':
ev.preventDefault(); setActive(at - 1); return;
case 'Home':
if (!input.value) { ev.preventDefault(); setActive(0); }
return;
case 'End':
if (!input.value) { ev.preventDefault(); setActive(opts.length - 1); }
return;
case 'Enter':
if (at < 0 || !opts[at]) return;
ev.preventDefault();
/* a synthetic click carries detail 0, which is exactly what the router reads as a
* KEYBOARD activation — so the focus lands where a keyboard navigation puts it */
opts[at].click();
return;
case 'Tab':
/* aria-modal="true" is a promise that Tab cannot walk out into the page behind, and
* the input is the dialog's only tabbable element, so the trap is: stay. Escape (or a
* click outside) is the way out, and both hand focus back to the trigger. */
ev.preventDefault();
input.focus();
return;
}
});
/* a click on the scrim — anywhere outside the box — closes */
ov.addEventListener('click', (ev) => { if (ev.target === ov) close(); });
btn.addEventListener('click', () => { ov.hidden ? open() : close(); });
/* Back and Forward are navigations no listener here can see: every close above is a user act on
* the document (Escape, the scrim, the trigger, picking a result). An open palette therefore rode
* a popstate onto the next page, aria-modal and Tab-trapped, while the router moved focus behind
* it. Same reasoning as the Appearance popover's — see fs-appearance.js. returnFocus=false because
* the router places focus itself on a navigation. */
router.onNavigate(() => close(false));
/* Ctrl/Cmd+K is the shortcut every command palette has taught users, and `/` is the one every
* search field on the web has. `/` only when the user is not already typing somewhere: an
* <input>, a contenteditable, or a .cbi-dropdown (fs-select.js gives those their own typeahead,
* where a `/` is a search character, not a shortcut). */
document.addEventListener('keydown', (ev) => {
if (ev.defaultPrevented) return;
if ((ev.ctrlKey || ev.metaKey) && !ev.altKey && (ev.key === 'k' || ev.key === 'K')) {
ev.preventDefault();
ov.hidden ? open() : close();
return;
}
if (ev.key !== '/' || ev.ctrlKey || ev.metaKey || ev.altKey) return;
if (ev.target.closest?.('input, textarea, select, [contenteditable], .cbi-dropdown')) return;
ev.preventDefault();
open();
});
}
return baseclass.extend({
wire
});
@@ -0,0 +1,522 @@
'use strict';
'require baseclass';
'require ui';
'require dom';
'require fs-fit as fit';
/* 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.
*
* The native <select> stays the form field and MUST remain frameEl.firstChild:
* ui.Select.getValue() returns `this.node.firstChild.value`. Inserting our widget BEFORE it made
* getValue read a <div> and return `undefined`, which broke Save. So insert AFTER, and mirror the
* value both ways. Sharing the frameEl also ties our node to the widget's lifecycle, so a CBI
* re-render disposes of it — no orphans.
*
* Runs theme-wide (required from the footer); watches for selects added later by client CBI. */
function readChoices(sel) {
const choices = {};
Array.prototype.forEach.call(sel.options, (o) => { 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');
}
/* undo enhance(): drop the widget, unhide the select, and — critically — cut every listener
* enhance() installed. The `change` listener used to survive teardown, and resync() calls
* teardown()+enhance() every time a script rebuilds the option list (CBI dependencies do this
* constantly on the firewall/network forms) — so the select accumulated one live listener per
* rebuild, each closing over a dead ui.Dropdown and its detached subtree: a leak that grew with
* every interaction. AbortController is the only way to drop an anonymous listener. */
function teardown(sel) {
if (sel._fsAbort) sel._fsAbort.abort();
if (sel._fsNode && sel._fsNode.parentNode)
sel._fsNode.parentNode.removeChild(sel._fsNode);
delete sel.dataset.fsSelect;
sel._fsDd = sel._fsNode = sel._fsKey = sel._fsAbort = null;
sel.removeAttribute('aria-hidden');
sel.style.display = '';
}
/* keep an enhanced select and its widget in step when a script drives the native element
* directly: ui.Select.setValue() rewrites value/options WITHOUT dispatching `change`, so
* enhance()'s mirror never fires and the widget went stale — showed the old value while Save
* read the new one. */
function resync(sel) {
const dd = sel._fsDd;
if (!dd || !sel._fsNode) return;
if (sel.disabled) { teardown(sel); return; } /* disabled later: back to native */
const key = choicesKey(sel);
if (key !== sel._fsKey) {
/* option list rebuilt — recreate the widget from the fresh options */
teardown(sel);
enhance(sel);
return;
}
if (dd.getValue() !== sel.value)
dd.setValue(sel.value);
}
/* A VALUE written through the IDL is invisible to every observer there is.
*
* `sel.value = x` and `options[i].selected = true` — which is exactly what `ui.Select.setValue()`
* does, and `form.js`'s `updateDefaultValue()` calls it on every dependency pass — set no content
* attribute and add no node, so no MutationRecord is produced at all. relevant() therefore could
* never wake, resync() never ran, and the widget showed the old label while `getValue()` (and Save)
* read the new one. Reproduced on the router: `s.value = 'DROP'` left the widget unchanged.
*
* So this runs from the fitter, i.e. once per content mutation batch — the same cadence the tables
* already use — and it is deliberately the CHEAP half of resync(): a value compare per enhanced
* select, no choicesKey() over every option. Re-keying the widget stays behind relevant(), which now
* sees an option-list rebuild. */
function resyncValues() {
for (const sel of document.querySelectorAll('select[data-fs-select]')) {
const dd = sel._fsDd;
if (!dd || !sel._fsNode || sel.disabled) continue;
if (dd.getValue() !== sel.value)
dd.setValue(sel.value);
}
}
function enhance(sel) {
if (sel.dataset.fsSelect || sel.disabled) return; /* disabled: NOT marked — it may be enabled later */
/* `multiple` and "not in a CBI field" are permanent, so mark it and stop re-testing on
* every scan */
if (sel.multiple || !sel.closest('.cbi-value-field, .td.cbi-value-field, .cbi-value')) {
sel.dataset.fsSelect = 'skip';
return;
}
const choices = readChoices(sel);
let dd;
try {
dd = new ui.Dropdown(sel.value, choices, {
sort: false,
optional: Object.prototype.hasOwnProperty.call(choices, '')
});
} catch (e) { return; }
const node = dd.render();
const ac = new AbortController();
sel.dataset.fsSelect = '1';
sel.style.display = 'none';
/* The hidden <select> leaves the CBI <label for=…> pointing at something no screen reader
* announces, and the visible widget nameless. Move the name over, drop the select from the
* a11y tree. */
const title = sel.closest('.cbi-value')?.querySelector('.cbi-value-title');
/* In a TABLE section there is no .cbi-value and no .cbi-value-title at all — form.js builds
* `E('td', {class: 'td cbi-value-field'})` there — so on firewall zones, port forwards and
* static leases the widget was left with no accessible name while the native select it replaces
* is aria-hidden. The cell's `data-title` IS the column heading (LuCI fills it for the card
* stack), which is the same string the header cell shows. */
const name = (title && title.textContent.trim()) ||
(sel.closest('.td')?.getAttribute('data-title') || '').trim();
if (name)
node.setAttribute('aria-label', name);
/* Clicking the field's caption must reach the widget. form.js wires that label to
* `#widget.cbid…`.click()/focus() — which is the native <select> we just set `display: none` on,
* so on this theme the click did nothing at all (measured: focus stayed on <body>, no list
* opened), while stock bootstrap focuses the select. The <label for=…> is equally dead for the
* same reason. Re-point the gesture at the visible control — focus only, which is the parity
* stock gets: its `elem.click()` on a `<select>` opens no list either, so the gesture has always
* meant "put me on this control". */
if (title)
title.addEventListener('click', () => node.focus(), { signal: ac.signal });
sel.setAttribute('aria-hidden', 'true');
sel._fsDd = dd;
sel._fsNode = node;
sel._fsKey = choicesKey(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);
/* stops our own dd->sel dispatch from echoing back through the sel->dd listener */
let syncing = false;
/* our widget -> native select (user picked an option) */
node.addEventListener('cbi-dropdown-change', () => {
const v = dd.getValue();
if (sel.value === v) return;
syncing = true;
sel.value = v;
sel.dispatchEvent(new Event('change', { bubbles: true }));
syncing = false;
}, { signal: ac.signal });
/* native select -> our widget (a script/CBI dependency changed and dispatched change on
* the select) — keeps the visible widget from going stale */
sel.addEventListener('change', () => {
if (syncing) return;
if (dd.getValue() !== sel.value)
dd.setValue(sel.value);
}, { signal: ac.signal });
}
/* Tag standalone data tables so the stacking rules key off a static `.fs-dt` instead of a live
* `:has(.tr.table-titles)` the style engine re-evaluated on every mutation of these polled tables
* (Processes/routes/leases). Not a .cbi-section-table — config forms keep their own layout.
*
* `.table`, not `table.table` — the SAME selector relevant() and STACKABLE use. Stock LuCI
* happens to emit only real <table>s, but a third-party luci-app-* may emit a <div class="table">
* (coverage rule, docs/conventions.md), which a tag qualifier would pass over so it could never card. */
function tagDataTables() {
document.querySelectorAll('#view .table:not(.cbi-section-table):not(.fs-dt)').forEach((t) => {
/* THREE header markups, and each missing one cost a page. L.ui.Table emits
* `.tr.table-titles`; the apk Software page emits `.tr.cbi-section-table-titles` (missing
* it is why the package list once needed a stacking block of its own); and a third-party
* table may simply use a real `<thead>` — luci-mod-dashboard's device lists are
* `<thead class="thead dashboard-bg"><th class="th nowrap">`, matching neither name. They
* therefore never carded, and because those `th`s are `nowrap` they could not compress
* either: on a phone the right-hand columns were cut off by .fs-main's overflow clip.
* Reported from a router with wifi clients.
*
* `thead`, not `thead tr`: that markup is built by E(), which appends the `<th>`s straight
* to the `<thead>` — the parser's implied row never happens, so a `tr` in the selector finds
* nothing. Read as "the header ROW-ISH element", which is what its children are cells of.
*
* ANY of the three = a data table; NONE = a key/value include (System, Memory), which must
* never card. `thead` is the structural form of the same statement the two classes make, so
* it belongs in the same list rather than in a rule of its own. */
const head = t.querySelector('.tr.table-titles, .tr.cbi-section-table-titles, thead');
if (!head) return;
t.classList.add('fs-dt');
labelCells(t, head);
});
}
/* Give every cell the column heading it will show once the table cards.
*
* The card layout prints `attr(data-title)` above each value (theme/30-tables.css), and LuCI's own
* table builders fill that attribute in. A foreign table has no reason to: luci-mod-dashboard emits
* bare `<td class="td">`, so carding it would have produced a column of values with nothing saying
* which was the hostname and which the signal — worse than the clipped table it replaced.
*
* The heading is COPIED, not invented: it is the text of the header cell in the same position, so
* the card says exactly what the column header says. Never overwrites an existing data-title — if
* the app set one, that is the app's answer and it knows more than a positional guess. Cheap enough
* to re-run on every fit pass (it is skipped entirely once the cells carry the attribute), which
* matters because these tables are POLLED: the rows are replaced wholesale every few seconds, and
* the fresh ones arrive without it. */
function labelCells(t, head) {
const titles = [ ...head.children ].map((c) => (c.textContent || '').trim());
if (!titles.some(Boolean)) return;
for (const row of t.querySelectorAll('.tr, tbody tr')) {
if (row === head) continue;
const cells = row.children;
for (let i = 0; i < cells.length; i++) {
if (i >= titles.length || !titles[i]) continue;
if (cells[i].hasAttribute('data-title')) continue;
/* a cell that spans columns has no single heading to take */
if (cells[i].colSpan > 1) continue;
cells[i].setAttribute('data-title', titles[i]);
}
}
}
/* ---- CARD-STACK A DATA TABLE THAT NO LONGER FITS --------------------------------
*
* Measuring, scheduling and the observers are fs-fit.js; this file supplies only the DECISION.
* A data table used to card by @container at THREE thresholds (568 plain, 780 leases, 800 apk
* package list), the last two each carrying their own COPY of the card rules — CSS cannot share
* a block across two thresholds. All were really asking "does it OVERFLOW?", a fact the browser
* computes, so it is measured instead: the card rules live once in theme/30-tables.css on
* .fs-stacked, and a third-party table of unknowable width works too.
*
* A CONFIG table (.cbi-section-table) keeps its @container (960, theme/65-dropdown.css) and must
* NOT be measured: its rows hold widgets (enhance() above turns every <select> into a
* ui.Dropdown) 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. Measured on the router: the firewall zone
* table then reported needing 1747px where it really needs 1190px and overflowed its section by
* 557px — an overflow the CSS-only version never had. A data table has no widgets, which is why
* it is the one that gets measured. */
const STACKABLE = '#view .table.fs-dt';
/* "Too cramped to be a table any more" — a DESIGN judgement. It has to be one: these tables do
* NOT overflow when the room runs out (their cells break anywhere), they compress into an
* unreadable ribbon. Do NOT give the cells a min-width so that "cramped" MANUFACTURES an
* overflow: tried, and it carded the firewall's zone table at 1420px and still overflowed by
* 39px once carded — a floor big enough to force the overflow is big enough to break the card. */
const CRAMPED = 568; /* stock LuCI cards its tables at a 600px viewport; below the 767px
* tier .fs-content pads 16px a side, so 600 -> 568 of room */
/* The ribbon has one more shape, and CRAMPED cannot see it: the table has room by the number
* above and still shreds its FIRST column, because auto table layout hands width out by what
* each column DEMANDS. The leftmost column is the row's identity and usually the least greedy —
* a wide neighbour (a hostname plus an IPv6, a modulation string) simply takes the width, and
* `overflow-wrap: anywhere` (theme/30-tables.css) lets the identity be squeezed with no floor:
* it breaks mid-word rather than overflow, so there is no overflow for fit.overflows() to read.
*
* Measured on the router (Wireless, one station, `Access Point "vaka_devices" (phy6-ap0)`):
* viewport 900 -> the column is 101px and 5 lines, 850 -> 80px and 7, 800 -> 76px and 8, and at
* NO width did the table card — a nine-line tower of half-words next to columns with room to
* spare (issue #7). Below 767 the MAC column drops out (the stock phone contract) and the
* column springs back to 167px, which is why this only ever bit between roughly 780 and 900.
*
* So: past this many lines the identity has stopped being readable and the card view — which
* gives every field its own labelled row — is simply better. A DESIGN judgement like CRAMPED,
* and it has to be one: any number of lines is legible in isolation. 5 is what the reporter
* asked for and what the measurements above bracket. Only the first column, and deliberately:
* a value column wrapping to a few lines is a value being shown, not a table falling apart. */
const MAX_ID_LINES = 5;
/* Is any row's leftmost cell a tower? Text lines, not height — fs-fit.textLines() explains why.
* The height gate in front of it is not premature: this runs on every poll tick (once a second,
* every mutation), and Processes/Connections render hundreds of rows whose first cell is a PID
* that cannot be a tower — one cheap read each keeps the Range walk for the cells that could. */
function idTower(t) {
const cells = t.querySelectorAll('.tr > .td:first-child');
if (!cells.length) return false;
const cs = getComputedStyle(cells[0]);
const lh = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.2 || 16;
for (const cell of cells) {
if (cell.clientHeight < MAX_ID_LINES * lh) continue;
if (fit.textLines(cell) > MAX_ID_LINES) return true;
}
return false;
}
function fitTables() {
document.querySelectorAll(STACKABLE).forEach((t) => {
const was = t.classList.contains('fs-stacked');
/* fs-fit rule 1: a stacked table is a pile of flex rows and always "fits", so reading
* it as it stands un-stacks it and the next frame stacks it again — oscillation. */
t.classList.remove('fs-stacked');
const room = fit.roomFor(t);
if (!(room > 0)) { if (was) t.classList.add('fs-stacked'); return; }
/* idTower last: it is the only one that walks the rows */
const stack = room < CRAMPED || fit.overflows(t) || idTower(t);
/* write only on a real change: the poll re-renders these tables once a second, and
* toggling the class off and on each tick would invalidate style for every row of
* Processes/Leases for nothing */
if (stack) t.classList.add('fs-stacked');
else if (was) t.classList.remove('fs-stacked');
});
}
/* ---- A PINNED ACTIONS COLUMN IS ONLY VALID FOR THE LAYOUT MODE IT WAS MEASURED IN ----
*
* luci-base's `form.js` (stabilizeActionColumnWidth) measures the widest
* `td.cbi-section-actions > div` and writes that number as an INLINE `width` and `min-width` onto the
* header cell, the footer cell and every actions cell, caching it in `data-action-col-width`. It does
* re-run on window resize — but it only deletes the CACHE, never the inline widths, so the fresh
* measurement reads the width it pinned last time. The pin feeds itself and can only ever grow.
*
* On a stock theme that is invisible: a config table is a table at every width, so every measurement
* is taken in the same layout. This theme cards it under `@container fs-content (max-width: 960px)`
* (theme/65-dropdown.css), where the actions cell is `flex: 1 1 100%` and its buttons deliberately
* spread across the whole card — so a measurement taken there is the CARD's width, and carrying it
* into table mode makes the column absurd.
*
* Measured on the router, Network -> Firewall -> Zones: loaded at 1000px (carded) and grown to
* 1280px, the actions column pins 634px, the table renders 1267px inside a 1056px content column and
* the column scrolls sideways by 256px — permanently, because upstream's own re-measure reads the
* pin. A FRESH load at 1280px renders the same table at 966px with a 192px actions column. Shrinking,
* and growing within table mode, were always fine; it is the card -> table crossing that breaks.
*
* So drop the pin whenever the layout it was measured in stops being the layout on screen. Upstream
* re-measures from a clean DOM on its own resize listener and pins the right number; if it does not,
* the natural width is what we wanted anyway.
*
* THE KEY IS THE ROOM, NOT THE MODE, and starting from the mode alone missed half of it: the card ->
* table crossing is one way a pin goes stale, and the card simply getting NARROWER is the other. At
* 768px this theme has no sidebar (data-narrow) and the column is 712px; at 800px the sidebar returns
* and the column is 520px — the viewport grew, the room shrank, and the table was carded on both
* sides, so a mode test sees no change at all. Measured: firewall/zones and wireless both kept a
* `min-width: 670px` cell in a 520px column, 154px of scroll. Keying on the room catches both, since
* a mode change cannot happen without one.
*
* The room is the parent's content box (fit.roomFor), which the table's own width does not feed back
* into — so wiping the pin cannot change the key and set this oscillating. It fires once per CHANGE,
* never per tick, so it does not fight upstream for the pin on a polled page. */
function unpinActionColumn() {
for (const t of document.querySelectorAll('#view .table.cbi-section-table')) {
if (!t.querySelector('.cbi-section-actions')) continue;
/* ---- and CLAIM upstream's resize hook, because under SPA navigation it is a leak ----
*
* stabilizeActionColumnWidth ends by attaching `window.addEventListener('resize', …)` once per
* TABLE ELEMENT, guarded by this expando, and the callback closes over that element. Nothing
* ever removes it. On a stock theme the next page is a full load and the listener dies with the
* document; here the document lives for the whole session, so every visit to a config page
* leaves another listener holding another detached table.
*
* Measured on the router over 120 navigations: window went from 1 resize listener to 31, and a
* heap snapshot 280 navigations wide grew by 26 880 UniqueElementData, 23 600 Text nodes,
* 18 520 EventListener and 1 160 <form> — a straight 11.8 KB per navigation that never
* plateaus once the module cache is full.
*
* Setting the flag before upstream reaches it means the listener is never attached, and nothing
* is lost: what it existed to do — re-measure the column when the width changes — is what the
* wipe below now does, from the room rather than from a window event. The fitter runs
* SYNCHRONOUSLY on the mutation batch that inserts the table (fs-fit rule 2), which is what
* makes claiming it in time possible at all; a table we somehow reach late simply keeps
* upstream's listener, i.e. today's behaviour. */
t.__actionColResizeAttached = true;
const key = Math.round(fit.roomFor(t));
if (t._fsActRoom === key) continue;
const seen = (t._fsActRoom !== undefined);
t._fsActRoom = key;
/* the first sighting is not a CHANGE: nothing has been pinned in another layout yet */
if (!seen) continue;
delete t.dataset.actionColWidth;
t.querySelectorAll('.cbi-section-actions').forEach((el) => {
el.style.removeProperty('width');
el.style.removeProperty('min-width');
});
}
}
/* Does this batch contain anything we could care about? Without it EVERY mutation scheduled a
* full scan — and the poll rewrites content once a second, so on Overview/Processes/Leases we
* ran three document-wide querySelectorAll plus a choicesKey() over every option of every
* enhanced select (thousands of characters on the firewall page) every second, forever, to
* discover that nothing had changed. */
function relevant(mutations) {
/* attributeFilter narrows the ATTRIBUTE, not the element: `value`/`disabled` live on inputs
* and buttons too, and a poll rewriting an input's value would otherwise wake the whole
* scan. This half is ours alone; the added-node walk below is fs-fit's shared one. */
for (const m of mutations) {
if (m.type === 'attributes' && m.target.tagName === 'SELECT')
return true;
/* …and a REBUILT OPTION LIST. `sel.replaceChildren(new Option(…))` puts <option> elements in
* addedNodes, and the shared walk below asks whether an added node IS or CONTAINS a select —
* an <option> is neither, so the batch was dropped and resync() never re-keyed the widget.
* Measured on the firewall page: after replaceChildren the native select listed AAA/BBB
* while the widget still offered reject/drop/accept, and picking from the stale list wrote a
* value the new list does not contain, i.e. `''`. CBI dependency handling rebuilds option
* lists constantly, which is the case resync() was written for. */
if (m.type === 'childList' && m.target.tagName === 'SELECT')
return true;
}
/* `.table`, not `table.table` — the same selector tagDataTables() and STACKABLE use.
* Additions only: a select or a table going away costs us nothing to notice. */
return fit.touches(mutations, 'select.cbi-input-select, .table');
}
/* ---- TYPE-AHEAD: jump to an option by typing its first letters ---------------------
*
* A native <select> gives this for free, and it is the only way anyone picks a country out of
* 248 entries. enhance() hides the native select, and ui.Dropdown.handleKeydown (luci-base) does
* only Esc/Enter/Space/arrows — no letter search — so Wireless -> Country Code became 248 items
* you could only scroll. (Stock LuCI never had it either; bootstrap only appears to, because it
* leaves that field a real <select>.)
*
* One document-level listener (a dropdown's <ul> holds focus while open), for EVERY
* .cbi-dropdown — ours and LuCI's own. Native semantics: only while OPEN; printable keys, no
* modifiers; buffer resets after a pause; the SAME letter repeated cycles (how you reach the
* second "Germany"); matches the LABEL first, then the value, so "RU" and "Russia" both find it.
* SPACE is deliberately excluded: ui.Dropdown binds it to "toggle the focused item" and its
* handler fires first, so treating it as a character would select something.
*
* Only HIGHLIGHTS (setFocus, as the arrows do); Enter/Esc stay ui.Dropdown's. */
const TYPEAHEAD_RESET_MS = 1000;
let _taBuf = '', _taTimer = null, _taLast = null;
function typeaheadItems(sb) {
const ul = sb.querySelector('ul.dropdown') || sb.querySelector('ul');
if (!ul) return [];
return [...ul.children].filter((li) =>
li.tagName === 'LI' &&
/* the "custom value" row (options.create) is an input, not a choice */
!li.querySelector('input:not([type="hidden"])') &&
li.getClientRects().length > 0);
}
function typeaheadLabel(li) {
return (li.textContent || '').trim().toLowerCase();
}
function wireTypeahead() {
document.addEventListener('keydown', (ev) => {
if (ev.ctrlKey || ev.altKey || ev.metaKey) return;
if (!ev.key || ev.key.length !== 1 || ev.key === ' ') return;
/* the create-item input is a text field — let the user type into it */
if (ev.target && ev.target.matches && ev.target.matches('input, textarea')) return;
const sb = ev.target.closest?.('.cbi-dropdown[open]');
if (!sb) return;
const items = typeaheadItems(sb);
if (!items.length) return;
/* a new dropdown starts a new search, however fast the user got here */
if (sb !== _taLast) { _taBuf = ''; _taLast = sb; }
const ch = ev.key.toLowerCase();
/* repeating one letter cycles; anything else extends the search */
const repeat = (_taBuf.length === 1 && _taBuf === ch);
const needle = repeat ? ch : (_taBuf + ch);
const start = items.findIndex((li) => li.classList.contains('focus'));
/* on a repeat, look AFTER the current item so the same letter walks forward; otherwise
* the search restarts from the top, as a native select does */
const from = repeat ? start + 1 : 0;
/* matches the LABEL first, then the value, so "RU" and "Russia" both find it */
const matches = (n) => (li) => typeaheadLabel(li).startsWith(n) ||
String(li.getAttribute('data-value') || '').toLowerCase().startsWith(n);
const match = matches(needle);
/* wrap around: the second pass covers what the first skipped */
let hit = items.slice(from).find(match) ?? items.find(match);
if (!hit && !repeat) {
/* the extended buffer matches nothing — treat this keystroke as a fresh search
* instead of swallowing it, so a mistyped letter is recoverable */
hit = items.find(matches(ch));
if (hit) _taBuf = '';
}
if (!hit) return;
_taBuf = repeat ? ch : (_taBuf + ch);
if (_taTimer) window.clearTimeout(_taTimer);
_taTimer = window.setTimeout(() => { _taBuf = ''; _taLast = null; }, TYPEAHEAD_RESET_MS);
/* 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();
ev.preventDefault();
ev.stopPropagation();
});
}
return baseclass.extend({
__init__() {
wireTypeahead();
const scan = () => {
document.querySelectorAll('select.cbi-input-select:not([data-fs-select])').forEach(enhance);
document.querySelectorAll('select.cbi-input-select[data-fs-select="1"]').forEach(resync);
};
scan();
/* A table must be TAGGED .fs-dt before it can be fitted, and re-tagged whenever the poll
* brings a fresh one back — so the two travel as one fitter, which fs-fit runs now, on
* every content mutation (synchronously, pre-paint) and on every resize of #view. */
fit.add(() => { tagDataTables(); fitTables(); unpinActionColumn(); resyncValues(); });
/* one scan per frame, however many mutations arrive (fit.frame — the theme's shared
* coalescer) */
const scanSoon = fit.frame(scan);
new MutationObserver((mutations) => {
if (relevant(mutations)) scanSoon();
}).observe(document.body, {
childList: true, subtree: true,
/* `disabled` flips and attr-driven value writes never mutate childList;
* watch them so resync()/enhance() notice (filtered — cheap) */
attributes: true, attributeFilter: [ 'disabled', 'value', 'selected' ]
});
}
});
@@ -0,0 +1,833 @@
'use strict';
'require baseclass';
/* ---- a view's injected CSS: never DELETE it; leave a poisoned document by a real load ----
*
* A view's <style> dies with the document on a full load; SPA nav never reloads, so it restyles
* every page after. `luci-app-filemanager` injects `.cbi-button-apply, .cbi-button-reset,
* .cbi-button-save:not(.custom-save-button) { display: none !important }` — unlayered + important,
* outranking every cascade layer: one visit and Save/Reset are gone from every config page.
*
* But DELETING them on nav broke SSClash. A poller can be re-registered by re-rendering the view;
* a stylesheet only returns if its injector runs AGAIN, and a library importing CSS at MODULE EVAL
* never will (module cached for the life of the document). ACE's ace_editor.css (14 KB of
* absolutely-positioned layers, gutter, line boxes) is imported once — after the sweep, navigating
* back to its editor gave a black rectangle 2 007 346 px tall. Deletion was silently one-way.
*
* So: a sheet matching only its OWN app's widgets (`.ace_*`, `.cpu-status-view-mode-entry`) is
* inert elsewhere — LEAVE it. One reaching into the widget universe the THEME styles
* (`.cbi-button-save`, `pre`, `:root`) can repaint any page: that document is spent, so refuse to
* hand it to another view and fall back to a REAL page load — speed traded, never correctness, and
* the fresh document carries no view CSS, so SPA nav resumes right after. That refusal is the SPA
* router's (fs-router.js) — this module only answers the question.
*
* `invasiveSheet()` is that test; its universe is read back from cascade.css itself (same-origin,
* so `cssRules` is readable) rather than a hand-written list, so it tracks the theme. 0.3 ms per
* nav. Exempt: `[data-fs-shell]` (the one <style> the server emits — marked, not guessed at) and
* anything inside `#view` (dies with the content swap); LuCI core injects no <style> at runtime at
* all (checked: luci.js, ui.js, cbi.js). If cascade.css cannot be read, EVERY view sheet counts as
* invasive: fail to the slow path, never the broken one. */
let _themeNames = null;
/* What counts as a NAME — a class or an id — in a selector. This is the vocabulary the whole zone
* test is written in: themeNames() harvests the theme's names with it, pinnedToApp() looks for the
* app's own name with it, and judgeSheet() asks whether a part names anything of ours with it. Three
* copies of the pattern sat under a comment explaining that two copies of the JUDGEMENT would drift
* into disagreeing — and a vocabulary that disagrees with itself is the same bug one level down: widen
* it in the harvester alone and names enter `names` that the other two can never match, so a selector
* that does reach the chrome reads as pinned and is left unfenced.
*
* Shared safely BECAUSE every use is String.match(): a /g regex is stateful under .test(), but
* [Symbol.match] resets lastIndex first. Do not call .test() on this one. */
const NAME_RE = /[.#][A-Za-z_][\w-]*/g;
/* ---- A QUOTED VALUE IS DATA, AND EVERY SCANNER BELOW USED TO READ IT AS SYNTAX ----
*
* `[title="a,b"]` is ONE selector part carrying a comma; `[href*="("]` is one attribute carrying an
* unbalanced paren; `[data-x=".foo"]` names no class at all. Read literally, each of the three
* scanners in this file gets a different wrong answer out of the same string:
* - selectorParts() split `.app-row[title="a,b"]` into `.app-row[title="a` and `b"]`. The second
* half carries no class or id, so pinnedToApp() called it UNPINNED and judgeSheet() called the
* sheet invasive — documentPoisoned() then reported the document spent and the SPA fell back to a
* full load on every navigation for the life of the page, which is the exact failure the <link>
* caching bug above was written to end.
* - fenceRules() rejoins the parts with ', ', so that same rule came back as `[title="a, b"]` —
* the app's own selector silently rewritten to match a value it never asked for, by the setter
* reporting success. Deleting a rule is what this file exists to prevent; changing one is worse,
* because nothing looks wrong afterwards.
* - stripPseudoArgs() counts parens, so `[href*="("]` drove `depth` to 1 and never back: the whole
* remainder of the selector was eaten and a part pinned by the app's own id read as unpinned.
*
* One masker answers it for all three, so the vocabulary cannot disagree with itself the way the
* comment above NAME_RE warns about. It replaces the CONTENT of every quoted string with spaces and
* is length-preserving 1:1, which is what lets selectorParts() scan the mask and still slice the
* ORIGINAL — the fence must write back the app's own bytes, not our reading of them. An escape and
* the character it escapes are both content, so `\"` cannot close the string. */
function maskStrings(text) {
let out = '', q = null;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (q === null) {
out += ch;
if (ch === '"' || ch === '\'') q = ch;
continue;
}
if (ch === '\\') {
out += ' ';
if (i + 1 < text.length) { out += ' '; i++; }
continue;
}
out += (ch === q) ? ch : ' ';
if (ch === q) q = null;
}
return out;
}
/* a re-hosted <style>'s text is no longer what its app wrote — dedupeViewSheets keys on the
* original, or the app's next identical copy stops looking like a duplicate (see there) */
const origText = new WeakMap();
function themeNames() {
if (_themeNames) return _themeNames;
const names = new Set(); /* every class and id the theme styles */
const props = new Set(); /* every custom property it declares or reads */
const walk = (rules) => {
for (const r of rules) {
/* masked like every other read of a selector: a `.foo` inside one of OUR quoted values
* would enter `names` as a name we style, and pinnedToApp() — which masks — could then
* never match it. That is the harvester-only widening NAME_RE's comment describes, and
* it ends with a foreign selector that does reach the chrome reading as pinned. */
if (r.selectorText)
(maskStrings(r.selectorText).match(NAME_RE) || []).forEach((n) => names.add(n));
if (r.cssText)
(r.cssText.match(/--[A-Za-z_][\w-]*/g) || []).forEach((p) => props.add(p));
if (r.cssRules) walk(r.cssRules);
}
};
for (const ss of document.styleSheets) {
if (!ss.href || !(/\/cascade\.css/).test(ss.href)) continue;
try { walk(ss.cssRules); } catch (e) { return null; }
}
_themeNames = names.size ? { names, props } : null;
return _themeNames;
}
/* Is this selector part held inside the app's OWN markup by a name the theme does not know?
* `#cbi-podkop-section > .cbi-section-remove` is: podkop's section has to exist for it to match
* anything, so it can never reach another page — or our chrome. A part made ENTIRELY of names the
* theme knows (`*`, `.nav`, `#indicators`, `ul.nav > li > a`) has nothing pinning it anywhere.
*
* Functional pseudo-class arguments are stripped before looking for the pin, and that is the whole
* difference between podkop and the file manager: `.cbi-button-save:not(.custom-save-button)` names
* an app class too, but inside a NEGATION — it does not require the app's markup, it excludes it.
*
* Shared by invasiveSheet() (is this sheet dangerous?) and fenceRules() (which parts get fenced?) —
* they must agree by construction: a part judged able to reach another page is exactly a part able
* to reach the chrome. Two copies of this test would drift into disagreeing. */
/* Split a selector list on its TOP-LEVEL commas. `String.split(',')` cannot: `:not(.a, .b)` is one
* part carrying a comma, and splitting it there hands both halves to pinnedToApp() as garbage —
* `.cbi-button-save:not(.custom-save-button` keeps a visible app name (the argument regex needs a
* closing paren to fire), so the file manager's own motivating rule reads as pinned and is neither
* judged nor fenced. Measured on the router: `documentPoisoned()` said clean. `:not(a, b)` is
* ordinary modern CSS, not an exotic.
*
* Scans the MASK and slices the ORIGINAL (see maskStrings): a comma inside `[title="a,b"]` is a
* character in a value, not a separator — while the parts handed back must be the app's own bytes,
* because fenceRules() joins them straight back into selectorText. */
function selectorParts(text) {
const scan = maskStrings(text);
const out = [];
let depth = 0, start = 0;
for (let i = 0; i < scan.length; i++) {
const ch = scan[i];
if (ch === '(') depth++;
else if (ch === ')') depth--;
else if (ch === ',' && depth === 0) { out.push(text.slice(start, i).trim()); start = i + 1; }
}
out.push(text.slice(start).trim());
return out.filter(Boolean);
}
/* Drop every functional pseudo-class ARGUMENT, nesting included. The old regex
* (`/:[a-z-]+\([^)]*\)/g`) stops at the first `)`, so `:not(:is(.app))` left a stray `)` and, worse,
* left `.app` looking like a pin.
*
* Works on the MASK, which does two things at once here: a paren inside `[href*="("]` no longer
* drives `depth` into a hole it never comes back from, and the `.foo` in `[data-x=".foo"]` stops
* looking like the app's own pin to pinnedToApp() — the only caller. Its output is read by NAME_RE
* and never written back to the CSSOM, so masking the content away costs nothing. */
function stripPseudoArgs(part) {
const scan = maskStrings(part);
let out = '', depth = 0;
for (let i = 0; i < scan.length; i++) {
const ch = scan[i];
if (ch === '(' ) { depth++; if (depth === 1) { out += ' '; continue; } }
if (ch === ')') { depth--; continue; }
if (!depth) out += ch;
}
return out;
}
function pinnedToApp(part, names) {
return (stripPseudoArgs(part).match(NAME_RE) || []).some((n) => !names.has(n));
}
/* A rule with a bare SELECTOR (`:root`, `pre`, `*`) still cannot touch us if none of its
* DECLARATIONS can: a custom property this theme never reads is inert. That is the difference
* between an app costing a full page load and not — `luci-app-temp-status` opens with
* `:root { --app-temp-status-temp: #147aff; … }`, and both it and the file manager's hex editor
* would otherwise read as "document spent" on the strength of the selector alone.
*
* Still invasive: any STANDARD property on a bare selector (the stock file manager writes
* `:root { color-scheme: light dark }`, re-pointing every UA widget at the OS preference), and any
* custom property the THEME reads — the point of the private `--fs-*` tier is that an app writing
* `--accent`/`--radius` on `:root` cannot repaint us, and this must keep it so for names we read. */
function inertDeclarations(rule, props) {
const st = rule.style;
if (!st || !st.length) return false; /* no declarations to judge -> judge by selector */
for (let i = 0; i < st.length; i++) {
const p = st.item(i);
if (p.slice(0, 2) !== '--') return false; /* a real property: it paints something */
if (props.has(p)) return false; /* a custom property the theme itself reads */
}
return true;
}
/* ---- the verdict is a property of the sheet, taken BEFORE we rewrite it -------------------
*
* An invasive verdict is STICKY, and it has to be: the moment rehostIntoThemeLayer() fences a sheet,
* what stands in the DOM is no longer the CSS its app wrote, and re-judging our own edit answers a
* different question than the one asked.
*
* It used to answer the right one BY ACCIDENT. The fence named a class (`.fs-sidebar`), so a fenced
* selector still carried a name the theme styles, still tripped the `themeHit` test, and
* documentPoisoned() went on reporting the document spent. Nothing said that was load-bearing —
* moving the fence onto an attribute leaves no class name in the text, every fenced document would
* have read CLEAN, and the SPA would have carried openclash's `*{padding:0!important}` into the next
* page with the chrome fenced and the content flattened. Take the verdict once, keep it.
*
* Only `true` is kept. A clean sheet can still GROW hostile rules — an app that builds its CSS with
* insertRule() has an EMPTY sheet the first time we look — so a clean verdict stays provisional and
* is re-taken on every ask. */
const _invasive = new WeakSet();
function invasiveSheet(el, universe) {
if (_invasive.has(el)) return true;
const v = judgeSheet(el, universe);
/* CACHE ONLY A VERDICT WE COULD ACTUALLY READ.
*
* A <link> has NO .sheet until its bytes land, and judgeSheet's "unreadable -> invasive" default
* therefore fires for EVERY linked app stylesheet at the instant <head>'s observer first sees
* it. Remembering that verdict is what turned a benign sheet into a permanently spent document:
* measured on the router, `luci-app-mwan3` ships ONE rule —
* `#mwan3-service-status > .alert-message { … }`, pinned to the app's own id, which this
* module's own judgement calls clean — and its <link> on the Overview left documentPoisoned()
* true for the life of the page, so every navigation FROM THE LANDING PAGE was a full load.
* Proven by serving those same bytes twice: as a <style> the document stayed clean, as a <link>
* it went poisoned. Nothing about the CSS decided it; only how it arrived.
*
* Safe to re-take, and the "never re-judge our own edit" rule above still holds, because the
* two cases do not overlap: rehostIntoThemeLayer() edits a <style>'s text and fences its rules
* — and a <style> in the document always HAS a sheet, so its verdict was cached when taken and
* is never re-taken. A <link> it never edits at all (it disables the element and re-imports the
* href into the theme layer), so a later read still sees the app's untouched CSS, which is the
* right question. A 404 or cross-origin sheet keeps answering `true` on every ask exactly as
* before — it simply is not remembered, which changes nothing.
*
* Conservative WHILE unreadable is preserved: `v` is still returned as taken, so a hostile
* sheet is fenced and re-hosted on sight and the document reads spent until the bytes prove
* otherwise. Only the memory is dropped. */
let readable;
try { readable = !!el.sheet; } catch (e) { readable = false; }
if (v && readable) _invasive.add(el);
return v;
}
/* true when this sheet can repaint a page that is not its own. A sheet that is not readable —
* still loading, 404, cross-origin — is invasive by default: unknown CSS takes the slow path,
* never the broken one. */
function judgeSheet(el, universe) {
let sheet;
try { sheet = el.sheet; } catch (e) { return true; }
if (!sheet) return true;
const { names, props } = universe;
let invasive = false;
const walk = (rules) => {
for (const r of rules) {
if (invasive) return;
if (r.selectorText) {
/* ONE question, the SAME one fenceRules() asks: is this part held inside the app's
* own markup by a name the theme does not know? `#cbi-podkop-section >
* .cbi-section-remove` is — podkop's section has to exist for it to match anything,
* so it can never reach another page or our chrome. A part with no such pin matches
* the same widgets everywhere, and that is what invasive MEANS.
*
* This used to ask a second question first — "does it name anything the theme
* styles?" — and skip the part when the answer was no. That is the hole: a pin is a
* name the theme does NOT know, so "names nothing of ours" was read as "pinned" when
* it often means the exact opposite. Measured on the router, verdict CLEAN and 95 of
* 338 chrome elements flattened, by two selectors an app could write by accident:
* *:not(#zzz) { padding: 0 !important } ← the `#` is inside a NEGATION
* [class] { padding: 0 !important } ← no class/id name at all
* Both are unpinned, both match the whole document. fenceRules() already keyed on
* pinnedToApp() alone and would have fenced them — it never got the chance, because
* the judge called the sheet clean and nothing was re-hosted. The comment above
* pinnedToApp() claims the two agree "by construction"; now they do. */
for (const p of selectorParts(r.selectorText)) {
if (pinnedToApp(p, names)) continue;
/* Unpinned, but it may still be unable to touch us: a rule whose every
* declaration is a custom property this theme never reads is inert wherever it
* lands (`:root { --app-temp-status-temp: … }`). */
if (inertDeclarations(r, props)) continue;
invasive = true;
return;
}
}
if (r.cssRules) walk(r.cssRules);
/* an @import's rules are not r.cssRules — follow it, or the verdict is blind to every
* rule behind it. Re-hosting a <link> produces exactly such a shim, and a sheet that
* judged its own shim inert would report a document clean while still carrying the
* poison into the next page. Unreadable (cross-origin) import: invasive, like any
* sheet we cannot read. */
if (r.styleSheet) {
let imported;
try { imported = r.styleSheet.cssRules; } catch (e) { invasive = true; return; }
if (imported) walk(imported);
}
}
};
try { walk(sheet.cssRules); } catch (e) { return true; }
return invasive;
}
/* Both element kinds count; the <link> half is not hypothetical: `luci-app-banip` and
* `luci-app-adblock` append `<link rel=stylesheet href=…/custom.css>` to <head> at MODULE EVAL,
* and it styles `.cbi-input-text`/`.cbi-input-select` — stock widgets, every page, unlayered. A
* <link> INSIDE the view tree (`luci-app-nlbwmon`) needs no handling: it dies with the swap. */
const VIEW_SHEETS = 'style:not([data-fs-shell]), link[rel~="stylesheet"]:not([data-fs-shell])';
/* An invasive sheet we OWN is contained — scopeToCurrentPage() darkens it the moment the router
* stamps the new page, so it cannot reach the next page and the document is not spent. One we could
* not attribute (not re-hostable, so never owned: an @import at the top, a sheet built with
* insertRule(), anything unreadable) still spends it, which is the pre-existing behaviour and the
* conservative half.
*
* A SILENCED sheet is contained too, and missing that undid the whole of the above for the <link>
* half. Re-hosting a <link> owns the @import SHIM and silences the ORIGINAL for good (see
* rehostIntoThemeLayer) — but the original stays in the document and a disabled sheet still answers
* `cssRules`, so it re-judged as invasive on every ask, was owned by nobody, and this returned true
* for the life of the document. Which means the very apps this module was written for
* (`luci-app-banip`, `luci-app-adblock`, `luci-app-openclash` — all three inject a <link>) turned
* the SPA router off entirely: a full page load on every navigation, from the moment such a page was
* opened until the tab was closed. The <style> half never showed it, because a <style> is re-hosted
* IN PLACE and therefore owned.
*
* Sound for the same reason ownership is: `el.sheet.disabled = true` is what decides whether CSS
* paints (silence() explains why the element flag alone is not enough), and nothing re-enables it —
* scopeToCurrentPage() only ever touches sheets in `_owner`, and the original is deliberately not
* one. A sheet that paints nothing cannot poison the next page. */
function documentPoisoned() {
const names = themeNames();
return Array.prototype.some.call(
document.querySelectorAll(VIEW_SHEETS),
(el) => !el.closest('#view')
&& (!names || (invasiveSheet(el, names) && !_owner.has(el) && !_silenced.has(el))));
}
/* ---- an invasive sheet still has to render ITS page: re-host it into the theme LAYER ----
*
* documentPoisoned() saves every page AFTER this one. It cannot save this one — the sheet is
* 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 reset owns the chrome outright:
* `luci-app-openclash` ships `* { margin: 0; padding: 0 }` (a log.htm reset, leaked document-wide
* by a <link> its Lua template prints into .fs-content). Measured on the router with those two
* rules and nothing else: menu text flush at x=0 with the icons clipped, submenu indent gone, tabs
* collapsed to a bare text row. On stock luci-theme-bootstrap — no layers — the same `*` (0,0,0)
* loses to any class selector on specificity and nobody ever noticed. That asymmetry is the whole
* bug, and it is ours: the layers are what handed a 0,0,0 selector the win (issue #8).
*
* So put the sheet back on specificity footing: re-host it into the EXISTING `theme` layer. Only
* same-layer arbitrates by specificity — which is exactly what makes bootstrap survive. Measured,
* all three placements, on the real cascade:
*
* app unlayered (today) chrome BROKEN app's own design OK
* app -> @layer theme chrome OK app's own design OK
* app -> @layer before theme chrome OK app's own design BROKEN
*
* The tempting shape is the third — give the app its own layer under the theme. Do NOT: the theme
* then beats the app at ANY specificity, including the rules it aims at its own page. OpenClash
* restyles its tabs with `#tab-header ul.cbi-tabmenu li` (1,1,2) and footstrap styles
* `ul.cbi-tabmenu li` (0,1,2) — demote the app and footstrap repaints the app author's own tabs.
* In `theme` the app keeps them, and `*` still loses to the chrome's 0,3,1. No new layer is
* declared; a re-opened `@layer theme` block appends to the one 00-header.css already names.
*
* What this deliberately does NOT fix: `base`. The app must outrank `theme` for its own page to
* work, so it sits above `base`, and `*` still wipes base's widget padding (cssdiff: `input`
* 4px->0, `.ifacebadge`, `strong` margins). That is not a regression — an unlayered `*` beats base
* today too — it is the price of the same trade, and the only way out would re-break the app.
*
* NEVER delete the sheet instead (see dedupeViewSheets below for what that cost). Re-hosting moves
* where a rule lands in the cascade; every rule still exists, so a library's "did I already import
* this?" check still finds its sheet. A <link> is DISABLED rather than removed, so an app that
* looks its own <link> back up by href still finds the element. */
/* ---- the fence: the chrome is ours, so make a foreign rule unable to MATCH it ----
*
* Re-hosting into the theme layer settles a fight on specificity. It cannot settle one against
* `!important`, because importance ranks ABOVE layers — measured: `* { padding: 0 !important }`
* still owns the chrome after re-hosting, and so does `#indicators { display: none !important }`.
* The only pure-CSS answer to a foreign flag is our own flag in an earlier layer (it wins — also
* measured), but that means ~550 of them, and `color`/`background` among them would beat this
* theme's OWN forced-colors block. Fixing the cascade by breaking high-contrast is not a fix.
*
* So do not out-rank the rule — put the chrome where it cannot be addressed. Appending
* `:where(:not([data-fs-chrome], [data-fs-chrome] *))` to a foreign selector's SUBJECT leaves it
* matching everything it used to except us. `!important` has nothing left to win.
*
* The chrome is NOT one element, and naming one is how this went wrong the first time: the fence
* said `.fs-sidebar`, which is the menu in both layouts (the bar is the same markup) — but the skip
* link is a sibling of .fs-shell and the Appearance popover hangs off <body>, so both stayed exposed
* while every test said the chrome was defended. `data-fs-chrome` is the fix: an element DECLARES
* that it is ours, where it is written (header.ut, fs-appearance.js), and the fence and the pin
* follow without being told. A future chrome root cannot forget to edit a constant in this file,
* because there is no constant naming it any more. `npm run chrome-fence` holds the three together.
*
* `:where()` is load-bearing and not cosmetic: it contributes ZERO specificity, so `*` stays 0,0,0
* and `#indicators` stays 1,0,0 and the app's rules keep their exact weight against each other and
* against the theme on its own page. A plain `:not(.fs-sidebar)` takes its argument's specificity
* and would silently re-order the app's stylesheet against itself.
*
* Only UNPINNED parts are fenced. A part pinned by the app's own name cannot reach the chrome
* anyway (proven by the same test invasiveSheet uses), so leaving it alone costs nothing and keeps
* the surgery as small as the danger.
*
* Two silent traps, both measured on the real CSSOM, both of which cost the app its rule:
* - A selector LIST must be fenced part by part. Appending to the whole `selectorText` fences only
* the last part: `*, ul` came back as `*, ul:where(…)` — `*` unfenced, chrome still exposed.
* - A pseudo-element must stay LAST. `a::after` + a tail append serialised to `a::after:where()`
* — the argument silently EATEN, leaving an empty `:where()` that matches NOTHING, and the
* setter reported success. The fence goes before the pseudo-element: `a:where(…)::after`. */
const CHROME_FENCE = ':where(:not([data-fs-chrome],[data-fs-chrome] *))';
function fenceSelector(part) {
/* the getter always normalises a pseudo-element to `::`, incl. legacy `:before` */
const i = part.indexOf('::');
return i < 0 ? part + CHROME_FENCE : part.slice(0, i) + CHROME_FENCE + part.slice(i);
}
function fenceRules(rules, names) {
for (const r of rules) {
if (r.selectorText) {
const parts = selectorParts(r.selectorText);
if (parts.length && parts.some((p) => !pinnedToApp(p, names))) {
/* The setter parses the whole selector and, on one it cannot parse, does NOTHING and
* does not throw — so it is atomic: never a half-written selector, and a failure just
* leaves the rule where it already was (unfenced, i.e. today's exposure). */
try {
r.selectorText = parts
.map((p) => (pinnedToApp(p, names) ? p : fenceSelector(p))).join(', ');
} catch (e) { /* left unfenced on purpose: the app keeps its rule */ }
}
}
if (r.cssRules) fenceRules(r.cssRules, names);
}
}
/* An @import's rules live in a sheet that is fetched separately, so they are not there the moment
* the shim is inserted — retry until they are, then fence. Bounded: a sheet that never becomes
* readable (404, cross-origin) simply stays unfenced, which is where we already were. */
function fenceImported(styleEl, names, tries) {
/* no initialiser: every path below assigns (the try, or the catch's null), so `= null`
* here was a dead store — eslint 10 puts no-useless-assignment in recommended and said so. */
let rules;
try {
const first = styleEl.sheet && styleEl.sheet.cssRules[0];
rules = first && first.styleSheet && first.styleSheet.cssRules;
} catch (e) { rules = null; }
if (rules) { fenceRules(rules, names); return; }
if (tries > 0) requestAnimationFrame(() => fenceImported(styleEl, names, tries - 1));
}
/* What a sheet IS, as text: the rules that are APPLYING, not the markup that may or may not have
* produced them. Serialised only ever to COMPARE — never re-parsed, so the serialiser cannot cost
* anyone a rule. */
const serializeRules = (rules) => Array.prototype.map.call(rules, (r) => r.cssText).join('\n');
/* ---- a <style>'s textContent is NOT its sheet, and both the wrap and the dedupe assumed it was ----
*
* Wrapping means re-setting textContent, which RE-PARSES: whatever the parse does not reproduce is
* deleted — silently, by the one fix in this file whose entire thesis is that deleting a view's CSS
* is one-way (see the head of the file). Two shapes where the text does not describe the sheet, and
* they are one question, not two:
* - an app that builds its CSS with insertRule() — an empty <style> appended first, rules pushed in
* after: the text is EMPTY while the rules apply, so the wrap writes `@layer theme {}` over a live
* sheet and every rule in it is gone.
* - a <style> carrying @import: it is invalid inside @layer and has to sit at the top of a sheet, so
* the wrapped copy comes back without it.
*
* So ask the exact question once — does re-parsing this text give back the sheet that is applying? —
* rather than enumerate the shapes that make it false; the enumeration is what missed insertRule().
* The probe is a CONSTRUCTIBLE sheet: never adopted, so nothing paints, no <head> mutation, and our
* own observer never sees it. It also drops @import (per spec), which is why that case needs no test
* of its own — the serialisations differ and the answer is already no.
*
* No probe means no answer, and the honest answer to "may I re-parse this?" when we cannot check is
* NO: the sheet keeps every rule and the fence still holds Zone 1 without it. */
let _probe = null;
function textIsSheet(el, live) {
try {
if (!_probe) _probe = new CSSStyleSheet();
_probe.replaceSync(el.textContent);
return serializeRules(_probe.cssRules) === serializeRules(live);
} catch (e) { return false; }
}
/* Sheets taken out of the cascade FOR GOOD — the re-hosted <link> originals. Kept because a
* silenced sheet is still an ELEMENT in the document that still answers `cssRules`, so every later
* ask re-judges it as invasive; documentPoisoned() explains what that cost. */
const _silenced = new WeakSet();
/* Take a re-hosted <link> out of the cascade — and MEAN IT.
*
* `el.disabled = true` alone does not do it, and the failure is silent and total. The IDL attribute
* forwards to the ELEMENT's own flag; the thing that decides whether the CSS paints is
* `el.sheet.disabled`, and a <link> that is still LOADING has no `.sheet` at all. Every runtime
* injection is in exactly that state when <head>'s observer hands it here — which is the case this
* module exists for (`luci-app-banip` and `luci-app-adblock` append their <link> at module eval,
* openclash prints one from its template). So the assignment landed on nothing, the sheet came up
* ENABLED when the bytes arrived, and the app's ORIGINAL, UNFENCED CSS went on painting beside the
* fenced @import shim.
*
* Measured on the router with `* { padding: 0 !important }` behind a runtime <link>: `el.disabled`
* read back `true`, `el.sheet.disabled` was `false`, and 95 of the 338 chrome elements were
* flattened — the sidebar's own padding went 0px 88px -> 0px — while the shim's fenced copy sat
* there matching nothing. Setting `el.sheet.disabled = true` by hand restored all of it. The
* documented "openclash: 47 damaged -> 0" holds only because THAT sheet is server-rendered and has
* therefore already loaded by the time the immediate pass sees it.
*/
function silence(el) {
_silenced.add(el);
el.disabled = true;
if (el.sheet) { el.sheet.disabled = true; return; }
/* no sheet yet: re-assert once there is one. `once` — the element is marked fsLayered, so this
* never re-arms, and a sheet that never loads has nothing to silence. */
el.addEventListener('load', () => { if (el.sheet) el.sheet.disabled = true; }, { once: true });
}
/* ---- PAGE OWNERSHIP: contain an invasive sheet instead of spending the document ----
*
* A foreign sheet is injected by ONE page and has no business painting any other. Before this, an
* invasive sheet made the whole document spent (documentPoisoned) and the SPA fell back to a full
* load on the way OUT — correct, but stock LuCI pays it: `view/status/cpu.js` and its four realtime
* siblings each `document.head.append(E('style', …))` at MODULE EVAL, and that style carries
* `svg text { fill: #eee; font-size: 9pt }` — real properties on a bare selector, so invasive by the
* only definition that catches `[class] { padding: 0 !important }`. Measured on the router: 37 of 38
* pages navigated in-place instead of 38, `status/realtime/load` 15 ms -> 157 ms, and the whole
* realtime family 2-10x slower.
*
* Removing the sheet on the way out is NOT the fix, and it is the obvious one: the append is at
* module top level, `L.require` caches the module, so a second visit re-runs nothing and the page
* would render unstyled. Disabling is reversible, which is the whole difference.
*
* OWNER = body[data-page] when the sheet was re-hosted. The order that makes this sound is in
* fs-router.js: it stamps data-page (line ~371) BEFORE require()ing the view class (~450), so at the
* moment a view module evaluates and appends its <style>, the attribute already names ITS page. On a
* full load the server stamped it. Either way "now" is the sheet's own page.
*
* Recorded on the element that PAINTS, never on the one that was permanently silenced: for a <link>
* that is the @import shim, and re-enabling the original instead would undo silence() and put the
* app's unfenced CSS back over the chrome — the measured 95-of-338 flattening.
*
* THE OWNER IS THE APP, NOT THE PAGE, and that is not a guess — per-page was written first and
* swept: `luci-app-zapret2` has three pages that share ONE injected <style>
* (`.label-status { … !important }`), so it was owned by whichever loaded first and arrived DARK on
* the other two. Per-page ownership silently un-styles any app whose pages share an injector, which
* is a whole class of app, not a corner. `admin/<group>/<app>` — the first three dispatch segments —
* is the smallest key that keeps an app's own pages together while still blocking the leak this
* exists to block: onto OTHER apps and onto stock pages. The sweep is
* `tools/...`-less on purpose (it needs a live router); re-run it against a router with third-party
* apps after touching this, and look for a sheet that a full load has and an SPA arrival does not.
*
* Segments, never the dash-joined `data-page`: a dispatch segment may itself contain a dash
* (`admin/system/package-manager`), so splitting the attribute on '-' would cut inside a name. */
const _owner = new WeakMap();
const APP_DEPTH = 3;
/* The router hands this over on every navigation (it holds the resolved segments). Until it does —
* the initial full load — ask the SERVER which page it dispatched to. */
let _curKey = null;
function appKey(segs) {
return (segs || []).slice(0, APP_DEPTH).join('/');
}
/* ---- THE URL IS NOT THE PAGE, and a sheet keyed on the URL is a sheet that dies ----
*
* `L.env.dispatchpath` is the leaf the SERVER resolved this request to; the address bar holds what
* was ASKED for, and LuCI's dispatcher walks a node down to its firstchild without rewriting it.
* Two shapes of that, both ordinary:
*
* /cgi-bin/luci/admin/status -> admin/status/overview the Status menu's OWN link
* /cgi-bin/luci/ -> admin/status/overview the landing page, on a router with no
* luci-mod-dashboard
*
* The URL says `admin/status` and `''`; the router, one navigation later, hands
* scopeToCurrentPage() the RESOLVED `admin/status/overview`. Those keys can never match, so the
* first SPA navigation away from such a page disables the sheets that page owns — and
* rehostIntoThemeLayer() has already silenced the app's original <link> for good, so nothing
* paints them again for the life of the document. A full-load key that is wrong is worse than no
* key at all: it is a sheet that works until you navigate.
*
* Measured on owrt2512 with luci-app-mwan3, whose status include injects
* `#mwan3-service-status > .alert-message { display:inline-block; width:15rem; … }`: full load on
* /admin/status renders the card as `inline-block 240px 96px`; System -> General and back leaves
* it `block 966px` — every interface card on the Overview stacked full-width. Keyed on the
* dispatch path: 240px before and after. */
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('/') : []);
}
/* Both halves, for the reason silence() documents: el.disabled is the ELEMENT's flag and
* el.sheet.disabled is what decides whether the CSS paints, and a still-loading <link> has no
* .sheet for the assignment to reach. */
function setEnabled(el, on) {
el.disabled = !on;
if (el.sheet) el.sheet.disabled = !on;
else if (!on) el.addEventListener('load', () => { if (el.sheet) el.sheet.disabled = true; }, { once: true });
}
/* Called by the router right after it stamps data-page, with the RESOLVED segments. Only sheets we
* OWN are touched: a clean sheet is harmless and an invasive one we could not attribute still
* poisons the document, so it keeps the full-load path rather than being silently disabled on its
* own page. */
function scopeToCurrentPage(segs) {
if (segs) _curKey = appKey(segs);
const key = currentKey();
document.querySelectorAll(VIEW_SHEETS).forEach((el) => {
if (el.closest('#view') || !_owner.has(el)) return;
setEnabled(el, _owner.get(el) === key);
});
}
function rehostIntoThemeLayer(el, universe) {
if (el.dataset.fsLayered) return;
if (el.tagName === 'LINK') {
/* A <link>'s rules cannot be moved into a layer in place — but an @import CAN name one.
* The href is already absolute and same-origin, so the re-fetch is a cache hit. */
const s = document.createElement('style');
s.dataset.fsLayered = '1';
s.textContent = '@import url("' + el.href.replace(/["\\]/g, '\\$&') + '") layer(theme);';
el.dataset.fsLayered = '1';
el.after(s); /* keep source order: ties inside the layer still resolve as they did */
silence(el);
_owner.set(s, currentKey()); /* the shim paints; the original is silenced for good */
fenceImported(s, universe.names, 60); /* ~1s of frames; a cache hit lands on the first */
return;
}
let rules;
try { rules = el.sheet && el.sheet.cssRules; } catch (e) { return; }
/* No rules yet: nothing to re-host, nothing to fence — and, crucially, nothing to MARK. An app
* that appends an empty <style> and fills it with insertRule() arrives here first; marking it
* handled now would leave the sheet it is about to build unfenced for the life of the document. */
if (!rules || !rules.length) return;
/* Handled — and never twice. fenceRules() is NOT idempotent: pinnedToApp() strips a functional
* pseudo-class before looking for the app's own name, so an already-fenced selector reads as
* unpinned all over again and a second pass appends a second fence. The mark is the only thing
* that says the work is done, so it has to be set for every path below, wrapped or not. */
el.dataset.fsLayered = '1';
_owner.set(el, currentKey()); /* a <style> is re-hosted IN PLACE, so it paints itself */
/* Wrap only if the text still IS the sheet (see textIsSheet). When it is not, the sheet stays
* unlayered — Zone 2 exactly where it already was, which is a trade — rather than lose rules,
* and it is still FENCED below: the fence is pure CSSOM, needs no re-parse, and is the half that
* answers `!important` anyway.
*
* Layer by TEXT, fence by CSSOM, in that order: re-setting textContent re-parses the sheet and
* would throw away any selector we had already rewritten. A <style>'s url()s resolve against the
* document either way, so re-parsing costs nothing here — which is exactly NOT true of a <link>
* (measured: cssText serialises `url("img.png")` still relative, so inlining a linked sheet would
* silently re-base every image and font in it. That is why a <link> keeps its @import). */
if (textIsSheet(el, rules)) {
origText.set(el, el.textContent); /* dedupeViewSheets keys on this — see there */
el.textContent = '@layer theme {\n' + el.textContent + '\n}';
}
try { if (el.sheet) fenceRules(el.sheet.cssRules, universe.names); } catch (e) { /* unfenced, not broken */ }
}
/* Re-hosting needs the theme's own selectors to tell an invasive sheet from an inert one. If
* cascade.css cannot be read we cannot classify, so re-host NOTHING and leave the cascade exactly
* as it is: documentPoisoned() already fails every sheet to the slow path in that case, and
* silently demoting an app we could not judge is the one move with no way back for its author. */
function rehostInvasiveSheets() {
const universe = themeNames(); /* {names, props} — NOT a bare Set; the fence wants .names */
if (!universe) return;
document.querySelectorAll(VIEW_SHEETS).forEach((el) => {
if (el.dataset.fsLayered) return;
if (invasiveSheet(el, universe)) rehostIntoThemeLayer(el, universe);
});
}
/* ---- the one thing that IS safe to remove: a byte-identical second copy ----
*
* Not deleting view CSS costs where an app injects on EVERY render: `luci-app-podkop` calls
* injectGlobalStyles() from render() (4 KB, no guard) and `luci-app-mosdns` re-appends three
* CodeMirror <link>s, so every SPA re-visit adds a copy that never stops being parsed. Dropping an
* EXACT duplicate cannot break anyone, for the reason the sweep failed: the rules do not go away —
* the surviving copy is byte-identical, and a library's "have I already imported this?" check (what
* ACE died on) still finds its sheet. Keep the FIRST copy: it is what any handle the app kept
* points at.
*
* Key a <style> on what its APP wrote, not on what stands in the DOM: re-hosting rewrites the text
* (@layer wrapper), so a wrapped first copy and podkop's next byte-identical injection would no
* longer match — the duplicate detector would go quiet exactly where it earns its keep, and the
* copies would pile up again. A <link> keys on href, which re-hosting leaves alone.
*
* Re-hosting must therefore run BEFORE this, never after: a fresh copy of a <link> is appended to
* <head>, which is EARLIER in document order than a template's <link> down in .fs-content, so the
* "keep the first" rule would keep the raw copy, drop the re-hosted one, and strand its @import
* shim — the chrome breaking again while shims pile up once per render (measured; that is the leak
* this function exists to prevent). Re-host first and both copies are equivalent by the time they
* are compared, so whichever survives is already layered and the loser's shim is a byte-identical
* duplicate this same pass collapses. */
function sheetKey(el) {
if (el.tagName === 'LINK') return 'LINK|' + el.href;
const t = origText.get(el);
if (t !== undefined) return 'STYLE|' + t;
/* Not wrapped, so no original was kept — and this sheet's textContent may not BE its sheet (see
* textIsSheet): every insertRule-built <style> has an empty one, so keying on the text gave them
* all the same key and the second one was REMOVED as a "duplicate" of a sheet it shares nothing
* with. That is the deletion this file exists to prevent, dressed as a dedupe. Key on what is
* applying instead. */
let rules;
try { rules = el.sheet && el.sheet.cssRules; } catch (e) { return null; }
/* A sheet with no rules is a duplicate of nothing — and it is very likely a <style> an app has
* appended but not yet filled: removing it strands the handle it is about to insertRule through. */
if (!rules || !rules.length) return null;
return 'STYLE|' + serializeRules(rules);
}
function dedupeViewSheets() {
const seen = new Set();
document.querySelectorAll(VIEW_SHEETS).forEach((el) => {
if (el.closest('#view')) return;
const key = sheetKey(el);
if (key === null) return;
if (seen.has(key)) el.remove();
else seen.add(key);
});
}
/* ---- THE LAYER ORDER IS A DOCUMENT-WIDE FACT, AND A SHEET INSERTED FIRST CAN REWRITE IT ----
*
* `@layer tokens, base, theme, page;` in 00-header.css is what makes theme beat base. That
* statement only holds while cascade.css is the FIRST sheet in the document to name a layer — the
* order is fixed by first appearance, and an earlier sheet naming `theme` makes theme the FIRST
* layer, i.e. the WEAKEST. Every later name is appended after it, so the whole cascade inverts:
* `tokens, base, page` end up above `theme` and base's `* { padding: 0 }` wins over the chrome's
* own rules. Measured on the router: `.fs-content` padding 24px/28px -> 0, and the top bar, the
* tabs and every button flattened with it.
*
* Which is exactly what re-hosting an app's sheet into `@layer theme` can cause, because WHERE the
* app put its <style> is the app's choice. Ace (shipped by `luci-app-ssclash`, and by any package
* that embeds an editor) calls `dom.importCssString`, which inserts its <style> as the FIRST CHILD
* of <head> — ahead of cascade.css. Wrapping that sheet, as the fence must, moved the first mention
* of `theme` to the top of the document and took the theme layer down with it. It is lazy, too:
* Ace adds more of those sheets on first hover, which is the "reloading fixes it until I touch
* anything" in the report.
*
* The repair is one declaration, and it works because inserting a NEW sheet re-runs the ordering
* (moving an existing one does NOT — measured, both ways): re-declare the canonical order from a
* fresh <style> placed first in <head>. Cheap, idempotent, and it states the same order 00-header.css
* does — one more copy of it, which is why the text is derived from nothing and simply repeated
* here, in the one other place that can see the whole document. */
const LAYER_ORDER = '@layer tokens, base, theme, page;';
let _layerStmt = null;
function reassertLayerOrder() {
const head = document.head;
if (!head) return;
/* The anchor is whichever of ours comes first: cascade.css, or the statement a previous pass
* already put in front of it. Only a sheet ahead of THAT can have named a layer before we did —
* on a page with no foreign sheet this is one querySelectorAll and out. */
const own = [...document.querySelectorAll('link[rel~="stylesheet"]')]
.find((l) => (/\/cascade\.css/).test(l.href || ''));
if (!own) return;
const anchor = _layerStmt && _layerStmt.isConnected ? _layerStmt : own;
const ahead = [...document.querySelectorAll('style, link[rel~="stylesheet"]')]
.some((el) => el !== anchor && el !== own &&
(anchor.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING));
if (!ahead) return;
/* A FRESH element every time: re-inserting the same node is a move, and a move does not re-run
* the ordering (measured — the inverted document stayed inverted). Dropping the previous one
* keeps this at one spare <style> per document however many sheets an app injects. */
if (_layerStmt) _layerStmt.remove();
_layerStmt = document.createElement('style');
_layerStmt.textContent = LAYER_ORDER;
head.insertBefore(_layerStmt, head.firstChild);
}
/* Watch <head> rather than deduping on navigation: the copy arrives too late otherwise — podkop
* injects from its render(), which resolves AFTER the router's require() callback, so a nav-time
* sweep left the document permanently carrying one stale duplicate (bounded, never zero). The
* observer collapses the copy in the microtask it appears in. It cannot loop: a removal produces a
* mutation with no ADDED nodes, and the handler bails unless a stylesheet was added.
*
* The immediate pass is not the observer's job and cannot be: a legacy Lua page's <link> is in the
* SERVER's HTML (openclash prints it into .fs-content), so it is parsed and applying long before
* this module is even fetched — there is no mutation to observe. It is re-hosted on the first pass
* instead, which costs a brief flash of unstyled chrome before the modules land; the page then
* settles correct, where today it stays broken. Runtime injections land in <head> (podkop, banip,
* adblock, the file manager), which is what the observer watches — deliberately not the whole
* document, since LuCI's poll rewrites content every second and this would fire on every tick. */
function watchViewSheets() {
/* Dedupe the immediate pass too, in the observer's order (re-host strictly first — see there).
* It used to re-host only, which left the server-rendered duplicate — the one case this pass
* exists for — uncollapsed for the life of the document. Measured with the real
* luci-app-openclash: it prints the same <link href=oc.css> from three templates, so its
* Overwrite Settings page carried two identical links and the two @import shims we make for
* them, parsing 117 KB of CSS twice. The observer never fires for either: both are in the
* SERVER's HTML, so there is no mutation to see. */
rehostInvasiveSheets();
dedupeViewSheets();
reassertLayerOrder(); /* strictly AFTER the re-host: it is the wrap that can invert the order */
const mo = new MutationObserver((muts) => {
for (const m of muts)
for (const n of m.addedNodes)
if (n.nodeName === 'STYLE' || n.nodeName === 'LINK') {
/* `continue`, not `return`: our own statement can share a batch with the very
* sheet that made it necessary, and bailing on the batch would skip that one. */
if (n === _layerStmt) continue;
rehostInvasiveSheets(); /* strictly before the dedupe — see there */
dedupeViewSheets();
reassertLayerOrder();
return;
}
});
mo.observe(document.head, { childList: true });
/* …and <body>, because `document.head.appendChild` is a CONVENTION, not a rule. An app that
* appends its <style> to <body> (or to documentElement) after chrome init was seen by nothing:
* the immediate pass had already run and the mutation was not under observation. Measured with
* `* { padding: 0 !important }` in a body-appended <style>: 95 of 338 chrome elements flattened
* and the sheet never marked. documentPoisoned() still saw it, so the SPA fell back to full
* loads — the page you are ON stayed broken, which is the half that matters.
*
* childList WITHOUT subtree, exactly as for <head>: this fires only for DIRECT children of
* <body>, and LuCI's poll rewrites content inside #view — a descendant — so the per-tick cost
* the head-only choice was protecting stays zero. */
mo.observe(document.body, { childList: true });
}
return baseclass.extend({
documentPoisoned,
scopeToCurrentPage,
watchViewSheets
});
@@ -0,0 +1,39 @@
'use strict';
'require baseclass';
/* ---- theme identity: the version footstrap SHIPS, and the repo it came from ----
*
* Shown at the foot of the Appearance tab with NO network call: it is what is installed, not what
* is available. Which version is available is the package manager's question — the installer adds
* the feed, and `apk upgrade` answers it.
*
* FS_VERSION is stamped at build/deploy: the theme Makefile (Build/Prepare) and dev-sync.sh rewrite
* the '0.0.0-dev' literal below — BY FILE NAME, so this constant cannot move to another file without
* changing both seds. An unstamped source checkout stays 'dev'. */
const FS_VERSION = '0.0.0-dev';
/* The parentheses around the regex are load-bearing — do not "tidy" them away. luci.mk minifies
* this file with jsmin, whose regex-vs-division test is a ONE-character lookback against a fixed
* allow-list. `n` (the last letter of `return`) is not on it, so `return /re/` is read as a
* division and the regex's `//` swallows the rest of the file — exiting 0 (openwrt/luci#8299).
*
* The shape of the version is the WHOLE test — do NOT re-add a `FS_VERSION !== '0.0.0-dev'`
* comparison. CI runs terser over this file BEFORE the Makefile stamps the version, so at that
* moment both sides of that comparison are the same literal: terser folded it to `&& !1` and
* every released build reported itself as '(dev)' forever after. An SDK/buildbot build has no
* terser step, so it worked locally and only ever broke in a release. A regex test is not constant-folded —
* proven by the same minified output, which kept this very `.test()` call — so the sentinel is
* excluded by its SHAPE instead. `-dev$` and not `^\d+\.\d+\.\d+$`, because dev-sync.sh stamps
* `git describe` ('0.9.4-12-gabc1234') and that must keep counting as a real version. */
function isReal() { return ((/^\d+\.\d+/).test(FS_VERSION)) && !((/-dev$/).test(FS_VERSION)); }
return baseclass.extend({
VERSION: FS_VERSION,
/* the project's page, for the link at the foot of the Appearance tab. One string, used once —
* it was assembled from a separate FS_REPO constant that the retired updater needed to build
* API paths from, and nothing has needed the bare `owner/name` since. */
REPO_URL: 'https://github.com/VizzleTF/luci-theme-footstrap',
isReal,
/* what the popover's version row prints */
label: () => (isReal() ? ('Footstrap v' + FS_VERSION) : 'Footstrap (dev)')
});
@@ -0,0 +1,301 @@
'use strict';
'require baseclass';
'require fs-prefs as prefs';
/* The theme's own UI primitives: the disclosure pair the menu is built on, the two controls the
* Appearance popover is built on, and the popup placement both hand-placed popups share. Nothing
* here knows what it is being used FOR — that is the point, and it is why the menu and the popover
* can each take what they need without either requiring the other. */
/* The chrome's ONE inline-SVG wrapper. Every icon this theme draws is the same 24x24 stroked
* outline and differs only in its path data, but the wrapper was written out per call site — in the
* menu, in the search box, and again in four .ut partials — so `stroke-width` and the two linecap
* attributes were free to drift between icons that are meant to look like one set. Body in, markup
* out; the caller supplies only the shape.
*
* aria-hidden: every icon here sits beside its own label (or inside a control that has one), and an
* unlabelled <svg> is otherwise announced as a graphic in its own right. */
function svgIcon(body, cls) {
return '<svg class="' + (cls || 'fs-ico') + '" aria-hidden="true" viewBox="0 0 24 24" fill="none" '
+ 'stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">'
+ 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. These lived once per menu file back when there were two, and the copies had already drifted
* (only one Escape handler learnt to check flyout mode). The trigger SELECTOR stays a parameter. */
/* `.open` and aria-expanded must never disagree — `.open` alone told a sighted user everything and
* a screen-reader user nothing — so every open and close goes through this one function.
* `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');
}
/* 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;
ev.preventDefault();
link.click();
});
}
/* Dismissal both ways: a click outside closes; and WCAG 2.2 SC 1.4.13 (Content on Hover or Focus)
* 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" — closing an
* unfolded ACCORDION because the user clicked elsewhere on the page would be wrong. */
function wireDismiss(opts) {
const active = () => (opts.when ? opts.when() : true);
document.addEventListener('click', (ev) => {
/* `closest?.` — a document-level listener sees whatever anyone dispatches, and a click
* whose target is not an Element (document itself, a text node from a synthetic dispatch)
* has no closest(). The throw would come out of THIS listener, i.e. the menu would stop
* closing its flyouts for the rest of the session. Every other document-level handler in
* the theme already guards it (fs-router's link router, fs-search's shortcuts,
* fs-select's typeahead); this one was the odd one out. */
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();
});
}
/* THE SEGMENTED CONTROL AND THE RANGE WRAPPER WERE HERE, and they are not coming back: the
* Appearance page draws its enums with `ui.Select` and its numbers with `ui.RangeSlider` — LuCI's
* own widgets, present on every release this theme supports and already dressed by this theme's
* stylesheet (`select` in base/30-forms.css, `.cbi-range-slider` in theme/60-inputs.css).
*
* They were written for the floating popover this page used to be, where a native <select> read as
* a hole in the card. On a page there is no such argument, and a control LuCI maintains is one this
* theme cannot get wrong on its own — the roving tabindex here was itself a bug fix for a
* radiogroup that announced one control and offered N tab stops. */
/* ---- colour: reading what the page is ACTUALLY painted, and what that costs in contrast --------
*
* The Appearance page has to answer two questions no stored value can: what colour is a role right
* now (the palette's own, when the axis is off — and there is no copy of the palette in JS, on
* purpose), and what contrast does the user's colour land at. Both are questions about the computed
* cascade, so both are asked of the browser.
*
* `getComputedStyle(root).getPropertyValue('--fs-accent')` cannot answer either: a custom property's
* computed value is the token stream after var() substitution, so a palette hex comes back as a hex
* but `oklch(from … l c H)` comes back as that function, unevaluated. Setting the expression as a
* real `color` on a real element and reading it back gets the browser to resolve it — which is the
* whole point, since relative colour, color-mix() and the tint's calc() are exactly the values the
* theme is made of. One hidden probe, reused: an element per query would be a layout thrash on
* every drag of the hue slider. */
let _probe = null;
function probeColor(expr) {
if (!_probe) {
/* off-screen rather than display:none — a display:none element still computes `color`, but
* keeping it laid out avoids depending on that being true of every engine. It carries no
* text and no size, so it paints nothing. */
_probe = E('span', { 'style': 'position:fixed;left:-9999px;top:0;width:0;height:0;overflow:hidden;pointer-events:none', 'aria-hidden': 'true' });
document.body.appendChild(_probe);
}
/* cleared first: an expression the engine rejects leaves the PREVIOUS colour standing, which
* would report a stale answer as a fresh one. */
_probe.style.color = '';
_probe.style.color = expr;
return getComputedStyle(_probe).color;
}
/* A computed colour string -> [r,g,b] 0..255, or null. Engines do not agree on the notation: both
* `rgb(9, 105, 218)` and the space-separated `rgb(9 105 218 / .5)` are current, and a colour outside
* sRGB (which oklch() readily produces) comes back from some engines as `color(srgb .03 .41 .85)`,
* i.e. 0..1 floats. Take the first three numbers and scale by whether the form is the 0..1 one —
* the alpha after the slash is deliberately ignored, since every token this reads is opaque. */
function parseColor(s) {
const nums = String(s || '').match(/[\d.]+/g);
if (!nums || nums.length < 3) return null;
const unit = (/^color\(/i).test(String(s)) ? 255 : 1;
return nums.slice(0, 3).map((n) => Math.max(0, Math.min(255, parseFloat(n) * unit)));
}
/* WCAG 2.x relative luminance and contrast ratio, on sRGB. Used only to REPORT: the theme never
* corrects a colour behind the user's back, it says what the colour costs and leaves the choice
* with them (03-palettes.css derives the ink over a fill, which is a different question — that one
* has a right answer). */
function luminance(rgb) {
const c = rgb.map((v) => {
const x = v / 255;
return (x <= .03928) ? (x / 12.92) : Math.pow((x + .055) / 1.055, 2.4);
});
return (.2126 * c[0]) + (.7152 * c[1]) + (.0722 * c[2]);
}
function contrastRatio(fgExpr, bgExpr) {
const fg = parseColor(probeColor(fgExpr)), bg = parseColor(probeColor(bgExpr));
if (!fg || !bg) return null;
const a = luminance(fg), b = luminance(bg);
return (Math.max(a, b) + .05) / (Math.min(a, b) + .05);
}
/* #rrggbb from whatever probeColor returned, because <input type="color"> accepts nothing else —
* not a name, not an rgb() string, not a short hex. An unparseable colour becomes black rather than
* throwing: the swatch is a convenience beside the text field, and the text field is authoritative. */
function toHex(s) {
const rgb = parseColor(s) || [ 0, 0, 0 ];
return '#' + rgb.map((v) => Math.round(v).toString(16).padStart(2, '0')).join('');
}
/* One COLOUR axis: a native swatch, the hex field beside it, and a button back to the palette's own
* colour. It reports through onPick as a hex STRING, or 0 for "back to the palette" — the caller
* hands either straight to fs-prefs.js's colorAxis.
*
* THERE IS NO HUE SLIDER, and it was here. The axis still stores a hue (1–360) and the stylesheet
* still rotates the palette by one — a value saved before this, or a router default written then,
* goes on working — but nothing in the UI produces one any more. Two controls for one value read as
* two settings however carefully they mirrored each other, and the slider was the half that could
* not do the job the axes exist for: rotating a hue keeps the palette's chroma, so no angle of it
* reaches a grey. A field that takes the colour you want is the whole control.
*
* `opts.probe` is the live token to read the effective colour back from (so the field shows the
* PALETTE's colour while the axis is off, with no copy of the palette in JS), and `opts.contrast`
* is the pair whose ratio is reported under the row. */
function colorControl(current, onPick, label, opts) {
const o = opts || {};
/* type=color is the one control here that is NOT ours: the browser draws the picker, which is
* the right call — it is the picker the user already knows, it is keyboard- and
* screen-reader-accessible without us reimplementing a colour wheel, and on a phone it is the
* native one. The text field beside it is what makes a value shareable (an admin pastes a hex
* from a brand guide), and what a browser without a real picker falls back to. */
const swatch = E('input', { 'type': 'color', 'class': 'fs-color-swatch', 'aria-label': label || '' });
const field = E('input', {
'type': 'text', 'class': 'fs-color-hex', 'spellcheck': 'false', 'autocomplete': 'off',
'inputmode': 'text', 'maxlength': '7', 'aria-label': label || ''
});
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 control keeps no copy of its own, because the page it
* lives on can change the axis behind it (a preset, Reset to default) and a private copy would
* then be the stale one. `current` is only the value at build time. */
const currentOf = o.read || (() => current);
/* Repaint everything that MIRRORS the axis rather than sets it. Called after every edit — and
* after a preset, a palette switch or a dark-mode flip, through the returned refresh() — because
* all three change what "the palette's own colour" is while this axis stays off. */
function reflect(v) {
const live = probeColor(o.probe);
const hex = (typeof v === 'string') ? v : toHex(live);
swatch.value = hex;
/* Do not fight the user mid-edit: `#0` is a legal thing to have typed so far, and
* overwriting the field on every keystroke made the input impossible to type into. */
if (document.activeElement !== field) field.value = hex;
/* the button back to the palette is also the axis's STATE readout: enabled means this axis
* is holding a colour of its own, disabled means what the field shows is the palette's. */
clear.disabled = !v;
if (!ratio) return;
const r = contrastRatio(o.contrast.fg, o.contrast.bg);
if (r === null) { ratio.textContent = ''; ratio.removeAttribute('title'); return; }
/* SAY WHAT IT MEANS, NOT WHAT IT MEASURES. This used to read "On a card 5.4:1 · AA", which is
* three pieces of jargon for one plain fact: whether the colour you just picked can be read.
* The ratio and the WCAG level are what a designer checks, and an admin recolouring a router
* is not one — the number stays, in the title, where it costs nothing and is there for
* anyone who does want it.
*
* The thresholds are still WCAG AA: 4.5:1 for body text, 3:1 for large text and for a UI
* SHAPE, which is why a hairline is graded on the second one (`kind: 'shape'`). A border
* below 3:1 is not a failure the way unreadable text is — a faint hairline is a legitimate
* thing to want — so it says "faint" and warns rather than "not enough" and alarms.
*
* The class names are written out WHOLE rather than built from a suffix: the theme's own
* namespace is swept for dead CSS by matching fs-* tokens in the source (tools/
* fs-orphans.mjs), and a name assembled by concatenation is invisible to that sweep — the
* three rules would read as dead CSS and the fragment as an unstyled class. */
const where = o.contrast.label;
const grade = (o.contrast.kind === 'shape')
? ((r >= 3)
? { cls: 'fs-contrast-aa', text: _('Clearly visible %s', 'footstrap').format(where) }
: { cls: 'fs-contrast-aa-large', text: _('Barely visible %s', 'footstrap').format(where) })
: (r >= 4.5)
? { cls: 'fs-contrast-aa', text: _('Easy to read %s', 'footstrap').format(where) }
: (r >= 3)
? { cls: 'fs-contrast-aa-large', text: _('Hard to read %s — large text only', 'footstrap').format(where) }
: { cls: 'fs-contrast-low', text: _('Too faint to read %s', 'footstrap').format(where) };
ratio.className = 'fs-color-contrast ' + grade.cls;
ratio.textContent = grade.text;
/* the measurement itself, for whoever wants it: the ratio and the threshold it was judged by */
ratio.title = _('Contrast %s:1 (WCAG AA wants %s:1 here)', 'footstrap')
.format(r.toFixed(1), (o.contrast.kind === 'shape') ? '3' : '4.5');
}
const pick = (v) => { onPick(v); reflect(v); };
swatch.addEventListener('input', () => pick(swatch.value.toLowerCase()));
/* commit on blur and on Enter, NOT on every keystroke: a half-typed `#0096` is a valid prefix of
* two different colours and applying it would repaint the page under the cursor. An unparseable
* value on blur snaps back to what the axis actually holds, so the field can never claim a
* colour the page is not painted in. */
const commit = () => {
const v = field.value.trim().toLowerCase();
if ((/^#[0-9a-f]{6}$/).test(v)) pick(v);
else reflect(currentOf());
};
field.addEventListener('blur', commit);
field.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); commit(); } });
clear.addEventListener('click', () => pick(0));
const wrap = E('div', { 'class': 'fs-colorctl' + (o.cls ? ' ' + o.cls : '') }, [
E('div', { 'class': 'fs-color-row' }, [ swatch, field, clear ])
].concat(ratio ? [ ratio ] : []));
/* the caller owns when this runs again; it cannot be done here, because probeColor() needs the
* document and this control is not in it yet */
wrap.fsRefresh = () => reflect(currentOf());
return wrap;
}
/* How close a popup may come to the viewport edge before it is nudged back in. Read by BOTH popups
* the theme places by hand — the Appearance popover (fs-appearance.js) and the menu's dropdown
* edge-clamp (menu-footstrap.js) — which had each written their own `8`. */
const EDGE_GAP = 8;
/* Place the popover next to its trigger and keep it inside the viewport. It is position:fixed on
* <body> because the sidebar is `overflow-y: auto` (which computes overflow-x to `auto` too), so
* an absolutely-positioned popover parented to the Appearance row was clipped off the sidebar
* edge. The top bar opens downward from the button's right edge, the sidebar sideways out of the
* rail; both are then clamped. */
function placePopover(btn, pop) {
const gap = EDGE_GAP, r = btn.getBoundingClientRect();
const w = pop.offsetWidth, h = pop.offsetHeight;
const vw = document.documentElement.clientWidth;
const vh = document.documentElement.clientHeight;
const top_layout = prefs.isTopLayout();
let left = top_layout ? (r.right - w) : (r.right + gap);
let top = top_layout ? (r.bottom + gap) : (r.bottom - h);
/* sidebar: if there is no room to the right, fall back above the trigger */
if (!top_layout && left + w > vw - gap) {
left = r.left;
top = r.top - h - gap;
}
pop.style.left = Math.max(gap, Math.min(left, vw - w - gap)) + 'px';
pop.style.top = Math.max(gap, Math.min(top, vh - h - gap)) + 'px';
}
return baseclass.extend({
svgIcon,
setOpen,
wireSpaceKey,
wireDismiss,
colorControl,
probeColor,
toHex,
EDGE_GAP,
placePopover
});
@@ -0,0 +1,83 @@
'use strict';
'require baseclass';
'require ui';
'require fs-fit as fit';
'require fs-menutree as tree';
'require fs-chrome as chrome';
'require fs-router as router';
'require fs-appearance as appearance';
'require fs-overview as overview';
'require fs-prefs as prefs';
'require fs-sheets as sheets';
'require fs-search as search';
/* The chrome BOOTSTRAP: load the menu tree once, hand it to the parts that need it, and wire them
* in the right order. It renders nothing itself — every piece lives in its own module:
*
* fs-menutree path <-> menu node, alias/firstchild resolution (a port of dispatcher.uc)
* fs-prefs the Appearance axes and their localStorage
* fs-widgets disclosure primitives, the seg/slider controls, popup placement
* 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
* fs-search the page-search palette (indexes the same tree, on first open)
* fs-appearance the Appearance controls, appended to the stock System page
* fs-overview the overview grid — a THEME module, not a luci-mod-status include
* fs-version the shipped version string (shown in the Appearance section, no network)
*
*
* They compose by CALLING each other, never by inheriting: LuCI instantiates every required module
* into a singleton, so `base.extend` across modules throws and a module cannot subclass another
* (docs/conventions.md — proven, not assumed). The same constraint is why the MAIN menu arrives as a callback:
* menu-footstrap.js is the one renderer, and it injects renderMainMenu here rather than overriding
* a method. LuCI raises DependencyError on a require() cycle, so the graph above is a DAG by
* construction — the shared halves (fs-menutree, fs-prefs) were pulled out precisely so that no two
* modules have to reach across into each other. */
return baseclass.extend({
/* entry point: load the menu tree, render the mode menu (which drives the injected
* renderMainMenu) and the section tabs, and wire the chrome. */
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 }`). Nothing below depends on
* it, and hanging it off ui.menu.load() only made the broken frame last a round-trip
* longer — or forever, since the .catch() below swallows a menu failure into console. */
sheets.watchViewSheets();
prefs.guardDarkStamp(); /* a third party stamping :root — same shape, different vector */
ui.menu.load().then((menu) => {
tree.setTree(menu);
chrome.setRenderMain(renderMainMenu);
/* the view this full load already rendered — see fs-router's seed() */
router.seed();
/* the bar's "does the menu fit beside the brand" measurement joins the engine the
* tables use: it re-runs on every #view resize (a rail collapse and a layout toggle
* produce one) and on content mutations */
fit.add(chrome.fitChrome);
chrome.renderChrome();
/* Both of these ADD to a stock page rather than owning a route: each watches
* body[data-page] and does nothing anywhere else. A theme may not register a
* dispatcher node — it outlives the theme that registered it. */
appearance.wire();
/* after setTree(): the palette indexes that tree — lazily, on its first open, but its
* recent-pages list is recorded from the first navigation onwards */
search.wire();
chrome.wireRail();
chrome.wireIndicatorCounts();
/* BEFORE router.wire(): the router restamps body[data-page] on every SPA navigation,
* and that attribute is what fs-overview keys off. Wiring its observer after the
* router's would still work today (the first stamp is the server's, already in the
* DOM), but it would make a route change racy against a listener that is not attached
* yet — cheap to order correctly, expensive to debug. */
overview.wire();
router.wire();
router.wireVisibility();
/* fs-chrome's renderTabMenu warns about exactly this, and the root chain was left bare: a
* throw anywhere in the calls above took out the menu, the router and the Appearance popover
* together, silently. It still fails — there is no sane partial recovery — but loudly. */
}).catch((e) => console.error('footstrap: chrome init failed', e));
}
});
@@ -0,0 +1,335 @@
'use strict';
'require baseclass';
'require ui';
'require fs-fit as fit';
'require fs-prefs as prefs';
'require fs-widgets as widgets';
'require menu-footstrap-common as common';
/* The theme's ONE menu renderer: a vertical #topmenu the CSS also turns into the top bar and the
* rail flyouts — same markup, no second renderer (docs/chrome.md). The disclosure primitives it builds
* sections on come from fs-widgets and the auto-collapse preference from fs-prefs; the rest of the
* chrome (mode menu, tabs, rail, router, popover) is bootstrapped by menu-footstrap-common, which
* this file composes with by injecting renderMainMenu into common.init — a callback, not an
* override, because a required LuCI module is a singleton and cannot be subclassed (docs/conventions.md).
* Spec: docs/chrome.md */
/* A NULL PROTOTYPE, because `ICONS[key]` is a lookup by a MENU NODE NAME — a string a third-party
* package chooses in its menu.d, not one this theme knows. A plain object literal answers
* `constructor` and `__proto__` out of Object.prototype, so a node with either name resolved to a
* truthy non-string, skipped the `|| ICONS._default` fallback, and iconSvg() concatenated it into
* link.innerHTML below — `function Object() { [native code] }` and `[object Object]` respectively
* (measured). The dotted reads (ICONS.vpn, ICONS._default) are unaffected. */
const ICONS = Object.assign(Object.create(null), {
status: '<rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/>',
system: '<rect x="5" y="5" width="14" height="14" rx="2"/><rect x="9" y="9" width="6" height="6" rx="1"/><path d="M9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3"/>',
services: '<circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="12" r="2.6"/>',
network: '<circle cx="6" cy="18" r="2.4"/><circle cx="18" cy="6" r="2.4"/><circle cx="18" cy="18" r="2.4"/><path d="M8 16 16 8M8 18h7.5M18 8.5V16"/>',
vpn: '<rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/>',
docker: '<rect x="3" y="11" width="4" height="4" rx=".7"/><rect x="8" y="11" width="4" height="4" rx=".7"/><rect x="13" y="11" width="4" height="4" rx=".7"/><rect x="8" y="6" width="4" height="4" rx=".7"/><path d="M18 13c0 4-3 6-8 6-4 0-7-2-7-4"/>',
_default: '<circle cx="12" cy="12" r="8.5"/>'
});
function iconSvg(name) {
const key = String(name || '').toLowerCase();
const body = ICONS[key]
|| ((/vpn|wireguard|openvpn/).test(key) ? ICONS.vpn : null)
|| ((/dock|container|lxc/).test(key) ? ICONS.docker : null)
|| ((/net|wifi|wireless|firewall|dhcp/).test(key) ? ICONS.network : null)
|| ((/serv|dnsmasq|cron/).test(key) ? ICONS.services : null)
|| ((/stat|overview|dash/).test(key) ? ICONS.status : null)
|| ICONS._default;
return widgets.svgIcon(body);
}
/* `.open` means two things: in the expanded sidebar, an unfolded accordion section (several may
* be open, the active one starts open); in the rail or the bar, a popup panel (exactly one open,
* hover drives it, a tap toggles it, cleared on outside click and once a real mouse enters). */
/* Is a section's panel a POPUP (flyout / bar dropdown) rather than an unfolded accordion? Must
* read the same input the STYLESHEET does: `data-narrow` (fitShell() in fs-chrome.js
* stamps it before the menu renders). NEVER a viewport breakpoint: it used to be
* matchMedia('(max-width: 767px)') while the CSS turns the sidebar into a bar as soon as the
* content column drops below --fs-content-min — 780px with the sidebar expanded. At 770-775px on
* the router the chrome was a bar while the menu still believed it was an accordion: Escape and
* click-outside did not close a panel (widgets.wireDismiss gates on this) and clampDropdown
* refused to place it. */
function flyoutMode() {
return prefs.currentRail() || prefs.isTopLayout() ||
document.documentElement.hasAttribute('data-narrow');
}
/* The trigger — a bare <a>. widgets.setOpen keeps `.open` and aria-expanded in step. */
const TRIGGER = ':scope > a';
const OPEN_LI = '#topmenu > li.open';
/* ---- dropdown edge-clamp (the BAR, every width and every bar state) ------
* 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. Nudge it back inside. Runs at
* ANY width now that the top bar is measured (no 768 floor); the rail flies panels out sideways
* and needs a different placement, so it is excluded below. */
/* the viewport edge gap, defined once in fs-widgets.js — the Appearance popover keeps a popup off
* the edge by the same amount, and the two used to state it separately */
const EDGE_GAP = widgets.EDGE_GAP;
/* Is this panel a BAR dropdown (anchored under its item) rather than a rail flyout (anchored
* beside it)? Ask what the STYLESHEET asks, exactly as flyoutMode() does: `data-narrow` is what
* turns the sidebar layout into a bar, and it also disables the rail (the rail's rules are all
* scoped `:not([data-narrow])`), so a narrow window is a bar even with the rail on. Gating this on
* isTopLayout() alone was issue #19's second half: the panel was anchored per item by the CSS but
* the clamp declined to place it, so an item near the right edge overflowed the viewport. */
function barDropdown() {
return prefs.isTopLayout() || document.documentElement.hasAttribute('data-narrow');
}
function clampDropdown(li) {
if (!barDropdown()) return;
const menu = li.querySelector(':scope > ul');
if (!menu) return;
/* One pending measure per item: sweeping the pointer across the bar otherwise queues a
* frame per item crossed, each a write-then-read of layout, none cancelled once the
* pointer has moved on. */
if (li._fsClampRaf) window.cancelAnimationFrame(li._fsClampRaf);
li._fsClampRaf = window.requestAnimationFrame(() => {
li._fsClampRaf = 0;
menu.style.left = ''; /* back to the CSS anchor before measuring */
const r = menu.getBoundingClientRect();
if (!r.width) return; /* still hidden — nothing to place */
/* measured after a frame: on pointerenter the :hover rule that reveals the panel
* has not applied yet, so it would still measure 0x0 */
const overflowRight = r.right - (window.innerWidth - EDGE_GAP);
if (overflowRight > 0)
menu.style.left = -Math.min(overflowRight, r.left - EDGE_GAP) + 'px';
});
}
/* a nudge computed for the old width, or for a bar we have since left, is wrong — drop them
* and let the next hover/tap recompute. */
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);
});
}
/* Restore the accordion after LEAVING flyout mode (rail expanded, window grew). closeFlyouts()
* alone used to run on both transitions — right going IN (a stuck popup is worse than a folded
* section), wrong coming OUT: it stripped `.open` from everything, and since the markup is not
* rebuilt on a rail toggle the remembered set was never re-applied, so expanding the rail folded
* every section and "Keep open" quietly meant nothing. */
function restoreAccordion() {
const auto = prefs.currentAutoCollapse();
document.querySelectorAll('#topmenu > li.has-sub').forEach((li) => {
const name = li.dataset.name || '';
setOpen(li, li.classList.contains('active') || (!auto && _openSections.has(name)));
});
}
/* Which top-level sections are unfolded, by node name. renderChrome() rebuilds #topmenu on every
* SPA nav, so without this a section the user opened (Keep open) would refold on every tab
* switch. Only consulted in the expanded sidebar with auto-collapse off; flyouts and
* auto-collapse only ever re-open the active one. Persisted in localStorage because a
* module-level Set does not survive a full page load, and plenty of LuCI pages are not SPA-able
* (non-`view` nodes, any F5). */
const OPEN_KEY = 'fs-menu-open';
/* prefs.lsGetArr owns the parse, the corruption guard and the Array check; this list is a SET
* (membership is the only question asked of it), which is the one part that differs. */
function loadOpenSections() {
return new Set(prefs.lsGetArr(OPEN_KEY));
}
function saveOpenSections() {
prefs.lsSet(OPEN_KEY, JSON.stringify(Array.from(_openSections)));
}
const _openSections = loadOpenSections();
/* main sections -> vertical sidebar list (#topmenu), collapsible */
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)
return E([]);
/* dispatchpath = [mode, section, subsection, …]; sections sit at
* index (level+1) because the first call gets the mode. */
const idx = (level || 0) + 1;
children.forEach((child) => {
/* the chrome carries its own Logout entry (partials/logout.ut, in every layout), so
* drop the tree's top-level admin/logout node or it shows up twice */
if (!level && child.name === 'logout')
return;
const submenu = renderMainMenu(child, url + '/' + child.name, (level || 0) + 1);
const hasSub = !!submenu.firstElementChild;
const isActive = (L.env.dispatchpath[idx] === child.name);
/* expanded sidebar + Keep open: a section starts open if it is the active one OR was
* left open before this re-render. Auto-collapse and flyout mode ignore the set. */
const keepOpen = hasSub && !level && !flyoutMode() && !prefs.currentAutoCollapse();
const startOpen = hasSub && !flyoutMode() &&
(isActive || (keepOpen && _openSections.has(child.name)));
if (keepOpen && startOpen && !_openSections.has(child.name)) {
_openSections.add(child.name);
saveOpenSections();
}
const chevron = hasSub
? '<svg class="fs-chevron" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></svg>'
: '';
/* `active` is a CLASS: it paints the item and says nothing to a screen reader, so the
* menu had no "you are here" at all. aria-current="page" belongs on the LEAF only — a
* section header is a disclosure button, not a link to the current page. */
const link = E('a', {
'href': hasSub ? '#' : L.url(url, child.name),
'class': (isActive && !hasSub) ? 'active' : '',
'aria-current': (isActive && !hasSub) ? 'page' : null
});
link.innerHTML = (level ? '' : iconSvg(child.name)) + '<span class="fs-label"></span>' + chevron;
link.querySelector('.fs-label').textContent = _(child.title);
/* collapsed rail: the label is hidden, so carry it as an attribute — CSS renders it as
* the flyout's heading (sections) or as a tooltip (leaves) */
if (!level) {
link.setAttribute('data-label', _(child.title));
if (hasSub)
submenu.setAttribute('data-title', _(child.title));
}
const li = E('li', {
'class': [
isActive ? 'active' : '',
hasSub ? 'has-sub' : '',
/* pre-opening the active section is an accordion affordance; in flyout
* mode it would pop a panel open on page load */
startOpen ? 'open' : ''
].join(' ').trim()
}, [ link, submenu ]);
/* for restoreAccordion(): the markup is not rebuilt on a rail toggle, so the
* remembered set has to be matched back to live <li>s by name */
if (!level) li.dataset.name = child.name;
if (hasSub) {
/* W3C APG disclosure-navigation: a section header is a BUTTON owning a panel, not
* a link to "#". Not role="menu" — APG is explicit that site navigation must not
* take on the menubar pattern's arrow-key semantics. */
/* THE ID MUST BE INJECTIVE, because a menu node name is a string a third-party
* package picks in its own menu.d and two of them may differ only in punctuation.
* `[^a-z0-9]+` -> '-' folded `foo.bar` and `foo-bar` onto ONE id, so two sections at
* the same level emitted the same `id` and both triggers' aria-controls resolved to
* whichever panel stands first in the document — a screen reader is then told the
* closed section owns the open one's contents. Escaping to the code point keeps the
* id readable for the ordinary all-alphanumeric case and cannot collide. */
const subId = 'fs-sub-' +
String(child.name).replace(/[^a-z0-9]/gi, (c) => '_' + c.charCodeAt(0).toString(16)) +
'-' + idx;
submenu.id = subId;
link.setAttribute('role', 'button');
link.setAttribute('aria-controls', subId);
link.setAttribute('aria-expanded', startOpen ? 'true' : 'false');
link.addEventListener('click', (ev) => {
ev.preventDefault();
const open = li.classList.contains('open');
/* flyout panels are exclusive, but must NOT touch the remembered set: it
* mirrors the desktop "Keep open" state, and wiping it here meant one tap
* in the phone flyout lost the user's open sections back on desktop */
if (flyoutMode()) {
closeFlyouts();
setOpen(li, !open);
if (!open) clampDropdown(li); /* tap-opened panel must fit too */
return;
}
/* the expanded-sidebar accordion folds the others back only when asked
* (Appearance -> Submenus) */
if (prefs.currentAutoCollapse()) { closeFlyouts(); _openSections.clear(); }
setOpen(li, !open);
/* remember the accordion state so any navigation restores it (Keep open) */
if (!open) _openSections.add(child.name);
else _openSections.delete(child.name);
saveOpenSections();
});
widgets.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 — a touch tap
* fires pointerenter ('touch') BEFORE the click, and clearing there would break
* tap-to-close. */
li.addEventListener('pointerenter', (ev) => {
if (ev.pointerType === 'mouse' && flyoutMode())
closeFlyouts();
/* the bar opens this panel on hover (pure CSS), so it must be placed on
* hover too, not only when a tap sets .open */
clampDropdown(li);
});
}
ul.appendChild(li);
});
return ul;
}
return baseclass.extend({
__init__() {
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()
});
/* Entering flyout mode: fold everything, or a section left open as an accordion
* reappears as a popup panel stuck on screen. Leaving it: restoreAccordion().
*
* Watch the ATTRIBUTE, not the rail button: fs-chrome's wireRail() registers its click
* handler from inside the ui.menu.load() promise, i.e. AFTER this runs, so a listener
* added here would fire first and read the old data-rail. data-layout rides along —
* toggling the layout live is the same transition, so it needs no menu re-render. */
const modeChanged = () => {
clearClamps();
flyoutMode() ? closeFlyouts() : restoreAccordion();
};
/* data-narrow is the third attribute flyoutMode() reads (fitShell() writes it). It was
* missing here, so dragging a window 800 -> 770px turned the sidebar into a bar with
* the accordion still unfolded inside it, and no transition handler ran. */
new MutationObserver(modeChanged).observe(document.documentElement, {
attributes: true, attributeFilter: [ 'data-rail', 'data-layout', 'data-narrow' ]
});
/* No 768 media-query listener any more: the top bar is measured at every width
* (fitChrome, no floor), so crossing 768 no longer flips the chrome — nothing to
* transition that the attribute observer and the resize clamp-clear below don't cover. */
/* a clamp computed at the old width is wrong at the new one; coalesced via fit.frame
* (the shared coalescer) because resize fires dozens of times a second while a window
* is dragged */
window.addEventListener('resize', fit.frame(clearClamps));
/* Appearance -> Submenus -> auto-collapse. fs-prefs.js owns the stored value and says only
* that it changed; applying it is entirely ours, because every piece of the state is —
* the remembered set, the `.open` class and the aria-expanded that setOpen keeps in step.
*
* restoreAccordion() already IS "apply the current auto-collapse setting": with the set
* cleared and auto ON it computes open = the active section alone, and with auto OFF it
* restores what "Keep open" remembered. Skipped in flyout mode — a section there is a
* popup, and force-opening the active one would leave it stuck on screen. */
document.addEventListener('fs-autocollapse', (ev) => {
if (ev.detail && ev.detail.on) {
_openSections.clear();
saveOpenSections();
}
if (!flyoutMode()) restoreAccordion();
});
}
});