mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-09 21:47:59 +08:00
🌴 Sync 2026-08-05 03:17:09
This commit is contained in:
1 parent
3c5b341e92
commit
d96dc08674
91 files changed
+18955
-44
No files matched your search
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-9 -1 100 100">
|
||||
<!-- The OpenWrt mark, and it stays OpenWrt's on purpose: the tab icon says which DEVICE this
|
||||
is, not which theme is painting it. What it does not need is the flat cyan tile the mark
|
||||
used to be pasted on — that tile was the "outdated" part (issue #7), and it fought every
|
||||
browser's tab strip. Transparent now, so the icon sits in whatever the browser draws.
|
||||
|
||||
Colours are the mark's own: cyan arcs, dark-blue ring. On a DARK tab strip that ring is
|
||||
nearly invisible, so it lightens under prefers-color-scheme — an SVG favicon is a live
|
||||
document and both Chrome and Firefox honour the query. logo_48.png cannot: it is the
|
||||
fallback for browsers with no SVG favicon support, and it carries the light variant. -->
|
||||
<style>
|
||||
@media (prefers-color-scheme: dark) { .ring { fill: #e6f4fb } }
|
||||
</style>
|
||||
<path d="m40.5 50.8a6.6 6.6 0 1 0 0 13.2 6.6 6.6 0 0 0 0-13.2m-40.5-33.9 7 6.8a47.5 47.5 0 0 1 67.2 0l7-6.8a57.2 57.2 0 0 0-81.2 0" fill="#00b5e2"/>
|
||||
<path d="m12.5 29.2 6.8 7a30 30 0 0 1 42.6 0l6.8-7a39.7 39.7 0 0 0-56.3 0" fill="#00b5e2"/>
|
||||
<path d="m24.8 41.6 6.8 6.9a12.6 12.6 0 0 1 18 0l6.8-6.9a22.3 22.3 0 0 0-31.6 0" fill="#00b5e2"/>
|
||||
<path class="ring" d="m64.9 39.7a30.2 30.2 0 1 1-48.7 0l-6.9-7a39.9 39.9 0 1 0 62.5 0z" fill="#002b49"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 320 B |
@@ -0,0 +1,781 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require ui';
|
||||
'require dom';
|
||||
'require fs-prefs as prefs';
|
||||
'require fs-widgets as widgets';
|
||||
'require fs-version as ver';
|
||||
|
||||
/* The Appearance CONTROLS: the DOM that presents the axes. It owns no preference — fs-prefs.js
|
||||
* holds the axes, fs-version.js the version string; this file is the form they are shown in.
|
||||
*
|
||||
* WHERE IT LIVES. It used to be a popover hanging off a button in the chrome; it is now a TAB on
|
||||
* System -> System (admin/system/system), beside General Settings / Logging / Time Synchronization
|
||||
* / Language and Style — the page an admin already opens to set the things that are not network. The axes had outgrown a floating panel — eighteen of them, nine
|
||||
* carrying a colour field, a swatch and a contrast readout — and a dialog that has to trap Tab,
|
||||
* place itself against a viewport edge and stay inside a 320px column is the wrong container for
|
||||
* that. Keeping BOTH would have meant every axis rendered twice, which is the failure this file's
|
||||
* own history is made of.
|
||||
*
|
||||
* It is appended by a MutationObserver rather than by a route of its own, and that is the same
|
||||
* boundary fs-overview.js sits on: a THEME may not own a dispatcher node, because a node outlives
|
||||
* the theme that registered it — switch to another theme and the menu keeps an entry whose view is
|
||||
* gone. So the theme owns no menu.d and no view; it watches for the stock page and adds one
|
||||
* section to it, additively, and removes nothing. Off the footstrap theme, or on any other page,
|
||||
* nothing runs at all.
|
||||
*
|
||||
* THE VERSION LINE MAKES NO REQUEST and must not grow one. Which version is INSTALLED is what this
|
||||
* page answers; which version is available is the package manager's question, and a theme polling a
|
||||
* release API to re-answer it from a settings page is the wrong shape twice over — it reaches the
|
||||
* network from a page that has no business doing so, and it reimplements `apk upgrade`. */
|
||||
|
||||
/* THE COLOUR PRESETS WERE HERE — eight chips that wrote the Accent axis, each painted in the
|
||||
* colour it would set. They are gone, and what they were for is not: the request behind them
|
||||
* (#20, "the blue theme is cool but sometimes you want grey or black") is answered by the Accent
|
||||
* row itself, which takes any #rrggbb and sits three rows below where the chips used to be. A
|
||||
* preset only ever wrote that one axis, so the chips were a second, prettier way to do the thing
|
||||
* the field already does — and they were the one control on this page that looked like nothing
|
||||
* else in LuCI: a bare row of coloured pills starting at the card's edge rather than at the field
|
||||
* column, which is what made the tab read as ragged. */
|
||||
|
||||
/* Build the whole form. Returns a promise for one element wire() appends to the stock page.
|
||||
*
|
||||
* Everything applies IMMEDIATELY — there is no Save button for the axes themselves, because there
|
||||
* is nothing to save: every axis is this browser's, in localStorage, and the page repaints under
|
||||
* the control as it moves. The one button that writes anything is "Save as default", which pushes
|
||||
* the current look to the ROUTER for other browsers. That distinction is the whole model
|
||||
* (docs/design-system.md) and it is why this page has no Save/Reset footer of LuCI's own. */
|
||||
function render() {
|
||||
/* still a promise: the view awaits it, and keeping the shape means a future asynchronous step
|
||||
* (or the caller) needs no change. */
|
||||
return Promise.resolve(build());
|
||||
}
|
||||
|
||||
function build() {
|
||||
/* every saved axis re-checks the Save button after it applies, so the button greys the moment
|
||||
* this browser matches the saved default again and un-greys the moment it diverges. Wrapped
|
||||
* around the appliers because the seg/slider/colour controls call them directly and have no
|
||||
* other seam back to here. refreshSave is a hoisted function declaration; saveBtn it reads is
|
||||
* assigned below, before any of these fire (all are user events). */
|
||||
const bump = (fn) => (v) => { fn(v); refreshSave(); };
|
||||
|
||||
/* Every colour control mirrors something it does not own — the PALETTE's colour, while its own
|
||||
* axis is off, and the contrast that colour lands at. A palette switch, a dark-mode flip or a
|
||||
* preset changes all of that under controls nobody touched, so they are refreshed together
|
||||
* rather than each listening for what might have moved. */
|
||||
const colourCtls = [];
|
||||
const refreshColours = () => colourCtls.forEach((c) => c.fsRefresh());
|
||||
/* wrap an applier so the colour readouts follow it: mode and palette change what every axis is
|
||||
* measured against, and a preset changes the axes themselves */
|
||||
const repaint = (fn) => (v) => { fn(v); refreshColours(); };
|
||||
|
||||
/* One captioned row, in LuCI's OWN row shape: `.cbi-value` > `label.cbi-value-title` +
|
||||
* `.cbi-value-field`. This tab used to draw its own two-column grid of stacked cards — an
|
||||
* uppercase eyebrow above each control — which made the theme's settings the one page in LuCI
|
||||
* that did not look like LuCI. Sitting beside General Settings / Logging / Time Synchronization /
|
||||
* Language and Style, the odd one out was ours.
|
||||
*
|
||||
* Nothing here styles those class names: `.cbi-value` is stock, base/30-forms.css and
|
||||
* theme/60-inputs.css already lay it out (title column, field column, hairline under each row),
|
||||
* so this page now inherits every future fix to the form layout instead of keeping a private
|
||||
* copy of it. That also means the row lives on a SHARED surface — Zone 2, where a third-party
|
||||
* app is entitled to win on specificity — which is exactly right for a page rendered inside
|
||||
* #view rather than for chrome.
|
||||
*
|
||||
* `make` is handed the SAME label string the caption renders, because every control in here
|
||||
* needs it a second time as its aria-label (segControl/sliderControl/colorControl take it as
|
||||
* their last argument) — and stating it twice is how the visible caption and what a screen
|
||||
* reader announces drift apart. One literal per axis, used by both, with nothing to keep in
|
||||
* sync. `extra` is for the rows that carry more than a control (the Save row's error line),
|
||||
* `opts.cls` for the rows CSS has to be able to single out. */
|
||||
const group = (label, make, opts) => {
|
||||
const o = opts || {};
|
||||
return E('div', { 'class': 'cbi-value' + (o.cls ? ' ' + o.cls : '') }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, [ label ]),
|
||||
E('div', { 'class': 'cbi-value-field' }, [ make(label) ].concat(o.extra || []))
|
||||
]);
|
||||
};
|
||||
|
||||
/* ---- the CONTROLS are LuCI's own, not this theme's ------------------------------------------
|
||||
*
|
||||
* Every enum axis is a `ui.Select` and every number is a `ui.RangeSlider` — the same widgets the
|
||||
* form on the other tabs is built from, so a dropdown here is the dropdown an admin already knows
|
||||
* and the theme's own stylesheet already dresses (`select` in base/30-forms.css,
|
||||
* `.cbi-range-slider` in theme/60-inputs.css). Both classes exist on the whole range this theme
|
||||
* supports — checked against luci's own openwrt-24.10 branch, not only against master.
|
||||
*
|
||||
* What this replaces is two primitives of ours: a segmented radiogroup with a roving tabindex and
|
||||
* a range wrapper with a live readout. They were written when this page was a floating popover
|
||||
* and a `<select>` inside it read as a hole in the card. On a page there is no such argument, and
|
||||
* a control LuCI maintains is one this theme cannot get wrong on its own.
|
||||
*
|
||||
* THE EVENT IS `widget-change`, dispatched by UIElement, not a listener on the inner element —
|
||||
* that is the seam the widget publishes, and reaching past it to the `<select>` would tie us to
|
||||
* how it happens to be built today. RangeSlider also emits `widget-update` while the handle
|
||||
* moves, which is what makes the tile resize UNDER the drag rather than on release; both are
|
||||
* wired, and the appliers are idempotent so the pair costs nothing. */
|
||||
const selectCtl = (current, choices, apply, label) => {
|
||||
const w = new ui.Select(String(current), choices, { widget: 'select', sort: Object.keys(choices) });
|
||||
const node = w.render();
|
||||
node.setAttribute('aria-label', label);
|
||||
node.addEventListener('widget-change', () => apply(w.getValue()));
|
||||
return node;
|
||||
};
|
||||
const sliderCtl = (current, min, max, apply, label, opts) => {
|
||||
const o = opts || {};
|
||||
const w = new ui.RangeSlider(String(current), {
|
||||
min: min, max: max, step: o.step || 1, calcunits: o.unit || null
|
||||
});
|
||||
const node = w.render();
|
||||
node.setAttribute('aria-label', label);
|
||||
const push = () => apply(parseInt(w.getValue(), 10));
|
||||
node.addEventListener('widget-update', push);
|
||||
node.addEventListener('widget-change', push);
|
||||
return node;
|
||||
};
|
||||
|
||||
/* one colour axis: the shared shape of the rows below. `probe` is the live token the control
|
||||
* reads the effective colour back from, `contrast` the pair it reports. */
|
||||
const colourGroup = (label, axis, probe, contrast, opts) => group(label, (lbl) => {
|
||||
const ctl = widgets.colorControl(axis.current(), bump(axis.apply), lbl, {
|
||||
probe: probe,
|
||||
read: axis.current,
|
||||
contrast: contrast,
|
||||
cls: (opts && opts.cls) || ''
|
||||
});
|
||||
colourCtls.push(ctl);
|
||||
return ctl;
|
||||
}, opts);
|
||||
|
||||
/* EVERY LABEL IN HERE CARRIES THE 'footstrap' CONTEXT (`_(str, ctx)`, key `ctx\1str`). LuCI
|
||||
* serves ONE MERGED catalogue — load_catalog() loads every *.<lang>.lmo in
|
||||
* /usr/lib/lua/luci/i18n and a lookup returns the first archive holding the hash — so a msgid is
|
||||
* a GLOBAL name shared with every luci-app, and readdir order picks the winner: the layout
|
||||
* toggle rendered "Максимум" on a Russian router (issue #6), because another catalogue
|
||||
* translates the msgid "Top" as "maximum". Contexting cannot be selective — whatever we leave
|
||||
* bare is a name anyone may take. The chrome and the login/notice sentences are deliberately
|
||||
* bare (inheriting luci-base's translation is a feature in the ~40 languages we have no
|
||||
* catalogue for), as are System/Memory/Storage in fs-overview.js, which MATCH the stock
|
||||
* headings. */
|
||||
|
||||
/* ---- section 1: the shell ---- */
|
||||
const shell = [
|
||||
group(_('Layout', 'footstrap'), (label) => selectCtl(prefs.currentLayout(), {
|
||||
sidebar: _('Sidebar', 'footstrap'),
|
||||
top: _('Top', 'footstrap')
|
||||
}, bump(prefs.applyLayout), label)),
|
||||
|
||||
group(_('Theme', 'footstrap'), (label) => selectCtl(prefs.currentMode(), {
|
||||
auto: _('Auto', 'footstrap'),
|
||||
light: _('Light', 'footstrap'),
|
||||
dark: _('Dark', 'footstrap')
|
||||
}, bump(repaint(prefs.applyMode)), label)),
|
||||
|
||||
group(_('Palette', 'footstrap'), (label) => selectCtl(prefs.currentPalette(), {
|
||||
footstrap: 'Footstrap',
|
||||
hicontrast: 'Hi-Contrast',
|
||||
/* names the OTHER package, luci-theme-bootstrap, whose colours this palette is —
|
||||
* so it is a proper noun and stays untranslated, like the two above it */
|
||||
bootstrap: 'Bootstrap'
|
||||
}, bump(repaint(prefs.applyPalette)), label)),
|
||||
|
||||
/* Density: how much air the UI uses. Pure token axis — 02-tokens.css multiplies the type and
|
||||
* space ladders, so every size, gap and padding in the theme follows at once. */
|
||||
group(_('Density', 'footstrap'), (label) => selectCtl(prefs.currentDensity(), {
|
||||
compact: _('Compact', 'footstrap'),
|
||||
normal: _('Normal', 'footstrap'),
|
||||
large: _('Large', 'footstrap')
|
||||
}, bump(prefs.applyDensity), label)),
|
||||
|
||||
group(_('Rounding', 'footstrap'),
|
||||
(label) => sliderCtl(prefs.currentRadius(), 0, 20, bump(prefs.applyRadius), label, { unit: 'px' })),
|
||||
|
||||
/* The top layout has no accordion (its sections are hover dropdowns, already exclusive), so
|
||||
* this switch is meaningless there. ALWAYS BUILT, HIDDEN BY CSS (:root[data-layout="top"]
|
||||
* .fs-ap-submenus). Do NOT put an `if (currentLayout() !== 'top')` around it: the page is
|
||||
* built once, so the branch would freeze the control to the layout the page LOADED in — it
|
||||
* would stay on screen after a switch to the bar and never appear after a switch away from
|
||||
* it. Toggling the layout re-renders nothing; CSS morphs the chrome. */
|
||||
group(_('Submenus', 'footstrap'), (label) => selectCtl(
|
||||
prefs.currentAutoCollapse() ? 'on' : 'off', {
|
||||
off: _('Keep open', 'footstrap'),
|
||||
on: _('Auto-collapse', 'footstrap')
|
||||
}, bump(prefs.applyAutoCollapse), label),
|
||||
{ cls: 'fs-ap-submenus' })
|
||||
];
|
||||
|
||||
/* ---- section 2: colours ---- */
|
||||
const colours = [
|
||||
/* the caption says what the axis is FOR: "Tint" alone reads as decoration and nobody would
|
||||
* look for the router-identity cue under it. */
|
||||
colourGroup(_('Tint (router identification)', 'footstrap'), {
|
||||
current: prefs.currentTint, apply: prefs.applyTint
|
||||
}, 'var(--fs-bg)', {
|
||||
/* the canvas is the one axis with no derived ink: its text is --fs-text, a palette token
|
||||
* this axis must not move, so the ratio is reported instead of corrected */
|
||||
fg: 'var(--fs-text)', bg: 'var(--fs-bg)', label: _('on the canvas', 'footstrap')
|
||||
}, { cls: 'fs-ap-tint' }),
|
||||
|
||||
/* the STRENGTH half of the Tint — how strong the hue reads. Only meaningful in hue mode: a
|
||||
* hex canvas IS the colour asked for, with no chroma of ours to scale. CSS hides it in the
|
||||
* other two states (no tint at all, or a hex one). */
|
||||
/* NOT "Density": that is the UI-density segment above, and this string is both the visible
|
||||
* caption AND the control's aria-label, so two rows would read "Density" and a screen reader
|
||||
* would announce "Density, radio group" and "Density, slider" with nothing to tell them
|
||||
* apart. */
|
||||
group(_('Tint strength', 'footstrap'),
|
||||
(label) => sliderCtl(prefs.currentTintStrength(), 0, 200, bump(repaint(prefs.applyTintStrength)), label, {
|
||||
step: 5,
|
||||
fmt: (v) => v + '%'
|
||||
}), { cls: 'fs-ap-tint fs-ap-density' }),
|
||||
|
||||
/* recolours the accented CONTROLS (buttons/toggles/sliders/focus rings), not the canvas the
|
||||
* way Tint does. Measured as TEXT on a card, which is the use that fails first: as a fill it
|
||||
* carries derived ink, as a link or a status label it carries only itself. */
|
||||
colourGroup(_('Accent', 'footstrap'), {
|
||||
current: prefs.currentAccent, apply: prefs.applyAccent
|
||||
}, 'var(--fs-accent)', {
|
||||
fg: 'var(--fs-accent)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap')
|
||||
}),
|
||||
|
||||
colourGroup(_('Good', 'footstrap'), {
|
||||
current: prefs.currentGood, apply: prefs.applyGood
|
||||
}, 'var(--fs-good)', {
|
||||
fg: 'var(--fs-good)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap')
|
||||
}),
|
||||
|
||||
colourGroup(_('Warning', 'footstrap'), {
|
||||
current: prefs.currentWarn, apply: prefs.applyWarn
|
||||
}, 'var(--fs-warn)', {
|
||||
fg: 'var(--fs-warn)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap')
|
||||
}),
|
||||
|
||||
colourGroup(_('Danger', 'footstrap'), {
|
||||
current: prefs.currentDanger, apply: prefs.applyDanger
|
||||
}, 'var(--fs-danger)', {
|
||||
fg: 'var(--fs-danger)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap')
|
||||
})
|
||||
];
|
||||
|
||||
/* ---- the SURFACES: the sheet the UI is drawn on ----
|
||||
* Cards, inset controls, the chrome bar and the hairlines between them. Every one of these is a
|
||||
* surface that body text is read ON, so what each reports is --fs-text against itself — the one
|
||||
* measurement that says whether the page is still readable. There is no ink to derive here and
|
||||
* none is: --fs-text is the palette's, and an axis that silently moved it would be recolouring
|
||||
* the very thing it is being measured against.
|
||||
*
|
||||
* The hairline is the exception and takes the 3:1 UI-component threshold rather than the text
|
||||
* one, which is what its readout comparing --fs-border to --fs-panel means: a border is a shape,
|
||||
* not a label, and AA asks 3:1 of it. Below that it is decoration — which a hairline is entitled
|
||||
* to be, so the readout says the number and leaves the call to the admin. */
|
||||
const surfaces = [
|
||||
colourGroup(_('Cards', 'footstrap'), {
|
||||
current: prefs.currentCard, apply: prefs.applyCard
|
||||
}, 'var(--fs-panel)', {
|
||||
fg: 'var(--fs-text)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap')
|
||||
}),
|
||||
|
||||
colourGroup(_('Controls', 'footstrap'), {
|
||||
current: prefs.currentControl, apply: prefs.applyControl
|
||||
}, 'var(--fs-panel2)', {
|
||||
fg: 'var(--fs-text)', bg: 'var(--fs-panel2)', label: _('on a control', 'footstrap')
|
||||
}),
|
||||
|
||||
colourGroup(_('Sidebar and bar', 'footstrap'), {
|
||||
current: prefs.currentBar, apply: prefs.applyBar
|
||||
}, 'var(--fs-bar-bg)', {
|
||||
fg: 'var(--fs-text)', bg: 'var(--fs-bar-bg)', label: _('in the sidebar', 'footstrap')
|
||||
}),
|
||||
|
||||
|
||||
colourGroup(_('Borders', 'footstrap'), {
|
||||
current: prefs.currentLine, apply: prefs.applyLine
|
||||
}, 'var(--fs-border)', {
|
||||
fg: 'var(--fs-border)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap'), kind: 'shape'
|
||||
})
|
||||
];
|
||||
|
||||
/* ---- section 3: the wallpaper and everything that depends on which one is picked ----
|
||||
*
|
||||
* Wallpaper is THREE-valued: Off, Pattern (an uploaded SVG, tiled and recoloured) and File (an
|
||||
* uploaded photo). Each value brings rows with it — the SVG plus Scale/Strength/Colours, or the
|
||||
* photo plus Dim — and those rows are SIBLINGS of the Wallpaper row, not children of its field.
|
||||
*
|
||||
* That is the whole point of this shape. They were nested inside the field at first, which put a
|
||||
* second `.cbi-value` inside a `.cbi-value-field` and therefore a second 180px caption column
|
||||
* inside the first: measured on the router, Scale and Strength started 216px right of every
|
||||
* other control on the page. LuCI has no such construct anywhere, and the eye reads it as two
|
||||
* forms interleaved. Flat rows, hidden as a group, is what the stock pages do with a dependent
|
||||
* field — one caption column, one field column, top to bottom.
|
||||
*
|
||||
* The select is the per-browser switch that decides whether to paint an image, so it is what
|
||||
* keeps the Save button honest (refreshSave); Choose/Remove only swap the picture behind
|
||||
* whoever is in that mode and never touch the axis. Both native file inputs stay hidden — the
|
||||
* styled buttons trigger them. */
|
||||
const wallpaper = (() => {
|
||||
const err = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' });
|
||||
const preview = E('img', { 'class': 'fs-ap-bgprev', 'alt': '', 'hidden': '' });
|
||||
/* display:none, not the `hidden` attribute — a bare `hidden=""` still rendered the native
|
||||
* "Choose File / No file chosen" control; only the styled button below should be visible. */
|
||||
const fileInput = E('input', { 'type': 'file', 'accept': 'image/*', 'style': 'display:none' });
|
||||
const chooseLabel = _('Choose image', 'footstrap');
|
||||
const chooseBtn = E('button', { 'class': 'btn cbi-button', 'type': 'button' }, [ chooseLabel ]);
|
||||
const removeBtn = E('button', { 'class': 'btn cbi-button-remove', 'type': 'button', 'hidden': '' }, [ _('Remove', 'footstrap') ]);
|
||||
|
||||
const patErr = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' });
|
||||
const patPreview = E('img', { 'class': 'fs-ap-bgprev', 'alt': '', 'hidden': '' });
|
||||
const patInput = E('input', { 'type': 'file', 'accept': 'image/svg+xml,.svg', 'style': 'display:none' });
|
||||
const patChooseLabel = _('Choose SVG', 'footstrap');
|
||||
const patChoose = E('button', { 'class': 'btn cbi-button', 'type': 'button' }, [ patChooseLabel ]);
|
||||
const patRemove = E('button', { 'class': 'btn cbi-button-remove', 'type': 'button', 'hidden': '' }, [ _('Remove', 'footstrap') ]);
|
||||
|
||||
/* Dim: the scrim opacity over the photo. An ORDINARY per-browser axis — it is in AXIS_KEYS
|
||||
* and in snapshotAxes(), so it moves this browser toward or away from the router default and
|
||||
* must therefore be bump()-ed like every other saved axis. It was not, on the strength of a
|
||||
* comment that said it wrote straight to uci: true until "keep every axis per-browser until
|
||||
* Save as default" made it a propAxis and did not reach this file. The symptom is the one
|
||||
* thing the Save button IS — its own status. Separate from the Tint's strength above. */
|
||||
const dimLabel = _('Dim', 'footstrap');
|
||||
const scaleLabel = _('Scale', 'footstrap');
|
||||
const strengthLabel = _('Strength', 'footstrap');
|
||||
const inkLabel = _('Colours', 'footstrap');
|
||||
|
||||
/* The rows the PATTERN brings. Scale and Strength are live: the appliers write a custom
|
||||
* property, so the tile behind the page resizes and fades under the drag with nothing to
|
||||
* reload. Colours decides whether the file's own palette is kept or thrown away for the
|
||||
* theme's — a mask uses the alpha only, which is right for line art and wrong for artwork
|
||||
* that carries its own colours, and only whoever picked the file knows which it is. */
|
||||
const patRows = [
|
||||
group(_('Pattern', 'footstrap'),
|
||||
() => E('div', { 'class': 'fs-ap-bgrow' }, [ patChoose, patRemove ]),
|
||||
{ extra: [ patInput, patPreview, patErr ] }),
|
||||
group(scaleLabel, (lbl) => sliderCtl(prefs.currentPatternSize(), 40, 1600,
|
||||
bump(prefs.applyPatternSize), lbl, { step: 20, unit: 'px' })),
|
||||
group(strengthLabel, (lbl) => sliderCtl(prefs.currentPatternStrength(), 0, 100,
|
||||
bump(prefs.applyPatternStrength), lbl, { step: 5, unit: '%' })),
|
||||
group(inkLabel, (lbl) => selectCtl(prefs.currentPatternInk(), {
|
||||
theme: _('Theme', 'footstrap'),
|
||||
original: _('As in file', 'footstrap')
|
||||
}, bump(prefs.applyPatternInk), lbl))
|
||||
];
|
||||
/* …and the rows the FILE photo brings. */
|
||||
const fileRows = [
|
||||
group(_('File', 'footstrap'),
|
||||
() => E('div', { 'class': 'fs-ap-bgrow' }, [ chooseBtn, removeBtn ]),
|
||||
{ extra: [ fileInput, preview, err ] }),
|
||||
group(dimLabel, (lbl) => sliderCtl(prefs.currentPhotoDim(), 0, 100,
|
||||
bump(prefs.applyPhotoDim), lbl, { step: 5, unit: '%' }))
|
||||
];
|
||||
|
||||
function reflect(tok) {
|
||||
if (tok) { preview.src = prefs.loginBgUrl(tok); preview.hidden = false; removeBtn.hidden = false; }
|
||||
else { preview.removeAttribute('src'); preview.hidden = true; removeBtn.hidden = true; }
|
||||
}
|
||||
function reflectPattern(tok) {
|
||||
if (tok) { patPreview.src = prefs.patternUrl(tok); patPreview.hidden = false; patRemove.hidden = false; }
|
||||
else { patPreview.removeAttribute('src'); patPreview.hidden = true; patRemove.hidden = true; }
|
||||
}
|
||||
/* `hidden` on the ROW, which is why 80-appearance.css restates it at a specificity that beats
|
||||
* `.cbi-value`'s own display — the UA's bare `[hidden]` rule loses to it. Hidden and not
|
||||
* removed: the rows are built once and each holds a live control whose value is this
|
||||
* browser's, so rebuilding them on every switch would be the popover's old bug (a control
|
||||
* frozen to the state it was constructed in) in a new place. */
|
||||
function togglePanel(v) {
|
||||
patRows.forEach((r) => { r.hidden = (v !== 'pattern'); });
|
||||
fileRows.forEach((r) => { r.hidden = (v !== 'file'); });
|
||||
}
|
||||
reflect(prefs.currentLoginBg());
|
||||
reflectPattern(prefs.currentPattern());
|
||||
togglePanel(prefs.currentWallpaper());
|
||||
|
||||
const setWallpaper = (v) => { prefs.applyWallpaper(v); refreshSave(); togglePanel(v); refreshColours(); };
|
||||
|
||||
patChoose.addEventListener('click', () => { patErr.hidden = true; patInput.click(); });
|
||||
patInput.addEventListener('change', () => {
|
||||
const f = patInput.files && patInput.files[0];
|
||||
patInput.value = ''; /* so re-picking the same file fires change again */
|
||||
if (!f) return;
|
||||
patErr.hidden = true; patChoose.disabled = true;
|
||||
patChoose.textContent = _('Uploading…', 'footstrap');
|
||||
prefs.uploadPattern(f)
|
||||
.then((tok) => {
|
||||
reflectPattern(tok);
|
||||
/* uploadPattern already switched THIS browser onto the pattern, so the control has
|
||||
* to catch up or the page paints the tile while the dropdown still reads Off.
|
||||
* `dom.callClassMethod` is how LuCI moves one of its own widgets from the outside;
|
||||
* setWallpaper is then called directly, because a programmatic setValue does NOT
|
||||
* emit `widget-change` — the event is the user's, and relying on it here would
|
||||
* leave the rows and the Save button behind. */
|
||||
dom.callClassMethod(seg, 'setValue', 'pattern');
|
||||
setWallpaper('pattern');
|
||||
})
|
||||
.catch((e) => { patErr.textContent = String((e && e.message) || e); patErr.hidden = false; })
|
||||
.finally(() => { patChoose.disabled = false; patChoose.textContent = patChooseLabel; });
|
||||
});
|
||||
patRemove.addEventListener('click', () => {
|
||||
patErr.hidden = true; patRemove.disabled = true;
|
||||
prefs.removePattern()
|
||||
.then(() => reflectPattern(''))
|
||||
.catch((e) => { patErr.textContent = String((e && e.message) || e); patErr.hidden = false; })
|
||||
.finally(() => { patRemove.disabled = false; });
|
||||
});
|
||||
|
||||
chooseBtn.addEventListener('click', () => { err.hidden = true; fileInput.click(); });
|
||||
fileInput.addEventListener('change', () => {
|
||||
const f = fileInput.files && fileInput.files[0];
|
||||
fileInput.value = ''; /* so re-picking the same file fires change again */
|
||||
if (!f) return;
|
||||
err.hidden = true; chooseBtn.disabled = true;
|
||||
chooseBtn.textContent = _('Uploading…', 'footstrap');
|
||||
prefs.uploadLoginBg(f)
|
||||
.then(reflect)
|
||||
.catch((e) => { err.textContent = String((e && e.message) || e); err.hidden = false; })
|
||||
.finally(() => { chooseBtn.disabled = false; chooseBtn.textContent = chooseLabel; });
|
||||
});
|
||||
removeBtn.addEventListener('click', () => {
|
||||
err.hidden = true; removeBtn.disabled = true;
|
||||
prefs.removeLoginBg()
|
||||
.then(() => reflect(''))
|
||||
.catch((e) => { err.textContent = String((e && e.message) || e); err.hidden = false; })
|
||||
.finally(() => { removeBtn.disabled = false; });
|
||||
});
|
||||
|
||||
let seg;
|
||||
const wallRow = group(_('Wallpaper', 'footstrap'), (label) => {
|
||||
seg = selectCtl(prefs.currentWallpaper(), {
|
||||
off: _('Off', 'footstrap'),
|
||||
pattern: _('Pattern', 'footstrap'),
|
||||
file: _('File', 'footstrap')
|
||||
}, setWallpaper, label);
|
||||
return seg;
|
||||
});
|
||||
|
||||
return [ wallRow ].concat(patRows, fileRows);
|
||||
})();
|
||||
|
||||
/* ---- section 4: the router default and the version ---- */
|
||||
/* the version line: read from fs-version.js, which the Makefile stamps at package time. No
|
||||
* request, no check — `apk upgrade` is what tells this router about a new one. */
|
||||
|
||||
/* Save the current look as the ROUTER-WIDE default (fs-prefs writes it to /etc/config/footstrap
|
||||
* via the scoped uci ACL). It does NOT change this browser — localStorage keeps overriding, so
|
||||
* the saved default only shows on a fresh browser/device. "Reset" is the escape hatch: it clears
|
||||
* this browser's overrides and reloads onto the saved default (a two-click confirm, since it
|
||||
* discards local tweaks).
|
||||
*
|
||||
* No status text — the Save BUTTON itself is the status: enabled "Save as default" when this
|
||||
* browser diverges from the saved default, disabled "Saved as default" when it already matches
|
||||
* (nothing to save). refreshSave() below drives that from prefs.matchesSavedDefault(). */
|
||||
const saveBtn = E('button', { 'class': 'btn cbi-button-action', 'type': 'button' }, [ _('Save as default', 'footstrap') ]);
|
||||
/* TWO resets, because there are two things underneath a browser's tweaks (fs-prefs.js):
|
||||
* "Reset to saved" clears them and lets every axis fall back through the layers — to whatever
|
||||
* Save as default put on the ROUTER; "Reset to default" writes the THEME's own built-ins
|
||||
* explicitly, which is the only way to say "as the theme ships" on a router that has a saved
|
||||
* default of its own. Neither touches /etc/config/footstrap. */
|
||||
const resetSavedBtn = E('button', { 'class': 'btn', 'type': 'button' }, [ _('Reset to saved', 'footstrap') ]);
|
||||
/* The stock destructive class, so the button that throws away every local tweak is the red one
|
||||
* on the page — LuCI paints .cbi-button-negative/.cbi-button-remove from --fs-danger
|
||||
* (theme/55-buttons.css). "Reset to saved" stays neutral on purpose: it drops this browser back
|
||||
* onto whatever the router says, which is a step BACK to a shared state rather than a discard. */
|
||||
const resetBtn = E('button', { 'class': 'btn cbi-button-negative', 'type': 'button' }, [ _('Reset to default', 'footstrap') ]);
|
||||
/* Save's only visible failure surface. saveAsDefault() writes /etc/config/footstrap over the
|
||||
* scoped uci ACL; the realistic failure is the rpc REJECTING — an expired session (403), a
|
||||
* missing ACL, ubus down — which the old code buried in a title tooltip nobody sees. (A DELETED
|
||||
* config is NOT caught here: rpcd stages the set in the session and commit then silently no-ops
|
||||
* without writing the file, returning success — measured on the router. The package owns that
|
||||
* file and the read side falls back to built-in defaults, so that edge is left to the package.) */
|
||||
const saveErr = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' });
|
||||
|
||||
/* the Save button IS the status: match -> disabled "Saved as default", diverged -> enabled
|
||||
* "Save as default". Called after every axis change (via bump). */
|
||||
function refreshSave() {
|
||||
const saved = prefs.matchesSavedDefault();
|
||||
saveBtn.disabled = saved;
|
||||
saveBtn.textContent = saved ? _('Saved as default', 'footstrap') : _('Save as default', 'footstrap');
|
||||
}
|
||||
saveBtn.addEventListener('click', () => {
|
||||
saveBtn.disabled = true;
|
||||
saveErr.hidden = true;
|
||||
prefs.saveAsDefault()
|
||||
.then(() => { saveErr.hidden = true; })
|
||||
/* On failure re-enable (refreshSave, below) so the user can retry. The usual cause is a
|
||||
* stale session, which a reload fixes — so say that. The raw rpc error — the one string
|
||||
* here neither the theme nor LuCI composed — stays in a title tooltip for debugging. */
|
||||
.catch((e) => {
|
||||
saveErr.textContent = _('Could not save the default. Reload the page and try again.', 'footstrap');
|
||||
saveErr.title = String((e && e.message) || e);
|
||||
saveErr.hidden = false;
|
||||
})
|
||||
.finally(refreshSave);
|
||||
});
|
||||
/* two-click confirm on BOTH: the first click arms, the second resets — discarding this browser's
|
||||
* tweaks is destructive of local work, and a native confirm() is banned in this UI. Arming one
|
||||
* disarms the other, so a primed button can never be fired by a click meant for its neighbour.
|
||||
*
|
||||
* Each reload lands on this tab rather than back on General Settings: a reset is a change to
|
||||
* what is on THIS tab, and being thrown to the top of the page to find it again is the kind of
|
||||
* small rudeness that makes a setting feel unfinished. See armReturn() / the mount() flag. */
|
||||
const armed = new Map();
|
||||
function disarm(btn, label) {
|
||||
armed.delete(btn);
|
||||
btn.textContent = label;
|
||||
btn.classList.remove('fs-ap-armed');
|
||||
}
|
||||
function twoClick(btn, label, run) {
|
||||
btn.addEventListener('click', () => {
|
||||
if (!armed.has(btn)) {
|
||||
[ ...armed.keys() ].forEach((other) => disarm(other, armed.get(other)));
|
||||
armed.set(btn, label);
|
||||
btn.textContent = _('Confirm reset', 'footstrap');
|
||||
btn.classList.add('fs-ap-armed');
|
||||
return;
|
||||
}
|
||||
disarm(btn, label);
|
||||
run();
|
||||
armReturn();
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
twoClick(resetSavedBtn, _('Reset to saved', 'footstrap'), prefs.resetToSaved);
|
||||
twoClick(resetBtn, _('Reset to default', 'footstrap'), prefs.resetToBuiltin);
|
||||
refreshSave(); /* correct label/enabled state before the first paint */
|
||||
|
||||
const versionLink = E('a', {
|
||||
'class': 'fs-ap-version',
|
||||
'href': ver.REPO_URL,
|
||||
'target': '_blank',
|
||||
'rel': 'noopener noreferrer'
|
||||
}, [ ver.label() ]);
|
||||
|
||||
const defaults = [
|
||||
/* the one row whose "control" is a pair of buttons, each already named by its own text — so
|
||||
* the caption is not re-used as an aria-label here and `make` ignores it */
|
||||
group(_('Router default', 'footstrap'),
|
||||
() => E('div', { 'class': 'fs-ap-actrow' }, [ saveBtn, resetSavedBtn, resetBtn ]),
|
||||
{ extra: saveErr })
|
||||
];
|
||||
|
||||
|
||||
defaults.push(E('div', { 'class': 'fs-ap-footer' }, [
|
||||
E('div', { 'class': 'fs-ap-verrow' }, [ versionLink ])
|
||||
]));
|
||||
|
||||
/* NOT .cbi-section: inside a tab pane that class is a card drawn within a card, and the stock
|
||||
* tabs (General Settings, Logging, …) put their rows straight into the pane. These are grouping
|
||||
* headings within one pane, so they are the theme's own class and take their rule from
|
||||
* styles/pages/80-appearance.css. */
|
||||
const section = (title, rows) => E('div', { 'class': 'fs-ap-section' }, [
|
||||
E('div', { 'class': 'fs-ap-head' }, [ E('h4', {}, [ title ]) ])
|
||||
].concat(rows));
|
||||
|
||||
/* ---- the folded groups ------------------------------------------------------------------
|
||||
* Recolouring is a thing most admins never do, and these are the widest rows on the page —
|
||||
* nine colour fields and an uploader, which used to sit permanently open in front of someone
|
||||
* who came here to change the layout. Each is a DISCLOSURE now: the heading is the control,
|
||||
* and both start closed.
|
||||
*
|
||||
* A disclosure and not a switch, which is what these were first. A switch answers "is this
|
||||
* feature on", and that is the wrong question — turning it off would either revert nine colours
|
||||
* (destructive, from a control that looks like a disclosure) or change nothing at all, which is
|
||||
* a switch that lies. Folding answers the question that is actually being asked: am I looking
|
||||
* at this right now. Nothing is applied, un-applied or disabled by opening or closing one.
|
||||
*
|
||||
* It is the W3C APG disclosure pattern, the same one the menu's sections use: a <button> owning
|
||||
* the region it shows, `aria-expanded` on the button and `aria-controls` pointing at the panel.
|
||||
* `hidden` on the panel rather than a class, so a closed group is out of the tab order and out
|
||||
* of the accessibility tree for free.
|
||||
*
|
||||
* The open/closed state is remembered per browser but is NOT an axis: it changes nothing about
|
||||
* how the page looks, so it is absent from AXIS_KEYS, from snapshotAxes() and from the
|
||||
* pre-paint. Closed is the default, including on a router that already has colours set — the
|
||||
* fold says where things are, not whether they are in use. */
|
||||
let foldSeq = 0;
|
||||
function foldable(title, rows, key) {
|
||||
const id = 'fs-ap-fold-' + (++foldSeq);
|
||||
let open = (prefs.lsGet(key) === 'on');
|
||||
const body = E('div', { 'class': 'fs-ap-body', 'id': id }, rows);
|
||||
const btn = E('button', {
|
||||
'type': 'button', 'class': 'fs-ap-fold', 'aria-expanded': String(open), 'aria-controls': id
|
||||
}, [
|
||||
E('h4', {}, [ title ]),
|
||||
/* The chevron is the affordance, and it is the SAME one the overview's card toggles
|
||||
* draw: an empty box whose ::after is two borders rotated 45° (styles/pages/
|
||||
* 20-overview.css). Not an <svg> — this theme has one chevron for "this panel opens",
|
||||
* and a second drawing of it would be a second thing to keep looking like the first.
|
||||
* Empty and aria-hidden: the STATE is on the button's aria-expanded, which is also what
|
||||
* CSS rotates it off, so what a screen reader is told and what the eye sees cannot
|
||||
* disagree. */
|
||||
E('span', { 'class': 'fs-ap-chev', 'aria-hidden': 'true' })
|
||||
]);
|
||||
const paint = () => {
|
||||
body.hidden = !open;
|
||||
btn.setAttribute('aria-expanded', String(open));
|
||||
};
|
||||
btn.addEventListener('click', () => {
|
||||
open = !open;
|
||||
prefs.lsSet(key, open ? 'on' : 'off');
|
||||
paint();
|
||||
/* the colour controls read the COMPUTED cascade, and a hidden element computes nothing
|
||||
* useful — so the readouts are filled when the group becomes visible, not before */
|
||||
if (open) refreshColours();
|
||||
});
|
||||
paint();
|
||||
return E('div', { 'class': 'fs-ap-section' }, [
|
||||
E('div', { 'class': 'fs-ap-head' }, [ btn ]), body
|
||||
]);
|
||||
}
|
||||
|
||||
/* Colours and Surfaces are ONE fold: they are the same job — "make this router a different
|
||||
* colour" — split into two headings only because a figure and the sheet it sits on are read
|
||||
* differently. Two folds for one decision would be two things to open. */
|
||||
const page = E('div', { 'class': 'fs-ap' }, [
|
||||
section(_('Interface', 'footstrap'), shell),
|
||||
foldable(_('Colours', 'footstrap'),
|
||||
colours.concat([ E('div', { 'class': 'fs-ap-head fs-ap-sub' }, [ E('h4', {}, [ _('Surfaces', 'footstrap') ]) ]) ], surfaces),
|
||||
'fs-ui-colours'),
|
||||
foldable(_('Background', 'footstrap'), wallpaper, 'fs-ui-background'),
|
||||
section(_('Defaults', 'footstrap'), defaults)
|
||||
]);
|
||||
|
||||
/* The colour controls read the COMPUTED cascade, which needs them in the document — so the first
|
||||
* fill cannot happen while the tree is still being assembled above. The view appends this
|
||||
* element synchronously on return, so a microtask is late enough and early enough: late enough
|
||||
* that the probe resolves, early enough that nothing has painted a blank field. */
|
||||
Promise.resolve().then(refreshColours);
|
||||
return page;
|
||||
}
|
||||
|
||||
/* ---- mounting it on the stock System page ---------------------------------------------------
|
||||
*
|
||||
* The same shape as fs-overview.js's, and for the same reason: a chrome module is instantiated once
|
||||
* per PAGE LOAD, so it has to notice SPA navigation itself. `body[data-page]` is the signal — both
|
||||
* the server template and fs-router stamp it with the dispatch path — so one attribute observer
|
||||
* covers arriving at System, leaving it, and coming back. */
|
||||
const PAGE = 'admin-system-system';
|
||||
/* A reset reloads the page, and a reload opens the stock page on the tab LuCI remembers — which is
|
||||
* never this one, because ui.tabs only knows the tabs it built itself. So the reset says where it
|
||||
* came from and mount() puts the user back. sessionStorage and not a URL fragment: the fragment is
|
||||
* the stock page's own business, and a stale one would keep re-opening this tab on every later
|
||||
* visit. The key is read once and removed, so it survives exactly one reload. */
|
||||
const RETURN_KEY = 'fs-ap-return';
|
||||
function armReturn() { try { sessionStorage.setItem(RETURN_KEY, '1'); } catch (e) {} }
|
||||
function takeReturn() {
|
||||
try {
|
||||
if (sessionStorage.getItem(RETURN_KEY) === null) return false;
|
||||
sessionStorage.removeItem(RETURN_KEY);
|
||||
return true;
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
const MARK = 'fs-ap'; /* the built form's own class; also how mount() knows it is already there */
|
||||
const TAB = 'fs-appearance'; /* the pane's data-tab, which ui.tabs' click handler matches on */
|
||||
|
||||
let _routeObserver = null, _viewObserver = null, _observedView = null, _building = false;
|
||||
|
||||
function onPage() { return (document.body.getAttribute('data-page') || '') === PAGE; }
|
||||
|
||||
function stopWatch() {
|
||||
if (_viewObserver) _viewObserver.disconnect();
|
||||
_viewObserver = null;
|
||||
_observedView = null;
|
||||
}
|
||||
|
||||
/* Append the form once the stock view has rendered. LuCI's system.js resolves its own promises
|
||||
* before it puts anything in #view, so there is nothing to hook but the DOM — hence the observer,
|
||||
* which also covers the view being re-rendered under us (a Save & Apply redraws the map).
|
||||
*
|
||||
* Idempotent through the marker: an observer fires for every mutation, and the form's own
|
||||
* construction is a mutation. Without the check it would append itself for as long as it kept
|
||||
* noticing itself. */
|
||||
/* The stock tab GROUP: the element whose children are the panes, which ui.tabs marks
|
||||
* data-initialized when it builds the menu — and the menu it inserted is that element's previous
|
||||
* sibling. Both are read from the DOM rather than assumed, because a group that is not initialised
|
||||
* yet is a page still rendering, not a page without tabs.
|
||||
*
|
||||
* The flag and the sibling are the whole test, deliberately: the panes themselves are NOT required
|
||||
* to be found here. On the stock System page they are not direct children of the marked element at
|
||||
* all — the map nests them a level deeper — so a `:scope > .cbi-tabcontainer` check found none and
|
||||
* the tab was never added, silently, on a page that plainly has tabs. */
|
||||
function tabGroup(view) {
|
||||
for (const g of view.querySelectorAll('[data-initialized="true"]')) {
|
||||
const menu = g.previousElementSibling;
|
||||
if (menu?.classList.contains('cbi-tabmenu'))
|
||||
return { group: g, menu };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* Append the pane and its tab once the stock view has rendered. LuCI's system.js resolves its own
|
||||
* promises before it puts anything in #view, so there is nothing to hook but the DOM — hence the
|
||||
* observer, which also covers the view being re-rendered under us (a Save & Apply redraws the map).
|
||||
*
|
||||
* The tab is added BY HAND rather than by calling ui.tabs.initTabGroup again: that function returns
|
||||
* immediately when the group carries data-initialized, and clearing the flag to re-run it would
|
||||
* build a SECOND menu beside the first (it inserts one unconditionally) and drop the stock tabs'
|
||||
* own click bindings. One <li>, the same click handler ui.tabs binds to every other tab, and the
|
||||
* pane the handler expects to find.
|
||||
*
|
||||
* Idempotent through the marker: an observer fires for every mutation, and the form's own
|
||||
* construction is a mutation. Without the check it would append itself for as long as it kept
|
||||
* noticing itself. */
|
||||
function mount() {
|
||||
const view = document.getElementById('view');
|
||||
if (!view || view.querySelector('.' + MARK) || _building) return;
|
||||
const tabs = tabGroup(view);
|
||||
if (!tabs) return;
|
||||
_building = true;
|
||||
render()
|
||||
.then((form) => {
|
||||
/* re-check: render() resolves on a microtask and the view can have been replaced, or
|
||||
* navigated away from, in the meantime */
|
||||
const v = document.getElementById('view');
|
||||
if (!onPage() || !v || v.querySelector('.' + MARK)) return;
|
||||
const t = tabGroup(v);
|
||||
if (!t) return;
|
||||
/* The tab is named after the THEME, not after what it does: it sits between four stock
|
||||
* tabs that are all "what this page configures" (General Settings, Logging, …), and a
|
||||
* fifth called Appearance reads as another facet of the router rather than as one
|
||||
* package's settings. "Footstrap" says whose these are — and it is a proper noun, so it
|
||||
* is deliberately NOT translated, like the palette name in the form below. */
|
||||
const title = 'Footstrap';
|
||||
/* data-tab-active is deliberately absent: the stock page opens on whichever tab it
|
||||
* opened on before, and a theme has no business taking that over. */
|
||||
t.group.appendChild(E('div', {
|
||||
'class': 'cbi-tabcontainer',
|
||||
'data-tab': TAB,
|
||||
'data-tab-title': title
|
||||
}, [ form ]));
|
||||
const link = E('a', { 'href': '#' }, [ title ]);
|
||||
link.addEventListener('click', ui.tabs.switchTab.bind(ui.tabs));
|
||||
t.menu.appendChild(E('li', { 'class': 'cbi-tab-disabled', 'data-tab': TAB }, [ link ]));
|
||||
/* …and if this load is the one a reset asked for, open on it. Clicking the link we just
|
||||
* built goes through ui.tabs' own switchTab, so the stock panes are hidden exactly the
|
||||
* way they are for any other tab — nothing here reimplements the switch. */
|
||||
if (takeReturn()) link.click();
|
||||
})
|
||||
.catch((e) => console.error('footstrap: the Appearance tab failed to build', e))
|
||||
.finally(() => { _building = false; });
|
||||
}
|
||||
|
||||
function watch() {
|
||||
const view = document.getElementById('view');
|
||||
if (_viewObserver && _observedView !== view) stopWatch();
|
||||
if (_viewObserver || !view || !onPage()) return;
|
||||
_observedView = view;
|
||||
_viewObserver = new MutationObserver(mount);
|
||||
_viewObserver.observe(view, { childList: true, subtree: true });
|
||||
mount();
|
||||
}
|
||||
|
||||
/* Called by menu-footstrap-common's init, once. Everything route-dependent hangs off the data-page
|
||||
* observer inside. */
|
||||
function wire() {
|
||||
if (_routeObserver || !document.body) return;
|
||||
_routeObserver = new MutationObserver(() => (onPage() ? watch() : stopWatch()));
|
||||
_routeObserver.observe(document.body, { attributes: true, attributeFilter: [ 'data-page' ] });
|
||||
if (onPage()) watch();
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
wire,
|
||||
render
|
||||
});
|
||||
@@ -0,0 +1,429 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require ui';
|
||||
'require fs-fit as fit';
|
||||
'require fs-prefs as prefs';
|
||||
'require fs-menutree as tree';
|
||||
|
||||
/* The chrome AROUND the content: the mode menu, the section tabs, the rail toggle, and the
|
||||
* measurements that decide how much room any of it gets. The MAIN menu is not here — it is injected
|
||||
* by menu-footstrap.js as a callback (renderMainMenu), because LuCI instantiates every required
|
||||
* module into a singleton and so a renderer cannot be a subclass of the chrome (docs/conventions.md). */
|
||||
|
||||
/* the injected main-menu renderer; handed over once by the theme's init() */
|
||||
let _renderMain = null;
|
||||
function setRenderMain(fn) {
|
||||
_renderMain = fn;
|
||||
}
|
||||
|
||||
/* section tabs -> #tabmenu (horizontal) */
|
||||
function renderTabMenu(node, url, level) {
|
||||
const container = document.querySelector('#tabmenu');
|
||||
/* a template without the container must not reject: an unhandled rejection here kills the
|
||||
* whole ui.menu.load() chain, i.e. every menu */
|
||||
if (!container)
|
||||
return E([]);
|
||||
const ul = E('ul', { 'class': 'tabs' });
|
||||
const children = ui.menu.getChildren(node);
|
||||
let activeNode = null;
|
||||
|
||||
children.forEach((child) => {
|
||||
const isActive = (L.env.dispatchpath[3 + (level || 0)] === child.name);
|
||||
/* aria-current="page", not just the `active` class: the class is paint, which a screen
|
||||
* reader cannot see. E() drops a null attribute value, so inactive tabs carry nothing. */
|
||||
ul.appendChild(E('li', { 'class': 'tabmenu-item-%s %s'.format(child.name, isActive ? 'active' : '') }, [
|
||||
E('a', { 'href': L.url(url, child.name), 'aria-current': isActive ? 'page' : null }, [ _(child.title) ])
|
||||
]));
|
||||
if (isActive)
|
||||
activeNode = child;
|
||||
});
|
||||
|
||||
if (ul.children.length === 0)
|
||||
return E([]);
|
||||
|
||||
container.appendChild(ul);
|
||||
container.style.display = '';
|
||||
|
||||
if (activeNode)
|
||||
renderTabMenu(activeNode, url + '/' + activeNode.name, (level || 0) + 1);
|
||||
|
||||
return ul;
|
||||
}
|
||||
|
||||
/* ---- tab-strip auto-fit ----
|
||||
* A tab strip (#tabmenu, or a view's own .cbi-tabmenu) can carry ~11 pills (luci-app-justclash)
|
||||
* that overflow one row. Rather than wrap, shrink: two density classes (styles/theme/40-tabs.css)
|
||||
* trim padding, then gap+font. Floored so a pill never gets tighter than its label — past the
|
||||
* floor the strip is allowed to wrap. */
|
||||
function stripFitsOneRow(ul) {
|
||||
/* Only laid-out children count: a display:none child has offsetTop 0, so taking it as `last`
|
||||
* read as "one row" while the strip had in fact wrapped, and the density fit never fired. */
|
||||
const items = [...ul.children].filter((el) => el.getClientRects().length > 0);
|
||||
const first = items[0], last = items[items.length - 1];
|
||||
/* one row iff first and last item share a top edge */
|
||||
return !first || !last || first.offsetTop === last.offsetTop;
|
||||
}
|
||||
function fitTabStrips() {
|
||||
/* `.fs-sidebar > ul.nav` is the main menu in EVERY layout — the same list — so the
|
||||
* flexDirection check below is what tells a bar (row) from a vertical sidebar (column),
|
||||
* where a one-row measure is meaningless. */
|
||||
document.querySelectorAll('.tabs, .cbi-tabmenu, .fs-sidebar > ul.nav').forEach((ul) => {
|
||||
if (ul.children.length < 2) return;
|
||||
if (ul.matches('.fs-sidebar > ul.nav') && getComputedStyle(ul).flexDirection !== 'row') {
|
||||
/* vertical list: the measure would floor it at fs-dense2 forever. Clear and skip. */
|
||||
if (ul.classList.contains('fs-dense1') || ul.classList.contains('fs-dense2'))
|
||||
ul.classList.remove('fs-dense1', 'fs-dense2');
|
||||
return;
|
||||
}
|
||||
/* steady state (poll tick on an already-fitting strip): one measure, no class writes —
|
||||
* the write-measure-write dance below forces a reflow per strip, every second. */
|
||||
if (!ul.classList.contains('fs-dense1') && !ul.classList.contains('fs-dense2') && stripFitsOneRow(ul))
|
||||
return;
|
||||
ul.classList.remove('fs-dense1', 'fs-dense2');
|
||||
if (stripFitsOneRow(ul)) return;
|
||||
ul.classList.add('fs-dense1');
|
||||
if (stripFitsOneRow(ul)) return;
|
||||
ul.classList.remove('fs-dense1');
|
||||
ul.classList.add('fs-dense2'); /* floor: leave wrapped if it still overflows */
|
||||
});
|
||||
}
|
||||
/* ---- does the CONTENT column still have room, once the sidebar has taken its cut? ----
|
||||
*
|
||||
* The sidebar gives way to the bar when what is LEFT for the content would be too narrow to read.
|
||||
* A viewport breakpoint (`@media (max-width: 767px)`) cannot say that: the cut is not a constant —
|
||||
* 224px expanded, 68px collapsed to the rail — so one breakpoint gave both states the same answer,
|
||||
* and the rail folded away at the same width as the full sidebar, the ~156px it had just freed
|
||||
* buying the user nothing. Do NOT measure the RENDERED sidebar either: the answer would depend on
|
||||
* the state it is deciding (once it is a bar there is no cut, so the content "fits", so it
|
||||
* un-narrows, so it cuts again) — oscillation.
|
||||
*
|
||||
* The widths come from the STYLESHEET (02-tokens.css), which is what lays the sidebar out; never
|
||||
* restate them here, or narrowing the rail in CSS leaves this subtracting the old width with no gate
|
||||
* able to see it. Memoised because fitShell runs on every resize and mutation and getComputedStyle
|
||||
* forces a style recalc; the fallbacks stop an empty custom property making the measurement NaN
|
||||
* (`NaN < NaN` is false, so the sidebar would simply never yield). */
|
||||
/* A CUSTOM PROPERTY IS UNTYPED, so its computed value is a token stream and not a length.
|
||||
* `parseFloat(getComputedStyle(root).getPropertyValue('--fs-sidebar-w'))` therefore reads
|
||||
* `calc(224px * 1)` — a string starting with `c` — and returns NaN, which the fallbacks below turn
|
||||
* straight back into the literals this function exists to stop restating. It read correctly until
|
||||
* the Density axis wrapped three of the four tokens in `calc(… * var(--fs-density-box))`; only
|
||||
* `--fs-content-min`, still a bare `500px`, kept working, which is why the failure was one-sided
|
||||
* and silent. Measured on the router: content-min 500, and sidebar/rail/pad all NaN.
|
||||
*
|
||||
* Resolve them the way the platform actually offers without registering the property: assign the
|
||||
* token to a REAL length property on a throwaway element and read the used value back. */
|
||||
let _probe = null;
|
||||
/* THE PROBE IS A PLAIN <div> IN THE SHARED DOCUMENT, so a third-party app's CSS can style it, and
|
||||
* every one of its declarations is !important for that reason alone. It carries no chrome mark, so
|
||||
* `fs-sheets`'s fence deliberately does not spare it (the fence protects the chrome's own elements),
|
||||
* and even a fenced sheet still matches an unmarked div. Issue #19: an app whose stylesheet carried
|
||||
* `div { min-width: 500px !important }` won every read — all four tokens came back 500 — so the cut
|
||||
* the sidebar was said to take became 500 + 2x500 and `fitShell` folded the sidebar into a bar on a
|
||||
* 1857px desktop. The threshold that produces is exactly 500 + 500 + 1000 = 2000 CSS px, which is
|
||||
* why it was reported as a ZOOM bug: Chrome at 90% gives 2063 CSS px and passed, 100% gives 1857 and
|
||||
* failed. Inline !important is what answers it — a style-attribute declaration outranks any author
|
||||
* rule at the same importance, so there is nothing left for the app to out-rank.
|
||||
* box-sizing is stated for the same reason: getComputedStyle().width is the CONTENT box, so a
|
||||
* foreign `border-box` plus padding would shave the reading (the padding/border resets below are
|
||||
* important, but only a stated box-sizing makes them provably irrelevant). */
|
||||
function resolveLen(token, dflt) {
|
||||
if (!_probe) {
|
||||
_probe = document.createElement('div');
|
||||
_probe.setAttribute('aria-hidden', 'true');
|
||||
/* out of flow, no box, no ink: it must never affect layout, scroll extent or hit-testing */
|
||||
_probe.style.cssText = 'position:absolute!important;visibility:hidden!important;' +
|
||||
'pointer-events:none!important;height:0!important;box-sizing:content-box!important;' +
|
||||
'min-width:0!important;max-width:none!important;border:0!important;' +
|
||||
'padding:0!important;margin:0!important;';
|
||||
document.body.appendChild(_probe);
|
||||
}
|
||||
_probe.style.setProperty('width', 'var(' + token + ')', 'important');
|
||||
const v = parseFloat(getComputedStyle(_probe).width);
|
||||
return Number.isFinite(v) ? v : dflt;
|
||||
}
|
||||
|
||||
/* Memoised because fitShell runs on every resize and mutation and resolving forces a style recalc —
|
||||
* but keyed on the DENSITY, because that is the one thing that changes these widths at runtime
|
||||
* (`prefs.applyDensity()` stamps `:root[data-density]` and calls fit.schedule() precisely so they
|
||||
* are re-measured). Reading one attribute is free; the memo without the key meant a density change
|
||||
* re-measured against the widths of the density before it. */
|
||||
/* The last resort, stated ONCE so the fallbacks and the sanity net below cannot restate the
|
||||
* stylesheet's widths in two different places. Reaching for these means the measurement failed. */
|
||||
const GEOM_DFLT = { contentMin: 500, sidebarW: 224, railW: 68, contentPad: 56 };
|
||||
|
||||
let _geom = null, _geomDensity = null;
|
||||
function shellGeometry() {
|
||||
const density = document.documentElement.getAttribute('data-density') || '';
|
||||
if (_geom && _geomDensity === density) return _geom;
|
||||
_geomDensity = density;
|
||||
const px = (name, dflt) => resolveLen(name, dflt);
|
||||
const g = {
|
||||
contentMin: px('--fs-content-min', GEOM_DFLT.contentMin),
|
||||
sidebarW: px('--fs-sidebar-w', GEOM_DFLT.sidebarW),
|
||||
railW: px('--fs-rail-w', GEOM_DFLT.railW),
|
||||
/* the token is ONE side's padding; the column loses it twice */
|
||||
contentPad: px('--fs-content-pad', GEOM_DFLT.contentPad / 2) * 2
|
||||
};
|
||||
/* Plausibility, and it costs one comparison: the rail IS the sidebar collapsed, so
|
||||
* 0 < railW < sidebarW holds by construction. Both known ways this measurement fails destroy
|
||||
* that — a hijacked probe reports ONE foreign width for all four (issue #19), a renamed or
|
||||
* absent token reports 0 for all four (an abs-positioned empty div with `width:auto` shrinks to
|
||||
* 0, and 0 is finite, so the per-read fallback above never fires). Neither can be seen in the
|
||||
* numbers one at a time; the RELATION between them is what gives it away. */
|
||||
_geom = (g.railW > 0 && g.railW < g.sidebarW && g.contentMin > 0) ? g : Object.assign({}, GEOM_DFLT);
|
||||
return _geom;
|
||||
}
|
||||
|
||||
function fitShell() {
|
||||
const root = document.documentElement;
|
||||
if (prefs.currentLayout() === 'top') { /* no sidebar, no cut, nothing to decide */
|
||||
root.removeAttribute('data-narrow');
|
||||
return;
|
||||
}
|
||||
const g = shellGeometry();
|
||||
const cut = prefs.currentRail() ? g.railW : g.sidebarW;
|
||||
/* clientWidth, not innerWidth: the column the content actually gets excludes a classic
|
||||
* scrollbar, and innerWidth includes it — 15-17px of phantom room on Linux/Windows, i.e. the
|
||||
* 500px floor really fired at ~484. */
|
||||
const content = document.documentElement.clientWidth - cut - g.contentPad;
|
||||
/* toggleAttribute, NOT setAttribute: a same-value setAttribute still QUEUES a mutation record
|
||||
* (measured in Chromium: 5 identical setAttribute('data-narrow','') -> 5 records; toggleAttribute
|
||||
* on an already-present attribute -> 0). fitShell runs from fitChrome, which fs-fit calls on every
|
||||
* mutation batch inside #view — i.e. once a second on any polled page. menu-footstrap observes
|
||||
* data-narrow and treats each record as a mode CHANGE, so on a phone (390 - 224 - 56 = 110 < 500,
|
||||
* so the attribute is permanently set) every poll tick re-fired closeFlyouts() and the section the
|
||||
* user had just tapped open snapped shut, forever. The bug was one-sided and therefore invisible
|
||||
* on a desktop: the else-branch removeAttribute on an absent attribute already fires 0 records. */
|
||||
root.toggleAttribute('data-narrow', content < g.contentMin);
|
||||
}
|
||||
|
||||
function fitChrome() {
|
||||
fitShell();
|
||||
|
||||
const bar = document.querySelector('.fs-sidebar');
|
||||
const menu = document.getElementById('topmenu');
|
||||
/* The top bar is MEASURED at every width — no 768 floor. It used to bail below 768 and hand
|
||||
* the job to a phone-bar media query, which left the sub-768 bar pinning its dropdowns to the
|
||||
* left edge and never collapsing "Refreshing"; the shrink/compact/stack escalation below now
|
||||
* runs at any width for the top layout. (The SIDEBAR layout still has its own phone bar,
|
||||
* decided by fitShell's data-narrow, and is untouched here.) */
|
||||
const topBar = !!bar && !!menu && prefs.isTopLayout();
|
||||
|
||||
if (bar) bar.classList.remove('fs-bar-stack', 'fs-ind-compact', 'fs-bar-actrow');
|
||||
fitTabStrips();
|
||||
/* ---- does the main menu fit on the brand's row? ----
|
||||
* Whether it fits depends on how many sections THIS router has (stock 5, a loaded box 11), not
|
||||
* on the viewport — so it is measured, not a breakpoint. `@media (max-width: 1199px)` stacked
|
||||
* it on every laptop: a stock bar's contents come to ~683px, i.e. one row fits down to ~723px.
|
||||
* Measured UNSTACKED (the remove above): a stacked menu owns a whole row and would "fit",
|
||||
* flipping straight back — oscillation.
|
||||
*
|
||||
* The menu's own pills wrapping IS the "does not fit" signal, but only because the unstacked
|
||||
* top bar is flex-wrap: nowrap (50-toplayout.css); otherwise the BAR wraps, hands the menu
|
||||
* a whole row, and it always "fits". Do NOT measure the bar's children by offsetTop instead:
|
||||
* the bar is align-items:center with children of differing heights, so their offsetTop differs
|
||||
* even on one row (that read as "wrapped" for a 5-section menu). */
|
||||
if (topBar && !stripFitsOneRow(menu)) {
|
||||
/* First step before stacking: collapse the poll pill ("Refreshing", ~90px) to an icon
|
||||
* square and re-measure — that width alone is often enough to keep the menu on the
|
||||
* brand's row and skip the second row entirely (styles/theme/50-toplayout.css). */
|
||||
bar.classList.add('fs-ind-compact');
|
||||
fitTabStrips();
|
||||
if (!stripFitsOneRow(menu)) {
|
||||
bar.classList.add('fs-bar-stack');
|
||||
fitTabStrips();
|
||||
}
|
||||
}
|
||||
|
||||
/* The cluster's own escalation, for EVERY bar — the top layout at any width, and the sidebar
|
||||
* layout once fitShell has stamped data-narrow. It runs after the menu's, because on the top
|
||||
* layout .fs-bar-stack has by then given the menu its own row and taken it out of the sum. */
|
||||
if (bar && (topBar || document.documentElement.hasAttribute('data-narrow')))
|
||||
fitCluster(bar, menu);
|
||||
}
|
||||
|
||||
/* ---- does the right-hand cluster still fit beside the brand? ----
|
||||
*
|
||||
* The same question as the menu's, one row up, and it cannot be asked the same way. The cluster is
|
||||
* four SIBLINGS — the indicators, Search, Appearance, Log out — so flexbox wraps them ONE AT A
|
||||
* TIME: measured on a 380px phone bar, Log out alone dropped onto a second row and sat at its LEFT
|
||||
* edge under the hostname, while the other three stayed up top. Either the whole cluster shares the
|
||||
* brand's row or it takes a row of its own, right-aligned; there is no useful state in between.
|
||||
*
|
||||
* Two steps, cheapest first: collapse the pills to icon squares (~200px of prose on a bar showing
|
||||
* both), and only if that still overflows give the cluster a row. .fs-ind-compact may already be
|
||||
* set by the menu's escalation above — adding it twice is free, and it must NOT be cleared here:
|
||||
* whoever asked for it still needs it. */
|
||||
function fitCluster(bar, menu) {
|
||||
bar.classList.remove('fs-bar-actrow');
|
||||
if (clusterFitsBrandRow(bar, menu))
|
||||
return;
|
||||
|
||||
bar.classList.add('fs-ind-compact');
|
||||
if (clusterFitsBrandRow(bar, menu))
|
||||
return;
|
||||
|
||||
bar.classList.add('fs-bar-actrow');
|
||||
}
|
||||
|
||||
/* Add the widths up rather than read positions: the bar is align-items:center over children of
|
||||
* differing heights, so offsetTop differs even on ONE row — the same trap the menu measurement
|
||||
* documents above. The menu is excluded because it owns a full-width row of its own in every bar
|
||||
* (`ul.nav { flex: 1 1 100% }`), so it is never what the cluster competes with. */
|
||||
function clusterFitsBrandRow(bar, menu) {
|
||||
const cs = getComputedStyle(bar);
|
||||
const gap = parseFloat(cs.columnGap) || 0;
|
||||
const room = bar.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
|
||||
let need = -gap;
|
||||
|
||||
for (const el of bar.children) {
|
||||
/* offsetParent is null for a display:none child, which is most of them in a bar:
|
||||
* .fs-navlabel, .fs-spacer and #modemenu never show there, and #indicators is empty
|
||||
* (and so hidden) until the first poll pill lands. */
|
||||
if (el === menu || el.offsetParent === null)
|
||||
continue;
|
||||
need += el.offsetWidth + gap;
|
||||
}
|
||||
|
||||
return need <= room;
|
||||
}
|
||||
/* No observer and no resize listener of our own: fs-fit owns both, and this file used to grow the
|
||||
* second one docs/conventions.md warns against. A view renders its .cbi-tabmenu into #view, which fs-fit's
|
||||
* MutationObserver already watches — and it re-fits SYNCHRONOUSLY (rule 2), where the copy here
|
||||
* deferred through fit.schedule(), i.e. the duplicate was strictly the slower path into the same
|
||||
* work. #tabmenu is a sibling of #view rather than inside it, but nothing writes it except
|
||||
* renderChrome(), which schedules a fit itself. Resize is fs-fit's ResizeObserver on #view (with a
|
||||
* window-resize fallback where there is no RO). */
|
||||
|
||||
/* modes -> #modemenu; drives the injected renderMainMenu for the active mode */
|
||||
function renderModeMenu(node, renderMainMenu) {
|
||||
const ul = document.querySelector('#modemenu');
|
||||
const children = ui.menu.getChildren(node);
|
||||
|
||||
children.forEach((child, index) => {
|
||||
const isActive = L.env.requestpath.length
|
||||
? child.name === L.env.requestpath[0]
|
||||
: index === 0;
|
||||
|
||||
/* the main menu must render even if a template has no #modemenu — only the mode
|
||||
* list itself is skippable chrome */
|
||||
if (ul)
|
||||
ul.appendChild(E('li', { 'class': isActive ? 'active' : '' }, [
|
||||
E('a', { 'href': L.url(child.name) }, [ _(child.title) ])
|
||||
]));
|
||||
|
||||
if (isActive)
|
||||
renderMainMenu(child, child.name);
|
||||
});
|
||||
|
||||
if (!ul)
|
||||
return;
|
||||
if (children.length <= 1)
|
||||
ul.classList.add('single');
|
||||
if (ul.children.length > 1)
|
||||
ul.style.display = '';
|
||||
}
|
||||
|
||||
/* rebuild mode menu + main menu + section tabs from the current L.env; on first load and after
|
||||
* every SPA nav. Containers are cleared first so a re-render does not stack duplicates. */
|
||||
function renderChrome() {
|
||||
const root = tree.tree();
|
||||
const modemenu = document.querySelector('#modemenu');
|
||||
const topmenu = document.querySelector('#topmenu');
|
||||
const tabmenu = document.querySelector('#tabmenu');
|
||||
|
||||
if (modemenu) { modemenu.innerHTML = ''; modemenu.style.display = 'none'; modemenu.classList.remove('single'); }
|
||||
if (topmenu) topmenu.innerHTML = '';
|
||||
if (tabmenu) { tabmenu.innerHTML = ''; tabmenu.style.display = 'none'; }
|
||||
|
||||
renderModeMenu(root, _renderMain);
|
||||
|
||||
if (L.env.dispatchpath.length >= 3) {
|
||||
let node = root, url = '';
|
||||
/* `node.children &&`, exactly as fs-menutree's nodeForSegs() walks it: a node without
|
||||
* children is an ordinary leaf, and reading `.children[…]` off one is a TypeError that
|
||||
* escapes renderChrome() — i.e. it takes out the mode menu, the tabs and, on the init path,
|
||||
* everything menu-footstrap-common wires after it. The walk already tests `node` on each
|
||||
* step; testing only half of what it dereferences is what left the hole. */
|
||||
for (let i = 0; i < 3 && node; i++) {
|
||||
node = node.children && node.children[L.env.dispatchpath[i]];
|
||||
url = url + (url ? '/' : '') + L.env.dispatchpath[i];
|
||||
}
|
||||
if (node)
|
||||
renderTabMenu(node, url);
|
||||
}
|
||||
|
||||
fit.schedule();
|
||||
}
|
||||
|
||||
/* Sidebar rail toggle: collapse the sidebar to an icon-only strip. The state lives on
|
||||
* <html data-rail> (head.ut re-applies it before paint) and in localStorage; everything else —
|
||||
* flyout submenus, hidden labels — is CSS keyed off that attribute. */
|
||||
function wireRail() {
|
||||
const btn = document.getElementById('fs-rail-toggle');
|
||||
if (!btn) return;
|
||||
|
||||
function sync() {
|
||||
const on = prefs.currentRail();
|
||||
btn.setAttribute('aria-expanded', on ? 'false' : 'true');
|
||||
const label = on ? _('Expand menu') : _('Collapse menu');
|
||||
btn.setAttribute('aria-label', label);
|
||||
btn.setAttribute('title', label);
|
||||
}
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
prefs.applyRail(!prefs.currentRail());
|
||||
sync();
|
||||
/* the sidebar's cut just changed by ~156px, so the content column may now clear (or fall
|
||||
* below) --fs-content-min: re-measure rather than wait for a resize that is not coming */
|
||||
fit.schedule();
|
||||
});
|
||||
|
||||
sync();
|
||||
}
|
||||
|
||||
/* An indicator pill carries its whole meaning as prose — LuCI writes "Unsaved Changes: 2" — and the
|
||||
* collapsed rail is 68px wide. Measured on the router: the pill wants 86px, wraps onto three lines
|
||||
* and hangs 34px past the rail's edge over the content (issue #14). The rail is an icon strip, so
|
||||
* CSS squares the pill there and draws this attribute instead of the label; a text node cannot be
|
||||
* reached by a selector, which is why the badge has to be lifted into one here.
|
||||
*
|
||||
* The COUNT is what it shows — the only part that changes and the only part worth reading at that
|
||||
* size. A pill with no trailing number (a third-party app's "Backup pending") falls back to a
|
||||
* neutral dot: clipping the prose was tried and rendered "up pen", because a centred pill gives an
|
||||
* ellipsis no start to anchor to. Choosing between the two is a decision, so it lives here rather
|
||||
* than as a second CSS rule. The full prose stays in the label either way — a screen reader still
|
||||
* reads it, and `title` keeps it reachable by pointer. */
|
||||
const IND_DOT = '•';
|
||||
|
||||
function wireIndicatorCounts() {
|
||||
const box = document.getElementById('indicators');
|
||||
if (!box) return;
|
||||
|
||||
function stamp() {
|
||||
box.querySelectorAll('[data-indicator]').forEach((el) => {
|
||||
const txt = el.textContent || '';
|
||||
const m = txt.match(/(\d+)\s*$/);
|
||||
el.setAttribute('data-fs-badge', m ? m[1] : IND_DOT);
|
||||
/* the rail hides the prose; the tooltip is where it stays reachable by pointer */
|
||||
el.setAttribute('title', txt);
|
||||
});
|
||||
}
|
||||
|
||||
/* ui.showIndicator REPLACES the label's text node on an update ("Unsaved Changes: 1" -> ": 2")
|
||||
* and appends the span on the first change of the session, so both matter — and characterData
|
||||
* too, for the in-place rewrite. Our own attribute writes do not re-enter: attributes are not
|
||||
* observed. */
|
||||
new MutationObserver(stamp).observe(box, { childList: true, subtree: true, characterData: true });
|
||||
stamp();
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
setRenderMain,
|
||||
renderChrome,
|
||||
wireIndicatorCounts,
|
||||
/* registered with fs-fit by the theme's init(): the bar's "does the menu fit beside the brand"
|
||||
* measurement rides the same engine as the data tables' */
|
||||
fitChrome,
|
||||
wireRail
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
|
||||
/* fs-fit — the theme's ONE "does it still fit?" engine; add fit logic here, do not grow a second
|
||||
* observer. No CSS query can ask what the CONTENT needs (media = viewport, container =
|
||||
* container): does the menu fit beside the brand, is a table still readable? Both were once
|
||||
* breakpoints (one @media, five @container thresholds) — guessed numbers that real routers got
|
||||
* wrong, useless for a third-party luci-app-* table of unknown column count.
|
||||
*
|
||||
* THREE RULES, each a bug that was hit:
|
||||
* 1. MEASURE UNCOLLAPSED — a collapsed thing always "fits" (a stacked table is a pile of flex
|
||||
* rows): read it as it stands and it un-collapses, next frame re-collapses. Oscillation.
|
||||
* 2. RE-FIT SYNCHRONOUSLY ON A MUTATION — the poll re-renders content once a second and the
|
||||
* fresh element has lost our class. A MutationObserver callback is a microtask (pre-paint),
|
||||
* rAF runs AT paint: deferring there painted a stacked table one frame at full width —
|
||||
* 19-109px of overflow, once a second, on Firewall/DHCP/Wireless.
|
||||
* 3. COALESCE ON RESIZE — every fit forces a synchronous layout.
|
||||
*
|
||||
* ResizeObserver, not onresize: a rail collapse and a layout toggle change the content width
|
||||
* without resizing the window. */
|
||||
|
||||
const _fitters = [];
|
||||
let _rafPending = false;
|
||||
let _ro = null, _mo = null;
|
||||
|
||||
/* Run every fitter NOW, synchronously. A fitter must be idempotent — this fires on every
|
||||
* relevant mutation. */
|
||||
function run() {
|
||||
for (const fit of _fitters) {
|
||||
try { fit(); }
|
||||
/* one broken fitter must take neither the others nor the poll's MutationObserver
|
||||
* callback with it — that would silently stop ALL re-fitting */
|
||||
catch (e) { console.error('fs-fit: a fitter threw', e); }
|
||||
}
|
||||
}
|
||||
|
||||
/* Next frame, at most once per frame (rule 3). */
|
||||
function schedule() {
|
||||
if (_rafPending) return;
|
||||
_rafPending = true;
|
||||
requestAnimationFrame(() => { _rafPending = false; run(); });
|
||||
}
|
||||
|
||||
/* Watch an element's size. Any change re-fits everything — the fitters are cheap and few. */
|
||||
function watch(el) {
|
||||
if (!el) return;
|
||||
if (!_ro) {
|
||||
if (!window.ResizeObserver) { /* no RO: fall back to the window */
|
||||
window.addEventListener('resize', schedule);
|
||||
return;
|
||||
}
|
||||
_ro = new ResizeObserver(schedule);
|
||||
}
|
||||
_ro.observe(el);
|
||||
}
|
||||
|
||||
/* Rule 2's mutation side. Deliberately NOT filtered by node type: a filter is a second place to
|
||||
* get wrong (the table fitter's own once said `table.table`, and LuCI renders most of its tables
|
||||
* as DIVs — so the poll never re-measured at all), and run() is a handful of measurements. */
|
||||
function observeContent() {
|
||||
if (_mo) return;
|
||||
const host = document.getElementById('view') || document.body;
|
||||
_mo = new MutationObserver(run);
|
||||
_mo.observe(host, { childList: true, subtree: true });
|
||||
watch(host);
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
/* Register a fitter and run it once. A fitter selects its own elements, strips its class
|
||||
* (rule 1), measures, re-applies. */
|
||||
add(fit) {
|
||||
if (typeof fit !== 'function') return;
|
||||
_fitters.push(fit);
|
||||
observeContent();
|
||||
fit();
|
||||
},
|
||||
|
||||
/* Re-fit on the next frame, coalesced. (There is no exported `run`: everything that changes
|
||||
* the available room — the layout toggle, the rail collapse — schedules. Only the mutation
|
||||
* observer re-fits synchronously, and that is rule 2's whole point.) */
|
||||
schedule,
|
||||
|
||||
/* Coalesce ANY callback into one call per frame (rule 3, for non-fitters): schedule() runs
|
||||
* EVERY fitter, so a caller wanting only its own work batched cannot use it — three had
|
||||
* hand-rolled the identical five lines. NOT for the per-element case: menu-footstrap.js's
|
||||
* clamp keeps a rAF handle per <li> so it can CANCEL a pending measure, which a one-flag
|
||||
* coalescer cannot express. */
|
||||
frame(fn) {
|
||||
let pending = false;
|
||||
return () => {
|
||||
if (pending) return;
|
||||
pending = true;
|
||||
requestAnimationFrame(() => { pending = false; fn(); });
|
||||
};
|
||||
},
|
||||
|
||||
/* Did this batch add anything matching `sel`? The poll rewrites content once a second, so a
|
||||
* MutationObserver here needs that cheap question before its document-wide queries. */
|
||||
touches(mutations, sel) {
|
||||
for (const m of mutations)
|
||||
for (const n of m.addedNodes) {
|
||||
if (n.nodeType !== 1) continue;
|
||||
if (n.matches(sel) || n.querySelector(sel)) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
/* Room for `el` = its PARENT's content box. Measuring against ITSELF does not work: a
|
||||
* `display: table` box with width:100% still grows past it when min-content needs more (auto
|
||||
* layout beats the declared width), so scrollWidth and clientWidth grow together and the
|
||||
* overflow is invisible. The parent is an ordinary block and does not grow. */
|
||||
roomFor(el) {
|
||||
const p = el && el.parentElement;
|
||||
if (!p) return Infinity;
|
||||
const cs = getComputedStyle(p);
|
||||
return p.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
|
||||
},
|
||||
|
||||
/* Does `el` need more width than it has been given? */
|
||||
overflows(el) {
|
||||
return el.scrollWidth > this.roomFor(el) + 1; /* +1: sub-pixel rounding */
|
||||
},
|
||||
|
||||
/* How many LINE BOXES of text does `el` render? The fact behind "this column has been
|
||||
* squeezed into a tower" — a cell that has to break its own words is a column that has run
|
||||
* out of width, which is a thing no viewport query can ask.
|
||||
*
|
||||
* NOT height / line-height: the assoclist's first cell is an `.ifacebadge`, a flex COLUMN
|
||||
* with a 32px icon over the text, so a third of that height is not text at all. Ranges over
|
||||
* the TEXT NODES only, so an <img> cannot be counted as a line.
|
||||
*
|
||||
* Cluster by the rect's TOP, not by vertical overlap: consecutive lines OVERLAP. Measured on
|
||||
* the router — tops 15-16px apart while each rect is 17-18px tall (the font's box is taller
|
||||
* than the line advance), so an overlap test merged an 8-line tower into ONE line and the
|
||||
* whole check silently never fired. Half a line of tolerance also merges what belongs on one
|
||||
* line: a `<small>` shares the baseline but sits a few px lower, and is not a new line. */
|
||||
textLines(el) {
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
const range = document.createRange();
|
||||
const rects = [];
|
||||
for (let n; (n = walker.nextNode());) {
|
||||
if (!n.nodeValue.trim()) continue;
|
||||
range.selectNodeContents(n);
|
||||
for (const r of range.getClientRects())
|
||||
if (r.width > 0.5 && r.height > 0.5) rects.push(r);
|
||||
}
|
||||
rects.sort((a, b) => a.top - b.top);
|
||||
let lines = 0, top = -Infinity;
|
||||
for (const r of rects)
|
||||
if (r.top - top > r.height * 0.5) { lines++; top = r.top; }
|
||||
return lines;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
|
||||
/* The menu tree as a ROUTING TABLE: path <-> node, with LuCI's alias/firstchild resolution.
|
||||
*
|
||||
* Pure lookup — it renders nothing and touches no DOM. Both the chrome (which walks the tree to
|
||||
* draw the mode menu and the section tabs) and the SPA router (which asks "what view does this URL
|
||||
* open?") need it, and a module they both require is the only way to give them one copy without a
|
||||
* cycle: LuCI's require() raises DependencyError on a dependency loop, so the shared half has to
|
||||
* come OUT rather than be reached across. */
|
||||
|
||||
/* the ACL-filtered tree from /admin/menu, handed over once by the chrome's init() */
|
||||
let _tree = null;
|
||||
|
||||
function setTree(tree) {
|
||||
_tree = tree;
|
||||
}
|
||||
|
||||
/* /cgi-bin/luci/admin/status/overview -> ['admin','status','overview'].
|
||||
* The bare base (what build_url() emits for the brand wordmark) yields an EMPTY seg list, NOT null:
|
||||
* the dispatcher's root node is itself a `firstchild`, so resolveSegs([]) walks to the overview
|
||||
* exactly as the server does — returning null made the wordmark un-routable and full-reload. null
|
||||
* stays reserved for a path outside LuCI's scriptname. */
|
||||
function segsFromPath(pathname) {
|
||||
const base = L.env.scriptname || '';
|
||||
if (base && pathname.indexOf(base) !== 0)
|
||||
return null;
|
||||
const rest = pathname.slice(base.length).replace(/^\/+|\/+$/g, '');
|
||||
return rest.length ? rest.split('/') : [];
|
||||
}
|
||||
|
||||
/* walk the (scrubbed, ACL-filtered) menu tree to the node for a path */
|
||||
function nodeForSegs(segs) {
|
||||
let node = _tree;
|
||||
for (let i = 0; i < segs.length; i++) {
|
||||
node = node && node.children && node.children[segs[i]];
|
||||
if (!node) return null;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/* ---- alias / firstchild resolution ----
|
||||
*
|
||||
* 7 of the 27 menu links are redirects, not pages: 4 `alias` (Firewall, System Log, Realtime
|
||||
* Graphs) and 3 `firstchild` (Administration, Terminal, Attended Sysupgrade) — i.e. the
|
||||
* most-clicked entries were the ones still doing a full load.
|
||||
*
|
||||
* The server does not redirect them: a full GET of /admin/status/logs answers 200 at that URL and
|
||||
* stamps the RESOLVED leaf into requestpath/dispatchpath/nodespec, keeping `pathinfo` as requested.
|
||||
* The client must resolve EXACTLY as dispatcher.uc does, or a click and an F5 on the same URL would
|
||||
* open different pages — nodeWeight() and firstChildOf() are ports, not approximations. Only the
|
||||
* ACL check is skipped: the tree from /admin/menu is already ACL-filtered for this session.
|
||||
*
|
||||
* `rewrite` is deliberately NOT followed: the tree has none, and a wrong guess at its splice
|
||||
* semantics would silently open the WRONG page — worse than the full load it falls back to. */
|
||||
|
||||
/* node_weight() from dispatcher.uc: lower wins; a login node sorts last. */
|
||||
function nodeWeight(node) {
|
||||
return Math.min(node.order ?? 9999, 9999) + (node.auth && node.auth.login ? 10000 : 0);
|
||||
}
|
||||
|
||||
/* resolve_firstchild() from dispatcher.uc: the eligible child of lowest weight. Ties go to tree
|
||||
* order (the comparison is strict, as upstream's is, and JSON.parse preserves key order). A
|
||||
* `firstchild` child is eligible only if it resolves to something itself — recursively. */
|
||||
function firstChildOf(node) {
|
||||
let bestName = null, best = null;
|
||||
const kids = node.children || {};
|
||||
for (const name in kids) {
|
||||
const child = kids[name];
|
||||
if (!child.satisfied || !child.title || !child.action || typeof child.action !== 'object')
|
||||
continue;
|
||||
if (child.action.type === 'firstchild') {
|
||||
if ((!best || nodeWeight(best) > nodeWeight(child)) && firstChildOf(child)) {
|
||||
best = child; bestName = name;
|
||||
}
|
||||
} else if (!child.firstchild_ineligible) {
|
||||
if (!best || nodeWeight(best) > nodeWeight(child)) {
|
||||
best = child; bestName = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
return best ? { name: bestName, node: best } : null;
|
||||
}
|
||||
|
||||
/* Follow alias/firstchild to the real page: {segs, node} of the leaf the dispatcher would have
|
||||
* rendered, or null when nothing resolves (the server would 404 — let it). The hop cap is a cycle
|
||||
* guard: an alias loop in some app's menu.d must not hang the UI. */
|
||||
function resolveSegs(segs) {
|
||||
let node = nodeForSegs(segs);
|
||||
for (let hops = 0; node && node.action && hops < 8; hops++) {
|
||||
const type = node.action.type;
|
||||
if (type === 'alias') {
|
||||
segs = String(node.action.path).split('/');
|
||||
node = nodeForSegs(segs);
|
||||
} else if (type === 'firstchild') {
|
||||
const pick = firstChildOf(node);
|
||||
if (!pick) return null;
|
||||
segs = segs.concat([ pick.name ]);
|
||||
node = pick.node;
|
||||
} else {
|
||||
return { segs, node };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ---- readonly is a property of the PATH, not of the leaf ----
|
||||
*
|
||||
* The dispatcher decides it twice over, from two different inputs. apply_tree_acls()
|
||||
* (dispatcher.uc:442) walks the menu JSON handed to the client and marks a NODE readonly when its
|
||||
* OWN `depends.acl` resolves to read-without-write. The request path instead accumulates every
|
||||
* ancestor's acls into ctx.acls and stamps the leaf from the accumulation
|
||||
* (`resolved.node.readonly = !perm`, :1003). So a leaf that declares no acl of its own still comes
|
||||
* back readonly from a real GET, while its node in the tree carries nothing at all.
|
||||
*
|
||||
* Reading the leaf alone therefore loses it, and did: measured on the stand, a full load of
|
||||
* admin/status/logs/syslog reports nodespec.readonly true — the flag sits on `logs`, two levels up —
|
||||
* against false on an SPA arrival, and the same for dmesg and all four realtime graphs. luci.js
|
||||
* implements hasViewPermission() as `!env.nodespec.readonly`, which is what views and luci.js's own
|
||||
* Save/Apply footer key their disabled state off, so on a session with narrower ACLs than root's
|
||||
* this is any page whose SECTION is read-only.
|
||||
*
|
||||
* OR down the path, exactly as check_acl_depends() folds the accumulated list: one read-only acl
|
||||
* anywhere on it is enough. Feed it the RESOLVED segments, since that is the path the dispatcher
|
||||
* accumulates over. */
|
||||
function readonlyForSegs(segs) {
|
||||
let node = _tree;
|
||||
if (node && node.readonly === true)
|
||||
return true;
|
||||
for (let i = 0; i < segs.length; i++) {
|
||||
node = node && node.children && node.children[segs[i]];
|
||||
if (!node)
|
||||
return false;
|
||||
if (node.readonly === true)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* The view class a menu node instantiates, or null if the node isn't SPA-able. The Status→Overview
|
||||
* `template` node maps to view.status.index (its server template just instantiates that — see
|
||||
* ensureOverviewHelpers in fs-router.js). Shared by navigate() and the hover prefetch. */
|
||||
function viewClassFor(node) {
|
||||
if (!node || !node.action || node.satisfied === false)
|
||||
return null;
|
||||
if (node.action.type === 'view')
|
||||
return 'view.' + String(node.action.path).replace(/\//g, '.');
|
||||
if (node.action.type === 'template' && node.action.path === 'admin_status/index')
|
||||
return 'view.status.index';
|
||||
return null;
|
||||
}
|
||||
|
||||
/* The node the CURRENT full-load landed on, i.e. what L.env.dispatchpath points at. */
|
||||
function currentNode() {
|
||||
return nodeForSegs(L.env.dispatchpath || []);
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
setTree,
|
||||
tree: () => _tree,
|
||||
segsFromPath,
|
||||
currentNode,
|
||||
resolveSegs,
|
||||
readonlyForSegs,
|
||||
viewClassFor
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require dom';
|
||||
'require network';
|
||||
'require fs-fit as fit';
|
||||
|
||||
/* Footstrap overview LAYOUT-only module: renders NOTHING of its own, only re-arranges the STOCK
|
||||
* sections — wrapping System / Memory / Storage in a grid so Memory and Storage sit in a right
|
||||
* column beside System. Content, data and styling stay luci-mod-status's. (Do not go back to the
|
||||
* old 05_footstrap_dashboard.js: re-rendering a custom tree every poll flickered and reset mobile
|
||||
* scroll.) The stock poll updates each section IN PLACE via dom.content() and never rebuilds the
|
||||
* .cbi-section wrapper, so once moved into our grid the wrappers stay put across polls.
|
||||
*
|
||||
* IT USED TO LIVE IN LuCI'S GLOBAL INCLUDE DIR (view/status/include/05_footstrap_overview_layout.js)
|
||||
* and that was a real defect, not a filing preference: luci-mod-status loads EVERY *.js in that
|
||||
* directory, so this file was fetched, parsed and evaluated on the overview of every router running
|
||||
* a DIFFERENT theme. Measured with a headless browser against the dev container with `bootstrap`
|
||||
* active: the request is right there beside 10_system.js and 20_memory.js. The `L.env.media` gate
|
||||
* silenced it, but only after it had already been downloaded and run — a theme package reaching
|
||||
* into another module's namespace, which is exactly what this project refuses to do to third-party
|
||||
* apps (docs/conventions.md, the three zones). It is a chrome module now, loaded by the chrome, so a router on
|
||||
* another theme never sees it at all.
|
||||
*
|
||||
* WHAT THAT COST, and why the code below looks the way it does: the old location bought two timing
|
||||
* guarantees for free, because LuCI evaluated the file INSIDE index.load(). Both had to be paid for
|
||||
* explicitly — see ensureOverviewHelpers() and patchOverview() at the bottom. */
|
||||
function isFootstrapTheme() {
|
||||
return String(L.env.media || '').indexOf('footstrap') >= 0;
|
||||
}
|
||||
|
||||
/* section title -> grid role. _() with NO msgctxt on purpose: these must resolve to exactly what
|
||||
* luci-mod-status resolves to, or the titles stop matching. Built once — it used to cost an
|
||||
* allocation plus three _() lookups per poll tick. */
|
||||
const ROLES = { [_('System')]: 'sys', [_('Memory')]: 'mem', [_('Storage')]: 'sto' };
|
||||
|
||||
function sectionTitle(sec) {
|
||||
/* TWO title markups, one per release: 25.12 wraps the heading (`.cbi-title > h3`), 24.10
|
||||
* emits a bare `<h3>` as the section's first child. Matching only the wrapped one meant the
|
||||
* grid never applied on 24.10 at all — measured on the dev container: every section tagged
|
||||
* `none`, `.fs-ovl` never built, silently. */
|
||||
const h = sec.querySelector('.cbi-title h3, :scope > h3');
|
||||
return (h && h.firstChild) ? String(h.firstChild.nodeValue || '').trim() : '';
|
||||
}
|
||||
|
||||
/* the wrapper we built, so the poll-tick fast path costs one property read */
|
||||
let _wrapEl = null;
|
||||
|
||||
function arrange() {
|
||||
/* the SPA nav can leave this _observer wired while another page renders into #view — detach as
|
||||
* soon as the route stops being the overview. Both the server template and the SPA router
|
||||
* stamp body[data-page] with the DISPATCH path, so /admin/status (firstchild -> overview)
|
||||
* matches too. */
|
||||
if ((document.body.getAttribute('data-page') || '') !== 'admin-status-overview') {
|
||||
stopWatch();
|
||||
return;
|
||||
}
|
||||
const view = document.getElementById('view');
|
||||
if (!view) return;
|
||||
|
||||
/* Fast path — the poll lands here once a second, forever. The stock poll never rebuilds the
|
||||
* .cbi-section wrappers, so the grid survives and there is nothing to do; proving that used
|
||||
* to cost a querySelectorAll over #view plus a sectionTitle() dig per section, every tick.
|
||||
* Deliberately NOT a disconnect(): if a future luci-mod-status ever DOES rebuild a section,
|
||||
* the wrapper loses its children and the slow path below rebuilds the grid — self-healing. */
|
||||
if (_wrapEl && _wrapEl.isConnected && _wrapEl.parentElement === view && _wrapEl.children.length === 3)
|
||||
return;
|
||||
|
||||
const found = {};
|
||||
view.querySelectorAll(':scope > .cbi-section').forEach((sec) => {
|
||||
const r = ROLES[sectionTitle(sec)];
|
||||
if (r && !found[r]) found[r] = sec;
|
||||
});
|
||||
/* wait until all three stock sections exist */
|
||||
if (!(found.sys && found.mem && found.sto)) return;
|
||||
/* already wrapped? (first tick after a rebuild re-finds the existing grid) */
|
||||
if (found.sys.parentElement && found.sys.parentElement.classList.contains('fs-ovl')) {
|
||||
_wrapEl = found.sys.parentElement;
|
||||
return;
|
||||
}
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'fs-ovl';
|
||||
found.sys.parentNode.insertBefore(wrap, found.sys);
|
||||
found.sys.classList.add('fs-ovl-sys'); wrap.appendChild(found.sys);
|
||||
found.mem.classList.add('fs-ovl-mem'); wrap.appendChild(found.mem);
|
||||
found.sto.classList.add('fs-ovl-sto'); wrap.appendChild(found.sto);
|
||||
_wrapEl = wrap;
|
||||
}
|
||||
|
||||
/* Stock sections render async and repaint every poll, so watch #view and re-run arrange()
|
||||
* (coalesced, ONE _observer per #view node — a per-poll _observer leak would slow the page down).
|
||||
* The SPA router may REPLACE the #view element between visits, so re-attach when the node we
|
||||
* observed is no longer the current one: a singleton bound to the first #view would silently
|
||||
* watch a detached tree and the grid would never apply on a later SPA visit. */
|
||||
let _observer = null, _observedView = null, _routeObserver = null;
|
||||
function stopWatch() {
|
||||
if (_observer) _observer.disconnect();
|
||||
_observer = null;
|
||||
_observedView = null;
|
||||
_wrapEl = null; /* the grid belongs to the #view we are leaving */
|
||||
}
|
||||
function watch() {
|
||||
const view = document.getElementById('view');
|
||||
if (_observer && _observedView !== view)
|
||||
stopWatch();
|
||||
arrange();
|
||||
/* The route check is what the stock include's render() used to provide implicitly — it only ran
|
||||
* when luci-mod-status rendered THIS page. A chrome module is alive on every page, so without
|
||||
* this an observer would be attached to #view on, say, the firewall page and re-run arrange()
|
||||
* for every mutation of a table it has no business watching. */
|
||||
if (_observer || !view ||
|
||||
(document.body.getAttribute('data-page') || '') !== 'admin-status-overview')
|
||||
return;
|
||||
_observedView = view;
|
||||
/* one arrange() per frame, however many mutations a poll tick delivers (fit.frame — the
|
||||
* theme's shared coalescer, fs-fit.js) */
|
||||
_observer = new MutationObserver(fit.frame(arrange));
|
||||
_observer.observe(view, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
/* WHAT REPLACED render(). The include was instantiated by luci-mod-status once per overview render,
|
||||
* which is how it knew a visit had happened; a chrome module is instantiated once per PAGE LOAD and
|
||||
* then has to notice SPA navigation itself. `body[data-page]` is the signal — both the server
|
||||
* template and fs-router stamp it with the dispatch path — so one attribute observer covers arriving
|
||||
* at the overview, leaving it, and coming back.
|
||||
*
|
||||
* The empty `.cbi-section` wrapper stock used to build around this include is gone with it, and so
|
||||
* is the `.fs-ovl-marker` element that existed only to let CSS hide that wrapper. */
|
||||
function wire() {
|
||||
if (_routeObserver || !isFootstrapTheme() || !document.body)
|
||||
return;
|
||||
_routeObserver = new MutationObserver(() => {
|
||||
if ((document.body.getAttribute('data-page') || '') === 'admin-status-overview')
|
||||
onOverview();
|
||||
else
|
||||
stopWatch();
|
||||
});
|
||||
_routeObserver.observe(document.body, { attributes: true, attributeFilter: [ 'data-page' ] });
|
||||
if ((document.body.getAttribute('data-page') || '') === 'admin-status-overview')
|
||||
onOverview();
|
||||
}
|
||||
|
||||
/* Everything that must happen on ARRIVAL at the overview, from either direction: a full page load
|
||||
* that lands here, or an SPA navigation that restamps data-page. patchOverview() is idempotent
|
||||
* (the __fsProgressive flag), so the two paths cannot double-patch. */
|
||||
function onOverview() {
|
||||
patchOverview();
|
||||
watch();
|
||||
}
|
||||
|
||||
/* ---- progressive paint -----------------------------------------------------
|
||||
*
|
||||
* Stock `view.status.index` calls poll_status(first_load=true), which Promise.all's over EVERY
|
||||
* include's load(), and render() does not return the tree until it resolves — so #view stays
|
||||
* EMPTY for as long as the slowest include takes. Measured on the dev router (warm SPA nav):
|
||||
* 182 ms of blank page, of which System/CPU/Memory/Storage/DHCP/Network were ready at 88 ms and
|
||||
* were simply waiting on 29_ports and 60_wifi (180 ms each).
|
||||
*
|
||||
* Replacing poll_status does two things:
|
||||
* 1. Each section paints when ITS OWN data lands: first content halves, 182 -> ~90 ms. Nothing
|
||||
* jumps — the frames are already in the DOM (built before poll_status is called), a section
|
||||
* just goes hidden -> filled, exactly as on a stock poll tick.
|
||||
* 2. Kills the redundant re-fetch: stock adds the poller only after the first load completes
|
||||
* and Poll.add() steps at once, so the overview re-fetched EVERYTHING (~250 ms of ubus)
|
||||
* right after the first paint. The in-flight guard joins that to the run already going.
|
||||
*
|
||||
* NOT a re-implementation — frames, toggles, includes and their render() stay upstream's.
|
||||
* fillSection() transcribes stock's own loop in the same order so it can be diffed against
|
||||
* index.js when luci-mod-status changes; if that shape is gone, the patch is skipped and the
|
||||
* page runs stock. */
|
||||
function fillSection(inc, container, res) {
|
||||
if (inc.failed)
|
||||
return;
|
||||
let content = null;
|
||||
if (typeof inc.render === 'function')
|
||||
content = inc.render(res);
|
||||
else if (inc.content != null)
|
||||
content = inc.content;
|
||||
if (typeof inc.oneshot === 'function') {
|
||||
inc.oneshot(res);
|
||||
inc.oneshot = null;
|
||||
}
|
||||
if (content != null) {
|
||||
container.parentNode.style.display = '';
|
||||
container.parentNode.classList.add('fade-in');
|
||||
if (!inc.hide)
|
||||
dom.content(container, content);
|
||||
}
|
||||
}
|
||||
|
||||
let _inflight = null;
|
||||
|
||||
function pollProgressive(includes, containers, first_load) {
|
||||
/* A run is already fetching exactly this data — join it instead of starting a second
|
||||
* stampede of the same RPCs. This is what kills the duplicate load. */
|
||||
if (_inflight)
|
||||
return first_load ? Promise.resolve() : _inflight;
|
||||
|
||||
const run = network.flushCache().then(() => Promise.all(
|
||||
includes.map((inc, i) => {
|
||||
if (inc.hide && !first_load)
|
||||
return null;
|
||||
const loaded = (typeof inc.load === 'function')
|
||||
? Promise.resolve(inc.load()).catch(() => { inc.failed = true; })
|
||||
: Promise.resolve(null);
|
||||
/* the point of the patch: fill THIS section the moment ITS data is here,
|
||||
* not at the end of a Promise.all over all of them */
|
||||
return loaded.then((res) => {
|
||||
try { fillSection(inc, containers[i], res); }
|
||||
catch (e) { console.error('footstrap: overview section failed', e); }
|
||||
});
|
||||
}).filter(Boolean)
|
||||
)).then(() => {
|
||||
const ssi = document.querySelector('div.includes');
|
||||
if (ssi) { ssi.style.display = ''; ssi.classList.add('fade-in'); }
|
||||
});
|
||||
|
||||
_inflight = run.finally(() => { _inflight = null; });
|
||||
/* NOBODY AWAITS THIS ON THE FIRST LOAD — the line below hands the caller a fresh
|
||||
* Promise.resolve() so index.render() can return at once — so a rejection here has no handler
|
||||
* and surfaces as an unhandled rejection in the console. `run` rejects for one ordinary reason:
|
||||
* network.flushCache() failing on an expired session, i.e. exactly when the user is already
|
||||
* being redirected to the login page and the noise is least useful. The sections' own failures
|
||||
* cannot reach it (fillSection is called inside a try/catch and inc.load() has its own .catch),
|
||||
* so there is nothing to report that the page has not reported already. */
|
||||
_inflight.catch(() => {});
|
||||
|
||||
/* First load: resolve NOW so index.render() returns its tree and the frames reach #view
|
||||
* immediately; the sections fill themselves. A poll tick resolves when the data is in —
|
||||
* that is what the poller expects. */
|
||||
return first_load ? Promise.resolve() : _inflight;
|
||||
}
|
||||
|
||||
/* Patch the stock overview view: replace poll_status so each section paints when its own data lands.
|
||||
*
|
||||
* TIMING, AND WHAT MOVING THE FILE COST. As an include this ran at module eval — which LuCI performs
|
||||
* inside index.load(), i.e. after the view instance exists and before render() calls poll_status:
|
||||
* the exact window the patch needs, for free, on both a full load and an SPA nav.
|
||||
*
|
||||
* A chrome module evaluates much earlier, so that window has to be aimed at rather than inherited,
|
||||
* and it is called from the ROUTE (see wire()) instead of at eval. Two reasons it is not called at
|
||||
* module eval any more: `L.require('view.status.index')` would pull the whole stock overview view
|
||||
* into memory on every page, including pages that are not the overview; and on a full page load the
|
||||
* require would race index.load() — which is why the patch is idempotent, guarded by the
|
||||
* `__fsProgressive` flag on the prototype, and why failing to land is harmless. If it misses, the
|
||||
* page simply renders the stock way: one Promise.all, ~90 ms later. Never broken, sometimes slower.
|
||||
*/
|
||||
function patchOverview() {
|
||||
/* `window.L`, NEVER the bare `L` this factory was handed — and the reason is NOT this module's
|
||||
* own convenience, it is what the STOCK view ends up holding. `require()` passes the object it
|
||||
* was called on into the loaded module's factory (`const L = this` in luci.js), and
|
||||
* `view/status/index.js` then loads its own includes with that same `L` inside `load()`
|
||||
* (`L.require('view.status.include.' + …)`), so whichever `L` reaches index.js reaches
|
||||
* 30_network.js too — which calls `L.itemlist(...)` directly. `ui` hangs itemlist/showModal/…
|
||||
* on the RUNTIME INSTANCE that the dispatcher builds (`window.L = new LuCI()`), and a chrome
|
||||
* module like this one is loaded as a dependency, i.e. through `LuCI.prototype.require`, so our
|
||||
* `L` is the PROTOTYPE and has none of them.
|
||||
*
|
||||
* `require()` caches by class name, so the FIRST caller decides this for everybody: patching
|
||||
* through the bare `L` cached view.status.index bound to the prototype, and the overview then
|
||||
* died mid-render on "L.itemlist is not a function" with the page stuck on "Loading view…"
|
||||
* (issue #22 follow-up). It only bit on an SPA arrival — a full page load has the dispatcher
|
||||
* require the view through `window.L` before this observer ever fires — and only when this
|
||||
* patch won the race against fs-router's own `RT.require`, which is exactly the "sometimes,
|
||||
* coming from another page" the report described. Same trap as the itemlist calls below
|
||||
* (docs/spa-router.md), reached from the other side. */
|
||||
window.L.require('view.status.index').then((idx) => {
|
||||
const proto = idx ? Object.getPrototypeOf(idx) : null;
|
||||
if (!proto || proto.__fsProgressive || typeof proto.poll_status !== 'function')
|
||||
return;
|
||||
proto.__fsProgressive = true;
|
||||
proto.poll_status = function(includes, containers, first_load) {
|
||||
return pollProgressive(includes, containers, first_load);
|
||||
};
|
||||
}).catch((e) => console.error('footstrap: overview progressive paint not applied', e));
|
||||
}
|
||||
|
||||
/* Status→Overview is a `template` node whose server template (admin_status/index.ut) defines 3
|
||||
* globals the stock status includes use (18_cpu/20_memory/25_storage/…) and then instantiates
|
||||
* view.status.index. Arriving by the theme's SPA router never runs that inline <script>, so define
|
||||
* them here, guarded, so a full load's copies (any theme) are not clobbered.
|
||||
*
|
||||
* This is the OTHER thing the include location used to give for free, and the one that survived the
|
||||
* move intact: the definitions had to exist before any include renders, which module eval inside
|
||||
* index.load() guaranteed. A chrome module evaluates once, at chrome init — i.e. before any SPA
|
||||
* navigation can possibly happen — so the guarantee is now stronger rather than weaker. On a full
|
||||
* load of the overview the template's own copies win the race and these are no-ops, as before.
|
||||
*
|
||||
* Bodies are verbatim from upstream except L.itemlist → window.L.itemlist (the two-L trap,
|
||||
* docs/spa-router.md). */
|
||||
function ensureOverviewHelpers() {
|
||||
/* eslint-disable no-var -- these three bodies are copied VERBATIM from LuCI's
|
||||
admin_status/index.ut so they can be diffed against upstream when it changes.
|
||||
Modernising the `var`s would silently break that property, which is the whole
|
||||
reason the copies are safe to carry. */
|
||||
if (typeof window.progressbar !== 'function')
|
||||
window.progressbar = function(query, value, max, byte) {
|
||||
var pg = document.querySelector(query),
|
||||
vn = parseInt(value) || 0,
|
||||
mn = parseInt(max) || 100,
|
||||
fv = byte ? String.format('%1024.2mB', value) : value,
|
||||
fm = byte ? String.format('%1024.2mB', max) : max,
|
||||
pc = Math.floor((100 / mn) * vn);
|
||||
if (pg) {
|
||||
pg.firstElementChild.style.width = pc + '%';
|
||||
pg.setAttribute('title', '%s / %s (%d%%)'.format(fv, fm, pc));
|
||||
}
|
||||
};
|
||||
if (typeof window.renderBox !== 'function')
|
||||
window.renderBox = function(title, active, childs) {
|
||||
childs = childs || [];
|
||||
childs.unshift(window.L.itemlist(E('span'), [].slice.call(arguments, 3)));
|
||||
return E('div', { class: 'ifacebox' }, [
|
||||
E('div', { class: 'ifacebox-head center ' + (active ? 'active' : '') },
|
||||
E('strong', title)),
|
||||
E('div', { class: 'ifacebox-body left' }, childs)
|
||||
]);
|
||||
};
|
||||
if (typeof window.renderBadge !== 'function')
|
||||
window.renderBadge = function(icon, title) {
|
||||
return E('span', { class: 'ifacebadge' }, [
|
||||
E('img', { src: icon, title: title || '' }),
|
||||
window.L.itemlist(E('span'), [].slice.call(arguments, 2))
|
||||
]);
|
||||
};
|
||||
/* eslint-enable no-var */
|
||||
}
|
||||
/* Unconditional: the typeof guards make it a no-op wherever the real definitions already exist. */
|
||||
ensureOverviewHelpers();
|
||||
|
||||
return baseclass.extend({
|
||||
/* Called by menu-footstrap-common's init, once. Everything route-dependent hangs off the
|
||||
* data-page observer inside. */
|
||||
wire,
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,836 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require ui';
|
||||
'require fs-menutree as tree';
|
||||
'require fs-chrome as chrome';
|
||||
'require fs-sheets as sheets';
|
||||
|
||||
/* ---- SPA client router ----
|
||||
*
|
||||
* Kills the full page reload for `view`-type menu nodes — 54 of 74 menu leaves (~73%) on the dev
|
||||
* router; the rest are call/function/template. LuCI already renders every page client-side into
|
||||
* #view; only NAVIGATION is server-dispatched. So intercept link clicks and re-instantiate the
|
||||
* target view in place — what the dispatcher's view.ut does via ui.instantiateView(), minus the
|
||||
* reload. Purely additive: anything that is not a satisfied `view` node (call/function/template/
|
||||
* alias/firstchild, external, download, cross-origin, modified click) or any error falls through to
|
||||
* a normal navigation, and deep links / F5 keep working because we pushState the real URL.
|
||||
*
|
||||
* Re-instantiation: L.require('view.x') returns a cached SINGLETON whose __init__ (the render)
|
||||
* already ran, so calling it again repaints nothing. Take the class off the instance
|
||||
* (prototype.constructor) and `new v.constructor()` for a fresh __init__ → load()+render(), which is
|
||||
* what a full load does anyway. docs/spa-router.md.
|
||||
*
|
||||
* The path->node half lives in fs-menutree.js (the chrome needs it too); the "has a view poisoned
|
||||
* this document with its CSS?" half in fs-sheets.js. */
|
||||
|
||||
/* --- stray-interval teardown for SPA nav ---
|
||||
* A full load kills every window.setInterval the outgoing page set; SPA nav does not, so a view's
|
||||
* poller keeps firing against a page that is gone (luci-app-podkop's log tailer runs
|
||||
* `podkop check_logs` forever after you navigate away). Track view-set ids and clear them on nav,
|
||||
* keeping L.Poll's own 1s tick (also a setInterval); L.Poll's queue is flushed in navigate().
|
||||
* Hooked at module eval — before any view render can set a timer, and LuCI resolves a module's
|
||||
* dependencies BEFORE running the dependent's factory, so this runs no later than it used to when
|
||||
* it sat in menu-footstrap-common.js itself. */
|
||||
const _viewIntervals = (window.__fsViewIntervals || (window.__fsViewIntervals = new Set()));
|
||||
(function hookIntervals() {
|
||||
if (window.__fsIntervalsHooked) return;
|
||||
window.__fsIntervalsHooked = true;
|
||||
const _si = window.setInterval, _ci = window.clearInterval;
|
||||
window.setInterval = function () {
|
||||
const id = _si.apply(window, arguments);
|
||||
_viewIntervals.add(id);
|
||||
return id;
|
||||
};
|
||||
window.clearInterval = function (id) {
|
||||
_viewIntervals.delete(id);
|
||||
return _ci.apply(window, arguments);
|
||||
};
|
||||
})();
|
||||
function clearViewIntervals() {
|
||||
const keep = (L.Poll && L.Poll.timer) || null;
|
||||
_viewIntervals.forEach((id) => { if (id !== keep) window.clearInterval(id); });
|
||||
}
|
||||
|
||||
let _wired = false;
|
||||
/* The pathname whose view is CURRENTLY rendered — popstate compares against it to tell a real
|
||||
* navigation from a mere fragment change (see there). Seeded from the served page. */
|
||||
let _curPath = window.location.pathname;
|
||||
/* nav generation token: two quick clicks race their async require()s, and without it the FIRST
|
||||
* view could render into #view after the second, leaving stale content under the newer
|
||||
* URL/title/chrome. A resolved require whose generation is stale renders nothing. */
|
||||
let _navGen = 0;
|
||||
|
||||
/* ---- Back must restore the scroll of WHICHEVER element is the scroller ----
|
||||
* The two layouts scroll different elements: the sidebar layout pins .fs-shell to 100dvh and gives
|
||||
* overflow-y to .fs-main (#maincontent), the top layout lets the document scroll. A browser restores
|
||||
* an INNER scrollable region only across full loads, never on a same-document traversal — measured
|
||||
* (docs/spa-router.md §2): Back opened the incoming page at 0, because the swap empties #view,
|
||||
* scrollHeight collapses and the browser clamps scrollTop.
|
||||
*
|
||||
* The DOCUMENT scroller was left to the browser's own scrollRestoration ('auto') on the grounds that
|
||||
* it is the case a UA does handle — and that is wrong for the same reason, measured on the stand: in
|
||||
* the top layout, scroll Processes to 400, open System, press Back, and the page opens at 0 (the
|
||||
* sidebar layout, restored here, comes back at 400). The UA restores at the traversal, i.e. BEFORE
|
||||
* this handler swaps #view; the height then collapses under the restored offset and the clamp takes
|
||||
* it back to 0, with nothing left to re-apply it. So record and replay BOTH offsets; the one that is
|
||||
* not this layout's scroller is 0 and skipped.
|
||||
*
|
||||
* NOT by replaceState on scroll: Safari rate-limits history writes (100 per 30 s) and a scroll
|
||||
* listener trips it. Each SPA entry instead carries a session-unique id (fsid) in history.state, and
|
||||
* the offsets live in this in-memory Map — lost on a full load, which is exactly when the browser's
|
||||
* own scrollable-region restoration takes over. The id is session-prefixed because a bare counter
|
||||
* restarts with every document: an entry stamped by a PREVIOUS document of this tab would collide
|
||||
* with a fresh one and restore another page's offset. */
|
||||
const _scrollMem = new Map();
|
||||
const _scrollSess = Date.now().toString(36) + Math.floor(Math.random() * 1e6).toString(36);
|
||||
let _histN = 0;
|
||||
let _curId = null;
|
||||
/* …and it is BOUNDED, because this document outlives every page in it. One entry per history entry
|
||||
* per session, never evicted, is the same shape as the leaks this file already fixes elsewhere (the
|
||||
* action-column resize listener, the view pollers) — small enough that nobody would see it and
|
||||
* unbounded all the same. A browser keeps ~50 entries per tab and the ones past that cannot be
|
||||
* traversed back to, so remembering more offsets than that can never be read. Least-recently-SAVED
|
||||
* goes first: a Map iterates in insertion order and `set` on an existing key does not refresh it,
|
||||
* so the re-save below is delete-then-set. */
|
||||
const SCROLL_MEM_MAX = 50;
|
||||
function newEntryId() { return _scrollSess + ':' + (++_histN); }
|
||||
|
||||
/* adopt the entry we are standing on: reuse its fsid if it has one, stamp one otherwise (entries
|
||||
* created by a full load carry state === null). replaceState keeps the entry's own scroll record. */
|
||||
function adoptEntry() {
|
||||
const st = history.state;
|
||||
if (st && st.fsid) { _curId = st.fsid; return; }
|
||||
_curId = newEntryId();
|
||||
try { history.replaceState(Object.assign({}, st, { fsid: _curId }), '', window.location.href); } catch (e) {}
|
||||
}
|
||||
|
||||
/* the outgoing DOM is still on screen at both call sites (the click, the popstate), so this must
|
||||
* run BEFORE _curId moves on to the incoming entry */
|
||||
function saveScroll() {
|
||||
if (!_curId) return;
|
||||
const sc = document.getElementById('maincontent');
|
||||
_scrollMem.delete(_curId); /* re-insert, so this entry counts as the most recently saved */
|
||||
_scrollMem.set(_curId, { win: Math.round(window.scrollY) || 0, main: sc ? sc.scrollTop : 0 });
|
||||
while (_scrollMem.size > SCROLL_MEM_MAX)
|
||||
_scrollMem.delete(_scrollMem.keys().next().value);
|
||||
}
|
||||
|
||||
/* Put the scrollers back where the entry left them — but only once the incoming view has grown that
|
||||
* much height (docs/spa-router.md §5: restoring before the content exists is clamped to 0 and reads as
|
||||
* "worked"). The view renders behind an RPC, so poll by frame; a newer navigation cancels via the
|
||||
* generation, and a page that never reaches the old height again is simply left at the top. Each
|
||||
* offset is waited for on its OWN scroller, so a layout switched between the two entries restores
|
||||
* whichever half it can rather than blocking on the half that no longer scrolls. */
|
||||
function restoreScroll(pos, gen) {
|
||||
if (!pos || (!pos.win && !pos.main)) return;
|
||||
let tries = 300; /* ~5 s at 60 fps — outlasts a slow RPC without polling forever */
|
||||
(function tick() {
|
||||
if (gen !== _navGen || --tries < 0) return;
|
||||
const de = document.documentElement;
|
||||
const sc = document.getElementById('maincontent');
|
||||
let pending = false;
|
||||
if (pos.main) {
|
||||
if (sc && sc.scrollHeight - sc.clientHeight >= pos.main) sc.scrollTop = pos.main;
|
||||
else pending = true;
|
||||
}
|
||||
if (pos.win) {
|
||||
if (de.scrollHeight - de.clientHeight >= pos.win) window.scrollTo(0, pos.win);
|
||||
else pending = true;
|
||||
}
|
||||
if (pending) requestAnimationFrame(tick);
|
||||
})();
|
||||
}
|
||||
|
||||
/* ---- the host half of "<host> | <page>" is read ONCE ----
|
||||
* head.ut stamps it and it cannot change within a document, but navigate() used to re-derive it from
|
||||
* the LIVE document.title on every hop — so any page that renames the tab became the host for every
|
||||
* page after it, until a full load. Third-party views do rename it (log viewers, dashboards), and it
|
||||
* takes only one: measured on the stand, a view setting `document.title = 'ACME Dashboard'` left the
|
||||
* next hops reading "ACME Dashboard | Routing", "ACME Dashboard | System". Captured at seed(), i.e.
|
||||
* at chrome init on the full load that started the session; the lazy branch is for a document whose
|
||||
* chrome came up without seed() having run. */
|
||||
let _titleHost = null;
|
||||
function titleHost() {
|
||||
if (_titleHost === null)
|
||||
_titleHost = (document.title.split('|')[0] || '').trim();
|
||||
return _titleHost;
|
||||
}
|
||||
|
||||
/* The view class the page CURRENTLY on screen wants (what _curPath resolves to). Read by the
|
||||
* stale-render repair below to tell "the superseded render happened to paint the right view
|
||||
* anyway" from "it painted the wrong one". */
|
||||
function currentViewClass() {
|
||||
const segs = tree.segsFromPath(_curPath);
|
||||
const res = segs && tree.resolveSegs(segs);
|
||||
return tree.viewClassFor(res && res.node);
|
||||
}
|
||||
|
||||
/* ---- a superseded FIRST render cannot be cancelled, so undo it ----
|
||||
*
|
||||
* _navGen stops a stale require() from calling `new view.constructor()` — but only on the CACHED
|
||||
* path. On a FIRST visit the require() IS the render (see navigate()): it constructs the view, whose
|
||||
* __init__ runs load() → render() → dom.content(#view) and registers its pollers, inside a promise
|
||||
* we do not own. Nothing to cancel.
|
||||
*
|
||||
* So the fast double-click is a real bug: click Firewall (uncached), click Wireless 100 ms later.
|
||||
* navigate(Wireless) flushes L.Poll's queue BEFORE Firewall's poller is added; Firewall then paints
|
||||
* into the #view that now belongs to Wireless and registers a poller the flush can no longer catch —
|
||||
* leaving Wireless's URL/title/menu/data-page, Firewall's content, and Firewall's poller running on
|
||||
* every page afterwards.
|
||||
*
|
||||
* Repair by re-running the current navigation: navigate() is exactly the "put the document back the
|
||||
* way a fresh load leaves it" routine. push=false — the URL never moved, only the DOM under it; if
|
||||
* it declines (the superseded view injected CSS), the reload does it the hard way. The className
|
||||
* check terminates this: if the superseded render painted the class the current path wants anyway
|
||||
* (A → B → A while A was still loading), the DOM and its poller are correct — and with two uncached
|
||||
* views racing it is also what stops a repair triggering a repair. */
|
||||
function repairStaleRender(className) {
|
||||
if (className === currentViewClass())
|
||||
return;
|
||||
console.warn('footstrap: a superseded view (' + className + ') rendered into the live page; re-rendering ' + _curPath);
|
||||
if (!navigate(_curPath, false))
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
/* ---- the generation must be checked at the PAINT, not at the dispatch ----
|
||||
*
|
||||
* _navGen is checked once, in the require() callback, i.e. BEFORE `new view.constructor()`. But a
|
||||
* view's __init__ is async — `ready.then(this.load).then(this.render).then(nodes =>
|
||||
* dom.content(document.getElementById('view'), nodes))` — so the DOM write happens two awaits later,
|
||||
* and every await is a point at which the whole event loop runs, another navigation included. The
|
||||
* dispatch-time check has expired by the time it matters.
|
||||
*
|
||||
* Measured on the router: leave the (cached) package manager for System after 150 ms, and the paints
|
||||
* into #view land 16010 System, 16490 package-manager — the view we walked away from paints LAST and
|
||||
* wins, permanently. URL, <title>, data-page and the menu highlight all say System; #view shows
|
||||
* Software. Only a reload clears it. The cached path is the COMMON one — after warm-up every
|
||||
* navigation takes it — and it had no guard at all: repairStaleRender() is called only when !cached.
|
||||
*
|
||||
* The fix has to be a paint-time check, and there are two constraints on where it can live.
|
||||
* ClassConstructor DISCARDS __init__'s return value (`this.__init__.apply(this, arguments)`, no
|
||||
* return), so the construction promise is unreachable — nothing to await. But __init__ resolves
|
||||
* `this.render` while it builds its chain, i.e. during `new`, so a wrapper installed on the
|
||||
* prototype BEFORE the construct is the one that gets bound. `new` then returns synchronously (the
|
||||
* first load() is already a microtask), which is what makes stamping the generation on the INSTANCE
|
||||
* right after it safe — and it must be the instance, not a map keyed by class name, or A → B → A
|
||||
* with everything cached would have the second construct's generation overwrite the first's.
|
||||
*
|
||||
* A superseded render resolves to a promise that never settles: the chain simply stops before
|
||||
* dom.content(), so nothing paints and addFooter() never runs. Returning empty nodes instead would
|
||||
* paint the emptiness over the live page, and throwing would hand LuCI's own .catch an error box to
|
||||
* render into the page we just opened.
|
||||
*
|
||||
* A view instance we did not construct (the singleton require() builds on a FIRST visit — the render
|
||||
* IS the require, so there is no window in which to arm anything) carries no stamp and is left alone:
|
||||
* that path is repairStaleRender()'s, and stays its. */
|
||||
const _guarded = new WeakSet();
|
||||
function armRenderGuard(cls) {
|
||||
if (_guarded.has(cls)) return;
|
||||
_guarded.add(cls);
|
||||
const orig = cls.prototype.render;
|
||||
cls.prototype.render = function () {
|
||||
/* unstamped: not ours to judge (see above) */
|
||||
const stale = () => this.__fsGen !== undefined && this.__fsGen !== _navGen;
|
||||
if (stale())
|
||||
return new Promise(() => {});
|
||||
return Promise.resolve(orig.apply(this, arguments)).then((nodes) => {
|
||||
/* re-check: render() itself awaits (an RPC, usually), which re-opens the window */
|
||||
return stale() ? new Promise(() => {}) : nodes;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/* The exact URL LuCI.require() will fetch for a class name, cache-bust and all. Matching it
|
||||
* byte-for-byte is what makes a hover prefetch a warm cache hit for the later require(). */
|
||||
function moduleUrl(className) {
|
||||
const v = L.env.resource_version ? ('?v=' + L.env.resource_version) : '';
|
||||
return (L.env.base_url || '') + '/' + className.replace(/\./g, '/') + '.js' + v;
|
||||
}
|
||||
|
||||
/* ---- link prefetch: warm the module cache for a page the user is about to open ----
|
||||
*
|
||||
* A plain fetch(), NOT require(): require() instantiates, and a view's __init__ IS its render, so it
|
||||
* would paint another page into #view. fetch() only fills the browser's HTTP cache, which the later
|
||||
* require()'s XHR then hits. Deduped per class; failures are silent (a pure optimisation).
|
||||
*
|
||||
* TRANSITIVE, and that is where most of the win is: warming the view class alone leaves its own
|
||||
* `require` pragmas one round-trip behind it. view/network/routes.js pulls tools/network.js (40.5 KB),
|
||||
* and measured on the dev router at 120 ms RTT a first visit cost 418 ms with the view warmed against
|
||||
* 296 ms with its deps warmed too — one RTT exactly. Over six pages, 1713 ms cold → 1184 warmed →
|
||||
* 1052 warmed transitively. The bytes are in hand either way, so the scan is free.
|
||||
*
|
||||
* The scan MUST NOT be line-anchored. The shipped files are MINIFIED and every pragma sits on one
|
||||
* line (`'use strict';'require view';'require fs';…`), so /^'require …'$/m matches nothing at all —
|
||||
* silently, which is how the first attempt at this measured a win of zero. luci.js lexes the leading
|
||||
* string literals; this reads the same head of the file with one regex. */
|
||||
const PRAGMA_HEAD = 2000; /* bytes of leading literals to scan — luci.js stops at the first non-string token */
|
||||
const PREFETCH_DEPTH = 3;
|
||||
|
||||
function pragmaDeps(src) {
|
||||
const re = /(['"])require[ \t]+([^'"]+?)\1/g;
|
||||
const head = src.slice(0, PRAGMA_HEAD);
|
||||
const out = [];
|
||||
let m;
|
||||
while ((m = re.exec(head)))
|
||||
out.push(m[2].split(/[ \t]+as[ \t]+/)[0]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/* SIX CLASS NAMES HAVE NO FILE, and fetching one is a guaranteed 404 in the user's console. luci.js
|
||||
* seeds its class registry with them at load — `const classes = { baseclass: Class, dom: DOM,
|
||||
* poll: Poll, request: Request, session: Session, view: View }` — so require() answers them from
|
||||
* memory and never asks the network, while `ls /www/luci-static/resources` has no baseclass.js,
|
||||
* dom.js, poll.js, request.js or session.js at all. Every view file's pragmas name `view` and
|
||||
* `baseclass`, so a dependency walk hits this on its very first step: measured, warming the recents
|
||||
* at idle put 404s for view.js and poll.js in the console of every page load, and the first hover
|
||||
* added baseclass.js. A require() that 404s is noise this theme refuses to make, and the same
|
||||
* standard applies here.
|
||||
*
|
||||
* A future LuCI adding a seventh built-in would cost one 404 per session — `_prefetched` makes it
|
||||
* at most one — and the list is a literal from luci.js, not a guess about it. */
|
||||
const BUILTIN_CLASSES = new Set([ 'baseclass', 'dom', 'poll', 'request', 'session', 'view' ]);
|
||||
|
||||
/* Is this class already instantiated? require() attaches its singleton to the LuCI prototype
|
||||
* (`ptr[parts[idx]] = instance`), so a SINGLE-segment name reads back as L[name] — which covers the
|
||||
* libs a loaded page has already pulled in (ui, form, uci, rpc, fs, validation, and network/firewall
|
||||
* once some network page has been open). Skipping them spends no request on a cache hit nobody needs.
|
||||
* `instanceof L.Class` rather than a truthiness test: L.env, L.url and L.get are members too, and a
|
||||
* dep whose name collided with one of those would otherwise be skipped without ever being loaded —
|
||||
* which is why this cannot be the guard for the six above either: they are seeded as CONSTRUCTORS
|
||||
* (and under other names — L.Poll, L.Request, L.Class), so no `instanceof` probe sees them. A DOTTED
|
||||
* name (tools.network) is not attached unless its parent already exists, so it is simply fetched —
|
||||
* and those are the ones the win comes from. */
|
||||
function classLoaded(name) {
|
||||
if (BUILTIN_CLASSES.has(name)) return true;
|
||||
try { return name.indexOf('.') < 0 && window.L[name] instanceof window.L.Class; }
|
||||
catch (e) { return false; }
|
||||
}
|
||||
|
||||
/* view classes already required, i.e. the ones LuCI has an instance cached for. A class NOT in
|
||||
* here is rendered by the require() itself (see navigate). */
|
||||
const _seen = new Set();
|
||||
const _prefetched = new Set();
|
||||
/* className -> the promise of ITS OWN body being in the HTTP cache; navigate() waits on that.
|
||||
* Deliberately the body and not the subtree, see _committed below. */
|
||||
const _warming = new Map();
|
||||
/* Roots a navigation has taken over. Speculation below them STOPS: require() is now fetching the same
|
||||
* graph and pipelines its parse and eval against those fetches, so descending would only race it —
|
||||
* measured at 120 ms RTT, waiting for the whole subtree instead cost 658 ms against 525 ms racing,
|
||||
* for the sake of a duplicate that stopping avoids outright. Deps have not been asked for yet when a
|
||||
* click arrives (they start only once the root body lands), so there is nothing in flight to collide
|
||||
* with: this leaves zero duplicated bytes AND require()'s pipelining. */
|
||||
const _committed = new Set();
|
||||
|
||||
function warmClass(name, depth, root) {
|
||||
if (_prefetched.has(name)) return;
|
||||
_prefetched.add(name);
|
||||
if (classLoaded(name)) return;
|
||||
let req;
|
||||
try { req = fetch(moduleUrl(name), { credentials: 'same-origin' }); }
|
||||
catch (e) { return; }
|
||||
const body = req.then((res) => (res.ok ? res.text() : '')).catch(() => '');
|
||||
_warming.set(name, body.then(() => {}, () => {}));
|
||||
/* The visited set is global and the depth capped, so the walk terminates regardless of what the
|
||||
* pragmas say — require() raises DependencyError on a cycle, but only for classes it actually
|
||||
* loads, and this walks files it may never hand to require() at all. */
|
||||
if (depth < PREFETCH_DEPTH)
|
||||
body.then((src) => {
|
||||
if (_committed.has(root)) return;
|
||||
for (const d of pragmaDeps(src)) warmClass(d, depth + 1, root);
|
||||
});
|
||||
}
|
||||
|
||||
/* Warm the view a menu path resolves to, plus its dependency tree. `segs` is the menu path
|
||||
* (`admin/network/routes`), the shape fs-search stores its recents in. */
|
||||
function prefetchSegs(segs) {
|
||||
if (!Array.isArray(segs) || !segs.length) return;
|
||||
const res = tree.resolveSegs(segs);
|
||||
const className = tree.viewClassFor(res && res.node);
|
||||
if (className) warmClass(className, 0, className);
|
||||
}
|
||||
|
||||
function prefetchView(pathname) {
|
||||
const segs = tree.segsFromPath(pathname);
|
||||
if (segs) prefetchSegs(segs);
|
||||
}
|
||||
|
||||
/* Wait for an in-flight prefetch of `className` instead of racing it — see the call site. Capped,
|
||||
* because a wedged prefetch must never wedge a navigation: on a stalled connection require()'s own
|
||||
* XHR and its error path are the better place to end up. */
|
||||
const WARM_WAIT_MS = 5000;
|
||||
function warmedThen(className) {
|
||||
_committed.add(className);
|
||||
const body = _warming.get(className);
|
||||
if (!body) return Promise.resolve();
|
||||
/* the loser of the race is cancelled: a prefetch that lands in 40 ms otherwise leaves a 5 s
|
||||
* timer armed behind every single navigation, doing nothing but keeping its closure alive */
|
||||
let t = 0;
|
||||
return Promise.race([ body, new Promise((r) => { t = window.setTimeout(r, WARM_WAIT_MS); }) ])
|
||||
.finally(() => window.clearTimeout(t));
|
||||
}
|
||||
|
||||
/* The page we are standing on arrived as a full load, so LuCI has ALREADY required — hence
|
||||
* instantiated and rendered — its view. Seed `_seen`, or the first SPA nav BACK to this page would
|
||||
* take require()'s cached instance, skip the re-instantiation and render nothing at all. */
|
||||
function seed() {
|
||||
const here = tree.viewClassFor(tree.currentNode());
|
||||
if (here)
|
||||
_seen.add(here);
|
||||
titleHost(); /* before any view can rename the tab — see there */
|
||||
/* the served page's entry needs an id too, or the first Back TO it has nothing to look up */
|
||||
adoptEntry();
|
||||
}
|
||||
|
||||
/* Attempt an in-place navigation to `pathname`. Returns true if handled as a
|
||||
* SPA nav (caller should preventDefault), false to let the browser do a normal
|
||||
* full navigation. `push` adds a history entry (false when replaying popstate).
|
||||
* `kbd` — the navigation was activated from the keyboard (see the focus block). */
|
||||
function navigate(pathname, push, kbd) {
|
||||
const segs = tree.segsFromPath(pathname);
|
||||
if (!segs) return false;
|
||||
|
||||
/* The view on screen injected CSS that can repaint any page: this document is spent, and
|
||||
* the only exit that leaves BOTH pages correct is a real navigation. See fs-sheets.js. */
|
||||
if (sheets.documentPoisoned()) return false;
|
||||
|
||||
/* `segs` is what the user clicked, `rsegs` the leaf it resolves to; they differ for an
|
||||
* alias/firstchild link, and a full load keeps BOTH — URL and pathinfo as requested,
|
||||
* requestpath/dispatchpath/nodespec/title resolved. Mirror that split exactly, or an F5
|
||||
* lands somewhere the click did not. */
|
||||
const res = tree.resolveSegs(segs);
|
||||
const node = res && res.node;
|
||||
const className = tree.viewClassFor(node);
|
||||
if (!className)
|
||||
return false;
|
||||
const rsegs = res.segs;
|
||||
|
||||
/* from here on the navigation is committed */
|
||||
const gen = ++_navGen;
|
||||
_curPath = pathname; /* what is on screen from now on — read by the popstate handler */
|
||||
|
||||
/* Ensure a #view, and clear what the OUTGOING page left as a SIBLING of #view inside .fs-content:
|
||||
* dom.content() replaces only #view's OWN children, so anything a page emitted next to it rides
|
||||
* along — the Status→Overview template emits <h2 name="content">Status</h2> there, hidden only
|
||||
* by a body[data-page='admin-status-overview'] rule, so after an SPA nav the orphan showed on
|
||||
* EVERY page until a full reload. Keep only the chrome that legitimately outlives a page (tabs,
|
||||
* server notices, <noscript>); this also gives a template page that emits no #view a fresh one. */
|
||||
const contentHost = document.querySelector('.fs-content');
|
||||
if (!contentHost) return false;
|
||||
Array.from(contentHost.children).forEach((c) => {
|
||||
if (c.id !== 'view' && c.id !== 'tabmenu' &&
|
||||
!c.classList.contains('alert-message') && c.nodeName !== 'NOSCRIPT')
|
||||
c.remove();
|
||||
});
|
||||
/* …and the RUNTIME notifications, which live one level up and therefore survived every
|
||||
* navigation. `ui.addNotification()` does `mc.insertBefore(msg, mc.firstElementChild)` on
|
||||
* #maincontent — this theme's <main class="fs-main"> — while the sweep above only reaches
|
||||
* children of .fs-content, a descendant of it. A full load clears them; SPA never did, so
|
||||
* "Upload request failed" and every third-party banner stacked up over each following page for
|
||||
* the rest of the session (reproduced: the banner outlives a nav, and only F5 removes it).
|
||||
* The .fs-content banners kept above are the SERVER's notices (notices.ut) and legitimately
|
||||
* outlive a page; these are not the same thing. */
|
||||
const mainHost = document.getElementById('maincontent');
|
||||
if (mainHost)
|
||||
Array.from(mainHost.children).forEach((c) => {
|
||||
if (c.classList.contains('alert-message')) c.remove();
|
||||
});
|
||||
if (!document.getElementById('view')) {
|
||||
const v = document.createElement('div');
|
||||
v.id = 'view';
|
||||
contentHost.appendChild(v);
|
||||
}
|
||||
|
||||
/* SAY THAT SOMETHING IS LOADING, on a route whose module has never been fetched.
|
||||
*
|
||||
* The chrome switches instantly — title, URL, body[data-page], the menu highlight — but the
|
||||
* sweep above deliberately does not touch the children of #view, and LuCI's own spinner is
|
||||
* written by `View.__init__`, i.e. only once the view module has ARRIVED. So between the click
|
||||
* and the last round-trip of the module chain the user reads the PREVIOUS page's content under
|
||||
* the new page's title, with nothing moving. Measured on the router at 600 ms latency: at 150 ms
|
||||
* and 400 ms #view still held the System page while everything else said Processes; the
|
||||
* "Loading view…" spinner appeared at 900 ms and the content at 1800 ms. A full load would have
|
||||
* shown the browser's own progress for that whole window.
|
||||
*
|
||||
* Only for a COLD route: `_seen` means the class has already been through require(), where
|
||||
* View.__init__ paints its spinner synchronously and there is no gap to fill.
|
||||
*
|
||||
* Deliberately the SAME markup and the SAME msgid luci-base uses, not a skeleton of our own:
|
||||
* when View.__init__ replaces it there is nothing to see, and the string arrives already
|
||||
* translated in the ~40 languages this theme ships no catalogue for (the chrome rule — a
|
||||
* context-free msgid is how we inherit luci-base's translation). */
|
||||
if (!_seen.has(tree.viewClassFor(node))) {
|
||||
const vp = document.getElementById('view');
|
||||
if (vp) {
|
||||
const s = document.createElement('div');
|
||||
s.className = 'spinning';
|
||||
s.textContent = _('Loading view…');
|
||||
vp.replaceChildren(s);
|
||||
}
|
||||
}
|
||||
|
||||
/* teardown: drop the outgoing view's pollers, then put the poll loop back into the state a FRESH
|
||||
* LOAD leaves it in. The only non-view poller LuCI adds is the transient apply/reboot
|
||||
* reachability check, so flushing the queue is safe.
|
||||
*
|
||||
* The re-arm matters: LuCI runs one 1 s tick and fires a queue entry only when
|
||||
* `tick % interval == 0`, so leaving the OUTGOING page's tick running makes the incoming poller
|
||||
* wait for the next multiple of its interval — up to `pollinterval`, 5 s. Wireless draws its
|
||||
* station list from the first poll and sat spinning for 4950 ms against ~360 ms on a full load.
|
||||
*
|
||||
* stop() alone is NOT the fix: it deletes `tick`, and Poll.add() only auto-starts when
|
||||
* `tick != null`, so the incoming pollers would never start at all. stop()+start() on an EMPTY
|
||||
* queue leaves what a fresh document has (`tick = 0`, no timer armed); the view's first
|
||||
* poll.add() then starts it and steps immediately — upstream's own sequence, since on a full
|
||||
* load initDOM() runs Poll.start() on an empty queue before the view renders. */
|
||||
if (L.Poll && L.Poll.queue) {
|
||||
L.Poll.queue.length = 0;
|
||||
L.Poll.stop();
|
||||
L.Poll.start();
|
||||
}
|
||||
/* kill the outgoing view's plain setInterval pollers too (podkop's log tailer) — a full load
|
||||
* would have. L.Poll's own tick survives. */
|
||||
clearViewIntervals();
|
||||
/* the outgoing page's links are about to become a detached tree — do not hold one of them */
|
||||
_lastHovered = null;
|
||||
/* run every registered navigation callback — today the search palette's recent-pages record and
|
||||
* its close-on-navigate. The seam is deliberately inverted: a registrant calls onNavigate()
|
||||
* (below) and the router names nobody, so an optional module that is not installed is not a
|
||||
* DependencyError that takes out the whole chrome.
|
||||
*
|
||||
* The RESOLVED segments are passed in, and that is not convenience: this runs BEFORE L.env is
|
||||
* re-pointed a few lines down, so a callback reading L.env.dispatchpath for "where are we going"
|
||||
* would silently record the page being left. */
|
||||
/* Logged, not swallowed. A registrant that throws is still isolated — the loop must finish, or a
|
||||
* broken registrant would take the palette's recents and its close-on-navigate with it — but the
|
||||
* empty catch made a registrant that throws on EVERY navigation indistinguishable from one that
|
||||
* was never registered, which is the failure mode the .catch at the bottom of this file was
|
||||
* given a console.error for. */
|
||||
for (const fn of _navCbs) {
|
||||
try { fn(rsegs); }
|
||||
catch (e) { console.error('footstrap: a navigation callback threw', e); }
|
||||
}
|
||||
try { if (typeof ui.hideModal === 'function') ui.hideModal(); } catch (e) {}
|
||||
|
||||
/* point the runtime env at the new node so views, tabs and highlighting read the right
|
||||
* path. For a fully-matched leaf, request == dispatch path. */
|
||||
L.env.requestpath = rsegs.slice();
|
||||
L.env.dispatchpath = rsegs.slice();
|
||||
L.env.pathinfo = '/' + segs.join('/');
|
||||
/* `readonly` is not decoration: luci.js implements hasViewPermission() as
|
||||
* `!env.nodespec.readonly`, and views (network/interfaces, wireless, the package manager)
|
||||
* plus luci.js's Save/Apply footer key their disabled state off it. Dropping it handed a
|
||||
* read-only user LIVE Save/Apply buttons on an SPA nav, where a full load disabled them —
|
||||
* and reading it off the LEAF was still dropping it for two thirds of the readonly pages,
|
||||
* because the dispatcher folds the acls of the whole path into that one flag. See
|
||||
* fs-menutree's readonlyForSegs(). */
|
||||
L.env.nodespec = { satisfied: true, action: node.action, title: node.title,
|
||||
depends: node.depends, readonly: tree.readonlyForSegs(rsegs) };
|
||||
|
||||
/* Keep <body data-page> in sync with the route: the server stamps the dispatch path
|
||||
* (`ctx.path`) on every full load, and page-scoped CSS keys off it. `rsegs` is the RESOLVED
|
||||
* leaf, so a firstchild URL like /admin/status yields the same "admin-status-overview" whether
|
||||
* it arrives as a full load or a client nav. Without the re-stamp the incoming page keeps the
|
||||
* previous page's data-page and its scoped styles silently do not apply. */
|
||||
document.body.setAttribute('data-page', rsegs.join('-'));
|
||||
|
||||
/* …and immediately hand the new page to fs-sheets, which darkens every foreign sheet that
|
||||
* belongs to a DIFFERENT page and re-lights the ones that belong to this one. This is what lets
|
||||
* an invasive sheet stay in the document without spending it — the alternative was a full load
|
||||
* on the way out of any page carrying one, which stock LuCI's five realtime views all do (see
|
||||
* the page-ownership block in fs-sheets.js). It must run AFTER the stamp above and BEFORE the
|
||||
* view renders, so nothing paints through a sheet that no longer owns the page. */
|
||||
sheets.scopeToCurrentPage(rsegs);
|
||||
|
||||
/* Re-navigating to the page already on screen must REPLACE its history entry, not push a
|
||||
* second one. Clicking the active menu item is ordinary, and a duplicate entry makes Back do
|
||||
* nothing: popstate fires, `location.pathname === _curPath`, and the fragment guard below
|
||||
* correctly returns — one dead Back press per stray click. A full load has no such trap. */
|
||||
if (push) {
|
||||
const same = pathname === window.location.pathname;
|
||||
if (_curId == null) _curId = newEntryId();
|
||||
/* a NEW entry gets a new id; re-navigating in place keeps the entry and therefore its id */
|
||||
if (!same) _curId = newEntryId();
|
||||
history[same ? 'replaceState' : 'pushState']({ fsnav: true, fsid: _curId }, '', pathname);
|
||||
}
|
||||
|
||||
/* titles: <host> | <page> */
|
||||
document.title = node.title ? (titleHost() + ' | ' + _(node.title)) : titleHost();
|
||||
const tmain = document.querySelector('.fs-title-main');
|
||||
if (tmain && node.title)
|
||||
tmain.textContent = _(node.title);
|
||||
|
||||
chrome.renderChrome();
|
||||
|
||||
/* a full load starts at the top; the in-place swap must too, or navigating away from a long
|
||||
* page opens the next one mid-scroll. In the desktop sidebar layout the window does NOT scroll —
|
||||
* .fs-shell is exactly 100dvh with overflow:hidden and .fs-main owns overflow-y, so the sidebar
|
||||
* can be static rather than a composited sticky layer (issue #7) — so reset it too; scrollTo on
|
||||
* whichever of the two is not the scroller is a harmless no-op.
|
||||
*
|
||||
* A popstate replay resets nothing on purpose: BOTH scrollers are restored there from _scrollMem —
|
||||
* see restoreScroll(). scrollRestoration is left at 'auto': the UA's own attempt lands before the
|
||||
* swap and is undone by it (§2), so it neither helps nor hurts, and 'manual' would only take away
|
||||
* the case that does work — a genuine full load. */
|
||||
if (push) {
|
||||
window.scrollTo(0, 0);
|
||||
const sc = document.getElementById('maincontent');
|
||||
if (sc) sc.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
/* ---- what a full load does for a keyboard/screen-reader user, and the SPA did not ----
|
||||
* renderChrome() has just done `#topmenu.innerHTML = ''`, so the very <a> the user activated with
|
||||
* Enter no longer exists: focus falls back to <body>, the next Tab restarts at the skip link, and
|
||||
* nothing says the page changed — URL, title and #view all moved in silence. So do what a real
|
||||
* navigation would, and where matters (Sutton's five-prototype study, docs/spa-router.md §3): a KEYBOARD
|
||||
* activation (ev.detail === 0) moves focus to the skip link — a small target whose :focus overlay
|
||||
* tells a sighted keyboard user where they are, with Enter jumping straight to the content; its
|
||||
* text differs from the live region's announcement below, so the double announcement complements
|
||||
* rather than repeats. A pointer activation (and a popstate replay, whose modality is unknowable)
|
||||
* keeps the wrapper focus: focusing the skip link there would flash its overlay on every mouse
|
||||
* click. <main> keeps tabindex="-1" and its outline-less :focus for exactly that path.
|
||||
* preventScroll because the scroll position is decided just above — focus() would otherwise drag
|
||||
* a popstate replay back to the top and undo the restoration. */
|
||||
const skip = kbd ? document.querySelector('.fs-skip') : null;
|
||||
const main = skip || document.getElementById('maincontent');
|
||||
if (main) main.focus({ preventScroll: true });
|
||||
const live = document.getElementById('fs-nav-status');
|
||||
if (live) live.textContent = node.title ? _(node.title) : '';
|
||||
|
||||
/* Require through the runtime singleton `window.L`, NOT the bare `L` a module factory is handed:
|
||||
* the dispatcher builds `window.L = new LuCI()` and `ui` augments THAT instance with
|
||||
* itemlist/showModal/…, so a view required via the bare `L` throws "L.itemlist is not a
|
||||
* function" mid-render (the two-L trap, docs/spa-router.md). require/instanceof errors fall back to a real
|
||||
* navigation; render-time errors are handled inside LuCI.view, as on a full load.
|
||||
*
|
||||
* WHEN to re-instantiate is the subtle part. require() does not hand back a class — it caches an
|
||||
* INSTANCE, so requiring a class not seen before CONSTRUCTS it, and a view's __init__ IS its
|
||||
* render. On a first visit the require has therefore already painted the page, and a
|
||||
* `new view.constructor()` after it painted a SECOND time — two renders, two pollers, double
|
||||
* RPCs for as long as the user stayed. Only on a REVISIT does require() return the cached
|
||||
* singleton whose __init__ already ran. `_seen` is that distinction, and it must be read BEFORE
|
||||
* the require resolves, since the require is what fills LuCI's cache. */
|
||||
/* Status→Overview needs the 3 template globals (progressbar/renderBox/renderBadge) an SPA
|
||||
* arrival never defines. fs-overview.js defines them at its own module eval — which, as a
|
||||
* chrome module, happens at chrome init, i.e. before any SPA navigation can occur. Not here:
|
||||
* the router has no business owning luci-mod-status's globals. */
|
||||
const RT = window.L;
|
||||
const cached = _seen.has(className);
|
||||
/* WAIT for an in-flight prefetch of this class rather than racing it. Two requests for the same
|
||||
* URL do not coalesce, so a click landing before the prefetch does downloads the module TWICE,
|
||||
* both at full latency, and gains nothing — measured at 120 ms RTT: the prefetch ran 2664→2788 ms
|
||||
* and the require's XHR 2682→2788 ms for the same 8.6 KB. That is the NORMAL case on a touch
|
||||
* device, where pointerover fires the same moment as the tap. Waiting costs nothing: the XHR
|
||||
* would have waited for exactly those bytes.
|
||||
*
|
||||
* `_seen` is marked here and not before, because it means "this class has been through require()"
|
||||
* and the wait introduces a window in which we may never get there. Marking it up front would
|
||||
* make the NEXT navigation take the cached branch — `new view.constructor()` on a class whose
|
||||
* require() is what renders it — i.e. two renders and two pollers for one page. */
|
||||
warmedThen(className).then(() => {
|
||||
/* superseded while waiting: never start the require. On a first visit the require IS the
|
||||
* render, so starting it here would paint a page the user has already left and hand
|
||||
* repairStaleRender() a mess that only exists because a require in flight cannot be stopped. */
|
||||
if (gen !== _navGen) return null;
|
||||
_seen.add(className);
|
||||
return RT.require(className);
|
||||
}).then((view) => {
|
||||
if (view == null) return;
|
||||
if (!(view instanceof RT.view))
|
||||
throw new TypeError('Loaded class ' + className + ' is not a view');
|
||||
if (gen !== _navGen) {
|
||||
/* A newer navigation superseded this one. On the CACHED path nothing has happened
|
||||
* yet — skipping the constructor below is the whole cancellation. On the FIRST-visit
|
||||
* path the require() has ALREADY rendered into the live page and registered its
|
||||
* pollers, with nothing to cancel: undo it. See repairStaleRender(). */
|
||||
if (!cached)
|
||||
repairStaleRender(className);
|
||||
return;
|
||||
}
|
||||
if (cached) {
|
||||
/* singleton: its __init__ already ran, re-run it. Arm the paint-time guard BEFORE the
|
||||
* construct (__init__ binds this.render as it builds its chain) and stamp the
|
||||
* generation right after it (the first load() is a microtask, so this lands first) —
|
||||
* this navigation can still be superseded while that render awaits its RPC. */
|
||||
const cls = view.constructor;
|
||||
armRenderGuard(cls);
|
||||
const inst = new cls();
|
||||
inst.__fsGen = gen;
|
||||
}
|
||||
}).catch((e) => {
|
||||
/* the full reload is a correct fallback, but swallowing the reason made every SPA-router
|
||||
* regression look like "the page is just slow to load". Log, then fall back. */
|
||||
console.error('footstrap: SPA nav to ' + className + ' failed, falling back to a full load', e);
|
||||
if (gen === _navGen) window.location = pathname;
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/* The same-origin nav URL an event's link points at, or null when the link is not ours to handle
|
||||
* (new-tab target, download, bare #hash, cross-origin, unparsable). Shared by the click router and
|
||||
* the hover prefetch, which used to carry drifting copies of this filter. */
|
||||
function linkUrlFrom(ev) {
|
||||
const a = ev.target.closest?.('a[href]');
|
||||
if (!a || (a.target && a.target !== '_self') || a.hasAttribute('download'))
|
||||
return null;
|
||||
const raw = a.getAttribute('href');
|
||||
if (!raw || raw.charAt(0) === '#') return null;
|
||||
let url;
|
||||
try { url = new URL(a.href, window.location.href); } catch (e) { return null; }
|
||||
return url.origin === window.location.origin ? url : null;
|
||||
}
|
||||
|
||||
/* Warm the view module behind an event's link — ONE filter for all three prefetch triggers, and the
|
||||
* same one the click router applies two lines further down: navigate() pushes a bare path, so a link
|
||||
* carrying ?query or #hash full-loads, and warming its module spends a request on a page the SPA
|
||||
* path can never open. The click handler declined those from the start; the three triggers below had
|
||||
* each grown a copy of the URL test without that half. */
|
||||
function prefetchFrom(ev) {
|
||||
const url = linkUrlFrom(ev);
|
||||
if (url && !url.search && !url.hash)
|
||||
prefetchView(url.pathname);
|
||||
}
|
||||
|
||||
/* The last <a> a pointer crossed, kept only to stop `pointerover` re-firing per child span (see the
|
||||
* listener). Cleared on every navigation: an element holds its parent, so retaining one anchor
|
||||
* retains the whole detached tree the content swap has just thrown away — a small leak, but one that
|
||||
* lasts until the pointer happens to cross some other link. */
|
||||
let _lastHovered = null;
|
||||
|
||||
function wireRouter() {
|
||||
if (_wired) return;
|
||||
_wired = true;
|
||||
|
||||
document.addEventListener('click', (ev) => {
|
||||
if (ev.defaultPrevented || ev.button !== 0 ||
|
||||
ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)
|
||||
return;
|
||||
|
||||
const url = linkUrlFrom(ev);
|
||||
if (!url) return;
|
||||
|
||||
/* navigate() carries only the pathname: pushState-ing a bare path for a link that
|
||||
* promised ?query= / #hash would strip both from the URL and from the view, which
|
||||
* reads location.search. Let those links full-load. */
|
||||
if (url.search || url.hash) return;
|
||||
|
||||
/* record the outgoing page's offset under the entry we are still ON; harmless when
|
||||
* navigate() declines (a full load throws the whole Map away anyway) */
|
||||
saveScroll();
|
||||
if (navigate(url.pathname, true, ev.detail === 0))
|
||||
ev.preventDefault();
|
||||
}, false);
|
||||
|
||||
/* Warm the view module cache when the pointer enters a nav link. `pointerover` bubbles from
|
||||
* EVERY element the pointer crosses — dragging across the process table fires it hundreds of
|
||||
* times — so bail on the element first: the same <a> re-fires this for every child span it
|
||||
* contains, and a non-link target is the overwhelmingly common case. */
|
||||
document.addEventListener('pointerover', (ev) => {
|
||||
const a = ev.target.closest?.('a[href]');
|
||||
if (!a || a === _lastHovered) return;
|
||||
_lastHovered = a;
|
||||
prefetchFrom(ev);
|
||||
}, { passive: true });
|
||||
|
||||
/* The pointer is not the only way a link gets chosen, and the other two ways got no prefetch at
|
||||
* all. A KEYBOARD user Tabs to the link and presses Enter — no pointer event ever fires, so the
|
||||
* whole optimisation was invisible to them; focusin is the keyboard's hover, and the Tab→Enter gap
|
||||
* is human-scale, so the module is usually there by the time Enter lands. A TOUCH user gets a
|
||||
* pointerover, but at the same moment as the tap, which is what the in-flight wait in navigate()
|
||||
* is for rather than an earlier trigger. pointerdown adds the one pointer case pointerover cannot
|
||||
* see: a link that scrolled UNDER a stationary pointer crosses no boundary and fires nothing.
|
||||
* Neither needs the lastHovered guard above — they fire once per interaction, not per element
|
||||
* crossed — and warmClass() dedupes per class anyway. */
|
||||
document.addEventListener('focusin', prefetchFrom, { passive: true });
|
||||
document.addEventListener('pointerdown', prefetchFrom, { passive: true });
|
||||
|
||||
window.addEventListener('popstate', () => {
|
||||
/* an entry carrying a query belongs to a full load (we only ever push bare paths):
|
||||
* replaying it as a bare-path SPA nav would drop the query the view expects */
|
||||
if (window.location.search) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
/* A FRAGMENT CHANGE IS NOT A NAVIGATION. Chrome fires `popstate` for a same-document
|
||||
* fragment nav, so clicking an `<a href="#">` inside a view — a very common idiom for
|
||||
* in-page controls — arrived here as if the user had pressed Back, and we re-ran navigate()
|
||||
* for the path already on screen, RE-INSTANTIATING the view and wiping the state the click
|
||||
* had just set (issue #3, "luci-app-filemanager does not work": its tab strip is four
|
||||
* `<a href="#">` links whose handler does not preventDefault). The view changed only if the
|
||||
* PATH changed; if just the fragment moved, the page owns it. */
|
||||
if (window.location.pathname === _curPath)
|
||||
return;
|
||||
|
||||
/* the outgoing DOM is still up: record its offset under the entry we are LEAVING, then
|
||||
* adopt the entry we arrived on and look up what IT recorded when it was left */
|
||||
saveScroll();
|
||||
adoptEntry();
|
||||
const target = _scrollMem.get(_curId);
|
||||
if (!navigate(window.location.pathname, false))
|
||||
window.location.reload();
|
||||
else
|
||||
restoreScroll(target, _navGen);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- the poll indicator must not outlive the poll ----
|
||||
*
|
||||
* LuCI shows the "Refreshing" pill on `poll-start`, flips it to "Paused" on `poll-stop`, and never
|
||||
* hides it again (core calls ui.hideIndicator() only for `uci-changes`). Invisible on a full load,
|
||||
* because Poll.start() dispatches `poll-start` only when the queue is non-empty — an unpolled page
|
||||
* never grows a pill. But our router flushes the queue and calls stop() on every nav, and stop()
|
||||
* DOES dispatch `poll-stop`, so walking from a polled page to an unpolled one left a "Paused" pill
|
||||
* reporting on a poll that does not exist there. Rule: the pill exists iff there is something to
|
||||
* poll. Registered at module eval, i.e. AFTER luci.js's own listener, so ours runs second and can
|
||||
* take back what that one just painted. */
|
||||
document.addEventListener('poll-stop', () => {
|
||||
if (L.Poll && L.Poll.queue && L.Poll.queue.length === 0) {
|
||||
try { ui.hideIndicator('poll-status'); } catch (e) {}
|
||||
}
|
||||
});
|
||||
|
||||
/* Pause LuCI's 1s poll loop while the tab is hidden: LuCI has no visibilitychange handler, so an
|
||||
* overview left open in a background tab hammers ubus 24/7 (notably the pricey iwinfo getAssocList)
|
||||
* on a low-power router. stop() only clearInterval()s (the queue survives); start() re-arms and runs
|
||||
* one immediate step(), so data is fresh on refocus. A poller added while hidden will not auto-start
|
||||
* (stop() deletes the tick) — start() picks it up on show: deferred, not lost. docs/spa-router.md. */
|
||||
let _visWired = false;
|
||||
function wireVisibility() {
|
||||
if (_visWired) return;
|
||||
_visWired = true;
|
||||
/* respect a manual pause: the user can stop polling from the "Refreshing" indicator, and an
|
||||
* unconditional start() on tab-show would silently undo it. Resume only what we paused. */
|
||||
let wasActive = true;
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!L.Poll) return;
|
||||
try {
|
||||
if (document.hidden) {
|
||||
wasActive = L.Poll.active();
|
||||
if (wasActive) L.Poll.stop();
|
||||
}
|
||||
else if (wasActive) {
|
||||
L.Poll.start();
|
||||
}
|
||||
} catch (e) {}
|
||||
});
|
||||
}
|
||||
|
||||
/* Callbacks to run on every SPA navigation, each handed the resolved segments of the INCOMING page
|
||||
* (see navigate() — they run before L.env is re-pointed). The registry is INVERTED on purpose: a
|
||||
* registrant calls in and the router names nobody, so it can never grow a static dependency on a
|
||||
* module that may not be installed. Registrant today: fs-search's recent-pages record and its
|
||||
* close-on-navigate. */
|
||||
const _navCbs = [];
|
||||
function onNavigate(fn) { if (typeof fn === 'function') _navCbs.push(fn); }
|
||||
|
||||
return baseclass.extend({
|
||||
seed,
|
||||
wire: wireRouter,
|
||||
wireVisibility,
|
||||
onNavigate,
|
||||
/* fs-search warms the pages this admin actually uses (its recents) and the arrow-key-highlighted
|
||||
* result, both of which the pointer/focus triggers above cannot see. The edge points that way
|
||||
* round — search → router — because the router must keep no dependency on the palette. */
|
||||
prefetchSegs
|
||||
});
|
||||
@@ -0,0 +1,406 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require fs-menutree as tree';
|
||||
'require fs-prefs as prefs';
|
||||
'require fs-router as router';
|
||||
'require fs-widgets as widgets';
|
||||
|
||||
/* Find a page by typing its name, instead of remembering which section owns it.
|
||||
*
|
||||
* A loaded router carries ~200 reachable menu nodes across 11 sections, and the only way to reach
|
||||
* one was to know its parent — "Attended Sysupgrade" is under System, "Crontab" under System too,
|
||||
* "Port Forwards" is a TAB of Network -> Firewall and appears in no menu list at all until you are
|
||||
* already there. This indexes every node the dispatcher would render, tabs included.
|
||||
*
|
||||
* It costs no request: the tree is the SAME ACL-filtered /admin/menu blob the chrome already
|
||||
* loaded (fs-menutree), so the palette knows exactly the pages this session is allowed to open —
|
||||
* nothing to leak, nothing to 403 on. The index is built on the FIRST open, not at init: a user
|
||||
* who never searches pays nothing, and a full load is the only thing that can change the tree.
|
||||
*
|
||||
* Navigation is deliberately NOT a call into the router: every result is a real <a href>, so a
|
||||
* click bubbles to the router's own document-level handler and takes the SPA path (or falls back
|
||||
* to a full load when the node is not SPA-able) with no second copy of that decision here. Enter
|
||||
* synthesises the same click. */
|
||||
|
||||
/* ---- the index ---------------------------------------------------------- */
|
||||
|
||||
/* admin/<section>/<page>/<tab> — four levels is every path LuCI's dispatcher renders. */
|
||||
const MAX_DEPTH = 4;
|
||||
|
||||
let _index = null;
|
||||
|
||||
/* The node's own children, ACL- and title-filtered exactly as ui.menu.getChildren() filters them
|
||||
* — but NOT through getChildren() itself, and that is the whole reason this function exists.
|
||||
*
|
||||
* On an ALIAS node getChildren() returns a copy whose `children` are the alias TARGET's, which is
|
||||
* right for drawing a menu and wrong for indexing: Network -> Firewall is an alias onto the
|
||||
* `firewall/zones` VIEW, a leaf, so its five tabs (Port Forwards, Traffic Rules, NAT Rules, IP
|
||||
* Sets, Custom Rules) came back as an empty list and "port" found nothing on a router that plainly
|
||||
* has a Port Forwards page. Measured on the dev router: 78 indexed nodes through getChildren(),
|
||||
* with every tab of every aliased page missing.
|
||||
*
|
||||
* Order does not matter here (search ranks by score, not by menu weight), so getChildren()'s sort
|
||||
* is not reimplemented — only its two filters, which are what "this session may open it" means. */
|
||||
function childrenOf(node) {
|
||||
const kids = node.children || {};
|
||||
const out = [];
|
||||
for (const name in kids) {
|
||||
const c = kids[name];
|
||||
if (!c || !c.satisfied || !c.title) continue;
|
||||
out.push({ name: name, node: c });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function walk(node, segs, trail, out, depth) {
|
||||
childrenOf(node).forEach((entry) => {
|
||||
const child = entry.node;
|
||||
/* the chrome carries its own Logout (partials/logout.ut) — searching for it would open a
|
||||
* confirmation the user did not ask for */
|
||||
if (depth === 1 && entry.name === 'logout')
|
||||
return;
|
||||
|
||||
const title = _(child.title);
|
||||
const csegs = segs.concat([ entry.name ]);
|
||||
|
||||
out.push({
|
||||
segs: csegs,
|
||||
path: csegs.join('/'),
|
||||
title: title,
|
||||
trail: trail,
|
||||
depth: depth,
|
||||
/* Three haystacks, searched in this order of preference (see score()). `name` is the
|
||||
* ENGLISH node name and is indexed on purpose: a Russian UI translates "Firewall" to
|
||||
* "Межсетевой экран", and an admin who knows OpenWrt by its English docs types
|
||||
* "firewall". Both find it. */
|
||||
t: title.toLowerCase(),
|
||||
p: trail.join(' ').toLowerCase(),
|
||||
/* …minus the ROOT segment, which is `admin` on every single node. Indexed, it made
|
||||
* every substring of "admin" — a, ad, adm, dmi, min, in — a hit on all ~200 pages, so
|
||||
* `min` returned "Terminal", "Administration", "Attended Sysupgrade" and stopped at the
|
||||
* 20-result cap sorted only by depth (measured). It carries no information precisely
|
||||
* because it is shared by everything. */
|
||||
n: csegs.slice(1).join(' ').toLowerCase()
|
||||
});
|
||||
|
||||
if (depth < MAX_DEPTH)
|
||||
walk(child, csegs, trail.concat([ title ]), out, depth + 1);
|
||||
});
|
||||
}
|
||||
|
||||
function buildIndex() {
|
||||
const root = tree.tree();
|
||||
const out = [];
|
||||
if (!root) return out;
|
||||
/* the mode (admin) is a container, not a destination: start one level in */
|
||||
childrenOf(root).forEach((mode) => {
|
||||
walk(mode.node, [ mode.name ], [], out, 1);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function index() {
|
||||
if (!_index) _index = buildIndex();
|
||||
return _index;
|
||||
}
|
||||
|
||||
/* ---- matching ----------------------------------------------------------- */
|
||||
|
||||
/* Every whitespace-separated token must hit SOMETHING, so "fire port" finds Firewall -> Port
|
||||
* Forwards while "fire xyz" finds nothing — an AND is what a user typing a second word means.
|
||||
*
|
||||
* Deliberately not a fuzzy subsequence match (the "fzf" kind): on a two-letter query it matches
|
||||
* almost every entry and the ranking then decides everything, which reads as random. A substring
|
||||
* ladder is predictable — what you typed is visibly in what you got. */
|
||||
const HIT_NONE = 99;
|
||||
function tokenScore(e, tok) {
|
||||
if (e.t.startsWith(tok)) return 0; /* the title begins with it */
|
||||
if (e.t.includes(tok)) return 1; /* somewhere in the title */
|
||||
if (e.n.includes(tok)) return 2; /* the English path segment */
|
||||
if (e.p.includes(tok)) return 3; /* an ancestor's title */
|
||||
return HIT_NONE;
|
||||
}
|
||||
|
||||
function search(q, limit) {
|
||||
const toks = q.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (!toks.length) return [];
|
||||
|
||||
const hits = [];
|
||||
for (const e of index()) {
|
||||
let sum = 0;
|
||||
for (const tok of toks) {
|
||||
const s = tokenScore(e, tok);
|
||||
if (s === HIT_NONE) { sum = HIT_NONE; break; }
|
||||
sum += s;
|
||||
}
|
||||
if (sum !== HIT_NONE)
|
||||
hits.push({ e: e, score: sum + (e.depth * 0.1) }); /* ties: the shallower page first */
|
||||
}
|
||||
hits.sort((a, b) => a.score - b.score);
|
||||
return hits.slice(0, limit).map((h) => h.e);
|
||||
}
|
||||
|
||||
/* ---- recently visited --------------------------------------------------- */
|
||||
|
||||
/* What the palette shows before a single character is typed. A router admin lives in three or four
|
||||
* pages, so the empty state is the most-used view of this thing — an empty box would waste it.
|
||||
*
|
||||
* Only the PATH is stored, never the title: the title is resolved back through the index on every
|
||||
* render, so it follows the UI language and a page that disappeared with its package simply drops
|
||||
* out instead of lingering as a dead row. */
|
||||
const RECENT_KEY = 'fs-recent';
|
||||
const RECENT_MAX = 8;
|
||||
|
||||
/* prefs.lsGetArr owns the parse, the corruption guard and the Array check; only the
|
||||
* "these are paths" filter is this module's business. */
|
||||
function loadRecent() {
|
||||
return prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string');
|
||||
}
|
||||
|
||||
let _recent = loadRecent();
|
||||
|
||||
function remember(segs) {
|
||||
if (!Array.isArray(segs) || !segs.length) return;
|
||||
const path = segs.join('/');
|
||||
_recent = [ path ].concat(_recent.filter((p) => p !== path)).slice(0, RECENT_MAX);
|
||||
prefs.lsSet(RECENT_KEY, JSON.stringify(_recent));
|
||||
}
|
||||
|
||||
function recentEntries() {
|
||||
const byPath = new Map(index().map((e) => [ e.path, e ]));
|
||||
return _recent.map((p) => byPath.get(p)).filter(Boolean).slice(0, RECENT_MAX);
|
||||
}
|
||||
|
||||
/* ---- warm the pages this admin actually uses ----
|
||||
*
|
||||
* The router's per-link prefetch needs a hover, a tap or a focus first, so the FIRST visit of a
|
||||
* session to a page still pays for its module chain. The recents list is the best predictor of that
|
||||
* page available anywhere in the theme — an admin lives in three or four of them — and it is already
|
||||
* on disk. Walking the whole menu instead would pull every view module on the box, which is the cost
|
||||
* docs/spa-router.md warns about; five recents is a handful of files.
|
||||
*
|
||||
* The current page is skipped: wire() has just remembered it, so it heads the list, and it is loaded
|
||||
* by definition. saveData is the user saying "not over this link", and speculation is exactly what
|
||||
* has to go then — the per-link prefetch stays, because that one follows a deliberate hover or tap. */
|
||||
const RECENT_WARM = 5;
|
||||
|
||||
function warmRecent() {
|
||||
try { if (navigator.connection && navigator.connection.saveData) return; } catch (e) {}
|
||||
const here = (L.env.dispatchpath || []).join('/');
|
||||
const paths = _recent.filter((p) => p !== here).slice(0, RECENT_WARM);
|
||||
if (!paths.length) return;
|
||||
/* Nothing waits on this, so it belongs after the page has settled — at idle, with a timeout for a
|
||||
* page that never goes idle (a busy poll). The fallback is deliberately a LONG timeout, unlike
|
||||
* fs-appearance's ~1 ms one: that wires a button the user may click immediately, this competes
|
||||
* with the view's own module fetches and RPCs and must lose that race on purpose. */
|
||||
const go = () => paths.forEach((p) => router.prefetchSegs(p.split('/')));
|
||||
if (typeof window.requestIdleCallback === 'function')
|
||||
window.requestIdleCallback(go, { timeout: 4000 });
|
||||
else
|
||||
window.setTimeout(go, 2000);
|
||||
}
|
||||
|
||||
/* ---- the palette -------------------------------------------------------- */
|
||||
|
||||
const MAX_RESULTS = 20;
|
||||
|
||||
function wire() {
|
||||
const btn = document.getElementById('fs-search-btn');
|
||||
if (!btn) return;
|
||||
|
||||
/* Remember the page this full load landed on. The SPA path is covered by onNavigate below;
|
||||
* this covers an F5, a non-SPA-able node and the very first page of a session. */
|
||||
remember(L.env.dispatchpath || []);
|
||||
/* The callback is handed the RESOLVED segments of the incoming page: reading L.env here would
|
||||
* give the OUTGOING one, since the router fires its callbacks before it re-points L.env. */
|
||||
router.onNavigate(remember);
|
||||
/* after remember(), so the page we are standing on is the one head of the list that gets skipped */
|
||||
warmRecent();
|
||||
|
||||
const input = E('input', {
|
||||
'type': 'text',
|
||||
'class': 'fs-search-input',
|
||||
'role': 'combobox',
|
||||
'aria-controls': 'fs-search-list',
|
||||
'aria-expanded': 'true',
|
||||
'aria-autocomplete': 'list',
|
||||
'aria-label': _('Search pages', 'footstrap'),
|
||||
'placeholder': _('Search pages…', 'footstrap'),
|
||||
'autocomplete': 'off',
|
||||
'autocapitalize': 'off',
|
||||
'spellcheck': 'false'
|
||||
});
|
||||
const list = E('div', { 'id': 'fs-search-list', 'class': 'fs-search-list', 'role': 'listbox', 'aria-label': _('Pages', 'footstrap') });
|
||||
const note = E('div', { 'class': 'fs-search-note' });
|
||||
const ico = E('span', { 'class': 'fs-search-ico' });
|
||||
ico.innerHTML = widgets.svgIcon('<circle cx="11" cy="11" r="7"/><path d="M16.5 16.5 21 21"/>');
|
||||
const box = E('div', { 'class': 'fs-search-box' }, [
|
||||
E('div', { 'class': 'fs-search-row' }, [ ico, input ]),
|
||||
/* the note is a CAPTION for the rows below it ("Recently visited") and the box's EMPTY
|
||||
* STATE when there are none, so it belongs above the list in both readings — it sat after
|
||||
* the list at first, which put the caption under the rows it captions */
|
||||
note,
|
||||
list
|
||||
]);
|
||||
/* data-fs-chrome marks a Zone 1 ROOT (docs/third-party-apps.md): this is a `position: fixed` overlay parented
|
||||
* to <body>, i.e. outside the <nav> that carries the mark in header.ut — exactly the shape that
|
||||
* once left the Appearance popover unfenced while every test said the chrome was defended. */
|
||||
const ov = E('div', {
|
||||
'id': 'fs-search-ov',
|
||||
'class': 'fs-search-ov',
|
||||
'data-fs-chrome': '',
|
||||
'role': 'dialog',
|
||||
'aria-modal': 'true',
|
||||
'aria-label': _('Search pages', 'footstrap'),
|
||||
'hidden': ''
|
||||
}, [ box ]);
|
||||
document.body.appendChild(ov);
|
||||
|
||||
let opts = [], ents = [], at = -1;
|
||||
|
||||
/* Warm the highlighted page's module chain, DEBOUNCED. render() re-runs setActive(0) on every
|
||||
* keystroke, so warming immediately would pull the top result of "w", "wi", "wir"… and only the
|
||||
* last of those is a page anyone asked for. The delay also matches how the list is used: the
|
||||
* highlight settles, then Enter. Only the ARROW KEYS and typing need this — the rows are real
|
||||
* anchors in the document, so a mouse moving over one already reaches the router's own pointerover
|
||||
* listener. warmClass() dedupes per class, so a row revisited costs nothing. */
|
||||
let warmT = null;
|
||||
function warmActive() {
|
||||
if (warmT) window.clearTimeout(warmT);
|
||||
warmT = window.setTimeout(() => {
|
||||
warmT = null;
|
||||
if (ents[at]) router.prefetchSegs(ents[at].segs);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function setActive(i) {
|
||||
if (!opts.length) { at = -1; input.removeAttribute('aria-activedescendant'); return; }
|
||||
at = (i + opts.length) % opts.length;
|
||||
opts.forEach((o, n) => {
|
||||
const on = (n === at);
|
||||
o.classList.toggle('active', on);
|
||||
o.setAttribute('aria-selected', on ? 'true' : 'false');
|
||||
});
|
||||
input.setAttribute('aria-activedescendant', opts[at].id);
|
||||
opts[at].scrollIntoView({ block: 'nearest' });
|
||||
warmActive();
|
||||
}
|
||||
|
||||
function render(q) {
|
||||
const entries = q ? search(q, MAX_RESULTS) : recentEntries();
|
||||
ents = entries;
|
||||
list.innerHTML = '';
|
||||
opts = entries.map((e, i) => {
|
||||
/* role="option" on the <a> rather than a <div> wrapping one: an option may not contain
|
||||
* an interactive element, and the anchor has to stay a real link — it is what carries
|
||||
* the click to the router, and what keeps middle-click and "copy link" working. */
|
||||
const a = E('a', {
|
||||
'class': 'fs-search-opt',
|
||||
'role': 'option',
|
||||
'id': 'fs-search-opt-' + i,
|
||||
'aria-selected': 'false',
|
||||
'href': L.url.apply(L, e.segs)
|
||||
}, [
|
||||
E('span', { 'class': 'fs-search-opt-title' }, [ e.title ]),
|
||||
e.trail.length ? E('span', { 'class': 'fs-search-opt-path' }, [ e.trail.join(' › ') ]) : ''
|
||||
]);
|
||||
/* close BEFORE the click reaches the router (which re-renders the chrome underneath),
|
||||
* and without taking focus back — the user is going somewhere else */
|
||||
a.addEventListener('click', () => close(false));
|
||||
a.addEventListener('pointermove', () => { if (at !== i) setActive(i); });
|
||||
list.appendChild(a);
|
||||
return a;
|
||||
});
|
||||
note.textContent = opts.length
|
||||
? (q ? '' : _('Recently visited', 'footstrap'))
|
||||
: (q ? _('No pages found', 'footstrap') : _('Start typing to find a page', 'footstrap'));
|
||||
note.hidden = !note.textContent;
|
||||
setActive(0);
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (!ov.hidden) return;
|
||||
ov.hidden = false;
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
input.value = '';
|
||||
render('');
|
||||
input.focus();
|
||||
}
|
||||
|
||||
function close(returnFocus = true) {
|
||||
if (ov.hidden) return;
|
||||
ov.hidden = true;
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
if (returnFocus) btn.focus();
|
||||
}
|
||||
|
||||
input.addEventListener('input', () => render(input.value.trim()));
|
||||
|
||||
/* Keys are handled on the OVERLAY, not the input: the scrim is part of the dialog and a click
|
||||
* on it moves focus to the overlay itself, where an Escape must still close. */
|
||||
ov.addEventListener('keydown', (ev) => {
|
||||
switch (ev.key) {
|
||||
case 'Escape':
|
||||
ev.preventDefault();
|
||||
close();
|
||||
return;
|
||||
case 'ArrowDown':
|
||||
ev.preventDefault(); setActive(at + 1); return;
|
||||
case 'ArrowUp':
|
||||
ev.preventDefault(); setActive(at - 1); return;
|
||||
case 'Home':
|
||||
if (!input.value) { ev.preventDefault(); setActive(0); }
|
||||
return;
|
||||
case 'End':
|
||||
if (!input.value) { ev.preventDefault(); setActive(opts.length - 1); }
|
||||
return;
|
||||
case 'Enter':
|
||||
if (at < 0 || !opts[at]) return;
|
||||
ev.preventDefault();
|
||||
/* a synthetic click carries detail 0, which is exactly what the router reads as a
|
||||
* KEYBOARD activation — so the focus lands where a keyboard navigation puts it */
|
||||
opts[at].click();
|
||||
return;
|
||||
case 'Tab':
|
||||
/* aria-modal="true" is a promise that Tab cannot walk out into the page behind, and
|
||||
* the input is the dialog's only tabbable element, so the trap is: stay. Escape (or a
|
||||
* click outside) is the way out, and both hand focus back to the trigger. */
|
||||
ev.preventDefault();
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
/* a click on the scrim — anywhere outside the box — closes */
|
||||
ov.addEventListener('click', (ev) => { if (ev.target === ov) close(); });
|
||||
|
||||
btn.addEventListener('click', () => { ov.hidden ? open() : close(); });
|
||||
|
||||
/* Back and Forward are navigations no listener here can see: every close above is a user act on
|
||||
* the document (Escape, the scrim, the trigger, picking a result). An open palette therefore rode
|
||||
* a popstate onto the next page, aria-modal and Tab-trapped, while the router moved focus behind
|
||||
* it. Same reasoning as the Appearance popover's — see fs-appearance.js. returnFocus=false because
|
||||
* the router places focus itself on a navigation. */
|
||||
router.onNavigate(() => close(false));
|
||||
|
||||
/* Ctrl/Cmd+K is the shortcut every command palette has taught users, and `/` is the one every
|
||||
* search field on the web has. `/` only when the user is not already typing somewhere: an
|
||||
* <input>, a contenteditable, or a .cbi-dropdown (fs-select.js gives those their own typeahead,
|
||||
* where a `/` is a search character, not a shortcut). */
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
if (ev.defaultPrevented) return;
|
||||
if ((ev.ctrlKey || ev.metaKey) && !ev.altKey && (ev.key === 'k' || ev.key === 'K')) {
|
||||
ev.preventDefault();
|
||||
ov.hidden ? open() : close();
|
||||
return;
|
||||
}
|
||||
if (ev.key !== '/' || ev.ctrlKey || ev.metaKey || ev.altKey) return;
|
||||
if (ev.target.closest?.('input, textarea, select, [contenteditable], .cbi-dropdown')) return;
|
||||
ev.preventDefault();
|
||||
open();
|
||||
});
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
wire
|
||||
});
|
||||
@@ -0,0 +1,522 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require ui';
|
||||
'require dom';
|
||||
'require fs-fit as fit';
|
||||
|
||||
/* Theme plain LuCI <select> fields (ui.Select, widget:'select') by rendering a styled
|
||||
* cbi-dropdown beside them — a native <select> popup cannot be CSS-styled.
|
||||
*
|
||||
* The native <select> stays the form field and MUST remain frameEl.firstChild:
|
||||
* ui.Select.getValue() returns `this.node.firstChild.value`. Inserting our widget BEFORE it made
|
||||
* getValue read a <div> and return `undefined`, which broke Save. So insert AFTER, and mirror the
|
||||
* value both ways. Sharing the frameEl also ties our node to the widget's lifecycle, so a CBI
|
||||
* re-render disposes of it — no orphans.
|
||||
*
|
||||
* Runs theme-wide (required from the footer); watches for selects added later by client CBI. */
|
||||
|
||||
function readChoices(sel) {
|
||||
const choices = {};
|
||||
Array.prototype.forEach.call(sel.options, (o) => { choices[o.value] = o.textContent; });
|
||||
return choices;
|
||||
}
|
||||
|
||||
/* cheap identity of the option list, to detect a script rebuilding it
|
||||
* (select.replaceChildren, dependency-driven re-population, …) */
|
||||
function choicesKey(sel) {
|
||||
return Array.prototype.map.call(sel.options, (o) => o.value + '\u0000' + o.textContent).join('\u0001');
|
||||
}
|
||||
|
||||
/* undo enhance(): drop the widget, unhide the select, and — critically — cut every listener
|
||||
* enhance() installed. The `change` listener used to survive teardown, and resync() calls
|
||||
* teardown()+enhance() every time a script rebuilds the option list (CBI dependencies do this
|
||||
* constantly on the firewall/network forms) — so the select accumulated one live listener per
|
||||
* rebuild, each closing over a dead ui.Dropdown and its detached subtree: a leak that grew with
|
||||
* every interaction. AbortController is the only way to drop an anonymous listener. */
|
||||
function teardown(sel) {
|
||||
if (sel._fsAbort) sel._fsAbort.abort();
|
||||
if (sel._fsNode && sel._fsNode.parentNode)
|
||||
sel._fsNode.parentNode.removeChild(sel._fsNode);
|
||||
delete sel.dataset.fsSelect;
|
||||
sel._fsDd = sel._fsNode = sel._fsKey = sel._fsAbort = null;
|
||||
sel.removeAttribute('aria-hidden');
|
||||
sel.style.display = '';
|
||||
}
|
||||
|
||||
/* keep an enhanced select and its widget in step when a script drives the native element
|
||||
* directly: ui.Select.setValue() rewrites value/options WITHOUT dispatching `change`, so
|
||||
* enhance()'s mirror never fires and the widget went stale — showed the old value while Save
|
||||
* read the new one. */
|
||||
function resync(sel) {
|
||||
const dd = sel._fsDd;
|
||||
if (!dd || !sel._fsNode) return;
|
||||
if (sel.disabled) { teardown(sel); return; } /* disabled later: back to native */
|
||||
const key = choicesKey(sel);
|
||||
if (key !== sel._fsKey) {
|
||||
/* option list rebuilt — recreate the widget from the fresh options */
|
||||
teardown(sel);
|
||||
enhance(sel);
|
||||
return;
|
||||
}
|
||||
if (dd.getValue() !== sel.value)
|
||||
dd.setValue(sel.value);
|
||||
}
|
||||
|
||||
/* A VALUE written through the IDL is invisible to every observer there is.
|
||||
*
|
||||
* `sel.value = x` and `options[i].selected = true` — which is exactly what `ui.Select.setValue()`
|
||||
* does, and `form.js`'s `updateDefaultValue()` calls it on every dependency pass — set no content
|
||||
* attribute and add no node, so no MutationRecord is produced at all. relevant() therefore could
|
||||
* never wake, resync() never ran, and the widget showed the old label while `getValue()` (and Save)
|
||||
* read the new one. Reproduced on the router: `s.value = 'DROP'` left the widget unchanged.
|
||||
*
|
||||
* So this runs from the fitter, i.e. once per content mutation batch — the same cadence the tables
|
||||
* already use — and it is deliberately the CHEAP half of resync(): a value compare per enhanced
|
||||
* select, no choicesKey() over every option. Re-keying the widget stays behind relevant(), which now
|
||||
* sees an option-list rebuild. */
|
||||
function resyncValues() {
|
||||
for (const sel of document.querySelectorAll('select[data-fs-select]')) {
|
||||
const dd = sel._fsDd;
|
||||
if (!dd || !sel._fsNode || sel.disabled) continue;
|
||||
if (dd.getValue() !== sel.value)
|
||||
dd.setValue(sel.value);
|
||||
}
|
||||
}
|
||||
|
||||
function enhance(sel) {
|
||||
if (sel.dataset.fsSelect || sel.disabled) return; /* disabled: NOT marked — it may be enabled later */
|
||||
/* `multiple` and "not in a CBI field" are permanent, so mark it and stop re-testing on
|
||||
* every scan */
|
||||
if (sel.multiple || !sel.closest('.cbi-value-field, .td.cbi-value-field, .cbi-value')) {
|
||||
sel.dataset.fsSelect = 'skip';
|
||||
return;
|
||||
}
|
||||
|
||||
const choices = readChoices(sel);
|
||||
|
||||
let dd;
|
||||
try {
|
||||
dd = new ui.Dropdown(sel.value, choices, {
|
||||
sort: false,
|
||||
optional: Object.prototype.hasOwnProperty.call(choices, '')
|
||||
});
|
||||
} catch (e) { return; }
|
||||
|
||||
const node = dd.render();
|
||||
const ac = new AbortController();
|
||||
sel.dataset.fsSelect = '1';
|
||||
sel.style.display = 'none';
|
||||
/* The hidden <select> leaves the CBI <label for=…> pointing at something no screen reader
|
||||
* announces, and the visible widget nameless. Move the name over, drop the select from the
|
||||
* a11y tree. */
|
||||
const title = sel.closest('.cbi-value')?.querySelector('.cbi-value-title');
|
||||
/* In a TABLE section there is no .cbi-value and no .cbi-value-title at all — form.js builds
|
||||
* `E('td', {class: 'td cbi-value-field'})` there — so on firewall zones, port forwards and
|
||||
* static leases the widget was left with no accessible name while the native select it replaces
|
||||
* is aria-hidden. The cell's `data-title` IS the column heading (LuCI fills it for the card
|
||||
* stack), which is the same string the header cell shows. */
|
||||
const name = (title && title.textContent.trim()) ||
|
||||
(sel.closest('.td')?.getAttribute('data-title') || '').trim();
|
||||
if (name)
|
||||
node.setAttribute('aria-label', name);
|
||||
/* Clicking the field's caption must reach the widget. form.js wires that label to
|
||||
* `#widget.cbid…`.click()/focus() — which is the native <select> we just set `display: none` on,
|
||||
* so on this theme the click did nothing at all (measured: focus stayed on <body>, no list
|
||||
* opened), while stock bootstrap focuses the select. The <label for=…> is equally dead for the
|
||||
* same reason. Re-point the gesture at the visible control — focus only, which is the parity
|
||||
* stock gets: its `elem.click()` on a `<select>` opens no list either, so the gesture has always
|
||||
* meant "put me on this control". */
|
||||
if (title)
|
||||
title.addEventListener('click', () => node.focus(), { signal: ac.signal });
|
||||
sel.setAttribute('aria-hidden', 'true');
|
||||
sel._fsDd = dd;
|
||||
sel._fsNode = node;
|
||||
sel._fsKey = choicesKey(sel);
|
||||
sel._fsAbort = ac;
|
||||
|
||||
/* AFTER the select: it must stay frameEl.firstChild for ui.Select to read its value on save */
|
||||
sel.parentNode.insertBefore(node, sel.nextSibling);
|
||||
|
||||
/* stops our own dd->sel dispatch from echoing back through the sel->dd listener */
|
||||
let syncing = false;
|
||||
|
||||
/* our widget -> native select (user picked an option) */
|
||||
node.addEventListener('cbi-dropdown-change', () => {
|
||||
const v = dd.getValue();
|
||||
if (sel.value === v) return;
|
||||
syncing = true;
|
||||
sel.value = v;
|
||||
sel.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
syncing = false;
|
||||
}, { signal: ac.signal });
|
||||
|
||||
/* native select -> our widget (a script/CBI dependency changed and dispatched change on
|
||||
* the select) — keeps the visible widget from going stale */
|
||||
sel.addEventListener('change', () => {
|
||||
if (syncing) return;
|
||||
if (dd.getValue() !== sel.value)
|
||||
dd.setValue(sel.value);
|
||||
}, { signal: ac.signal });
|
||||
}
|
||||
|
||||
/* Tag standalone data tables so the stacking rules key off a static `.fs-dt` instead of a live
|
||||
* `:has(.tr.table-titles)` the style engine re-evaluated on every mutation of these polled tables
|
||||
* (Processes/routes/leases). Not a .cbi-section-table — config forms keep their own layout.
|
||||
*
|
||||
* `.table`, not `table.table` — the SAME selector relevant() and STACKABLE use. Stock LuCI
|
||||
* happens to emit only real <table>s, but a third-party luci-app-* may emit a <div class="table">
|
||||
* (coverage rule, docs/conventions.md), which a tag qualifier would pass over so it could never card. */
|
||||
function tagDataTables() {
|
||||
document.querySelectorAll('#view .table:not(.cbi-section-table):not(.fs-dt)').forEach((t) => {
|
||||
/* THREE header markups, and each missing one cost a page. L.ui.Table emits
|
||||
* `.tr.table-titles`; the apk Software page emits `.tr.cbi-section-table-titles` (missing
|
||||
* it is why the package list once needed a stacking block of its own); and a third-party
|
||||
* table may simply use a real `<thead>` — luci-mod-dashboard's device lists are
|
||||
* `<thead class="thead dashboard-bg"><th class="th nowrap">`, matching neither name. They
|
||||
* therefore never carded, and because those `th`s are `nowrap` they could not compress
|
||||
* either: on a phone the right-hand columns were cut off by .fs-main's overflow clip.
|
||||
* Reported from a router with wifi clients.
|
||||
*
|
||||
* `thead`, not `thead tr`: that markup is built by E(), which appends the `<th>`s straight
|
||||
* to the `<thead>` — the parser's implied row never happens, so a `tr` in the selector finds
|
||||
* nothing. Read as "the header ROW-ISH element", which is what its children are cells of.
|
||||
*
|
||||
* ANY of the three = a data table; NONE = a key/value include (System, Memory), which must
|
||||
* never card. `thead` is the structural form of the same statement the two classes make, so
|
||||
* it belongs in the same list rather than in a rule of its own. */
|
||||
const head = t.querySelector('.tr.table-titles, .tr.cbi-section-table-titles, thead');
|
||||
if (!head) return;
|
||||
t.classList.add('fs-dt');
|
||||
labelCells(t, head);
|
||||
});
|
||||
}
|
||||
|
||||
/* Give every cell the column heading it will show once the table cards.
|
||||
*
|
||||
* The card layout prints `attr(data-title)` above each value (theme/30-tables.css), and LuCI's own
|
||||
* table builders fill that attribute in. A foreign table has no reason to: luci-mod-dashboard emits
|
||||
* bare `<td class="td">`, so carding it would have produced a column of values with nothing saying
|
||||
* which was the hostname and which the signal — worse than the clipped table it replaced.
|
||||
*
|
||||
* The heading is COPIED, not invented: it is the text of the header cell in the same position, so
|
||||
* the card says exactly what the column header says. Never overwrites an existing data-title — if
|
||||
* the app set one, that is the app's answer and it knows more than a positional guess. Cheap enough
|
||||
* to re-run on every fit pass (it is skipped entirely once the cells carry the attribute), which
|
||||
* matters because these tables are POLLED: the rows are replaced wholesale every few seconds, and
|
||||
* the fresh ones arrive without it. */
|
||||
function labelCells(t, head) {
|
||||
const titles = [ ...head.children ].map((c) => (c.textContent || '').trim());
|
||||
if (!titles.some(Boolean)) return;
|
||||
for (const row of t.querySelectorAll('.tr, tbody tr')) {
|
||||
if (row === head) continue;
|
||||
const cells = row.children;
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
if (i >= titles.length || !titles[i]) continue;
|
||||
if (cells[i].hasAttribute('data-title')) continue;
|
||||
/* a cell that spans columns has no single heading to take */
|
||||
if (cells[i].colSpan > 1) continue;
|
||||
cells[i].setAttribute('data-title', titles[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- CARD-STACK A DATA TABLE THAT NO LONGER FITS --------------------------------
|
||||
*
|
||||
* Measuring, scheduling and the observers are fs-fit.js; this file supplies only the DECISION.
|
||||
* A data table used to card by @container at THREE thresholds (568 plain, 780 leases, 800 apk
|
||||
* package list), the last two each carrying their own COPY of the card rules — CSS cannot share
|
||||
* a block across two thresholds. All were really asking "does it OVERFLOW?", a fact the browser
|
||||
* computes, so it is measured instead: the card rules live once in theme/30-tables.css on
|
||||
* .fs-stacked, and a third-party table of unknowable width works too.
|
||||
*
|
||||
* A CONFIG table (.cbi-section-table) keeps its @container (960, theme/65-dropdown.css) and must
|
||||
* NOT be measured: its rows hold widgets (enhance() above turns every <select> into a
|
||||
* ui.Dropdown) and a widget bakes in the width of the layout it was laid out in, so
|
||||
* un-collapsing it to read it CHANGES what is read. Measured on the router: the firewall zone
|
||||
* table then reported needing 1747px where it really needs 1190px and overflowed its section by
|
||||
* 557px — an overflow the CSS-only version never had. A data table has no widgets, which is why
|
||||
* it is the one that gets measured. */
|
||||
const STACKABLE = '#view .table.fs-dt';
|
||||
|
||||
/* "Too cramped to be a table any more" — a DESIGN judgement. It has to be one: these tables do
|
||||
* NOT overflow when the room runs out (their cells break anywhere), they compress into an
|
||||
* unreadable ribbon. Do NOT give the cells a min-width so that "cramped" MANUFACTURES an
|
||||
* overflow: tried, and it carded the firewall's zone table at 1420px and still overflowed by
|
||||
* 39px once carded — a floor big enough to force the overflow is big enough to break the card. */
|
||||
const CRAMPED = 568; /* stock LuCI cards its tables at a 600px viewport; below the 767px
|
||||
* tier .fs-content pads 16px a side, so 600 -> 568 of room */
|
||||
|
||||
/* The ribbon has one more shape, and CRAMPED cannot see it: the table has room by the number
|
||||
* above and still shreds its FIRST column, because auto table layout hands width out by what
|
||||
* each column DEMANDS. The leftmost column is the row's identity and usually the least greedy —
|
||||
* a wide neighbour (a hostname plus an IPv6, a modulation string) simply takes the width, and
|
||||
* `overflow-wrap: anywhere` (theme/30-tables.css) lets the identity be squeezed with no floor:
|
||||
* it breaks mid-word rather than overflow, so there is no overflow for fit.overflows() to read.
|
||||
*
|
||||
* Measured on the router (Wireless, one station, `Access Point "vaka_devices" (phy6-ap0)`):
|
||||
* viewport 900 -> the column is 101px and 5 lines, 850 -> 80px and 7, 800 -> 76px and 8, and at
|
||||
* NO width did the table card — a nine-line tower of half-words next to columns with room to
|
||||
* spare (issue #7). Below 767 the MAC column drops out (the stock phone contract) and the
|
||||
* column springs back to 167px, which is why this only ever bit between roughly 780 and 900.
|
||||
*
|
||||
* So: past this many lines the identity has stopped being readable and the card view — which
|
||||
* gives every field its own labelled row — is simply better. A DESIGN judgement like CRAMPED,
|
||||
* and it has to be one: any number of lines is legible in isolation. 5 is what the reporter
|
||||
* asked for and what the measurements above bracket. Only the first column, and deliberately:
|
||||
* a value column wrapping to a few lines is a value being shown, not a table falling apart. */
|
||||
const MAX_ID_LINES = 5;
|
||||
|
||||
/* Is any row's leftmost cell a tower? Text lines, not height — fs-fit.textLines() explains why.
|
||||
* The height gate in front of it is not premature: this runs on every poll tick (once a second,
|
||||
* every mutation), and Processes/Connections render hundreds of rows whose first cell is a PID
|
||||
* that cannot be a tower — one cheap read each keeps the Range walk for the cells that could. */
|
||||
function idTower(t) {
|
||||
const cells = t.querySelectorAll('.tr > .td:first-child');
|
||||
if (!cells.length) return false;
|
||||
const cs = getComputedStyle(cells[0]);
|
||||
const lh = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.2 || 16;
|
||||
for (const cell of cells) {
|
||||
if (cell.clientHeight < MAX_ID_LINES * lh) continue;
|
||||
if (fit.textLines(cell) > MAX_ID_LINES) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function fitTables() {
|
||||
document.querySelectorAll(STACKABLE).forEach((t) => {
|
||||
const was = t.classList.contains('fs-stacked');
|
||||
|
||||
/* fs-fit rule 1: a stacked table is a pile of flex rows and always "fits", so reading
|
||||
* it as it stands un-stacks it and the next frame stacks it again — oscillation. */
|
||||
t.classList.remove('fs-stacked');
|
||||
const room = fit.roomFor(t);
|
||||
if (!(room > 0)) { if (was) t.classList.add('fs-stacked'); return; }
|
||||
|
||||
/* idTower last: it is the only one that walks the rows */
|
||||
const stack = room < CRAMPED || fit.overflows(t) || idTower(t);
|
||||
/* write only on a real change: the poll re-renders these tables once a second, and
|
||||
* toggling the class off and on each tick would invalidate style for every row of
|
||||
* Processes/Leases for nothing */
|
||||
if (stack) t.classList.add('fs-stacked');
|
||||
else if (was) t.classList.remove('fs-stacked');
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- A PINNED ACTIONS COLUMN IS ONLY VALID FOR THE LAYOUT MODE IT WAS MEASURED IN ----
|
||||
*
|
||||
* luci-base's `form.js` (stabilizeActionColumnWidth) measures the widest
|
||||
* `td.cbi-section-actions > div` and writes that number as an INLINE `width` and `min-width` onto the
|
||||
* header cell, the footer cell and every actions cell, caching it in `data-action-col-width`. It does
|
||||
* re-run on window resize — but it only deletes the CACHE, never the inline widths, so the fresh
|
||||
* measurement reads the width it pinned last time. The pin feeds itself and can only ever grow.
|
||||
*
|
||||
* On a stock theme that is invisible: a config table is a table at every width, so every measurement
|
||||
* is taken in the same layout. This theme cards it under `@container fs-content (max-width: 960px)`
|
||||
* (theme/65-dropdown.css), where the actions cell is `flex: 1 1 100%` and its buttons deliberately
|
||||
* spread across the whole card — so a measurement taken there is the CARD's width, and carrying it
|
||||
* into table mode makes the column absurd.
|
||||
*
|
||||
* Measured on the router, Network -> Firewall -> Zones: loaded at 1000px (carded) and grown to
|
||||
* 1280px, the actions column pins 634px, the table renders 1267px inside a 1056px content column and
|
||||
* the column scrolls sideways by 256px — permanently, because upstream's own re-measure reads the
|
||||
* pin. A FRESH load at 1280px renders the same table at 966px with a 192px actions column. Shrinking,
|
||||
* and growing within table mode, were always fine; it is the card -> table crossing that breaks.
|
||||
*
|
||||
* So drop the pin whenever the layout it was measured in stops being the layout on screen. Upstream
|
||||
* re-measures from a clean DOM on its own resize listener and pins the right number; if it does not,
|
||||
* the natural width is what we wanted anyway.
|
||||
*
|
||||
* THE KEY IS THE ROOM, NOT THE MODE, and starting from the mode alone missed half of it: the card ->
|
||||
* table crossing is one way a pin goes stale, and the card simply getting NARROWER is the other. At
|
||||
* 768px this theme has no sidebar (data-narrow) and the column is 712px; at 800px the sidebar returns
|
||||
* and the column is 520px — the viewport grew, the room shrank, and the table was carded on both
|
||||
* sides, so a mode test sees no change at all. Measured: firewall/zones and wireless both kept a
|
||||
* `min-width: 670px` cell in a 520px column, 154px of scroll. Keying on the room catches both, since
|
||||
* a mode change cannot happen without one.
|
||||
*
|
||||
* The room is the parent's content box (fit.roomFor), which the table's own width does not feed back
|
||||
* into — so wiping the pin cannot change the key and set this oscillating. It fires once per CHANGE,
|
||||
* never per tick, so it does not fight upstream for the pin on a polled page. */
|
||||
function unpinActionColumn() {
|
||||
for (const t of document.querySelectorAll('#view .table.cbi-section-table')) {
|
||||
if (!t.querySelector('.cbi-section-actions')) continue;
|
||||
/* ---- and CLAIM upstream's resize hook, because under SPA navigation it is a leak ----
|
||||
*
|
||||
* stabilizeActionColumnWidth ends by attaching `window.addEventListener('resize', …)` once per
|
||||
* TABLE ELEMENT, guarded by this expando, and the callback closes over that element. Nothing
|
||||
* ever removes it. On a stock theme the next page is a full load and the listener dies with the
|
||||
* document; here the document lives for the whole session, so every visit to a config page
|
||||
* leaves another listener holding another detached table.
|
||||
*
|
||||
* Measured on the router over 120 navigations: window went from 1 resize listener to 31, and a
|
||||
* heap snapshot 280 navigations wide grew by 26 880 UniqueElementData, 23 600 Text nodes,
|
||||
* 18 520 EventListener and 1 160 <form> — a straight 11.8 KB per navigation that never
|
||||
* plateaus once the module cache is full.
|
||||
*
|
||||
* Setting the flag before upstream reaches it means the listener is never attached, and nothing
|
||||
* is lost: what it existed to do — re-measure the column when the width changes — is what the
|
||||
* wipe below now does, from the room rather than from a window event. The fitter runs
|
||||
* SYNCHRONOUSLY on the mutation batch that inserts the table (fs-fit rule 2), which is what
|
||||
* makes claiming it in time possible at all; a table we somehow reach late simply keeps
|
||||
* upstream's listener, i.e. today's behaviour. */
|
||||
t.__actionColResizeAttached = true;
|
||||
const key = Math.round(fit.roomFor(t));
|
||||
if (t._fsActRoom === key) continue;
|
||||
const seen = (t._fsActRoom !== undefined);
|
||||
t._fsActRoom = key;
|
||||
/* the first sighting is not a CHANGE: nothing has been pinned in another layout yet */
|
||||
if (!seen) continue;
|
||||
delete t.dataset.actionColWidth;
|
||||
t.querySelectorAll('.cbi-section-actions').forEach((el) => {
|
||||
el.style.removeProperty('width');
|
||||
el.style.removeProperty('min-width');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* Does this batch contain anything we could care about? Without it EVERY mutation scheduled a
|
||||
* full scan — and the poll rewrites content once a second, so on Overview/Processes/Leases we
|
||||
* ran three document-wide querySelectorAll plus a choicesKey() over every option of every
|
||||
* enhanced select (thousands of characters on the firewall page) every second, forever, to
|
||||
* discover that nothing had changed. */
|
||||
function relevant(mutations) {
|
||||
/* attributeFilter narrows the ATTRIBUTE, not the element: `value`/`disabled` live on inputs
|
||||
* and buttons too, and a poll rewriting an input's value would otherwise wake the whole
|
||||
* scan. This half is ours alone; the added-node walk below is fs-fit's shared one. */
|
||||
for (const m of mutations) {
|
||||
if (m.type === 'attributes' && m.target.tagName === 'SELECT')
|
||||
return true;
|
||||
/* …and a REBUILT OPTION LIST. `sel.replaceChildren(new Option(…))` puts <option> elements in
|
||||
* addedNodes, and the shared walk below asks whether an added node IS or CONTAINS a select —
|
||||
* an <option> is neither, so the batch was dropped and resync() never re-keyed the widget.
|
||||
* Measured on the firewall page: after replaceChildren the native select listed AAA/BBB
|
||||
* while the widget still offered reject/drop/accept, and picking from the stale list wrote a
|
||||
* value the new list does not contain, i.e. `''`. CBI dependency handling rebuilds option
|
||||
* lists constantly, which is the case resync() was written for. */
|
||||
if (m.type === 'childList' && m.target.tagName === 'SELECT')
|
||||
return true;
|
||||
}
|
||||
/* `.table`, not `table.table` — the same selector tagDataTables() and STACKABLE use.
|
||||
* Additions only: a select or a table going away costs us nothing to notice. */
|
||||
return fit.touches(mutations, 'select.cbi-input-select, .table');
|
||||
}
|
||||
|
||||
/* ---- TYPE-AHEAD: jump to an option by typing its first letters ---------------------
|
||||
*
|
||||
* A native <select> gives this for free, and it is the only way anyone picks a country out of
|
||||
* 248 entries. enhance() hides the native select, and ui.Dropdown.handleKeydown (luci-base) does
|
||||
* only Esc/Enter/Space/arrows — no letter search — so Wireless -> Country Code became 248 items
|
||||
* you could only scroll. (Stock LuCI never had it either; bootstrap only appears to, because it
|
||||
* leaves that field a real <select>.)
|
||||
*
|
||||
* One document-level listener (a dropdown's <ul> holds focus while open), for EVERY
|
||||
* .cbi-dropdown — ours and LuCI's own. Native semantics: only while OPEN; printable keys, no
|
||||
* modifiers; buffer resets after a pause; the SAME letter repeated cycles (how you reach the
|
||||
* second "Germany"); matches the LABEL first, then the value, so "RU" and "Russia" both find it.
|
||||
* SPACE is deliberately excluded: ui.Dropdown binds it to "toggle the focused item" and its
|
||||
* handler fires first, so treating it as a character would select something.
|
||||
*
|
||||
* Only HIGHLIGHTS (setFocus, as the arrows do); Enter/Esc stay ui.Dropdown's. */
|
||||
const TYPEAHEAD_RESET_MS = 1000;
|
||||
let _taBuf = '', _taTimer = null, _taLast = null;
|
||||
|
||||
function typeaheadItems(sb) {
|
||||
const ul = sb.querySelector('ul.dropdown') || sb.querySelector('ul');
|
||||
if (!ul) return [];
|
||||
return [...ul.children].filter((li) =>
|
||||
li.tagName === 'LI' &&
|
||||
/* the "custom value" row (options.create) is an input, not a choice */
|
||||
!li.querySelector('input:not([type="hidden"])') &&
|
||||
li.getClientRects().length > 0);
|
||||
}
|
||||
|
||||
function typeaheadLabel(li) {
|
||||
return (li.textContent || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function wireTypeahead() {
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
if (ev.ctrlKey || ev.altKey || ev.metaKey) return;
|
||||
if (!ev.key || ev.key.length !== 1 || ev.key === ' ') return;
|
||||
/* the create-item input is a text field — let the user type into it */
|
||||
if (ev.target && ev.target.matches && ev.target.matches('input, textarea')) return;
|
||||
|
||||
const sb = ev.target.closest?.('.cbi-dropdown[open]');
|
||||
if (!sb) return;
|
||||
|
||||
const items = typeaheadItems(sb);
|
||||
if (!items.length) return;
|
||||
|
||||
/* a new dropdown starts a new search, however fast the user got here */
|
||||
if (sb !== _taLast) { _taBuf = ''; _taLast = sb; }
|
||||
|
||||
const ch = ev.key.toLowerCase();
|
||||
/* repeating one letter cycles; anything else extends the search */
|
||||
const repeat = (_taBuf.length === 1 && _taBuf === ch);
|
||||
const needle = repeat ? ch : (_taBuf + ch);
|
||||
|
||||
const start = items.findIndex((li) => li.classList.contains('focus'));
|
||||
/* on a repeat, look AFTER the current item so the same letter walks forward; otherwise
|
||||
* the search restarts from the top, as a native select does */
|
||||
const from = repeat ? start + 1 : 0;
|
||||
|
||||
/* matches the LABEL first, then the value, so "RU" and "Russia" both find it */
|
||||
const matches = (n) => (li) => typeaheadLabel(li).startsWith(n) ||
|
||||
String(li.getAttribute('data-value') || '').toLowerCase().startsWith(n);
|
||||
const match = matches(needle);
|
||||
|
||||
/* wrap around: the second pass covers what the first skipped */
|
||||
let hit = items.slice(from).find(match) ?? items.find(match);
|
||||
if (!hit && !repeat) {
|
||||
/* the extended buffer matches nothing — treat this keystroke as a fresh search
|
||||
* instead of swallowing it, so a mistyped letter is recoverable */
|
||||
hit = items.find(matches(ch));
|
||||
if (hit) _taBuf = '';
|
||||
}
|
||||
if (!hit) return;
|
||||
|
||||
_taBuf = repeat ? ch : (_taBuf + ch);
|
||||
if (_taTimer) window.clearTimeout(_taTimer);
|
||||
_taTimer = window.setTimeout(() => { _taBuf = ''; _taLast = null; }, TYPEAHEAD_RESET_MS);
|
||||
|
||||
/* the widget's own highlighter: adds .focus, scrolls the item into view and focuses it,
|
||||
* so Enter (ui.Dropdown's handler) commits exactly what is highlighted */
|
||||
const inst = dom.findClassInstance(sb);
|
||||
if (inst && typeof inst.setFocus === 'function')
|
||||
inst.setFocus(sb, hit, true);
|
||||
else
|
||||
hit.focus();
|
||||
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
__init__() {
|
||||
wireTypeahead();
|
||||
|
||||
const scan = () => {
|
||||
document.querySelectorAll('select.cbi-input-select:not([data-fs-select])').forEach(enhance);
|
||||
document.querySelectorAll('select.cbi-input-select[data-fs-select="1"]').forEach(resync);
|
||||
};
|
||||
scan();
|
||||
|
||||
/* A table must be TAGGED .fs-dt before it can be fitted, and re-tagged whenever the poll
|
||||
* brings a fresh one back — so the two travel as one fitter, which fs-fit runs now, on
|
||||
* every content mutation (synchronously, pre-paint) and on every resize of #view. */
|
||||
fit.add(() => { tagDataTables(); fitTables(); unpinActionColumn(); resyncValues(); });
|
||||
|
||||
/* one scan per frame, however many mutations arrive (fit.frame — the theme's shared
|
||||
* coalescer) */
|
||||
const scanSoon = fit.frame(scan);
|
||||
new MutationObserver((mutations) => {
|
||||
if (relevant(mutations)) scanSoon();
|
||||
}).observe(document.body, {
|
||||
childList: true, subtree: true,
|
||||
/* `disabled` flips and attr-driven value writes never mutate childList;
|
||||
* watch them so resync()/enhance() notice (filtered — cheap) */
|
||||
attributes: true, attributeFilter: [ 'disabled', 'value', 'selected' ]
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,833 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
|
||||
/* ---- a view's injected CSS: never DELETE it; leave a poisoned document by a real load ----
|
||||
*
|
||||
* A view's <style> dies with the document on a full load; SPA nav never reloads, so it restyles
|
||||
* every page after. `luci-app-filemanager` injects `.cbi-button-apply, .cbi-button-reset,
|
||||
* .cbi-button-save:not(.custom-save-button) { display: none !important }` — unlayered + important,
|
||||
* outranking every cascade layer: one visit and Save/Reset are gone from every config page.
|
||||
*
|
||||
* But DELETING them on nav broke SSClash. A poller can be re-registered by re-rendering the view;
|
||||
* a stylesheet only returns if its injector runs AGAIN, and a library importing CSS at MODULE EVAL
|
||||
* never will (module cached for the life of the document). ACE's ace_editor.css (14 KB of
|
||||
* absolutely-positioned layers, gutter, line boxes) is imported once — after the sweep, navigating
|
||||
* back to its editor gave a black rectangle 2 007 346 px tall. Deletion was silently one-way.
|
||||
*
|
||||
* So: a sheet matching only its OWN app's widgets (`.ace_*`, `.cpu-status-view-mode-entry`) is
|
||||
* inert elsewhere — LEAVE it. One reaching into the widget universe the THEME styles
|
||||
* (`.cbi-button-save`, `pre`, `:root`) can repaint any page: that document is spent, so refuse to
|
||||
* hand it to another view and fall back to a REAL page load — speed traded, never correctness, and
|
||||
* the fresh document carries no view CSS, so SPA nav resumes right after. That refusal is the SPA
|
||||
* router's (fs-router.js) — this module only answers the question.
|
||||
*
|
||||
* `invasiveSheet()` is that test; its universe is read back from cascade.css itself (same-origin,
|
||||
* so `cssRules` is readable) rather than a hand-written list, so it tracks the theme. 0.3 ms per
|
||||
* nav. Exempt: `[data-fs-shell]` (the one <style> the server emits — marked, not guessed at) and
|
||||
* anything inside `#view` (dies with the content swap); LuCI core injects no <style> at runtime at
|
||||
* all (checked: luci.js, ui.js, cbi.js). If cascade.css cannot be read, EVERY view sheet counts as
|
||||
* invasive: fail to the slow path, never the broken one. */
|
||||
let _themeNames = null;
|
||||
|
||||
/* What counts as a NAME — a class or an id — in a selector. This is the vocabulary the whole zone
|
||||
* test is written in: themeNames() harvests the theme's names with it, pinnedToApp() looks for the
|
||||
* app's own name with it, and judgeSheet() asks whether a part names anything of ours with it. Three
|
||||
* copies of the pattern sat under a comment explaining that two copies of the JUDGEMENT would drift
|
||||
* into disagreeing — and a vocabulary that disagrees with itself is the same bug one level down: widen
|
||||
* it in the harvester alone and names enter `names` that the other two can never match, so a selector
|
||||
* that does reach the chrome reads as pinned and is left unfenced.
|
||||
*
|
||||
* Shared safely BECAUSE every use is String.match(): a /g regex is stateful under .test(), but
|
||||
* [Symbol.match] resets lastIndex first. Do not call .test() on this one. */
|
||||
const NAME_RE = /[.#][A-Za-z_][\w-]*/g;
|
||||
|
||||
/* ---- A QUOTED VALUE IS DATA, AND EVERY SCANNER BELOW USED TO READ IT AS SYNTAX ----
|
||||
*
|
||||
* `[title="a,b"]` is ONE selector part carrying a comma; `[href*="("]` is one attribute carrying an
|
||||
* unbalanced paren; `[data-x=".foo"]` names no class at all. Read literally, each of the three
|
||||
* scanners in this file gets a different wrong answer out of the same string:
|
||||
* - selectorParts() split `.app-row[title="a,b"]` into `.app-row[title="a` and `b"]`. The second
|
||||
* half carries no class or id, so pinnedToApp() called it UNPINNED and judgeSheet() called the
|
||||
* sheet invasive — documentPoisoned() then reported the document spent and the SPA fell back to a
|
||||
* full load on every navigation for the life of the page, which is the exact failure the <link>
|
||||
* caching bug above was written to end.
|
||||
* - fenceRules() rejoins the parts with ', ', so that same rule came back as `[title="a, b"]` —
|
||||
* the app's own selector silently rewritten to match a value it never asked for, by the setter
|
||||
* reporting success. Deleting a rule is what this file exists to prevent; changing one is worse,
|
||||
* because nothing looks wrong afterwards.
|
||||
* - stripPseudoArgs() counts parens, so `[href*="("]` drove `depth` to 1 and never back: the whole
|
||||
* remainder of the selector was eaten and a part pinned by the app's own id read as unpinned.
|
||||
*
|
||||
* One masker answers it for all three, so the vocabulary cannot disagree with itself the way the
|
||||
* comment above NAME_RE warns about. It replaces the CONTENT of every quoted string with spaces and
|
||||
* is length-preserving 1:1, which is what lets selectorParts() scan the mask and still slice the
|
||||
* ORIGINAL — the fence must write back the app's own bytes, not our reading of them. An escape and
|
||||
* the character it escapes are both content, so `\"` cannot close the string. */
|
||||
function maskStrings(text) {
|
||||
let out = '', q = null;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (q === null) {
|
||||
out += ch;
|
||||
if (ch === '"' || ch === '\'') q = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\') {
|
||||
out += ' ';
|
||||
if (i + 1 < text.length) { out += ' '; i++; }
|
||||
continue;
|
||||
}
|
||||
out += (ch === q) ? ch : ' ';
|
||||
if (ch === q) q = null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* a re-hosted <style>'s text is no longer what its app wrote — dedupeViewSheets keys on the
|
||||
* original, or the app's next identical copy stops looking like a duplicate (see there) */
|
||||
const origText = new WeakMap();
|
||||
|
||||
function themeNames() {
|
||||
if (_themeNames) return _themeNames;
|
||||
const names = new Set(); /* every class and id the theme styles */
|
||||
const props = new Set(); /* every custom property it declares or reads */
|
||||
const walk = (rules) => {
|
||||
for (const r of rules) {
|
||||
/* masked like every other read of a selector: a `.foo` inside one of OUR quoted values
|
||||
* would enter `names` as a name we style, and pinnedToApp() — which masks — could then
|
||||
* never match it. That is the harvester-only widening NAME_RE's comment describes, and
|
||||
* it ends with a foreign selector that does reach the chrome reading as pinned. */
|
||||
if (r.selectorText)
|
||||
(maskStrings(r.selectorText).match(NAME_RE) || []).forEach((n) => names.add(n));
|
||||
if (r.cssText)
|
||||
(r.cssText.match(/--[A-Za-z_][\w-]*/g) || []).forEach((p) => props.add(p));
|
||||
if (r.cssRules) walk(r.cssRules);
|
||||
}
|
||||
};
|
||||
for (const ss of document.styleSheets) {
|
||||
if (!ss.href || !(/\/cascade\.css/).test(ss.href)) continue;
|
||||
try { walk(ss.cssRules); } catch (e) { return null; }
|
||||
}
|
||||
_themeNames = names.size ? { names, props } : null;
|
||||
return _themeNames;
|
||||
}
|
||||
|
||||
/* Is this selector part held inside the app's OWN markup by a name the theme does not know?
|
||||
* `#cbi-podkop-section > .cbi-section-remove` is: podkop's section has to exist for it to match
|
||||
* anything, so it can never reach another page — or our chrome. A part made ENTIRELY of names the
|
||||
* theme knows (`*`, `.nav`, `#indicators`, `ul.nav > li > a`) has nothing pinning it anywhere.
|
||||
*
|
||||
* Functional pseudo-class arguments are stripped before looking for the pin, and that is the whole
|
||||
* difference between podkop and the file manager: `.cbi-button-save:not(.custom-save-button)` names
|
||||
* an app class too, but inside a NEGATION — it does not require the app's markup, it excludes it.
|
||||
*
|
||||
* Shared by invasiveSheet() (is this sheet dangerous?) and fenceRules() (which parts get fenced?) —
|
||||
* they must agree by construction: a part judged able to reach another page is exactly a part able
|
||||
* to reach the chrome. Two copies of this test would drift into disagreeing. */
|
||||
/* Split a selector list on its TOP-LEVEL commas. `String.split(',')` cannot: `:not(.a, .b)` is one
|
||||
* part carrying a comma, and splitting it there hands both halves to pinnedToApp() as garbage —
|
||||
* `.cbi-button-save:not(.custom-save-button` keeps a visible app name (the argument regex needs a
|
||||
* closing paren to fire), so the file manager's own motivating rule reads as pinned and is neither
|
||||
* judged nor fenced. Measured on the router: `documentPoisoned()` said clean. `:not(a, b)` is
|
||||
* ordinary modern CSS, not an exotic.
|
||||
*
|
||||
* Scans the MASK and slices the ORIGINAL (see maskStrings): a comma inside `[title="a,b"]` is a
|
||||
* character in a value, not a separator — while the parts handed back must be the app's own bytes,
|
||||
* because fenceRules() joins them straight back into selectorText. */
|
||||
function selectorParts(text) {
|
||||
const scan = maskStrings(text);
|
||||
const out = [];
|
||||
let depth = 0, start = 0;
|
||||
for (let i = 0; i < scan.length; i++) {
|
||||
const ch = scan[i];
|
||||
if (ch === '(') depth++;
|
||||
else if (ch === ')') depth--;
|
||||
else if (ch === ',' && depth === 0) { out.push(text.slice(start, i).trim()); start = i + 1; }
|
||||
}
|
||||
out.push(text.slice(start).trim());
|
||||
return out.filter(Boolean);
|
||||
}
|
||||
|
||||
/* Drop every functional pseudo-class ARGUMENT, nesting included. The old regex
|
||||
* (`/:[a-z-]+\([^)]*\)/g`) stops at the first `)`, so `:not(:is(.app))` left a stray `)` and, worse,
|
||||
* left `.app` looking like a pin.
|
||||
*
|
||||
* Works on the MASK, which does two things at once here: a paren inside `[href*="("]` no longer
|
||||
* drives `depth` into a hole it never comes back from, and the `.foo` in `[data-x=".foo"]` stops
|
||||
* looking like the app's own pin to pinnedToApp() — the only caller. Its output is read by NAME_RE
|
||||
* and never written back to the CSSOM, so masking the content away costs nothing. */
|
||||
function stripPseudoArgs(part) {
|
||||
const scan = maskStrings(part);
|
||||
let out = '', depth = 0;
|
||||
for (let i = 0; i < scan.length; i++) {
|
||||
const ch = scan[i];
|
||||
if (ch === '(' ) { depth++; if (depth === 1) { out += ' '; continue; } }
|
||||
if (ch === ')') { depth--; continue; }
|
||||
if (!depth) out += ch;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pinnedToApp(part, names) {
|
||||
return (stripPseudoArgs(part).match(NAME_RE) || []).some((n) => !names.has(n));
|
||||
}
|
||||
|
||||
/* A rule with a bare SELECTOR (`:root`, `pre`, `*`) still cannot touch us if none of its
|
||||
* DECLARATIONS can: a custom property this theme never reads is inert. That is the difference
|
||||
* between an app costing a full page load and not — `luci-app-temp-status` opens with
|
||||
* `:root { --app-temp-status-temp: #147aff; … }`, and both it and the file manager's hex editor
|
||||
* would otherwise read as "document spent" on the strength of the selector alone.
|
||||
*
|
||||
* Still invasive: any STANDARD property on a bare selector (the stock file manager writes
|
||||
* `:root { color-scheme: light dark }`, re-pointing every UA widget at the OS preference), and any
|
||||
* custom property the THEME reads — the point of the private `--fs-*` tier is that an app writing
|
||||
* `--accent`/`--radius` on `:root` cannot repaint us, and this must keep it so for names we read. */
|
||||
function inertDeclarations(rule, props) {
|
||||
const st = rule.style;
|
||||
if (!st || !st.length) return false; /* no declarations to judge -> judge by selector */
|
||||
for (let i = 0; i < st.length; i++) {
|
||||
const p = st.item(i);
|
||||
if (p.slice(0, 2) !== '--') return false; /* a real property: it paints something */
|
||||
if (props.has(p)) return false; /* a custom property the theme itself reads */
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ---- the verdict is a property of the sheet, taken BEFORE we rewrite it -------------------
|
||||
*
|
||||
* An invasive verdict is STICKY, and it has to be: the moment rehostIntoThemeLayer() fences a sheet,
|
||||
* what stands in the DOM is no longer the CSS its app wrote, and re-judging our own edit answers a
|
||||
* different question than the one asked.
|
||||
*
|
||||
* It used to answer the right one BY ACCIDENT. The fence named a class (`.fs-sidebar`), so a fenced
|
||||
* selector still carried a name the theme styles, still tripped the `themeHit` test, and
|
||||
* documentPoisoned() went on reporting the document spent. Nothing said that was load-bearing —
|
||||
* moving the fence onto an attribute leaves no class name in the text, every fenced document would
|
||||
* have read CLEAN, and the SPA would have carried openclash's `*{padding:0!important}` into the next
|
||||
* page with the chrome fenced and the content flattened. Take the verdict once, keep it.
|
||||
*
|
||||
* Only `true` is kept. A clean sheet can still GROW hostile rules — an app that builds its CSS with
|
||||
* insertRule() has an EMPTY sheet the first time we look — so a clean verdict stays provisional and
|
||||
* is re-taken on every ask. */
|
||||
const _invasive = new WeakSet();
|
||||
|
||||
function invasiveSheet(el, universe) {
|
||||
if (_invasive.has(el)) return true;
|
||||
const v = judgeSheet(el, universe);
|
||||
/* CACHE ONLY A VERDICT WE COULD ACTUALLY READ.
|
||||
*
|
||||
* A <link> has NO .sheet until its bytes land, and judgeSheet's "unreadable -> invasive" default
|
||||
* therefore fires for EVERY linked app stylesheet at the instant <head>'s observer first sees
|
||||
* it. Remembering that verdict is what turned a benign sheet into a permanently spent document:
|
||||
* measured on the router, `luci-app-mwan3` ships ONE rule —
|
||||
* `#mwan3-service-status > .alert-message { … }`, pinned to the app's own id, which this
|
||||
* module's own judgement calls clean — and its <link> on the Overview left documentPoisoned()
|
||||
* true for the life of the page, so every navigation FROM THE LANDING PAGE was a full load.
|
||||
* Proven by serving those same bytes twice: as a <style> the document stayed clean, as a <link>
|
||||
* it went poisoned. Nothing about the CSS decided it; only how it arrived.
|
||||
*
|
||||
* Safe to re-take, and the "never re-judge our own edit" rule above still holds, because the
|
||||
* two cases do not overlap: rehostIntoThemeLayer() edits a <style>'s text and fences its rules
|
||||
* — and a <style> in the document always HAS a sheet, so its verdict was cached when taken and
|
||||
* is never re-taken. A <link> it never edits at all (it disables the element and re-imports the
|
||||
* href into the theme layer), so a later read still sees the app's untouched CSS, which is the
|
||||
* right question. A 404 or cross-origin sheet keeps answering `true` on every ask exactly as
|
||||
* before — it simply is not remembered, which changes nothing.
|
||||
*
|
||||
* Conservative WHILE unreadable is preserved: `v` is still returned as taken, so a hostile
|
||||
* sheet is fenced and re-hosted on sight and the document reads spent until the bytes prove
|
||||
* otherwise. Only the memory is dropped. */
|
||||
let readable;
|
||||
try { readable = !!el.sheet; } catch (e) { readable = false; }
|
||||
if (v && readable) _invasive.add(el);
|
||||
return v;
|
||||
}
|
||||
|
||||
/* true when this sheet can repaint a page that is not its own. A sheet that is not readable —
|
||||
* still loading, 404, cross-origin — is invasive by default: unknown CSS takes the slow path,
|
||||
* never the broken one. */
|
||||
function judgeSheet(el, universe) {
|
||||
let sheet;
|
||||
try { sheet = el.sheet; } catch (e) { return true; }
|
||||
if (!sheet) return true;
|
||||
|
||||
const { names, props } = universe;
|
||||
let invasive = false;
|
||||
const walk = (rules) => {
|
||||
for (const r of rules) {
|
||||
if (invasive) return;
|
||||
if (r.selectorText) {
|
||||
/* ONE question, the SAME one fenceRules() asks: is this part held inside the app's
|
||||
* own markup by a name the theme does not know? `#cbi-podkop-section >
|
||||
* .cbi-section-remove` is — podkop's section has to exist for it to match anything,
|
||||
* so it can never reach another page or our chrome. A part with no such pin matches
|
||||
* the same widgets everywhere, and that is what invasive MEANS.
|
||||
*
|
||||
* This used to ask a second question first — "does it name anything the theme
|
||||
* styles?" — and skip the part when the answer was no. That is the hole: a pin is a
|
||||
* name the theme does NOT know, so "names nothing of ours" was read as "pinned" when
|
||||
* it often means the exact opposite. Measured on the router, verdict CLEAN and 95 of
|
||||
* 338 chrome elements flattened, by two selectors an app could write by accident:
|
||||
* *:not(#zzz) { padding: 0 !important } ← the `#` is inside a NEGATION
|
||||
* [class] { padding: 0 !important } ← no class/id name at all
|
||||
* Both are unpinned, both match the whole document. fenceRules() already keyed on
|
||||
* pinnedToApp() alone and would have fenced them — it never got the chance, because
|
||||
* the judge called the sheet clean and nothing was re-hosted. The comment above
|
||||
* pinnedToApp() claims the two agree "by construction"; now they do. */
|
||||
for (const p of selectorParts(r.selectorText)) {
|
||||
if (pinnedToApp(p, names)) continue;
|
||||
/* Unpinned, but it may still be unable to touch us: a rule whose every
|
||||
* declaration is a custom property this theme never reads is inert wherever it
|
||||
* lands (`:root { --app-temp-status-temp: … }`). */
|
||||
if (inertDeclarations(r, props)) continue;
|
||||
invasive = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (r.cssRules) walk(r.cssRules);
|
||||
/* an @import's rules are not r.cssRules — follow it, or the verdict is blind to every
|
||||
* rule behind it. Re-hosting a <link> produces exactly such a shim, and a sheet that
|
||||
* judged its own shim inert would report a document clean while still carrying the
|
||||
* poison into the next page. Unreadable (cross-origin) import: invasive, like any
|
||||
* sheet we cannot read. */
|
||||
if (r.styleSheet) {
|
||||
let imported;
|
||||
try { imported = r.styleSheet.cssRules; } catch (e) { invasive = true; return; }
|
||||
if (imported) walk(imported);
|
||||
}
|
||||
}
|
||||
};
|
||||
try { walk(sheet.cssRules); } catch (e) { return true; }
|
||||
return invasive;
|
||||
}
|
||||
|
||||
/* Both element kinds count; the <link> half is not hypothetical: `luci-app-banip` and
|
||||
* `luci-app-adblock` append `<link rel=stylesheet href=…/custom.css>` to <head> at MODULE EVAL,
|
||||
* and it styles `.cbi-input-text`/`.cbi-input-select` — stock widgets, every page, unlayered. A
|
||||
* <link> INSIDE the view tree (`luci-app-nlbwmon`) needs no handling: it dies with the swap. */
|
||||
const VIEW_SHEETS = 'style:not([data-fs-shell]), link[rel~="stylesheet"]:not([data-fs-shell])';
|
||||
|
||||
/* An invasive sheet we OWN is contained — scopeToCurrentPage() darkens it the moment the router
|
||||
* stamps the new page, so it cannot reach the next page and the document is not spent. One we could
|
||||
* not attribute (not re-hostable, so never owned: an @import at the top, a sheet built with
|
||||
* insertRule(), anything unreadable) still spends it, which is the pre-existing behaviour and the
|
||||
* conservative half.
|
||||
*
|
||||
* A SILENCED sheet is contained too, and missing that undid the whole of the above for the <link>
|
||||
* half. Re-hosting a <link> owns the @import SHIM and silences the ORIGINAL for good (see
|
||||
* rehostIntoThemeLayer) — but the original stays in the document and a disabled sheet still answers
|
||||
* `cssRules`, so it re-judged as invasive on every ask, was owned by nobody, and this returned true
|
||||
* for the life of the document. Which means the very apps this module was written for
|
||||
* (`luci-app-banip`, `luci-app-adblock`, `luci-app-openclash` — all three inject a <link>) turned
|
||||
* the SPA router off entirely: a full page load on every navigation, from the moment such a page was
|
||||
* opened until the tab was closed. The <style> half never showed it, because a <style> is re-hosted
|
||||
* IN PLACE and therefore owned.
|
||||
*
|
||||
* Sound for the same reason ownership is: `el.sheet.disabled = true` is what decides whether CSS
|
||||
* paints (silence() explains why the element flag alone is not enough), and nothing re-enables it —
|
||||
* scopeToCurrentPage() only ever touches sheets in `_owner`, and the original is deliberately not
|
||||
* one. A sheet that paints nothing cannot poison the next page. */
|
||||
function documentPoisoned() {
|
||||
const names = themeNames();
|
||||
return Array.prototype.some.call(
|
||||
document.querySelectorAll(VIEW_SHEETS),
|
||||
(el) => !el.closest('#view')
|
||||
&& (!names || (invasiveSheet(el, names) && !_owner.has(el) && !_silenced.has(el))));
|
||||
}
|
||||
|
||||
/* ---- an invasive sheet still has to render ITS page: re-host it into the theme LAYER ----
|
||||
*
|
||||
* documentPoisoned() saves every page AFTER this one. It cannot save this one — the sheet is
|
||||
* already applying. Every footstrap rule lives in a @layer, and an UNLAYERED normal declaration
|
||||
* beats a layered one at any specificity, so a third-party reset owns the chrome outright:
|
||||
* `luci-app-openclash` ships `* { margin: 0; padding: 0 }` (a log.htm reset, leaked document-wide
|
||||
* by a <link> its Lua template prints into .fs-content). Measured on the router with those two
|
||||
* rules and nothing else: menu text flush at x=0 with the icons clipped, submenu indent gone, tabs
|
||||
* collapsed to a bare text row. On stock luci-theme-bootstrap — no layers — the same `*` (0,0,0)
|
||||
* loses to any class selector on specificity and nobody ever noticed. That asymmetry is the whole
|
||||
* bug, and it is ours: the layers are what handed a 0,0,0 selector the win (issue #8).
|
||||
*
|
||||
* So put the sheet back on specificity footing: re-host it into the EXISTING `theme` layer. Only
|
||||
* same-layer arbitrates by specificity — which is exactly what makes bootstrap survive. Measured,
|
||||
* all three placements, on the real cascade:
|
||||
*
|
||||
* app unlayered (today) chrome BROKEN app's own design OK
|
||||
* app -> @layer theme chrome OK app's own design OK
|
||||
* app -> @layer before theme chrome OK app's own design BROKEN
|
||||
*
|
||||
* The tempting shape is the third — give the app its own layer under the theme. Do NOT: the theme
|
||||
* then beats the app at ANY specificity, including the rules it aims at its own page. OpenClash
|
||||
* restyles its tabs with `#tab-header ul.cbi-tabmenu li` (1,1,2) and footstrap styles
|
||||
* `ul.cbi-tabmenu li` (0,1,2) — demote the app and footstrap repaints the app author's own tabs.
|
||||
* In `theme` the app keeps them, and `*` still loses to the chrome's 0,3,1. No new layer is
|
||||
* declared; a re-opened `@layer theme` block appends to the one 00-header.css already names.
|
||||
*
|
||||
* What this deliberately does NOT fix: `base`. The app must outrank `theme` for its own page to
|
||||
* work, so it sits above `base`, and `*` still wipes base's widget padding (cssdiff: `input`
|
||||
* 4px->0, `.ifacebadge`, `strong` margins). That is not a regression — an unlayered `*` beats base
|
||||
* today too — it is the price of the same trade, and the only way out would re-break the app.
|
||||
*
|
||||
* NEVER delete the sheet instead (see dedupeViewSheets below for what that cost). Re-hosting moves
|
||||
* where a rule lands in the cascade; every rule still exists, so a library's "did I already import
|
||||
* this?" check still finds its sheet. A <link> is DISABLED rather than removed, so an app that
|
||||
* looks its own <link> back up by href still finds the element. */
|
||||
/* ---- the fence: the chrome is ours, so make a foreign rule unable to MATCH it ----
|
||||
*
|
||||
* Re-hosting into the theme layer settles a fight on specificity. It cannot settle one against
|
||||
* `!important`, because importance ranks ABOVE layers — measured: `* { padding: 0 !important }`
|
||||
* still owns the chrome after re-hosting, and so does `#indicators { display: none !important }`.
|
||||
* The only pure-CSS answer to a foreign flag is our own flag in an earlier layer (it wins — also
|
||||
* measured), but that means ~550 of them, and `color`/`background` among them would beat this
|
||||
* theme's OWN forced-colors block. Fixing the cascade by breaking high-contrast is not a fix.
|
||||
*
|
||||
* So do not out-rank the rule — put the chrome where it cannot be addressed. Appending
|
||||
* `:where(:not([data-fs-chrome], [data-fs-chrome] *))` to a foreign selector's SUBJECT leaves it
|
||||
* matching everything it used to except us. `!important` has nothing left to win.
|
||||
*
|
||||
* The chrome is NOT one element, and naming one is how this went wrong the first time: the fence
|
||||
* said `.fs-sidebar`, which is the menu in both layouts (the bar is the same markup) — but the skip
|
||||
* link is a sibling of .fs-shell and the Appearance popover hangs off <body>, so both stayed exposed
|
||||
* while every test said the chrome was defended. `data-fs-chrome` is the fix: an element DECLARES
|
||||
* that it is ours, where it is written (header.ut, fs-appearance.js), and the fence and the pin
|
||||
* follow without being told. A future chrome root cannot forget to edit a constant in this file,
|
||||
* because there is no constant naming it any more. `npm run chrome-fence` holds the three together.
|
||||
*
|
||||
* `:where()` is load-bearing and not cosmetic: it contributes ZERO specificity, so `*` stays 0,0,0
|
||||
* and `#indicators` stays 1,0,0 and the app's rules keep their exact weight against each other and
|
||||
* against the theme on its own page. A plain `:not(.fs-sidebar)` takes its argument's specificity
|
||||
* and would silently re-order the app's stylesheet against itself.
|
||||
*
|
||||
* Only UNPINNED parts are fenced. A part pinned by the app's own name cannot reach the chrome
|
||||
* anyway (proven by the same test invasiveSheet uses), so leaving it alone costs nothing and keeps
|
||||
* the surgery as small as the danger.
|
||||
*
|
||||
* Two silent traps, both measured on the real CSSOM, both of which cost the app its rule:
|
||||
* - A selector LIST must be fenced part by part. Appending to the whole `selectorText` fences only
|
||||
* the last part: `*, ul` came back as `*, ul:where(…)` — `*` unfenced, chrome still exposed.
|
||||
* - A pseudo-element must stay LAST. `a::after` + a tail append serialised to `a::after:where()`
|
||||
* — the argument silently EATEN, leaving an empty `:where()` that matches NOTHING, and the
|
||||
* setter reported success. The fence goes before the pseudo-element: `a:where(…)::after`. */
|
||||
const CHROME_FENCE = ':where(:not([data-fs-chrome],[data-fs-chrome] *))';
|
||||
|
||||
function fenceSelector(part) {
|
||||
/* the getter always normalises a pseudo-element to `::`, incl. legacy `:before` */
|
||||
const i = part.indexOf('::');
|
||||
return i < 0 ? part + CHROME_FENCE : part.slice(0, i) + CHROME_FENCE + part.slice(i);
|
||||
}
|
||||
|
||||
function fenceRules(rules, names) {
|
||||
for (const r of rules) {
|
||||
if (r.selectorText) {
|
||||
const parts = selectorParts(r.selectorText);
|
||||
if (parts.length && parts.some((p) => !pinnedToApp(p, names))) {
|
||||
/* The setter parses the whole selector and, on one it cannot parse, does NOTHING and
|
||||
* does not throw — so it is atomic: never a half-written selector, and a failure just
|
||||
* leaves the rule where it already was (unfenced, i.e. today's exposure). */
|
||||
try {
|
||||
r.selectorText = parts
|
||||
.map((p) => (pinnedToApp(p, names) ? p : fenceSelector(p))).join(', ');
|
||||
} catch (e) { /* left unfenced on purpose: the app keeps its rule */ }
|
||||
}
|
||||
}
|
||||
if (r.cssRules) fenceRules(r.cssRules, names);
|
||||
}
|
||||
}
|
||||
|
||||
/* An @import's rules live in a sheet that is fetched separately, so they are not there the moment
|
||||
* the shim is inserted — retry until they are, then fence. Bounded: a sheet that never becomes
|
||||
* readable (404, cross-origin) simply stays unfenced, which is where we already were. */
|
||||
function fenceImported(styleEl, names, tries) {
|
||||
/* no initialiser: every path below assigns (the try, or the catch's null), so `= null`
|
||||
* here was a dead store — eslint 10 puts no-useless-assignment in recommended and said so. */
|
||||
let rules;
|
||||
try {
|
||||
const first = styleEl.sheet && styleEl.sheet.cssRules[0];
|
||||
rules = first && first.styleSheet && first.styleSheet.cssRules;
|
||||
} catch (e) { rules = null; }
|
||||
if (rules) { fenceRules(rules, names); return; }
|
||||
if (tries > 0) requestAnimationFrame(() => fenceImported(styleEl, names, tries - 1));
|
||||
}
|
||||
|
||||
/* What a sheet IS, as text: the rules that are APPLYING, not the markup that may or may not have
|
||||
* produced them. Serialised only ever to COMPARE — never re-parsed, so the serialiser cannot cost
|
||||
* anyone a rule. */
|
||||
const serializeRules = (rules) => Array.prototype.map.call(rules, (r) => r.cssText).join('\n');
|
||||
|
||||
/* ---- a <style>'s textContent is NOT its sheet, and both the wrap and the dedupe assumed it was ----
|
||||
*
|
||||
* Wrapping means re-setting textContent, which RE-PARSES: whatever the parse does not reproduce is
|
||||
* deleted — silently, by the one fix in this file whose entire thesis is that deleting a view's CSS
|
||||
* is one-way (see the head of the file). Two shapes where the text does not describe the sheet, and
|
||||
* they are one question, not two:
|
||||
* - an app that builds its CSS with insertRule() — an empty <style> appended first, rules pushed in
|
||||
* after: the text is EMPTY while the rules apply, so the wrap writes `@layer theme {}` over a live
|
||||
* sheet and every rule in it is gone.
|
||||
* - a <style> carrying @import: it is invalid inside @layer and has to sit at the top of a sheet, so
|
||||
* the wrapped copy comes back without it.
|
||||
*
|
||||
* So ask the exact question once — does re-parsing this text give back the sheet that is applying? —
|
||||
* rather than enumerate the shapes that make it false; the enumeration is what missed insertRule().
|
||||
* The probe is a CONSTRUCTIBLE sheet: never adopted, so nothing paints, no <head> mutation, and our
|
||||
* own observer never sees it. It also drops @import (per spec), which is why that case needs no test
|
||||
* of its own — the serialisations differ and the answer is already no.
|
||||
*
|
||||
* No probe means no answer, and the honest answer to "may I re-parse this?" when we cannot check is
|
||||
* NO: the sheet keeps every rule and the fence still holds Zone 1 without it. */
|
||||
let _probe = null;
|
||||
function textIsSheet(el, live) {
|
||||
try {
|
||||
if (!_probe) _probe = new CSSStyleSheet();
|
||||
_probe.replaceSync(el.textContent);
|
||||
return serializeRules(_probe.cssRules) === serializeRules(live);
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
/* Sheets taken out of the cascade FOR GOOD — the re-hosted <link> originals. Kept because a
|
||||
* silenced sheet is still an ELEMENT in the document that still answers `cssRules`, so every later
|
||||
* ask re-judges it as invasive; documentPoisoned() explains what that cost. */
|
||||
const _silenced = new WeakSet();
|
||||
|
||||
/* Take a re-hosted <link> out of the cascade — and MEAN IT.
|
||||
*
|
||||
* `el.disabled = true` alone does not do it, and the failure is silent and total. The IDL attribute
|
||||
* forwards to the ELEMENT's own flag; the thing that decides whether the CSS paints is
|
||||
* `el.sheet.disabled`, and a <link> that is still LOADING has no `.sheet` at all. Every runtime
|
||||
* injection is in exactly that state when <head>'s observer hands it here — which is the case this
|
||||
* module exists for (`luci-app-banip` and `luci-app-adblock` append their <link> at module eval,
|
||||
* openclash prints one from its template). So the assignment landed on nothing, the sheet came up
|
||||
* ENABLED when the bytes arrived, and the app's ORIGINAL, UNFENCED CSS went on painting beside the
|
||||
* fenced @import shim.
|
||||
*
|
||||
* Measured on the router with `* { padding: 0 !important }` behind a runtime <link>: `el.disabled`
|
||||
* read back `true`, `el.sheet.disabled` was `false`, and 95 of the 338 chrome elements were
|
||||
* flattened — the sidebar's own padding went 0px 88px -> 0px — while the shim's fenced copy sat
|
||||
* there matching nothing. Setting `el.sheet.disabled = true` by hand restored all of it. The
|
||||
* documented "openclash: 47 damaged -> 0" holds only because THAT sheet is server-rendered and has
|
||||
* therefore already loaded by the time the immediate pass sees it.
|
||||
*/
|
||||
function silence(el) {
|
||||
_silenced.add(el);
|
||||
el.disabled = true;
|
||||
if (el.sheet) { el.sheet.disabled = true; return; }
|
||||
/* no sheet yet: re-assert once there is one. `once` — the element is marked fsLayered, so this
|
||||
* never re-arms, and a sheet that never loads has nothing to silence. */
|
||||
el.addEventListener('load', () => { if (el.sheet) el.sheet.disabled = true; }, { once: true });
|
||||
}
|
||||
|
||||
/* ---- PAGE OWNERSHIP: contain an invasive sheet instead of spending the document ----
|
||||
*
|
||||
* A foreign sheet is injected by ONE page and has no business painting any other. Before this, an
|
||||
* invasive sheet made the whole document spent (documentPoisoned) and the SPA fell back to a full
|
||||
* load on the way OUT — correct, but stock LuCI pays it: `view/status/cpu.js` and its four realtime
|
||||
* siblings each `document.head.append(E('style', …))` at MODULE EVAL, and that style carries
|
||||
* `svg text { fill: #eee; font-size: 9pt }` — real properties on a bare selector, so invasive by the
|
||||
* only definition that catches `[class] { padding: 0 !important }`. Measured on the router: 37 of 38
|
||||
* pages navigated in-place instead of 38, `status/realtime/load` 15 ms -> 157 ms, and the whole
|
||||
* realtime family 2-10x slower.
|
||||
*
|
||||
* Removing the sheet on the way out is NOT the fix, and it is the obvious one: the append is at
|
||||
* module top level, `L.require` caches the module, so a second visit re-runs nothing and the page
|
||||
* would render unstyled. Disabling is reversible, which is the whole difference.
|
||||
*
|
||||
* OWNER = body[data-page] when the sheet was re-hosted. The order that makes this sound is in
|
||||
* fs-router.js: it stamps data-page (line ~371) BEFORE require()ing the view class (~450), so at the
|
||||
* moment a view module evaluates and appends its <style>, the attribute already names ITS page. On a
|
||||
* full load the server stamped it. Either way "now" is the sheet's own page.
|
||||
*
|
||||
* Recorded on the element that PAINTS, never on the one that was permanently silenced: for a <link>
|
||||
* that is the @import shim, and re-enabling the original instead would undo silence() and put the
|
||||
* app's unfenced CSS back over the chrome — the measured 95-of-338 flattening.
|
||||
*
|
||||
* THE OWNER IS THE APP, NOT THE PAGE, and that is not a guess — per-page was written first and
|
||||
* swept: `luci-app-zapret2` has three pages that share ONE injected <style>
|
||||
* (`.label-status { … !important }`), so it was owned by whichever loaded first and arrived DARK on
|
||||
* the other two. Per-page ownership silently un-styles any app whose pages share an injector, which
|
||||
* is a whole class of app, not a corner. `admin/<group>/<app>` — the first three dispatch segments —
|
||||
* is the smallest key that keeps an app's own pages together while still blocking the leak this
|
||||
* exists to block: onto OTHER apps and onto stock pages. The sweep is
|
||||
* `tools/...`-less on purpose (it needs a live router); re-run it against a router with third-party
|
||||
* apps after touching this, and look for a sheet that a full load has and an SPA arrival does not.
|
||||
*
|
||||
* Segments, never the dash-joined `data-page`: a dispatch segment may itself contain a dash
|
||||
* (`admin/system/package-manager`), so splitting the attribute on '-' would cut inside a name. */
|
||||
const _owner = new WeakMap();
|
||||
const APP_DEPTH = 3;
|
||||
|
||||
/* The router hands this over on every navigation (it holds the resolved segments). Until it does —
|
||||
* the initial full load — ask the SERVER which page it dispatched to. */
|
||||
let _curKey = null;
|
||||
|
||||
function appKey(segs) {
|
||||
return (segs || []).slice(0, APP_DEPTH).join('/');
|
||||
}
|
||||
|
||||
/* ---- THE URL IS NOT THE PAGE, and a sheet keyed on the URL is a sheet that dies ----
|
||||
*
|
||||
* `L.env.dispatchpath` is the leaf the SERVER resolved this request to; the address bar holds what
|
||||
* was ASKED for, and LuCI's dispatcher walks a node down to its firstchild without rewriting it.
|
||||
* Two shapes of that, both ordinary:
|
||||
*
|
||||
* /cgi-bin/luci/admin/status -> admin/status/overview the Status menu's OWN link
|
||||
* /cgi-bin/luci/ -> admin/status/overview the landing page, on a router with no
|
||||
* luci-mod-dashboard
|
||||
*
|
||||
* The URL says `admin/status` and `''`; the router, one navigation later, hands
|
||||
* scopeToCurrentPage() the RESOLVED `admin/status/overview`. Those keys can never match, so the
|
||||
* first SPA navigation away from such a page disables the sheets that page owns — and
|
||||
* rehostIntoThemeLayer() has already silenced the app's original <link> for good, so nothing
|
||||
* paints them again for the life of the document. A full-load key that is wrong is worse than no
|
||||
* key at all: it is a sheet that works until you navigate.
|
||||
*
|
||||
* Measured on owrt2512 with luci-app-mwan3, whose status include injects
|
||||
* `#mwan3-service-status > .alert-message { display:inline-block; width:15rem; … }`: full load on
|
||||
* /admin/status renders the card as `inline-block 240px 96px`; System -> General and back leaves
|
||||
* it `block 966px` — every interface card on the Overview stacked full-width. Keyed on the
|
||||
* dispatch path: 240px before and after. */
|
||||
function currentKey() {
|
||||
if (_curKey !== null) return _curKey;
|
||||
const dp = L.env && L.env.dispatchpath;
|
||||
if (dp && dp.length) return appKey(dp);
|
||||
/* no env to read (a document that never got the bootstrap): the URL is all there is */
|
||||
const p = location.pathname.replace(/^.*\/cgi-bin\/luci\/?/, '').replace(/\/+$/, '');
|
||||
return appKey(p ? p.split('/') : []);
|
||||
}
|
||||
|
||||
/* Both halves, for the reason silence() documents: el.disabled is the ELEMENT's flag and
|
||||
* el.sheet.disabled is what decides whether the CSS paints, and a still-loading <link> has no
|
||||
* .sheet for the assignment to reach. */
|
||||
function setEnabled(el, on) {
|
||||
el.disabled = !on;
|
||||
if (el.sheet) el.sheet.disabled = !on;
|
||||
else if (!on) el.addEventListener('load', () => { if (el.sheet) el.sheet.disabled = true; }, { once: true });
|
||||
}
|
||||
|
||||
/* Called by the router right after it stamps data-page, with the RESOLVED segments. Only sheets we
|
||||
* OWN are touched: a clean sheet is harmless and an invasive one we could not attribute still
|
||||
* poisons the document, so it keeps the full-load path rather than being silently disabled on its
|
||||
* own page. */
|
||||
function scopeToCurrentPage(segs) {
|
||||
if (segs) _curKey = appKey(segs);
|
||||
const key = currentKey();
|
||||
document.querySelectorAll(VIEW_SHEETS).forEach((el) => {
|
||||
if (el.closest('#view') || !_owner.has(el)) return;
|
||||
setEnabled(el, _owner.get(el) === key);
|
||||
});
|
||||
}
|
||||
|
||||
function rehostIntoThemeLayer(el, universe) {
|
||||
if (el.dataset.fsLayered) return;
|
||||
|
||||
if (el.tagName === 'LINK') {
|
||||
/* A <link>'s rules cannot be moved into a layer in place — but an @import CAN name one.
|
||||
* The href is already absolute and same-origin, so the re-fetch is a cache hit. */
|
||||
const s = document.createElement('style');
|
||||
s.dataset.fsLayered = '1';
|
||||
s.textContent = '@import url("' + el.href.replace(/["\\]/g, '\\$&') + '") layer(theme);';
|
||||
el.dataset.fsLayered = '1';
|
||||
el.after(s); /* keep source order: ties inside the layer still resolve as they did */
|
||||
silence(el);
|
||||
_owner.set(s, currentKey()); /* the shim paints; the original is silenced for good */
|
||||
fenceImported(s, universe.names, 60); /* ~1s of frames; a cache hit lands on the first */
|
||||
return;
|
||||
}
|
||||
|
||||
let rules;
|
||||
try { rules = el.sheet && el.sheet.cssRules; } catch (e) { return; }
|
||||
/* No rules yet: nothing to re-host, nothing to fence — and, crucially, nothing to MARK. An app
|
||||
* that appends an empty <style> and fills it with insertRule() arrives here first; marking it
|
||||
* handled now would leave the sheet it is about to build unfenced for the life of the document. */
|
||||
if (!rules || !rules.length) return;
|
||||
|
||||
/* Handled — and never twice. fenceRules() is NOT idempotent: pinnedToApp() strips a functional
|
||||
* pseudo-class before looking for the app's own name, so an already-fenced selector reads as
|
||||
* unpinned all over again and a second pass appends a second fence. The mark is the only thing
|
||||
* that says the work is done, so it has to be set for every path below, wrapped or not. */
|
||||
el.dataset.fsLayered = '1';
|
||||
_owner.set(el, currentKey()); /* a <style> is re-hosted IN PLACE, so it paints itself */
|
||||
|
||||
/* Wrap only if the text still IS the sheet (see textIsSheet). When it is not, the sheet stays
|
||||
* unlayered — Zone 2 exactly where it already was, which is a trade — rather than lose rules,
|
||||
* and it is still FENCED below: the fence is pure CSSOM, needs no re-parse, and is the half that
|
||||
* answers `!important` anyway.
|
||||
*
|
||||
* Layer by TEXT, fence by CSSOM, in that order: re-setting textContent re-parses the sheet and
|
||||
* would throw away any selector we had already rewritten. A <style>'s url()s resolve against the
|
||||
* document either way, so re-parsing costs nothing here — which is exactly NOT true of a <link>
|
||||
* (measured: cssText serialises `url("img.png")` still relative, so inlining a linked sheet would
|
||||
* silently re-base every image and font in it. That is why a <link> keeps its @import). */
|
||||
if (textIsSheet(el, rules)) {
|
||||
origText.set(el, el.textContent); /* dedupeViewSheets keys on this — see there */
|
||||
el.textContent = '@layer theme {\n' + el.textContent + '\n}';
|
||||
}
|
||||
try { if (el.sheet) fenceRules(el.sheet.cssRules, universe.names); } catch (e) { /* unfenced, not broken */ }
|
||||
}
|
||||
|
||||
/* Re-hosting needs the theme's own selectors to tell an invasive sheet from an inert one. If
|
||||
* cascade.css cannot be read we cannot classify, so re-host NOTHING and leave the cascade exactly
|
||||
* as it is: documentPoisoned() already fails every sheet to the slow path in that case, and
|
||||
* silently demoting an app we could not judge is the one move with no way back for its author. */
|
||||
function rehostInvasiveSheets() {
|
||||
const universe = themeNames(); /* {names, props} — NOT a bare Set; the fence wants .names */
|
||||
if (!universe) return;
|
||||
document.querySelectorAll(VIEW_SHEETS).forEach((el) => {
|
||||
if (el.dataset.fsLayered) return;
|
||||
if (invasiveSheet(el, universe)) rehostIntoThemeLayer(el, universe);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- the one thing that IS safe to remove: a byte-identical second copy ----
|
||||
*
|
||||
* Not deleting view CSS costs where an app injects on EVERY render: `luci-app-podkop` calls
|
||||
* injectGlobalStyles() from render() (4 KB, no guard) and `luci-app-mosdns` re-appends three
|
||||
* CodeMirror <link>s, so every SPA re-visit adds a copy that never stops being parsed. Dropping an
|
||||
* EXACT duplicate cannot break anyone, for the reason the sweep failed: the rules do not go away —
|
||||
* the surviving copy is byte-identical, and a library's "have I already imported this?" check (what
|
||||
* ACE died on) still finds its sheet. Keep the FIRST copy: it is what any handle the app kept
|
||||
* points at.
|
||||
*
|
||||
* Key a <style> on what its APP wrote, not on what stands in the DOM: re-hosting rewrites the text
|
||||
* (@layer wrapper), so a wrapped first copy and podkop's next byte-identical injection would no
|
||||
* longer match — the duplicate detector would go quiet exactly where it earns its keep, and the
|
||||
* copies would pile up again. A <link> keys on href, which re-hosting leaves alone.
|
||||
*
|
||||
* Re-hosting must therefore run BEFORE this, never after: a fresh copy of a <link> is appended to
|
||||
* <head>, which is EARLIER in document order than a template's <link> down in .fs-content, so the
|
||||
* "keep the first" rule would keep the raw copy, drop the re-hosted one, and strand its @import
|
||||
* shim — the chrome breaking again while shims pile up once per render (measured; that is the leak
|
||||
* this function exists to prevent). Re-host first and both copies are equivalent by the time they
|
||||
* are compared, so whichever survives is already layered and the loser's shim is a byte-identical
|
||||
* duplicate this same pass collapses. */
|
||||
function sheetKey(el) {
|
||||
if (el.tagName === 'LINK') return 'LINK|' + el.href;
|
||||
const t = origText.get(el);
|
||||
if (t !== undefined) return 'STYLE|' + t;
|
||||
/* Not wrapped, so no original was kept — and this sheet's textContent may not BE its sheet (see
|
||||
* textIsSheet): every insertRule-built <style> has an empty one, so keying on the text gave them
|
||||
* all the same key and the second one was REMOVED as a "duplicate" of a sheet it shares nothing
|
||||
* with. That is the deletion this file exists to prevent, dressed as a dedupe. Key on what is
|
||||
* applying instead. */
|
||||
let rules;
|
||||
try { rules = el.sheet && el.sheet.cssRules; } catch (e) { return null; }
|
||||
/* A sheet with no rules is a duplicate of nothing — and it is very likely a <style> an app has
|
||||
* appended but not yet filled: removing it strands the handle it is about to insertRule through. */
|
||||
if (!rules || !rules.length) return null;
|
||||
return 'STYLE|' + serializeRules(rules);
|
||||
}
|
||||
|
||||
function dedupeViewSheets() {
|
||||
const seen = new Set();
|
||||
document.querySelectorAll(VIEW_SHEETS).forEach((el) => {
|
||||
if (el.closest('#view')) return;
|
||||
const key = sheetKey(el);
|
||||
if (key === null) return;
|
||||
if (seen.has(key)) el.remove();
|
||||
else seen.add(key);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- THE LAYER ORDER IS A DOCUMENT-WIDE FACT, AND A SHEET INSERTED FIRST CAN REWRITE IT ----
|
||||
*
|
||||
* `@layer tokens, base, theme, page;` in 00-header.css is what makes theme beat base. That
|
||||
* statement only holds while cascade.css is the FIRST sheet in the document to name a layer — the
|
||||
* order is fixed by first appearance, and an earlier sheet naming `theme` makes theme the FIRST
|
||||
* layer, i.e. the WEAKEST. Every later name is appended after it, so the whole cascade inverts:
|
||||
* `tokens, base, page` end up above `theme` and base's `* { padding: 0 }` wins over the chrome's
|
||||
* own rules. Measured on the router: `.fs-content` padding 24px/28px -> 0, and the top bar, the
|
||||
* tabs and every button flattened with it.
|
||||
*
|
||||
* Which is exactly what re-hosting an app's sheet into `@layer theme` can cause, because WHERE the
|
||||
* app put its <style> is the app's choice. Ace (shipped by `luci-app-ssclash`, and by any package
|
||||
* that embeds an editor) calls `dom.importCssString`, which inserts its <style> as the FIRST CHILD
|
||||
* of <head> — ahead of cascade.css. Wrapping that sheet, as the fence must, moved the first mention
|
||||
* of `theme` to the top of the document and took the theme layer down with it. It is lazy, too:
|
||||
* Ace adds more of those sheets on first hover, which is the "reloading fixes it until I touch
|
||||
* anything" in the report.
|
||||
*
|
||||
* The repair is one declaration, and it works because inserting a NEW sheet re-runs the ordering
|
||||
* (moving an existing one does NOT — measured, both ways): re-declare the canonical order from a
|
||||
* fresh <style> placed first in <head>. Cheap, idempotent, and it states the same order 00-header.css
|
||||
* does — one more copy of it, which is why the text is derived from nothing and simply repeated
|
||||
* here, in the one other place that can see the whole document. */
|
||||
const LAYER_ORDER = '@layer tokens, base, theme, page;';
|
||||
let _layerStmt = null;
|
||||
|
||||
function reassertLayerOrder() {
|
||||
const head = document.head;
|
||||
if (!head) return;
|
||||
/* The anchor is whichever of ours comes first: cascade.css, or the statement a previous pass
|
||||
* already put in front of it. Only a sheet ahead of THAT can have named a layer before we did —
|
||||
* on a page with no foreign sheet this is one querySelectorAll and out. */
|
||||
const own = [...document.querySelectorAll('link[rel~="stylesheet"]')]
|
||||
.find((l) => (/\/cascade\.css/).test(l.href || ''));
|
||||
if (!own) return;
|
||||
const anchor = _layerStmt && _layerStmt.isConnected ? _layerStmt : own;
|
||||
const ahead = [...document.querySelectorAll('style, link[rel~="stylesheet"]')]
|
||||
.some((el) => el !== anchor && el !== own &&
|
||||
(anchor.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING));
|
||||
if (!ahead) return;
|
||||
|
||||
/* A FRESH element every time: re-inserting the same node is a move, and a move does not re-run
|
||||
* the ordering (measured — the inverted document stayed inverted). Dropping the previous one
|
||||
* keeps this at one spare <style> per document however many sheets an app injects. */
|
||||
if (_layerStmt) _layerStmt.remove();
|
||||
_layerStmt = document.createElement('style');
|
||||
_layerStmt.textContent = LAYER_ORDER;
|
||||
head.insertBefore(_layerStmt, head.firstChild);
|
||||
}
|
||||
|
||||
/* Watch <head> rather than deduping on navigation: the copy arrives too late otherwise — podkop
|
||||
* injects from its render(), which resolves AFTER the router's require() callback, so a nav-time
|
||||
* sweep left the document permanently carrying one stale duplicate (bounded, never zero). The
|
||||
* observer collapses the copy in the microtask it appears in. It cannot loop: a removal produces a
|
||||
* mutation with no ADDED nodes, and the handler bails unless a stylesheet was added.
|
||||
*
|
||||
* The immediate pass is not the observer's job and cannot be: a legacy Lua page's <link> is in the
|
||||
* SERVER's HTML (openclash prints it into .fs-content), so it is parsed and applying long before
|
||||
* this module is even fetched — there is no mutation to observe. It is re-hosted on the first pass
|
||||
* instead, which costs a brief flash of unstyled chrome before the modules land; the page then
|
||||
* settles correct, where today it stays broken. Runtime injections land in <head> (podkop, banip,
|
||||
* adblock, the file manager), which is what the observer watches — deliberately not the whole
|
||||
* document, since LuCI's poll rewrites content every second and this would fire on every tick. */
|
||||
function watchViewSheets() {
|
||||
/* Dedupe the immediate pass too, in the observer's order (re-host strictly first — see there).
|
||||
* It used to re-host only, which left the server-rendered duplicate — the one case this pass
|
||||
* exists for — uncollapsed for the life of the document. Measured with the real
|
||||
* luci-app-openclash: it prints the same <link href=oc.css> from three templates, so its
|
||||
* Overwrite Settings page carried two identical links and the two @import shims we make for
|
||||
* them, parsing 117 KB of CSS twice. The observer never fires for either: both are in the
|
||||
* SERVER's HTML, so there is no mutation to see. */
|
||||
rehostInvasiveSheets();
|
||||
dedupeViewSheets();
|
||||
reassertLayerOrder(); /* strictly AFTER the re-host: it is the wrap that can invert the order */
|
||||
const mo = new MutationObserver((muts) => {
|
||||
for (const m of muts)
|
||||
for (const n of m.addedNodes)
|
||||
if (n.nodeName === 'STYLE' || n.nodeName === 'LINK') {
|
||||
/* `continue`, not `return`: our own statement can share a batch with the very
|
||||
* sheet that made it necessary, and bailing on the batch would skip that one. */
|
||||
if (n === _layerStmt) continue;
|
||||
rehostInvasiveSheets(); /* strictly before the dedupe — see there */
|
||||
dedupeViewSheets();
|
||||
reassertLayerOrder();
|
||||
return;
|
||||
}
|
||||
});
|
||||
mo.observe(document.head, { childList: true });
|
||||
/* …and <body>, because `document.head.appendChild` is a CONVENTION, not a rule. An app that
|
||||
* appends its <style> to <body> (or to documentElement) after chrome init was seen by nothing:
|
||||
* the immediate pass had already run and the mutation was not under observation. Measured with
|
||||
* `* { padding: 0 !important }` in a body-appended <style>: 95 of 338 chrome elements flattened
|
||||
* and the sheet never marked. documentPoisoned() still saw it, so the SPA fell back to full
|
||||
* loads — the page you are ON stayed broken, which is the half that matters.
|
||||
*
|
||||
* childList WITHOUT subtree, exactly as for <head>: this fires only for DIRECT children of
|
||||
* <body>, and LuCI's poll rewrites content inside #view — a descendant — so the per-tick cost
|
||||
* the head-only choice was protecting stays zero. */
|
||||
mo.observe(document.body, { childList: true });
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
documentPoisoned,
|
||||
scopeToCurrentPage,
|
||||
watchViewSheets
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
|
||||
/* ---- theme identity: the version footstrap SHIPS, and the repo it came from ----
|
||||
*
|
||||
* Shown at the foot of the Appearance tab with NO network call: it is what is installed, not what
|
||||
* is available. Which version is available is the package manager's question — the installer adds
|
||||
* the feed, and `apk upgrade` answers it.
|
||||
*
|
||||
* FS_VERSION is stamped at build/deploy: the theme Makefile (Build/Prepare) and dev-sync.sh rewrite
|
||||
* the '0.0.0-dev' literal below — BY FILE NAME, so this constant cannot move to another file without
|
||||
* changing both seds. An unstamped source checkout stays 'dev'. */
|
||||
const FS_VERSION = '0.0.0-dev';
|
||||
|
||||
/* The parentheses around the regex are load-bearing — do not "tidy" them away. luci.mk minifies
|
||||
* this file with jsmin, whose regex-vs-division test is a ONE-character lookback against a fixed
|
||||
* allow-list. `n` (the last letter of `return`) is not on it, so `return /re/` is read as a
|
||||
* division and the regex's `//` swallows the rest of the file — exiting 0 (openwrt/luci#8299).
|
||||
*
|
||||
* The shape of the version is the WHOLE test — do NOT re-add a `FS_VERSION !== '0.0.0-dev'`
|
||||
* comparison. CI runs terser over this file BEFORE the Makefile stamps the version, so at that
|
||||
* moment both sides of that comparison are the same literal: terser folded it to `&& !1` and
|
||||
* every released build reported itself as '(dev)' forever after. An SDK/buildbot build has no
|
||||
* terser step, so it worked locally and only ever broke in a release. A regex test is not constant-folded —
|
||||
* proven by the same minified output, which kept this very `.test()` call — so the sentinel is
|
||||
* excluded by its SHAPE instead. `-dev$` and not `^\d+\.\d+\.\d+$`, because dev-sync.sh stamps
|
||||
* `git describe` ('0.9.4-12-gabc1234') and that must keep counting as a real version. */
|
||||
function isReal() { return ((/^\d+\.\d+/).test(FS_VERSION)) && !((/-dev$/).test(FS_VERSION)); }
|
||||
|
||||
return baseclass.extend({
|
||||
VERSION: FS_VERSION,
|
||||
/* the project's page, for the link at the foot of the Appearance tab. One string, used once —
|
||||
* it was assembled from a separate FS_REPO constant that the retired updater needed to build
|
||||
* API paths from, and nothing has needed the bare `owner/name` since. */
|
||||
REPO_URL: 'https://github.com/VizzleTF/luci-theme-footstrap',
|
||||
isReal,
|
||||
/* what the popover's version row prints */
|
||||
label: () => (isReal() ? ('Footstrap v' + FS_VERSION) : 'Footstrap (dev)')
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require fs-prefs as prefs';
|
||||
|
||||
/* The theme's own UI primitives: the disclosure pair the menu is built on, the two controls the
|
||||
* Appearance popover is built on, and the popup placement both hand-placed popups share. Nothing
|
||||
* here knows what it is being used FOR — that is the point, and it is why the menu and the popover
|
||||
* can each take what they need without either requiring the other. */
|
||||
|
||||
/* The chrome's ONE inline-SVG wrapper. Every icon this theme draws is the same 24x24 stroked
|
||||
* outline and differs only in its path data, but the wrapper was written out per call site — in the
|
||||
* menu, in the search box, and again in four .ut partials — so `stroke-width` and the two linecap
|
||||
* attributes were free to drift between icons that are meant to look like one set. Body in, markup
|
||||
* out; the caller supplies only the shape.
|
||||
*
|
||||
* aria-hidden: every icon here sits beside its own label (or inside a control that has one), and an
|
||||
* unlabelled <svg> is otherwise announced as a graphic in its own right. */
|
||||
function svgIcon(body, cls) {
|
||||
return '<svg class="' + (cls || 'fs-ico') + '" aria-hidden="true" viewBox="0 0 24 24" fill="none" '
|
||||
+ 'stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">'
|
||||
+ body + '</svg>';
|
||||
}
|
||||
|
||||
/* ---- disclosure primitives, shared by the menu ----
|
||||
* A section header is a W3C-APG disclosure control: an <a role="button"> owning a panel it shows and
|
||||
* hides. These lived once per menu file back when there were two, and the copies had already drifted
|
||||
* (only one Escape handler learnt to check flyout mode). The trigger SELECTOR stays a parameter. */
|
||||
|
||||
/* `.open` and aria-expanded must never disagree — `.open` alone told a sighted user everything and
|
||||
* a screen-reader user nothing — so every open and close goes through this one function.
|
||||
* `linkSel` is the layout's trigger (the menu's `:scope > a`). */
|
||||
function setOpen(li, on, linkSel) {
|
||||
li.classList.toggle('open', on);
|
||||
li.querySelector(linkSel)?.setAttribute('aria-expanded', on ? 'true' : 'false');
|
||||
}
|
||||
|
||||
/* An <a role="button"> is given Enter by the browser but NOT Space, and a
|
||||
* disclosure control has to answer both. */
|
||||
function wireSpaceKey(link) {
|
||||
link.addEventListener('keydown', (ev) => {
|
||||
if (ev.key !== ' ' && ev.key !== 'Spacebar') return;
|
||||
ev.preventDefault();
|
||||
link.click();
|
||||
});
|
||||
}
|
||||
|
||||
/* Dismissal both ways: a click outside closes; and WCAG 2.2 SC 1.4.13 (Content on Hover or Focus)
|
||||
* requires a hover/focus panel to be dismissible from the KEYBOARD, with focus handed back to the
|
||||
* trigger. `when` restricts both to flyout mode, where `.open` means "popup panel" — closing an
|
||||
* unfolded ACCORDION because the user clicked elsewhere on the page would be wrong. */
|
||||
function wireDismiss(opts) {
|
||||
const active = () => (opts.when ? opts.when() : true);
|
||||
|
||||
document.addEventListener('click', (ev) => {
|
||||
/* `closest?.` — a document-level listener sees whatever anyone dispatches, and a click
|
||||
* whose target is not an Element (document itself, a text node from a synthetic dispatch)
|
||||
* has no closest(). The throw would come out of THIS listener, i.e. the menu would stop
|
||||
* closing its flyouts for the rest of the session. Every other document-level handler in
|
||||
* the theme already guards it (fs-router's link router, fs-search's shortcuts,
|
||||
* fs-select's typeahead); this one was the odd one out. */
|
||||
if (active() && !ev.target.closest?.(opts.inside))
|
||||
opts.close();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
if (ev.key !== 'Escape' || !active()) return;
|
||||
const open = document.querySelector(opts.open);
|
||||
if (!open) return;
|
||||
const trigger = open.querySelector(opts.trigger);
|
||||
opts.close();
|
||||
trigger?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
/* THE SEGMENTED CONTROL AND THE RANGE WRAPPER WERE HERE, and they are not coming back: the
|
||||
* Appearance page draws its enums with `ui.Select` and its numbers with `ui.RangeSlider` — LuCI's
|
||||
* own widgets, present on every release this theme supports and already dressed by this theme's
|
||||
* stylesheet (`select` in base/30-forms.css, `.cbi-range-slider` in theme/60-inputs.css).
|
||||
*
|
||||
* They were written for the floating popover this page used to be, where a native <select> read as
|
||||
* a hole in the card. On a page there is no such argument, and a control LuCI maintains is one this
|
||||
* theme cannot get wrong on its own — the roving tabindex here was itself a bug fix for a
|
||||
* radiogroup that announced one control and offered N tab stops. */
|
||||
|
||||
/* ---- colour: reading what the page is ACTUALLY painted, and what that costs in contrast --------
|
||||
*
|
||||
* The Appearance page has to answer two questions no stored value can: what colour is a role right
|
||||
* now (the palette's own, when the axis is off — and there is no copy of the palette in JS, on
|
||||
* purpose), and what contrast does the user's colour land at. Both are questions about the computed
|
||||
* cascade, so both are asked of the browser.
|
||||
*
|
||||
* `getComputedStyle(root).getPropertyValue('--fs-accent')` cannot answer either: a custom property's
|
||||
* computed value is the token stream after var() substitution, so a palette hex comes back as a hex
|
||||
* but `oklch(from … l c H)` comes back as that function, unevaluated. Setting the expression as a
|
||||
* real `color` on a real element and reading it back gets the browser to resolve it — which is the
|
||||
* whole point, since relative colour, color-mix() and the tint's calc() are exactly the values the
|
||||
* theme is made of. One hidden probe, reused: an element per query would be a layout thrash on
|
||||
* every drag of the hue slider. */
|
||||
let _probe = null;
|
||||
function probeColor(expr) {
|
||||
if (!_probe) {
|
||||
/* off-screen rather than display:none — a display:none element still computes `color`, but
|
||||
* keeping it laid out avoids depending on that being true of every engine. It carries no
|
||||
* text and no size, so it paints nothing. */
|
||||
_probe = E('span', { 'style': 'position:fixed;left:-9999px;top:0;width:0;height:0;overflow:hidden;pointer-events:none', 'aria-hidden': 'true' });
|
||||
document.body.appendChild(_probe);
|
||||
}
|
||||
/* cleared first: an expression the engine rejects leaves the PREVIOUS colour standing, which
|
||||
* would report a stale answer as a fresh one. */
|
||||
_probe.style.color = '';
|
||||
_probe.style.color = expr;
|
||||
return getComputedStyle(_probe).color;
|
||||
}
|
||||
|
||||
/* A computed colour string -> [r,g,b] 0..255, or null. Engines do not agree on the notation: both
|
||||
* `rgb(9, 105, 218)` and the space-separated `rgb(9 105 218 / .5)` are current, and a colour outside
|
||||
* sRGB (which oklch() readily produces) comes back from some engines as `color(srgb .03 .41 .85)`,
|
||||
* i.e. 0..1 floats. Take the first three numbers and scale by whether the form is the 0..1 one —
|
||||
* the alpha after the slash is deliberately ignored, since every token this reads is opaque. */
|
||||
function parseColor(s) {
|
||||
const nums = String(s || '').match(/[\d.]+/g);
|
||||
if (!nums || nums.length < 3) return null;
|
||||
const unit = (/^color\(/i).test(String(s)) ? 255 : 1;
|
||||
return nums.slice(0, 3).map((n) => Math.max(0, Math.min(255, parseFloat(n) * unit)));
|
||||
}
|
||||
|
||||
/* WCAG 2.x relative luminance and contrast ratio, on sRGB. Used only to REPORT: the theme never
|
||||
* corrects a colour behind the user's back, it says what the colour costs and leaves the choice
|
||||
* with them (03-palettes.css derives the ink over a fill, which is a different question — that one
|
||||
* has a right answer). */
|
||||
function luminance(rgb) {
|
||||
const c = rgb.map((v) => {
|
||||
const x = v / 255;
|
||||
return (x <= .03928) ? (x / 12.92) : Math.pow((x + .055) / 1.055, 2.4);
|
||||
});
|
||||
return (.2126 * c[0]) + (.7152 * c[1]) + (.0722 * c[2]);
|
||||
}
|
||||
function contrastRatio(fgExpr, bgExpr) {
|
||||
const fg = parseColor(probeColor(fgExpr)), bg = parseColor(probeColor(bgExpr));
|
||||
if (!fg || !bg) return null;
|
||||
const a = luminance(fg), b = luminance(bg);
|
||||
return (Math.max(a, b) + .05) / (Math.min(a, b) + .05);
|
||||
}
|
||||
|
||||
/* #rrggbb from whatever probeColor returned, because <input type="color"> accepts nothing else —
|
||||
* not a name, not an rgb() string, not a short hex. An unparseable colour becomes black rather than
|
||||
* throwing: the swatch is a convenience beside the text field, and the text field is authoritative. */
|
||||
function toHex(s) {
|
||||
const rgb = parseColor(s) || [ 0, 0, 0 ];
|
||||
return '#' + rgb.map((v) => Math.round(v).toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/* One COLOUR axis: a native swatch, the hex field beside it, and a button back to the palette's own
|
||||
* colour. It reports through onPick as a hex STRING, or 0 for "back to the palette" — the caller
|
||||
* hands either straight to fs-prefs.js's colorAxis.
|
||||
*
|
||||
* THERE IS NO HUE SLIDER, and it was here. The axis still stores a hue (1–360) and the stylesheet
|
||||
* still rotates the palette by one — a value saved before this, or a router default written then,
|
||||
* goes on working — but nothing in the UI produces one any more. Two controls for one value read as
|
||||
* two settings however carefully they mirrored each other, and the slider was the half that could
|
||||
* not do the job the axes exist for: rotating a hue keeps the palette's chroma, so no angle of it
|
||||
* reaches a grey. A field that takes the colour you want is the whole control.
|
||||
*
|
||||
* `opts.probe` is the live token to read the effective colour back from (so the field shows the
|
||||
* PALETTE's colour while the axis is off, with no copy of the palette in JS), and `opts.contrast`
|
||||
* is the pair whose ratio is reported under the row. */
|
||||
function colorControl(current, onPick, label, opts) {
|
||||
const o = opts || {};
|
||||
|
||||
/* type=color is the one control here that is NOT ours: the browser draws the picker, which is
|
||||
* the right call — it is the picker the user already knows, it is keyboard- and
|
||||
* screen-reader-accessible without us reimplementing a colour wheel, and on a phone it is the
|
||||
* native one. The text field beside it is what makes a value shareable (an admin pastes a hex
|
||||
* from a brand guide), and what a browser without a real picker falls back to. */
|
||||
const swatch = E('input', { 'type': 'color', 'class': 'fs-color-swatch', 'aria-label': label || '' });
|
||||
const field = E('input', {
|
||||
'type': 'text', 'class': 'fs-color-hex', 'spellcheck': 'false', 'autocomplete': 'off',
|
||||
'inputmode': 'text', 'maxlength': '7', 'aria-label': label || ''
|
||||
});
|
||||
const clear = E('button', { 'class': 'btn fs-color-clear', 'type': 'button' }, [ _('Palette', 'footstrap') ]);
|
||||
const ratio = o.contrast ? E('div', { 'class': 'cbi-value-description fs-color-contrast' }) : null;
|
||||
|
||||
/* what the axis holds RIGHT NOW — the control keeps no copy of its own, because the page it
|
||||
* lives on can change the axis behind it (a preset, Reset to default) and a private copy would
|
||||
* then be the stale one. `current` is only the value at build time. */
|
||||
const currentOf = o.read || (() => current);
|
||||
|
||||
/* Repaint everything that MIRRORS the axis rather than sets it. Called after every edit — and
|
||||
* after a preset, a palette switch or a dark-mode flip, through the returned refresh() — because
|
||||
* all three change what "the palette's own colour" is while this axis stays off. */
|
||||
function reflect(v) {
|
||||
const live = probeColor(o.probe);
|
||||
const hex = (typeof v === 'string') ? v : toHex(live);
|
||||
swatch.value = hex;
|
||||
/* Do not fight the user mid-edit: `#0` is a legal thing to have typed so far, and
|
||||
* overwriting the field on every keystroke made the input impossible to type into. */
|
||||
if (document.activeElement !== field) field.value = hex;
|
||||
/* the button back to the palette is also the axis's STATE readout: enabled means this axis
|
||||
* is holding a colour of its own, disabled means what the field shows is the palette's. */
|
||||
clear.disabled = !v;
|
||||
if (!ratio) return;
|
||||
const r = contrastRatio(o.contrast.fg, o.contrast.bg);
|
||||
if (r === null) { ratio.textContent = ''; ratio.removeAttribute('title'); return; }
|
||||
/* SAY WHAT IT MEANS, NOT WHAT IT MEASURES. This used to read "On a card 5.4:1 · AA", which is
|
||||
* three pieces of jargon for one plain fact: whether the colour you just picked can be read.
|
||||
* The ratio and the WCAG level are what a designer checks, and an admin recolouring a router
|
||||
* is not one — the number stays, in the title, where it costs nothing and is there for
|
||||
* anyone who does want it.
|
||||
*
|
||||
* The thresholds are still WCAG AA: 4.5:1 for body text, 3:1 for large text and for a UI
|
||||
* SHAPE, which is why a hairline is graded on the second one (`kind: 'shape'`). A border
|
||||
* below 3:1 is not a failure the way unreadable text is — a faint hairline is a legitimate
|
||||
* thing to want — so it says "faint" and warns rather than "not enough" and alarms.
|
||||
*
|
||||
* The class names are written out WHOLE rather than built from a suffix: the theme's own
|
||||
* namespace is swept for dead CSS by matching fs-* tokens in the source (tools/
|
||||
* fs-orphans.mjs), and a name assembled by concatenation is invisible to that sweep — the
|
||||
* three rules would read as dead CSS and the fragment as an unstyled class. */
|
||||
const where = o.contrast.label;
|
||||
const grade = (o.contrast.kind === 'shape')
|
||||
? ((r >= 3)
|
||||
? { cls: 'fs-contrast-aa', text: _('Clearly visible %s', 'footstrap').format(where) }
|
||||
: { cls: 'fs-contrast-aa-large', text: _('Barely visible %s', 'footstrap').format(where) })
|
||||
: (r >= 4.5)
|
||||
? { cls: 'fs-contrast-aa', text: _('Easy to read %s', 'footstrap').format(where) }
|
||||
: (r >= 3)
|
||||
? { cls: 'fs-contrast-aa-large', text: _('Hard to read %s — large text only', 'footstrap').format(where) }
|
||||
: { cls: 'fs-contrast-low', text: _('Too faint to read %s', 'footstrap').format(where) };
|
||||
ratio.className = 'fs-color-contrast ' + grade.cls;
|
||||
ratio.textContent = grade.text;
|
||||
/* the measurement itself, for whoever wants it: the ratio and the threshold it was judged by */
|
||||
ratio.title = _('Contrast %s:1 (WCAG AA wants %s:1 here)', 'footstrap')
|
||||
.format(r.toFixed(1), (o.contrast.kind === 'shape') ? '3' : '4.5');
|
||||
}
|
||||
|
||||
const pick = (v) => { onPick(v); reflect(v); };
|
||||
|
||||
swatch.addEventListener('input', () => pick(swatch.value.toLowerCase()));
|
||||
/* commit on blur and on Enter, NOT on every keystroke: a half-typed `#0096` is a valid prefix of
|
||||
* two different colours and applying it would repaint the page under the cursor. An unparseable
|
||||
* value on blur snaps back to what the axis actually holds, so the field can never claim a
|
||||
* colour the page is not painted in. */
|
||||
const commit = () => {
|
||||
const v = field.value.trim().toLowerCase();
|
||||
if ((/^#[0-9a-f]{6}$/).test(v)) pick(v);
|
||||
else reflect(currentOf());
|
||||
};
|
||||
field.addEventListener('blur', commit);
|
||||
field.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); commit(); } });
|
||||
clear.addEventListener('click', () => pick(0));
|
||||
|
||||
const wrap = E('div', { 'class': 'fs-colorctl' + (o.cls ? ' ' + o.cls : '') }, [
|
||||
E('div', { 'class': 'fs-color-row' }, [ swatch, field, clear ])
|
||||
].concat(ratio ? [ ratio ] : []));
|
||||
/* the caller owns when this runs again; it cannot be done here, because probeColor() needs the
|
||||
* document and this control is not in it yet */
|
||||
wrap.fsRefresh = () => reflect(currentOf());
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/* How close a popup may come to the viewport edge before it is nudged back in. Read by BOTH popups
|
||||
* the theme places by hand — the Appearance popover (fs-appearance.js) and the menu's dropdown
|
||||
* edge-clamp (menu-footstrap.js) — which had each written their own `8`. */
|
||||
const EDGE_GAP = 8;
|
||||
|
||||
/* Place the popover next to its trigger and keep it inside the viewport. It is position:fixed on
|
||||
* <body> because the sidebar is `overflow-y: auto` (which computes overflow-x to `auto` too), so
|
||||
* an absolutely-positioned popover parented to the Appearance row was clipped off the sidebar
|
||||
* edge. The top bar opens downward from the button's right edge, the sidebar sideways out of the
|
||||
* rail; both are then clamped. */
|
||||
function placePopover(btn, pop) {
|
||||
const gap = EDGE_GAP, r = btn.getBoundingClientRect();
|
||||
const w = pop.offsetWidth, h = pop.offsetHeight;
|
||||
const vw = document.documentElement.clientWidth;
|
||||
const vh = document.documentElement.clientHeight;
|
||||
const top_layout = prefs.isTopLayout();
|
||||
|
||||
let left = top_layout ? (r.right - w) : (r.right + gap);
|
||||
let top = top_layout ? (r.bottom + gap) : (r.bottom - h);
|
||||
|
||||
/* sidebar: if there is no room to the right, fall back above the trigger */
|
||||
if (!top_layout && left + w > vw - gap) {
|
||||
left = r.left;
|
||||
top = r.top - h - gap;
|
||||
}
|
||||
|
||||
pop.style.left = Math.max(gap, Math.min(left, vw - w - gap)) + 'px';
|
||||
pop.style.top = Math.max(gap, Math.min(top, vh - h - gap)) + 'px';
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
svgIcon,
|
||||
setOpen,
|
||||
wireSpaceKey,
|
||||
wireDismiss,
|
||||
colorControl,
|
||||
probeColor,
|
||||
toHex,
|
||||
EDGE_GAP,
|
||||
placePopover
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require ui';
|
||||
'require fs-fit as fit';
|
||||
'require fs-menutree as tree';
|
||||
'require fs-chrome as chrome';
|
||||
'require fs-router as router';
|
||||
'require fs-appearance as appearance';
|
||||
'require fs-overview as overview';
|
||||
'require fs-prefs as prefs';
|
||||
'require fs-sheets as sheets';
|
||||
'require fs-search as search';
|
||||
|
||||
/* The chrome BOOTSTRAP: load the menu tree once, hand it to the parts that need it, and wire them
|
||||
* in the right order. It renders nothing itself — every piece lives in its own module:
|
||||
*
|
||||
* fs-menutree path <-> menu node, alias/firstchild resolution (a port of dispatcher.uc)
|
||||
* fs-prefs the Appearance axes and their localStorage
|
||||
* fs-widgets disclosure primitives, the seg/slider controls, popup placement
|
||||
* fs-chrome mode menu, section tabs, the rail toggle, the "does it still fit" measurements
|
||||
* fs-router the SPA client router (docs/spa-router.md)
|
||||
* fs-sheets the guard against a view's injected CSS repainting every later page
|
||||
* fs-search the page-search palette (indexes the same tree, on first open)
|
||||
* fs-appearance the Appearance controls, appended to the stock System page
|
||||
* fs-overview the overview grid — a THEME module, not a luci-mod-status include
|
||||
* fs-version the shipped version string (shown in the Appearance section, no network)
|
||||
*
|
||||
*
|
||||
* They compose by CALLING each other, never by inheriting: LuCI instantiates every required module
|
||||
* into a singleton, so `base.extend` across modules throws and a module cannot subclass another
|
||||
* (docs/conventions.md — proven, not assumed). The same constraint is why the MAIN menu arrives as a callback:
|
||||
* menu-footstrap.js is the one renderer, and it injects renderMainMenu here rather than overriding
|
||||
* a method. LuCI raises DependencyError on a require() cycle, so the graph above is a DAG by
|
||||
* construction — the shared halves (fs-menutree, fs-prefs) were pulled out precisely so that no two
|
||||
* modules have to reach across into each other. */
|
||||
|
||||
return baseclass.extend({
|
||||
/* entry point: load the menu tree, render the mode menu (which drives the injected
|
||||
* renderMainMenu) and the section tabs, and wire the chrome. */
|
||||
init(renderMainMenu) {
|
||||
/* FIRST, and outside the promise: a third-party sheet that outranks the chrome is already
|
||||
* painting (fs-sheets: openclash's `* { margin: 0; padding: 0 }`). Nothing below depends on
|
||||
* it, and hanging it off ui.menu.load() only made the broken frame last a round-trip
|
||||
* longer — or forever, since the .catch() below swallows a menu failure into console. */
|
||||
sheets.watchViewSheets();
|
||||
prefs.guardDarkStamp(); /* a third party stamping :root — same shape, different vector */
|
||||
|
||||
ui.menu.load().then((menu) => {
|
||||
tree.setTree(menu);
|
||||
chrome.setRenderMain(renderMainMenu);
|
||||
|
||||
/* the view this full load already rendered — see fs-router's seed() */
|
||||
router.seed();
|
||||
|
||||
/* the bar's "does the menu fit beside the brand" measurement joins the engine the
|
||||
* tables use: it re-runs on every #view resize (a rail collapse and a layout toggle
|
||||
* produce one) and on content mutations */
|
||||
fit.add(chrome.fitChrome);
|
||||
|
||||
chrome.renderChrome();
|
||||
/* Both of these ADD to a stock page rather than owning a route: each watches
|
||||
* body[data-page] and does nothing anywhere else. A theme may not register a
|
||||
* dispatcher node — it outlives the theme that registered it. */
|
||||
appearance.wire();
|
||||
/* after setTree(): the palette indexes that tree — lazily, on its first open, but its
|
||||
* recent-pages list is recorded from the first navigation onwards */
|
||||
search.wire();
|
||||
chrome.wireRail();
|
||||
chrome.wireIndicatorCounts();
|
||||
/* BEFORE router.wire(): the router restamps body[data-page] on every SPA navigation,
|
||||
* and that attribute is what fs-overview keys off. Wiring its observer after the
|
||||
* router's would still work today (the first stamp is the server's, already in the
|
||||
* DOM), but it would make a route change racy against a listener that is not attached
|
||||
* yet — cheap to order correctly, expensive to debug. */
|
||||
overview.wire();
|
||||
router.wire();
|
||||
router.wireVisibility();
|
||||
/* fs-chrome's renderTabMenu warns about exactly this, and the root chain was left bare: a
|
||||
* throw anywhere in the calls above took out the menu, the router and the Appearance popover
|
||||
* together, silently. It still fails — there is no sane partial recovery — but loudly. */
|
||||
}).catch((e) => console.error('footstrap: chrome init failed', e));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require ui';
|
||||
'require fs-fit as fit';
|
||||
'require fs-prefs as prefs';
|
||||
'require fs-widgets as widgets';
|
||||
'require menu-footstrap-common as common';
|
||||
|
||||
/* The theme's ONE menu renderer: a vertical #topmenu the CSS also turns into the top bar and the
|
||||
* rail flyouts — same markup, no second renderer (docs/chrome.md). The disclosure primitives it builds
|
||||
* sections on come from fs-widgets and the auto-collapse preference from fs-prefs; the rest of the
|
||||
* chrome (mode menu, tabs, rail, router, popover) is bootstrapped by menu-footstrap-common, which
|
||||
* this file composes with by injecting renderMainMenu into common.init — a callback, not an
|
||||
* override, because a required LuCI module is a singleton and cannot be subclassed (docs/conventions.md).
|
||||
* Spec: docs/chrome.md */
|
||||
|
||||
/* A NULL PROTOTYPE, because `ICONS[key]` is a lookup by a MENU NODE NAME — a string a third-party
|
||||
* package chooses in its menu.d, not one this theme knows. A plain object literal answers
|
||||
* `constructor` and `__proto__` out of Object.prototype, so a node with either name resolved to a
|
||||
* truthy non-string, skipped the `|| ICONS._default` fallback, and iconSvg() concatenated it into
|
||||
* link.innerHTML below — `function Object() { [native code] }` and `[object Object]` respectively
|
||||
* (measured). The dotted reads (ICONS.vpn, ICONS._default) are unaffected. */
|
||||
const ICONS = Object.assign(Object.create(null), {
|
||||
status: '<rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/>',
|
||||
system: '<rect x="5" y="5" width="14" height="14" rx="2"/><rect x="9" y="9" width="6" height="6" rx="1"/><path d="M9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3"/>',
|
||||
services: '<circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="12" r="2.6"/>',
|
||||
network: '<circle cx="6" cy="18" r="2.4"/><circle cx="18" cy="6" r="2.4"/><circle cx="18" cy="18" r="2.4"/><path d="M8 16 16 8M8 18h7.5M18 8.5V16"/>',
|
||||
vpn: '<rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/>',
|
||||
docker: '<rect x="3" y="11" width="4" height="4" rx=".7"/><rect x="8" y="11" width="4" height="4" rx=".7"/><rect x="13" y="11" width="4" height="4" rx=".7"/><rect x="8" y="6" width="4" height="4" rx=".7"/><path d="M18 13c0 4-3 6-8 6-4 0-7-2-7-4"/>',
|
||||
_default: '<circle cx="12" cy="12" r="8.5"/>'
|
||||
});
|
||||
|
||||
function iconSvg(name) {
|
||||
const key = String(name || '').toLowerCase();
|
||||
const body = ICONS[key]
|
||||
|| ((/vpn|wireguard|openvpn/).test(key) ? ICONS.vpn : null)
|
||||
|| ((/dock|container|lxc/).test(key) ? ICONS.docker : null)
|
||||
|| ((/net|wifi|wireless|firewall|dhcp/).test(key) ? ICONS.network : null)
|
||||
|| ((/serv|dnsmasq|cron/).test(key) ? ICONS.services : null)
|
||||
|| ((/stat|overview|dash/).test(key) ? ICONS.status : null)
|
||||
|| ICONS._default;
|
||||
return widgets.svgIcon(body);
|
||||
}
|
||||
|
||||
/* `.open` means two things: in the expanded sidebar, an unfolded accordion section (several may
|
||||
* be open, the active one starts open); in the rail or the bar, a popup panel (exactly one open,
|
||||
* hover drives it, a tap toggles it, cleared on outside click and once a real mouse enters). */
|
||||
/* Is a section's panel a POPUP (flyout / bar dropdown) rather than an unfolded accordion? Must
|
||||
* read the same input the STYLESHEET does: `data-narrow` (fitShell() in fs-chrome.js
|
||||
* stamps it before the menu renders). NEVER a viewport breakpoint: it used to be
|
||||
* matchMedia('(max-width: 767px)') while the CSS turns the sidebar into a bar as soon as the
|
||||
* content column drops below --fs-content-min — 780px with the sidebar expanded. At 770-775px on
|
||||
* the router the chrome was a bar while the menu still believed it was an accordion: Escape and
|
||||
* click-outside did not close a panel (widgets.wireDismiss gates on this) and clampDropdown
|
||||
* refused to place it. */
|
||||
function flyoutMode() {
|
||||
return prefs.currentRail() || prefs.isTopLayout() ||
|
||||
document.documentElement.hasAttribute('data-narrow');
|
||||
}
|
||||
|
||||
/* The trigger — a bare <a>. widgets.setOpen keeps `.open` and aria-expanded in step. */
|
||||
const TRIGGER = ':scope > a';
|
||||
const OPEN_LI = '#topmenu > li.open';
|
||||
|
||||
/* ---- dropdown edge-clamp (the BAR, every width and every bar state) ------
|
||||
* A bar panel hangs off its OWN item (li position:relative, ul left:0 — theme/20-shell.css), so an
|
||||
* item near the right edge would push its panel past the viewport. Nudge it back inside. Runs at
|
||||
* ANY width now that the top bar is measured (no 768 floor); the rail flies panels out sideways
|
||||
* and needs a different placement, so it is excluded below. */
|
||||
/* the viewport edge gap, defined once in fs-widgets.js — the Appearance popover keeps a popup off
|
||||
* the edge by the same amount, and the two used to state it separately */
|
||||
const EDGE_GAP = widgets.EDGE_GAP;
|
||||
/* Is this panel a BAR dropdown (anchored under its item) rather than a rail flyout (anchored
|
||||
* beside it)? Ask what the STYLESHEET asks, exactly as flyoutMode() does: `data-narrow` is what
|
||||
* turns the sidebar layout into a bar, and it also disables the rail (the rail's rules are all
|
||||
* scoped `:not([data-narrow])`), so a narrow window is a bar even with the rail on. Gating this on
|
||||
* isTopLayout() alone was issue #19's second half: the panel was anchored per item by the CSS but
|
||||
* the clamp declined to place it, so an item near the right edge overflowed the viewport. */
|
||||
function barDropdown() {
|
||||
return prefs.isTopLayout() || document.documentElement.hasAttribute('data-narrow');
|
||||
}
|
||||
function clampDropdown(li) {
|
||||
if (!barDropdown()) return;
|
||||
const menu = li.querySelector(':scope > ul');
|
||||
if (!menu) return;
|
||||
|
||||
/* One pending measure per item: sweeping the pointer across the bar otherwise queues a
|
||||
* frame per item crossed, each a write-then-read of layout, none cancelled once the
|
||||
* pointer has moved on. */
|
||||
if (li._fsClampRaf) window.cancelAnimationFrame(li._fsClampRaf);
|
||||
li._fsClampRaf = window.requestAnimationFrame(() => {
|
||||
li._fsClampRaf = 0;
|
||||
menu.style.left = ''; /* back to the CSS anchor before measuring */
|
||||
const r = menu.getBoundingClientRect();
|
||||
if (!r.width) return; /* still hidden — nothing to place */
|
||||
|
||||
/* measured after a frame: on pointerenter the :hover rule that reveals the panel
|
||||
* has not applied yet, so it would still measure 0x0 */
|
||||
const overflowRight = r.right - (window.innerWidth - EDGE_GAP);
|
||||
if (overflowRight > 0)
|
||||
menu.style.left = -Math.min(overflowRight, r.left - EDGE_GAP) + 'px';
|
||||
});
|
||||
}
|
||||
/* a nudge computed for the old width, or for a bar we have since left, is wrong — drop them
|
||||
* and let the next hover/tap recompute. */
|
||||
function clearClamps() {
|
||||
document.querySelectorAll('#topmenu ul').forEach((m) => { m.style.left = ''; });
|
||||
}
|
||||
|
||||
function setOpen(li, on) {
|
||||
widgets.setOpen(li, on, TRIGGER);
|
||||
}
|
||||
|
||||
function closeFlyouts(except) {
|
||||
document.querySelectorAll(OPEN_LI).forEach((o) => {
|
||||
if (o !== except) setOpen(o, false);
|
||||
});
|
||||
}
|
||||
|
||||
/* Restore the accordion after LEAVING flyout mode (rail expanded, window grew). closeFlyouts()
|
||||
* alone used to run on both transitions — right going IN (a stuck popup is worse than a folded
|
||||
* section), wrong coming OUT: it stripped `.open` from everything, and since the markup is not
|
||||
* rebuilt on a rail toggle the remembered set was never re-applied, so expanding the rail folded
|
||||
* every section and "Keep open" quietly meant nothing. */
|
||||
function restoreAccordion() {
|
||||
const auto = prefs.currentAutoCollapse();
|
||||
document.querySelectorAll('#topmenu > li.has-sub').forEach((li) => {
|
||||
const name = li.dataset.name || '';
|
||||
setOpen(li, li.classList.contains('active') || (!auto && _openSections.has(name)));
|
||||
});
|
||||
}
|
||||
|
||||
/* Which top-level sections are unfolded, by node name. renderChrome() rebuilds #topmenu on every
|
||||
* SPA nav, so without this a section the user opened (Keep open) would refold on every tab
|
||||
* switch. Only consulted in the expanded sidebar with auto-collapse off; flyouts and
|
||||
* auto-collapse only ever re-open the active one. Persisted in localStorage because a
|
||||
* module-level Set does not survive a full page load, and plenty of LuCI pages are not SPA-able
|
||||
* (non-`view` nodes, any F5). */
|
||||
const OPEN_KEY = 'fs-menu-open';
|
||||
/* prefs.lsGetArr owns the parse, the corruption guard and the Array check; this list is a SET
|
||||
* (membership is the only question asked of it), which is the one part that differs. */
|
||||
function loadOpenSections() {
|
||||
return new Set(prefs.lsGetArr(OPEN_KEY));
|
||||
}
|
||||
function saveOpenSections() {
|
||||
prefs.lsSet(OPEN_KEY, JSON.stringify(Array.from(_openSections)));
|
||||
}
|
||||
const _openSections = loadOpenSections();
|
||||
|
||||
/* main sections -> vertical sidebar list (#topmenu), collapsible */
|
||||
function renderMainMenu(tree, url, level) {
|
||||
const ul = level ? E('ul', {}) : document.querySelector('#topmenu');
|
||||
const children = ui.menu.getChildren(tree);
|
||||
|
||||
if (!ul || children.length === 0 || level > 1)
|
||||
return E([]);
|
||||
|
||||
/* dispatchpath = [mode, section, subsection, …]; sections sit at
|
||||
* index (level+1) because the first call gets the mode. */
|
||||
const idx = (level || 0) + 1;
|
||||
|
||||
children.forEach((child) => {
|
||||
/* the chrome carries its own Logout entry (partials/logout.ut, in every layout), so
|
||||
* drop the tree's top-level admin/logout node or it shows up twice */
|
||||
if (!level && child.name === 'logout')
|
||||
return;
|
||||
|
||||
const submenu = renderMainMenu(child, url + '/' + child.name, (level || 0) + 1);
|
||||
const hasSub = !!submenu.firstElementChild;
|
||||
const isActive = (L.env.dispatchpath[idx] === child.name);
|
||||
|
||||
/* expanded sidebar + Keep open: a section starts open if it is the active one OR was
|
||||
* left open before this re-render. Auto-collapse and flyout mode ignore the set. */
|
||||
const keepOpen = hasSub && !level && !flyoutMode() && !prefs.currentAutoCollapse();
|
||||
const startOpen = hasSub && !flyoutMode() &&
|
||||
(isActive || (keepOpen && _openSections.has(child.name)));
|
||||
if (keepOpen && startOpen && !_openSections.has(child.name)) {
|
||||
_openSections.add(child.name);
|
||||
saveOpenSections();
|
||||
}
|
||||
const chevron = hasSub
|
||||
? '<svg class="fs-chevron" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></svg>'
|
||||
: '';
|
||||
|
||||
/* `active` is a CLASS: it paints the item and says nothing to a screen reader, so the
|
||||
* menu had no "you are here" at all. aria-current="page" belongs on the LEAF only — a
|
||||
* section header is a disclosure button, not a link to the current page. */
|
||||
const link = E('a', {
|
||||
'href': hasSub ? '#' : L.url(url, child.name),
|
||||
'class': (isActive && !hasSub) ? 'active' : '',
|
||||
'aria-current': (isActive && !hasSub) ? 'page' : null
|
||||
});
|
||||
link.innerHTML = (level ? '' : iconSvg(child.name)) + '<span class="fs-label"></span>' + chevron;
|
||||
link.querySelector('.fs-label').textContent = _(child.title);
|
||||
|
||||
/* collapsed rail: the label is hidden, so carry it as an attribute — CSS renders it as
|
||||
* the flyout's heading (sections) or as a tooltip (leaves) */
|
||||
if (!level) {
|
||||
link.setAttribute('data-label', _(child.title));
|
||||
if (hasSub)
|
||||
submenu.setAttribute('data-title', _(child.title));
|
||||
}
|
||||
|
||||
const li = E('li', {
|
||||
'class': [
|
||||
isActive ? 'active' : '',
|
||||
hasSub ? 'has-sub' : '',
|
||||
/* pre-opening the active section is an accordion affordance; in flyout
|
||||
* mode it would pop a panel open on page load */
|
||||
startOpen ? 'open' : ''
|
||||
].join(' ').trim()
|
||||
}, [ link, submenu ]);
|
||||
/* for restoreAccordion(): the markup is not rebuilt on a rail toggle, so the
|
||||
* remembered set has to be matched back to live <li>s by name */
|
||||
if (!level) li.dataset.name = child.name;
|
||||
|
||||
if (hasSub) {
|
||||
/* W3C APG disclosure-navigation: a section header is a BUTTON owning a panel, not
|
||||
* a link to "#". Not role="menu" — APG is explicit that site navigation must not
|
||||
* take on the menubar pattern's arrow-key semantics. */
|
||||
/* THE ID MUST BE INJECTIVE, because a menu node name is a string a third-party
|
||||
* package picks in its own menu.d and two of them may differ only in punctuation.
|
||||
* `[^a-z0-9]+` -> '-' folded `foo.bar` and `foo-bar` onto ONE id, so two sections at
|
||||
* the same level emitted the same `id` and both triggers' aria-controls resolved to
|
||||
* whichever panel stands first in the document — a screen reader is then told the
|
||||
* closed section owns the open one's contents. Escaping to the code point keeps the
|
||||
* id readable for the ordinary all-alphanumeric case and cannot collide. */
|
||||
const subId = 'fs-sub-' +
|
||||
String(child.name).replace(/[^a-z0-9]/gi, (c) => '_' + c.charCodeAt(0).toString(16)) +
|
||||
'-' + idx;
|
||||
submenu.id = subId;
|
||||
link.setAttribute('role', 'button');
|
||||
link.setAttribute('aria-controls', subId);
|
||||
link.setAttribute('aria-expanded', startOpen ? 'true' : 'false');
|
||||
|
||||
link.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
const open = li.classList.contains('open');
|
||||
/* flyout panels are exclusive, but must NOT touch the remembered set: it
|
||||
* mirrors the desktop "Keep open" state, and wiping it here meant one tap
|
||||
* in the phone flyout lost the user's open sections back on desktop */
|
||||
if (flyoutMode()) {
|
||||
closeFlyouts();
|
||||
setOpen(li, !open);
|
||||
if (!open) clampDropdown(li); /* tap-opened panel must fit too */
|
||||
return;
|
||||
}
|
||||
/* the expanded-sidebar accordion folds the others back only when asked
|
||||
* (Appearance -> Submenus) */
|
||||
if (prefs.currentAutoCollapse()) { closeFlyouts(); _openSections.clear(); }
|
||||
setOpen(li, !open);
|
||||
/* remember the accordion state so any navigation restores it (Keep open) */
|
||||
if (!open) _openSections.add(child.name);
|
||||
else _openSections.delete(child.name);
|
||||
saveOpenSections();
|
||||
});
|
||||
|
||||
widgets.wireSpaceKey(link);
|
||||
|
||||
/* hybrid devices: once a real MOUSE enters, drop the tap-opened panel so hover is
|
||||
* authoritative and two panels never stack. Guarded on pointerType — a touch tap
|
||||
* fires pointerenter ('touch') BEFORE the click, and clearing there would break
|
||||
* tap-to-close. */
|
||||
li.addEventListener('pointerenter', (ev) => {
|
||||
if (ev.pointerType === 'mouse' && flyoutMode())
|
||||
closeFlyouts();
|
||||
/* the bar opens this panel on hover (pure CSS), so it must be placed on
|
||||
* hover too, not only when a tap sets .open */
|
||||
clampDropdown(li);
|
||||
});
|
||||
}
|
||||
|
||||
ul.appendChild(li);
|
||||
});
|
||||
|
||||
return ul;
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
__init__() {
|
||||
common.init(renderMainMenu);
|
||||
|
||||
/* Click-outside and Escape close an open flyout. Gated on flyoutMode(): outside it
|
||||
* `.open` means "unfolded accordion", which must not fold on a click elsewhere. */
|
||||
widgets.wireDismiss({
|
||||
when: flyoutMode,
|
||||
inside: '#topmenu > li.has-sub',
|
||||
open: OPEN_LI,
|
||||
trigger: TRIGGER,
|
||||
close: () => closeFlyouts()
|
||||
});
|
||||
|
||||
/* Entering flyout mode: fold everything, or a section left open as an accordion
|
||||
* reappears as a popup panel stuck on screen. Leaving it: restoreAccordion().
|
||||
*
|
||||
* Watch the ATTRIBUTE, not the rail button: fs-chrome's wireRail() registers its click
|
||||
* handler from inside the ui.menu.load() promise, i.e. AFTER this runs, so a listener
|
||||
* added here would fire first and read the old data-rail. data-layout rides along —
|
||||
* toggling the layout live is the same transition, so it needs no menu re-render. */
|
||||
const modeChanged = () => {
|
||||
clearClamps();
|
||||
flyoutMode() ? closeFlyouts() : restoreAccordion();
|
||||
};
|
||||
/* data-narrow is the third attribute flyoutMode() reads (fitShell() writes it). It was
|
||||
* missing here, so dragging a window 800 -> 770px turned the sidebar into a bar with
|
||||
* the accordion still unfolded inside it, and no transition handler ran. */
|
||||
new MutationObserver(modeChanged).observe(document.documentElement, {
|
||||
attributes: true, attributeFilter: [ 'data-rail', 'data-layout', 'data-narrow' ]
|
||||
});
|
||||
/* No 768 media-query listener any more: the top bar is measured at every width
|
||||
* (fitChrome, no floor), so crossing 768 no longer flips the chrome — nothing to
|
||||
* transition that the attribute observer and the resize clamp-clear below don't cover. */
|
||||
|
||||
/* a clamp computed at the old width is wrong at the new one; coalesced via fit.frame
|
||||
* (the shared coalescer) because resize fires dozens of times a second while a window
|
||||
* is dragged */
|
||||
window.addEventListener('resize', fit.frame(clearClamps));
|
||||
|
||||
/* Appearance -> Submenus -> auto-collapse. fs-prefs.js owns the stored value and says only
|
||||
* that it changed; applying it is entirely ours, because every piece of the state is —
|
||||
* the remembered set, the `.open` class and the aria-expanded that setOpen keeps in step.
|
||||
*
|
||||
* restoreAccordion() already IS "apply the current auto-collapse setting": with the set
|
||||
* cleared and auto ON it computes open = the active section alone, and with auto OFF it
|
||||
* restores what "Keep open" remembered. Skipped in flyout mode — a section there is a
|
||||
* popup, and force-opening the active one would leave it stuck on screen. */
|
||||
document.addEventListener('fs-autocollapse', (ev) => {
|
||||
if (ev.detail && ev.detail.on) {
|
||||
_openSections.clear();
|
||||
saveOpenSections();
|
||||
}
|
||||
if (!flyoutMode()) restoreAccordion();
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user