🎄 Sync 2026-08-27 02:13:09
Merge-upstream / merge (push) Canceled after 0s

This commit is contained in:
github-actions[bot] committed 2026-08-27 02:13:09 +08:00
1 parent f99c0304cd
commit 410d762592
154 files changed
+9225 -8782

No files matched your search

@@ -6,91 +6,61 @@
'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.
/* The Appearance controls: the DOM that presents the axes. It owns no preference — fs-prefs.js
* holds the axes and 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 — twenty-one 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 a tab on System -> System, beside General Settings / Logging / Time Synchronization /
* Language and Style. Twenty-one axes, nine of them with a colour field, a swatch and a contrast
* readout, do not fit a floating popover that has to trap Tab and stay inside a 320px column, and
* keeping both containers would render every axis twice.
*
* 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 form is appended by a MutationObserver rather than by a route of its own, the same boundary
* fs-overview.js sits on: a theme may not own a dispatcher node, because the node outlives the
* theme that registered it and the menu would keep an entry whose view is gone. So the theme owns
* no menu.d and no view — it watches for the stock page, adds one section and removes nothing.
*
* 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. */
* The version line makes no request and must not grow one: which version is INSTALLED is what this
* page answers, and which is available is the package manager's question. */
/* 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. */
* Everything applies immediately and there is nothing to save: every axis is this browser's, in
* localStorage, and the page repaints under the control as it moves. Only "Save as default" writes
* anything, pushing the current look to the ROUTER for other browsers. That distinction is the
* model (docs/design-system.md), and why this page has no Save/Reset footer of LuCI's own. */
function render() {
/* A promise, and the build runs INSIDE it rather than as its argument: `Promise.resolve(build())`
* evaluates build() synchronously, so a throw in it unwound out of render() before mount() could
* attach .catch/.finally — leaving mount()'s _building flag set for the life of the document. The
* tab then never built again on that page and nothing was logged, which is both channels silent
* at once. Deferred like this, the same throw lands in the .catch that exists for it. */
/* build() runs inside the promise, not as its argument: `Promise.resolve(build())` evaluates it
* synchronously, so a throw unwinds out of render() before mount() can attach .catch/.finally
* and leaves _building set for the life of the document — the tab then never builds again and
* nothing is logged. */
return Promise.resolve().then(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). */
/* every saved axis re-checks the Save button after applying, so it greys the moment this
* browser matches the saved default and un-greys when it diverges. Wrapped around the appliers
* because the controls call them directly and have no other seam back here. */
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. */
/* 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 or dark-mode flip moves
* all of it under controls nobody touched, so they refresh together. */
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 */
* measured against */
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.
/* One captioned row in LuCI's own shape: `.cbi-value` > `label.cbi-value-title` +
* `.cbi-value-field`. Nothing here styles those class names — base/30-forms.css and
* theme/60-inputs.css already lay them out — so this page inherits every future fix to the form
* layout instead of keeping a private copy. It also puts the row on a shared surface (zone 2,
* where an app may win on specificity), which is right for a page inside #view.
*
* 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. */
* `make` is handed the same label string the caption renders, because every control needs it a
* second time as its aria-label; stating it twice is how the visible caption and what a screen
* reader announces drift apart. `extra` carries rows with more than a control, `opts.cls` marks
* the rows CSS has to single out. */
const group = (label, make, opts) => {
const o = opts || {};
return E('div', { 'class': 'cbi-value' + (o.cls ? ' ' + o.cls : '') }, [
@@ -99,24 +69,18 @@ function build() {
]);
};
/* ---- the CONTROLS are LuCI's own, not this theme's ------------------------------------------
/* ---- the controls are LuCI's own ----
*
* 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.
* Every enum axis is a `ui.Select` and every number a `ui.RangeSlider`: the widgets the other
* tabs are built from, already dressed by this stylesheet (`select` in base/30-forms.css,
* `.cbi-range-slider` in theme/60-inputs.css). Both exist on every release this theme supports
* — checked against openwrt-24.10, since `ui.RangeSlider` is the one that did not exist further
* back.
*
* 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. */
* Listen for `widget-change` from UIElement, not for an event on the inner element: that is the
* seam the widget publishes, and reaching past it ties this to how the widget happens to be
* built. RangeSlider also emits `widget-update` while the handle moves, which is what resizes
* the page under the drag; the appliers are idempotent, so wiring both 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();
@@ -124,52 +88,11 @@ function build() {
node.addEventListener('widget-change', () => apply(w.getValue()));
return node;
};
/* THE ONE WIDGET THIS THEME CANNOT ASSUME, and the reason 23.05 is a supported release again.
*
* `ui.RangeSlider` arrived in 24.10. On 23.05 it is simply not there, and because this whole
* panel is built inside one try/catch the miss took the ENTIRE Appearance tab with it — a
* console line and an empty tab, reported from the field rather than by a gate, because no gate
* had ever opened a 23.05 router.
*
* So the widget is used where it exists and reproduced where it does not: same DOM, same class
* names (`.cbi-range-slider` and its value/units children are what theme/60-inputs.css dresses),
* same two events, and the same base class — `ui.AbstractElement` is the name LuCI exports on
* BOTH 23.05 and master, which is what makes `setUpdateEvents`/`setChangeEvents` available to
* the copy. Nothing below this line knows which of the two it got.
*
* Kept deliberately smaller than upstream's: `calculate` is not reproduced, because no axis on
* this page uses it. If one ever does, use the real widget's shape rather than growing this. */
const RangeSlider = ui.RangeSlider || ui.AbstractElement.extend({
__init__(value, options) {
this.value = value;
this.options = Object.assign({ min: 0, max: 100, step: 1, calcunits: null }, options);
},
render() {
this.sliderEl = E('input', {
type: 'range', min: this.options.min, max: this.options.max,
step: this.options.step || 'any', value: this.value
});
this.valueEl = E('output', { class: 'cbi-range-slider-value' }, String(this.value));
const node = E('div', { class: 'cbi-range-slider' }, [
this.sliderEl,
this.valueEl,
this.options.calcunits ? E('span', { class: 'cbi-range-slider-calc-units' }, this.options.calcunits) : null
].filter(Boolean));
this.node = node;
this.setUpdateEvents(this.sliderEl, 'input', 'blur');
this.setChangeEvents(this.sliderEl, 'change');
this.sliderEl.addEventListener('input', () => { this.valueEl.textContent = this.sliderEl.value; });
dom.bindClassInstance(node, this);
return node;
},
getValue() { return this.sliderEl.value; },
setValue(value) { this.sliderEl.value = value; this.valueEl.textContent = value; }
});
const sliderCtl = (current, min, max, apply, label, opts) => {
const o = opts || {};
const w = new RangeSlider(String(current), {
min: min, max: max, step: o.step || 1, calcunits: o.unit || null
const w = new ui.RangeSlider(String(current), {
min: min, max: max, step: o.step || 1
});
const node = w.render();
node.setAttribute('aria-label', label);
@@ -179,8 +102,8 @@ function build() {
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. */
/* one colour axis: `probe` is the live token the control reads the effective colour back from,
* `contrast` the pair it reports */
const colourGroup = (label, axis, probe, contrast, opts) => group(label, (lbl) => {
const ctl = widgets.colorControl(axis.current(), bump(axis.apply), lbl, {
probe: probe,
@@ -192,16 +115,14 @@ function build() {
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. */
/* Every label here carries the 'footstrap' context (`_(str, ctx)`, key `ctx\1str`). LuCI serves
* one merged catalogue — load_catalog() loads every *.<lang>.lmo and a lookup returns the first
* archive holding the hash — so a bare msgid is a global name any luci-app may take, and
* readdir order picks the winner: the layout toggle rendered "Максимум" on a Russian router
* because another catalogue translates "Top" as "maximum" (issue #6). Contexting cannot be
* selective. The chrome, the login/notice sentences and the System/Memory/Storage headings are
* deliberately bare — inheriting luci-base's translation covers the ~40 languages this theme
* has no catalogue for. */
/* ---- section 1: the shell ---- */
const shell = [
@@ -226,8 +147,6 @@ function build() {
'2020': 'OpenWrt 2020'
}, 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'),
@@ -235,14 +154,12 @@ function build() {
}, bump(prefs.applyDensity), label)),
group(_('Rounding', 'footstrap'),
(label) => sliderCtl(prefs.currentRadius(), 0, 20, bump(prefs.applyRadius), label, { unit: 'px' })),
(label) => sliderCtl(prefs.currentRadius(), 0, 20, bump(prefs.applyRadius), label)),
/* 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. */
/* The top layout has no accordion, so this switch is meaningless there: always built,
* hidden by CSS (:root[data-layout="top"] .fs-ap-submenus). Do not wrap it in an
* `if (currentLayout() !== 'top')` — the page is built once, so the branch would freeze
* the control to the layout the page loaded in while CSS morphs the chrome live. */
group(_('Submenus', 'footstrap'), (label) => selectCtl(
prefs.currentAutoCollapse() ? 'on' : 'off', {
off: _('Keep open', 'footstrap'),
@@ -253,32 +170,31 @@ function build() {
/* ---- 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. */
/* 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 */
/* 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 select 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, combo box" and "Density, slider" with nothing to tell them
* apart. */
/* The strength half of the Tint, meaningful only 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.
*
* Not called "Density": that is the select above, and this string is both the caption and
* the aria-label, so a screen reader would announce two rows under one name. */
group(_('Tint strength', 'footstrap'),
(label) => sliderCtl(prefs.currentTintStrength(), 0, 200, bump(repaint(prefs.applyTintStrength)), label, {
step: 5,
unit: '%'
step: 5
}), { cls: 'fs-ap-tint fs-ap-tintstr' }),
/* 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. */
/* recolours the accented controls (buttons/toggles/sliders/focus rings), not the canvas.
* Measured as text on a card, the use that fails first: as a fill it carries derived ink,
* as a link or status label it carries only itself. It is also what answers #20 ("sometimes
* you want grey or black"), taking any #rrggbb — the colour-chip presets that once sat here
* are not coming back. */
colourGroup(_('Accent', 'footstrap'), {
current: prefs.currentAccent, apply: prefs.applyAccent
}, 'var(--fs-accent)', {
@@ -304,17 +220,14 @@ function build() {
})
];
/* ---- 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 surfaces: the sheet the UI is drawn on ----
* Cards, inset controls, the chrome bar and the hairlines between them. Body text is read on
* each, so each reports --fs-text against itself. No ink is derived here: --fs-text is the
* palette's, and moving it would recolour the very thing 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. */
* The hairline takes the 3:1 UI-component threshold instead — a border is a shape, not a label
* — and below that it is decoration, which a hairline is entitled to be, so the readout states
* the number and leaves the call to the admin. */
const surfaces = [
colourGroup(_('Cards', 'footstrap'), {
current: prefs.currentCard, apply: prefs.applyCard
@@ -342,28 +255,23 @@ function build() {
})
];
/* ---- section 3: the wallpaper and everything that depends on which one is picked ----
/* ---- section 3: the wallpaper and the rows each value brings ----
*
* 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.
* Wallpaper is three-valued: Off, Pattern (an uploaded SVG, tiled and recoloured) and File (an
* uploaded photo). The rows a value brings are SIBLINGS of the Wallpaper row, not children of
* its field: nesting a `.cbi-value` inside a `.cbi-value-field` puts a second caption column
* inside the first, and those controls started 216px right of every other one on the page,
* measured on the router. Flat rows hidden as a group is what the stock pages do with a
* dependent 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. */
* The select is the per-browser switch deciding whether to paint an image, so it is what keeps
* the Save button honest; Choose/Remove only swap the picture and never touch the axis. The
* 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. */
/* display:none, not `hidden`: a bare `hidden=""` still renders the native
* "Choose File / No file chosen" control */
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 ]);
@@ -376,30 +284,27 @@ function build() {
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. */
/* Dim: the scrim opacity over the photo. An ordinary per-browser axis — it is in AXIS_KEYS
* and snapshotAxes(), so it moves this browser toward or away from the router default and
* must be bump()-ed like every other saved axis, or the Save button misreports its own
* status. Separate from the Tint 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. */
/* The rows the pattern brings. Scale and Strength are live: the appliers write a custom
* property, so the tile resizes and fades under the drag. Colours decides whether the
* file's own palette is kept or replaced by the theme's — a mask uses the alpha only,
* which is right for line art and wrong for artwork that carries its own colours. */
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' })),
bump(prefs.applyPatternSize), lbl, { step: 20 })),
group(strengthLabel, (lbl) => sliderCtl(prefs.currentPatternStrength(), 0, 100,
bump(prefs.applyPatternStrength), lbl, { step: 5, unit: '%' })),
bump(prefs.applyPatternStrength), lbl, { step: 5 })),
group(inkLabel, (lbl) => selectCtl(prefs.currentPatternInk(), {
theme: _('Theme', 'footstrap'),
original: _('As in file', 'footstrap')
@@ -411,7 +316,7 @@ function build() {
() => 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: '%' }))
bump(prefs.applyPhotoDim), lbl, { step: 5 }))
];
function reflect(tok) {
@@ -422,11 +327,10 @@ function build() {
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. */
/* `hidden` on the row, which 80-appearance.css restates at a specificity beating
* `.cbi-value`'s own display (the UA's bare `[hidden]` rule loses to it). Hidden, not
* removed: each row holds a live control, so rebuilding on every switch would freeze it to
* the state it was constructed in. */
function togglePanel(v) {
patRows.forEach((r) => { r.hidden = (v !== 'pattern'); });
fileRows.forEach((r) => { r.hidden = (v !== 'file'); });
@@ -447,12 +351,11 @@ function build() {
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. */
/* uploadPattern already switched this browser onto the pattern, so the control
* must catch up or the page paints the tile while the dropdown reads Off.
* `dom.callClassMethod` is how LuCI moves its own widgets from outside;
* setWallpaper is then called directly, because a programmatic setValue emits
* no `widget-change`. */
dom.callClassMethod(seg, 'setValue', 'pattern');
setWallpaper('pattern');
})
@@ -500,47 +403,37 @@ function build() {
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).
/* ---- section 4: the router default and the version ----
*
* 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(). */
* Save the current look as the router-wide default (fs-prefs writes /etc/config/footstrap over
* the scoped uci ACL). It does not change this browser — localStorage keeps overriding — so the
* saved default only shows on a fresh browser. The two Reset buttons below are the escape
* hatches, and they do not land in the same place. */
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. */
/* Two resets, because two things sit underneath a browser's tweaks (fs-prefs.js): "Reset to
* saved" clears them and lets every axis fall back to whatever the router holds, while "Reset
* to default" writes the theme's built-ins explicitly — the only way to say "as the theme
* ships" on a router with 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. */
/* the stock destructive class, so the button discarding every local tweak is the red one
* (theme/55-buttons.css). "Reset to saved" stays neutral: it steps back to the shared state
* rather than discarding. */
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.) */
/* Save's only visible failure surface. The realistic failure is the rpc rejecting — an expired
* session (403), a missing ACL, ubus down. A DELETED config is not caught: rpcd stages the set
* in the session and commit then 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. */
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).
/* The Save button is the status: matching disables it, diverging enables it. Called after every
* axis change (via bump).
*
* Unless this browser refuses storage, in which case the status would be a lie in both halves:
* nothing was written, so every current*() reads the ROUTER default back and the comparison is
* true however far the page has been dragged from it. Say what is actually true instead — the
* axes apply and are forgotten on reload — and leave the button enabled, because pushing this
* browser's look to the router is the one thing that still works here. */
* Unless the browser refuses storage, where the comparison would always be true — nothing was
* written, so every current*() reads the router default back however far the page has been
* dragged from it. Say so instead, and leave the button enabled: pushing this browser's look to
* the router is the one thing that still works. */
function refreshSave() {
if (prefs.storageBroken()) {
saveBtn.disabled = false;
@@ -558,9 +451,9 @@ function build() {
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. */
/* on failure refreshSave re-enables the button so the user can retry; the usual cause
* is a stale session, which a reload fixes. The raw rpc error stays in a title
* tooltip. */
.catch((e) => {
saveErr.textContent = _('Could not save the default. Reload the page and try again.', 'footstrap');
saveErr.title = String((e && e.message) || e);
@@ -568,13 +461,9 @@ function build() {
})
.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. */
/* Two-click confirm on both: discarding local tweaks is destructive and a native confirm() is
* banned in this UI. Arming one disarms the other, so a primed button cannot be fired by a
* click meant for its neighbour. Each reload lands back on this tab — see armReturn(). */
const armed = new Map();
function disarm(btn, label) {
armed.delete(btn);
@@ -598,21 +487,20 @@ function build() {
}
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 */
refreshSave(); /* correct label and enabled state before the first paint */
const versionLink = E('a', {
'class': 'fs-ap-version',
'href': ver.REPO_URL,
'target': '_blank',
/* `noreferrer` alone: it implies noopener wherever it is honoured at all, and the theme's
* other outward links — footer.ut's two and the footer's own — spell it that way. Two
* spellings of one rule is how they drift. */
/* `noreferrer` alone: it implies noopener, and the theme's other outward links spell it
* that way */
'rel': '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 */
/* the one row whose control is a pair of buttons, each named by its own text, so `make`
* ignores the caption rather than re-using it as an aria-label */
group(_('Router default', 'footstrap'),
() => E('div', { 'class': 'fs-ap-actrow' }, [ saveBtn, resetSavedBtn, resetBtn ]),
{ extra: saveErr })
@@ -623,35 +511,27 @@ function build() {
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. */
/* not .cbi-section: inside a tab pane that is a card within a card, and the stock tabs put
* their rows straight into the pane. These are grouping headings within one pane, styled by
* 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.
/* ---- the folded groups ----
* Nine colour fields and an uploader are the widest rows on the page and most admins never
* touch them, so each group is a disclosure, closed by default.
*
* 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.
* A disclosure and not a switch: a switch answers "is this feature on", and turning it off
* would either revert nine colours or change nothing at all. Opening or closing a fold applies,
* un-applies and disables nothing.
*
* 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.
* W3C APG disclosure pattern, as the menu's sections use: a <button> owning the region,
* `aria-expanded` on it and `aria-controls` pointing at the panel. `hidden` on the panel rather
* than a class, so a closed group leaves the tab order and 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. */
* 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, snapshotAxes() and the pre-paint. */
let foldSeq = 0;
function foldable(title, rows, key) {
const id = 'fs-ap-fold-' + (++foldSeq);
@@ -661,13 +541,10 @@ function build() {
'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. */
/* the same chevron the overview's card toggles draw: an empty box whose ::after is two
* borders rotated 45° (pages/20-overview.css), not a second <svg> to keep in step.
* Empty and aria-hidden — the state is the button's aria-expanded, which is also what
* CSS rotates it off. */
E('span', { 'class': 'fs-ap-chev', 'aria-hidden': 'true' })
]);
const paint = () => {
@@ -678,9 +555,8 @@ function build() {
open = !open;
prefs.lsSet(key, open ? 'on' : 'off');
paint();
/* Refreshed on OPEN because the axes below it may have moved while it was collapsed —
* a palette switch or a preset changes what every readout says. Cheap and skipped
* while closed: nothing in that fold is on screen to be wrong. */
/* refreshed on open because the axes below may have moved while it was collapsed;
* skipped while closed, where nothing is on screen to be wrong */
if (open) refreshColours();
});
paint();
@@ -689,9 +565,8 @@ function build() {
]);
}
/* 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. */
/* Colours and Surfaces are one fold: the same job, split into two headings only because a
* figure and the sheet it sits on are read differently */
const page = E('div', { 'class': 'fs-ap' }, [
section(_('Interface', 'footstrap'), shell),
foldable(_('Colours', 'footstrap'),
@@ -701,26 +576,22 @@ function build() {
section(_('Defaults', 'footstrap'), defaults)
]);
/* The first fill, deferred one microtask so the tree above is finished being assembled. It does
* NOT wait for the form to be in the document, and does not need to: every value a readout shows
* comes back through widgets.probeColor(), which keeps its own hidden probe attached to <body>
* and resolves the cascade there — so this runs while the form is still detached (mount() appends
* it a microtask later) and still reads the live palette. */
/* The first fill, deferred one microtask so the tree above is finished. It does not wait for
* the form to be in the document: every readout resolves through widgets.probeColor(), whose
* hidden probe is attached to <body>, so a detached form still reads the live palette. */
Promise.resolve().then(refreshColours);
return page;
}
/* ---- mounting it on the stock System 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. */
* The same shape as fs-overview.js's: a chrome module is instantiated once per page load, so it
* notices SPA navigation itself through `body[data-page]`, which the server template and fs-router
* both stamp with the dispatch path. */
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
/* A reset reloads the page, and a reload opens the tab LuCI remembers — never this one, since
* ui.tabs only knows the tabs it built itself. sessionStorage rather than a URL fragment: the
* fragment is the stock page's business, and a stale one would re-open 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) {} }
@@ -731,7 +602,7 @@ function takeReturn() {
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 MARK = 'fs-ap'; /* the built form's class, and how mount() knows it is already there */
/* how long the stock view gets to render its tabs before a missing group counts as a failure */
const TAB_DEADLINE = 5000;
const TAB = 'fs-appearance'; /* the pane's data-tab, which ui.tabs' click handler matches on */
@@ -746,33 +617,22 @@ function stopWatch() {
_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.
* data-initialized when it builds the menu; the menu it inserted is that element's previous
* sibling. Both are read from the DOM, 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, and a check for them by CLASS is what failed. A modern pane carries no class at
* all — form.js gives it `data-tab` and `data-tab-title` and nothing else, and `.cbi-tabcontainer`
* is luci-compat vocabulary from the Lua CBI — so `:scope > .cbi-tabcontainer` matched nothing on a
* page that plainly has tabs, and the tab was never added, silently. ui.tabs itself marks
* `panes[0].parentNode`, so the panes ARE this element's children; they are simply not identifiable
* that way. */
/* Tab groups that belong to the page we just LEFT. The router stamps body[data-page] before the
* incoming view renders, and on a warm route #view still holds the outgoing page's DOM at that
* moment — so mount() found ITS tab strip and appended the whole Appearance form, plus a live,
* clickable "Footstrap" <li>, to another page's tabs. Measured arriving at System -> System from
* Network -> DHCP: two builds for one arrival, and for 66 ms on localhost (an RTT or more on a real
* router, since the window is the incoming view's load()) the tab sat on the DHCP strip and opened
* all 24 Appearance rows when clicked. Every group present at the moment of the stamp is therefore
* disqualified; the incoming view's own group is a fresh element and is not in this set. */
* The flag and the sibling are the whole test. The panes are deliberately not looked for by class:
* a modern pane carries none — form.js gives it `data-tab` and `data-tab-title`, and
* `.cbi-tabcontainer` is luci-compat vocabulary — so matching on that silently finds nothing on a
* page that plainly has tabs.
*
* Groups belonging to the page just left are disqualified: the router stamps body[data-page]
* before the incoming view renders, and #view still holds the outgoing page's DOM at that moment,
* so mount() would append the form and a clickable "Footstrap" <li> to another page's tab strip:
* arriving at System -> System from Network -> DHCP that was two builds for one arrival, and the
* tab sat on the DHCP strip for 66 ms on localhost (an RTT or more on a real router).
* The incoming view's own group is a fresh element and is not in this set. */
const _staleGroups = new WeakSet();
function disqualifyCurrentGroups() {
const view = document.getElementById('view');
@@ -793,42 +653,51 @@ function tabGroup(view) {
/* 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).
* observer, which also covers the view being re-rendered (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.
* The tab is added by hand rather than by calling ui.tabs.initTabGroup again: that returns
* immediately on a group carrying data-initialized, and clearing the flag to re-run it builds a
* second menu beside the first and drops the stock tabs' click bindings. One <li>, the same click
* handler ui.tabs binds to every other tab, and the pane that handler expects.
*
* 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. */
* Idempotent through the marker, since the form's own construction is a mutation the observer sees.
*
* A map redraw (Save without Apply) rebuilds the group and `ui.tabs` stamps `data-initialized` as
* an attribute change that can land after the last childList change, so the observer watches that
* attribute too and a miss schedules retries on a widening delay — otherwise the tab is missing
* until the next navigation (openwrt/luci#8903). */
const RETRIES = [ 0, 60, 150, 300, 600, 1200 ];
let _retryTimer = 0, _retryAt = 0;
function retryMount() {
if (_retryTimer) return;
if (_retryAt >= RETRIES.length) return;
const delay = RETRIES[_retryAt++];
_retryTimer = window.setTimeout(() => { _retryTimer = 0; mount(); }, delay);
}
function mount() {
const view = document.getElementById('view');
if (!view || view.querySelector('.' + MARK) || _building) return;
if (!view || !onPage()) return;
if (view.querySelector('.' + MARK)) { _retryAt = 0; return; }
if (_building) return;
const tabs = tabGroup(view);
if (!tabs) return;
if (!tabs) { retryMount(); 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 */
/* re-check: render() resolves on a microtask, and the view may have been replaced or
* navigated away from meanwhile */
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. */
/* Named after the theme, not after what it does: beside four stock tabs that are all
* "what this page configures", a fifth called Appearance would read as another facet
* of the router. A proper noun, so deliberately untranslated. */
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. */
/* The same shape a stock pane has, which is `data-tab` + `data-tab-title` and no class:
* ui.tabs.switchTab reads the attributes, and the `cbi-tabcontainer` class this used to
* carry is luci-compat's, styled by no rule this theme ships. */
* opened on before, and a theme has no business taking that over. The pane's shape is
* a stock pane's — `data-tab` + `data-tab-title`, no class. */
t.group.appendChild(E('div', {
'data-tab': TAB,
'data-tab-title': title
@@ -836,9 +705,8 @@ function mount() {
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 this load is the one a reset asked for, open on it: clicking the link goes through
* ui.tabs' own switchTab, so nothing here reimplements the switch */
if (takeReturn()) link.click();
})
.catch((e) => console.error('footstrap: the Appearance tab failed to build', e))
@@ -851,31 +719,40 @@ function watch() {
if (_viewObserver || !view || !onPage()) return;
_observedView = view;
_viewObserver = new MutationObserver(mount);
_viewObserver.observe(view, { childList: true, subtree: true });
/* `data-initialized` is the moment the group becomes usable, and it does not always come with
* a childList change (see retryMount above) */
_viewObserver.observe(view, {
childList: true, subtree: true,
attributes: true, attributeFilter: [ 'data-initialized' ],
});
mount();
/* A DEADLINE on the one failure that is otherwise perfectly silent. tabGroup() reads three
* private ui.tabs facts — the `data-initialized` marker, the `cbi-tabmenu` class on the menu it
* inserts, and `cbi-tab-disabled` on the items — and one of those has already moved between
* 24.10 and 25.12 (`data-tab-group` was dropped with no announcement). If any of the three we do
* read goes the same way, mount() simply returns early on every mutation: the stock page renders
* perfectly, nothing throws, and every Appearance axis becomes unreachable. Say it once, after
* the page has had time to render. */
/* A deadline on an otherwise silent failure. tabGroup() reads two private ui.tabs facts — the
* `data-initialized` marker and the `cbi-tabmenu` class on the menu — and mount() writes a
* third, `cbi-tab-disabled` on the item it appends; one such fact has already moved between
* 24.10 and 25.12 (`data-tab-group` was dropped unannounced). If another does, mount() returns
* early on every mutation: the stock page renders, nothing throws, and every Appearance axis
* is unreachable. */
window.setTimeout(() => {
const v = document.getElementById('view');
if (!onPage() || !v || v.querySelector('.' + MARK) || _building) return;
/* one last attempt: the complaint below claims ui.tabs changed shape, which is only true
* if a fresh look still finds no group */
_retryAt = 0;
mount();
if (v.querySelector('.' + MARK) || _building || tabGroup(v)) return;
console.error('footstrap: the Appearance tab could not be attached — this page has tabs, but '
+ 'ui.tabs no longer marks them the way fs-appearance.js looks for. Every Appearance axis '
+ 'is unreachable until that is updated.');
}, TAB_DEADLINE);
}
/* Called by menu-footstrap-common's init, once. Everything route-dependent hangs off the data-page
* observer inside. */
/* called once by menu-footstrap-common's init; everything route-dependent hangs off the data-page
* observer inside */
function wire() {
if (_routeObserver || !document.body) return;
_routeObserver = new MutationObserver(() => {
/* BEFORE deciding anything: whatever is in #view at the moment data-page changes belongs to
* the page being left (see _staleGroups). */
/* before deciding anything: whatever is in #view when data-page changes belongs to the
* page being left (see _staleGroups) */
disqualifyCurrentGroups();
return onPage() ? watch() : stopWatch();
});
@@ -5,10 +5,10 @@
'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 chrome around the content: the mode menu, the section tabs, the rail toggle and the
* measurements deciding how much room each gets. The main menu is injected by menu-footstrap.js as
* a callback (renderMainMenu), since a required LuCI module is a singleton and a renderer cannot
* subclass the chrome (docs/conventions.md). */
/* the injected main-menu renderer; handed over once by the theme's init() */
let _renderMain = null;
@@ -29,8 +29,8 @@ function renderTabMenu(node, url, level) {
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. */
/* aria-current, not just the `active` class, which is paint 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) ])
]));
@@ -52,31 +52,31 @@ function renderTabMenu(node, url, level) {
/* ---- 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. */
* that overflow one row. Rather than wrap, shrink: two density classes (theme/40-tabs.css) trim
* padding, then gap+font. Floored so a pill never gets tighter than its label; past the floor the
* strip may 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. */
/* only laid-out children count: a display:none child has offsetTop 0, so taking it as `last`
* reads as "one row" on a strip that has wrapped */
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. */
/* `.fs-sidebar > ul.nav` is the main menu in every layout, 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. */
/* vertical list: the measure would floor it at fs-dense2 forever */
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. */
* the write-measure-write below forces a reflow per strip, on every tick */
if (!ul.classList.contains('fs-dense1') && !ul.classList.contains('fs-dense2') && stripFitsOneRow(ul))
return;
ul.classList.remove('fs-dense1', 'fs-dense2');
@@ -87,45 +87,30 @@ function fitTabStrips() {
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? ----
/* ---- 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 sidebar gives way to the bar when what is left for the content would be too narrow to read.
* A viewport breakpoint cannot say that: the cut is 224px expanded and 68px as a rail, so one
* breakpoint gives both states the same answer and the rail folds away at the same width as the
* full sidebar. Do not measure the RENDERED sidebar either — the answer would depend on the state
* it is deciding (as a bar there is no cut, so the content fits, so it un-narrows) and oscillate.
*
* 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.
* The widths come from the stylesheet (02-tokens.css) and are never restated here, or narrowing
* the rail in CSS leaves this subtracting the old width with no gate able to see it.
*
* 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. */
* A custom property is untyped, so `parseFloat(getComputedStyle(root).getPropertyValue(…))` reads
* `calc(224px * 1)` and returns NaN — silently, since `NaN < NaN` is false and the sidebar simply
* never yields. Assign the token to a real length property on a throwaway element and read the
* used value back instead. */
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). */
/* The probe is a plain <div> in the shared document, so a third-party app's CSS can style it —
* it carries no chrome mark and the fence deliberately does not spare it. Every declaration is
* therefore !important, which a style-attribute wins outright: an app carrying
* `div { min-width: 500px !important }` otherwise wins every read and the sidebar folds into a bar
* on a 1857px desktop (issue #19) — the cut becomes 500 + 500 + 1000 = 2000 CSS px, which is why it
* was reported as a zoom bug: Chrome at 90% gives 2063 CSS px and passes, 100% gives 1857 and
* fails. box-sizing is stated for the same reason — getComputedStyle().width is
* the content box, so a foreign `border-box` plus padding would shave the reading. */
function resolveLen(token, dflt) {
if (!_probe) {
_probe = document.createElement('div');
@@ -142,19 +127,18 @@ function resolveLen(token, dflt) {
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. */
/* Memoised because fitShell runs on every resize and mutation and resolving forces a style recalc,
* but keyed on the density: that is the one thing changing these widths at runtime
* (`prefs.applyDensity()` stamps `:root[data-density]` and calls fit.schedule() for exactly that).
*
* The defaults are stated once so the fallbacks and the sanity net below cannot restate the
* stylesheet's widths in two places. Reaching for them means the measurement failed. */
const GEOM_DFLT = { contentMin: 500, sidebarW: 224, railW: 68, contentPad: 56, contentMax: 1280 };
let _geom = null, _geomDensity = null, _geomWarned = false;
function shellGeometry() {
const density = document.documentElement.getAttribute('data-density') || '';
/* the gutter is re-asked even on the memo hit: it moves with the WIDTH, not with the density */
/* the gutter is re-asked even on a memo hit: it moves with the width, not the density */
if (_geom && _geomDensity === density) return _geom;
_geomDensity = density;
const px = (name, dflt) => resolveLen(name, dflt);
@@ -162,27 +146,22 @@ function shellGeometry() {
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. It is also only the FALLBACK:
* measureShell() overwrites this field with the gutter the column actually got, and until it
* has (the login page has no `.fs-content`, and nothing has measured before the first
* fitter) the token is the honest answer. */
/* the token is one side's padding; the column loses it twice. It is only the fallback —
* measureShell() overwrites this with the gutter the column actually got, which nothing
* has measured before the first fitter (and the login page has no `.fs-content`). */
contentPad: px('--fs-content-pad', GEOM_DFLT.contentPad / 2) * 2,
/* the cap the column stops growing at, so the model knows where the surplus becomes margin
* rather than gutter — see columnWidth() */
/* where the column stops growing, i.e. where surplus becomes margin — see columnWidth() */
contentMax: px('--fs-content-max', GEOM_DFLT.contentMax)
};
/* 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. */
/* Plausibility, at the cost of one comparison: the rail is the sidebar collapsed, so
* 0 < railW < sidebarW holds by construction. Both known failures destroy it — a hijacked probe
* reports one foreign width for all four (issue #19), a renamed or absent token reports 0 for
* all four (an abs-positioned empty div shrinks to 0, which is finite, so the per-read fallback
* never fires). Only the relation between the numbers gives either away. */
const sane = (g.railW > 0 && g.railW < g.sidebarW && g.contentMin > 0);
/* SAY SO when it fires. The fallback keeps the chrome laid out, which is right — but it lays it
* out on the literals CLAUDE.md forbids copying into JS, so from there on the sidebar folds at a
* width nobody chose while the page still LOOKS correct. Silent, that is how a renamed token
* ships green; one console line is the difference between a mystery and a grep. Once per
* document — this runs on every resize and every mutation. */
/* The fallback keeps the chrome laid out, but on built-in literals, so the sidebar folds at a
* width nobody chose while the page still looks correct. Say so, or a renamed token ships
* green. Once per document — this runs on every resize and mutation. */
if (!sane && !_geomWarned) {
_geomWarned = true;
console.error('footstrap: the chrome widths did not read back from the stylesheet (got '
@@ -190,60 +169,43 @@ function shellGeometry() {
+ 'renamed, or a foreign sheet is reaching the measurement probe.');
}
_geom = sane ? g : Object.assign({}, GEOM_DFLT);
/* the token is only the fallback; the gutter that counts is the one measureShell() read */
if (_shellPad != null) _geom.contentPad = _shellPad;
return _geom;
}
/* THE GUTTER IS MEASURED WHERE IT IS APPLIED, not read off the token that usually supplies it, and
* SO IS THE WINDOW — but both are read HERE, from a fitter, and nowhere else.
/* The window's width and the column's gutter are read here, from a fitter, and nowhere else.
*
* `--fs-content-pad` is what `.fs-content` uses at desktop widths, and `theme/20-shell.css` gives
* the same element `padding: var(--fs-space-4)` below 767px: the token says 28px a side while the
* column's gutter is 16px there. That is a 24px error in a model this file exists to keep honest,
* and `live-audit` found it on every page at 320, 390 and 568 — the same trap the alert-message rule
* under that media query carries a paragraph about, repeated in the JS. The breakpoint itself may
* NOT come here: a width literal copied into JS is exactly what these reads exist to avoid, and a
* media query has no `data-*` to key on either. Asking the element what it actually got is the only
* form that cannot drift.
* The gutter is measured where it is applied rather than read off `--fs-content-pad`: below 767px
* `theme/20-shell.css` re-pads `.fs-content` to `var(--fs-space-4)`, so the token says 28px a side
* while the real gutter is 16px. The breakpoint may not be restated here — a width literal in JS is
* what these reads exist to avoid — so the element is asked what it actually got.
*
* WHY IT IS A SEPARATE FUNCTION AND WHY ONLY A FITTER CALLS IT. `clientWidth` is a LAYOUT read and
* `getComputedStyle` resolves style; `contentWidth()` below is called mid-scroll, by the one pass in
* this theme that must answer without reading either (fs-select's, for a table the poll brought in
* under the reader's thumb). So the numbers are taken when a fitter runs — which is on every resize
* and every content mutation, and never during a flick, because `fitChrome()` defers exactly like
* every other measuring pass — and everything after that reads what was stored. Nothing they
* describe can change without a resize, and a resize schedules a fit; a resize that lands DURING a
* scroll is deferred with the rest, so a mid-scroll answer is the geometry as of the last still
* moment, which is the trade this whole file is built on.
* Only a fitter calls this because `clientWidth` is a layout read and `getComputedStyle` resolves
* style, while `contentWidth()` below must answer mid-scroll without either (fs-select's pass, for
* a table the poll brought in under the reader's thumb). A fitter runs on every resize and content
* mutation and defers during a flick, so a mid-scroll answer is the geometry as of the last still
* moment.
*
* Unlike the token probe above, a hostile app's declaration is not a threat here: `.fs-content`
* carries no chrome mark, so an app CAN restyle it, and if it did then that padding IS the column's
* real gutter. What the probe must not report is a foreign answer for OUR layout; this reports the
* layout as it stands. Before any fitter has run — the login page has no `.fs-content` at all — the
* token stands in. */
* A hostile declaration is no threat here: `.fs-content` carries no chrome mark, so if an app
* re-pads it then that padding IS the column's gutter. Before any fitter has run — the login page
* has no `.fs-content` — the token stands in. */
let _shellOuter = 0, _shellPad = null, _padAt = null;
function measureShell() {
/* the window's own width, every time: fitShell read it before this function existed and the
* value is what everything downstream is measured against */
/* the window's own width, every time: everything downstream is measured against it */
_shellOuter = document.documentElement.clientWidth;
/* The GUTTER, though, is resolved style, and this runs on every mutation batch — once a second
* on any polled page. Three things move it: the width (a media query re-paddings the column
* below 767px), the density (the token is `calc(28px * var(--fs-density-space))`) and the PAGE.
* The third is the one the paragraph above already names and this key used to miss: `.fs-content`
* carries no chrome mark, so a foreign sheet may re-pad it — and `sheets.scopeToCurrentPage()`
* enables and disables those sheets on every client navigation, with no width and no density
* change to notice it by. Navigating off a page whose sheet re-padded the column would otherwise
* leave this pinned at that app's gutter for as long as the width held. `body[data-page]` is the
* one attribute a navigation always restamps, so it is the third term; an unchanged page still
* resolves nothing. Same trade as the token memo above, for the same reason. */
/* The gutter is resolved style and this runs on every mutation batch, so it is memoised on the
* three things that move it: the width (a media query re-pads the column below 767px), the
* density (the token is a calc over it) and the page. The page is the third term because a
* foreign sheet may re-pad `.fs-content`, and `sheets.scopeToCurrentPage()` enables and
* disables those sheets per navigation with no width or density change to notice it by;
* `body[data-page]` is the one attribute a navigation always restamps. */
const key = (document.documentElement.getAttribute('data-density') || '') + '|' + _shellOuter +
'|' + (document.body ? document.body.getAttribute('data-page') || '' : '');
if (_padAt === key && _shellPad != null) return;
const host = document.querySelector('.fs-content');
const cs = host ? getComputedStyle(host) : null;
const v = cs ? parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight) : NaN;
/* a failed read leaves the key unset, so the next pass tries again rather than caching a miss */
/* a failed read leaves the key unset, so the next pass retries rather than caching a miss */
if (Number.isFinite(v) && v >= 0) {
_shellPad = v;
_padAt = key;
@@ -254,44 +216,37 @@ function measureShell() {
function columnWidth(g, state) {
/* narrow OR top: in both the chrome is above the content, not beside it */
const cut = (state.narrow || state.top) ? 0 : (state.rail ? g.railW : g.sidebarW);
/* AND THE COLUMN STOPS GROWING. `.fs-content` is `max-width: var(--fs-content-max); margin: 0
* auto`, so past roughly a 1500px window the surplus becomes margin and not column: without the
* cap this answered ~2280 on a 2560px sidebar layout for a column that is 1224 wide. No caller
* can reach that today — both ask a LOWER bound (`< --fs-content-min` here, `< CRAMPED` in
* fs-select) and the cap only binds where both are clear by a factor of two — but this is the
* exported answer to "how wide is the content column", and an answer that is only true below
* 1500px is the kind of thing the next caller inherits without being told. */
/* The column stops growing: `.fs-content` is `max-width: var(--fs-content-max); margin: 0
* auto`, so past ~1500px the surplus becomes margin, and without the cap this answers ~2280 on
* a 2560px window for a column that is 1224 wide. No caller can reach that today (both ask a
* lower bound), but this is the exported answer to "how wide is the content column". */
const room = Math.min(state.outerW - cut, g.contentMax);
return Math.max(0, room - g.contentPad);
}
function fitShell() {
const root = document.documentElement;
/* the one place the window and the gutter are read — see measureShell(). It runs in BOTH
* branches: the bar layout decides nothing here, but `contentWidth()` still answers in it. */
/* runs in both branches: the bar layout decides nothing here, but `contentWidth()` still
* answers in it */
measureShell();
if (prefs.currentLayout() === 'top') { /* no sidebar, no cut, nothing to decide */
root.removeAttribute('data-narrow');
return;
}
const g = shellGeometry();
/* asked UNCOLLAPSED — this is the measurement that decides `data-narrow`, so it may not read it */
/* asked uncollapsed: this measurement decides `data-narrow`, so it may not read it */
const content = columnWidth(g, { outerW: _shellOuter, rail: prefs.currentRail() });
/* 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. */
/* toggleAttribute, not setAttribute: a same-value setAttribute still queues a mutation record,
* while toggleAttribute on an already-present attribute queues none. fitShell runs on every
* mutation batch inside #view, and menu-footstrap treats each data-narrow record as a mode
* change, so on a phone (where the attribute is permanently set) every poll tick would re-fire
* closeFlyouts() and snap shut the section the user just opened. */
root.toggleAttribute('data-narrow', content < g.contentMin);
}
function fitChrome() {
/* Nothing this function asks can change while the reader scrolls — the bar's width, the menu's
* width, the room beside the brand — and every one of those questions is a layout read landing
* in the middle of a flick. Put off until the scrolling stops; see fs-fit.js. */
/* nothing asked here — bar width, menu width, room beside the brand — can change while the
* reader scrolls, and each is a layout read landing mid-flick: defer (fs-fit.js) */
if (fit.scrolling()) {
fit.deferMeasurement();
return;
@@ -301,31 +256,28 @@ function fitChrome() {
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.) */
/* The top bar is measured at every width, with no breakpoint floor: the shrink/compact/stack
* escalation below runs at any width. (The sidebar layout 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.
* It depends on how many sections THIS router has (stock 5, a loaded box 11), not on the
* viewport, so it is measured — a `max-width: 1199px` breakpoint stacked it on every laptop.
* Measured unstacked (the remove above), because a stacked menu owns a whole row, would "fit"
* and flip straight back.
*
* 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). */
* The menu's own pills wrapping is the "does not fit" signal, and only works because the
* unstacked top bar is flex-wrap: nowrap (50-toplayout.css) — otherwise the bar wraps, hands
* the menu a row and it always fits. Do not measure the bar's children by offsetTop instead:
* the bar is align-items:center over children of differing heights, so offsetTop differs even
* on one row. */
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). */
/* first step before stacking: collapse the poll pill (~90px) to an icon square and
* re-measure — often enough to keep the menu on the brand's row
* (theme/50-toplayout.css) */
bar.classList.add('fs-ind-compact');
fitTabStrips();
if (!stripFitsOneRow(menu)) {
@@ -334,25 +286,23 @@ function fitChrome() {
}
}
/* 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, so that when the menu
* did NOT fit, .fs-bar-stack has already given it a row of its own. */
/* the cluster's own escalation, for every bar; after the menu's, so a menu that did not fit
* has already been given a row of its own by .fs-bar-stack */
if (bar && (topBar || document.documentElement.hasAttribute('data-narrow')))
fitCluster(bar, menu);
publishBarHeight(bar);
}
/* ---- HOW TALL THE BAR ACTUALLY IS, for whoever has to stick underneath it ----
/* ---- how tall the bar actually is, for whoever sticks underneath it ----
*
* A data table's header row sticks while its rows scroll past (theme/30-tables.css). Where the
* DOCUMENT scrolls — the top layout, and the sidebar layout once it has become a bar — the sticky
* bar is what the header has to clear, and `--fs-bar-h` is only its DESIGNED height: the bar grows
* when the brand wraps, when the menu takes a row of its own (.fs-bar-stack) or when the cluster
* does (.fs-bar-actrow), and every one of those is decided a few lines above by measurement.
* So the measurement is published, and the CSS falls back to the token when it is missing.
* A data table's header row sticks while its rows scroll past (theme/30-tables.css), and where the
* document scrolls it has to clear the sticky bar. `--fs-bar-h` is only the bar's DESIGNED height:
* it grows when the brand wraps, when the menu takes a row (.fs-bar-stack) or when the cluster does
* (.fs-bar-actrow), each decided by measurement above. So the measurement is published and the CSS
* falls back to the token.
*
* Written only on a CHANGE and rounded to the pixel: this runs on every fit pass, and a custom
* Written only on a change and rounded to the pixel: this runs on every fit pass, and a custom
* property write on :root invalidates style for the whole document. */
let _barH = 0;
function publishBarHeight(bar) {
@@ -366,16 +316,13 @@ function publishBarHeight(bar) {
/* ---- 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.
* The cluster is four siblings (indicators, Search, Appearance, Log out), which flexbox wraps one
* at a time: on a narrow bar Log out alone drops to a second row, left-aligned under the hostname.
* Either the whole cluster shares the brand's row or it takes a row of its own, right-aligned.
*
* 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. */
* Two steps, cheapest first: collapse the pills to icon squares (~200px of prose), then give the
* cluster a row. .fs-ind-compact may already be set by the menu's escalation above and 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))
@@ -388,14 +335,10 @@ function fitCluster(bar, menu) {
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 either way. Where it wraps to a row of its own it plainly
* is not competing (`ul.nav { flex: 1 1 100% }` — the sidebar-layout bar, and the top bar once
* .fs-bar-stack is set); where it does share the brand's row, un-stacked at `flex: 1 1 auto`
* (theme/50-toplayout.css), it is the one child that SHRINKS, so counting its current width would
* report the cluster as not fitting whenever the menu happened to be wide. What the cluster has to
* fit beside is the brand and the other actions. */
/* Add the widths up rather than read positions, for the offsetTop reason above. The menu is
* excluded either way: on a row of its own (`ul.nav { flex: 1 1 100% }`) it is not competing, and
* where it shares the brand's row it is the child that shrinks (`flex: 1 1 auto`), so counting its
* current width would report the cluster as not fitting whenever the menu happened to be wide. */
function clusterFitsBrandRow(bar, menu) {
const cs = getComputedStyle(bar);
const gap = parseFloat(cs.columnGap) || 0;
@@ -403,9 +346,7 @@ function clusterFitsBrandRow(bar, menu) {
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. */
/* offsetParent is null for a display:none child, which is most of them in a bar */
if (el === menu || el.offsetParent === null)
continue;
need += el.offsetWidth + gap;
@@ -413,12 +354,10 @@ function clusterFitsBrandRow(bar, menu) {
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. */
/* No observer and no resize listener of our own — fs-fit owns both (docs/conventions.md). A view
* renders its .cbi-tabmenu into #view, which fs-fit's MutationObserver already watches and re-fits
* synchronously. #tabmenu is a sibling of #view, but nothing writes it except renderChrome(),
* which schedules a fit itself. */
/* modes -> #modemenu; drives the injected renderMainMenu for the active mode */
function renderModeMenu(node, renderMainMenu) {
@@ -430,8 +369,7 @@ function renderModeMenu(node, renderMainMenu) {
? 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 */
/* the main menu must render even where a template has no #modemenu */
if (ul)
ul.appendChild(E('li', { 'class': isActive ? 'active' : '' }, [
E('a', { 'href': L.url(child.name) }, [ _(child.title) ])
@@ -449,7 +387,7 @@ function renderModeMenu(node, renderMainMenu) {
ul.style.display = '';
}
/* rebuild mode menu + main menu + section tabs from the current L.env; on first load and after
/* 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();
@@ -465,11 +403,9 @@ function renderChrome() {
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. */
/* `node.children &&`, as fs-menutree's nodeForSegs() walks it: a childless node is an
* ordinary leaf, and reading `.children[…]` off one throws out of renderChrome(), taking
* the mode menu, the tabs and everything init wires after it */
for (let i = 0; i < 3 && node; i++) {
node = node.children && node.children[L.env.dispatchpath[i]];
url = url + (url ? '/' : '') + L.env.dispatchpath[i];
@@ -482,8 +418,8 @@ function renderChrome() {
}
/* 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. */
* <html data-rail> (head.ut re-applies it before paint) and in localStorage; everything else is
* CSS keyed off that attribute. */
function wireRail() {
const btn = document.getElementById('fs-rail-toggle');
if (!btn) return;
@@ -499,26 +435,23 @@ function wireRail() {
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 */
/* the sidebar's cut just changed by ~156px, so the 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.
/* An indicator pill carries its meaning as prose ("Unsaved Changes: 2") and the collapsed rail is
* 68px wide, so the pill wraps onto three lines and hangs past the rail's edge (issue #14). CSS
* squares the pill there and draws this attribute instead of the label — a text node cannot be
* reached by a selector, so the badge is lifted into an attribute here.
*
* 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. */
* The count is the only part that changes and the only part readable at that size. A pill with no
* trailing number falls back to a neutral dot; clipping the prose instead renders as garbage
* ("up pen"), because a centred pill gives an ellipsis no start to anchor to. The full prose
* stays in the label for screen readers, and in `title` for the pointer. */
const IND_DOT = '•';
function wireIndicatorCounts() {
@@ -535,10 +468,9 @@ function wireIndicatorCounts() {
});
}
/* 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. */
/* ui.showIndicator replaces the label's text node on an update and appends the span on the
* first change of the session, so childList, subtree and characterData all matter. Our own
* attribute writes do not re-enter: attributes are not observed. */
new MutationObserver(stamp).observe(box, { childList: true, subtree: true, characterData: true });
stamp();
}
@@ -547,24 +479,20 @@ 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' */
/* registered with fs-fit by the theme's init(): the bar's fit rides the same engine as the
* data tables' */
fitChrome,
/* The width a page's content column has, WITHOUT reading layout. The sidebar (or the rail) eats
* a known amount of the window and the shell adds a known padding, and all three are already
* memoised against the density attribute for `fitShell()`. It is exported because a pass that
* must answer mid-scroll — fs-select's, for a table the poll has just brought in — otherwise has
* only the window's own width, which in the sidebar layout is the wrong number by exactly the
* sidebar: at an 800px window the column is 520px, so the cheap judgement said "plenty of room"
* for a table that then overflowed and was clipped.
/* The width a page's content column has, without reading layout: the sidebar or rail eats a
* known amount of the window and the shell adds a known padding, all memoised for fitShell().
* Exported because a pass answering mid-scroll (fs-select's, for a table the poll just brought
* in) otherwise has only the window width, which in the sidebar layout is wrong by exactly the
* sidebar — at 800px the column is 520px, so a table judged to have room overflows.
*
* The arithmetic itself is columnWidth()'s, shared with fitShell — see there. All this adds is
* the page's current state, which fitShell is in the middle of deciding and this one reads. */
* The arithmetic is columnWidth()'s; this only adds the page's current state. */
contentWidth() {
/* NO LAYOUT READ, and that is the whole point of this export: the window's width and the
* column's gutter are whatever the last fitter measured (measureShell), and the three
* attributes below are style, not layout. The bootstrap read is for a caller that arrives
/* no layout read: the window width and gutter are whatever the last fitter measured, and
* the three attributes below are style. The bootstrap read serves a caller arriving
* before any fitter has run, which cannot happen mid-scroll. */
if (!_shellOuter) measureShell();
const root = document.documentElement;
@@ -576,10 +504,9 @@ return baseclass.extend({
});
},
/* exported for the unit suite in the theme's own repository (tests/chrome-geometry.test.mjs — no
* tests ship in the package): the cut is pure arithmetic over a handful of measured numbers, and
* driving it directly is the only way to hold every combination of layout, rail and width
/* exported for tests/chrome-geometry.test.mjs (no tests ship in the package): driving the
* arithmetic directly is the only way to cover every combination of layout, rail and width
* without a browser */
columnWidth,
columnWidth, /* fs:probe */
wireRail
});
File diff suppressed because it is too large. Load diff
@@ -1,13 +1,9 @@
'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 menu tree as a routing table: path <-> node, with LuCI's alias/firstchild resolution. Pure
* lookup — no DOM. The chrome and the SPA router both need it, and require() raises
* DependencyError on a loop, so the shared half is its own module rather than reached across. */
/* the ACL-filtered tree from /admin/menu, handed over once by the chrome's init() */
let _tree = null;
@@ -17,10 +13,10 @@ function setTree(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. */
* 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 as the server does — returning null made the wordmark full-reload instead. null is
* reserved for a path outside LuCI's scriptname. */
function segsFromPath(pathname) {
const base = L.env.scriptname || '';
if (base && pathname.indexOf(base) !== 0)
@@ -41,18 +37,15 @@ function nodeForSegs(segs) {
/* ---- 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.
* 7 of the 27 menu links are redirects rather than pages (4 `alias`, 3 `firstchild`). The server
* does not redirect them: a full GET answers 200 at the requested URL and stamps the resolved leaf
* into requestpath/dispatchpath/nodespec. The client must therefore resolve exactly as
* dispatcher.uc does, or a click and an F5 on the same URL open different pages — nodeWeight() and
* firstChildOf() are ports, not approximations. Only the ACL check is skipped, the tree from
* /admin/menu already being ACL-filtered for this session.
*
* 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. */
* `rewrite` is deliberately not followed: the tree has none, and a wrong guess at its splice
* semantics would 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) {
@@ -106,42 +99,23 @@ function resolveSegs(segs) {
/* ---- 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.
* apply_tree_acls() (dispatcher.uc:442) marks a NODE readonly from its own `depends.acl`, but a
* request accumulates every ancestor's acls into ctx.acls and stamps the leaf from the
* accumulation (`resolved.node.readonly = !perm`, :1003). Reading the leaf alone therefore
* disagrees with a full load: luci.js derives hasViewPermission() from
* `!env.nodespec.readonly`, which views and the Save/Apply footer key their disabled state off, so
* every page whose SECTION is read-only would gain controls the server refuses.
*
* 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.
* The operator is AND down the path, not OR: check_acl_depends() (:312) is handed the whole
* concatenated list (:457-470) and grants write as soon as ANY group in it does, so a page is
* read-only only when every acl-bearing node on the path is. A leaf with a writable acl of its own
* re-opens the whole path.
*
* AND down the path, not OR — the operator is the whole of this function and it was the wrong one.
* check_acl_depends() (dispatcher.uc:312) is handed ONE list, the concatenation ctx_append() built
* from every node's `depends.acl` along the path (:457-470), and it answers `writable = true` as
* soon as ANY group in that list grants write. So a page is read-only exactly when NO group on the
* path is writable — and since apply_tree_acls() (:436-446) marks a node `readonly` when that
* node's own list yields no write, "no group on the path is writable" is "EVERY acl-bearing node on
* the path is readonly". One read-only ancestor is not enough: a leaf that declares a writable acl
* of its own re-opens the whole path.
* A node with no `depends.acl` contributes nothing to ctx.acls and is skipped. That is also why
* the test reads `depends.acl` rather than the `readonly` flag alone: an acl-bearing node the
* session may write carries no flag and would otherwise look ungated.
*
* Measured, because a source reading is not a verdict: on the 25.12 stand, `admin/status/logs`
* carries the read-only `luci-mod-status-logs` and its child `syslog` was given a writable acl of
* its own; a full load then reported `nodespec.readonly` FALSE while this function said true — the
* same class of disagreement between a click and an F5 that the leaf-only reading used to produce,
* only in the opposite direction, and this time it TAKES AWAY a Save/Apply the server allows.
*
* A node with no `depends.acl` is not evidence either way and is skipped: the dispatcher puts
* nothing into ctx.acls for it. That is also why the answer needs `depends.acl` at all rather than
* the `readonly` flag alone — an acl-bearing node the session may write carries no flag, and is
* therefore indistinguishable from an ungated node without looking at its acl list. /admin/menu
* serves both fields (66 of 243 nodes carry an acl on the stand).
*
* Feed it the RESOLVED segments, since that is the path the dispatcher accumulates over. */
* Feed it the RESOLVED segments — that is the path the dispatcher accumulates over. */
function readonlyForSegs(segs) {
let node = _tree;
let gated = 0, locked = 0;
@@ -163,10 +137,10 @@ function readonlyForSegs(segs) {
return gated > 0 && gated === locked;
}
/* 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 — the
* globals that template also defines are the chrome bootstrap's, see ensureOverviewHelpers in
* menu-footstrap-common.js). Shared by navigate() and the hover prefetch. */
/* The view class a menu node instantiates, or null when the node is not SPA-able. The
* Status -> Overview `template` node maps to view.status.index, its server template doing nothing
* else; the globals that template also defines come from ensureOverviewHelpers() in
* menu-footstrap-common.js. Shared by navigate() and the hover prefetch. */
function viewClassFor(node) {
if (!node || !node.action || node.satisfied === false)
return null;
@@ -177,7 +151,7 @@ function viewClassFor(node) {
return null;
}
/* The node the CURRENT full-load landed on, i.e. what L.env.dispatchpath points at. */
/* The node the current full load landed on, i.e. what L.env.dispatchpath points at. */
function currentNode() {
return nodeForSegs(L.env.dispatchpath || []);
}
@@ -4,44 +4,31 @@
'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.
/* Overview layout only: renders nothing of its own, it re-arranges the STOCK System / Memory /
* Storage sections into a grid. Content, data and styling stay luci-mod-status's — rendering a
* custom tree every poll instead (the old 05_footstrap_dashboard.js) flickered and reset mobile
* scroll. The stock poll fills each section in place via dom.content() and never rebuilds the
* .cbi-section wrapper, so the wrappers stay inside our grid 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 — which is why nothing below re-checks `L.env.media`: the file
* cannot be reached except through this theme's own footer partial, and a gate for that is a gate
* for a state that cannot occur.
*
* 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 patchOverview() below and ensureOverviewHelpers() in menu-footstrap-common.js. */
/* 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. */
* This must NOT be filed in LuCI's global include dir (view/status/include/): luci-mod-status
* evaluates every *.js there, so the file would be fetched and run on routers using another theme,
* with an `L.env.media` gate only silencing it after the fact. As a chrome module it is
* unreachable except through this theme's footer partial, which is why nothing below re-checks
* `L.env.media`. That location also supplied two timing guarantees for free, by evaluating inside
* index.load(); both are paid for explicitly here — patchOverview() below, and
* ensureOverviewHelpers() in menu-footstrap-common.js. */
/* section title -> grid role. _() with no msgctxt on purpose: these must resolve to exactly what
* luci-mod-status resolves to, or the titles stop matching. Built once, not per poll tick. */
const ROLES = { [_('System')]: 'sys', [_('Memory')]: 'mem', [_('Storage')]: 'sto' };
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. */
/* 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 one silently disables the grid on
* the other. */
const h = sec.querySelector('.cbi-title h3, :scope > h3');
if (!h) return '';
/* The first non-empty TEXT node, not `firstChild`. That heading is not text-only any more: 25.12
* appends a hide/show <span> inside the same <h3>, and `firstChild` lands on the title today only
* because upstream happened to put the words first. One reordering there and every section reads
* as an empty title — the grid silently stops being built, with the page still rendering fine. */
/* the first non-empty TEXT node, not `firstChild`: 25.12 appends a hide/show <span> inside the
* same <h3>, so `firstChild` depends on upstream keeping the words first */
for (const n of h.childNodes) {
if (n.nodeType !== 3) continue;
const t = String(n.nodeValue || '').trim();
@@ -53,37 +40,29 @@ function sectionTitle(sec) {
/* the wrapper we built, so the poll-tick fast path costs one property read */
let _wrapEl = null;
/* A PORT NAME THE CARD HAD TO CUT IS STILL READABLE ON HOVER.
/* A port name the card had to cut stays readable on hover: styles/pages/20-overview.css ellipses
* it at one line so every card takes the width the row can spare, and the name is the one thing on
* a card that cannot be guessed from the rest.
*
* styles/pages/20-overview.css clamps a port card's name to two lines with an ellipsis — that is
* what lets every card be 99px wide instead of as wide as the longest interface name on the device.
* The name is the one thing on the card that cannot be guessed from the rest of it, so the element
* carries its own full text as a native tooltip.
* The tooltip is set unconditionally: testing `scrollWidth` against `clientWidth` per card would
* force a synchronous layout on every poll tick, since 29_ports.js rebuilds these tiles each time.
*
* NOTHING HERE READS LAYOUT. Asking "was this one actually truncated?" means `scrollHeight` against
* `clientHeight` per card, i.e. a forced synchronous layout inside the path a poll tick lands on
* every five seconds — and 29_ports.js REBUILDS these tiles on each of those ticks, so the question
* would be asked again every time. A title on a name that fits costs a tooltip repeating what is
* already on screen; a layout read here costs the page.
*
* It runs BEFORE arrange()'s fast path, and that is the point: the fast path returns as soon as the
* grid is intact, while the tiles under it are new elements with no attribute on them. */
* Runs BEFORE arrange()'s fast path, which returns as soon as the grid is intact while the tiles
* under it are new elements. */
function nameTooltips(view) {
for (const icon of view.querySelectorAll('img[src*="/port_"]')) {
const head = icon.closest('.ifacebox')?.firstElementChild;
const name = head ? head.textContent.trim() : '';
/* `!==` so an unchanged name is not written back on every tick — a title write is cheap,
* but a mutation of a node inside the tree we are observing is not. */
/* `!==`: a mutation inside the tree we observe is not cheap, even when the write is */
if (name && head.title !== name)
head.title = name;
}
}
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. */
/* an SPA nav can leave the observer wired while another page renders into #view: detach as soon
* as the route stops being the overview. body[data-page] carries the DISPATCH path from both
* the server template and the router, so /admin/status (firstchild -> overview) matches. */
if ((document.body.getAttribute('data-page') || '') !== 'admin-status-overview') {
stopWatch();
return;
@@ -93,11 +72,10 @@ function arrange() {
nameTooltips(view);
/* 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. */
/* Fast path: the poll lands here on every tick, forever, and the stock poll never rebuilds
* the .cbi-section wrappers, so the grid survives. Deliberately not a disconnect() — if a
* future luci-mod-status does rebuild a section, the wrapper loses its children and the slow
* path below rebuilds the grid. */
if (_wrapEl && _wrapEl.isConnected && _wrapEl.parentElement === view && _wrapEl.children.length === 3)
return;
@@ -122,11 +100,10 @@ function arrange() {
_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. */
/* Stock sections render async and repaint every poll, so watch #view and re-run arrange(),
* coalesced and one observer per #view node. The SPA router may replace #view between visits, so
* re-attach when the observed node is no longer the current one — a singleton bound to the first
* #view would watch a detached tree and the grid would never apply again. */
let _observer = null, _observedView = null, _routeObserver = null;
function stopWatch() {
if (_observer) _observer.disconnect();
@@ -139,10 +116,8 @@ function watch() {
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. */
/* a chrome module is alive on every page, so without the route check an observer would attach
* to #view on, say, the firewall page and re-run arrange() for every table mutation */
if (_observer || !view ||
(document.body.getAttribute('data-page') || '') !== 'admin-status-overview')
return;
@@ -153,14 +128,9 @@ function watch() {
_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. */
/* A chrome module is instantiated once per page load, so it has to notice SPA navigation itself.
* `body[data-page]` is the signal — the server template and fs-router both stamp it with the
* dispatch path — so one attribute observer covers arriving, leaving and coming back. */
function wire() {
if (_routeObserver || !document.body)
return;
@@ -175,34 +145,29 @@ function wire() {
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. */
/* Arrival at the overview, from a full page load or an SPA navigation. patchOverview() is
* idempotent (the __fsProgressive flag), so the two paths cannot double-patch. */
function onOverview() {
patchOverview();
watch();
}
/* ---- progressive paint -----------------------------------------------------
/* ---- 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).
* Stock `view.status.index` calls poll_status(first_load=true), which Promise.all's over every
* include's load(), and render() withholds the tree until it resolves — #view stays empty for as
* long as the slowest include takes (measured: 182 ms, of which most sections were ready at 88 ms
* and waiting on 29_ports and 60_wifi).
*
* 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.
* 1. each section paints when its own data lands (182 -> ~90 ms). Nothing jumps: the frames are
* already in the DOM, a section goes hidden -> filled as on any poll tick;
* 2. drops the redundant re-fetch — stock adds the poller after the first load and Poll.add()
* steps at once, re-fetching everything (~250 ms of ubus) right after the first paint.
*
* 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. */
* Not a re-implementation: frames, toggles, includes and their render() stay upstream's.
* fillSection() transcribes stock's loop in the same order so it can be diffed against index.js;
* if that shape is gone, the patch is skipped and the page runs stock. */
function fillSection(inc, container, res) {
if (inc.failed)
return;
@@ -224,20 +189,15 @@ function fillSection(inc, container, res) {
}
let _inflight = null;
/* WHICH containers the in-flight run is filling. The guard below is module-level because the
* duplicate load it kills is module-level, but the frames are per RENDER — so joining a run blindly
* joined one that fills SOMEBODY ELSE'S frames. Reproduced: double-click Status → Overview 100 ms
* apart, or leave and return inside the first-load window, and the second arrival's sections stayed
* at their birth `display:none` for a full poll interval (measured 5.9 s against 0.4 s on a single
* arrival) — it had "joined" a run that was filling the frames the content swap had just detached.
* Stock LuCI has no such guard and each render fetches its own data, so the blank page is ours. */
/* Which containers the in-flight run is filling. The guard is module-level because the duplicate
* load it kills is, but frames are per render: joining a run blindly joins one filling somebody
* else's frames, and the second arrival's sections then stay at `display:none` for a full poll
* interval (5.9 s against 0.4 s). */
let _inflightFor = null;
function pollProgressive(includes, containers, first_load) {
/* A run is already fetching exactly this data, FOR THESE FRAMES — join it instead of starting a
* second stampede of the same RPCs. This is what kills the duplicate load. A run for older
* frames is left to finish into the detached nodes it owns, which costs nothing and is simpler
* than cancelling RPCs that are already in flight. */
/* a run already fetching this data for THESE frames is joined rather than duplicated; a run for
* older frames is left to finish into the detached nodes it owns */
if (_inflight && _inflightFor === containers)
return first_load ? Promise.resolve() : _inflight;
@@ -248,8 +208,8 @@ function pollProgressive(includes, containers, first_load) {
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 */
/* the point of the patch: fill this section when its own data lands, 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); }
@@ -261,58 +221,38 @@ function pollProgressive(includes, containers, first_load) {
});
_inflight = run.finally(() => {
/* only if it is still OURS: a newer render may have replaced it while this one was running */
/* only if still ours: a newer render may have replaced it mid-run */
if (_inflightFor === containers) { _inflight = null; _inflightFor = null; }
});
_inflightFor = containers;
/* 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. */
/* Nobody awaits this on the first load (the caller gets a fresh Promise.resolve()), so a
* rejection would surface as an unhandled one. `run` rejects for one ordinary reason:
* flushCache() on an expired session, when the user is already being redirected to login.
* Section failures cannot reach it — fillSection runs in a try/catch and inc.load() has its
* own .catch. */
_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. */
/* first load resolves now, so index.render() returns its tree and the frames reach #view while
* the sections fill themselves; a poll tick resolves when its data is in, as 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.
/* 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.
*/
* Called from the route (wire()), not at module eval: requiring 'view.status.index' at eval would
* pull the whole stock view into memory on every page, and on a full load it would race
* index.load(). Hence the `__fsProgressive` guard and the fact that missing the window is
* harmless — the page then renders the stock way, one Promise.all, ~90 ms later. */
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`, never the bare `L` this factory was handed. require() passes the object it was
* called on into the loaded module's factory, and index.js loads its own includes with that
* same `L` — 30_network.js then calls `L.itemlist(...)`, which lives on the runtime instance
* (`window.L = new LuCI()`), not on the prototype a chrome module receives. require() caches
* by class name, so the first caller decides this for everybody: through the bare `L` the
* overview dies mid-render on "L.itemlist is not a function", stuck on "Loading view…" (issue
* #22 follow-up). docs/spa-router.md. */
window.L.require('view.status.index').then((idx) => {
const proto = idx ? Object.getPrototypeOf(idx) : null;
if (!proto || proto.__fsProgressive || typeof proto.poll_status !== 'function')
@@ -324,17 +264,12 @@ function patchOverview() {
}).catch((e) => console.error('footstrap: overview progressive paint not applied', e));
}
/* THE THREE TEMPLATE GLOBALS ARE NOT HERE, and where they went is the point. `progressbar`,
* `renderBox` and `renderBadge` come from `admin_status/index.ut`'s inline script, which an SPA
* arrival never runs, so the theme defines them — but a stock include calls them bare from its own
* `render()`, so they must exist before the view class does. While this file was in the chrome's
* directive prologue that was free: it evaluated at chrome init. As a page module it is required
* during the navigation that needs it, racing the router's require of the view class, so the
* definitions moved to `menu-footstrap-common.js`, which every page evaluates before the router
* exists. This module keeps only what the Overview itself needs. */
/* `progressbar`, `renderBox` and `renderBadge` are defined in menu-footstrap-common.js, not here:
* a stock include calls them bare from its own render(), so they must exist before the view class
* does, and this page module is required during the navigation that races it. */
return baseclass.extend({
/* Called by menu-footstrap-common's init, once. Everything route-dependent hangs off the
* data-page observer inside. */
/* called once by menu-footstrap-common's init; everything route-dependent hangs off the
* data-page observer inside */
wire,
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -5,46 +5,37 @@
'require fs-router as router';
'require fs-widgets as widgets';
/* Find a page by typing its name, instead of remembering which section owns it.
/* Find a page by typing its name instead of knowing which section owns it. A loaded router
* carries ~200 menu nodes across 11 sections, and a tab such as Firewall -> Port Forwards appears
* in no menu list until you are already there; this indexes every node the dispatcher would
* render, tabs included.
*
* 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 loaded
* (fs-menutree), so the palette lists exactly the pages this session may open. The index is built
* on the first open, not at init — a user who never searches pays nothing, and only a full load
* can change the tree.
*
* 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. */
* 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 no copy of that decision lives
* here. Enter synthesises the same click. */
/* ---- the index ---------------------------------------------------------- */
/* How deep below the MODE the walk goes. `depth` counts recursion levels and the walk starts at 1
* on a node that already has two segments, so a node at depth N carries N+1 of them: 4 admits
* admin/<section>/<page>/<tab>/<subtab>. That is one more than LuCI's own dispatcher renders, and
* deliberately so — a third-party node nested deeper is worth finding, and the depth term in the
* score below already ranks it last. */
/* How deep below the mode the walk goes. `depth` counts recursion levels starting at 1 on a node
* that already has two segments, so 4 admits admin/<section>/<page>/<tab>/<subtab> — one level
* more than LuCI renders, so a deeper third-party node is still findable and the depth term in
* search() ranks it last. */
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.
/* The node's own children, ACL- and title-filtered as ui.menu.getChildren() filters them, but not
* through getChildren(): on an alias node it returns the alias TARGET's children, which is right
* for drawing a menu and wrong for indexing — Network -> Firewall aliases onto the `firewall/zones`
* leaf, so all five of its tabs vanish from the index (78 nodes instead of 238).
*
* 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. */
* Order does not matter (search ranks by score), so only the two filters are reimplemented, not
* the sort. */
function childrenOf(node) {
const kids = node.children || {};
const out = [];
@@ -59,8 +50,8 @@ function childrenOf(node) {
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 */
/* the chrome carries its own Logout (partials/logout.ut); indexing it would let a search
* open a confirmation the user did not ask for */
if (depth === 1 && entry.name === 'logout')
return;
@@ -73,17 +64,13 @@ function walk(node, segs, trail, out, depth) {
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. */
/* Three haystacks, in this order of preference (see tokenScore()). The English
* node name is indexed on purpose: a translated UI otherwise hides the page from
* an admin who knows OpenWrt by its English docs. */
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. */
/* …minus the root segment, `admin`, which every node shares: indexed, every
* substring of it ("ad", "min", …) hits all ~200 pages and fills the result cap */
n: csegs.slice(1).join(' ').toLowerCase()
});
@@ -103,13 +90,10 @@ function buildIndex() {
return out;
}
/* Built once per DOCUMENT, and deliberately not re-derived per open: it is a projection of the
* client menu tree, which `ui.menu.load()` itself caches for the life of the document. So the
* palette is exactly as fresh as the sidebar beside it — a package installed or removed without a
* reload is missing from, or still listed in, BOTH. Invalidating only this half would make the two
* disagree, which is worse than either being stale; the reload the package manager already prompts
* for is what refreshes them together. (Cheap enough to rebuild if that ever changes: 238 nodes on
* the dev router.) */
/* Built once per document: it projects the client menu tree, which `ui.menu.load()` caches for
* the life of the document, so the palette is exactly as fresh as the sidebar beside it.
* Invalidating only this half would let the two disagree, which is worse than both being stale;
* the reload a package install prompts for refreshes them together. */
function index() {
if (!_index) _index = buildIndex();
return _index;
@@ -117,12 +101,9 @@ function 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. */
/* Every whitespace-separated token must hit something: a second word means AND. Deliberately not
* a fuzzy subsequence match — on a two-letter query that matches nearly every entry and leaves the
* ranking to decide everything, which reads as random. */
const HIT_NONE = 99;
function tokenScore(e, tok) {
if (e.t.startsWith(tok)) return 0; /* the title begins with it */
@@ -145,7 +126,7 @@ function search(q, limit) {
sum += s;
}
if (sum !== HIT_NONE)
hits.push({ e: e, score: sum + (e.depth * 0.1) }); /* ties: the shallower page first */
hits.push({ e: e, score: sum + (e.depth * 0.1) }); /* ties: shallower page first */
}
hits.sort((a, b) => a.score - b.score);
return hits.slice(0, limit).map((h) => h.e);
@@ -153,17 +134,17 @@ function search(q, limit) {
/* ---- 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.
/* What the palette shows before anything is typed: an admin lives in three or four pages, so the
* empty state is its most-used view.
*
* Only the 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. */
* Only the path is stored, never the title — the title is resolved through the index on every
* render, so it follows the UI language and a page removed with its package 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. */
* "these are paths" filter belongs here */
function loadRecent() {
return prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string');
}
@@ -184,15 +165,14 @@ function recentEntries() {
/* ---- 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 router's per-link prefetch needs a hover, tap or focus first, so a session's first visit to
* a page still pays for its module chain. The recents list is the best predictor available and is
* already on disk; warming the whole menu instead would pull every view module on the box
* (docs/spa-router.md).
*
* 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. */
* The current page is skipped — wire() has just remembered it and it is loaded by definition.
* Under saveData nothing speculative runs; the per-link prefetch stays, since it follows a
* deliberate hover or tap. */
const RECENT_WARM = 5;
function warmRecent() {
@@ -200,10 +180,9 @@ function warmRecent() {
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: nothing
* on screen waits for this, so it competes with the view's own module fetches and RPCs and must
* lose that race on purpose. */
/* Nothing waits on this, so it runs at idle, with a timeout for a page that never goes idle (a
* busy poll). The fallback delay is long on purpose: this competes with the view's own module
* fetches and RPCs and must lose that race. */
const go = () => paths.forEach((p) => router.prefetchSegs(p.split('/')));
if (typeof window.requestIdleCallback === 'function')
window.requestIdleCallback(go, { timeout: 4000 });
@@ -219,13 +198,13 @@ 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 the page this full load landed on: onNavigate below covers the SPA path, this
* covers an F5, a non-SPA-able node and the 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. */
/* the callback is handed the resolved segments of the INCOMING page; L.env still points at
* the outgoing one when the router fires its callbacks */
router.onNavigate(remember);
/* after remember(), so the page we are standing on is the one head of the list that gets skipped */
/* after remember(), so the page we stand on heads the list and is the one skipped */
warmRecent();
const input = E('input', {
@@ -247,15 +226,14 @@ function wire() {
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 */
/* the note captions the rows below it ("Recently visited") and doubles as the empty
* state, so it belongs above the list in both readings */
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. */
/* data-fs-chrome marks a zone-1 root (docs/third-party-apps.md): this overlay is parented to
* <body>, outside the <nav> that carries the mark in header.ut, so without it the fence does
* not cover the palette — the shape that once left the Appearance popover unfenced */
const ov = E('div', {
'id': 'fs-search-ov',
'class': 'fs-search-ov',
@@ -269,12 +247,11 @@ function wire() {
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. */
/* Warm the highlighted page's module chain, debounced: render() re-runs setActive(0) on every
* keystroke, so warming at once would pull the top result of "w", "wi", "wir"… Only arrow keys
* and typing need this — the rows are real anchors, so a mouse over one already reaches the
* router's pointerover listener. fs-router's warmClass() dedupes, so a row revisited costs
* nothing. */
let warmT = null;
function warmActive() {
if (warmT) window.clearTimeout(warmT);
@@ -302,9 +279,9 @@ function wire() {
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. */
/* role="option" on the <a> itself: an option may not contain an interactive element,
* and the anchor must stay a real link — it carries the click to the router and keeps
* middle-click and "copy link" working */
const a = E('a', {
'class': 'fs-search-opt',
'role': 'option',
@@ -315,8 +292,8 @@ function wire() {
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 */
/* close before the click reaches the router, which re-renders the chrome underneath;
* no focus return, the user is going elsewhere */
a.addEventListener('click', () => close(false));
a.addEventListener('pointermove', () => { if (at !== i) setActive(i); });
list.appendChild(a);
@@ -347,8 +324,8 @@ function wire() {
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. */
/* keys are handled on the overlay, not the input: a click on the scrim moves focus to the
* overlay itself, where Escape must still close */
ov.addEventListener('keydown', (ev) => {
switch (ev.key) {
case 'Escape':
@@ -368,14 +345,14 @@ function wire() {
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 */
/* a synthetic click carries detail 0, which the router reads as a keyboard
* activation, so 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. */
/* aria-modal="true" promises Tab cannot walk out into the page behind, and the input
* is the dialog's only tabbable element. Escape or an outside click is the way out,
* both handing focus back to the trigger. */
ev.preventDefault();
input.focus();
return;
@@ -387,16 +364,14 @@ function wire() {
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. returnFocus=false because the router places focus itself on a navigation. */
/* Back and Forward are navigations no listener above can see — every other close is a user act
* on the document — so without this an open palette rides a popstate onto the next page,
* aria-modal and Tab-trapped. returnFocus=false: the router places focus itself. */
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). */
/* Ctrl/Cmd+K and `/`, the two shortcuts users already have. `/` only when the user is not
* typing somewhere — an <input>, a contenteditable, or a .cbi-dropdown, where fs-select.js's
* typeahead reads it as a search character. */
document.addEventListener('keydown', (ev) => {
if (ev.defaultPrevented) return;
if ((ev.ctrlKey || ev.metaKey) && !ev.altKey && (ev.key === 'k' || ev.key === 'K')) {
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,43 +1,28 @@
'use strict';
'require baseclass';
/* ---- theme identity: the version footstrap SHIPS, and the repo it came from ----
/* The installed version, shown on the Appearance tab with no network call; which version is
* available is the package manager's question.
*
* 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'. */
* The Makefile (Build/Prepare), dev-sync.sh and tools/stage.sh sed the literal below BY FILE
* NAME, so it cannot move to another file without changing all three. An unstamped checkout
* 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 parentheses are load-bearing: jsmin's regex-vs-division test is a one-character lookback
* against a fixed allow-list, and `n` (the last letter of `return`) is not on it, so `return /re/`
* is read as a division and 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. */
* The sentinel is matched by shape, never by `FS_VERSION !== '0.0.0-dev'`: terser runs before the
* Makefile stamps the version, so that comparison folds to a constant and every release reports
* '(dev)' — an SDK build has no terser step, so it broke only in releases. A regex test is not
* folded. `-dev$` rather than `^\d+\.\d+\.\d+$`, because dev-sync.sh stamps `git describe`
* ('0.9.4-12-gabc1234'), which must count as a real version. */
function isReal() { return ((/^\d+\.\d+/).test(FS_VERSION)) && !((/-dev$/).test(FS_VERSION)); }
/* Two exports, and the two that were dropped are worth a line each so they are not re-added out of
* habit: `VERSION` (the bare string) and `isReal` had no caller anywhere in the theme or the
* templates — `label()` is what every reader actually wants, and it is the one place that decides
* how a dev build names itself. An export nobody imports is not free: it is a promise to keep the
* shape stable. */
/* Do not re-add `VERSION` or `isReal` as exports: neither had a caller, and an export nobody
* imports is a promise to keep the shape stable. */
return baseclass.extend({
/* the project's page: the link at the foot of the Appearance tab, and its only reader since
* the footer line was withdrawn. It was assembled from a separate FS_REPO constant that the
* retired self-update script needed to build API paths from, and nothing has needed the bare
* `owner/name` since. */
REPO_URL: 'https://github.com/VizzleTF/luci-theme-footstrap',
/* what the Appearance tab's version row prints */
label: () => (isReal() ? ('Footstrap v' + FS_VERSION) : 'Footstrap (dev)')
});
@@ -1,24 +1,14 @@
'use strict';
'require baseclass';
/* The theme's own UI primitives: the one inline-SVG wrapper, the disclosure pair the menu is built
* on, and the colour control the Appearance tab's nine colour axes are built on. Nothing here knows
* what it is being used FOR — that is the point, and it is why the menu and the Appearance tab can
* each take what they need without either requiring the other.
*
* A popup PLACEMENT helper lived here too, shared by the menu's dropdown clamp and the Appearance
* panel when that was a floating popover on <body>. The panel is a tab on System -> System now
* (fs-appearance.js) and places nothing, so the helper had one caller left and went with it; the
* edge gap it clamped against belongs to the menu, which is the only thing still clamping. */
/* The theme's UI primitives: the inline-SVG wrapper, the disclosure pair the menu is built on, and
* the colour control behind the Appearance tab's colour axes. Nothing here knows what it is used
* for, so the menu and the Appearance tab share it without requiring each other. */
/* The 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. */
/* The one inline-SVG wrapper: every theme icon is a 24x24 stroked outline differing only in path
* data, so stroke width and linecaps are stated once and cannot drift between call sites.
* aria-hidden, because each icon sits beside its own label — 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">'
@@ -26,20 +16,19 @@ function svgIcon(body, cls) {
}
/* ---- 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. */
* A section header is a W3C-APG disclosure control: an <a role="button"> owning a panel it shows
* and hides. The trigger selector stays a parameter. */
/* `.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`). */
/* Every open and close goes through here so `.open` and aria-expanded cannot disagree: `.open`
* alone tells a sighted user everything and a screen-reader user nothing. `linkSel` is the
* layout's trigger (the menu's `:scope > a`). */
function setOpen(li, on, linkSel) {
li.classList.toggle('open', on);
li.querySelector(linkSel)?.setAttribute('aria-expanded', on ? 'true' : 'false');
}
/* An <a role="button"> is given Enter by the browser but NOT Space, and a
* disclosure control has to answer both. */
/* 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;
@@ -48,20 +37,16 @@ function wireSpaceKey(link) {
});
}
/* 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. */
/* A click outside closes; WCAG 2.2 SC 1.4.13 also requires a hover/focus panel to be dismissible
* from the keyboard, with focus handed back to the trigger. `when` restricts both to flyout mode,
* where `.open` means "popup panel" — an unfolded accordion must not close on an outside click. */
function wireDismiss(opts) {
const active = () => (opts.when ? opts.when() : true);
document.addEventListener('click', (ev) => {
/* `closest?.` — a document-level listener sees 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. */
/* `closest?.`: a document-level listener sees any dispatched click, including one whose
* target is not an Element and has no closest(). The throw would kill this listener for
* the rest of the session. */
if (active() && !ev.target.closest?.(opts.inside))
opts.close();
});
@@ -76,70 +61,53 @@ function wireDismiss(opts) {
});
}
/* 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. */
/* The Appearance page draws enums with `ui.Select` and numbers with `ui.RangeSlider`: LuCI widgets
* are present on every supported release, already dressed by this stylesheet (`select` in
* base/30-forms.css, `.cbi-range-slider` in theme/60-inputs.css), and cannot be got wrong here.
* Do not re-add a segmented control or range wrapper of our own. */
/* ---- colour: reading what the page is ACTUALLY painted, and what that costs in contrast --------
/* ---- colour: reading what the page is actually painted ----
*
* 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.
* Two questions no stored value answers: what colour a role is right now (the palette's own while
* the axis is off — there is deliberately no copy of the palette in JS), and what contrast the
* user's colour lands at. Both are 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. */
* `getComputedStyle(root).getPropertyValue('--fs-accent')` answers neither: a custom property
* computes to the token stream after var() substitution, so `oklch(from … l c H)` comes back
* unevaluated. Setting the expression as a real `color` and reading it back makes the browser
* resolve it — relative colour, color-mix() and the tint's calc() are what the theme is made of.
* One hidden probe is reused; an element per query would thrash layout on every slider drag. */
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. */
/* EVERY DECLARATION IMPORTANT, and the reason is the same one fs-chrome.js's length probe
* carries (issue #19): this is an unmarked plain element in a document shared with
* `luci-app-*`, the sheet fence deliberately does not spare it, and an app's unlayered
* `span { color: … !important }` or `* { color: … !important }` outranks a layer and a plain
* inline style alike. A probe that loses its own colour reports the app's instead — and this
* probe's answer is written into the hex field, painted into the swatch and graded for
* contrast, so a stolen reading becomes the admin's saved axis on the next confirm. */
/* Off-screen rather than display:none, so the reading does not depend on a display:none
* element computing `color` in every engine. It has no text and no size, so it paints
* nothing.
*
* Every declaration is !important (issue #19): this is an unmarked element in a document
* shared with `luci-app-*`, and an app's unlayered `span { color: … !important }` outranks
* a layer and a plain inline style alike. A probe that loses its own colour reports the
* app's, which then becomes the admin's saved axis on the next confirm. */
_probe = E('span', { 'aria-hidden': 'true' });
_probe.style.cssText = 'position:fixed!important;left:-9999px!important;top:0!important;'
+ 'width:0!important;height:0!important;overflow:hidden!important;'
+ 'pointer-events:none!important;';
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. */
/* cleared first: an expression the engine rejects leaves the previous colour standing, which
* would report a stale answer as a fresh one */
_probe.style.setProperty('color', '');
_probe.style.setProperty('color', expr, 'important');
return getComputedStyle(_probe).color;
}
/* A computed colour -> [r,g,b] 0..255, or null. RASTERISED, never parsed out of the string, and that
* is not a preference: a computed `color` keeps the SPACE it was authored in, so an axis in hue mode
* comes back as `oklch(0.54 0.19 300)`. Read as numbers that is [0.54, 0.19, 300] — three values in
* the wrong units, clamped into a colour nobody chose — and every consumer here believed it: the hex
* field, the `<input type="color">` swatch beside it and the WCAG readout. Measured on the stand with
* `tint=120, accent=300`: a near-white green canvas whose real text contrast is ~16:1 reported #010078 and "Too
* faint to read", and confirming the swatch would have written that navy over the admin's own hue.
*
* Painting one pixel and reading it back asks the engine to do the conversion instead. It is the
* same method tools/export-tier.mjs uses, and for the same reason its header gives: "a color-mix()
* computes to whatever space it was written in, and `oklch(L C H)` has three numbers that parse
* perfectly well — silently and wrongly — as an rgb() triple". The parse survives only as the
* fallback for an engine with no 2D context, where the legacy `rgb()`/`color(srgb …)` forms are all
* that can appear anyway. */
/* A computed colour -> [r,g,b] 0..255, or null. Rasterised, not parsed: a computed `color` keeps
* the space it was authored in, so `oklch(0.54 0.19 300)` would parse as three numbers in the
* wrong units and produce a colour nobody chose — measured: #010078, graded "Too faint to read",
* in the hex field, the swatch and the contrast readout alike. Painting one pixel makes the engine
* convert instead (tools/export-tier.mjs uses the same method). The string parse remains only as
* the fallback for an engine with no 2D context, where only the legacy `rgb()`/`color(srgb …)`
* forms can appear. */
let _cx = null;
function rasterCtx() {
if (_cx !== null) return _cx;
@@ -154,8 +122,8 @@ function parseColor(s) {
const str = String(s || '');
const cx = rasterCtx();
if (cx) {
/* fillStyle KEEPS the last value it could parse, so a colour this engine rejects would report
* the previous one as a fresh reading — the same trap probeColor() clears its own style for. */
/* fillStyle keeps the last value it could parse, so a colour this engine rejects would
* report the previous one as a fresh reading — the trap probeColor() clears for */
cx.fillStyle = '#000';
cx.fillStyle = str;
cx.clearRect(0, 0, 1, 1);
@@ -170,10 +138,9 @@ function parseColor(s) {
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). */
/* WCAG 2.x relative luminance and contrast ratio, on sRGB. Used only to report: the theme states
* what a colour costs and leaves the choice with the user, never correcting it (03-palettes.css
* derives the ink over a fill, which is a different question). */
function luminance(rgb) {
const c = rgb.map((v) => {
const x = v / 255;
@@ -188,36 +155,30 @@ function contrastRatio(fgExpr, bgExpr) {
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. */
/* #rrggbb, because <input type="color"> accepts nothing else. An unparseable colour becomes black
* rather than throwing: the text field beside the swatch is the authoritative one. */
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.
/* One colour axis: a native swatch, a hex field and a button back to the palette's own colour.
* Reports through onPick as a hex string, or 0 for "back to the palette", either of which the
* caller hands 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.
* There is no hue slider and one is not coming back: rotating a hue keeps the palette's chroma,
* so no angle of it reaches a grey. The axis still accepts a stored hue (1–360) and the stylesheet
* still rotates the palette by one, so a saved value goes on working.
*
* `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. */
* `opts.probe` is the live token the effective colour is read back from, so the field shows the
* palette's colour while the axis is off without a copy of the palette in JS. `opts.contrast` is
* the pair whose ratio is reported under the row. */
function colorControl(current, onPick, label, opts) {
const o = opts || {};
/* 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. */
/* type=color leaves the picker to the browser: accessible without reimplementing a colour
* wheel, and native on a phone. The text field beside it takes a pasted hex and is the
* fallback where the browser draws no picker. */
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',
@@ -226,42 +187,33 @@ function colorControl(current, onPick, label, opts) {
const clear = E('button', { 'class': 'btn fs-color-clear', 'type': 'button' }, [ _('Palette', 'footstrap') ]);
const ratio = o.contrast ? E('div', { 'class': 'cbi-value-description fs-color-contrast' }) : null;
/* what the axis holds RIGHT NOW — the 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. */
/* what the axis holds right now: the page can change it behind this control (a preset, Reset
* to default), so a private copy would go stale. `current` is only the build-time value. */
const currentOf = o.read || (() => current);
/* Repaint everything that MIRRORS the axis 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. */
/* Repaint everything that mirrors the axis. Called after every edit, and through the returned
* refresh() after a preset, palette switch or dark-mode flip — each changes what the palette's
* own colour is while this axis stays off. */
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. */
/* do not fight the user mid-edit: `#0` is a legal prefix, 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. */
/* the button back to the palette doubles as the axis state readout: enabled means the axis
* holds a colour of its own, disabled means the field shows 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 readout states what the ratio means; the number itself stays in the title.
* Thresholds are WCAG AA: 4.5:1 for body text, 3:1 for large text and for a UI shape, so a
* hairline is graded on the second (`kind: 'shape'`) and warns rather than fails — a faint
* border is a legitimate choice.
*
* 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. */
* Class names are written out whole: tools/fs-orphans.mjs sweeps dead CSS by matching
* fs-* tokens in the source, and a concatenated name is invisible to it. */
const where = o.contrast.label;
const grade = (o.contrast.kind === 'shape')
? ((r >= 3)
@@ -274,7 +226,6 @@ function colorControl(current, onPick, label, opts) {
: { 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');
}
@@ -282,10 +233,9 @@ function colorControl(current, onPick, label, opts) {
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. */
/* commit on blur and Enter, not per keystroke: a half-typed `#0096` would repaint the page
* under the cursor. An unparseable value snaps back to what the axis holds, so the field
* cannot 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);
@@ -298,8 +248,8 @@ function colorControl(current, onPick, label, opts) {
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 */
/* the caller decides when this runs: probeColor() needs the document, and this control is not
* in it yet */
wrap.fsRefresh = () => reflect(currentOf());
return wrap;
}
@@ -9,25 +9,16 @@
'require fs-sheets as sheets';
'require fs-search as search';
/* PAGE MODULES: TWO OF THESE MODULES ARE FOR ONE PAGE EACH, AND USED TO SHIP WITH EVERY PAGE.
/* Page modules: `fs-appearance` (System -> System) and `fs-overview` (Status -> Overview) each
* serve one page and are required only on it. A `require` pragma would make them a hard
* dependency — luci.js fetches and evaluates before this factory runs — costing 11.5 KB and 3.8 KB
* after terser on every admin page that has neither.
*
* `fs-appearance` draws the Appearance controls on System -> System and `fs-overview` reshapes
* Status -> Overview; each watches `body[data-page]` and does nothing anywhere else, which is the
* right design — a theme may not register a dispatcher node, so it cannot own a route. But they
* were `require`d in the directive prologue, and a pragma is a hard dependency: luci.js fetches and
* evaluates them before this file's factory runs, on EVERY admin page. Measured after terser: 11.5
* KB for the Appearance panel and 3.8 KB for the Overview one, on every cold visit to a page that
* has neither.
* Each module keeps its own `body[data-page]` observer; `wire()` re-checks the page
* synchronously, so a module arriving after the stamp still starts watching.
*
* So the pragma is replaced by the same observation they were doing for themselves, once, here: on
* the page they belong to — and only there — the module is required and wired. What each of them
* then does is unchanged, including its own observer: `wire()` re-checks the page synchronously, so
* a module that arrives after the stamp still starts watching. The dependency EDGE is what moved,
* not the behaviour.
*
* The map duplicates a page name that also lives inside each module, and `npm run page-modules`
* derives both sides and fails if they drift — the same trade as the Appearance axes, which are
* implemented twice on purpose and held by a gate. */
* The map duplicates a page name that also lives inside each module; `npm run page-modules`
* derives both sides and fails on drift. */
const PAGE_MODULES = {
'admin-system-system': 'fs-appearance',
'admin-status-overview': 'fs-overview'
@@ -40,8 +31,8 @@ function wirePageModules() {
const name = PAGE_MODULES[document.body.getAttribute('data-page') || ''];
if (!name || _pageModules.has(name)) return;
_pageModules.set(name, RT.require(name).then((m) => m.wire()).catch((e) => {
/* a page module that will not load costs its own page's extras and nothing else, so it
* is dropped from the map and retried the next time that page comes up */
/* a failed page module costs only its own page's extras: drop it and retry the next
* time that page comes up */
_pageModules.delete(name);
console.error('footstrap: ' + name + ' did not load', e);
}));
@@ -51,31 +42,26 @@ function wirePageModules() {
load();
}
/* THE THREE TEMPLATE GLOBALS STATUS→OVERVIEW NEEDS, DEFINED WHERE ORDER IS GUARANTEED.
/* The three template globals Status -> Overview needs, defined where ordering is guaranteed.
*
* `admin_status/index.ut` defines `progressbar`, `renderBox` and `renderBadge` in an inline script
* and then instantiates `view.status.index`; the stock includes (18_cpu, 30_network, 60_wifi…) call
* them bare from their own `render()`. An SPA arrival never runs that inline script, so this theme
* is their only definition — and a definition that arrives late is a `ReferenceError` thrown from a
* stock include on a page already committed to the document.
* the stock includes (18_cpu, 30_network, 60_wifi…) call bare from their own `render()`. An SPA
* arrival never runs that script, so the theme is their only definition — and a late definition is
* a `ReferenceError` from a stock include on a page already committed to the document.
*
* THEY LIVE HERE RATHER THAN IN `fs-overview.js` FOR EXACTLY ONE REASON: ordering. While that module
* was in the directive prologue it evaluated at chrome init, i.e. before any navigation could
* happen, and the guarantee was free. It is a page module now (PAGE_MODULES above) — required
* DURING the navigation that needs it, in a chain that races the router's own require of the view
* class. fs-overview's chain is the shorter of the two and should win, but nothing orders them, and
* losing costs the page. This file is required by the footer on every page and evaluates before the
* router exists, so moving the ~40 dependency-free lines here restores the guarantee at a cost of
* their own bytes; the 3.8 KB of Overview layout code stays on the Overview.
* They live here rather than in `fs-overview.js` because a page module is required DURING the
* navigation that needs it, racing the router's own require of the view class with nothing
* ordering the two. This file is required by the footer on every page and evaluates before the
* router exists.
*
* Bodies are verbatim from upstream except L.itemlist → window.L.itemlist (the two-L trap,
* docs/spa-router.md), and the typeof guards make every one a no-op on a full page load, where the
* template's own copies win the race — any theme, as before. */
* Bodies are verbatim from upstream except L.itemlist -> window.L.itemlist (the two-L trap,
* docs/spa-router.md). The typeof guards make each a no-op on a full page load, where the
* template's own copies win the race. */
function ensureOverviewHelpers() {
/* eslint-disable no-var -- these three bodies are copied VERBATIM from LuCI's
/* eslint-disable no-var -- these three bodies are verbatim copies of 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. */
Modernising the `var`s would break that property, which is what makes carrying
the copies safe. */
if (typeof window.progressbar !== 'function')
window.progressbar = function(query, value, max, byte) {
var pg = document.querySelector(query),
@@ -109,11 +95,10 @@ function ensureOverviewHelpers() {
/* eslint-enable no-var */
}
/* Unconditional: the typeof guards make it a no-op wherever the real definitions already exist. */
ensureOverviewHelpers();
/* 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:
/* Chrome bootstrap: load the menu tree once, hand it to the parts that need it and wire them in
* 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
@@ -123,28 +108,23 @@ ensureOverviewHelpers();
* 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)
* fs-overview the overview grid — a theme module, not a luci-mod-status include
* fs-version the shipped version string
*
*
* 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. */
* They compose by calling, never by inheriting: LuCI makes every required module a singleton, so
* `base.extend` across modules throws (docs/conventions.md). Hence the main menu arriving as a
* callback — menu-footstrap.js injects renderMainMenu rather than overriding a method. A
* require() cycle raises DependencyError, so the graph is a DAG by construction and the shared
* halves (fs-menutree, fs-prefs) are separate modules. */
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. */
/* First, and outside the promise: a third-party sheet that outranks the chrome is already
* painting (fs-sheets: openclash's `* { margin: 0; padding: 0 }`). Deferring this to
* ui.menu.load() extends the broken frame by a round trip, or forever when the .catch()
* below swallows a menu failure. */
sheets.watchViewSheets();
prefs.guardDarkStamp(); /* a third party stamping :root — same shape, different vector */
prefs.guardDarkStamp(); /* same, for a third party stamping :root */
ui.menu.load().then((menu) => {
tree.setTree(menu);
@@ -154,27 +134,22 @@ return baseclass.extend({
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 */
* tables use: re-run on every #view resize and on content mutations */
fit.add(chrome.fitChrome);
chrome.renderChrome();
/* after setTree(): the palette indexes that tree — lazily, on its first open, but its
* recent-pages list is recorded from the first navigation onwards */
/* after setTree(): the palette indexes that tree on first open, and records recent
* pages 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 the page modules key off. Wiring the 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. */
/* before router.wire(): the router restamps body[data-page] on every SPA navigation,
* and that attribute is what the page modules key off */
wirePageModules();
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 tab
* together, silently. It still fails — there is no sane partial recovery — but loudly. */
/* no sane partial recovery — a throw above loses the menu, the router and the Appearance
* tab together — so this fails loudly rather than silently */
}).catch((e) => console.error('footstrap: chrome init failed', e));
}
});
@@ -6,20 +6,17 @@
'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, Appearance tab) 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 */
/* The theme's one menu renderer: a vertical #topmenu that the CSS also turns into the top bar and
* the rail flyouts — same markup, no second renderer. Disclosure primitives come from fs-widgets,
* the auto-collapse preference from fs-prefs; the rest of the chrome is bootstrapped by
* menu-footstrap-common, which this file composes with by injecting renderMainMenu into
* common.init — a callback, not an override, since a required LuCI module is a singleton and
* cannot be subclassed. Spec: docs/chrome.md */
/* 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. */
/* Null prototype: `ICONS[key]` is keyed by a menu node name, which a third-party package picks in
* its own menu.d. On a plain literal a node called `constructor` or `__proto__` resolves out of
* Object.prototype to a truthy non-string ('[object Object]' for the latter), skips the
* `|| ICONS._default` fallback and is concatenated into link.innerHTML. */
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"/>',
@@ -42,17 +39,15 @@ function iconSvg(name) {
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. */
/* `.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? Reads
* the same input as the stylesheet: `data-narrow`, stamped by fitShell() in fs-chrome.js before
* the menu renders. Never a viewport breakpoint — the CSS turns the sidebar into a bar when the
* content column drops below --fs-content-min, so a media query leaves a band of widths where the
* chrome is a bar and the menu still believes it is an accordion — a `max-width: 767px` media
* query left exactly that band at 770-775px. */
function flyoutMode() {
return prefs.currentRail() || prefs.isTopLayout() ||
document.documentElement.hasAttribute('data-narrow');
@@ -62,19 +57,16 @@ function flyoutMode() {
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. */
/* ---- dropdown edge-clamp (the bar, at every width) ----
* A bar panel hangs off its own item (li position:relative, ul left:0 — theme/20-shell.css), so an
* item near the right edge would push its panel past the viewport. The rail flies panels out
* sideways and needs a different placement, so it is excluded below. */
/* how close the panel may come to the viewport edge before it is nudged back in */
const EDGE_GAP = 8;
/* 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. */
/* Is this panel a bar dropdown (anchored under its item) rather than a rail flyout (anchored
* beside it)? Same input as the stylesheet: `data-narrow` turns the sidebar into a bar and also
* disables the rail (its rules are scoped `:not([data-narrow])`), so a narrow window is a bar even
* with the rail on. Gating on isTopLayout() alone leaves such a panel unclamped (issue #19). */
function barDropdown() {
return prefs.isTopLayout() || document.documentElement.hasAttribute('data-narrow');
}
@@ -83,9 +75,8 @@ function clampDropdown(li) {
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. */
/* one pending measure per item: sweeping the pointer across the bar otherwise queues a
* write-then-read of layout per item crossed, none cancelled once the pointer moves on */
if (li._fsClampRaf) window.cancelAnimationFrame(li._fsClampRaf);
li._fsClampRaf = window.requestAnimationFrame(() => {
li._fsClampRaf = 0;
@@ -93,15 +84,15 @@ function clampDropdown(li) {
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 */
/* measured after a frame: on pointerenter the :hover rule revealing the panel has not
* applied yet, so it still measures 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. */
/* a nudge computed for the old width, or for a bar we have since left, is wrong: drop it and let
* the next hover/tap recompute */
function clearClamps() {
document.querySelectorAll('#topmenu ul').forEach((m) => { m.style.left = ''; });
}
@@ -116,11 +107,10 @@ function closeFlyouts(except) {
});
}
/* 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. */
/* Restore the accordion after leaving flyout mode (rail expanded, window grew). closeFlyouts() is
* right going in — a stuck popup is worse than a folded section — but wrong coming out: the markup
* is not rebuilt on a rail toggle, so nothing would re-apply the remembered set and "Keep open"
* would mean nothing. */
function restoreAccordion() {
const auto = prefs.currentAutoCollapse();
document.querySelectorAll('#topmenu > li.has-sub').forEach((li) => {
@@ -129,15 +119,13 @@ function restoreAccordion() {
});
}
/* 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). */
/* Which top-level sections are unfolded, by node name: renderChrome() rebuilds #topmenu on every
* SPA nav, so a section the user opened would otherwise refold on every tab switch. Only consulted
* in the expanded sidebar with auto-collapse off. Persisted in localStorage because a module-level
* Set does not survive a full page load, and many LuCI pages are not SPA-able. */
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. */
/* prefs.lsGetArr owns the parse, the corruption guard and the Array check; a Set is used because
* membership is the only question asked of it */
function loadOpenSections() {
return new Set(prefs.lsGetArr(OPEN_KEY));
}
@@ -159,8 +147,8 @@ function renderMainMenu(tree, url, level) {
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 */
/* the chrome carries its own Logout entry (partials/logout.ut), so the tree's
* top-level admin/logout node would show up twice */
if (!level && child.name === 'logout')
return;
@@ -181,9 +169,9 @@ function renderMainMenu(tree, url, level) {
? '<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. */
/* `active` only paints the item; aria-current is what a screen reader reads, and it
* belongs on the LEAF alone — 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' : '',
@@ -209,21 +197,18 @@ function renderMainMenu(tree, url, level) {
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 */
/* for restoreAccordion(): the remembered set is 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
/* 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. */
* take on the menubar pattern's arrow-key semantics.
*
* The id must be injective: node names come from third-party menu.d files and may
* differ only in punctuation, and folding them (`[^a-z0-9]+` -> '-') makes two
* triggers' aria-controls resolve to the same panel. Escaping to the code point
* keeps the ordinary all-alphanumeric case readable and cannot collide. */
const subId = 'fs-sub-' +
String(child.name).replace(/[^a-z0-9]/gi, (c) => '_' + c.charCodeAt(0).toString(16)) +
'-' + idx;
@@ -235,16 +220,16 @@ function renderMainMenu(tree, url, level) {
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 */
/* flyout panels are exclusive, but must not touch the remembered set: it
* mirrors the desktop "Keep open" state, which one tap on a phone would
* otherwise wipe */
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
/* the sidebar accordion folds the others back only when asked
* (Appearance -> Submenus) */
if (prefs.currentAutoCollapse()) { closeFlyouts(); _openSections.clear(); }
setOpen(li, !open);
@@ -256,9 +241,9 @@ function renderMainMenu(tree, url, level) {
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
/* hybrid devices: once a real mouse enters, drop the tap-opened panel so hover is
* authoritative and two panels never stack. Guarded on pointerType, since a touch
* tap fires pointerenter ('touch') before the click and clearing there would break
* tap-to-close. */
li.addEventListener('pointerenter', (ev) => {
if (ev.pointerType === 'mouse' && flyoutMode())
@@ -279,8 +264,8 @@ 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. */
/* 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',
@@ -289,34 +274,31 @@ return baseclass.extend({
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().
/* Entering flyout mode folds everything, or a section left open as an accordion
* reappears as a popup stuck on screen; leaving it restores the accordion.
*
* 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
* 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. */
* toggling the layout live is the same transition and 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. */
/* data-narrow is the third attribute flyoutMode() reads (fitShell() writes it): without
* it, narrowing the window turns the sidebar into a bar with no transition handler
* running and the accordion still unfolded inside it */
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. */
/* No media-query listener: the top bar is measured at every width (fitChrome), so no
* breakpoint flips the chrome — the attribute observer and the resize clamp-clear
* below cover every transition. */
/* 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.
*
* WIDTH ONLY, for the reason fs-fit's observer states at length: every browser on iOS
* fires `resize` continuously while the URL bar slides away, and a clamp computed for a
* width that did not change is work done on a page the user is scrolling. */
* because resize fires dozens of times a second while a window is dragged. Width only:
* iOS fires `resize` continuously while the URL bar slides away, and re-clamping for an
* unchanged width is work done on a page the user is scrolling. */
let lastWidth = window.innerWidth;
const reclamp = fit.frame(clearClamps);
window.addEventListener('resize', () => {
@@ -325,14 +307,11 @@ return baseclass.extend({
reclamp();
});
/* 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. */
/* Appearance -> Submenus -> auto-collapse: fs-prefs.js owns the stored value and only
* announces the change; the remembered set, `.open` and aria-expanded are all ours.
* restoreAccordion() is already "apply the current setting" — with the set cleared and
* auto on it opens the active section alone. Skipped in flyout mode, where force-opening
* the active section would leave a popup stuck on screen. */
document.addEventListener('fs-autocollapse', (ev) => {
if (ev.detail && ev.detail.on) {
_openSections.clear();