mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-01 15:13:56 +08:00
This commit is contained in:
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();
|
||||
|
||||
Reference in new issue
Block a user