mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-10-30 02:55:13 +08:00
update 2026-09-24 06:37:55
This commit is contained in:
1 parent
3d5ff73d1e
commit
907a158127
144 files changed
+43946
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
@@ -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,18 @@
|
||||
{
|
||||
"id": "/cgi-bin/luci/",
|
||||
"name": "LuCI",
|
||||
"short_name": "LuCI",
|
||||
"start_url": "/cgi-bin/luci/",
|
||||
"scope": "/cgi-bin/luci/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f6f8fa",
|
||||
"theme_color": "#f6f8fa",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/luci-static/footstrap/app-icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,851 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require ui';
|
||||
'require dom';
|
||||
'require fs-prefs as prefs';
|
||||
'require fs-axes as axes';
|
||||
'require fs-assets as assets';
|
||||
'require fs-version as ver';
|
||||
|
||||
/* The Appearance controls: the DOM that presents the axes. It owns no preference — fs-prefs.js
|
||||
* holds the axes and fs-version.js the version string; this file is the form they are shown in.
|
||||
*
|
||||
* Built once, for the dispatched page: `view/footstrap/appearance.js` — a menu entry, an ordinary
|
||||
* route — calls `renderStandalone()` below, the whole surface this file exposes. Before the page
|
||||
* existed the same form was stapled onto a tab on System -> System instead, watching for the stock
|
||||
* form's own tab strip with a MutationObserver, a 5 s deadline, a `body[data-page]` tracker, a
|
||||
* stale-group WeakSet disqualifying the outgoing page's own DOM from a false match, and a
|
||||
* sessionStorage flag so a reset's reload landed back on the tab rather than whichever one LuCI
|
||||
* remembered. A Save & Apply on a real router once lost that tab outright, never reproduced on
|
||||
* either stand — a mount stapled onto another app's own render is a timing dependency on that
|
||||
* render, by construction, and the most likely account of a user report that could never be
|
||||
* reproduced either way. All of it is gone now that the page owns a route of its own. Twenty-one
|
||||
* axes, nine of them with a colour field, a swatch and a contrast readout, do not fit a floating
|
||||
* popover that has to trap Tab and stay inside a 320px column, and keeping both containers would
|
||||
* render every axis twice.
|
||||
*
|
||||
* The version line makes no request and must not grow one: which version is INSTALLED is what this
|
||||
* page answers, and which is available is the package manager's question. */
|
||||
|
||||
/* ---- colour: reading what the page is actually painted ----
|
||||
*
|
||||
* This lived in fs-widgets.js, which the menu and the search palette also require — so the whole
|
||||
* colour engine was downloaded on every admin page to be used on this one. It is 3 KB of probe,
|
||||
* canvas and WCAG arithmetic that nothing outside this form has ever called: `colorControl` was
|
||||
* fs-widgets' only colour export and this file its only consumer. */
|
||||
|
||||
/* ---- colour: reading what the page is actually painted ----
|
||||
*
|
||||
* Two questions no stored value answers: what colour a role is right now (the palette's own while
|
||||
* the axis is off — there is deliberately no copy of the palette in JS), and what contrast the
|
||||
* user's colour lands at. Both are about the computed cascade, so both are asked of the browser.
|
||||
*
|
||||
* `getComputedStyle(root).getPropertyValue('--fs-accent')` answers neither: a custom property
|
||||
* computes to the token stream after var() substitution, so `oklch(from … l c H)` comes back
|
||||
* unevaluated. Setting the expression as a real `color` and reading it back makes the browser
|
||||
* resolve it — relative colour, color-mix() and the tint's calc() are what the theme is made of.
|
||||
* One hidden probe is reused; an element per query would thrash layout on every slider drag. */
|
||||
let _probe = null;
|
||||
function probeColor(expr) {
|
||||
if (!_probe) {
|
||||
/* Off-screen rather than display:none, so the reading does not depend on a display:none
|
||||
* element computing `color` in every engine. It has no text and no size, so it paints
|
||||
* nothing.
|
||||
*
|
||||
* Every declaration is !important (issue #19): this is an unmarked element in a document
|
||||
* shared with `luci-app-*`, and an app's unlayered `span { color: … !important }` outranks
|
||||
* a layer and a plain inline style alike. A probe that loses its own colour reports the
|
||||
* app's, which then becomes the admin's saved axis on the next confirm. */
|
||||
_probe = E('span', { 'aria-hidden': 'true' });
|
||||
_probe.style.cssText = 'position:fixed!important;left:-9999px!important;top:0!important;'
|
||||
+ 'width:0!important;height:0!important;overflow:hidden!important;'
|
||||
+ 'pointer-events:none!important;';
|
||||
document.body.appendChild(_probe);
|
||||
}
|
||||
/* cleared first: an expression the engine rejects leaves the previous colour standing, which
|
||||
* would report a stale answer as a fresh one */
|
||||
_probe.style.setProperty('color', '');
|
||||
_probe.style.setProperty('color', expr, 'important');
|
||||
return getComputedStyle(_probe).color;
|
||||
}
|
||||
|
||||
/* A computed colour -> [r,g,b] 0..255, or null. Rasterised, not parsed: a computed `color` keeps
|
||||
* the space it was authored in, so `oklch(0.54 0.19 300)` would parse as three numbers in the
|
||||
* wrong units and produce a colour nobody chose — measured: #010078, graded "Too faint to read",
|
||||
* in the hex field, the swatch and the contrast readout alike. Painting one pixel makes the engine
|
||||
* convert instead (tools/export-tier.mjs uses the same method). The string parse remains only as
|
||||
* the fallback for an engine with no 2D context, where only the legacy `rgb()`/`color(srgb …)`
|
||||
* forms can appear. */
|
||||
let _cx = null;
|
||||
function rasterCtx() {
|
||||
if (_cx !== null) return _cx;
|
||||
try {
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = cv.height = 1;
|
||||
_cx = cv.getContext('2d', { willReadFrequently: true }) || false;
|
||||
} catch (e) { _cx = false; }
|
||||
return _cx;
|
||||
}
|
||||
function parseColor(s) {
|
||||
const str = String(s || '');
|
||||
const cx = rasterCtx();
|
||||
if (cx) {
|
||||
/* fillStyle keeps the last value it could parse, so a colour this engine rejects would
|
||||
* report the previous one as a fresh reading — the trap probeColor() clears for */
|
||||
cx.fillStyle = '#000';
|
||||
cx.fillStyle = str;
|
||||
cx.clearRect(0, 0, 1, 1);
|
||||
cx.fillRect(0, 0, 1, 1);
|
||||
const d = cx.getImageData(0, 0, 1, 1).data;
|
||||
if (d[3] === 255) return [ d[0], d[1], d[2] ];
|
||||
/* translucent: composite over nothing is meaningless for a readout, so fall through */
|
||||
}
|
||||
const nums = str.match(/[\d.]+/g);
|
||||
if (!nums || nums.length < 3) return null;
|
||||
const unit = (/^color\(/i).test(str) ? 255 : 1;
|
||||
return nums.slice(0, 3).map((n) => Math.max(0, Math.min(255, parseFloat(n) * unit)));
|
||||
}
|
||||
|
||||
/* WCAG 2.x relative luminance and contrast ratio, on sRGB. Used only to report: the theme states
|
||||
* what a colour costs and leaves the choice with the user, never correcting it (03-palettes.css
|
||||
* derives the ink over a fill, which is a different question). */
|
||||
function luminance(rgb) {
|
||||
const c = rgb.map((v) => {
|
||||
const x = v / 255;
|
||||
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, because <input type="color"> accepts nothing else. An unparseable colour becomes black
|
||||
* rather than throwing: the text field beside the swatch is the authoritative one. */
|
||||
function toHex(s) {
|
||||
const rgb = parseColor(s) || [ 0, 0, 0 ];
|
||||
return '#' + rgb.map((v) => Math.round(v).toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/* One colour axis: a native swatch, a hex field and a button back to the palette's own colour.
|
||||
* Reports through onPick as a hex string, or 0 for "back to the palette", either of which the
|
||||
* caller hands straight to fs-prefs.js's colorAxis.
|
||||
*
|
||||
* There is no hue slider and one is not coming back: rotating a hue keeps the palette's chroma,
|
||||
* so no angle of it reaches a grey. The axis still accepts a stored hue (1–360) and the stylesheet
|
||||
* still rotates the palette by one, so a saved value goes on working.
|
||||
*
|
||||
* `opts.probe` is the live token the effective colour is read back from, so the field shows the
|
||||
* palette's colour while the axis is off without a copy of the palette in JS. `opts.contrast` is
|
||||
* the pair whose ratio is reported under the row. */
|
||||
function colorControl(current, onPick, label, opts) {
|
||||
const o = opts || {};
|
||||
|
||||
/* type=color leaves the picker to the browser: accessible without reimplementing a colour
|
||||
* wheel, and native on a phone. The text field beside it takes a pasted hex and is the
|
||||
* fallback where the browser draws no picker. */
|
||||
const swatch = E('input', { 'type': 'color', 'class': 'fs-color-swatch', 'aria-label': label || '' });
|
||||
const field = E('input', {
|
||||
'type': 'text', 'class': 'fs-color-hex', 'spellcheck': 'false', 'autocomplete': 'off',
|
||||
'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 page can change it behind this control (a preset, Reset
|
||||
* to default), so a private copy would go stale. `current` is only the build-time value. */
|
||||
const currentOf = o.read || (() => current);
|
||||
|
||||
/* Repaint everything that mirrors the axis. Called after every edit, and through the returned
|
||||
* refresh() after a preset, palette switch or dark-mode flip — each changes what the palette's
|
||||
* own colour is while this axis stays off. */
|
||||
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 prefix, and overwriting the field on
|
||||
* every keystroke made the input impossible to type into */
|
||||
if (document.activeElement !== field) field.value = hex;
|
||||
/* the button back to the palette doubles as the axis state readout: enabled means the axis
|
||||
* holds a colour of its own, disabled means the field shows the palette's */
|
||||
clear.disabled = !v;
|
||||
if (!ratio) return;
|
||||
const r = contrastRatio(o.contrast.fg, o.contrast.bg);
|
||||
if (r === null) { ratio.textContent = ''; ratio.removeAttribute('title'); return; }
|
||||
/* The readout states what the ratio means; the number itself stays in the title.
|
||||
* Thresholds are WCAG AA: 4.5:1 for body text, 3:1 for large text and for a UI shape, so a
|
||||
* hairline is graded on the second (`kind: 'shape'`) and warns rather than fails — a faint
|
||||
* border is a legitimate choice.
|
||||
*
|
||||
* Class names are written out whole: tools/fs-orphans.mjs sweeps dead CSS by matching
|
||||
* fs-* tokens in the source, and a concatenated name is invisible to it. */
|
||||
const where = o.contrast.label;
|
||||
const grade = (o.contrast.kind === 'shape')
|
||||
? ((r >= 3)
|
||||
? { 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;
|
||||
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 Enter, not per keystroke: a half-typed `#0096` would repaint the page
|
||||
* under the cursor. An unparseable value snaps back to what the axis holds, so the field
|
||||
* cannot claim a colour the page is not painted in. */
|
||||
const commit = () => {
|
||||
const v = field.value.trim().toLowerCase();
|
||||
if ((/^#[0-9a-f]{6}$/).test(v)) pick(v);
|
||||
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 decides when this runs: probeColor() needs the document, and this control is not
|
||||
* in it yet */
|
||||
wrap.fsRefresh = () => reflect(currentOf());
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/* Build the whole form. Returns the root element, for renderStandalone() below to hand to the
|
||||
* page's own render().
|
||||
*
|
||||
* Everything applies immediately and there is nothing to save: every axis is this browser's, in
|
||||
* localStorage, and the page repaints under the control as it moves. Only "Save to router" writes
|
||||
* anything, pushing the current look to the ROUTER for other browsers. That distinction is the
|
||||
* model (docs/design-system.md), and why this page has no Save/Reset footer of LuCI's own —
|
||||
* view/footstrap/appearance.js nulls handleSave/handleSaveApply/handleReset, which is what actually
|
||||
* drops it: view.js's own default footer (Save & Apply | Apply unchecked | Save | Reset, wired to
|
||||
* apply_rollback) renders and runs otherwise, POSTing on a form with nothing staged (measured,
|
||||
* live, before that null). */
|
||||
function build() {
|
||||
/* every saved axis re-checks the Save button after applying, so it greys the moment this
|
||||
* browser matches the saved default and un-greys when it diverges. Wrapped around the appliers
|
||||
* because the controls call them directly and have no other seam back here. */
|
||||
const bump = (fn) => (v) => { fn(v); refreshSave(); };
|
||||
|
||||
/* Every colour control mirrors something it does not own: the palette's colour while its own
|
||||
* axis is off, and the contrast that colour lands at. A palette switch or dark-mode flip moves
|
||||
* all of it under controls nobody touched, so they refresh together. */
|
||||
const colourCtls = [];
|
||||
const refreshColours = () => colourCtls.forEach((c) => c.fsRefresh());
|
||||
/* wrap an applier so the colour readouts follow it: mode and palette change what every axis is
|
||||
* measured against */
|
||||
const repaint = (fn) => (v) => { fn(v); refreshColours(); };
|
||||
|
||||
/* One captioned row in LuCI's own shape: `.cbi-value` > `label.cbi-value-title` +
|
||||
* `.cbi-value-field`. Nothing here styles those class names — base/30-forms.css and
|
||||
* theme/60-inputs.css already lay them out — so this page inherits every future fix to the form
|
||||
* layout instead of keeping a private copy. It also puts the row on a shared surface (zone 2,
|
||||
* where an app may win on specificity), which is right for a page inside #view.
|
||||
*
|
||||
* `make` is handed the same label string the caption renders, because every control needs it a
|
||||
* second time as its aria-label; stating it twice is how the visible caption and what a screen
|
||||
* reader announces drift apart. `extra` carries rows with more than a control, `opts.cls` marks
|
||||
* the rows CSS has to single out. */
|
||||
const group = (label, make, opts) => {
|
||||
const o = opts || {};
|
||||
return E('div', { 'class': 'cbi-value' + (o.cls ? ' ' + o.cls : '') }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, [ label ]),
|
||||
E('div', { 'class': 'cbi-value-field' }, [ make(label) ].concat(o.extra || []))
|
||||
]);
|
||||
};
|
||||
|
||||
/* the three literals every colour row repeats; a string literal survives minification intact */
|
||||
const CARD_BG = 'var(--fs-panel)', INK = 'var(--fs-text)', ON_CARD = _('on a card', 'footstrap');
|
||||
|
||||
/* ---- the controls are LuCI's own ----
|
||||
*
|
||||
* Every enum axis is a `ui.Select` and every number a `ui.RangeSlider`: the widgets the other
|
||||
* tabs are built from, already dressed by this stylesheet (`select` in base/30-forms.css,
|
||||
* `.cbi-range-slider` in theme/60-inputs.css). Both exist on every release this theme supports
|
||||
* — checked against openwrt-24.10, since `ui.RangeSlider` is the one that did not exist further
|
||||
* back.
|
||||
*
|
||||
* Listen for `widget-change` from UIElement, not for an event on the inner element: that is the
|
||||
* seam the widget publishes, and reaching past it ties this to how the widget happens to be
|
||||
* built. RangeSlider also emits `widget-update` while the handle moves, which is what resizes
|
||||
* the page under the drag; the appliers are idempotent, so wiring both costs nothing. */
|
||||
const selectCtl = (current, choices, apply, label) => {
|
||||
const w = new ui.Select(String(current), choices, { widget: 'select', sort: Object.keys(choices) });
|
||||
const node = w.render();
|
||||
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
|
||||
});
|
||||
const node = w.render();
|
||||
node.setAttribute('aria-label', label);
|
||||
const push = () => apply(parseInt(w.getValue(), 10));
|
||||
/* `live: false` for an axis whose value moves THIS CONTROL. Content width widens the column
|
||||
* the Appearance page is drawn in, so applying mid-drag slides the handle out from under
|
||||
* the pointer, the pointer catches up, and one small drag runs the value to the maximum —
|
||||
* reported from a router, and not a thing any other slider here can do: rounding and the
|
||||
* tint sliders repaint around a control that stays where it is. On release the page still
|
||||
* follows, and a keyboard user is unaffected either way: an arrow key on a range input
|
||||
* fires `change` as well as `input`, so the value applies at once. */
|
||||
if (o.live !== false) node.addEventListener('widget-update', push);
|
||||
node.addEventListener('widget-change', push);
|
||||
return node;
|
||||
};
|
||||
|
||||
/* one colour axis: `probe` is the live token the control reads the effective colour back from,
|
||||
* `contrast` the pair it reports */
|
||||
const colourGroup = (label, axis, probe, contrast, opts) => group(label, (lbl) => {
|
||||
const ctl = colorControl(axis.current(), bump(axis.apply), lbl, {
|
||||
probe: probe,
|
||||
read: axis.current,
|
||||
contrast: contrast,
|
||||
cls: (opts && opts.cls) || ''
|
||||
});
|
||||
colourCtls.push(ctl);
|
||||
return ctl;
|
||||
}, opts);
|
||||
|
||||
/* Every label here carries the 'footstrap' context (`_(str, ctx)`, key `ctx\1str`). LuCI serves
|
||||
* one merged catalogue — load_catalog() loads every *.<lang>.lmo and a lookup returns the first
|
||||
* archive holding the hash — so a bare msgid is a global name any luci-app may take, and
|
||||
* readdir order picks the winner: the layout toggle rendered "Максимум" on a Russian router
|
||||
* because another catalogue translates "Top" as "maximum" (issue #6). Contexting cannot be
|
||||
* selective. The chrome, the login/notice sentences and the System/Memory/Storage headings are
|
||||
* deliberately bare — inheriting luci-base's translation covers the ~40 languages this theme
|
||||
* has no catalogue for. */
|
||||
|
||||
/* ---- section 1: the shell ---- */
|
||||
const shell = [
|
||||
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(axes.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',
|
||||
/* names the OTHER package again, luci-theme-openwrt-2020, whose colourway this is */
|
||||
'2020': 'OpenWrt 2020',
|
||||
/* names the OpenWrt forum (forum.openwrt.org), whose Discourse colourway this is —
|
||||
* a proper noun like the three above it, not the English common noun "forum" */
|
||||
forum: 'Forum'
|
||||
}, bump(repaint(axes.applyPalette)), label)),
|
||||
|
||||
group(_('Density', 'footstrap'), (label) => selectCtl(prefs.currentDensity(), {
|
||||
compact: _('Compact', 'footstrap'),
|
||||
normal: _('Normal', 'footstrap'),
|
||||
large: _('Large', 'footstrap')
|
||||
}, bump(prefs.applyDensity), label)),
|
||||
|
||||
/* issue #44: the content column's own cap, separate from Density (which moves type and
|
||||
* air, not the column's ceiling). The slider STARTS at the 1280px the theme has always
|
||||
* shipped and only ever widens — there is no reason to offer a column narrower than the
|
||||
* one every page was designed against, and keeping 1280 as the left end also keeps
|
||||
* --fs-content-min (500px, what the sidebar-to-bar fold is measured against) out of reach
|
||||
* by construction rather than by a rule someone has to remember. */
|
||||
group(_('Content width', 'footstrap'),
|
||||
(label) => sliderCtl(axes.currentContentWidth(), 1280, 3840,
|
||||
bump(axes.applyContentWidth), label, { step: 40, live: false })),
|
||||
|
||||
group(_('Rounding', 'footstrap'),
|
||||
(label) => sliderCtl(axes.currentRadius(), 0, 20, bump(axes.applyRadius), label)),
|
||||
|
||||
/* The top layout has no accordion, so this switch is meaningless there: always built,
|
||||
* hidden by CSS (:root[data-layout="top"] .fs-ap-submenus). Do not wrap it in an
|
||||
* `if (currentLayout() !== 'top')` — the page is built once, so the branch would freeze
|
||||
* the control to the layout the page loaded in while CSS morphs the chrome live. */
|
||||
group(_('Submenus', 'footstrap'), (label) => selectCtl(
|
||||
prefs.currentAutoCollapse() ? 'on' : 'off', {
|
||||
off: _('Keep open', 'footstrap'),
|
||||
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: axes.currentTint, apply: axes.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, meaningful only in hue mode: a hex canvas is the colour
|
||||
* asked for, with no chroma of ours to scale. CSS hides it in the other two states.
|
||||
*
|
||||
* Not called "Density": that is the select above, and this string is both the caption and
|
||||
* the aria-label, so a screen reader would announce two rows under one name. */
|
||||
group(_('Tint strength', 'footstrap'),
|
||||
(label) => sliderCtl(axes.currentTintStrength(), 0, 200, bump(repaint(axes.applyTintStrength)), label, {
|
||||
step: 5
|
||||
}), { cls: 'fs-ap-tint fs-ap-tintstr' }),
|
||||
|
||||
/* recolours the accented controls (buttons/toggles/sliders/focus rings), not the canvas.
|
||||
* Measured as text on a card, the use that fails first: as a fill it carries derived ink,
|
||||
* as a link or status label it carries only itself. It is also what answers #20 ("sometimes
|
||||
* you want grey or black"), taking any #rrggbb — the colour-chip presets that once sat here
|
||||
* are not coming back. */
|
||||
/* Four status roles, one shape: the role's own colour read against a card. Written out
|
||||
* eight times between here and the surfaces below, they cost their repeated literals in
|
||||
* full — a string is not mangled — so the rows are data and the row is stated once. */
|
||||
...[
|
||||
[ _('Accent', 'footstrap'), axes.currentAccent, axes.applyAccent, 'var(--fs-accent)' ],
|
||||
[ _('Good', 'footstrap'), axes.currentGood, axes.applyGood, 'var(--fs-good)' ],
|
||||
[ _('Warning', 'footstrap'), axes.currentWarn, axes.applyWarn, 'var(--fs-warn)' ],
|
||||
[ _('Danger', 'footstrap'), axes.currentDanger, axes.applyDanger, 'var(--fs-danger)' ]
|
||||
].map(([ label, current, apply, ink ]) =>
|
||||
colourGroup(label, { current, apply }, ink, { fg: ink, bg: CARD_BG, label: ON_CARD }))
|
||||
];
|
||||
|
||||
/* ---- the surfaces: the sheet the UI is drawn on ----
|
||||
* Cards, inset controls, the chrome bar and the hairlines between them. Body text is read on
|
||||
* each, so each reports --fs-text against itself. No ink is derived here: --fs-text is the
|
||||
* palette's, and moving it would recolour the very thing being measured against.
|
||||
*
|
||||
* The hairline takes the 3:1 UI-component threshold instead — a border is a shape, not a label
|
||||
* — and below that it is decoration, which a hairline is entitled to be, so the readout states
|
||||
* the number and leaves the call to the admin. */
|
||||
const surfaces = [
|
||||
/* Same rows, one column wider: a surface reports the ink read ON it, which is --fs-text
|
||||
* for the three that carry body text and the hairline itself for the border. */
|
||||
...[
|
||||
[ _('Cards', 'footstrap'), axes.currentCard, axes.applyCard, CARD_BG, INK, CARD_BG, ON_CARD ],
|
||||
[ _('Controls', 'footstrap'), axes.currentControl, axes.applyControl, 'var(--fs-panel2)', INK, 'var(--fs-panel2)', _('on a control', 'footstrap') ],
|
||||
[ _('Sidebar and bar', 'footstrap'), axes.currentBar, axes.applyBar, 'var(--fs-bar-bg)', INK, 'var(--fs-bar-bg)', _('in the sidebar', 'footstrap') ],
|
||||
[ _('Borders', 'footstrap'), axes.currentLine, axes.applyLine, 'var(--fs-border)', 'var(--fs-border)', CARD_BG, ON_CARD, 'shape' ]
|
||||
].map(([ label, current, apply, probe, fg, bg, where, kind ]) =>
|
||||
colourGroup(label, { current, apply }, probe, { fg, bg, label: where, kind }))
|
||||
];
|
||||
|
||||
/* ---- section 3: the wallpaper and the rows each value brings ----
|
||||
*
|
||||
* Wallpaper is three-valued: Off, Pattern (an uploaded SVG, tiled and recoloured) and File (an
|
||||
* uploaded photo). The rows a value brings are SIBLINGS of the Wallpaper row, not children of
|
||||
* its field: nesting a `.cbi-value` inside a `.cbi-value-field` puts a second caption column
|
||||
* inside the first, and those controls started 216px right of every other one on the page,
|
||||
* measured on the router. Flat rows hidden as a group is what the stock pages do with a
|
||||
* dependent field.
|
||||
*
|
||||
* The select is the per-browser switch deciding whether to paint an image, so it is what keeps
|
||||
* the Save button honest; Choose/Remove only swap the picture and never touch the axis. The
|
||||
* native file inputs stay hidden — the styled buttons trigger them. */
|
||||
const wallpaper = (() => {
|
||||
const err = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' });
|
||||
const preview = E('img', { 'class': 'fs-ap-bgprev', 'alt': '', 'hidden': '' });
|
||||
/* display:none, not `hidden`: a bare `hidden=""` still renders the native
|
||||
* "Choose File / No file chosen" control */
|
||||
const fileInput = E('input', { 'type': 'file', 'accept': 'image/*', 'style': 'display:none' });
|
||||
const chooseLabel = _('Choose image', 'footstrap');
|
||||
const chooseBtn = E('button', { 'class': 'btn cbi-button', 'type': 'button' }, [ chooseLabel ]);
|
||||
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 snapshotAxes(), so it moves this browser toward or away from the router default and
|
||||
* must be bump()-ed like every other saved axis, or the Save button misreports its own
|
||||
* status. Separate from the Tint strength above. */
|
||||
const dimLabel = _('Dim', 'footstrap');
|
||||
const scaleLabel = _('Scale', 'footstrap');
|
||||
const strengthLabel = _('Strength', 'footstrap');
|
||||
const inkLabel = _('Colours', 'footstrap');
|
||||
|
||||
/* The rows the pattern brings. Scale and Strength are live: the appliers write a custom
|
||||
* property, so the tile resizes and fades under the drag. Colours decides whether the
|
||||
* file's own palette is kept or replaced by the theme's — a mask uses the alpha only,
|
||||
* which is right for line art and wrong for artwork that carries its own colours. */
|
||||
const patRows = [
|
||||
group(_('Pattern', 'footstrap'),
|
||||
() => E('div', { 'class': 'fs-ap-bgrow' }, [ patChoose, patRemove ]),
|
||||
{ extra: [ patInput, patPreview, patErr ] }),
|
||||
group(scaleLabel, (lbl) => sliderCtl(axes.currentPatternSize(), 40, 1600,
|
||||
bump(axes.applyPatternSize), lbl, { step: 20 })),
|
||||
group(strengthLabel, (lbl) => sliderCtl(axes.currentPatternStrength(), 0, 100,
|
||||
bump(axes.applyPatternStrength), lbl, { step: 5 })),
|
||||
group(inkLabel, (lbl) => selectCtl(axes.currentPatternInk(), {
|
||||
theme: _('Theme', 'footstrap'),
|
||||
original: _('As in file', 'footstrap')
|
||||
}, bump(axes.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(axes.currentPhotoDim(), 0, 100,
|
||||
bump(axes.applyPhotoDim), lbl, { step: 5 }))
|
||||
];
|
||||
|
||||
function reflect(tok) {
|
||||
if (tok) { preview.src = axes.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 = axes.patternUrl(tok); patPreview.hidden = false; patRemove.hidden = false; }
|
||||
else { patPreview.removeAttribute('src'); patPreview.hidden = true; patRemove.hidden = true; }
|
||||
}
|
||||
/* `hidden` on the row, which 80-appearance.css restates at a specificity beating
|
||||
* `.cbi-value`'s own display (the UA's bare `[hidden]` rule loses to it). Hidden, not
|
||||
* removed: each row holds a live control, so rebuilding on every switch would freeze it to
|
||||
* the state it was constructed in. */
|
||||
function togglePanel(v) {
|
||||
patRows.forEach((r) => { r.hidden = (v !== 'pattern'); });
|
||||
fileRows.forEach((r) => { r.hidden = (v !== 'file'); });
|
||||
}
|
||||
reflect(axes.currentLoginBg());
|
||||
reflectPattern(axes.currentPattern());
|
||||
togglePanel(axes.currentWallpaper());
|
||||
|
||||
const setWallpaper = (v) => { axes.applyWallpaper(v); refreshSave(); togglePanel(v); refreshColours(); };
|
||||
|
||||
/* Both uploads present the same three controls and the same four states — pick, upload,
|
||||
* report, remove — so the wiring is stated once. What differs is `after`: the pattern also
|
||||
* has to move the Wallpaper dropdown, because the upload switched this browser onto the
|
||||
* tile and the page would otherwise paint it while the control still read Off.
|
||||
*
|
||||
* The file input is cleared on every change so re-picking the SAME file fires `change`
|
||||
* again, and the button carries its own busy state: the label is restored in `finally`, or
|
||||
* a failed upload leaves "Uploading…" standing for the life of the form. */
|
||||
const wireUploader = (u) => {
|
||||
const fail = (e) => { u.err.textContent = String((e && e.message) || e); u.err.hidden = false; };
|
||||
u.choose.addEventListener('click', () => { u.err.hidden = true; u.input.click(); });
|
||||
u.input.addEventListener('change', () => {
|
||||
const f = u.input.files && u.input.files[0];
|
||||
u.input.value = '';
|
||||
if (!f) return;
|
||||
u.err.hidden = true; u.choose.disabled = true;
|
||||
u.choose.textContent = _('Uploading…', 'footstrap');
|
||||
u.upload(f)
|
||||
.then((tok) => { u.reflect(tok); if (u.after) u.after(tok); })
|
||||
.catch(fail)
|
||||
.finally(() => { u.choose.disabled = false; u.choose.textContent = u.label; });
|
||||
});
|
||||
u.remove.addEventListener('click', () => {
|
||||
u.err.hidden = true; u.remove.disabled = true;
|
||||
u.drop().then(() => u.reflect('')).catch(fail)
|
||||
.finally(() => { u.remove.disabled = false; });
|
||||
});
|
||||
};
|
||||
|
||||
wireUploader({
|
||||
choose: patChoose, remove: patRemove, input: patInput, err: patErr,
|
||||
label: patChooseLabel, reflect: reflectPattern,
|
||||
upload: assets.uploadPattern, drop: assets.removePattern,
|
||||
/* `dom.callClassMethod` is how LuCI moves its own widgets from outside; setWallpaper is
|
||||
* then called directly, because a programmatic setValue emits no `widget-change`. */
|
||||
after: () => { dom.callClassMethod(seg, 'setValue', 'pattern'); setWallpaper('pattern'); }
|
||||
});
|
||||
|
||||
wireUploader({
|
||||
choose: chooseBtn, remove: removeBtn, input: fileInput, err: err,
|
||||
label: chooseLabel, reflect: reflect,
|
||||
upload: assets.uploadLoginBg, drop: assets.removeLoginBg
|
||||
});
|
||||
|
||||
let seg;
|
||||
const wallRow = group(_('Wallpaper', 'footstrap'), (label) => {
|
||||
seg = selectCtl(axes.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 ----
|
||||
*
|
||||
* Save the current look as the router-wide default (fs-prefs writes /etc/config/footstrap over
|
||||
* the scoped uci ACL). It does not change this browser — localStorage keeps overriding — so the
|
||||
* saved default only shows on a fresh browser. The two Reset buttons below are the escape
|
||||
* hatches, and they do not land in the same place. */
|
||||
/* the button/status label said three times below, inside this one build() call — hoisting the
|
||||
* RESULT, not the msgid, so update-po.sh still sees the literal `_()` argument elsewhere
|
||||
* (measured: 22 B x3 -> 27 B, 39 B saved) */
|
||||
const SAVE_TO_ROUTER = _('Save to router', 'footstrap');
|
||||
/* said twice below, same build() call (measured: 24 B x2 -> 30 B, 18 B saved) */
|
||||
const RESET_TO_ROUTER = _('Reset to router', 'footstrap');
|
||||
const saveBtn = E('button', { 'class': 'btn cbi-button-action', 'type': 'button' }, [ SAVE_TO_ROUTER ]);
|
||||
/* Two resets, because two things sit underneath a browser's tweaks (fs-prefs.js): "Reset to
|
||||
* router" clears them and lets every axis fall back to whatever the router holds, while "Reset
|
||||
* to built-in" writes the theme's built-ins explicitly — the only way to say "as the theme
|
||||
* ships" on a router that has a look of its own. Neither touches /etc/config/footstrap.
|
||||
*
|
||||
* ONE word per state, and "default" is not one of them: it used to name the router's look in
|
||||
* "Save as default" and the theme's in "Reset to default", while the router's look also
|
||||
* answered to "saved" — three names for two states, asked about on the forum (topic 251930,
|
||||
* post 92). The row now reads as one save and two resets, over `router` and `built-in`. */
|
||||
const resetSavedBtn = E('button', { 'class': 'btn', 'type': 'button' }, [ RESET_TO_ROUTER ]);
|
||||
/* the stock destructive class, so the button discarding every local tweak is the red one
|
||||
* (theme/55-buttons.css). "Reset to router" stays neutral: it steps back to the shared state
|
||||
* rather than discarding. */
|
||||
const resetBtn = E('button', { 'class': 'btn cbi-button-negative', 'type': 'button' }, [ _('Reset to built-in', 'footstrap') ]);
|
||||
/* Save's only visible failure surface. The realistic failure is the rpc rejecting — an expired
|
||||
* session (403), a missing ACL, ubus down. A DELETED config is not caught: rpcd stages the set
|
||||
* in the session and commit then no-ops without writing the file, returning success (measured
|
||||
* on the router). The package owns that file and the read side falls back to built-in
|
||||
* defaults. */
|
||||
const saveErr = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' });
|
||||
|
||||
/* The Save button is the status: matching disables it, diverging enables it. Called after every
|
||||
* axis change (via bump).
|
||||
*
|
||||
* Unless the browser refuses storage, where the comparison would always be true — nothing was
|
||||
* written, so every current*() reads the router default back however far the page has been
|
||||
* dragged from it. Say so instead, and leave the button enabled: pushing this browser's look to
|
||||
* the router is the one thing that still works. */
|
||||
function refreshSave() {
|
||||
if (prefs.storageBroken()) {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = SAVE_TO_ROUTER;
|
||||
saveErr.textContent = _('This browser is not storing preferences (site data is blocked), so a change here lasts until you reload. Saving as default still works and applies to every browser.', 'footstrap');
|
||||
saveErr.hidden = false;
|
||||
return;
|
||||
}
|
||||
const saved = axes.matchesSavedDefault();
|
||||
saveBtn.disabled = saved;
|
||||
saveBtn.textContent = saved ? _('Saved to router', 'footstrap') : SAVE_TO_ROUTER;
|
||||
}
|
||||
saveBtn.addEventListener('click', () => {
|
||||
saveBtn.disabled = true;
|
||||
saveErr.hidden = true;
|
||||
axes.saveAsDefault()
|
||||
.then(() => { saveErr.hidden = true; })
|
||||
/* on failure refreshSave re-enables the button so the user can retry; the usual cause
|
||||
* is a stale session, which a reload fixes. The raw rpc error stays in a title
|
||||
* tooltip. */
|
||||
.catch((e) => {
|
||||
saveErr.textContent = _('Could not save the default. Reload the page and try again.', 'footstrap');
|
||||
saveErr.title = String((e && e.message) || e);
|
||||
saveErr.hidden = false;
|
||||
})
|
||||
.finally(refreshSave);
|
||||
});
|
||||
/* Two-click confirm on both: discarding local tweaks is destructive and a native confirm() is
|
||||
* banned in this UI. Arming one disarms the other, so a primed button cannot be fired by a
|
||||
* click meant for its neighbour. Each reload lands back on this same page — an ordinary route,
|
||||
* unlike the tab this once needed a sessionStorage flag to return to. */
|
||||
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();
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
twoClick(resetSavedBtn, RESET_TO_ROUTER, axes.resetToSaved);
|
||||
twoClick(resetBtn, _('Reset to built-in', 'footstrap'), axes.resetToBuiltin);
|
||||
refreshSave(); /* correct label and enabled state before the first paint */
|
||||
|
||||
const versionLink = E('a', {
|
||||
'class': 'fs-ap-version',
|
||||
'href': ver.REPO_URL,
|
||||
'target': '_blank',
|
||||
/* `noreferrer` alone: it implies noopener, and the theme's other outward links spell it
|
||||
* that way */
|
||||
'rel': 'noreferrer'
|
||||
}, [ ver.label() ]);
|
||||
|
||||
/* One clause per button, in LuCI's own help-sentence idiom and under the buttons themselves,
|
||||
* because that is where the question is asked: with all three named "save"/"reset" and two of
|
||||
* them resets, the row does not say which state each lands in (forum topic 251930, post 90 —
|
||||
* "what is the difference on 'reset to saved' and 'reset to default'?"). Three lines rather
|
||||
* than a paragraph: three parts want a list, and each is read on its own.
|
||||
*
|
||||
* Text nodes separated by <br>, NOT a child per line: `.cbi-value-field *` in
|
||||
* theme/60-inputs.css hands mono to every descendant and excludes `.cbi-value-description`
|
||||
* itself, so a wrapped line becomes a descendant that the exclusion does not reach — measured,
|
||||
* all three lines came out monospace. Text nodes inherit the sans face from the container, and
|
||||
* one container also means one `?` glyph with every line aligned under it. */
|
||||
const buttonHelp = E('div', { 'class': 'cbi-value-description' }, [
|
||||
_('Save to router — store this look on the router. Browsers with a look of their own keep it.', 'footstrap'),
|
||||
E('br'),
|
||||
_('Reset to router — drop this browser\'s changes and follow the router.', 'footstrap'),
|
||||
E('br'),
|
||||
_('Reset to built-in — go back to the look the theme ships with.', 'footstrap')
|
||||
]);
|
||||
|
||||
const defaults = [
|
||||
/* the one row whose control is a pair of buttons, each named by its own text, so `make`
|
||||
* ignores the caption rather than re-using it as an aria-label */
|
||||
group(_('Saved look', 'footstrap'),
|
||||
() => E('div', { 'class': 'fs-ap-actrow' }, [ saveBtn, E('span', { 'class': 'fs-ap-actgap', 'aria-hidden': 'true' }), resetSavedBtn, resetBtn ]),
|
||||
{ extra: [ buttonHelp, saveErr ] })
|
||||
];
|
||||
|
||||
|
||||
/* ---- the catalogue this router has not got ----
|
||||
*
|
||||
* The theme's translations are their own packages since 0.14.4, the way every `luci-app-*`
|
||||
* ships them, and nothing in a package manager can read `uci luci.main.lang` to fetch the right
|
||||
* one: apk learns it from `install-if` against `luci-i18n-base-<lang>` (owfeed.yml), opkg has no
|
||||
* conditional form of that at all, and a router upgraded from 0.14.3 through the feed simply
|
||||
* loses the catalogue that used to ride inside the theme (issue #41). `install.sh` covers its
|
||||
* own path; this covers the one nobody ran a script on.
|
||||
*
|
||||
* ASKED OF THE PAGE, not of the package list: the theme has no ubus call of its own and must not
|
||||
* grow one for this. `_()` returns its argument unchanged when no catalogue answers, so asking
|
||||
* for a string the catalogue certainly carries is the whole test.
|
||||
*
|
||||
* `Layout` is that string — a caption this very form renders, present in every catalogue under `po/`. A word
|
||||
* that only LOOKS certain is worse than no check: `Appearance` is in the source but obsolete in
|
||||
* the catalogues (`#~ msgid`), so testing it reported "not translated" on a router whose
|
||||
* Russian catalogue was installed and working.
|
||||
*
|
||||
* The language comes from the document, not from `L.env`, which carries no language field at
|
||||
* all — the dispatcher stamps `<html lang>` and that is what the page knows.
|
||||
*
|
||||
* Nothing is shown on an English or `auto` router, where there is no catalogue to miss. */
|
||||
const lang = (document.documentElement.getAttribute('lang') || '').trim();
|
||||
const untranslated = lang && lang !== 'en' && lang !== 'auto' &&
|
||||
_('Layout', 'footstrap') === 'Layout';
|
||||
const missing = untranslated ? E('div', { 'class': 'fs-ap-verrow fs-ap-i18n' }, [
|
||||
E('span', {}, [ _('This theme is not translated on this router yet.', 'footstrap') + ' ' ]),
|
||||
E('code', {}, [ 'luci-i18n-footstrap-' + lang ])
|
||||
]) : '';
|
||||
|
||||
defaults.push(E('div', { 'class': 'fs-ap-footer' }, [
|
||||
E('div', { 'class': 'fs-ap-verrow' }, [ versionLink ]),
|
||||
missing
|
||||
]));
|
||||
|
||||
/* not .cbi-section: inside a tab pane that is a card within a card, and the stock tabs put
|
||||
* their rows straight into the pane. These are grouping headings within one pane, styled by
|
||||
* pages/80-appearance.css. */
|
||||
const section = (title, rows) => E('div', { 'class': 'fs-ap-section' }, [
|
||||
E('div', { 'class': 'fs-ap-head' }, [ E('h4', {}, [ title ]) ])
|
||||
].concat(rows));
|
||||
|
||||
/* ---- the folded groups ----
|
||||
* Nine colour fields and an uploader are the widest rows on the page and most admins never
|
||||
* touch them, so each group is a disclosure, closed by default.
|
||||
*
|
||||
* A disclosure and not a switch: a switch answers "is this feature on", and turning it off
|
||||
* would either revert nine colours or change nothing at all. Opening or closing a fold applies,
|
||||
* un-applies and disables nothing.
|
||||
*
|
||||
* W3C APG disclosure pattern, as the menu's sections use: a <button> owning the region,
|
||||
* `aria-expanded` on it and `aria-controls` pointing at the panel. `hidden` on the panel rather
|
||||
* than a class, so a closed group leaves the tab order and the accessibility tree for free.
|
||||
*
|
||||
* The open/closed state is remembered per browser but is not an axis — it changes nothing about
|
||||
* how the page looks — so it is absent from AXIS_KEYS, snapshotAxes() and the pre-paint. */
|
||||
let foldSeq = 0;
|
||||
function foldable(title, rows, key) {
|
||||
const id = 'fs-ap-fold-' + (++foldSeq);
|
||||
let open = (prefs.lsGet(key) === 'on');
|
||||
/* id only: `aria-controls` needs one, and no rule has ever styled the panel itself */
|
||||
const body = E('div', { 'id': id }, rows);
|
||||
const btn = E('button', {
|
||||
'type': 'button', 'class': 'fs-ap-fold', 'aria-expanded': String(open), 'aria-controls': id
|
||||
}, [
|
||||
E('h4', {}, [ title ]),
|
||||
/* the same chevron the overview's card toggles draw: an empty box whose ::after is two
|
||||
* borders rotated 45° (pages/20-overview.css), not a second <svg> to keep in step.
|
||||
* Empty and aria-hidden — the state is the button's aria-expanded, which is also what
|
||||
* CSS rotates it off. */
|
||||
E('span', { 'class': 'fs-ap-chev', 'aria-hidden': 'true' })
|
||||
]);
|
||||
const paint = () => {
|
||||
body.hidden = !open;
|
||||
btn.setAttribute('aria-expanded', String(open));
|
||||
};
|
||||
btn.addEventListener('click', () => {
|
||||
open = !open;
|
||||
prefs.lsSet(key, open ? 'on' : 'off');
|
||||
paint();
|
||||
/* refreshed on open because the axes below may have moved while it was collapsed;
|
||||
* skipped while closed, where nothing is on screen to be wrong */
|
||||
if (open) refreshColours();
|
||||
});
|
||||
paint();
|
||||
return E('div', { 'class': 'fs-ap-section' }, [
|
||||
E('div', { 'class': 'fs-ap-head' }, [ btn ]), body
|
||||
]);
|
||||
}
|
||||
|
||||
/* Above the first section, because the misreading this once caused (the tab sat inside the stock
|
||||
* System form, so LuCI's own Save & Apply footer sat under a page it did not save — forum topic
|
||||
* 251930) happened before anyone scrolled to Defaults. The dispatched page has no such footer to
|
||||
* misread: view/footstrap/appearance.js nulls handleSave/handleSaveApply/handleReset, so
|
||||
* view.js's addFooter() renders nothing here, and the caveat sentence that once named it is
|
||||
* gone with the tab. A bare `.alert-message`: theme/35-alerts.css keeps the tinted variants for
|
||||
* a STATUS and a flat panel for a note. */
|
||||
const note = E('div', { 'class': 'alert-message fs-ap-note' }, [
|
||||
E('p', {}, [ _('Footstrap theme settings apply at once and are stored permanently in this browser.', 'footstrap') ])
|
||||
]);
|
||||
|
||||
/* Colours and Surfaces are one fold: the same job, split into two headings only because a
|
||||
* figure and the sheet it sits on are read differently */
|
||||
const page = E('div', { 'class': 'fs-ap' }, [
|
||||
note,
|
||||
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(_('Saving', 'footstrap'), defaults)
|
||||
]);
|
||||
|
||||
/* The first fill, deferred one microtask so the tree above is finished. It does not wait for
|
||||
* the form to be in the document: every readout resolves inside fs-widgets against a hidden
|
||||
* probe attached to <body>, so a detached form still reads the live palette. */
|
||||
Promise.resolve().then(refreshColours);
|
||||
return page;
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
/* the dispatched page's whole render(): view/footstrap/appearance.js calls this and nothing
|
||||
* else in this file — no mount, no observer, no deadline, no tab. A menu entry and a route did
|
||||
* that job instead; see the file's own header comment for what used to live here. */
|
||||
renderStandalone: () => Promise.resolve().then(build)
|
||||
});
|
||||
@@ -0,0 +1,555 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require rpc';
|
||||
'require ui';
|
||||
'require fs-axes as axes';
|
||||
|
||||
/* fs-assets — putting a file ON THE ROUTER, and taking it off again.
|
||||
*
|
||||
* Two uploads live here, the pattern tile and the login photo, and everything they need that the
|
||||
* rest of the theme does not: a DOMParser pass over an SVG, a canvas re-encode of a photo, the
|
||||
* chmod that makes a freshly written 0600 file servable, and the rollback that runs when the token
|
||||
* write fails after the bytes have landed.
|
||||
*
|
||||
* It is a module of its own because of WHERE it is needed: this machinery is reached only from the
|
||||
* Appearance tab, one page out of nearly two hundred, and it was ~4 KB of DOMParser, canvas and rpc
|
||||
* plumbing downloaded to a router's browser on the way to the DHCP page.
|
||||
*
|
||||
* The token accessors and the two live appliers live in `fs-axes` beside the axes themselves — the
|
||||
* Appearance previews and head.ut's pre-paint read the same fields — so this file requires that one
|
||||
* and nothing else of the theme's. */
|
||||
|
||||
/* `reject: true` is load-bearing: without it a refused write arrives as SUCCESS. rpc.js raises on
|
||||
* the ubus status code only when the declaration asks it to, and otherwise hands the code back as
|
||||
* the resolved value — measured on the router, a per-config ACL refusal resolves with 6
|
||||
* (permission denied) and every `.then()` below runs as if the file had been written, greying the
|
||||
* Save button over a write that never happened. */
|
||||
/* The four messages each said twice or three times below. Hoisted because a string literal is not
|
||||
* mangled, so every repeat is paid in full on flash — and because a message with two spellings is a
|
||||
* message that gets fixed in one of them. The msgid and its 'footstrap' context stay literal
|
||||
* arguments here, which is what update-po.sh's extractor reads. */
|
||||
const MSG_UPLOAD_FAILED = _('Upload failed.', 'footstrap');
|
||||
const MSG_NOT_SVG = _('That file is not an SVG image.', 'footstrap');
|
||||
const MSG_BAD_IMAGE = _('Could not process the image.', 'footstrap');
|
||||
const MSG_PICK_SVG = _('Please choose an SVG file.', 'footstrap');
|
||||
/* One template, two numbers, rather than composing "1 script, 3 external references" out of
|
||||
* pluralised fragments at run time: a translator gets one sentence to place the two %d's in,
|
||||
* instead of a jigsaw of "script"/"scripts" and "reference"/"references" that has to agree in
|
||||
* every language's own plural rule. */
|
||||
const MSG_SANITIZED = _('Cleaned before upload: removed %d unsafe element(s) and %d external reference(s).', 'footstrap');
|
||||
const MSG_NOTHING_LEFT = _('That SVG had nothing left once the unsafe parts were removed.', 'footstrap');
|
||||
|
||||
const _uciSet = rpc.declare({ object: 'uci', method: 'set', params: [ 'config', 'section', 'values' ], reject: true });
|
||||
const _uciCommit = rpc.declare({ object: 'uci', method: 'commit', params: [ 'config' ], reject: true });
|
||||
|
||||
/* ---- the pattern: an SVG the admin uploads, tiled and recoloured ----
|
||||
*
|
||||
* The bytes come from the admin, never from a third-party host: a theme in a package feed does not
|
||||
* reach out at run time.
|
||||
*
|
||||
* Router-side, like the login photo and for the same reason — a file cannot live in localStorage,
|
||||
* and a pattern is something a router wears. The path is a fixed server-side constant matched
|
||||
* exactly by the rpcd ACL, so nothing user-controlled reaches a path. It lives under /etc so a
|
||||
* package upgrade cannot delete it (keep.d carries it across a sysupgrade). It keeps the .svg name
|
||||
* for its own sake — the CGI handler that now serves it (root/www/cgi-bin) sets Content-Type
|
||||
* itself and does not rely on uhttpd's by-extension typing the way the login photo's /www symlink
|
||||
* still does.
|
||||
*
|
||||
* How it is made to fit is 15-wallpaper.css's mask, not anything done to the bytes: the file
|
||||
* supplies the alpha and the theme the colour, so one upload reads correctly in both modes and
|
||||
* under every palette.
|
||||
*
|
||||
* What is cleaned, and why cleaning is honest here: an SVG is a document, not a picture, and while
|
||||
* a masked or background image never executes script, the same file fetched from its own URL would
|
||||
* (OpenWrt forum thread 251930). THIS FILE IS NOT THE SECURITY BOUNDARY and none of what follows
|
||||
* changes that: the rpcd ACL authorises the cgi-upload POST regardless of what wrote it, so a `curl`
|
||||
* straight to the endpoint skips every check below, and what actually stops the file executing if
|
||||
* it is ever opened directly is the server's own response headers — the CGI handler
|
||||
* (root/www/cgi-bin/luci-theme-footstrap-pattern) sends `Content-Security-Policy: default-src
|
||||
* 'none'; sandbox` on every request for it, upload path or not. That is true of the bytes this
|
||||
* module hands to `fetch()` whether they are the admin's original file, a cleaned copy, or (before
|
||||
* this pass existed) a refusal that never reached the server at all. So sanitizing changes
|
||||
* CONVENIENCE, not the threat model: an admin whose export carries a stray handler or an external
|
||||
* `url()` — an editor artefact, not a deliberate payload in the overwhelming case — gets a working
|
||||
* tile and a note about what was taken out, instead of a refusal they have to go and hand-edit
|
||||
* their way around. */
|
||||
const PAT_PATH = '/etc/footstrap/pattern.svg'; /* cgi-upload target; the ACL grants exactly this */
|
||||
const PAT_MAX = 512 * 1024; /* a tile that has to reach a router's flash and then every page load */
|
||||
/* What a cleaned SVG has had taken out of it, decided on the PARSED document and not on its text: a
|
||||
* regex over the source guesses at a grammar the browser already implements, and guesses in both
|
||||
* directions — a handler pattern also matches an ordinary `only_selected="false"`, while an entity
|
||||
* or odd whitespace hides a real handler from it.
|
||||
*
|
||||
* DOMParser is the parser the file will actually be read by, and parsing is inert: no script runs,
|
||||
* no subresource is fetched, no handler is bound. So the questions are exact ones about nodes, and
|
||||
* `_sanitizeSvg` below asks all of them in the SAME walk that removes what fails one:
|
||||
*
|
||||
* - is it an SVG at all (a parsererror, or a root that is not <svg>, is not an image — refused,
|
||||
* not cleanable: there is no document to clean)
|
||||
* - does it carry an element that executes or embeds (script, foreignObject, iframe, style, …) —
|
||||
* the element is removed
|
||||
* - does it carry a real event-handler attribute — `^on[a-z]+$` — the attribute is removed
|
||||
* - does any value start a `javascript:` url — the attribute is removed
|
||||
* - does any href point off this router — a leading `//`, or a `data:`/`blob:`/`about:` payload —
|
||||
* the attribute is removed
|
||||
* - does an element carry `xml:base` — it moves where every relative reference under it resolves,
|
||||
* so it is removed regardless of what it points to
|
||||
* - does a `style` attribute reach off this document — `url(` naming anything but a same-document
|
||||
* fragment, `@import`, or `expression(` — that ONE DECLARATION is dropped, the rest of the
|
||||
* value kept
|
||||
*
|
||||
* The check is for the way the file can be reached that a mask does not cover: its own URL, opened
|
||||
* directly, same-origin with the session.
|
||||
*
|
||||
* `animate`/`set` are listed for a second reason as well: they can retarget an attribute at run
|
||||
* time (`<set attributename="href" to="javascript:…">`), and a tile that animates repaints a
|
||||
* full-viewport layer behind every page.
|
||||
*
|
||||
* `<style>` is removed whole, as an ELEMENT (PAT_BAD_TAGS): `<style>@import
|
||||
* url("//attacker/x.css")</style>` reaches off-router with no script and no href at all, a tile has
|
||||
* no legitimate use for a stylesheet, and parsing its CSS to tell a safe one from a dangerous one
|
||||
* would be exactly the grammar-guessing this file avoids everywhere else — so the element is taken
|
||||
* out without being read.
|
||||
*
|
||||
* A `style` ATTRIBUTE is narrower, because it is common where the element is not: a vector editor
|
||||
* writes one on every shape it draws, and an Inkscape export measured for this fix carried 278 of
|
||||
* them — `style="fill:#100f0d;fill-opacity:1;fill-rule:nonzero;stroke:none"` — not one containing a
|
||||
* reference of any kind. Removing the attribute outright would touch nearly every real upload for
|
||||
* nothing a mask does not already stop, so `_sanitizeStyleValue` below splits it on `;` and drops
|
||||
* only the declaration that reaches off this document: `url(` naming anything but a `#fragment`
|
||||
* inside the SAME svg, `@import`, or a CSS `expression(` (legacy IE, parsed by nothing shipping
|
||||
* today, dropped anyway for the same reason `data:`/`blob:`/`about:` are below — the grammar to
|
||||
* tell a dead syntax from a live one in every future engine is not worth having). `url(#gradient)`
|
||||
* is how ordinary SVG gradients and clip-paths are written and is kept; a relative or absolute
|
||||
* `url(…)` is not, because it is a second fetch and not "into this document" either way. The match
|
||||
* is case-insensitive and tolerant of whitespace before the `(` — CSS itself is stricter about the
|
||||
* second one, but a check a differently-spaced upload slips past is not a check, and the cost of
|
||||
* dropping a declaration that could not have run anyway is one line fewer of `style`, not a hole.
|
||||
*
|
||||
* `data:`/`blob:`/`about:` were allowed
|
||||
* through `href`/`xlink:href` before this pass, on the theory that a tile only ever uses `data:` to
|
||||
* embed its own bitmap — closed anyway, because the same scheme admits `data:text/html` and
|
||||
* `data:image/svg+xml`, and telling those apart from a `data:image/png` bitmap means parsing the
|
||||
* URI's own MIME token, which is exactly the kind of grammar-guessing this file avoids everywhere
|
||||
* else. The cost is real: an admin embedding a bitmap inside the tile now needs a second uploaded
|
||||
* asset referenced by a same-origin URL instead of a `data:` href — the tile itself is unaffected,
|
||||
* since 15-wallpaper.css never used one. */
|
||||
const PAT_BAD_TAGS = [ 'script', 'foreignobject', 'iframe', 'embed', 'object', 'audio', 'video', 'animate', 'set', 'style' ];
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
/* CSS Syntax Level 3 §4.3.7: `\` + 1-6 hex digits (+ one optional trailing whitespace that ends the
|
||||
* escape) decodes to that code point, and `\` + any other character is that character literally.
|
||||
* Security review finding (LOW): `_externalStyleRef` used to match the literal word `url(`, so a
|
||||
* hex-escaped function name — `u\72l(//evil.example/x)`, `\72` being 'r' — reached a real CSS
|
||||
* tokenizer as an ordinary `url()` while the literal match never saw it. Decoding first closes it
|
||||
* without a second grammar to maintain: once the escape is gone, the same literal match is exactly
|
||||
* what a browser would have resolved. */
|
||||
function _cssUnescape(v) {
|
||||
return v.replace(/\\([0-9a-fA-F]{1,6})[ \t\n\r\f]?|\\([^\r\n\f])/g,
|
||||
(m, hex, lit) => (hex !== undefined ? String.fromCodePoint(parseInt(hex, 16)) : lit));
|
||||
}
|
||||
|
||||
/* True if a CSS declaration reaches off this document — see the comment above PAT_BAD_TAGS for
|
||||
* what is and is not admitted and why. `url(` is matched everywhere it occurs, because one
|
||||
* declaration can itself carry more than one (a shorthand, or a fallback list), and a single
|
||||
* external reference among same-document ones is still external. Case-insensitive and tolerant of
|
||||
* whitespace before `(` on purpose: this is the removing side of the check, so matching MORE than
|
||||
* the CSS grammar strictly allows costs a dropped declaration that could not have run anyway, while
|
||||
* matching less would let a spacing or casing trick back through. */
|
||||
function _externalStyleRef(v) {
|
||||
v = _cssUnescape(v);
|
||||
if ((/@import\b/i).test(v) || (/expression\s*\(/i).test(v)) return true;
|
||||
const urlRe = /url\s*\(\s*(['"]?)\s*([^)\s'"]*)/gi;
|
||||
let m;
|
||||
while ((m = urlRe.exec(v)) !== null)
|
||||
if (m[2].charAt(0) !== '#') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/* A `style` value with every off-document DECLARATION dropped and the rest kept verbatim — see the
|
||||
* comment above PAT_BAD_TAGS for the `style` ATTRIBUTE's narrower treatment. Splitting and
|
||||
* rejoining on `;` is only ever done when something is actually being removed: a clean value never
|
||||
* reaches this function's caller with `removed` counted, so it never gets rewritten either (point 2
|
||||
* of the sanitize pass — see `_sanitizeSvg`). */
|
||||
function _sanitizeStyleValue(v) {
|
||||
let removed = 0;
|
||||
const kept = v.split(';').filter((raw) => {
|
||||
const d = raw.trim();
|
||||
if (!d) return true;
|
||||
if (_externalStyleRef(d)) { removed++; return false; }
|
||||
return true;
|
||||
});
|
||||
return { value: kept.join(';'), removed };
|
||||
}
|
||||
|
||||
/* One walk that both judges the document's shape and cleans its content — a second traversal that
|
||||
* could disagree with this one is exactly the risk a duplicate check runs (openwrt/luci#8981's
|
||||
* lesson applied here). Returns `{ error }` for the four things that cannot be cleaned honestly
|
||||
* (not an SVG, a parser error, the wrong namespace, nothing left once the unsafe parts are gone),
|
||||
* or `{ text, elements, refs }` — `text` is the ORIGINAL string, untouched, when `elements` and
|
||||
* `refs` are both 0: a round-trip through XMLSerializer can rewrite namespaces, entities and
|
||||
* whitespace, which on a 138 KB tile is silent corruption nobody notices until it is on a router, so
|
||||
* serializing only happens when something was actually removed. */
|
||||
function _sanitizeSvg(text) {
|
||||
let doc;
|
||||
try { doc = new DOMParser().parseFromString(text, 'image/svg+xml'); }
|
||||
catch (e) { return { error: MSG_NOT_SVG }; }
|
||||
const root = doc && doc.documentElement;
|
||||
/* An SVG is its ROOT'S NAMESPACE, not its root's spelling. `nodeName` is the qualified name, so
|
||||
* it answers both questions wrong at once: `<svg xmlns="http://www.w3.org/1999/xhtml">` reads as
|
||||
* `svg` and is admitted although it is an XHTML document that executes on all three engines,
|
||||
* while `<s:svg xmlns:s="http://www.w3.org/2000/svg">` reads as `s:svg` and is turned away
|
||||
* although it is an ordinary picture. */
|
||||
if (!root || doc.querySelector('parsererror') ||
|
||||
root.localName.toLowerCase() !== 'svg' || root.namespaceURI !== SVG_NS)
|
||||
return { error: MSG_NOT_SVG };
|
||||
|
||||
let elements = 0, refs = 0;
|
||||
|
||||
/* A processing instruction can attach an XSLT stylesheet carried INSIDE this same document, and
|
||||
* the transform's output is a document this walk never sees: `<xsl:element name="script">`
|
||||
* builds the element by name, so nothing here is called script. Measured executing on Firefox
|
||||
* (Chromium and WebKit decline to run XSLT on an image/svg+xml document). A tile has no use for
|
||||
* one, and without the PI the embedded stylesheet is never applied. */
|
||||
|
||||
/* Residual, verified NOT exploitable here (2026-09 security review): a prologue of
|
||||
* `<!DOCTYPE svg SYSTEM "http://evil.example/x.dtd">` is a document-level construct this walk
|
||||
* never visits — `doc.doctype` is left as parsed, the same as everything else here that is not an
|
||||
* element or an attribute — and it survives `XMLSerializer` byte for byte on the same round trip
|
||||
* that runs below when anything else on the document needs cleaning. It costs nothing today
|
||||
* because no shipping engine resolves an external DTD for any document at all, `image/svg+xml`
|
||||
* included (external entity resolution has been off since the XXE era); a browser that ever did
|
||||
* would only reach that fetch by opening the file directly, which the CGI answers with
|
||||
* `Content-Security-Policy: default-src 'none'; sandbox` — every other consumer sees this bundle
|
||||
* only as a `mask-image`/`background-image`/`<img>` source, none of which parses a DOCTYPE at all.
|
||||
* Not closed: dropping `doc.doctype` would carve out one more node type in a walk that otherwise
|
||||
* judges only elements and their attributes, for a fetch nothing in this project's support matrix
|
||||
* performs. Starts mattering the day some consumer of this bundle DOES resolve an external DTD, or
|
||||
* the file is ever served as a navigable document without that header. */
|
||||
for (const n of [ ...doc.childNodes ])
|
||||
if (n.nodeType === Node.PROCESSING_INSTRUCTION_NODE) { n.remove(); elements++; }
|
||||
|
||||
/* Snapshot before mutating: querySelectorAll answers once, and removing a bad element also
|
||||
* detaches everything under it — `isConnected` is how the loop tells a still-live descendant
|
||||
* from one whose parent already left in an earlier turn of this same loop. */
|
||||
const els = [ root ].concat([ ...root.querySelectorAll('*') ]);
|
||||
for (const el of els) {
|
||||
if (el !== root && !el.isConnected) continue;
|
||||
/* localName, never nodeName: in an XML document nodeName carries the namespace PREFIX, so
|
||||
* `<s:script xmlns:s="http://www.w3.org/2000/svg">` reads as `s:script` and walks straight
|
||||
* past a list of names — measured executing on all three engines, as does the same element
|
||||
* put in the xhtml namespace. localName is `script` for every one of those spellings. */
|
||||
if (PAT_BAD_TAGS.indexOf((el.localName || el.nodeName).toLowerCase()) >= 0) {
|
||||
el.remove();
|
||||
elements++;
|
||||
continue;
|
||||
}
|
||||
/* A snapshot, not a live walk: removing an attribute while indexing the live NamedNodeMap
|
||||
* shifts every index after it, the same trap a live NodeList sets for element removal above. */
|
||||
const attrs = [];
|
||||
for (let i = 0; el.attributes && i < el.attributes.length; i++)
|
||||
attrs.push({ name: el.attributes[i].name, value: el.attributes[i].value });
|
||||
for (const a of attrs) {
|
||||
const n = a.name.toLowerCase();
|
||||
const v = String(a.value || '').trim();
|
||||
/* a REAL handler is `on` + letters and nothing else; `only_selected` is not one. The
|
||||
* qualified name is right here, unlike on the element above: a prefixed `s:onload` or
|
||||
* `xlink:onload` fires on none of the three engines, so matching localName would only
|
||||
* strip attributes that do nothing.
|
||||
* Verified in the 2026-09 security review, including the case where the prefix is bound
|
||||
* right back to the SVG namespace itself (`s:onload` under `xmlns:s="…/2000/svg"`): SVG
|
||||
* defines its event-handler attributes in NO namespace at all, not "the SVG namespace",
|
||||
* so a prefixed spelling never binds as a listener regardless of what the prefix
|
||||
* resolves to. That is a platform guarantee, not this file's, and would stop holding only
|
||||
* if some future consumer bound SVG event attributes by namespace instead of the empty
|
||||
* one the spec gives them — a change to how SVG itself is interpreted, not something a
|
||||
* check here could see coming or catch. */
|
||||
if ((/^on[a-z]+$/).test(n)) { el.removeAttribute(a.name); refs++; continue; }
|
||||
/* Residual, verified NOT exploitable here (2026-09 security review): XML attribute-value
|
||||
* normalisation does not fold a numeric character reference the way HTML does, so
|
||||
* `href="javas	cript:alert(1)"` reaches `v` as a DOM string with a LITERAL TAB still in
|
||||
* it — `/^javascript:/i` does not match a value starting with a control character, and the
|
||||
* fixture round-trips through `XMLSerializer` byte for byte, so it survives both this
|
||||
* filter and the serializer. It still resolves to `javascript:` in a real browser, because
|
||||
* a URL parser strips TAB/LF/CR from the scheme before comparing it (WHATWG URL, "scheme
|
||||
* start state") — the same class of hole every naive `^scheme:` regex has. Not closed:
|
||||
* matching every control character a URL parser discards is the grammar-guessing this file
|
||||
* exists to avoid, for a value that never resolves as a URL here in the first place — this
|
||||
* bundle only ever leaves the router as `mask-image`/`background-image`/`<img>`, none of
|
||||
* which reads `href` as navigable, and the CGI serving it back for a direct open sends
|
||||
* `Content-Security-Policy: default-src 'none'; sandbox` regardless. Starts mattering the
|
||||
* day the tile is served without that header, or inlined into a page rather than
|
||||
* referenced as an image. */
|
||||
if ((/^javascript:/i).test(v)) { el.removeAttribute(a.name); refs++; continue; }
|
||||
/* Security review finding (LOW): `xml:base` (XML Base, W3C) is not itself a reference —
|
||||
* it changes what every RELATIVE one in the subtree UNDER IT resolves against. A perfectly
|
||||
* relative `href="x.png"` inside `<svg xml:base="http://evil.example/">` fetches from
|
||||
* evil.example, and the href check below never sees an absolute URL to catch, because
|
||||
* there isn't one in that attribute — the base moved instead. Removing the ONE attribute
|
||||
* that redirects resolution closes href, xlink:href and a `style` url()'s base alike at
|
||||
* once, the same way `<style>` above is removed whole rather than parsed for a safe half:
|
||||
* a tile has no legitimate use for an alternate base either. Checked on EVERY element via
|
||||
* this same walk, not only the root — XML Base is re-settable at any depth. Matched on the
|
||||
* literal `xml:` prefix, not a resolved namespace, same as every other attribute check
|
||||
* here: `xml` needs no `xmlns:xml` declaration to mean this (Namespaces in XML §4), so the
|
||||
* qualified name alone is the realistic case; an export that goes on to alias some OTHER
|
||||
* prefix to the XML namespace just to carry it is a narrower attack this does not chase,
|
||||
* consistent with this file avoiding a second grammar it would have to maintain. */
|
||||
if (n === 'xml:base') { el.removeAttribute(a.name); refs++; continue; }
|
||||
/* Residual, verified NOT exploitable here (2026-09 security review): the branch below is
|
||||
* the ONLY place a `url(` is ever inspected. A PRESENTATION ATTRIBUTE that takes the same
|
||||
* function — `fill`, `stroke`, `filter`, `clip-path`, `mask`, `cursor` (SVG 1.1 §11, §15) —
|
||||
* is a plain attribute value to this walk, not a CSS declaration, so `<rect
|
||||
* fill="url(//evil.example/x.svg#g)"/>` matches nothing above and passes with its ORIGINAL
|
||||
* bytes untouched. What it can do is start a second fetch — no script, no navigation,
|
||||
* exactly the reach `_externalStyleRef` already closes for `style` — and that fetch runs
|
||||
* only where the SVG is rendered with its own resource-loading context, which this bundle
|
||||
* never is: it is only ever a `mask-image`/`background-image`/`<img>` source, and a direct
|
||||
* open is answered by the CGI's `Content-Security-Policy: default-src 'none'; sandbox`
|
||||
* regardless. Not closed: doing here what `_sanitizeStyleValue` does for `style` means the
|
||||
* same check against every presentation attribute the SVG spec lets carry a `url()`, a list
|
||||
* this file would then have to track as the spec grows it — the grammar this file exists to
|
||||
* avoid guessing at, moved from one attribute to a dozen. Starts mattering the day a
|
||||
* presentation `url()` gets a fetch path this parser-only pass does not already block by
|
||||
* other means, or the tile stops being image-only. */
|
||||
if (n === 'style') {
|
||||
const cleaned = _sanitizeStyleValue(v);
|
||||
if (cleaned.removed) {
|
||||
if (cleaned.value.trim()) el.setAttribute(a.name, cleaned.value);
|
||||
else el.removeAttribute(a.name);
|
||||
refs += cleaned.removed;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ((/(?:^|:)href$/).test(n)) {
|
||||
/* off-router reference (a leading `//` is protocol-relative and just as external), or
|
||||
* a scheme that can carry a second document behind what looks like a same-document
|
||||
* reference — see the comment above PAT_BAD_TAGS for why this is a blanket removal
|
||||
* and not a MIME-token check. */
|
||||
if ((/^(?:[a-z][a-z0-9+.-]*:)?\/\//i).test(v) || (/^(?:data|blob|about):/i).test(v))
|
||||
{ el.removeAttribute(a.name); refs++; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!(elements + refs)) return { text }; /* untouched: hand back the ORIGINAL bytes */
|
||||
if (!root.children || root.children.length === 0) return { error: MSG_NOTHING_LEFT };
|
||||
return { text: new XMLSerializer().serializeToString(doc), elements, refs };
|
||||
}
|
||||
|
||||
/* read the picked file as text so it can be inspected before upload, and so what reaches the
|
||||
* router is exactly the bytes that were checked */
|
||||
function _readText(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = () => resolve(String(fr.result || ''));
|
||||
fr.onerror = () => reject(new Error(_('That file could not be read.', 'footstrap')));
|
||||
fr.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- login/page background upload: router-side, and deliberately not an axis ----
|
||||
* The other axes are per-browser with a router default; this one has no browser layer. An admin
|
||||
* uploads an image once, it becomes the router-wide background for every device and shows
|
||||
* pre-login, so it is absent from AXIS_KEYS, snapshotAxes() and matchesSavedDefault() — it must not
|
||||
* move the Save button — and needs no factory, so tools/axes.mjs never sees it.
|
||||
*
|
||||
* The image is a served file, uhttpd having no gzip to make inlining it in every <head> viable;
|
||||
* only its cache-bust token lives in uci -> window.__fsSD -> the url() head.ut stamps. The path is
|
||||
* a fixed server-side constant matched exactly by the rpcd ACL, so nothing user-controlled reaches
|
||||
* a path. */
|
||||
const BG_PATH = '/etc/footstrap/login-bg'; /* cgi-upload target; the ACL grants exactly this */
|
||||
const BG_MAX_SIDE = 1920; /* cap the longest side — a router serves this off flash with no gzip, and 1080p covers the screens LuCI is actually admin'd from; still crisp full-screen, far fewer flash/wire bytes */
|
||||
const BG_QUALITY = 0.9;
|
||||
const BG_SRC_MAX = 25 * 1024 * 1024; /* refuse a source this big before decoding (decode-bomb guard) */
|
||||
/* No `reject: true` here, unlike every other declare in this file: with it, "the file was already
|
||||
* gone" and "the router refused to delete it" arrive as the same Error. Without it the promise
|
||||
* resolves with the ubus status as a number, which this code can branch on. */
|
||||
const _fileRemoveStatus = rpc.declare({ object: 'file', method: 'remove', params: [ 'path' ] });
|
||||
|
||||
/* Delete, treating "not found" as done. Anything else is a real refusal (a read-only or full
|
||||
* overlay, an immutable flag, a path replaced by a directory) and must not be reported as a
|
||||
* removal: the file stays on flash and stays fetchable WITHOUT a session through the /www symlink,
|
||||
* which is what an admin removing a background believes they have stopped. */
|
||||
const UBUS_NOT_FOUND = 4;
|
||||
function _removeServed(path) {
|
||||
return _fileRemoveStatus(path).then((res) => {
|
||||
const code = (typeof res === 'number') ? res : parseInt(res, 10);
|
||||
if (code === 0 || code === UBUS_NOT_FOUND || isNaN(code)) return;
|
||||
return Promise.reject(new Error(
|
||||
_('The router refused to delete the file (ubus status %d).', 'footstrap').format(code)));
|
||||
});
|
||||
}
|
||||
/* cgi-upload writes the file 0600 and uhttpd refuses to serve a file that is not world-readable
|
||||
* (0600 -> 403, 0644 -> 200), so make it 0644 first. The rpcd ACL grants exec on exactly two fixed
|
||||
* commands — chmod 644 on the two files this module uploads — with no caller-controlled
|
||||
* argument. */
|
||||
const _fileExec = rpc.declare({ object: 'file', method: 'exec', params: [ 'command', 'params' ], reject: true });
|
||||
/* …and the ubus status is only half of it: `file.exec` reports the command's exit status inside the
|
||||
* payload, so a chmod that ran and failed still comes back as a successful call — and the upload
|
||||
* then reports success for a file uhttpd will 403, leaving every device a scrim over nothing. */
|
||||
function _chmodServeable(path) {
|
||||
return _fileExec('/bin/chmod', [ '644', path ]).then((res) => {
|
||||
if (res && res.code)
|
||||
throw new Error(MSG_UPLOAD_FAILED + ' (chmod ' + res.code + ')');
|
||||
return res;
|
||||
});
|
||||
}
|
||||
/* Re-encode the picked image to a bounded JPEG on a canvas. A security step as much as a size one:
|
||||
* the canvas keeps only the decoded pixels, so EXIF and any bytes appended past the image are
|
||||
* dropped and the uploaded blob is exactly what the browser drew.
|
||||
*
|
||||
* The whole body is guarded, because a throw inside an event handler does not reject the promise it
|
||||
* sits in — it escapes as an uncaught error and leaves the promise pending forever. Two real ways
|
||||
* out of `onload`: `getContext('2d')` answers null when the canvas cannot be backed, and
|
||||
* drawImage/toBlob can throw. A pending promise leaves the caller's "Uploading…" button disabled
|
||||
* and lying until the form is rebuilt on a later arrival at the page. */
|
||||
function _downscale(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
try {
|
||||
const scale = Math.min(1, BG_MAX_SIDE / Math.max(img.width, img.height));
|
||||
const w = Math.max(1, Math.round(img.width * scale));
|
||||
const h = Math.max(1, Math.round(img.height * scale));
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = w; cv.height = h;
|
||||
const ctx = cv.getContext('2d');
|
||||
if (!ctx) throw new Error('no 2d context');
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
cv.toBlob((blob) => blob ? resolve(blob) : reject(new Error(MSG_BAD_IMAGE)),
|
||||
'image/jpeg', BG_QUALITY);
|
||||
} catch (e) { reject(new Error(MSG_BAD_IMAGE)); }
|
||||
};
|
||||
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error(_('That file is not a readable image.', 'footstrap'))); };
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/* An upload that has landed but could not be RECORDED must not stay on the router. The two paths
|
||||
* below write the file first and the token second, and the second half can fail on its own (no
|
||||
* `settings` section, a narrowed uci ACL, ubus busy) — the image then sits at mode 0644 and is
|
||||
* served to anyone through the /www symlink, which does not depend on the token, while Remove is
|
||||
* hidden precisely because the token is empty. Roll the file back and report the failure that
|
||||
* started it; a rollback that itself fails is appended, because the admin has to know the file is
|
||||
* there. */
|
||||
function _rollbackUpload(path, cause) {
|
||||
return _removeServed(path).then(
|
||||
() => Promise.reject(cause),
|
||||
() => Promise.reject(new Error(String((cause && cause.message) || cause) + ' — '
|
||||
+ _('the uploaded file could not be removed either; it is still on the router.', 'footstrap')))
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- one upload, two assets ----
|
||||
*
|
||||
* Both wallpapers travel the same road: refuse what should not be sent, turn the picked file into
|
||||
* the bytes that will actually be stored, POST them to cgi-upload, take the md5 `checksum` back as
|
||||
* the cache-bust token, make the file servable, write the token to uci, and only then paint it.
|
||||
* Every step of that was written out twice, and the two copies had already drifted — one quoted
|
||||
* the url() it wrote with `"` and the other with `'`.
|
||||
*
|
||||
* What genuinely differs is one function: what `prepare` hands back to be uploaded. The SVG is read
|
||||
* as text and inspected, because an SVG is a document and the check has to see the parsed tree; the
|
||||
* photo is redrawn on a canvas, which both bounds it and drops EXIF, because a raster has nothing
|
||||
* to inspect. Everything either side of that is the same road.
|
||||
*
|
||||
* `rollback` is the reason the order matters. The bytes land before the token does, and the second
|
||||
* half can fail on its own — no `settings` section, a narrowed uci ACL, ubus busy — leaving a file
|
||||
* at 0644 served through the /www symlink while Remove stays hidden, because Remove keys off the
|
||||
* token being non-empty. So a failure after the write takes the file away again. */
|
||||
function assetAxis(o) {
|
||||
const upload = (file) => Promise.resolve()
|
||||
.then(() => o.prepare(file))
|
||||
.then((blob) => {
|
||||
const fd = new FormData();
|
||||
fd.append('sessionid', rpc.getSessionID());
|
||||
fd.append('filename', o.path);
|
||||
fd.append('filedata', blob, o.filename);
|
||||
return fetch(L.env.cgi_base + '/cgi-upload',
|
||||
{ method: 'POST', body: fd, credentials: 'same-origin' })
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status))));
|
||||
})
|
||||
.then((reply) => {
|
||||
/* cgi-upload answers { name, size, checksum, sha256sum } or { failure: [code, msg] } */
|
||||
if (!reply || reply.failure)
|
||||
return Promise.reject(new Error((reply && reply.failure && reply.failure[1])
|
||||
|| MSG_UPLOAD_FAILED));
|
||||
const tok = String(reply.checksum || '').toLowerCase();
|
||||
if (!axes.tokenOk(tok)) return Promise.reject(new Error(MSG_UPLOAD_FAILED));
|
||||
/* cgi-upload writes 0600 and uhttpd refuses to serve a file that is not world-readable
|
||||
* (0600 -> 403, 0644 -> 200); _chmodServeable checks the command's exit status, not
|
||||
* just the ubus call's */
|
||||
return _chmodServeable(o.path)
|
||||
/* uci gets the token and nothing else: putting a file on the router is not the same
|
||||
* act as making every other device paint it */
|
||||
.then(() => _uciSet('footstrap', 'settings', { [o.field]: tok }))
|
||||
.then(() => _uciCommit('footstrap'))
|
||||
.catch((e) => _rollbackUpload(o.path, e))
|
||||
.then(() => {
|
||||
/* switch this browser onto it: the ordinary axis path, localStorage only */
|
||||
axes.applyWallpaper(o.wallpaper);
|
||||
o.apply(tok);
|
||||
return tok;
|
||||
});
|
||||
});
|
||||
|
||||
/* Remove: delete the file, blank the token (uci `set` to '', not delete — the scoped ACL grants
|
||||
* set/commit only), clear the url() live. */
|
||||
const remove = () => _removeServed(o.path)
|
||||
.then(() => _uciSet('footstrap', 'settings', { [o.field]: '' }))
|
||||
.then(() => _uciCommit('footstrap'))
|
||||
.then(() => { o.apply(''); });
|
||||
|
||||
return { upload, remove };
|
||||
}
|
||||
|
||||
/* The tile. No canvas step, which is what strips a photo's EXIF: an SVG redrawn to a canvas comes
|
||||
* back a raster, so the parsed-document check above stands in for it. */
|
||||
const PATTERN = assetAxis({
|
||||
path: PAT_PATH, filename: 'pattern.svg', field: 'pattern', wallpaper: 'pattern',
|
||||
apply: (tok) => axes.applyPattern(tok),
|
||||
prepare: (file) => {
|
||||
if (!file) return Promise.reject(new Error(MSG_PICK_SVG));
|
||||
const isSvg = (/(^image\/svg\+xml$)/i).test(file.type || '') || (/\.svg$/i).test(file.name || '');
|
||||
if (!isSvg) return Promise.reject(new Error(MSG_PICK_SVG));
|
||||
if (file.size > PAT_MAX) return Promise.reject(new Error(_('That file is too large.', 'footstrap')));
|
||||
return _readText(file).then((text) => {
|
||||
const cleaned = _sanitizeSvg(text);
|
||||
if (cleaned.error) return Promise.reject(new Error(cleaned.error));
|
||||
/* Told once, here, regardless of whether the upload that follows succeeds — what left
|
||||
* the file is true independently of the network call, and `prepare()` has no channel
|
||||
* back to the caller past the Blob it returns. `ui.addNotification` is the stock banner
|
||||
* every other runtime notice in the theme already uses (fs-router.js). */
|
||||
if (cleaned.elements || cleaned.refs)
|
||||
ui.addNotification(null, E('p', {}, [ MSG_SANITIZED.format(cleaned.elements, cleaned.refs) ]), 'info');
|
||||
return new Blob([ cleaned.text ], { type: 'image/svg+xml' });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/* The photo. cgi-upload is the endpoint L.ui.uploadFile uses — session in the `sessionid` field,
|
||||
* path in `filename`, bytes in `filedata` — and it authorises the write against the ACL's `file`
|
||||
* grant for BG_PATH. */
|
||||
const LOGIN_BG = assetAxis({
|
||||
path: BG_PATH, filename: 'login-bg', field: 'login_bg', wallpaper: 'file',
|
||||
apply: (tok) => axes.applyLoginBg(tok),
|
||||
prepare: (file) => {
|
||||
if (!file || !(/^image\//).test(file.type || ''))
|
||||
return Promise.reject(new Error(_('Please choose an image file.', 'footstrap')));
|
||||
if (file.size > BG_SRC_MAX)
|
||||
return Promise.reject(new Error(_('That image is too large.', 'footstrap')));
|
||||
return _downscale(file);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return baseclass.extend({
|
||||
uploadPattern: PATTERN.upload,
|
||||
removePattern: PATTERN.remove,
|
||||
uploadLoginBg: LOGIN_BG.upload,
|
||||
removeLoginBg: LOGIN_BG.remove,
|
||||
_sanitizeSvg, /* fs:probe */
|
||||
});
|
||||
@@ -0,0 +1,515 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require rpc';
|
||||
'require fs-prefs as prefs';
|
||||
'require fs-fit as fit';
|
||||
|
||||
/* fs-axes — the nineteen Appearance axes and the Save-as-default machinery.
|
||||
*
|
||||
* Split out of fs-prefs.js for one reason: WHERE it is needed. `fs-prefs` is required by the
|
||||
* chrome, the menu and the search palette, so it is fetched on every admin page — and of its
|
||||
* sixty-one exports the cold path called eight. Everything here is reached only from
|
||||
* `fs-appearance` (the form) and `fs-assets` (the two uploads), both page modules, so the router
|
||||
* fetches it on the Appearance tab and nowhere else. Measured: 6.6 KB off what every page
|
||||
* downloads.
|
||||
*
|
||||
* What stayed behind in `fs-prefs`, and why:
|
||||
* - the localStorage wrappers and `sd()`, which everything here calls through `prefs.`
|
||||
* - dark mode, because `guardDarkStamp` defends against a third-party app on every page and
|
||||
* `tools/chrome-fence.mjs` holds `stampDark()` to that file by path
|
||||
* - layout, density, rail and auto-collapse, because the chrome and the menu apply them live —
|
||||
* and because `tools/scroll-anchor.mjs` and `tools/scroll-jank.mjs` stamp layout and density
|
||||
* through `L.require('fs-prefs')` to sweep their matrix
|
||||
*
|
||||
* The pre-paint in head.ut has already stamped every axis before the first frame, so nothing here
|
||||
* is needed to PAINT a page correctly — only to change one from the form. tools/axes.mjs reads the
|
||||
* whole resources directory rather than a path, precisely so an axis may live in a second file. */
|
||||
|
||||
const FS_RADIUS_DEFAULT = 12;
|
||||
|
||||
const FS_HEX_RE = /^#[0-9a-f]{6}$/i;
|
||||
/* 0 | 1..360 | '#rrggbb', from anything: localStorage (always a string), the router default (a uci
|
||||
* string, or a number from a config written before the axes took colours) or a caller. Anything
|
||||
* unrecognised reads as off, the built-in default. */
|
||||
function normColor(v) {
|
||||
if (typeof v === 'number') return (v >= 1 && v <= 360) ? v : 0;
|
||||
if (typeof v !== 'string') return 0;
|
||||
const s = v.trim();
|
||||
if (FS_HEX_RE.test(s)) return s.toLowerCase();
|
||||
const h = parseInt(s, 10);
|
||||
return (h >= 1 && h <= 360) ? h : 0;
|
||||
}
|
||||
function colorAxis(key, attr, hueProp, colorProp) {
|
||||
/* 'fs-tint' -> 'tint', the window.__fsSD field. Every colour key is one word today, so the
|
||||
* hyphen fold changes nothing; it is here because the failure when one is not would be silent
|
||||
* (see enumAxis). */
|
||||
const sdKey = key.slice(3).replace(/-/g, '_');
|
||||
const def = () => normColor(prefs.sd(sdKey));
|
||||
return {
|
||||
def,
|
||||
current() {
|
||||
const raw = prefs.lsGet(key);
|
||||
return (raw !== null) ? normColor(raw) : def();
|
||||
},
|
||||
apply(val) {
|
||||
const root = document.documentElement;
|
||||
const v = normColor(val);
|
||||
prefs.lsSet(key, String(v));
|
||||
if (!v) {
|
||||
root.removeAttribute(attr);
|
||||
root.style.removeProperty(hueProp);
|
||||
root.style.removeProperty(colorProp);
|
||||
} else if (typeof v === 'number') {
|
||||
root.style.removeProperty(colorProp);
|
||||
/* the hue first, then the attribute that switches the rotation on: the other
|
||||
* order paints one frame in the previous colour on a fresh load */
|
||||
root.style.setProperty(hueProp, String(v));
|
||||
root.setAttribute(attr, 'hue');
|
||||
} else {
|
||||
root.style.removeProperty(hueProp);
|
||||
root.style.setProperty(colorProp, v);
|
||||
root.setAttribute(attr, 'hex');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* A numeric slider axis that sets an inline custom property and no attribute. Each validates to
|
||||
* [min,max], stores the choice explicitly (including the default, so it overrides a router default)
|
||||
* and removes the property AT the default, so 02-tokens' own value shows through; they differ only
|
||||
* in how the number formats onto the property, which is the one varying argument. The prefs.sd() field
|
||||
* name is passed explicitly because one instance needs a rename rather than a spelling
|
||||
* ('fs-radius' -> rounding), and a factory right for four keys out of five is the trap enumAxis and
|
||||
* colorAxis name above.
|
||||
*
|
||||
* `after`, like listAxis's, is optional and runs once the property is set: every propAxis instance
|
||||
* but Content width only repaints a rule that reads the token, so none of the other five pass it.
|
||||
* Content width moves --fs-content-max, which shellGeometry() (fs-chrome.js) reads to decide
|
||||
* data-narrow, so its applier must re-run the fit or the chrome goes on deciding against the width
|
||||
* the column had a moment ago. */
|
||||
function propAxis(key, sdKey, prop, min, max, dfl, fmt, after) {
|
||||
const inRange = (n) => (typeof n === 'number' && n >= min && n <= max);
|
||||
const def = () => { const d = prefs.sd(sdKey); return inRange(d) ? d : dfl; };
|
||||
return {
|
||||
def,
|
||||
current() {
|
||||
const raw = prefs.lsGet(key);
|
||||
if (raw !== null) { const v = parseInt(raw, 10); return inRange(v) ? v : dfl; }
|
||||
return def();
|
||||
},
|
||||
apply(n) {
|
||||
const root = document.documentElement;
|
||||
const v = Math.max(min, Math.min(max, n | 0));
|
||||
prefs.lsSet(key, String(v));
|
||||
if (v === dfl) root.style.removeProperty(prop);
|
||||
else root.style.setProperty(prop, fmt(v));
|
||||
if (after) after();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* Palette: footstrap is the default (bare :root); every other colourway is an opt-in data-palette
|
||||
* value, defined in styles/03-palettes.css.
|
||||
*
|
||||
* Not the enumAxis shape, which has one `on` name and reads every other stored string — including a
|
||||
* real palette — as the default. The array is what VALIDATES a stored value: a name added to the
|
||||
* CSS and not here is one head.ut pre-paints and the live applier then rejects, so the page paints
|
||||
* it and the first touch of any other control takes it away.
|
||||
*
|
||||
* Legacy names ('rvht'/'roman'/'github') are migrated by head.ut before paint, so they never reach
|
||||
* currentPalette() on a loaded page; the stray fallthrough covers them anyway. */
|
||||
|
||||
const PALETTES = [ 'hicontrast', 'bootstrap', '2020', 'forum' ]; /* the non-default values; 'footstrap' = bare :root */
|
||||
const PALETTE = prefs.listAxis('fs-palette', 'data-palette', PALETTES, 'footstrap');
|
||||
const currentPalette = PALETTE.current, applyPalette = PALETTE.apply;
|
||||
|
||||
/* Wallpaper is a multi-value axis: off (bare canvas), pattern (the admin-uploaded SVG, tiled and
|
||||
* recoloured — 15-wallpaper.css) or file (the admin-uploaded photo, 16-login-bg.css).
|
||||
* data-wallpaper carries the value, or is absent for 'off'. Both images are router-side; this axis
|
||||
* only decides whether THIS browser paints one, so a router-wide backdrop comes from
|
||||
* Save-as-default, including the pre-login page.
|
||||
*
|
||||
* The list validates a stored value, so adding one means this line, the head.ut whitelist, the
|
||||
* Wallpaper select in fs-appearance.js and the rules in 15-wallpaper.css. A value that is no longer
|
||||
* in the list falls back to 'off'. */
|
||||
const WALLPAPERS = [ 'pattern', 'file' ]; /* the non-off values; 'off' = bare :root */
|
||||
const WALLPAPER = prefs.listAxis('fs-wallpaper', 'data-wallpaper', WALLPAPERS, 'off');
|
||||
const currentWallpaper = WALLPAPER.current, applyWallpaper = WALLPAPER.apply;
|
||||
|
||||
/* Density: how much air the UI uses. A three-value axis like wallpaper, and a pure token axis —
|
||||
* 02-tokens.css multiplies the type and space ladders and every size follows, with no layout switch
|
||||
* and no re-render.
|
||||
*
|
||||
* Beyond stamping the attribute it must re-run the measured decisions (fitChrome, fitTables,
|
||||
* fitShell), which were taken against the old metrics: Compact makes more fit and Large less, so
|
||||
* otherwise the bar stays stacked — or stays unstacked and overflows — until the next resize. */
|
||||
|
||||
const TINT = colorAxis('fs-tint', 'data-tint', '--fs-tint-h', '--fs-bg');
|
||||
const currentTint = TINT.current, applyTint = TINT.apply;
|
||||
|
||||
/* Accent axis: the UI accent (solid buttons, toggle knobs, sliders, focus rings, accented links)
|
||||
* while canvas, cards and status colours stay put. On a hue, CSS rotates --fs-accent and keeps the
|
||||
* palette's lightness and chroma, so --fs-on-accent stays legible unrecomputed; on a hex the ink is
|
||||
* recomputed from the entered colour's lightness (03-palettes.css). 0 = off. */
|
||||
const ACCENT = colorAxis('fs-accent', 'data-accent', '--fs-accent-h', '--fs-accent');
|
||||
const currentAccent = ACCENT.current, applyAccent = ACCENT.apply;
|
||||
|
||||
/* The three status colours are the same axis pointed at --fs-good / --fs-warn / --fs-danger, kept
|
||||
* separate because they carry separate meanings and every derived tint is a color-mix() of the
|
||||
* role, so each follows its own axis. They are not protected from recolouring: a status colour is
|
||||
* information, and an admin who paints Danger green has said so. What the theme owes them is
|
||||
* readable ink over the fill (03-palettes.css) and the contrast readout beside each field. */
|
||||
/* ---- the surface axes: the sheet the UI is drawn on, rather than the marks on it ----
|
||||
*
|
||||
* The cards, the chrome, the inset controls and the hairlines. Their own factory rather than four
|
||||
* more colorAxis instances, because:
|
||||
*
|
||||
* - there is no hue mode — rotating the hue of a near-white card keeps its chroma (~0.003), so
|
||||
* every angle produces the same white. The Tint axis colours a surface by SETTING a chroma;
|
||||
* - there is no derived ink — what reads on these is --fs-text, a palette token these axes must
|
||||
* not move, so the Appearance page reports the contrast instead;
|
||||
* - they therefore need no attribute: an inline custom property on :root is the whole mechanism,
|
||||
* and every derived token follows because each is a color-mix() of the one this sets. That is
|
||||
* why --fs-bar-bg is a surface of its own — an admin who wants a dark chrome over light cards
|
||||
* has to be able to say so.
|
||||
*
|
||||
* Off is prefs.lsSet('0'), not a deleted key: once a router default exists, clearing means "inherit
|
||||
* it". */
|
||||
|
||||
function surfaceAxis(key, sdKey, prop) {
|
||||
const norm = (v) => {
|
||||
const s = (typeof v === 'string') ? v.trim().toLowerCase() : '';
|
||||
return FS_HEX_RE.test(s) ? s : 0;
|
||||
};
|
||||
const def = () => norm(prefs.sd(sdKey));
|
||||
return {
|
||||
def,
|
||||
current() {
|
||||
const raw = prefs.lsGet(key);
|
||||
return (raw !== null) ? norm(raw) : def();
|
||||
},
|
||||
apply(val) {
|
||||
const v = norm(val);
|
||||
prefs.lsSet(key, String(v));
|
||||
if (v) document.documentElement.style.setProperty(prop, v);
|
||||
else document.documentElement.style.removeProperty(prop);
|
||||
}
|
||||
};
|
||||
}
|
||||
const CARD = surfaceAxis('fs-card', 'card', '--fs-panel-base');
|
||||
const currentCard = CARD.current, applyCard = CARD.apply;
|
||||
const CONTROL = surfaceAxis('fs-control', 'control', '--fs-panel2-base');
|
||||
const currentControl = CONTROL.current, applyControl = CONTROL.apply;
|
||||
const BAR = surfaceAxis('fs-bar', 'bar', '--fs-bar-bg');
|
||||
const currentBar = BAR.current, applyBar = BAR.apply;
|
||||
const LINE = surfaceAxis('fs-line', 'line', '--fs-border-base');
|
||||
const currentLine = LINE.current, applyLine = LINE.apply;
|
||||
|
||||
|
||||
const GOOD = colorAxis('fs-good', 'data-good', '--fs-good-h', '--fs-good');
|
||||
const currentGood = GOOD.current, applyGood = GOOD.apply;
|
||||
const WARN = colorAxis('fs-warn', 'data-warn', '--fs-warn-h', '--fs-warn');
|
||||
const currentWarn = WARN.current, applyWarn = WARN.apply;
|
||||
const DANGER = colorAxis('fs-danger', 'data-danger', '--fs-danger-h', '--fs-danger');
|
||||
const currentDanger = DANGER.current, applyDanger = DANGER.apply;
|
||||
|
||||
/* Rounding: the propAxis instance (default const and rationale up top), --fs-radius-base in px. */
|
||||
const RADIUS = propAxis('fs-radius', 'rounding', '--fs-radius-base', 0, 20, FS_RADIUS_DEFAULT, (v) => (v + 'px'));
|
||||
const currentRadius = RADIUS.current, applyRadius = RADIUS.apply, radiusDefault = RADIUS.def;
|
||||
|
||||
/* Content width (issue #44): how far the reader lets the column grow past --fs-content-max's own
|
||||
* 1280px. A propAxis like Rounding, pointed at that one token — the range comment lives in
|
||||
* 02-tokens.css, next to the token it bounds, and is not restated here.
|
||||
*
|
||||
* The one propAxis instance that moves chrome geometry: shellGeometry() (fs-chrome.js) reads
|
||||
* --fs-content-max on every fit, so dragging the slider must re-run it through the SAME
|
||||
* fit.schedule() every other geometry-affecting axis calls, or the sidebar goes on folding at the
|
||||
* width the column had before the drag. */
|
||||
const FS_CWIDTH_DEFAULT = 1280;
|
||||
const CWIDTH = propAxis('fs-content-width', 'content_width', '--fs-content-max', 1280, 3840,
|
||||
FS_CWIDTH_DEFAULT, (v) => (v + 'px'), () => fit.schedule());
|
||||
const currentContentWidth = CWIDTH.current, applyContentWidth = CWIDTH.apply, contentWidthDefault = CWIDTH.def;
|
||||
|
||||
/* Layout axis: horizontal top bar (the default) vs vertical sidebar. One template, one renderer —
|
||||
* CSS morphs the chrome off :root[data-layout] and toggling re-renders nothing; menu-footstrap.js
|
||||
* observes the attribute and folds the accordion into dropdowns or restores it.
|
||||
*
|
||||
* Read the ATTRIBUTE, not localStorage: head.ut stamps it server-side from the router default and
|
||||
* the pre-paint script overrides it, so it always carries an explicit value. localStorage would
|
||||
* report 'sidebar' on a router defaulting to 'top' until the user first touched the toggle. */
|
||||
|
||||
const AXIS_KEYS = [
|
||||
'fs-layout', 'fs-darkmode', 'fs-palette', 'fs-wallpaper', 'fs-tint',
|
||||
'fs-accent', 'fs-good', 'fs-warn', 'fs-danger', 'fs-card', 'fs-control',
|
||||
'fs-bar', 'fs-line', 'fs-radius', 'fs-menu-autocollapse', 'fs-tint-strength',
|
||||
'fs-density', 'fs-photo-dim', 'fs-pattern-size', 'fs-pattern-strength',
|
||||
'fs-pattern-ink', 'fs-content-width'
|
||||
];
|
||||
/* Tint strength: a multiplier on the tint chroma (03-palettes.css), 100% being the designed
|
||||
* strength and 200% the cap. 0 is not quite "no tint" — the relative colour that applies the tint
|
||||
* replaces chroma outright, so 0 leaves a neutral canvas at the same lightness rather than the
|
||||
* untinted one; clearing the Tint hue is the real off. It only bites while a Tint hue is set, and
|
||||
* is moot under the File wallpaper, where the photo covers the canvas.
|
||||
*
|
||||
* This axis and its default live above _resolvedDefault()'s module-init call below: a propAxis
|
||||
* instance is a `const`, so declaring it further down leaves it in the TDZ at init and the whole
|
||||
* module throws, taking the chrome and the menu with it. */
|
||||
const FS_TSTR_DEFAULT = 100;
|
||||
const TSTR = propAxis('fs-tint-strength', 'tint_strength', '--fs-tint-strength', 0, 200, FS_TSTR_DEFAULT, (v) => String(v / 100));
|
||||
const currentTintStrength = TSTR.current, applyTintStrength = TSTR.apply, tintStrengthDefault = TSTR.def;
|
||||
|
||||
/* Photo dim: the scrim opacity over the FILE photo (0–100%). The photo is shared; how strongly
|
||||
* this browser dims it is not, and it reaches the router through Save-as-default. Only bites while
|
||||
* the wallpaper is 'file'. Declared up here for the TDZ reason above. */
|
||||
const FS_PDIM_DEFAULT = 74;
|
||||
const PDIM = propAxis('fs-photo-dim', 'photo_dim', '--fs-photo-dim', 0, 100, FS_PDIM_DEFAULT, (v) => (v + '%'));
|
||||
const currentPhotoDim = PDIM.current, applyPhotoDim = PDIM.apply, photoDimDefault = PDIM.def;
|
||||
|
||||
/* The pattern's two live knobs, and the third that is an enum. All three bite only while the
|
||||
* wallpaper is 'pattern'; the FILE is shared, how this browser draws it is not.
|
||||
*
|
||||
* Size is the tile's edge in px, with a wide range because "how big is one repeat" is a property of
|
||||
* the artwork. Strength is the layer's opacity 0-100, which is the knob a `<g opacity>` baked into
|
||||
* the file would put out of CSS's reach. Declared up here for the TDZ reason above. */
|
||||
const FS_PSIZE_DEFAULT = 440;
|
||||
const PSIZE = propAxis('fs-pattern-size', 'pattern_size', '--fs-pattern-size', 40, 1600, FS_PSIZE_DEFAULT, (v) => (v + 'px'));
|
||||
const currentPatternSize = PSIZE.current, applyPatternSize = PSIZE.apply, patternSizeDefault = PSIZE.def;
|
||||
const FS_PSTR_DEFAULT = 20;
|
||||
const PSTR = propAxis('fs-pattern-strength', 'pattern_strength', '--fs-pattern-strength', 0, 100, FS_PSTR_DEFAULT, (v) => String(v / 100));
|
||||
const currentPatternStrength = PSTR.current, applyPatternStrength = PSTR.apply, patternStrengthDefault = PSTR.def;
|
||||
/* Ink: 'theme' (the file's alpha, the theme's colour) or 'original' (the file's own colours, no
|
||||
* mask). Two-valued with the default as a bare :root, i.e. the enumAxis shape. */
|
||||
const PINK = prefs.enumAxis('fs-pattern-ink', 'data-pattern-ink', 'original', 'theme');
|
||||
const currentPatternInk = PINK.current, applyPatternInk = PINK.apply;
|
||||
/* `reject: true` is load-bearing: without it a refused write arrives as SUCCESS. rpc.js raises on
|
||||
* the ubus status code only when the declaration asks it to, and otherwise hands the code back as
|
||||
* the resolved value — measured on the router, a per-config ACL refusal resolves with 6
|
||||
* (permission denied) and every `.then()` below runs as if the file had been written, greying the
|
||||
* Save button over a write that never happened. */
|
||||
|
||||
const _uciSet = rpc.declare({ object: 'uci', method: 'set', params: [ 'config', 'section', 'values' ], reject: true });
|
||||
const _uciCommit = rpc.declare({ object: 'uci', method: 'commit', params: [ 'config' ], reject: true });
|
||||
|
||||
function snapshotAxes() {
|
||||
return {
|
||||
layout: prefs.currentLayout(),
|
||||
darkmode: prefs.currentMode(),
|
||||
palette: currentPalette(),
|
||||
wallpaper: currentWallpaper(),
|
||||
tint: String(currentTint()),
|
||||
accent: String(currentAccent()),
|
||||
good: String(currentGood()),
|
||||
warn: String(currentWarn()),
|
||||
danger: String(currentDanger()),
|
||||
card: String(currentCard()),
|
||||
control: String(currentControl()),
|
||||
bar: String(currentBar()),
|
||||
line: String(currentLine()),
|
||||
rounding: String(currentRadius()),
|
||||
autocollapse: prefs.currentAutoCollapse() ? 'on' : 'off',
|
||||
tint_strength: String(currentTintStrength()),
|
||||
density: prefs.currentDensity(),
|
||||
photo_dim: String(currentPhotoDim()),
|
||||
pattern_size: String(currentPatternSize()),
|
||||
pattern_strength: String(currentPatternStrength()),
|
||||
pattern_ink: currentPatternInk(),
|
||||
content_width: String(currentContentWidth())
|
||||
};
|
||||
}
|
||||
/* The resolved router default (the uci value if set, else the built-in) in snapshotAxes() string
|
||||
* form, so the Appearance tab can grey the Save button when this browser already shows exactly it.
|
||||
* Seeded from window.__fsSD at load and replaced with the just-saved snapshot, so a save flips the
|
||||
* match without a reload.
|
||||
*
|
||||
* Every field is the axis's own def(): a second copy of a validation drifts with no symptom beyond
|
||||
* matchesSavedDefault() lying, which is the one thing the Save button is. `layout` is the exception,
|
||||
* since prefs.currentLayout() reads the attribute — its fallback must stay `top`, matching head.ut's
|
||||
* stamp and resetToBuiltin(), or a fresh install shows dirty before anything is touched and
|
||||
* resetToSaved() lands on the wrong layout. */
|
||||
function _resolvedDefault() {
|
||||
return {
|
||||
layout: prefs.sd('layout') || 'top',
|
||||
darkmode: prefs.modeDefault(),
|
||||
palette: PALETTE.def(),
|
||||
wallpaper: WALLPAPER.def(),
|
||||
tint: String(TINT.def()),
|
||||
accent: String(ACCENT.def()),
|
||||
good: String(GOOD.def()),
|
||||
warn: String(WARN.def()),
|
||||
danger: String(DANGER.def()),
|
||||
card: String(CARD.def()),
|
||||
control: String(CONTROL.def()),
|
||||
bar: String(BAR.def()),
|
||||
line: String(LINE.def()),
|
||||
rounding: String(radiusDefault()),
|
||||
autocollapse: (prefs.autoCollapseDefault() ? 'on' : 'off'),
|
||||
tint_strength: String(tintStrengthDefault()),
|
||||
density: prefs.densityDefault(),
|
||||
photo_dim: String(photoDimDefault()),
|
||||
pattern_size: String(patternSizeDefault()),
|
||||
pattern_strength: String(patternStrengthDefault()),
|
||||
pattern_ink: PINK.def(),
|
||||
content_width: String(contentWidthDefault())
|
||||
};
|
||||
}
|
||||
let _savedDefault = _resolvedDefault();
|
||||
function matchesSavedDefault() {
|
||||
const cur = snapshotAxes();
|
||||
return Object.keys(cur).every((k) => cur[k] === _savedDefault[k]);
|
||||
}
|
||||
|
||||
/* ---- no axis reaches /etc/config/footstrap except through Save-as-default ----
|
||||
* Every axis is per-browser. An axis that wrote through on change — on the argument that the photo
|
||||
* it relates to is router-side — re-pointed the router-wide default for every other device from one
|
||||
* browser, and moved the Save baseline with it, so the button did not even light up. A per-browser
|
||||
* preference must never mutate shared state invisibly.
|
||||
*
|
||||
* Only the photo's bytes and its cache-bust token are router-side. Whether a browser paints it is
|
||||
* `fs-wallpaper` and how dim is `fs-photo-dim`: ordinary axes, saved with the rest or not at
|
||||
* all. */
|
||||
function saveAsDefault() {
|
||||
const snap = snapshotAxes();
|
||||
return _uciSet('footstrap', 'settings', snap)
|
||||
.then(() => _uciCommit('footstrap'))
|
||||
.then(() => { _savedDefault = snap; });
|
||||
}
|
||||
/* ---- the two resets, which are not the same escape hatch ----
|
||||
*
|
||||
* Both drop this browser's tweaks and differ in what is underneath:
|
||||
*
|
||||
* resetToSaved() clears the keys, so every axis falls back to the router default where one is
|
||||
* set and to the built-in where it is not. The browser goes back to inheriting.
|
||||
* resetToBuiltin() writes the theme's own defaults explicitly, the only way to say "as the theme
|
||||
* ships" — clearing the keys is the sentence that means "inherit the router
|
||||
* default".
|
||||
*
|
||||
* Both leave /etc/config/footstrap alone: neither un-saves a router default.
|
||||
*
|
||||
* The caller reloads so head.ut re-applies everything in one pass — the appliers repaint correctly,
|
||||
* but the controls on the page were built from the values they had at render time. */
|
||||
function resetToSaved() {
|
||||
AXIS_KEYS.forEach(prefs.lsDel);
|
||||
}
|
||||
|
||||
/* The built-in defaults, written through the ordinary appliers so each validates its own value and
|
||||
* stamps :root as usual. Stated rather than derived: a default is a default because it is what a
|
||||
* bare :root paints, and the five with a named const use it, so the numbers cannot drift from the
|
||||
* CSS. */
|
||||
function resetToBuiltin() {
|
||||
/* `top` for layout, not sidebar: the bar is what a bare :root paints (head.ut stamps it when uci
|
||||
* says nothing), so it is what "as the theme ships" means. The colour and surface axes reset to
|
||||
* 0, which is "the palette's own".
|
||||
*
|
||||
* Stated as calls rather than carried in AXES: a fourth column of thunks measured 297 B against
|
||||
* this list, and a wrong value here is what the Save button's "Reset to built-in" shows on the
|
||||
* first click — the one state no static gate can see and the live check does. */
|
||||
prefs.applyLayout('top');
|
||||
prefs.applyMode('auto');
|
||||
applyPalette('footstrap');
|
||||
applyWallpaper('off');
|
||||
applyTint(0);
|
||||
applyAccent(0);
|
||||
applyGood(0);
|
||||
applyWarn(0);
|
||||
applyDanger(0);
|
||||
applyCard(0);
|
||||
applyControl(0);
|
||||
applyBar(0);
|
||||
applyLine(0);
|
||||
applyRadius(FS_RADIUS_DEFAULT);
|
||||
prefs.applyAutoCollapse('off');
|
||||
applyTintStrength(FS_TSTR_DEFAULT);
|
||||
prefs.applyDensity('normal');
|
||||
applyPhotoDim(FS_PDIM_DEFAULT);
|
||||
applyPatternSize(FS_PSIZE_DEFAULT);
|
||||
applyPatternStrength(FS_PSTR_DEFAULT);
|
||||
applyPatternInk('theme');
|
||||
applyContentWidth(FS_CWIDTH_DEFAULT);
|
||||
}
|
||||
|
||||
/* ---- the two uploaded wallpapers, browser side ----
|
||||
*
|
||||
* What the router last saved, what URL that is, and how to paint it. Putting the file THERE is
|
||||
* fs-assets.js: a DOMParser pass, a canvas re-encode, a chmod and a rollback, reached only from the
|
||||
* Appearance tab and so not worth downloading on every admin page. The token accessors stay here
|
||||
* because `prefs.sd()` is private to this module and because head.ut's pre-paint reads the same fields.
|
||||
*
|
||||
* Neither is an axis: an axis is per-browser with a router default, and these have no browser
|
||||
* layer — one admin uploads once and every device sees it, pre-login included. So they are absent
|
||||
|
||||
* from AXIS_KEYS, snapshotAxes() and matchesSavedDefault(), and must not move the Save button. */
|
||||
/* NOT a /www symlink, unlike BG_SERVE below: an SVG served as a plain static file is a same-origin
|
||||
* document that runs script opened directly (OpenWrt forum thread 251930). This CGI handler reads
|
||||
* the fixed /etc/footstrap/pattern.svg path itself and answers with CSP 'none' + sandbox and
|
||||
* nosniff, headers uhttpd cannot attach to a static file — see the uci-default and the handler for
|
||||
* the rest of the reasoning. Painting stays exactly as before: a URL is a URL to a mask-image. */
|
||||
const PAT_SERVE = '/cgi-bin/luci-theme-footstrap-pattern';
|
||||
function currentPattern() {
|
||||
const t = prefs.sd('pattern');
|
||||
return (typeof t === 'string' && BG_TOKEN_RE.test(t)) ? t : '';
|
||||
}
|
||||
function patternUrl(tok) { return PAT_SERVE + '?v=' + tok; }
|
||||
|
||||
/* set/clear the tile URL live. This only supplies the url(); whether it PAINTS is the Wallpaper
|
||||
* axis (data-wallpaper="pattern"). Exported because fs-assets.js applies the token it just wrote. */
|
||||
function applyPattern(tok) {
|
||||
const root = document.documentElement;
|
||||
if (tok) root.style.setProperty('--fs-pattern-url', 'url("' + patternUrl(tok) + '")');
|
||||
else root.style.removeProperty('--fs-pattern-url');
|
||||
prefs.setSD('pattern', tok || '');
|
||||
}
|
||||
|
||||
/* Still the plain uhttpd symlink the uci-default makes, unlike PAT_SERVE above: fs-assets.js
|
||||
* re-encodes every login-bg upload to a JPEG on a canvas before it is sent, so the stored bytes
|
||||
* are pixels only — there is no script grammar left in a raster for a direct hit to run. */
|
||||
const BG_SERVE = '/luci-static/footstrap/bg';
|
||||
/* the cache-bust token charset, an md5/sha hex string. One copy here; head.ut's ucode sanitiser
|
||||
* and the pre-paint inline script keep their own identical copies unavoidably, running before this
|
||||
* module — see the axes contract in head.ut. */
|
||||
const BG_TOKEN_RE = /^[a-f0-9]{6,64}$/;
|
||||
/* the same question asked from fs-assets.js, which validates the checksum an upload replies with.
|
||||
* A predicate rather than the pattern itself, so the charset stays stated once. */
|
||||
function tokenOk(t) { return BG_TOKEN_RE.test(t); }
|
||||
|
||||
/* the token the server last saved, validated to the same hex charset head.ut's sanitiser and
|
||||
* pre-paint use, so the Appearance tab can build a cache-busted preview src. '' = none. */
|
||||
function currentLoginBg() {
|
||||
const t = prefs.sd('login_bg');
|
||||
return (typeof t === 'string' && BG_TOKEN_RE.test(t)) ? t : '';
|
||||
}
|
||||
function loginBgUrl(tok) { return BG_SERVE + '?v=' + tok; }
|
||||
|
||||
/* applyPattern's twin for the photo; data-wallpaper="file" decides whether it paints. */
|
||||
function applyLoginBg(tok) {
|
||||
const root = document.documentElement;
|
||||
if (tok) root.style.setProperty('--fs-login-bg-url', 'url("' + loginBgUrl(tok) + '")');
|
||||
else root.style.removeProperty('--fs-login-bg-url');
|
||||
prefs.setSD('login_bg', tok || '');
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
currentPalette, applyPalette,
|
||||
currentWallpaper, applyWallpaper,
|
||||
currentTint, applyTint,
|
||||
currentAccent, applyAccent,
|
||||
currentGood, applyGood,
|
||||
currentWarn, applyWarn,
|
||||
currentDanger, applyDanger,
|
||||
currentCard, applyCard,
|
||||
currentControl, applyControl,
|
||||
currentBar, applyBar,
|
||||
currentLine, applyLine,
|
||||
currentRadius, applyRadius,
|
||||
currentTintStrength, applyTintStrength,
|
||||
currentPhotoDim, applyPhotoDim,
|
||||
currentPatternSize, applyPatternSize,
|
||||
currentPatternStrength, applyPatternStrength,
|
||||
currentPatternInk, applyPatternInk,
|
||||
currentContentWidth, applyContentWidth, contentWidthDefault,
|
||||
|
||||
currentPattern, patternUrl, applyPattern,
|
||||
currentLoginBg, loginBgUrl, applyLoginBg,
|
||||
tokenOk,
|
||||
|
||||
snapshotAxes, matchesSavedDefault, saveAsDefault, resetToSaved, resetToBuiltin
|
||||
});
|
||||
@@ -0,0 +1,629 @@
|
||||
'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 deciding how much room each gets. The main menu is injected by menu-footstrap.js as
|
||||
* a callback (renderMainMenu), since a required LuCI module is a singleton and a renderer cannot
|
||||
* subclass the chrome (docs/conventions.md). */
|
||||
|
||||
/* the injected main-menu renderer; handed over once by the theme's init() */
|
||||
let _renderMain = null;
|
||||
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, not just the `active` class, which is paint a screen reader cannot see.
|
||||
* E() drops a null attribute value, so inactive tabs carry nothing. */
|
||||
ul.appendChild(E('li', { 'class': 'tabmenu-item-%s %s'.format(child.name, isActive ? 'active' : '') }, [
|
||||
E('a', { 'href': L.url(url, child.name), 'aria-current': isActive ? 'page' : null }, [ _(child.title) ])
|
||||
]));
|
||||
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 (theme/40-tabs.css) trim
|
||||
* padding, then gap+font. Floored so a pill never gets tighter than its label; past the floor the
|
||||
* strip may wrap. */
|
||||
function stripFitsOneRow(ul) {
|
||||
/* only laid-out children count: a display:none child has offsetTop 0, so taking it as `last`
|
||||
* reads as "one row" on a strip that has wrapped */
|
||||
const items = [...ul.children].filter((el) => el.getClientRects().length > 0);
|
||||
const first = items[0], last = items[items.length - 1];
|
||||
/* one row iff first and last item share a top edge */
|
||||
return !first || !last || first.offsetTop === last.offsetTop;
|
||||
}
|
||||
function fitTabStrips() {
|
||||
/* `.fs-sidebar > ul.nav` is the main menu in every layout, 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 */
|
||||
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 below forces a reflow per strip, on every tick */
|
||||
if (!ul.classList.contains('fs-dense1') && !ul.classList.contains('fs-dense2') && stripFitsOneRow(ul))
|
||||
return;
|
||||
ul.classList.remove('fs-dense1', 'fs-dense2');
|
||||
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 cannot say that: the cut is 224px expanded and 68px as a rail, so one
|
||||
* breakpoint gives both states the same answer and the rail folds away at the same width as the
|
||||
* full sidebar. Do not measure the RENDERED sidebar either — the answer would depend on the state
|
||||
* it is deciding (as a bar there is no cut, so the content fits, so it un-narrows) and oscillate.
|
||||
*
|
||||
* The widths come from the stylesheet (02-tokens.css) and are never restated here, or narrowing
|
||||
* the rail in CSS leaves this subtracting the old width with no gate able to see it.
|
||||
*
|
||||
* A custom property is untyped, so `parseFloat(getComputedStyle(root).getPropertyValue(…))` reads
|
||||
* `calc(224px * 1)` and returns NaN — silently, since `NaN < NaN` is false and the sidebar simply
|
||||
* never yields. Assign the token to a real length property on a throwaway element and read the
|
||||
* used value back instead. */
|
||||
let _probe = null;
|
||||
/* The probe is a plain <div> in the shared document, so a third-party app's CSS can style it —
|
||||
* it carries no chrome mark and the fence deliberately does not spare it. Every declaration is
|
||||
* therefore !important, which a style-attribute wins outright: an app carrying
|
||||
* `div { min-width: 500px !important }` otherwise wins every read and the sidebar folds into a bar
|
||||
* on a 1857px desktop (issue #19) — the cut becomes 500 + 500 + 1000 = 2000 CSS px, which is why it
|
||||
* was reported as a zoom bug: Chrome at 90% gives 2063 CSS px and passes, 100% gives 1857 and
|
||||
* fails. box-sizing is stated for the same reason — getComputedStyle().width is
|
||||
* the content box, so a foreign `border-box` plus padding would shave the reading. */
|
||||
function resolveLen(token, dflt) {
|
||||
if (!_probe) {
|
||||
_probe = document.createElement('div');
|
||||
_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: that is the one thing changing these widths at runtime
|
||||
* (`prefs.applyDensity()` stamps `:root[data-density]` and calls fit.schedule() for exactly that).
|
||||
*
|
||||
* The defaults are stated once so the fallbacks and the sanity net below cannot restate the
|
||||
* stylesheet's widths in two places. Reaching for them means the measurement failed. */
|
||||
const GEOM_DFLT = { contentMin: 500, sidebarW: 224, railW: 68, contentPad: 56, contentMax: 1280 };
|
||||
/* the class name toggled below by fitShell's own escalation and by fitCluster's (measured: 16 B x3
|
||||
* -> 25 B, 23 B saved) */
|
||||
const CLASS_IND_COMPACT = 'fs-ind-compact';
|
||||
|
||||
let _geom = null, _geomDensity = null, _geomWarned = false;
|
||||
function shellGeometry() {
|
||||
const density = document.documentElement.getAttribute('data-density') || '';
|
||||
/* contentMax moves with data-content-width (02-tokens.css), the other three widths do not, but
|
||||
* they are one memo and the axis's own applier calls fit.schedule() same as Density's — folded
|
||||
* into the same key rather than a second one, or a content-width change alone would hit the
|
||||
* density-only key and go on answering the previous contentMax until density also changed */
|
||||
const cwidth = document.documentElement.getAttribute('data-content-width') || '';
|
||||
const key = density + '|' + cwidth;
|
||||
/* the gutter is re-asked even on a memo hit: it moves with the width, not the density */
|
||||
if (_geom && _geomDensity === key) return _geom;
|
||||
_geomDensity = key;
|
||||
const px = (name, dflt) => resolveLen(name, dflt);
|
||||
const g = {
|
||||
contentMin: px('--fs-content-min', GEOM_DFLT.contentMin),
|
||||
sidebarW: px('--fs-sidebar-w', GEOM_DFLT.sidebarW),
|
||||
railW: px('--fs-rail-w', GEOM_DFLT.railW),
|
||||
/* the token is one side's padding; the column loses it twice. It is only the fallback —
|
||||
* measureShell() overwrites this with the gutter the column actually got, which nothing
|
||||
* has measured before the first fitter (and the login page has no `.fs-content`). */
|
||||
contentPad: px('--fs-content-pad', GEOM_DFLT.contentPad / 2) * 2,
|
||||
/* where the column stops growing, i.e. where surplus becomes margin — see columnWidth() */
|
||||
contentMax: px('--fs-content-max', GEOM_DFLT.contentMax)
|
||||
};
|
||||
/* Plausibility, at the cost of one comparison: the rail is the sidebar collapsed, so
|
||||
* 0 < railW < sidebarW holds by construction. Both known failures destroy it — a hijacked probe
|
||||
* reports one foreign width for all four (issue #19), a renamed or absent token reports 0 for
|
||||
* all four (an abs-positioned empty div shrinks to 0, which is finite, so the per-read fallback
|
||||
* never fires). Only the relation between the numbers gives either away. */
|
||||
const sane = (g.railW > 0 && g.railW < g.sidebarW && g.contentMin > 0);
|
||||
/* The fallback keeps the chrome laid out, but on built-in literals, so the sidebar folds at a
|
||||
* width nobody chose while the page still looks correct. Say so, or a renamed token ships
|
||||
* green. Once per document — this runs on every resize and mutation. */
|
||||
if (!sane && !_geomWarned) {
|
||||
_geomWarned = true;
|
||||
console.error('footstrap: the chrome widths did not read back from the stylesheet (got '
|
||||
+ JSON.stringify(g) + ') — falling back to the built-in defaults. A --fs-* width token was '
|
||||
+ 'renamed, or a foreign sheet is reaching the measurement probe.');
|
||||
}
|
||||
_geom = sane ? g : Object.assign({}, GEOM_DFLT);
|
||||
if (_shellPad != null) _geom.contentPad = _shellPad;
|
||||
return _geom;
|
||||
}
|
||||
|
||||
/* The window's width and the column's gutter are read here, from a fitter or from
|
||||
* `contentWidth()`'s own staleness check below — nowhere else.
|
||||
*
|
||||
* The gutter is measured where it is applied rather than read off `--fs-content-pad`: below 767px
|
||||
* `theme/20-shell.css` re-pads `.fs-content` to `var(--fs-space-4)`, so the token says 28px a side
|
||||
* while the real gutter is 16px. The breakpoint may not be restated here — a width literal in JS is
|
||||
* what these reads exist to avoid — so the element is asked what it actually got.
|
||||
*
|
||||
* `clientWidth` is a layout read and `getComputedStyle` resolves style, so a fitter is the one
|
||||
* caller allowed to reach BOTH unconditionally: it defers the whole pass during a flick
|
||||
* (`fit.scrolling()`, fs-fit.js) rather than pay either mid-scroll. `contentWidth()` reaches only
|
||||
* the first (comparison, not the write) on every call, and this function's own body only when
|
||||
* that comparison says the width moved — its answer is otherwise the geometry as of the last still
|
||||
* moment, exactly what a fitter last measured.
|
||||
*
|
||||
* A hostile declaration is no threat here: `.fs-content` carries no chrome mark, so if an app
|
||||
* re-pads it then that padding IS the column's gutter. Before any fitter has run — the login page
|
||||
* has no `.fs-content` — the token stands in. */
|
||||
let _shellOuter = 0, _shellPad = null, _padAt = null;
|
||||
function measureShell() {
|
||||
/* the window's own width, every time: everything downstream is measured against it */
|
||||
_shellOuter = document.documentElement.clientWidth;
|
||||
/* The gutter is resolved style and this runs on every mutation batch, so it is memoised on the
|
||||
* three things that move it: the width (a media query re-pads the column below 767px), the
|
||||
* density (the token is a calc over it) and the page. The page is the third term because a
|
||||
* foreign sheet may re-pad `.fs-content`, and `sheets.scopeToCurrentPage()` enables and
|
||||
* disables those sheets per navigation with no width or density change to notice it by;
|
||||
* `body[data-page]` is the one attribute a navigation always restamps. */
|
||||
const key = (document.documentElement.getAttribute('data-density') || '') + '|' + _shellOuter +
|
||||
'|' + (document.body ? document.body.getAttribute('data-page') || '' : '');
|
||||
if (_padAt === key && _shellPad != null) return;
|
||||
const host = document.querySelector('.fs-content');
|
||||
const cs = host ? getComputedStyle(host) : null;
|
||||
const v = cs ? parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight) : NaN;
|
||||
/* a failed read leaves the key unset, so the next pass retries rather than caching a miss */
|
||||
if (Number.isFinite(v) && v >= 0) {
|
||||
_shellPad = v;
|
||||
_padAt = key;
|
||||
if (_geom) _geom.contentPad = v;
|
||||
}
|
||||
}
|
||||
|
||||
function columnWidth(g, state) {
|
||||
/* narrow OR top: in both the chrome is above the content, not beside it */
|
||||
const cut = (state.narrow || state.top) ? 0 : (state.rail ? g.railW : g.sidebarW);
|
||||
/* The column stops growing: `.fs-content` is `max-width: var(--fs-content-max); margin: 0
|
||||
* auto`, so past ~1500px the surplus becomes margin, and without the cap this answers ~2280 on
|
||||
* a 2560px window for a column that is 1224 wide. No caller can reach that today (both ask a
|
||||
* lower bound), but this is the exported answer to "how wide is the content column". */
|
||||
const room = Math.min(state.outerW - cut, g.contentMax);
|
||||
return Math.max(0, room - g.contentPad);
|
||||
}
|
||||
|
||||
function fitShell() {
|
||||
const root = document.documentElement;
|
||||
/* runs in both branches: the bar layout decides nothing here, but `contentWidth()` still
|
||||
* answers in it */
|
||||
measureShell();
|
||||
if (prefs.currentLayout() === 'top') { /* no sidebar, no cut, nothing to decide */
|
||||
root.removeAttribute('data-narrow');
|
||||
return;
|
||||
}
|
||||
const g = shellGeometry();
|
||||
/* asked uncollapsed: this measurement decides `data-narrow`, so it may not read it */
|
||||
const content = columnWidth(g, { outerW: _shellOuter, rail: prefs.currentRail() });
|
||||
/* toggleAttribute, not setAttribute: a same-value setAttribute still queues a mutation record,
|
||||
* while toggleAttribute on an already-present attribute queues none. fitShell runs on every
|
||||
* mutation batch inside #view, and menu-footstrap treats each data-narrow record as a mode
|
||||
* change, so on a phone (where the attribute is permanently set) every poll tick would re-fire
|
||||
* closeFlyouts() and snap shut the section the user just opened. */
|
||||
root.toggleAttribute('data-narrow', content < g.contentMin);
|
||||
}
|
||||
|
||||
function fitChrome() {
|
||||
/* nothing asked here — bar width, menu width, room beside the brand — can change while the
|
||||
* reader scrolls, and each is a layout read landing mid-flick: defer (fs-fit.js) */
|
||||
if (fit.scrolling()) {
|
||||
fit.deferMeasurement();
|
||||
return;
|
||||
}
|
||||
|
||||
fitShell();
|
||||
|
||||
const bar = document.querySelector('.fs-sidebar');
|
||||
const menu = document.getElementById('topmenu');
|
||||
/* The top bar is measured at every width, with no breakpoint floor: the shrink/compact/stack
|
||||
* escalation below runs at any width. (The sidebar layout has its own phone bar, decided by
|
||||
* fitShell's data-narrow, and is untouched here.) */
|
||||
const topBar = !!bar && !!menu && prefs.isTopLayout();
|
||||
|
||||
/* THE BAR MAY NOT CHANGE HEIGHT WHILE IT IS BEING MEASURED, IN EITHER DIRECTION. The three
|
||||
* classes below are taken off so the menu can be asked whether it fits on one row (fs-fit
|
||||
* rule 1), and while they are off the bar's OWN box is free to answer any height its content
|
||||
* currently needs — not only shorter. A `min-height` floor alone stops the shrink but not the
|
||||
* grow: with the classes off and `fs-dense1`/`fs-dense2` stripped by `fitTabStrips()`, this pass
|
||||
* measured the bar walking 230 -> 202 -> 164 -> 144 -> 123 -> 131 -> 123px against a settled
|
||||
* 123px on owrt2512 at 767px — 107px of growth a floor never sees, on top of the shrink it does
|
||||
* — each step landing between two of the poll's own separate section refreshes, so the browser
|
||||
* paints in between and the reader is moved by exactly as much, on Chromium and Firefox as well
|
||||
* as Safari (`tools/fit-quiet.mjs`, `../tmp/task-toplayout/pass-probe.mjs`). `min-height` alone
|
||||
* was measured to `accc451`'s WebKit-only diagnosis instead — WebKit's own scroll anchoring
|
||||
* looked like the whole story only because it is the one engine with no anchoring at all to hide
|
||||
* this walk behind; Chromium and Firefox absorb it the same way they absorb any other layout
|
||||
* change, which is not the same as not producing it.
|
||||
*
|
||||
* So both `min-height` AND `height` are pinned to the SAME value for the whole decision — a hard
|
||||
* pin, not a floor — because nothing this pass measures (`stripFitsOneRow()`'s `offsetTop`,
|
||||
* `clusterFitsBrandRow()`'s widths) reads the BAR's own height; `overflow: visible`
|
||||
* (`theme/20-shell.css`) means a row the pin is too short for still lays out and measures
|
||||
* correctly, it only paints past the pinned box's edge, which is invisible for the one
|
||||
* synchronous pass before the pin comes off. The pin is released only once the final class set
|
||||
* is decided — after `fitCluster()`, before `publishBarHeight()` — which must measure the bar
|
||||
* the reader actually gets. */
|
||||
const pinned = bar ? Math.round(bar.getBoundingClientRect().height) : 0;
|
||||
const hadMinH = bar ? bar.style.minHeight : '';
|
||||
const hadH = bar ? bar.style.height : '';
|
||||
if (pinned > 0) { bar.style.minHeight = pinned + 'px'; bar.style.height = pinned + 'px'; }
|
||||
|
||||
if (bar) bar.classList.remove('fs-bar-stack', CLASS_IND_COMPACT, 'fs-bar-actrow');
|
||||
fitTabStrips();
|
||||
/* ---- does the main menu fit on the brand's row? ----
|
||||
* It depends on how many sections THIS router has (stock 5, a loaded box 11), not on the
|
||||
* viewport, so it is measured — a `max-width: 1199px` breakpoint stacked it on every laptop.
|
||||
* Measured unstacked (the remove above), because a stacked menu owns a whole row, would "fit"
|
||||
* and flip straight back.
|
||||
*
|
||||
* The menu's own pills wrapping is the "does not fit" signal, and only works because the
|
||||
* unstacked top bar is flex-wrap: nowrap (50-toplayout.css) — otherwise the bar wraps, hands
|
||||
* the menu a row and it always fits. Do not measure the bar's children by offsetTop instead:
|
||||
* the bar is align-items:center over children of differing heights, so offsetTop differs even
|
||||
* on one row. */
|
||||
if (topBar && !stripFitsOneRow(menu)) {
|
||||
/* first step before stacking: collapse the poll pill (~90px) to an icon square and
|
||||
* re-measure — often enough to keep the menu on the brand's row
|
||||
* (theme/50-toplayout.css) */
|
||||
bar.classList.add(CLASS_IND_COMPACT);
|
||||
fitTabStrips();
|
||||
if (!stripFitsOneRow(menu)) {
|
||||
bar.classList.add('fs-bar-stack');
|
||||
fitTabStrips();
|
||||
}
|
||||
}
|
||||
|
||||
/* the cluster's own escalation, for every bar; after the menu's, so a menu that did not fit
|
||||
* has already been given a row of its own by .fs-bar-stack */
|
||||
if (bar && (topBar || document.documentElement.hasAttribute('data-narrow')))
|
||||
fitCluster(bar, menu);
|
||||
|
||||
if (pinned > 0) { bar.style.minHeight = hadMinH; bar.style.height = hadH; }
|
||||
publishBarHeight(bar);
|
||||
}
|
||||
|
||||
/* ---- how tall the bar actually is, for whoever sticks underneath it ----
|
||||
*
|
||||
* A data table's header row sticks while its rows scroll past (theme/30-tables.css), and where the
|
||||
* document scrolls it has to clear the sticky bar. `--fs-bar-h` is only the bar's DESIGNED height:
|
||||
* it grows when the brand wraps, when the menu takes a row (.fs-bar-stack) or when the cluster does
|
||||
* (.fs-bar-actrow), each decided by measurement above. So the measurement is published and the CSS
|
||||
* falls back to the token.
|
||||
*
|
||||
* Written only on a change and rounded to the pixel: this runs on every fit pass, and a custom
|
||||
* property write on :root invalidates style for the whole document. */
|
||||
let _barH = 0;
|
||||
function publishBarHeight(bar) {
|
||||
const root = document.documentElement;
|
||||
if (!bar) return;
|
||||
const h = Math.round(bar.getBoundingClientRect().height);
|
||||
if (!(h > 0) || h === _barH) return;
|
||||
_barH = h;
|
||||
root.style.setProperty('--fs-bar-live', `${h}px`);
|
||||
}
|
||||
|
||||
/* ---- does the right-hand cluster still fit beside the brand? ----
|
||||
*
|
||||
* The cluster is four siblings (indicators, Search, Appearance, Log out), which flexbox wraps one
|
||||
* at a time: on a narrow bar Log out alone drops to a second row, left-aligned under the hostname.
|
||||
* Either the whole cluster shares the brand's row or it takes a row of its own, right-aligned.
|
||||
*
|
||||
* Two steps, cheapest first: collapse the pills to icon squares (~200px of prose), then give the
|
||||
* cluster a row. .fs-ind-compact may already be set by the menu's escalation above and must not be
|
||||
* cleared here — whoever asked for it still needs it. */
|
||||
function fitCluster(bar, menu) {
|
||||
bar.classList.remove('fs-bar-actrow');
|
||||
if (clusterFitsBrandRow(bar, menu))
|
||||
return;
|
||||
|
||||
bar.classList.add(CLASS_IND_COMPACT);
|
||||
if (clusterFitsBrandRow(bar, menu))
|
||||
return;
|
||||
|
||||
bar.classList.add('fs-bar-actrow');
|
||||
}
|
||||
|
||||
/* Add the widths up rather than read positions, for the offsetTop reason above. The menu is
|
||||
* excluded either way: on a row of its own (`ul.nav { flex: 1 1 100% }`) it is not competing, and
|
||||
* where it shares the brand's row it is the child that shrinks (`flex: 1 1 auto`), so counting its
|
||||
* current width would report the cluster as not fitting whenever the menu happened to be wide. */
|
||||
function clusterFitsBrandRow(bar, menu) {
|
||||
const cs = getComputedStyle(bar);
|
||||
const gap = parseFloat(cs.columnGap) || 0;
|
||||
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 */
|
||||
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 (docs/conventions.md). A view
|
||||
* renders its .cbi-tabmenu into #view, which fs-fit's MutationObserver already watches and re-fits
|
||||
* synchronously. #tabmenu is a sibling of #view, but nothing writes it except renderChrome(),
|
||||
* which schedules a fit itself. */
|
||||
|
||||
/* modes -> #modemenu; drives the injected renderMainMenu for the active mode */
|
||||
function renderModeMenu(node, renderMainMenu) {
|
||||
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 where a template has no #modemenu */
|
||||
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 &&`, as fs-menutree's nodeForSegs() walks it: a childless node is an
|
||||
* ordinary leaf, and reading `.children[…]` off one throws out of renderChrome(), taking
|
||||
* the mode menu, the tabs and everything init wires after it */
|
||||
for (let i = 0; i < 3 && node; i++) {
|
||||
node = node.children && node.children[L.env.dispatchpath[i]];
|
||||
url = url + (url ? '/' : '') + L.env.dispatchpath[i];
|
||||
}
|
||||
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 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 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 meaning as prose ("Unsaved Changes: 2") and the collapsed rail is
|
||||
* 68px wide, so the pill wraps onto three lines and hangs past the rail's edge (issue #14). CSS
|
||||
* squares the pill there and draws this attribute instead of the label — a text node cannot be
|
||||
* reached by a selector, so the badge is lifted into an attribute here.
|
||||
*
|
||||
* The count is the only part that changes and the only part readable at that size. A pill with no
|
||||
* trailing number falls back to a neutral dot; clipping the prose instead renders as garbage
|
||||
* ("up pen"), because a centred pill gives an ellipsis no start to anchor to. The full prose
|
||||
* stays in the label for screen readers, and in `title` for the pointer. */
|
||||
const IND_DOT = '•';
|
||||
|
||||
/* Idempotent attribute write, so a poll tick that finds nothing changed touches no DOM — same
|
||||
* shape as `fsSyncAttr` in menu-footstrap-common.js, restated rather than imported (that file does
|
||||
* not export it). */
|
||||
function syncIndAttr(el, name, value) {
|
||||
if (value === null) {
|
||||
if (el.hasAttribute(name)) el.removeAttribute(name);
|
||||
} else if (el.getAttribute(name) !== value) {
|
||||
el.setAttribute(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
/* A CLICKABLE `[data-indicator]` (the poll pill, "Unsaved Changes: N", …) ships as a bare
|
||||
* span: no role, name or tabindex, so Tab skips it and a screen reader
|
||||
* announces a run of text with no name, role or state (WCAG 2.1.1, 4.1.2). ui.showIndicator's own
|
||||
* click handler already lives on this exact element — this only adds the second, W3C-APG way to
|
||||
* reach it; it does not add a competing one. The name is the pill's own prose ("Refreshing"),
|
||||
* never invented. Enter/Space call el.click() because a <span>, unlike an <a>, gets neither key
|
||||
* for free (contrast fs-widgets.js's wireSpaceKey, written for an <a role="button">); calling
|
||||
* click() cannot double-fire the mouse handler, since a keydown is not a click. */
|
||||
function wireIndicatorKeyboard(el) {
|
||||
if (el.dataset.fsWired) return;
|
||||
el.dataset.fsWired = '1';
|
||||
el.addEventListener('keydown', (ev) => {
|
||||
if (ev.key !== 'Enter' && ev.key !== ' ' && ev.key !== 'Spacebar') return;
|
||||
ev.preventDefault();
|
||||
el.click();
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
/* Only a CLICKABLE indicator becomes a control. `ui.showIndicator` sets
|
||||
* `data-clickable` only when it was handed a handler, and a status-only pill is
|
||||
* given none (luci-base ui.js; `battstatus.js` calls it with a null handler) — a
|
||||
* `role="button"` tabstop with nothing behind it is worse than the bare span it
|
||||
* replaces. The ring and the pointer cursor in theme/20-shell.css are scoped to
|
||||
* `[data-clickable]` for the same reason; the two must not disagree. */
|
||||
if (el.hasAttribute('data-clickable')) {
|
||||
syncIndAttr(el, 'role', 'button');
|
||||
syncIndAttr(el, 'tabindex', '0');
|
||||
syncIndAttr(el, 'aria-label', txt.trim() || null);
|
||||
wireIndicatorKeyboard(el);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ui.showIndicator replaces the label's text node on an update and appends the span on the
|
||||
* first change of the session, so childList, subtree and characterData all matter. Our own
|
||||
* attribute writes do not re-enter: attributes are not observed.
|
||||
*
|
||||
* AND THE BAR IS RE-FITTED HERE, because a pill arriving is a layout change the fit engine
|
||||
* cannot see: fs-fit watches `#view` and the dialog, and `#indicators` is in the chrome. The
|
||||
* cluster then wraps — flexbox answers first — and the compact form only follows when something
|
||||
* else happens to wake the pass. Measured on WebKit at 390px in the narrow sidebar bar, a second
|
||||
* indicator beside the poll pill pushed the whole page down 91px and held it there for 708 ms,
|
||||
* until `fs-ind-compact` landed at 771 ms and it snapped back: a lurch down and up, once per
|
||||
* appearance, which is what "the Overview twitches, as if an invisible loading bar came and
|
||||
* went" is from the reader's side. Called from the same callback as `stamp()`, so the decision
|
||||
* is taken in the microtask before paint and the wrapped frame is never drawn. It cannot
|
||||
* re-enter: `fitChrome()` writes classes on the BAR and attributes here, and neither is what
|
||||
* this observer watches.
|
||||
*
|
||||
* ON A PILL ARRIVING OR LEAVING, not on its text: `ui.showIndicator` rewrites the poll pill's
|
||||
* label on every tick, and a fit is a handful of forced layouts — running one per tick to answer
|
||||
* a label that did not change width is the cost this file spends its measurements avoiding. A
|
||||
* childList record is the cluster gaining or losing a member, which is the layout change that
|
||||
* wraps it. */
|
||||
new MutationObserver((recs) => {
|
||||
stamp();
|
||||
if (recs.some((r) => r.type === 'childList' && r.target === box)) fitChrome();
|
||||
})
|
||||
.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 fit rides the same engine as the
|
||||
* data tables' */
|
||||
fitChrome,
|
||||
|
||||
/* The width a page's content column has: the sidebar or rail eats a known amount of the
|
||||
* window and the shell adds a known padding, all memoised for fitShell(). Exported because a
|
||||
* pass answering mid-scroll (fs-select's, for a table the poll just brought in) otherwise has
|
||||
* only the window width, which in the sidebar layout is wrong by exactly the sidebar — at
|
||||
* 800px the column is 520px, so a table judged to have room overflows.
|
||||
*
|
||||
* The arithmetic is columnWidth()'s; this only adds the page's current state. */
|
||||
contentWidth() {
|
||||
/* `_shellOuter` is refreshed only by measureShell(), which only fitShell() calls, and
|
||||
* fitChrome() steps ASIDE FOR THE WHOLE SCROLL_IDLE WINDOW (400ms, fs-fit.js) whenever
|
||||
* fit.scrolling() answers yes — including for a resize that lands mid-flick, since a
|
||||
* resize is exactly what starts that window (fs-fit.js's resize observer feeds the same
|
||||
* motion sampler `scrolling()` reads). A caller landing in that window, most of all
|
||||
* fs-select's, got the width the PREVIOUS viewport had: at 568px settling to 390px, model
|
||||
* stayed 568 for up to 220ms of the 400 (measured: −178px, exactly 568−390;
|
||||
* ../tmp/task-vnstat/probe2.mjs, probe3.mjs; live-audit's `geometry|fs-content` finding on
|
||||
* owrt2410, CI run 34364446910).
|
||||
*
|
||||
* So the window's own width is compared fresh on every call, not only when `_shellOuter`
|
||||
* is still zero. `clientWidth` is the one read the old "no layout read" promise here was
|
||||
* already conditional on: the bootstrap branch (no fitter has run yet) made this exact
|
||||
* call through measureShell(). A plain `!==` compares against the cached width for free —
|
||||
* nothing invalidated layout since the last read, so this costs nothing when nothing
|
||||
* moved — and measureShell()'s own further reads (the resolved gutter) only run when the
|
||||
* comparison says the width actually did. */
|
||||
if (document.documentElement.clientWidth !== _shellOuter) measureShell();
|
||||
const root = document.documentElement;
|
||||
return columnWidth(shellGeometry(), {
|
||||
outerW: _shellOuter,
|
||||
narrow: root.hasAttribute('data-narrow'),
|
||||
top: prefs.isTopLayout(),
|
||||
rail: prefs.currentRail()
|
||||
});
|
||||
},
|
||||
|
||||
/* exported for tests/chrome-geometry.test.mjs (no tests ship in the package): driving the
|
||||
* arithmetic directly is the only way to cover every combination of layout, rail and width
|
||||
* without a browser */
|
||||
columnWidth, /* fs:probe */
|
||||
wireRail
|
||||
});
|
||||
@@ -0,0 +1,672 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require ui';
|
||||
|
||||
/* fs-fit — the theme's one "does it still fit?" engine and the one place the reader's scroll
|
||||
* position is corrected. Add fit logic here, never a second observer: no CSS query can ask what the
|
||||
* content needs (media = viewport, container = container). Three rules, each a bug that was hit:
|
||||
* rule 1, measure uncollapsed, since a collapsed thing always fits; rule 2, re-fit synchronously on
|
||||
* a mutation, since deferring a poll tick to rAF paints one frame of a stacked table at full width
|
||||
* (19-109px of overflow); rule 3, coalesce on resize, since every fit forces a layout. The reader's
|
||||
* place is docs/anchoring.md, "The corrections"; the measurement behind each rule is
|
||||
* docs/anchoring-log.md. */
|
||||
|
||||
/* Exported, not armed at module eval: only fs-select.js clears the rule this raises, and arming
|
||||
* without it left every data table invisible. docs/anchoring-log.md, "The arm belongs to the disarm". */
|
||||
function armGate() {
|
||||
if (!fittersEnabled()) return;
|
||||
/* WRITTEN AS THE LITERAL `dataset.fsFit`, never through a helper: tools/table-contract.mjs
|
||||
* reads this file for exactly that spelling to prove the gate rule is still armed, and an
|
||||
* indirection hides the write from it. The behaviour survives being factored out; the
|
||||
* contract does not. */
|
||||
try { document.documentElement.dataset.fsFit = '1'; }
|
||||
catch (e) { /* no document, no flag to write */ }
|
||||
}
|
||||
|
||||
const _fitters = [];
|
||||
let _rafPending = false;
|
||||
let _ro = null, _mo = null, _moFlag = null, _moTabs = null;
|
||||
|
||||
function runAll(list, what) {
|
||||
for (const fit of list) {
|
||||
try { fit(); }
|
||||
catch (e) { console.error('fs-fit: a ' + what + ' threw', e); }
|
||||
}
|
||||
}
|
||||
|
||||
/* dev switch — docs/anchoring.md, "The dev switches" */
|
||||
function fittersEnabled() {
|
||||
try { return localStorage.getItem('fsFit') !== 'off'; }
|
||||
catch (e) { return true; }
|
||||
}
|
||||
function run(records) {
|
||||
if (!fittersEnabled()) return;
|
||||
runAll(_fitters, 'fitter');
|
||||
holdFloor(records);
|
||||
if (!_anchorPending) rememberRest();
|
||||
}
|
||||
|
||||
/* ---- the document may not get shorter while a tick is in flight ----
|
||||
* A layout taken while `dom.content()` has the container empty clamps the reader's offset into a
|
||||
* document that was never that short. Written before the tick (a pin in the same statement
|
||||
* sequence is never laid out: 1882px still clamped away) and on the containers, never the column,
|
||||
* where it suppresses the engine's own anchoring (css-scroll-anchoring-1 §2.2.2) and cost 120px of
|
||||
* growth, all 120px. The selector is `dom.content()`'s own three: a section body, a table, and a
|
||||
* table's body — the third was missing and a poll emptying it took 58px from under the reader on
|
||||
* 24.10's Overview. docs/anchoring.md, "The document may not get shorter: `holdFloor()`". */
|
||||
const SHRINKS = '.cbi-section > div, .table';
|
||||
|
||||
const FLOORED = '[data-fs-floor]';
|
||||
|
||||
/* One floor per container, cleared and re-measured in one batched pass: not while the reader
|
||||
* scrolls, and not on a table box — `min-height` is undefined there (CSS 2.1 §10.7) and a 313px
|
||||
* floor still collapsed to 30px on WebKit. The clear is what keeps the answer honest (issue #41). */
|
||||
function holdFloor(records) {
|
||||
if (scrolling()) return;
|
||||
const host = document.getElementById('view');
|
||||
if (!host) return; /* the login page has no view */
|
||||
const boxes = [], hs = [];
|
||||
host.querySelectorAll(SHRINKS).forEach((el) => {
|
||||
let box = el, cs = window.getComputedStyle(el);
|
||||
while (box && box !== host && cs.display.startsWith('table')) {
|
||||
box = box.parentElement;
|
||||
if (box) cs = window.getComputedStyle(box);
|
||||
}
|
||||
if (!box || box === host || boxes.indexOf(box) !== -1) return;
|
||||
boxes.push(box);
|
||||
});
|
||||
host.querySelectorAll(FLOORED).forEach((box) => { if (boxes.indexOf(box) === -1) boxes.push(box); });
|
||||
|
||||
let dirty = boxes;
|
||||
if (records && records.length) {
|
||||
const targets = [];
|
||||
for (const r of records) if (r.target && targets.indexOf(r.target) === -1) targets.push(r.target);
|
||||
dirty = boxes.filter((box) => targets.some((t) => box.contains(t) || t.contains(box)));
|
||||
}
|
||||
if (!dirty.length) return;
|
||||
|
||||
/* The sweep puts back the offset its own clear pass took: 8 of 38 sweeps took the document down a
|
||||
* pixel and the offset with it, losing a 60px correction. docs/anchoring-log.md, "The sweep's own clamp". */
|
||||
const sc = scroller(), page = sc || document.documentElement;
|
||||
const at = scrollTop(), tall = page.scrollHeight;
|
||||
dirty.forEach((box) => { box.style.minHeight = ''; });
|
||||
/* The box's own rect height, not `offsetHeight`'s rounding: 22 floors half a pixel too tall made
|
||||
* the document 2px taller, and the next clear handed that back as a clamp — 6 of 12 refills
|
||||
* corrected against 12 of 12. docs/anchoring-log.md, "The floor is written half a pixel too tall". */
|
||||
dirty.forEach((box) => hs.push(box.getBoundingClientRect().height));
|
||||
dirty.forEach((box, i) => {
|
||||
if (hs[i] > 0) { box.style.minHeight = hs[i] + 'px'; box.setAttribute('data-fs-floor', ''); }
|
||||
else box.removeAttribute('data-fs-floor');
|
||||
});
|
||||
/* And where the scroller stayed shorter the shrink is real: the clamp gave back only the 60px the
|
||||
* document lost at its bottom. docs/anchoring-log.md, "A clamp is not the reader". */
|
||||
const landed = scrollTop();
|
||||
if (landed >= at) return;
|
||||
if (page.scrollHeight >= tall) writeOffset(sc, at); else _clampedTo = landed;
|
||||
}
|
||||
|
||||
/* Is the page moving? asked of the position, never of the events: on iOS momentum carries the page
|
||||
* long after the finger has gone, and one offset read per frame sees it. SCROLL_IDLE is how long the
|
||||
* page must hold still before put-off work may run: 137-256px of roughness at 200ms against 59px —
|
||||
* one pixel of rounding per frame, the floor — at 250ms and above.
|
||||
* docs/anchoring.md, "Is the page moving: `scrolling()`". */
|
||||
const SCROLL_IDLE = 400;
|
||||
let _deferred = false;
|
||||
function deferMeasurement() { _deferred = true; }
|
||||
let _movingUntil = 0;
|
||||
let _lastOffset = null;
|
||||
let _sampling = false;
|
||||
/* This file's own write is a scroll event too, and counting it as motion blocked the floor, the
|
||||
* reference and the correction for 400ms — 59px uncorrected, and two of them distrusted a correct
|
||||
* engine. docs/anchoring-log.md, "A correction's own write reads as the reader moving". */
|
||||
let _ownWrite = null;
|
||||
function sawOwnWrite(y) {
|
||||
if (_ownWrite === null) return true;
|
||||
if (Math.abs(y - _ownWrite) < 1) return false;
|
||||
_ownWrite = null;
|
||||
return true;
|
||||
}
|
||||
/* Where the browser's clamp last put the offset, read by `applyAnchor()` alone — `scrolling()`
|
||||
* keeps answering for the whole theme. docs/anchoring-log.md, "A clamp is not the reader". */
|
||||
let _clampedTo = null;
|
||||
function sawClamp() {
|
||||
if (scrollTop() === _clampedTo) return true;
|
||||
_clampedTo = null;
|
||||
return false;
|
||||
}
|
||||
function writeOffset(sc, value) {
|
||||
if (sc) sc.scrollTop = value; else window.scrollTo(0, value);
|
||||
_ownWrite = sc ? sc.scrollTop : window.scrollY;
|
||||
}
|
||||
|
||||
/* Which element scrolls, cached per width: a probe per frame is a forced layout mid-flick, and the
|
||||
* stylesheet decides it. docs/anchoring-log.md, "Which element scrolls, asked once per width". */
|
||||
let _scroller = null, _scrollerAt = -1, _scrollerKey = null;
|
||||
function layoutKey() {
|
||||
const root = document.documentElement;
|
||||
return (root.getAttribute('data-layout') || '') + (root.hasAttribute('data-narrow') ? '|narrow' : '');
|
||||
}
|
||||
function scroller() {
|
||||
const key = layoutKey();
|
||||
if (_scrollerAt === _resizeSeq && _scrollerKey === key &&
|
||||
(_scroller === null || _scroller.isConnected))
|
||||
return _scroller;
|
||||
const sc = document.getElementById('maincontent');
|
||||
const flow = sc ? window.getComputedStyle(sc).overflowY : '';
|
||||
_scroller = (flow === 'auto' || flow === 'scroll') ? sc : null;
|
||||
_scrollerAt = _resizeSeq;
|
||||
_scrollerKey = key;
|
||||
return _scroller;
|
||||
}
|
||||
function scrollTop() {
|
||||
const sc = scroller();
|
||||
return sc ? sc.scrollTop : window.scrollY;
|
||||
}
|
||||
|
||||
function scrolling() { return Date.now() < _movingUntil; }
|
||||
function sampleMotion() {
|
||||
const y = scrollTop();
|
||||
if (_lastOffset === null || y !== _lastOffset) {
|
||||
const own = !sawOwnWrite(y);
|
||||
_lastOffset = y;
|
||||
if (!own) _movingUntil = Date.now() + SCROLL_IDLE;
|
||||
}
|
||||
if (scrolling()) { requestAnimationFrame(sampleMotion); return; }
|
||||
_sampling = false;
|
||||
/* The offset's own response to the write below, not a reference element's drift: one re-established
|
||||
* on wrong ground shows none. docs/anchoring-log.md, "A floor that shrinks with nobody watching". */
|
||||
const target = _deferredFloor;
|
||||
_deferredFloor = null;
|
||||
const floorBefore = (target && target.isConnected) ? (parseFloat(target.style.minHeight) || 0) : 0;
|
||||
const offsetBefore = scrollTop();
|
||||
holdFloor();
|
||||
if (target && target.isConnected) {
|
||||
const shrink = floorBefore - (parseFloat(target.style.minHeight) || 0);
|
||||
if (shrink > 1) settleDeferredFloor(offsetBefore, shrink);
|
||||
}
|
||||
rememberRest();
|
||||
if (_deferred) {
|
||||
_deferred = false;
|
||||
run();
|
||||
}
|
||||
}
|
||||
|
||||
function noteMotion() {
|
||||
if (!sawOwnWrite(scrollTop())) return;
|
||||
_movingUntil = Date.now() + SCROLL_IDLE;
|
||||
if (_sampling) return;
|
||||
_sampling = true;
|
||||
requestAnimationFrame(sampleMotion);
|
||||
}
|
||||
|
||||
let _userUntil = 0;
|
||||
function noteIntent() {
|
||||
_userUntil = Date.now() + SCROLL_IDLE;
|
||||
}
|
||||
function noteUser() {
|
||||
noteIntent();
|
||||
noteMotion();
|
||||
}
|
||||
|
||||
(function watchMotion() {
|
||||
const opts = { passive: true, capture: true };
|
||||
window.addEventListener('scroll', noteMotion, opts);
|
||||
for (const name of [ 'wheel', 'touchmove' ])
|
||||
window.addEventListener(name, noteUser, opts);
|
||||
for (const name of [ 'mousedown', 'keydown', 'touchstart' ])
|
||||
window.addEventListener(name, noteIntent, opts);
|
||||
})();
|
||||
|
||||
function schedule() {
|
||||
if (_rafPending) return;
|
||||
_rafPending = true;
|
||||
requestAnimationFrame(() => { _rafPending = false; run(); });
|
||||
}
|
||||
|
||||
/* Width only: iOS resizes the viewport height as the URL bar slides away, and twenty height-only
|
||||
* steps rewrote 1054 class attributes. docs/anchoring-log.md, "Width only: the URL bar is a resize". */
|
||||
let _resizeSeq = 0;
|
||||
const _lastWidth = new WeakMap();
|
||||
function onResize(entries) {
|
||||
let widthMoved = false;
|
||||
for (const e of entries) {
|
||||
const w = Math.round(e.contentRect.width);
|
||||
if (_lastWidth.get(e.target) !== w) {
|
||||
_lastWidth.set(e.target, w);
|
||||
widthMoved = true;
|
||||
}
|
||||
}
|
||||
if (widthMoved) { _resizeSeq++; schedule(); }
|
||||
}
|
||||
|
||||
function watch(el) {
|
||||
if (!el) return;
|
||||
if (!_ro) _ro = new ResizeObserver(onResize);
|
||||
_ro.observe(el);
|
||||
}
|
||||
|
||||
/* ---- scroll anchoring, where the engine has none ----
|
||||
* An older WebKit absorbs none of a tick's growth and the page moved on every one, measured on the
|
||||
* reporter's own router: +133px, +134px, +123px, +108px. Computed from the reference, never the
|
||||
* offset: measuring the offset corrects an anchoring engine's own adjustment (16 movements, 1827px).
|
||||
* Whether there is one to leave anything behind is asked of the platform, never of a browser name —
|
||||
* with anchoring suppressed, a 120px growth moves the reader 120px.
|
||||
* docs/anchoring.md, "Who is responsible: `ENGINE_ANCHORS`" and "The corrections". */
|
||||
const ENGINE_ANCHORS = (() => {
|
||||
/* dev switch — docs/anchoring.md, "The dev switches" */
|
||||
try { if (localStorage.getItem('fsEngineAnchor') === 'off') return false; }
|
||||
catch (e) { /* no storage, no switch */ }
|
||||
try { return typeof CSS !== 'undefined' && typeof CSS.supports === 'function'
|
||||
? CSS.supports('overflow-anchor', 'auto') : true; }
|
||||
catch (e) { return true; }
|
||||
})();
|
||||
|
||||
/* A load-time check cannot see an engine that declines on a given refill: two residuals written
|
||||
* back 419-420ms late move the observer to the 7-36ms path until trust returns. Never a browser
|
||||
* name, only a count. docs/anchoring.md, "When the platform check is not enough: `_engineTrusted`". */
|
||||
const LATE_MISS_LIMIT = 2;
|
||||
let _lateMisses = 0;
|
||||
let _engineTrusted = ENGINE_ANCHORS;
|
||||
/* The witness's own rounding on a 32-36 row lease table at 390 wide is 8-12.25px, none of them a
|
||||
* residual. docs/anchoring-log.md, "The witness is not safe to write on its own". */
|
||||
const LATE_ROUND_TOLERANCE = 16;
|
||||
|
||||
/* Trust returns after this many refills where `_rest.el` itself held: the two cheaper witnesses
|
||||
* tried recovered on an engine still 48px wrong. docs/anchoring-log.md, "Trust that comes back". */
|
||||
const TRUST_RECOVERY_LIMIT = 2;
|
||||
let _lateHits = 0;
|
||||
|
||||
let _rest = null;
|
||||
let _restAt = null, _restPage = null;
|
||||
/* The floored box `holdFloor()` refused for: wired to nothing, a growth-then-shrink refill inside
|
||||
* one motion window left `_restAt` 59px high.
|
||||
* docs/anchoring-log.md, "A floor that shrinks with nobody watching". */
|
||||
let _deferredFloor = null;
|
||||
function pageStamp() {
|
||||
return (document.body && document.body.getAttribute('data-page')) || '';
|
||||
}
|
||||
function forgetRest() {
|
||||
_rest = null;
|
||||
_restAt = null;
|
||||
_restPage = null;
|
||||
}
|
||||
/* `force`: only for the caller that has just written the offset itself, since the engine's own
|
||||
* compensation leaves `scrolling()` true for milliseconds after it — `moved 0, 0, -59` without,
|
||||
* `0, 0, 0` with it. docs/anchoring-log.md, "The reference after this file's own write must be
|
||||
* re-taken forced". */
|
||||
function rememberRest(force) {
|
||||
if (scrolling() && !force) return;
|
||||
if (ENGINE_ANCHORS && scrollTop() <= 0) {
|
||||
_rest = null;
|
||||
_restAt = 0;
|
||||
_restPage = pageStamp();
|
||||
return;
|
||||
}
|
||||
const ref = anchorRef();
|
||||
_restAt = scrollTop();
|
||||
_restPage = pageStamp();
|
||||
_rest = ref ? { el: ref.el, top: ref.top, at: _restAt, sec: ref.sec, secTop: ref.secTop } : null;
|
||||
}
|
||||
|
||||
function anchorFor() {
|
||||
const at = scrollTop();
|
||||
const clamped = (_restAt != null && at < _restAt && !scrolling() && _restPage === pageStamp());
|
||||
if (!_rest || !_rest.el.isConnected) {
|
||||
if (clamped) return { by: _restAt - at };
|
||||
if (_rest && _rest.sec && _rest.sec.isConnected && at === _restAt)
|
||||
return { el: _rest.sec, top: _rest.secTop, slack: 0 };
|
||||
return anchorRef();
|
||||
}
|
||||
if (at !== _rest.at && !clamped) return null;
|
||||
return { el: _rest.el, top: _rest.top, slack: Math.max(0, _rest.at - at) };
|
||||
}
|
||||
|
||||
function anchorRef() {
|
||||
if (scrolling()) return null;
|
||||
|
||||
const host = document.getElementById('view');
|
||||
if (!host) return null;
|
||||
const box = host.getBoundingClientRect();
|
||||
const x = Math.round(box.left + (Math.min(box.width, window.innerWidth || box.width) / 2));
|
||||
let y = 1;
|
||||
let el = document.elementFromPoint(x, y);
|
||||
const chrome = el && el.closest ? el.closest('[data-fs-chrome]') : null;
|
||||
if (chrome) y = Math.max(1, Math.round(chrome.getBoundingClientRect().bottom) + 1);
|
||||
|
||||
const floor = Math.max(1, Math.round(window.innerHeight || 800));
|
||||
const pick = (yy) => {
|
||||
if (typeof document.elementsFromPoint === 'function') {
|
||||
for (const cand of document.elementsFromPoint(x, yy))
|
||||
if (cand !== host && host.contains(cand)) return cand;
|
||||
return null;
|
||||
}
|
||||
const one = document.elementFromPoint(x, yy);
|
||||
return (one && one !== host && host.contains(one)) ? one : null;
|
||||
};
|
||||
el = null;
|
||||
for (let step = 0; step < 5 && !el; step++)
|
||||
el = pick(Math.min(floor - 1, y + (Math.round(floor * 0.12) * step)));
|
||||
if (!el) return null;
|
||||
const table = el.closest('.table.fs-dt');
|
||||
if (table) {
|
||||
const up = table.parentElement;
|
||||
el = (up && up !== host && host.contains(up)) ? up : table;
|
||||
}
|
||||
if (!el || el === host || !host.contains(el)) return null;
|
||||
if (!el.getClientRects().length) return null;
|
||||
let keep = el.parentElement;
|
||||
while (keep && keep !== host && !keep.classList.contains('cbi-section')
|
||||
&& !keep.classList.contains('cbi-map') && !keep.classList.contains('fs-ovl'))
|
||||
keep = keep.parentElement;
|
||||
if (!keep || keep === host || !host.contains(keep)) keep = null;
|
||||
return { el, top: el.getBoundingClientRect().top,
|
||||
sec: keep, secTop: keep ? keep.getBoundingClientRect().top : 0 };
|
||||
}
|
||||
|
||||
let _anchorPending = null;
|
||||
let _anchorWhy = null;
|
||||
const _anchorTrail = [];
|
||||
function awhy(w) {
|
||||
_anchorWhy = w;
|
||||
_anchorTrail.push(w + '@' + Math.round(performance.now()));
|
||||
if (_anchorTrail.length > 8) _anchorTrail.shift();
|
||||
}
|
||||
let _anchorFrame = 0;
|
||||
function anchorEnabled() {
|
||||
try { return localStorage.getItem('fsAnchor') !== 'off'; }
|
||||
catch (e) { return true; }
|
||||
}
|
||||
/* ---- what the engine's own anchoring leaves behind ----
|
||||
* It keeps a reference still; it does not promise that a section can vanish and come back. An
|
||||
* older WebKit moved 180px for 120px of growth, so the element is asked where it is now. */
|
||||
let _lateFrame = 0;
|
||||
/* why the last late correction did or did not write — eight exits, one symptom from outside */
|
||||
let _lateWhy = null;
|
||||
const _lateTrail = [];
|
||||
function why(w) {
|
||||
_lateWhy = w;
|
||||
_lateTrail.push(w + '@' + Math.round(performance.now()));
|
||||
if (_lateTrail.length > 8) _lateTrail.shift();
|
||||
}
|
||||
|
||||
function lateDrift(ref, grow, floorShrink) {
|
||||
if (_lateFrame) return why('busy');
|
||||
if (!ref) return why('no-reference');
|
||||
why('armed');
|
||||
_lateFrame = requestAnimationFrame(() => {
|
||||
why('frame');
|
||||
const seen = scrollTop();
|
||||
const settle = () => {
|
||||
_lateFrame = 0;
|
||||
why('settle');
|
||||
if (!anchorEnabled()) return why('anchoring-off');
|
||||
if (Date.now() < _userUntil) return why('reader-intent');
|
||||
if (_restPage !== pageStamp()) return why('page-changed');
|
||||
/* The offset, not the event stream: the engine's own compensation starts the motion sampler, and
|
||||
* in WebKit the event arrives up to 1.2s late. `ref.at`, since run() has re-taken `_restAt` and the
|
||||
* comparison becomes a value against itself (320px through, @1440 side). */
|
||||
if (scrollTop() !== seen) return why('offset-moved');
|
||||
let el = ref.el, was = ref.top;
|
||||
if (!el || !el.isConnected) {
|
||||
if (!ref.sec || !ref.sec.isConnected || ref.secTop == null) return why('reference-gone');
|
||||
el = ref.sec; was = ref.secTop;
|
||||
}
|
||||
let drift = el.getBoundingClientRect().top - was;
|
||||
/* The witness can be blind: the fold's element need not sit below the container this tick refilled,
|
||||
* 13 of 13 refills reading 0px with the reader 120px off, and a gap under the tolerance is `grow`'s
|
||||
* own rounding. docs/anchoring-log.md, "A witness at the fold can be blind". */
|
||||
if (Math.abs(drift) < 1 && grow > 1) {
|
||||
const compensated = seen - ref.at;
|
||||
if (Math.abs(compensated) < 1) drift = grow - compensated;
|
||||
else if (Math.abs(grow - compensated) > LATE_ROUND_TOLERANCE) {
|
||||
if (++_lateMisses >= LATE_MISS_LIMIT) { _engineTrusted = false; _lateHits = 0; }
|
||||
rememberRest(true);
|
||||
return why('engine-partly-' + Math.round(compensated) + '-of-' + Math.round(grow));
|
||||
}
|
||||
}
|
||||
if (Math.abs(drift) < 1) {
|
||||
if (grow > 1 || floorShrink > 1) rememberRest(true);
|
||||
return why('no-drift-grow-' + Math.round(grow)); /* the engine put it back */
|
||||
}
|
||||
if (Math.abs(drift) > (window.innerHeight || 800)) return why('drift-too-big');
|
||||
const sc = scroller();
|
||||
const at = sc ? sc.scrollTop : window.scrollY;
|
||||
writeOffset(sc, at + drift);
|
||||
why('wrote-' + Math.round(drift));
|
||||
/* A fresh, forced rememberRest(): `_rest` was taken mid-transition, and left standing it is what
|
||||
* the next refill measures against. docs/anchoring-log.md, "The reference after this file's
|
||||
* own write must be re-taken forced". */
|
||||
rememberRest(true);
|
||||
/* A drop in this box's own floor says nothing about the engine: every run() rewrites `min-height`,
|
||||
* an invalidation in itself. docs/anchoring-log.md, "A floor shrink is not evidence about the engine". */
|
||||
if (floorShrink > 1) return;
|
||||
/* A direct drift inside the blind branch's tolerance is that same table rounding: 1px against a
|
||||
* 132px growth counted as a miss, twice over.
|
||||
* docs/anchoring-log.md, "One pixel of direct drift is the same table rounding". */
|
||||
if (Math.abs(drift) <= LATE_ROUND_TOLERANCE) return;
|
||||
if (++_lateMisses >= LATE_MISS_LIMIT) { _engineTrusted = false; _lateHits = 0; }
|
||||
};
|
||||
/* How long to wait is a question about the reader (docs/anchoring.md): where nothing is driving,
|
||||
* the engine is the only mover and its window is 7-36ms — 404-422ms became 8-61ms. */
|
||||
if (!scrolling() && Date.now() >= _userUntil) { why('now'); settle(); }
|
||||
else { why(scrolling() ? 'wait-idle-moving' : 'wait-idle-intent'); _lateFrame = window.setTimeout(settle, SCROLL_IDLE); }
|
||||
});
|
||||
}
|
||||
|
||||
/* A floor `holdFloor()` could not clear at mutation time, cleared later with nobody watching: its
|
||||
* own frame slot, and the offset's own response to the write below as the check. Not gated on
|
||||
* `scrolling()` — the belated write is what the engine reacts to, and the reaction re-arms the
|
||||
* motion window, still reading true one rAF later on all three cells.
|
||||
* docs/anchoring-log.md, "A floor that shrinks with nobody watching". */
|
||||
let _floorLateFrame = 0;
|
||||
function settleDeferredFloor(offsetBefore, shrink) {
|
||||
if (_floorLateFrame) return;
|
||||
_floorLateFrame = requestAnimationFrame(() => {
|
||||
const seen = scrollTop();
|
||||
_floorLateFrame = requestAnimationFrame(() => {
|
||||
_floorLateFrame = 0;
|
||||
if (!anchorEnabled() || Date.now() < _userUntil) return;
|
||||
if (_restPage !== pageStamp()) return;
|
||||
if (scrollTop() !== seen) return; /* still settling, or the reader has moved */
|
||||
const wanted = offsetBefore - shrink;
|
||||
const gap = wanted - seen;
|
||||
if (Math.abs(gap) <= LATE_ROUND_TOLERANCE) return; /* the engine already gave it back */
|
||||
if (Math.abs(gap) > (window.innerHeight || 800)) return;
|
||||
const sc = scroller();
|
||||
const at = sc ? sc.scrollTop : window.scrollY;
|
||||
writeOffset(sc, at + gap);
|
||||
if (_rest) { _rest.top -= gap; if (_rest.sec) _rest.secTop -= gap; _rest.at = scrollTop(); }
|
||||
_restAt = scrollTop();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleAnchor(ref) {
|
||||
if (!ref) return awhy('no-reference');
|
||||
if (!anchorEnabled()) return awhy('anchoring-off');
|
||||
if (_anchorPending) return awhy('pending-kept-first');
|
||||
_anchorPending = ref;
|
||||
if (_anchorFrame) return;
|
||||
_anchorFrame = requestAnimationFrame(() => {
|
||||
_anchorFrame = 0;
|
||||
const pending = _anchorPending;
|
||||
_anchorPending = null;
|
||||
const wrote = applyAnchor(pending);
|
||||
rememberRest(wrote);
|
||||
});
|
||||
}
|
||||
function applyAnchor(ref) {
|
||||
if (!ref) return;
|
||||
/* Not into a moving page — unless the motion IS the clamp this correction is for, which held a
|
||||
* correct -60px correction unwritten. docs/anchoring-log.md, "A clamp is not the reader". */
|
||||
if (scrolling() && !sawClamp()) return awhy('refused-moving');
|
||||
const sc = scroller();
|
||||
const at = sc ? sc.scrollTop : window.scrollY;
|
||||
if (ref.by != null) {
|
||||
if (ref.by < 1) return awhy('by-under-1');
|
||||
writeOffset(sc, at + ref.by);
|
||||
awhy('wrote-by-' + Math.round(ref.by));
|
||||
return true;
|
||||
}
|
||||
if (at <= 0) return awhy('at-top');
|
||||
if (!ref.el.isConnected) return awhy('reference-gone');
|
||||
const drift = ref.el.getBoundingClientRect().top - ref.top;
|
||||
if (Math.abs(drift) < 1) return awhy('no-drift'); /* nothing needed correcting here */
|
||||
_lateHits = 0;
|
||||
if (Math.abs(drift) > (window.innerHeight || 0) + 200 + (ref.slack || 0)) return awhy('drift-too-big');
|
||||
writeOffset(sc, at + drift);
|
||||
awhy('wrote-' + Math.round(drift));
|
||||
return true;
|
||||
}
|
||||
|
||||
function observeContent() {
|
||||
if (_mo) return;
|
||||
const hosts = [ document.getElementById('view') || document.body, document.getElementById('modal_overlay') ]
|
||||
.filter(Boolean);
|
||||
const viewHost = hosts[0];
|
||||
_mo = new MutationObserver((records) => {
|
||||
const settled = _rest;
|
||||
const trustEngine = _engineTrusted;
|
||||
const ref = trustEngine ? null : anchorFor();
|
||||
/* The record's own target, read before run() rewrites its floor: the growth in pixels, rather than
|
||||
* whichever element the fold happened to hit, and read on every tick so recovery can tell a tick
|
||||
* that tested the engine from one that did not. The floored box the record sits IN, since
|
||||
* `dom.content()` refills a node inside the pinned box on 1 of 12 nodes (docs/anchoring-log.md,
|
||||
* "A witness that cannot see the box it is measuring"). */
|
||||
let r = null, box = null;
|
||||
for (const m of records) {
|
||||
if (m.type !== 'childList' || !m.target.closest) continue;
|
||||
const b = m.target.closest(FLOORED);
|
||||
if (b) { r = m; box = b; break; }
|
||||
}
|
||||
const before = box && (parseFloat(box.style.minHeight) || 0);
|
||||
let grew = before ? box.offsetHeight - before : 0;
|
||||
/* only growth above the reader's reference: counting a box below it wrote the whole growth back,
|
||||
* offset +120 and reader -120 (docs/anchoring-log.md, "Growth below the reader") */
|
||||
if (grew > 1 && settled && settled.el && settled.el.isConnected
|
||||
&& box.getBoundingClientRect().top >= settled.el.getBoundingClientRect().top) grew = 0;
|
||||
if (!trustEngine && grew > 1 && _rest && _rest.el.isConnected && Date.now() >= _userUntil
|
||||
&& !scrolling() && _restPage === pageStamp()
|
||||
&& Math.abs(_rest.el.getBoundingClientRect().top - _rest.top) < 1
|
||||
&& ++_lateHits >= TRUST_RECOVERY_LIMIT) {
|
||||
_engineTrusted = true;
|
||||
_lateMisses = _lateHits = 0;
|
||||
}
|
||||
const wasScrolling = scrolling();
|
||||
if (r && before && wasScrolling) _deferredFloor = box;
|
||||
run(records);
|
||||
if (!wasScrolling) _deferredFloor = null;
|
||||
const floorShrink = (r && before) ? Math.max(0, before - (parseFloat(box.style.minHeight) || 0)) : 0;
|
||||
/* `#view` itself emptied and refilled is a page swap: a reference measured mid-swap is read 431px
|
||||
* out and written over a correct restoreScroll(). docs/anchoring.md, "The commit is not a refill". */
|
||||
let gone = 0, came = 0, took = 0, gave = 0;
|
||||
for (const m of records) {
|
||||
if (m.target === viewHost) { gone += m.removedNodes.length; came += m.addedNodes.length; }
|
||||
if (m.type === 'childList') { took += m.removedNodes.length; gave += m.addedNodes.length; }
|
||||
}
|
||||
if (gone && came) {
|
||||
forgetRest();
|
||||
return;
|
||||
}
|
||||
/* Not on a batch that only took nodes away: arming on the empty half wrote -834px, and superseding
|
||||
* with the later batch cost 12 findings. docs/anchoring-log.md, "The later batch must not win". */
|
||||
if (trustEngine) {
|
||||
if (took && !gave && grew <= 0 && floorShrink <= 1) why('emptying');
|
||||
else lateDrift(settled, grew, floorShrink);
|
||||
}
|
||||
else scheduleAnchor(ref);
|
||||
});
|
||||
for (const host of hosts) {
|
||||
_mo.observe(host, { childList: true, subtree: true });
|
||||
watch(host);
|
||||
}
|
||||
_moFlag = new MutationObserver(() => run());
|
||||
_moFlag.observe(document.body, { attributes: true, attributeFilter: [ 'class' ] });
|
||||
|
||||
/* A tab switch, a closing fold and a `depends()` row mutate no node, and `min-height` beats the
|
||||
* `height: 0` they collapse with: 1299px of blank left standing (docs/anchoring-log.md, "A tab
|
||||
* switch moves no node"). A same-value write is not a change, though: the fitters re-apply classes
|
||||
* every pass, and an unguarded `class` watch froze the tab, 391 callbacks in 432ms
|
||||
* (docs/anchoring-log.md, "A same-value class write is a feedback loop"). */
|
||||
_moTabs = new MutationObserver((records) =>
|
||||
records.some((r) => r.oldValue !== r.target.getAttribute(r.attributeName)
|
||||
&& (r.attributeName !== 'class' || r.target.dataset.field)) && run());
|
||||
for (const host of hosts)
|
||||
_moTabs.observe(host, { attributes: true, attributeOldValue: true,
|
||||
attributeFilter: [ 'data-tab-active', 'hidden', 'aria-expanded', 'class' ], subtree: true });
|
||||
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
add(fit) {
|
||||
if (typeof fit !== 'function') return;
|
||||
_fitters.push(fit);
|
||||
observeContent();
|
||||
try { fit(); }
|
||||
catch (e) { console.error('fs-fit: a fitter threw on registration', e); }
|
||||
},
|
||||
|
||||
scrolling,
|
||||
/* unmarked, for tools/scroll-anchor.mjs — see `_lateWhy` */
|
||||
lateWhy: () => _lateWhy,
|
||||
lateTrail: () => _lateTrail.slice(),
|
||||
anchorWhy: () => _anchorWhy,
|
||||
anchorTrail: () => _anchorTrail.slice(),
|
||||
deferMeasurement,
|
||||
|
||||
/* -> the offset this file last took a reference at, so a probe does not measure the guard instead
|
||||
* of the anchor. No probe marker: packaging strips those, and the sweep reads the installed package. */
|
||||
restAt: () => _restAt,
|
||||
|
||||
/* -> whether the engine is still trusted with a refill; unmarked, like `restAt` above */
|
||||
engineTrusted: () => _engineTrusted,
|
||||
|
||||
forgetRest,
|
||||
|
||||
armGate,
|
||||
|
||||
schedule,
|
||||
|
||||
frame(fn) {
|
||||
let pending = false;
|
||||
return () => {
|
||||
if (pending) return;
|
||||
pending = true;
|
||||
requestAnimationFrame(() => { pending = false; fn(); });
|
||||
};
|
||||
},
|
||||
|
||||
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;
|
||||
},
|
||||
|
||||
roomFor(el) {
|
||||
const p = el && el.parentElement;
|
||||
if (!p) return Infinity;
|
||||
const cs = getComputedStyle(p);
|
||||
return p.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
|
||||
},
|
||||
|
||||
/* The browser's own answer is the whole test — a canvas approximation cost ~1ms per pass and claimed
|
||||
* 144px where the engine's floor is 93 on a 114-row table — and the box's own width is the half
|
||||
* `scrollWidth` cannot see (2px at 1440). */
|
||||
overflows(el) {
|
||||
const room = this.roomFor(el);
|
||||
const grown = el.getBoundingClientRect().width;
|
||||
return Math.max(el.scrollWidth, grown) > room + 1; /* +1: sub-pixel rounding */
|
||||
},
|
||||
|
||||
/* Is somebody else already scrolling this? A 598px `div.resizeable` came out as cards on a 1280px
|
||||
* screen, where the page had 1224px of room. The walk stops at the content root. */
|
||||
inScroller(el) {
|
||||
for (let p = el.parentElement; p && p.id !== 'view' && p.id !== 'modal_overlay'; p = p.parentElement)
|
||||
if ((/(auto|scroll)/).test(window.getComputedStyle(p).overflowX)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
|
||||
/* The menu tree as a routing table: path <-> node, with LuCI's alias/firstchild resolution. Pure
|
||||
* lookup — no DOM. The chrome and the SPA router both need it, and require() raises
|
||||
* DependencyError on a loop, so the shared half is its own module rather than reached across. */
|
||||
|
||||
/* the ACL-filtered tree from /admin/menu, handed over once by the chrome's init() */
|
||||
let _tree = null;
|
||||
|
||||
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 as the server does — returning null made the wordmark full-reload instead. null is
|
||||
* reserved for a path outside LuCI's scriptname. */
|
||||
function segsFromPath(pathname) {
|
||||
const base = L.env.scriptname || '';
|
||||
if (base && pathname.indexOf(base) !== 0)
|
||||
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 rather than pages (4 `alias`, 3 `firstchild`). The server
|
||||
* does not redirect them: a full GET answers 200 at the requested URL and stamps the resolved leaf
|
||||
* into requestpath/dispatchpath/nodespec. The client must therefore resolve exactly as
|
||||
* dispatcher.uc does, or a click and an F5 on the same URL open different pages — nodeWeight() and
|
||||
* firstChildOf() are ports, not approximations. Only the ACL check is skipped, the tree from
|
||||
* /admin/menu already being ACL-filtered for this session.
|
||||
*
|
||||
* `rewrite` is deliberately not followed: the tree has none, and a wrong guess at its splice
|
||||
* semantics would open the wrong page — worse than the full load it falls back to. */
|
||||
|
||||
/* node_weight() from dispatcher.uc: lower wins; a login node sorts last. */
|
||||
function nodeWeight(node) {
|
||||
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 ----
|
||||
*
|
||||
* apply_tree_acls() (dispatcher.uc:442) marks a NODE readonly from its own `depends.acl`, but a
|
||||
* request accumulates every ancestor's acls into ctx.acls and stamps the leaf from the
|
||||
* accumulation (`resolved.node.readonly = !perm`, :1003). Reading the leaf alone therefore
|
||||
* disagrees with a full load: luci.js derives hasViewPermission() from
|
||||
* `!env.nodespec.readonly`, which views and the Save/Apply footer key their disabled state off, so
|
||||
* every page whose SECTION is read-only would gain controls the server refuses.
|
||||
*
|
||||
* The operator is AND down the path, not OR: check_acl_depends() (:312) is handed the whole
|
||||
* concatenated list (:457-470) and grants write as soon as ANY group in it does, so a page is
|
||||
* read-only only when every acl-bearing node on the path is. A leaf with a writable acl of its own
|
||||
* re-opens the whole path.
|
||||
*
|
||||
* A node with no `depends.acl` contributes nothing to ctx.acls and is skipped. That is also why
|
||||
* the test reads `depends.acl` rather than the `readonly` flag alone: an acl-bearing node the
|
||||
* session may write carries no flag and would otherwise look ungated.
|
||||
*
|
||||
* Feed it the RESOLVED segments — that is the path the dispatcher accumulates over. */
|
||||
function readonlyForSegs(segs) {
|
||||
let node = _tree;
|
||||
let gated = 0, locked = 0;
|
||||
const weigh = (n) => {
|
||||
const acl = n && n.depends && n.depends.acl;
|
||||
if (!acl || !acl.length)
|
||||
return;
|
||||
gated++;
|
||||
if (n.readonly === true)
|
||||
locked++;
|
||||
};
|
||||
weigh(node);
|
||||
for (let i = 0; i < segs.length; i++) {
|
||||
node = node && node.children && node.children[segs[i]];
|
||||
if (!node)
|
||||
break;
|
||||
weigh(node);
|
||||
}
|
||||
return gated > 0 && gated === locked;
|
||||
}
|
||||
|
||||
/* The view class a menu node instantiates, or null when the node is not SPA-able. The
|
||||
* Status -> Overview `template` node maps to view.status.index, its server template doing nothing
|
||||
* else; the globals that template also defines come from ensureOverviewHelpers() in
|
||||
* menu-footstrap-common.js. Shared by navigate() and the hover prefetch. */
|
||||
function viewClassFor(node) {
|
||||
if (!node || !node.action || node.satisfied === false)
|
||||
return null;
|
||||
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,
|
||||
/* raw presence, no alias or firstchild resolution: fs-commands gates each command on the menu
|
||||
* node whose `depends.acl` names the group that command needs, and a node that resolves
|
||||
* elsewhere would answer for a permission the session may not hold */
|
||||
nodeForSegs,
|
||||
segsFromPath,
|
||||
currentNode,
|
||||
resolveSegs,
|
||||
readonlyForSegs,
|
||||
viewClassFor
|
||||
});
|
||||
@@ -0,0 +1,400 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require dom';
|
||||
'require network';
|
||||
'require fs-fit as fit';
|
||||
'require menu-footstrap-common as common';
|
||||
|
||||
/* Overview layout only: renders nothing of its own, it re-arranges the STOCK System / Memory /
|
||||
* Storage sections into a grid. Content, data and styling stay luci-mod-status's — rendering a
|
||||
* custom tree every poll instead (the old 05_footstrap_dashboard.js) flickered and reset mobile
|
||||
* scroll. The stock poll fills each section in place via dom.content() and never rebuilds the
|
||||
* .cbi-section wrapper, so the wrappers stay inside our grid across polls.
|
||||
*
|
||||
* This must NOT be filed in LuCI's global include dir (view/status/include/): luci-mod-status
|
||||
* evaluates every *.js there, so the file would be fetched and run on routers using another theme,
|
||||
* with an `L.env.media` gate only silencing it after the fact. As a chrome module it is
|
||||
* unreachable except through this theme's footer partial, which is why nothing below re-checks
|
||||
* `L.env.media`. That location also supplied two timing guarantees for free, by evaluating inside
|
||||
* index.load(); both are paid for explicitly here — patchOverview() below, and
|
||||
* ensureOverviewHelpers() in menu-footstrap-common.js. */
|
||||
/* section title -> grid role. _() with no msgctxt on purpose: these must resolve to exactly what
|
||||
* luci-mod-status resolves to, or the titles stop matching. Built once, not per poll tick. */
|
||||
const ROLES = { [_('System')]: 'sys', [_('Memory')]: 'mem', [_('Storage')]: 'sto' };
|
||||
/* the data-page value four call sites compare against; a string literal is not mangled, so a
|
||||
* repeat is paid in full on flash every time (measured: 24 B x4 -> 37 B, 59 B saved) */
|
||||
|
||||
function headerEl(sec) {
|
||||
/* two title markups, one per release: 25.12 wraps the heading (`.cbi-title > h3`), 24.10 emits
|
||||
* a bare `<h3>` as the section's first child. Matching only one silently disables the grid on
|
||||
* the other. */
|
||||
return sec.querySelector('.cbi-title h3, :scope > h3');
|
||||
}
|
||||
|
||||
function sectionTitle(sec) {
|
||||
const h = headerEl(sec);
|
||||
if (!h) return '';
|
||||
/* the first non-empty TEXT node, not `firstChild`: 25.12 appends a hide/show <span> inside the
|
||||
* same <h3>, so `firstChild` depends on upstream keeping the words first */
|
||||
for (const n of h.childNodes) {
|
||||
if (n.nodeType !== 3) continue;
|
||||
const t = String(n.nodeValue || '').trim();
|
||||
if (t) return t;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/* ---- keyboard disclosure: the card header becomes the toggle, the pill becomes its glyph ----
|
||||
*
|
||||
* Live on owrt2512 (25.12.4), Status -> Overview carries 14 `[data-clickable]` elements — the
|
||||
* topbar poll pill plus one Hide/Show toggle per card — and every one is a bare <span>: no
|
||||
* tabindex, no role, no aria-expanded. Tab never reaches one and a screen reader announces a run
|
||||
* of text with no name, role or state. WCAG 2.1.1 Keyboard (A), 4.1.2 Name, Role, Value (A).
|
||||
* docs/findings.md, "A card cannot be collapsed from the keyboard".
|
||||
*
|
||||
* index.js's own pill keeps doing the actual show/hide — untouched, so mouse behaviour for anyone
|
||||
* clicking it does not change. The header becomes a second, W3C-APG way to reach the SAME handler,
|
||||
* not a competing one. */
|
||||
|
||||
/* Idempotent attribute write, so a poll tick that finds nothing changed touches no DOM and fires
|
||||
* no mutation record. Same shape as `fsSyncAttr` in menu-footstrap-common.js — restated rather than
|
||||
* imported, since that file does not export it. */
|
||||
function syncAttr(el, name, value) {
|
||||
if (value === null) {
|
||||
if (el.hasAttribute(name)) el.removeAttribute(name);
|
||||
} else if (el.getAttribute(name) !== value) {
|
||||
el.setAttribute(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
/* index.js's own attribute: "inactive" is expanded (the pill reads "Hide"), "active" is collapsed
|
||||
* (it reads "Show") — the chevron mirror in pages/20-overview.css reads the same attribute the
|
||||
* same way. */
|
||||
function pillExpanded(label) {
|
||||
return label.getAttribute('data-style') !== 'active';
|
||||
}
|
||||
|
||||
/* The panel `data-style` shows or hides is `.cbi-title`'s next sibling in the 25.12 markup this was
|
||||
* measured on. 24.10 emits no `.cbi-title` wrapper (headerEl() above) and is left without
|
||||
* aria-controls rather than guessed at — role, tabindex, aria-expanded and the keyboard below still
|
||||
* apply there; aria-controls is the one piece this function cannot state with confidence. */
|
||||
function panelFor(h) {
|
||||
const holder = h.closest('.cbi-title');
|
||||
return holder ? holder.nextElementSibling : null;
|
||||
}
|
||||
|
||||
let _panelSeq = 0;
|
||||
|
||||
/* Promote one card's header to a disclosure control. Idempotent: a tick that finds the header
|
||||
* already wired touches only aria-expanded, and only when it actually changed; the click/keydown
|
||||
* listeners are added once (`dataset.fsWired`), never re-added. */
|
||||
function wireDisclosure(sec) {
|
||||
const h = headerEl(sec);
|
||||
const label = h && h.querySelector('.label[data-indicator="poll-status"]');
|
||||
if (!h || !label) return; /* not this card's shape — left alone rather than guessed at */
|
||||
|
||||
const panel = panelFor(h);
|
||||
if (panel && !panel.id) panel.id = 'fs-ovl-panel-' + (_panelSeq++);
|
||||
|
||||
syncAttr(h, 'role', 'button');
|
||||
syncAttr(h, 'tabindex', '0');
|
||||
syncAttr(h, 'aria-controls', panel ? panel.id : null);
|
||||
syncAttr(h, 'aria-expanded', pillExpanded(label) ? 'true' : 'false');
|
||||
/* the header now carries the pill's name, role and state; a screen reader user tabbing past it
|
||||
* to a second, unlabelled clickable span would hear an unexplained duplicate control — same
|
||||
* reasoning as the aria-hidden on svgIcon()'s output, fs-widgets.js:12 */
|
||||
syncAttr(label, 'aria-hidden', 'true');
|
||||
|
||||
if (h.dataset.fsWired) return;
|
||||
h.dataset.fsWired = '1';
|
||||
|
||||
h.addEventListener('click', (ev) => {
|
||||
/* a click landing on the pill itself already ran index.js's own handler; forwarding here
|
||||
* too would toggle the card twice */
|
||||
if (ev.target.closest?.('[data-indicator="poll-status"]') !== label) label.click();
|
||||
syncAttr(h, 'aria-expanded', pillExpanded(label) ? 'true' : 'false');
|
||||
});
|
||||
/* the pill is an <a>-less <span>, so neither key is native here — contrast
|
||||
* fs-widgets.js's wireSpaceKey, written for an <a role="button">, which gets Enter for free
|
||||
* and needs only Space added */
|
||||
h.addEventListener('keydown', (ev) => {
|
||||
if (ev.key !== 'Enter' && ev.key !== ' ' && ev.key !== 'Spacebar') return;
|
||||
ev.preventDefault();
|
||||
label.click();
|
||||
});
|
||||
}
|
||||
|
||||
/* A `.cbi-section` LuCI still renders when a stock include has nothing to show this tick: title
|
||||
* "-", the poll pill its only content, 72px tall on the live Overview — seventh there (a fixture is
|
||||
* not proof of position). Real and cosmetic, so it is suppressed rather than left as a rung in the
|
||||
* tab order with nothing behind its own name. */
|
||||
function hideEmptyCard(sec) {
|
||||
if (sectionTitle(sec) === '-') sec.classList.add('fs-ovl-empty');
|
||||
}
|
||||
|
||||
function tidyCards(view) {
|
||||
view.querySelectorAll('.cbi-section').forEach((sec) => {
|
||||
hideEmptyCard(sec);
|
||||
wireDisclosure(sec);
|
||||
});
|
||||
}
|
||||
|
||||
/* the wrapper we built, so the poll-tick fast path costs one property read */
|
||||
let _wrapEl = null;
|
||||
|
||||
/* A port name the card had to cut stays readable on hover: styles/pages/20-overview.css ellipses
|
||||
* it at one line so every card takes the width the row can spare, and the name is the one thing on
|
||||
* a card that cannot be guessed from the rest.
|
||||
*
|
||||
* The tooltip is set unconditionally: testing `scrollWidth` against `clientWidth` per card would
|
||||
* force a synchronous layout on every poll tick, since 29_ports.js rebuilds these tiles each time.
|
||||
*
|
||||
* Runs BEFORE arrange()'s fast path, which returns as soon as the grid is intact while the tiles
|
||||
* under it are new elements. */
|
||||
function nameTooltips(view) {
|
||||
for (const icon of view.querySelectorAll('img[src*="/port_"]')) {
|
||||
const head = icon.closest('.ifacebox')?.firstElementChild;
|
||||
const name = head ? head.textContent.trim() : '';
|
||||
/* `!==`: a mutation inside the tree we observe is not cheap, even when the write is */
|
||||
if (name && head.title !== name)
|
||||
head.title = name;
|
||||
}
|
||||
}
|
||||
|
||||
function arrange() {
|
||||
/* an SPA nav can leave the observer wired while another page renders into #view: detach as soon
|
||||
* as the route stops being the overview. body[data-page] carries the DISPATCH path from both
|
||||
* the server template and the router, so /admin/status (firstchild -> overview) matches. */
|
||||
if ((document.body.getAttribute('data-page') || '') !== /* spelled out, not hoisted: tools/page-modules.mjs reads this value out of the module's
|
||||
* SOURCE to check it against the map in menu-footstrap-common.js */
|
||||
'admin-status-overview') {
|
||||
stopWatch();
|
||||
return;
|
||||
}
|
||||
const view = document.getElementById('view');
|
||||
if (!view) return;
|
||||
|
||||
nameTooltips(view);
|
||||
/* 20_memory.js, 25_storage.js and 30_network.js each call their OWN local progressbar(), not
|
||||
* the theme's window.progressbar (menu-footstrap-common.js), so the reading and colour those
|
||||
* bars need are stamped on here instead — on the same poll-tick callback nameTooltips() already
|
||||
* runs on, ahead of the fast-path return below: dom.content() rewrites a section's title every
|
||||
* tick even when the wrapper survives, so a bar's reading is stale on every tick this line is
|
||||
* skipped. */
|
||||
common.annotateMeters(view);
|
||||
/* Same reasoning, same placement: dom.content() rewrites a card's body every tick even when the
|
||||
* wrapper survives, so a card that is only now getting its title text needs wiring on this
|
||||
* pass, not just the first one. tidyCards() is its own idempotent write (wireDisclosure(),
|
||||
* hideEmptyCard() above), so a tick that changes nothing here touches no DOM either. */
|
||||
tidyCards(view);
|
||||
|
||||
/* Fast path: the poll lands here on every tick, forever, and the stock poll never rebuilds
|
||||
* the .cbi-section wrappers, so the grid survives. Deliberately not a disconnect() — if a
|
||||
* future luci-mod-status does rebuild a section, the wrapper loses its children and the slow
|
||||
* path below rebuilds the grid. */
|
||||
if (_wrapEl && _wrapEl.isConnected && _wrapEl.parentElement === view && _wrapEl.children.length === 3)
|
||||
return;
|
||||
|
||||
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 and one observer per #view node. The SPA router may replace #view between visits, so
|
||||
* re-attach when the observed node is no longer the current one — a singleton bound to the first
|
||||
* #view would watch a detached tree and the grid would never apply again. */
|
||||
let _observer = null, _observedRoot = null, _routeObserver = null;
|
||||
function stopWatch() {
|
||||
if (_observer) _observer.disconnect();
|
||||
_observer = null;
|
||||
_observedRoot = null;
|
||||
_wrapEl = null; /* the grid belongs to the #view we are leaving */
|
||||
}
|
||||
function watch() {
|
||||
const view = document.getElementById('view');
|
||||
/* `#maincontent`, not `#view`: a client navigation builds a fresh `#view` before it is in the
|
||||
* document and swaps it in afterwards, and this runs on the `data-page` stamp, which comes
|
||||
* first — so the observer bound here read `isConnected: false` after one round trip and the
|
||||
* grid stopped being re-arranged on every poll tick, silently, until the next full load. The
|
||||
* shell's column outlives every swap. Same fault, same fix as fs-appearance.js. */
|
||||
const root = document.getElementById('maincontent') || view;
|
||||
if (_observer && _observedRoot !== root)
|
||||
stopWatch();
|
||||
arrange();
|
||||
/* a chrome module is alive on every page, so without the route check an observer would attach
|
||||
* to #view on, say, the firewall page and re-run arrange() for every table mutation */
|
||||
if (_observer || !view ||
|
||||
(document.body.getAttribute('data-page') || '') !== 'admin-status-overview')
|
||||
return;
|
||||
_observedRoot = root;
|
||||
/* one arrange() per frame, however many mutations a poll tick delivers (fit.frame — the
|
||||
* theme's shared coalescer, fs-fit.js) */
|
||||
_observer = new MutationObserver(fit.frame(arrange));
|
||||
_observer.observe(root, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
/* A chrome module is instantiated once per page load, so it has to notice SPA navigation itself.
|
||||
* `body[data-page]` is the signal — the server template and fs-router both stamp it with the
|
||||
* dispatch path — so one attribute observer covers arriving, leaving and coming back. */
|
||||
function wire() {
|
||||
if (_routeObserver || !document.body)
|
||||
return;
|
||||
_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();
|
||||
}
|
||||
|
||||
/* Arrival at the overview, from a full page load or an SPA navigation. patchOverview() is
|
||||
* idempotent (the __fsProgressive flag), so the two paths cannot double-patch. */
|
||||
function onOverview() {
|
||||
patchOverview();
|
||||
watch();
|
||||
}
|
||||
|
||||
/* ---- progressive paint ----
|
||||
*
|
||||
* Stock `view.status.index` calls poll_status(first_load=true), which Promise.all's over every
|
||||
* include's load(), and render() withholds the tree until it resolves — #view stays empty for as
|
||||
* long as the slowest include takes (measured: 182 ms, of which most sections were ready at 88 ms
|
||||
* and waiting on 29_ports and 60_wifi).
|
||||
*
|
||||
* Replacing poll_status does two things:
|
||||
* 1. each section paints when its own data lands (182 -> ~90 ms). Nothing jumps: the frames are
|
||||
* already in the DOM, a section goes hidden -> filled as on any poll tick;
|
||||
* 2. drops the redundant re-fetch — stock adds the poller after the first load and Poll.add()
|
||||
* steps at once, re-fetching everything (~250 ms of ubus) right after the first paint.
|
||||
*
|
||||
* Not a re-implementation: frames, toggles, includes and their render() stay upstream's.
|
||||
* fillSection() transcribes stock's loop in the same order so it can be diffed against index.js;
|
||||
* if that shape is gone, the patch is skipped and the page runs stock. */
|
||||
function fillSection(inc, container, res) {
|
||||
if (inc.failed)
|
||||
return;
|
||||
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;
|
||||
/* Which containers the in-flight run is filling. The guard is module-level because the duplicate
|
||||
* load it kills is, but frames are per render: joining a run blindly joins one filling somebody
|
||||
* else's frames, and the second arrival's sections then stay at `display:none` for a full poll
|
||||
* interval (5.9 s against 0.4 s). */
|
||||
let _inflightFor = null;
|
||||
|
||||
function pollProgressive(includes, containers, first_load) {
|
||||
/* a run already fetching this data for THESE frames is joined rather than duplicated; a run for
|
||||
* older frames is left to finish into the detached nodes it owns */
|
||||
if (_inflight && _inflightFor === containers)
|
||||
return first_load ? Promise.resolve() : _inflight;
|
||||
|
||||
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 when its own data lands, not at the
|
||||
* end of a Promise.all over all of them */
|
||||
return loaded.then((res) => {
|
||||
try { fillSection(inc, containers[i], res); }
|
||||
catch (e) { console.error('footstrap: overview section failed', e); }
|
||||
});
|
||||
}).filter(Boolean)
|
||||
)).then(() => {
|
||||
const ssi = document.querySelector('div.includes');
|
||||
if (ssi) { ssi.style.display = ''; ssi.classList.add('fade-in'); }
|
||||
});
|
||||
|
||||
_inflight = run.finally(() => {
|
||||
/* only if still ours: a newer render may have replaced it mid-run */
|
||||
if (_inflightFor === containers) { _inflight = null; _inflightFor = null; }
|
||||
});
|
||||
_inflightFor = containers;
|
||||
/* Nobody awaits this on the first load (the caller gets a fresh Promise.resolve()), so a
|
||||
* rejection would surface as an unhandled one. `run` rejects for one ordinary reason:
|
||||
* flushCache() on an expired session, when the user is already being redirected to login.
|
||||
* Section failures cannot reach it — fillSection runs in a try/catch and inc.load() has its
|
||||
* own .catch. */
|
||||
_inflight.catch(() => {});
|
||||
|
||||
/* first load resolves now, so index.render() returns its tree and the frames reach #view while
|
||||
* the sections fill themselves; a poll tick resolves when its data is in, as the poller
|
||||
* expects */
|
||||
return first_load ? Promise.resolve() : _inflight;
|
||||
}
|
||||
|
||||
/* Patch the stock overview view: replace poll_status so each section paints when its own data
|
||||
* lands.
|
||||
*
|
||||
* Called from the route (wire()), not at module eval: requiring 'view.status.index' at eval would
|
||||
* pull the whole stock view into memory on every page, and on a full load it would race
|
||||
* index.load(). Hence the `__fsProgressive` guard and the fact that missing the window is
|
||||
* harmless — the page then renders the stock way, one Promise.all, ~90 ms later. */
|
||||
function patchOverview() {
|
||||
/* `window.L`, never the bare `L` this factory was handed. require() passes the object it was
|
||||
* called on into the loaded module's factory, and index.js loads its own includes with that
|
||||
* same `L` — 30_network.js then calls `L.itemlist(...)`, which lives on the runtime instance
|
||||
* (`window.L = new LuCI()`), not on the prototype a chrome module receives. require() caches
|
||||
* by class name, so the first caller decides this for everybody: through the bare `L` the
|
||||
* overview dies mid-render on "L.itemlist is not a function", stuck on "Loading view…" (issue
|
||||
* #22 follow-up). docs/spa-router.md. */
|
||||
window.L.require('view.status.index').then((idx) => {
|
||||
const proto = idx ? Object.getPrototypeOf(idx) : null;
|
||||
if (!proto || proto.__fsProgressive || typeof proto.poll_status !== 'function')
|
||||
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));
|
||||
}
|
||||
|
||||
/* `progressbar`, `renderBox` and `renderBadge` are defined in menu-footstrap-common.js, not here:
|
||||
* a stock include calls them bare from its own render(), so they must exist before the view class
|
||||
* does, and this page module is required during the navigation that races it. `annotateMeters` is
|
||||
* required from that same module (see arrange()) rather than duplicated: it is not a template
|
||||
* global, so nothing forces it to live there, but the threshold split and label lookup it shares
|
||||
* with `window.progressbar` would drift into two copies otherwise. */
|
||||
|
||||
return baseclass.extend({
|
||||
/* called once by menu-footstrap-common's init; everything route-dependent hangs off the
|
||||
* data-page observer inside */
|
||||
wire,
|
||||
});
|
||||
@@ -0,0 +1,373 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require rpc';
|
||||
'require fs-fit as fit';
|
||||
|
||||
/* The Appearance axes this file owns; the controls that present them are fs-appearance.js. The
|
||||
* axis list is AXIS_KEYS, which is exactly the fields of snapshotAxes() — what Save-as-default
|
||||
* writes.
|
||||
* All client-side, instant and persisted in localStorage, with head.ut's inline script re-applying
|
||||
* them before paint so a reload never flashes the wrong one; tools/axes.mjs derives the contract
|
||||
* from this file and holds the two copies to it.
|
||||
*
|
||||
* ---- three layers, and the browser always wins ----
|
||||
* Every axis resolves as localStorage ?? router-default ?? built-in. The router default is
|
||||
* Appearance -> Save to router (written to /etc/config/footstrap, read back into window.__fsSD);
|
||||
* the built-in is a bare :root. A new browser inherits the router default; this browser's own
|
||||
* choice overrides it in either direction.
|
||||
*
|
||||
* ---- every applier stores its choice EXPLICITLY ----
|
||||
* Once a router default exists, clearing a key means "inherit the router default", not "the
|
||||
* built-in" — so an applier that lsDel'd on the default value could not express "the built-in, not
|
||||
* the router's" (a router-defaulted tint could never be turned back off). Every axis records the
|
||||
* chosen value, including the off/default one. lsDel is reserved for resetToSaved(). */
|
||||
/* A browser can refuse storage outright (blocked cookies, dom.storage.enabled=false, a partitioned
|
||||
* WebView) and then every access throws. The helpers below swallow it, because an axis that cannot
|
||||
* be remembered must still APPLY, but they record it: otherwise current*() reads null, falls back
|
||||
* to the router's look, and the Save button sits disabled reading "Saved to router" over a page
|
||||
* painted in axes the router default does not carry. */
|
||||
let _lsBroken = false;
|
||||
function storageBroken() { return _lsBroken; }
|
||||
function lsGet(k) { try { return localStorage.getItem(k); } catch (e) { _lsBroken = true; return null; } }
|
||||
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) { _lsBroken = true; } }
|
||||
function lsDel(k) { try { localStorage.removeItem(k); } catch (e) { _lsBroken = true; } }
|
||||
|
||||
/* A stored JSON array, or [] — the shape the two remembered lists use (the search palette's recent
|
||||
* paths, the menu's open sections). lsGet owns the try/catch around localStorage; this one covers
|
||||
* JSON.parse over a value another tab may have corrupted, and the Array guard that stops a stored
|
||||
* object being spread into a list. */
|
||||
function lsGetArr(k) {
|
||||
try {
|
||||
const a = JSON.parse(lsGet(k) || '[]');
|
||||
return Array.isArray(a) ? a : [];
|
||||
} catch (e) { return []; }
|
||||
}
|
||||
|
||||
/* the router-wide defaults the server stamped (head.ut), read at runtime so current*() reports the
|
||||
* effective default when this browser has no localStorage */
|
||||
function sd(k) { try { return (window.__fsSD || {})[k]; } catch (e) { return undefined; } }
|
||||
|
||||
/* …and the write back: an applier that persists to the router must update the blob the server
|
||||
* stamped, or current*() keeps reporting the old router default until the next full load and
|
||||
* matchesSavedDefault() lies about whether anything is left to save */
|
||||
function setSD(field, val) { try { (window.__fsSD = window.__fsSD || {})[field] = val; } catch (e) {} }
|
||||
|
||||
/* ---- every axis owns its ROUTER DEFAULT, and nothing else may restate it ----
|
||||
* `def()` is the sd() branch of current() alone: the effective value with no localStorage. Exposed
|
||||
* because _resolvedDefault() needs exactly that branch, and a second copy of the same validation
|
||||
* drifts without a symptom — matchesSavedDefault() then lies about the one thing the Save button
|
||||
* is, its own status. */
|
||||
function modeDefault() {
|
||||
const d = sd('darkmode');
|
||||
return (d === 'dark' || d === 'light') ? d : 'auto';
|
||||
}
|
||||
function currentMode() {
|
||||
const s = lsGet('fs-darkmode');
|
||||
if (s === 'true') return 'dark';
|
||||
if (s === 'false') return 'light';
|
||||
if (s === 'auto') return 'auto';
|
||||
if (s === null) return modeDefault();
|
||||
return 'auto';
|
||||
}
|
||||
/* ---- dark mode is announced in three dialects, because apps sniff for it ----
|
||||
*
|
||||
* An app with its own dark styles has to guess whether the page is dark and there is no standard:
|
||||
* apps read `data-theme="dark"` on :root (luci-app-justclash keys 21 rules off it), Bootstrap's
|
||||
* `data-bs-theme` (luci-app-ssclash), or, failing both, the luminance of the body background. All
|
||||
* three are stamped for the same fact: before that, every one of justclash's [data-theme="dark"]
|
||||
* rules was dead and a dark page rendered its light fills.
|
||||
*
|
||||
* `data-darkmode` is the name the theme's own CSS keys off. The other two are outbound
|
||||
* compatibility, like the `--*-color-*` export tier: nothing in `styles/` may read them, and
|
||||
* tools/axes.mjs fails the build if it does. */
|
||||
/* the attribute name reused below by the writer, the guard's reader and both MutationObserver
|
||||
* filters (measured: 15 B x4 -> 28 B, 32 B saved) */
|
||||
function stampDark(root, dark) {
|
||||
/* the literal stays spelled out HERE: tools/axes.mjs reads the attribute names out of
|
||||
* this function's SOURCE, so a hoisted const reads as no attribute at all and the gate
|
||||
* reports the pre-paint and the live applier as drifted. The 45 B a const would save are
|
||||
* not worth teaching a gate to resolve them. */
|
||||
root.setAttribute('data-darkmode', dark ? 'true' : 'false');
|
||||
root.setAttribute('data-theme', dark ? 'dark' : 'light');
|
||||
root.setAttribute('data-bs-theme', dark ? 'dark' : 'light');
|
||||
}
|
||||
|
||||
const _mqDark = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
/* the one expression for "is this page dark right now", so the applier, the OS listener and the
|
||||
* guard below cannot disagree about it */
|
||||
function intendedDark() {
|
||||
const m = currentMode();
|
||||
return m === 'dark' || (m === 'auto' && _mqDark.matches);
|
||||
}
|
||||
|
||||
function applyMode(val) {
|
||||
const root = document.documentElement;
|
||||
/* 'auto' is stored explicitly, so it overrides a router default of dark/light — otherwise a
|
||||
* router defaulted to dark could never be set back to "follow the OS" */
|
||||
if (val === 'auto') lsSet('fs-darkmode', 'auto');
|
||||
else lsSet('fs-darkmode', val === 'dark' ? 'true' : 'false');
|
||||
/* after the store, so intendedDark() reads the choice just made and no second copy of the
|
||||
* condition is needed in terms of `val` */
|
||||
stampDark(root, intendedDark());
|
||||
}
|
||||
|
||||
/* ---- the three dialects are published, so third parties write them too ----
|
||||
*
|
||||
* Announcing dark mode in a vocabulary apps understand is what makes them follow the page, and it
|
||||
* is why an app reaches for the same attribute: `luci-app-openclash` stamps `data-darkmode="true"`
|
||||
* onto :root from seven of its templates, gated on an isDarkBackground() that consults
|
||||
* `matchMedia('(prefers-color-scheme: dark)')` before it looks at the real background. So a user
|
||||
* who chose LIGHT here, on an OS set to dark, has the theme flipped by opening an OpenClash page —
|
||||
* and one of those templates removes the attribute head.ut writes as 'false'.
|
||||
*
|
||||
* No cascade trick answers a DOM write, so watch the attributes we own and restate the truth.
|
||||
* Nothing else is guarded: the other axes are private to this theme, no app has a reason to know
|
||||
* them, and a survey of ten shipping packages found none that writes one. The published trio is
|
||||
* the surface precisely because it is published.
|
||||
*
|
||||
* This corrects a wrong premise rather than fighting the app's intent: when the page really is
|
||||
* dark, the app's write agrees with ours and the guard never fires. It cannot ping-pong either —
|
||||
* our write produces a mutation, the callback re-runs, the values match, it returns. */
|
||||
function guardDarkStamp() {
|
||||
const root = document.documentElement;
|
||||
const check = () => {
|
||||
const dark = intendedDark();
|
||||
if (root.getAttribute('data-darkmode') === (dark ? 'true' : 'false') &&
|
||||
root.getAttribute('data-theme') === (dark ? 'dark' : 'light') &&
|
||||
root.getAttribute('data-bs-theme') === (dark ? 'dark' : 'light')) return;
|
||||
stampDark(root, dark);
|
||||
};
|
||||
/* an app's inline <script> runs while its template is parsed, long before this module is
|
||||
* fetched, so the attribute can already be wrong and observing alone would never see it */
|
||||
check();
|
||||
new MutationObserver(check).observe(root, {
|
||||
attributes: true,
|
||||
attributeFilter: [ 'data-darkmode', 'data-theme', 'data-bs-theme' ]
|
||||
});
|
||||
}
|
||||
/* ---- the browser's own chrome follows the page ----
|
||||
*
|
||||
* `<meta name="theme-color">` is what colours a mobile address bar, the Android task-switcher card
|
||||
* and an installed PWA's title bar. head.ut ships it at the default palette's page colour, which is
|
||||
* all a static template can know; from here it tracks the live one.
|
||||
*
|
||||
* Read from the BODY's computed background, not from `--fs-bg`: a custom property returns its token
|
||||
* stream, so a palette whose page colour is a color-mix() would put `color-mix(in srgb, …)` in the
|
||||
* attribute, and 21 of the axes reach the canvas through one mix or another.
|
||||
*
|
||||
* One observer rather than a call in each applier: the colour is a function of :root's attributes
|
||||
* and inline properties — mode, palette, tint, its strength — and every axis already writes exactly
|
||||
* those. Adding an axis therefore needs nothing here. Coalesced into a frame because the tint and
|
||||
* strength sliders write on every input event, and getComputedStyle forces style resolution. */
|
||||
function paintThemeColor() {
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (!meta || !document.body) return;
|
||||
const bg = getComputedStyle(document.body).backgroundColor;
|
||||
/* a transparent body means the sheet has not applied yet; keep the server's value */
|
||||
if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') meta.setAttribute('content', bg);
|
||||
}
|
||||
|
||||
function watchThemeColor() {
|
||||
let queued = false;
|
||||
const paint = () => {
|
||||
queued = false;
|
||||
paintThemeColor();
|
||||
};
|
||||
const schedule = () => {
|
||||
if (queued) return;
|
||||
queued = true;
|
||||
window.requestAnimationFrame(paint);
|
||||
};
|
||||
paintThemeColor();
|
||||
new MutationObserver(schedule).observe(document.documentElement,
|
||||
{ attributes: true, attributeFilter: [ 'style', 'class', 'data-darkmode', 'data-palette', 'data-wallpaper' ] });
|
||||
}
|
||||
|
||||
/* "Auto" means follow the OS continuously, not only at page load. Only while the effective mode is
|
||||
* auto: an explicit browser choice, or an explicit router default with no browser override. */
|
||||
_mqDark.addEventListener('change', () => {
|
||||
if (currentMode() === 'auto') {
|
||||
const root = document.documentElement;
|
||||
stampDark(root, intendedDark());
|
||||
}
|
||||
});
|
||||
/* Corner radius: the card radius (0–20px) as an inline --fs-radius-base on :root, from which
|
||||
* 02-tokens derives every other radius. head.ut pre-paints it and tools/axes.mjs holds JS/CSS/head
|
||||
* to this one number, hence the named const. */
|
||||
|
||||
/* ---- the four axis shapes, each written once ----
|
||||
*
|
||||
* Sixteen axes are four shapes, so the shape lives in a factory and each instance is one line:
|
||||
* enumAxis (pattern ink), colorAxis (tint, accent, good, warn, danger), surfaceAxis (cards,
|
||||
* controls, bar, borders), propAxis (rounding, tint strength, photo dim, pattern size, pattern
|
||||
* strength, content width). Same contract throughout: `current()` is localStorage ?? def(), `def()` is the router
|
||||
* default alone, `apply()` stores the choice explicitly. None use `this` — every export is a
|
||||
* detached method reference, so a `this` here would throw on the first call.
|
||||
*
|
||||
* Each factory takes its localStorage key as the first argument, and tools/axes.mjs matches the
|
||||
* call by its literal args: an axis built by a factory has no lsGet('fs-…') call site for the gate
|
||||
* to find.
|
||||
*
|
||||
* The remaining axes stay separate, each with a quirk a shared table would need an option for:
|
||||
* `mode` stores a value it does not apply and owns an MQL listener, `layout` reads the attribute,
|
||||
* `wallpaper` and `density` are three-valued, `palette` outgrew the two-value shape when the third
|
||||
* one landed, `autoCollapse` has no :root attribute. */
|
||||
|
||||
/* An axis whose values are a list, with one of them stamped as nothing.
|
||||
*
|
||||
* `values` are the names that become `attr="<name>"`; `dflt` is the one that leaves :root bare, and
|
||||
* is what a stray or missing value falls back to. `after` runs once the attribute is stamped, for
|
||||
* the axis that has to re-take a measurement.
|
||||
*
|
||||
* The list IS the validation: a name added to the stylesheet and not here is one head.ut pre-paints
|
||||
* and this rejects, so the page paints it and the first touch of any other control takes it away. */
|
||||
function listAxis(key, attr, values, dflt, after) {
|
||||
/* 'fs-pattern-ink' -> 'pattern_ink', the window.__fsSD field. The underscore is the point: the
|
||||
* localStorage key is hyphenated and the uci option is not, so a bare slice(3) names a field
|
||||
* head.ut never emits, sd() returns undefined forever, and the axis reports the built-in
|
||||
* default however the router is set — Save-as-default then writes it over the admin's value. */
|
||||
const sdKey = key.slice(3).replace(/-/g, '_');
|
||||
const ok = (v) => (values.indexOf(v) >= 0);
|
||||
const def = () => (ok(sd(sdKey)) ? sd(sdKey) : dflt);
|
||||
return {
|
||||
def,
|
||||
current() {
|
||||
const s = lsGet(key);
|
||||
if (ok(s)) return s;
|
||||
if (s === dflt) return dflt;
|
||||
if (s === null) return def();
|
||||
return dflt; /* a stray value reads as the built-in default */
|
||||
},
|
||||
apply(val) {
|
||||
const root = document.documentElement;
|
||||
const v = ok(val) ? val : dflt;
|
||||
/* stored explicitly (including the default), so it overrides a router default */
|
||||
lsSet(key, v);
|
||||
if (v === dflt) root.removeAttribute(attr);
|
||||
else root.setAttribute(attr, v);
|
||||
if (after) after();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* A two-value axis: `on` is stamped as the attribute's value, `off` is a bare :root. The list shape
|
||||
* with a list of one — kept as its own name because tools/axes.mjs matches the call, and because
|
||||
* "two-valued" is what most of these axes are. */
|
||||
function enumAxis(key, attr, on, off) {
|
||||
return listAxis(key, attr, [ on ], off);
|
||||
}
|
||||
|
||||
/* A colour axis — Tint, Accent and the three status colours are one axis pointed at five tokens:
|
||||
* same validation, same "0 is off", same ordering rule (set the custom property BEFORE the
|
||||
* attribute, or a fresh load paints one frame in the previous colour).
|
||||
*
|
||||
* A value is one of three things, and the attribute says which (03-palettes.css matches on it):
|
||||
*
|
||||
* 0 off — no attribute, the palette as it shipped
|
||||
* 1–360 a HUE: CSS rotates the palette's own colour through oklch(from … l c H), so
|
||||
* lightness, chroma and every contrast margin stay the palette's
|
||||
* '#rrggbb' a COLOUR, stamped inline on :root as the live token; the ink over it is derived
|
||||
* from its lightness in CSS
|
||||
*
|
||||
* Both live in one localStorage key rather than a colour key beside a hue key: two keys would need
|
||||
* a third to say which is in effect, and that third is the one a pre-paint script forgets.
|
||||
* `hueProp` carries the degrees, `colorProp` the live token a hex value overwrites; each mode
|
||||
* clears the other's property, so the two can never both be half-applied. */
|
||||
const DENSITIES = [ 'compact', 'large' ]; /* the two non-default values; 'normal' = bare :root */
|
||||
const DENSITY = listAxis('fs-density', 'data-density', DENSITIES, 'normal', () => fit.schedule());
|
||||
const currentDensity = DENSITY.current, applyDensity = DENSITY.apply,
|
||||
densityDefault = DENSITY.def;
|
||||
|
||||
/* Content width lives entirely in fs-axes.js now (a propAxis like Rounding, issue #44): unlike
|
||||
* Density it sets no attribute, so nothing here or in fs-chrome.js needs to read one, and
|
||||
* currentContentWidth()/applyContentWidth() have no caller outside the Appearance tab — the one
|
||||
* thing that kept Density's shape in this cold-path file. */
|
||||
/* Background-tint axis: the canvas the cards float on (--fs-bg), so a whole install reads as one
|
||||
* colour and a tab or a screenshot says which router it belongs to. Cards, chrome and the status
|
||||
* colours keep the palette's values — the cue colours the paper, not the UI. On a hue it is mixed
|
||||
* in CSS (03-palettes.css explains why that stays contrast-safe at every angle); on a hex it IS the
|
||||
* canvas. 0 is off rather than red, a hue wheel wrapping, so one end of the range is free. */
|
||||
function currentLayout() {
|
||||
return document.documentElement.getAttribute('data-layout') === 'top' ? 'top' : 'sidebar';
|
||||
}
|
||||
function isTopLayout() {
|
||||
return currentLayout() === 'top';
|
||||
}
|
||||
function applyLayout(val) {
|
||||
const layout = (val === 'top') ? 'top' : 'sidebar';
|
||||
/* always an explicit value, never a removed attribute: every layout rule matches data-layout
|
||||
* positively, and lsDel would let the server default re-assert on the next load */
|
||||
lsSet('fs-layout', layout);
|
||||
document.documentElement.setAttribute('data-layout', layout);
|
||||
/* the bar and the column leave the menu different room: re-take the fits-on-one-row
|
||||
* measurement */
|
||||
fit.schedule();
|
||||
}
|
||||
|
||||
/* Sidebar accordion: auto-collapse on = one section open at a time; off (default) they stack.
|
||||
* Only meaningful for the expanded sidebar — rail flyouts and the mobile bar are always
|
||||
* exclusive. Read by menu-footstrap.js. */
|
||||
function autoCollapseDefault() {
|
||||
return sd('autocollapse') === 'on';
|
||||
}
|
||||
function currentAutoCollapse() {
|
||||
const s = lsGet('fs-menu-autocollapse');
|
||||
if (s === 'true') return true;
|
||||
if (s === 'false') return false;
|
||||
if (s === null) return autoCollapseDefault();
|
||||
return false;
|
||||
}
|
||||
function applyAutoCollapse(val) {
|
||||
const on = (val === 'on');
|
||||
lsSet('fs-menu-autocollapse', on ? 'true' : 'false');
|
||||
|
||||
/* Switching it on with several sections unfolded leaves the menu in a state the setting says is
|
||||
* impossible, so somebody must fold them — but not this module, which owns storage while the
|
||||
* menu owns every piece of the open/closed state and opens and closes only through setOpen().
|
||||
* Reaching in with a raw classList.remove satisfies the class and leaves the aria saying
|
||||
* expanded. Say what changed and let the menu apply it. */
|
||||
document.dispatchEvent(new CustomEvent('fs-autocollapse', { detail: { on } }));
|
||||
}
|
||||
|
||||
/* The sidebar rail's collapsed flag; the button that flips it is chrome (fs-chrome.js). Not part
|
||||
* of the router-wide defaults — a transient chrome collapse, not an appearance choice — so it is
|
||||
* absent from snapshotAxes() and resetToSaved(). */
|
||||
function applyRail(on) {
|
||||
const root = document.documentElement;
|
||||
if (on) { root.setAttribute('data-rail', 'true'); lsSet('fs-rail', 'true'); }
|
||||
else { root.removeAttribute('data-rail'); lsDel('fs-rail'); }
|
||||
}
|
||||
function currentRail() {
|
||||
return document.documentElement.getAttribute('data-rail') === 'true';
|
||||
}
|
||||
|
||||
/* ---- Save to router: write the current effective axes to /etc/config/footstrap ----
|
||||
* The scoped rpcd ACL (config 'footstrap' only) lets the admin's session set and commit those
|
||||
* options; rpcd validates the config/section/option names, so no value reaches a shell. The server
|
||||
* reads them back on the next load and head.ut's sanitiser clamps each before it becomes
|
||||
* window.__fsSD.
|
||||
*
|
||||
* snapshotAxes() reads the effective values, which already fold in this browser's localStorage, so
|
||||
* Save captures what the user sees. It does not touch localStorage: this browser keeps overriding,
|
||||
* and the saved default is for other devices. resetToSaved() drops this browser back onto it. */
|
||||
/* Every saved axis's localStorage key: what Save-as-default clears and what a reset walks.
|
||||
*
|
||||
* Tried as one table of [key, field, def] with the resolved defaults derived from it: correct,
|
||||
* and 188 B larger after minification — three lists of short literals compress better than
|
||||
* twenty-one rows of data, because a function name is mangled and a row is not. The copies are
|
||||
* held together by tools/axes.mjs instead, which reads snapshotAxes()'s body and holds every
|
||||
* field against header.ut's FS_AXES. */
|
||||
|
||||
return baseclass.extend({
|
||||
/* the storage wrappers and the router-default reader: fs-axes.js is built on these */
|
||||
lsGet, lsSet, lsDel, lsGetArr, storageBroken, sd, setSD,
|
||||
/* the two axis shapes, so the nineteen axes in fs-axes.js can be built from them */
|
||||
listAxis, enumAxis,
|
||||
|
||||
currentMode, applyMode, modeDefault, guardDarkStamp, watchThemeColor,
|
||||
currentDensity, applyDensity, densityDefault,
|
||||
currentLayout, applyLayout, isTopLayout,
|
||||
currentAutoCollapse, applyAutoCollapse, autoCollapseDefault,
|
||||
currentRail, applyRail
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,427 @@
|
||||
'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 knowing which section owns it. A loaded router
|
||||
* carries ~200 menu nodes across 11 sections, and a tab such as Firewall -> Port Forwards appears
|
||||
* in no menu list until you are already there; this indexes every node the dispatcher would
|
||||
* render, tabs included.
|
||||
*
|
||||
* It costs no request: the tree is the same ACL-filtered /admin/menu blob the chrome loaded
|
||||
* (fs-menutree), so the palette lists exactly the pages this session may open. The index is built
|
||||
* on the first open, not at init — a user who never searches pays nothing, and only a full load
|
||||
* can change the tree.
|
||||
*
|
||||
* Navigation is deliberately not a call into the router: every result is a real <a href>, so a
|
||||
* click bubbles to the router's own document-level handler and no copy of that decision lives
|
||||
* here. Enter synthesises the same click. */
|
||||
|
||||
/* ---- the index ---------------------------------------------------------- */
|
||||
|
||||
/* How deep below the mode the walk goes. `depth` counts recursion levels starting at 1 on a node
|
||||
* that already has two segments, so 4 admits admin/<section>/<page>/<tab>/<subtab> — one level
|
||||
* more than LuCI renders, so a deeper third-party node is still findable and the depth term in
|
||||
* search() ranks it last. */
|
||||
const MAX_DEPTH = 4;
|
||||
|
||||
let _index = null;
|
||||
|
||||
/* The node's own children, ACL- and title-filtered as ui.menu.getChildren() filters them, but not
|
||||
* through getChildren(): on an alias node it returns the alias TARGET's children, which is right
|
||||
* for drawing a menu and wrong for indexing — Network -> Firewall aliases onto the `firewall/zones`
|
||||
* leaf, so all five of its tabs vanish from the index (78 nodes instead of 238).
|
||||
*
|
||||
* Order does not matter (search ranks by score), so only the two filters are reimplemented, not
|
||||
* the sort. */
|
||||
function childrenOf(node) {
|
||||
const kids = node.children || {};
|
||||
const out = [];
|
||||
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); indexing it would let a search
|
||||
* 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, in this order of preference (see tokenScore()). The English
|
||||
* node name is indexed on purpose: a translated UI otherwise hides the page from
|
||||
* an admin who knows OpenWrt by its English docs. */
|
||||
t: title.toLowerCase(),
|
||||
p: trail.join(' ').toLowerCase(),
|
||||
/* …minus the root segment, `admin`, which every node shares: indexed, every
|
||||
* substring of it ("ad", "min", …) hits all ~200 pages and fills the result cap */
|
||||
n: csegs.slice(1).join(' ').toLowerCase()
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* Built once per document: it projects the client menu tree, which `ui.menu.load()` caches for
|
||||
* the life of the document, so the palette is exactly as fresh as the sidebar beside it.
|
||||
* Invalidating only this half would let the two disagree, which is worse than both being stale;
|
||||
* the reload a package install prompts for refreshes them together. */
|
||||
function index() {
|
||||
if (!_index) _index = buildIndex();
|
||||
return _index;
|
||||
}
|
||||
|
||||
/* ---- extra sources -------------------------------------------------------
|
||||
*
|
||||
* An optional package can add rows to the same list — one indexes the SECTION titles inside each
|
||||
* page, so "footstrap" finds System -> Appearance. A source hands over entries in the shape
|
||||
* buildIndex() produces and nothing else: the matching, the ranking and the rendering stay here,
|
||||
* or two lists would disagree about what a hit is. Two fields are the source's alone: `onTake`,
|
||||
* called when the row is chosen, and `key`, what the recents list stores it under when its `path`
|
||||
* is not its own (see keyOf).
|
||||
*
|
||||
* Registration is a GLOBAL ARRAY, not an export a package requires. This module is fetched on the
|
||||
* first gesture and most sessions never make it; a package that had to `require` it to register
|
||||
* would pull it onto every page and pay its 4.5 KB for a palette nobody opened. Pushing a function
|
||||
* onto `window.__fsSearchSources` costs the package nothing and names no one in either direction.
|
||||
*
|
||||
* `window.__fsSearchGen` is how a source says its data grew — a harvester fills in over a session
|
||||
* — and the stamp below is what rebuilds the pool when it does. */
|
||||
const _sources = [];
|
||||
let _pool = null, _stamp = -1;
|
||||
|
||||
function globalSources() {
|
||||
return Array.isArray(window.__fsSearchSources) ? window.__fsSearchSources : [];
|
||||
}
|
||||
|
||||
function addSource(fn) {
|
||||
_sources.push(fn);
|
||||
_pool = null;
|
||||
}
|
||||
function refresh() {
|
||||
_pool = null;
|
||||
}
|
||||
function pool() {
|
||||
const all = _sources.concat(globalSources());
|
||||
const stamp = all.length + (window.__fsSearchGen || 0);
|
||||
if (_pool && stamp === _stamp) return _pool;
|
||||
_stamp = stamp;
|
||||
_pool = all.reduce((rows, fn) => {
|
||||
try { return rows.concat(fn() || []); }
|
||||
catch (e) { console.error('footstrap: a search source threw', e); return rows; }
|
||||
}, index().slice());
|
||||
return _pool;
|
||||
}
|
||||
|
||||
/* ---- matching ----------------------------------------------------------- */
|
||||
|
||||
/* Every whitespace-separated token must hit something: a second word means AND. Deliberately not
|
||||
* a fuzzy subsequence match — on a two-letter query that matches nearly every entry and leaves the
|
||||
* ranking to decide everything, which reads as random. */
|
||||
const HIT_NONE = 99;
|
||||
function tokenScore(e, tok) {
|
||||
if (e.t.startsWith(tok)) return 0; /* the title begins with it */
|
||||
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 pool()) {
|
||||
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: 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 anything is typed: an admin lives in three or four pages, so the
|
||||
* empty state is its most-used view.
|
||||
*
|
||||
* Only the KEY is stored, never the title — the title is resolved through the pool on every
|
||||
* render, so it follows the UI language and a row whose package went away drops out instead of
|
||||
* lingering as a dead row. A page's key is its menu path; a row from a source carries its own
|
||||
* `key`, because a section has no dispatcher node and therefore no path that is only its own —
|
||||
* the sections source keys one `admin/system/system#Footstrap`, the page it is on plus its own
|
||||
* heading. */
|
||||
const RECENT_KEY = 'fs-recent';
|
||||
const RECENT_MAX = 8;
|
||||
|
||||
/* the string a row is remembered under, and the one menu-footstrap-common's remember() writes */
|
||||
function keyOf(e) {
|
||||
return e.key || e.path;
|
||||
}
|
||||
|
||||
/* The list is WRITTEN by menu-footstrap-common.js, which is on every page — this module is not any
|
||||
* more, and a palette that only loads when it is opened cannot be what records where the admin has
|
||||
* been. Read here, at open time, so it is always current. `prefs.lsGetArr` owns the parse, the
|
||||
* corruption guard and the Array check; only the "these are keys" filter belongs here. */
|
||||
function recentEntries() {
|
||||
const recent = prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string');
|
||||
/* pool(), not index(): a section is recalled exactly as a page is. Against the index alone a
|
||||
* section key resolved to nothing and the row silently vanished, so taking "Footstrap" left
|
||||
* only "System" in the list — the page path is all either row carries. */
|
||||
const byKey = new Map(pool().map((e) => [ keyOf(e), e ]));
|
||||
return recent.map((k) => byKey.get(k)).filter(Boolean).slice(0, RECENT_MAX);
|
||||
}
|
||||
|
||||
/* ---- the palette -------------------------------------------------------- */
|
||||
|
||||
const MAX_RESULTS = 20;
|
||||
|
||||
/* Built on the first open and kept — the overlay, its listeners and the index survive for the life
|
||||
* of the document, so a second Ctrl+K costs nothing. Until then this module is not even fetched:
|
||||
* menu-footstrap-common.js holds the shortcut and requires this on the first gesture. */
|
||||
let _built = null;
|
||||
|
||||
function build() {
|
||||
const btn = document.getElementById('fs-search-btn');
|
||||
if (!btn) return null;
|
||||
|
||||
/* the list's id, said three times below as an id, a class and aria-controls (measured: 17 B x3
|
||||
* -> 26 B, 25 B saved) */
|
||||
const ID_SEARCH_LIST = 'fs-search-list';
|
||||
/* the attribute name toggled below and stated once more as its own starting value (measured:
|
||||
* 15 B x3 -> 27 B, 18 B saved) */
|
||||
const ATTR_EXPANDED = 'aria-expanded';
|
||||
const input = E('input', {
|
||||
'type': 'text',
|
||||
'class': 'fs-search-input',
|
||||
'role': 'combobox',
|
||||
'aria-controls': ID_SEARCH_LIST,
|
||||
[ATTR_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': ID_SEARCH_LIST, 'class': ID_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 captions the rows below it ("Recently visited") and doubles as the empty
|
||||
* state, so it belongs above the list in both readings */
|
||||
note,
|
||||
list
|
||||
]);
|
||||
/* data-fs-chrome marks a zone-1 root (docs/third-party-apps.md): this overlay is parented to
|
||||
* <body>, outside the <nav> that carries the mark in header.ut, so without it the fence does
|
||||
* not cover the palette — the shape that once left the Appearance popover unfenced */
|
||||
const ov = E('div', {
|
||||
'id': 'fs-search-ov',
|
||||
'class': 'fs-search-ov',
|
||||
'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 at once would pull the top result of "w", "wi", "wir"… Only arrow keys
|
||||
* and typing need this — the rows are real anchors, so a mouse over one already reaches the
|
||||
* router's pointerover listener. fs-router's warmClass() dedupes, so a row revisited costs
|
||||
* nothing. */
|
||||
let warmT = null;
|
||||
function warmActive() {
|
||||
if (warmT) window.clearTimeout(warmT);
|
||||
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> itself: an option may not contain an interactive element,
|
||||
* and the anchor must stay a real link — it carries the click to the router and keeps
|
||||
* middle-click and "copy link" working */
|
||||
const a = E('a', {
|
||||
'class': 'fs-search-opt',
|
||||
'role': 'option',
|
||||
'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;
|
||||
* no focus return, the user is going elsewhere.
|
||||
*
|
||||
* `onTake` is how a row from an extra source finishes the job the href cannot: a
|
||||
* section row's href can only reach the PAGE, so the source that produced it opens the
|
||||
* tab and scrolls to the section itself. It fires for a click and for Enter alike —
|
||||
* Enter synthesises this very click. */
|
||||
a.addEventListener('click', () => {
|
||||
close(false);
|
||||
if (typeof e.onTake === 'function') e.onTake();
|
||||
});
|
||||
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(ATTR_EXPANDED, 'true');
|
||||
input.value = '';
|
||||
render('');
|
||||
input.focus();
|
||||
}
|
||||
|
||||
function close(returnFocus = true) {
|
||||
if (ov.hidden) return;
|
||||
ov.hidden = true;
|
||||
btn.setAttribute(ATTR_EXPANDED, 'false');
|
||||
if (returnFocus) btn.focus();
|
||||
}
|
||||
|
||||
input.addEventListener('input', () => render(input.value.trim()));
|
||||
|
||||
/* keys are handled on the overlay, not the input: a click on the scrim moves focus to the
|
||||
* overlay itself, where Escape must still close */
|
||||
ov.addEventListener('keydown', (ev) => {
|
||||
switch (ev.key) {
|
||||
case 'Escape':
|
||||
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 the router reads as a keyboard
|
||||
* activation, so focus lands where a keyboard navigation puts it */
|
||||
opts[at].click();
|
||||
return;
|
||||
case 'Tab':
|
||||
/* aria-modal="true" promises Tab cannot walk out into the page behind, and the input
|
||||
* is the dialog's only tabbable element. Escape or an outside click is the way out,
|
||||
* both handing focus back to the trigger. */
|
||||
ev.preventDefault();
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
/* 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 above can see — every other close is a user act
|
||||
* on the document — so without this an open palette rides a popstate onto the next page,
|
||||
* aria-modal and Tab-trapped. returnFocus=false: the router places focus itself. */
|
||||
router.onNavigate(() => close(false));
|
||||
|
||||
/* Ctrl/Cmd+K and `/`, the two shortcuts users already have. `/` only when the user is not
|
||||
* typing somewhere — an <input>, a contenteditable, or a .cbi-dropdown, where fs-select.js's
|
||||
* typeahead reads it as a search character. */
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
if (ev.defaultPrevented) return;
|
||||
if ((ev.ctrlKey || ev.metaKey) && !ev.altKey && (ev.key === 'k' || ev.key === 'K')) {
|
||||
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 { open, close };
|
||||
}
|
||||
|
||||
/* the one entry point: build if this is the first gesture, then open */
|
||||
function openPalette() {
|
||||
if (!_built) _built = build();
|
||||
if (_built) _built.open();
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
open: openPalette,
|
||||
/* the seam an optional package registers through; see addSource() */
|
||||
addSource, refresh
|
||||
});
|
||||
@@ -0,0 +1,977 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require ui';
|
||||
'require dom';
|
||||
'require fs-fit as fit';
|
||||
/* for the content column's width without a layout read — see the mid-scroll branch in fitTables */
|
||||
'require fs-chrome as chrome';
|
||||
|
||||
/* 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 cut every listener enhance() installed.
|
||||
* resync() calls teardown()+enhance() every time a script rebuilds the option list, which CBI
|
||||
* dependencies do constantly, so a surviving listener accumulates one per rebuild, each closing
|
||||
* over a dead ui.Dropdown and its detached subtree. 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 shows the old value while Save reads 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) {
|
||||
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: `sel.value = x` and
|
||||
* `options[i].selected = true` — what `ui.Select.setValue()` does, and form.js calls it on every
|
||||
* dependency pass — set no content attribute and add no node, so no MutationRecord exists for
|
||||
* relevant() to wake on and the widget shows the old label while Save reads the new value.
|
||||
*
|
||||
* So this runs from the fitter, once per content mutation batch, and is deliberately the cheap half
|
||||
* of resync(): a value compare per enhanced select, no choicesKey() over every option. Re-keying
|
||||
* stays behind relevant(), which 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, .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) {
|
||||
/* marked, not merely returned from: unmarked, the same select is re-selected on every
|
||||
* mutation frame and throws again, forever. One loud failure, then left as a stock
|
||||
* <select>. */
|
||||
sel.dataset.fsSelect = 'skip';
|
||||
console.error('footstrap: a select could not be enhanced', 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 and drop the select from the
|
||||
* a11y tree */
|
||||
const title = sel.closest('.cbi-value')?.querySelector('.cbi-value-title');
|
||||
/* a table section has no .cbi-value and no .cbi-value-title — form.js builds a bare
|
||||
* `td.cbi-value-field` — so the widget would be left nameless there. The cell's `data-title` is
|
||||
* the column heading, which LuCI fills for the card stack. */
|
||||
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 the native
|
||||
* <select>, which this theme has set `display: none` on, so the click does nothing at all.
|
||||
* Focus only, which is the parity stock gets — its `elem.click()` on a `<select>` opens no list
|
||||
* either. */
|
||||
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-evaluates on every mutation of these polled tables.
|
||||
* Not a .cbi-section-table — config forms keep their own layout.
|
||||
*
|
||||
* `.table`, not `table.table`, and a bare `<table>` counts too: a third-party app may emit a
|
||||
* `<div class="table">`, or a table with no LuCI class at all, and neither would be tagged,
|
||||
* measured or carded (docs/conventions.md: coverage is a contract). The bare-table half matches
|
||||
* nothing on the dev stand — a census over all 196 menu pages found zero — which is the point.
|
||||
*
|
||||
* No `:not(.fs-dt)`: these tables are polled, and ui.Table.update() and every hand-rolled
|
||||
* equivalent replace the rows inside the element they already have. Claiming a table once means the
|
||||
* fresh rows arrive without `.tr`/`.td` or `data-title` on a table that may by then be `.fs-stacked`
|
||||
* and clipped with no scrollbar. Both functions below are additive, skip what is already done and
|
||||
* re-decide nothing.
|
||||
*
|
||||
* `#view` is not the only place a table lands: `ui.showModal` parks its dialog on `#modal_overlay`,
|
||||
* a SIBLING of #view, so a table in the wireless-scan dialog was reached by none of this and stood
|
||||
* 56px past the dialog's edge at 390px. So the roots are listed once here and every table query is
|
||||
* built from them: the overlay is the scroll container (base/60-modal.css) and theme/70-modal.css
|
||||
* already keeps a wide child from scrolling the dialog off the screen, and a table is the widest
|
||||
* child there is.
|
||||
*
|
||||
* A CLOSED dialog is not a content root, though it is still in the DOM: `hideModal()` only drops a
|
||||
* class off <body> and the dialog keeps its markup, but the overlay then shrink-fits, so a table
|
||||
* inside it would be judged against a width the dialog will never have. `body.modal-overlay-active`
|
||||
* is the same fact the CSS uses. The flag flips AFTER the content is written, so this is asked per
|
||||
* PASS rather than fixed at load, and fs-fit.js watches that class. */
|
||||
const ROOTS = [ '#view', '#modal_overlay' ];
|
||||
const inRoots = (sel, roots) => roots.map((r) => `${r} ${sel}`).join(', ');
|
||||
const liveRoots = () => (document.body.classList.contains('modal-overlay-active') ? ROOTS : ROOTS.slice(0, 1));
|
||||
|
||||
const foreignTables = () => inRoots('.table:not(.cbi-section-table)', liveRoots()) + ', ' +
|
||||
inRoots('table:not(.table):not(.cbi-section-table)', liveRoots());
|
||||
|
||||
/* The fourth header markup, and the one only a foreign table produces: `<table><tr><th>…`, with no
|
||||
* `<thead>` for the parser to imply and none of LuCI's class names.
|
||||
*
|
||||
* "Every cell in the first row is a `<th>`" is the whole test, and it has to be EVERY: a data row
|
||||
* whose first cell is a row header would otherwise be read as the header row and every value below
|
||||
* it captioned with a value. A table with no `<th>` at all returns null and keeps the scroll
|
||||
* fallback. */
|
||||
function headerRow(t) {
|
||||
const row = t.rows && t.rows[0];
|
||||
if (!row || !row.cells.length) return null;
|
||||
return [ ...row.cells ].every((c) => c.tagName === 'TH') ? row : null;
|
||||
}
|
||||
|
||||
function tagDataTables() {
|
||||
document.querySelectorAll(foreignTables()).forEach((t) => {
|
||||
/* Four header markups, and missing one costs a page: ui.Table emits `.tr.table-titles`,
|
||||
* the apk Software page `.tr.cbi-section-table-titles`, a third-party table may use a real
|
||||
* `<thead>` (luci-mod-dashboard's are `<thead class="thead">` with `nowrap` cells, which
|
||||
* neither card nor compress, so their right-hand columns were clipped on a phone), and
|
||||
* headerRow() covers the fourth.
|
||||
*
|
||||
* `thead`, not `thead tr`: markup built by E() appends the `<th>`s straight to the
|
||||
* `<thead>`, so the parser's implied row never happens.
|
||||
*
|
||||
* Any of the four means a data table; none means a key/value include (System, Memory),
|
||||
* which must never card. */
|
||||
const head = t.querySelector('.tr.table-titles, .tr.cbi-section-table-titles, thead') || headerRow(t);
|
||||
if (!head) return;
|
||||
/* `.table` as well as `.fs-dt`, and only ever added: the theme's table vocabulary is
|
||||
* written against `.table`, so a newly recognised foreign table has to join it. A no-op on
|
||||
* everything LuCI renders. */
|
||||
t.classList.add('table', 'fs-dt');
|
||||
adoptMarkup(t, head);
|
||||
labelCells(t, head);
|
||||
});
|
||||
}
|
||||
|
||||
/* …and the rows and cells inside it, or the claim is a trap.
|
||||
*
|
||||
* `.table` alone gets the frame and the padding; everything that makes the CARD is written one
|
||||
* level down (`.table.fs-stacked .tr { display: flex }`, the `.td[data-title]::before` label, the
|
||||
* hidden header row), and a bare foreign `<table>` carries none of those names. The fitter would
|
||||
* then set `.fs-stacked` and change nothing, while `#view .table.fs-dt.fs-stacked` clips the
|
||||
* columns with no scrollbar to reach them.
|
||||
*
|
||||
* `.tr` / `.td` / `.th` are LuCI's own names for these roles (docs/third-party-apps.md), and the
|
||||
* theme is already writing `.table` onto the same element. Additive only and cheap to re-run every
|
||||
* pass: these tables are polled, so fresh rows arrive bare.
|
||||
*
|
||||
* The header also has to be recognisable, or the card shows it as a first row of column names: a
|
||||
* `<thead>` becomes `.thead`, a plain first row of `<th>` becomes `.tr.table-titles` — the two
|
||||
* names theme/30-tables.css hides when stacked. */
|
||||
function adoptMarkup(t, head) {
|
||||
/* Decided once, at claim time, then read on every pass: asking afresh each pass would answer
|
||||
* "already adopted" the moment we adopted it, and the bare rows a poll brings in would never be
|
||||
* taken. Asking it at all is what keeps the theme's hands off LuCI's own markup — adding
|
||||
* `table-titles` to a `.tr.cbi-section-table-titles` head would rewrite a class LuCI chose. */
|
||||
if (t._fsAdopt === undefined) t._fsAdopt = !t.querySelector('.tr, .thead');
|
||||
if (!t._fsAdopt) return;
|
||||
if (head.tagName === 'THEAD') head.classList.add('thead');
|
||||
else head.classList.add('tr', 'table-titles');
|
||||
const titleRow = (head.firstElementChild && head.firstElementChild.tagName === 'TR') ? head.firstElementChild : head;
|
||||
for (const c of titleRow.children) c.classList.add('th');
|
||||
/* `t.rows` covers a real <table> with or without a <tbody> — a table built with createElement
|
||||
* has its <tr> directly under the <table>, where `tbody tr` finds nothing. A `<div class="table">`
|
||||
* has no `.rows` and is LuCI's own markup, which carries the classes. */
|
||||
if (!t.rows) return;
|
||||
for (const row of t.rows) {
|
||||
if (head.contains(row) || row === head) continue;
|
||||
row.classList.add('tr');
|
||||
for (const cell of row.children) cell.classList.add(cell.tagName === 'TH' ? 'th' : 'td');
|
||||
}
|
||||
}
|
||||
|
||||
/* 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 builders fill it in; a
|
||||
* foreign table has no reason to, and carding one without captions gives a column of values with
|
||||
* nothing saying which is which.
|
||||
*
|
||||
* The heading is copied from the header cell in the same position, never invented, and never
|
||||
* overwrites an existing data-title — the app's own answer knows more than a positional guess.
|
||||
* Re-run every pass, because these tables are polled and fresh rows arrive bare; the per-row skip
|
||||
* below is what keeps that affordable. */
|
||||
function labelCells(t, head) {
|
||||
const rows = t.querySelectorAll('.tr, tbody tr');
|
||||
/* a `<thead>` written as markup nests a real `<tr>` (the parser inserts one even where the
|
||||
* author left it out) while one built by E() holds the `<th>`s directly, so reading
|
||||
* `head.children` blind captions every cell with the header row's entire text */
|
||||
const titleRow = (head.firstElementChild && head.firstElementChild.tagName === 'TR') ? head.firstElementChild : head;
|
||||
const titles = [ ...titleRow.children ].map((c) => (c.textContent || '').trim());
|
||||
if (!titles.some(Boolean)) return;
|
||||
for (const row of rows) {
|
||||
if (row === head) continue;
|
||||
const cells = row.children;
|
||||
/* Skip a row that is already captioned, asked of the ROW and of nothing else. A claimed
|
||||
* table is revisited on every mutation pass and everything LuCI renders is captioned by
|
||||
* ui.Table as it builds it, so the common case is answered by one attribute read.
|
||||
*
|
||||
* Per row, because a poll replaces rows one at a time and a table can hold captioned and
|
||||
* bare rows at once. A table-level probe has to guess which row speaks for the rest, and
|
||||
* every choice is wrong for some shape — asking the last row stalls forever on a `<tfoot>`
|
||||
* of totals while fresh rows keep arriving above it.
|
||||
*
|
||||
* Its blind spot: the test reads the FIRST cell, so a cell replaced inside a row whose first
|
||||
* cell survives stays bare. Nothing in the tree reaches it — ui.Table.update() swaps whole
|
||||
* `<tr>`s and hand-rolled equivalents rebuild the row — and testing every cell would cost
|
||||
* the walk this skip exists to avoid on every pass of every polled table.
|
||||
*
|
||||
* A first cell that can never take a caption (spanning, or with empty header text) leaves
|
||||
* its row walked every pass. The walk is idempotent, so that is the safe direction. */
|
||||
if (cells.length && cells[0].hasAttribute('data-title')) continue;
|
||||
/* Column cursor, not the cell index: a cell spanning N columns occupies N header slots while
|
||||
* advancing the index by one, so keying titles off `i` captions every cell after it with the
|
||||
* heading to its left. The spanning cell itself takes none, having no single heading. (A
|
||||
* rowspan reaching down from an earlier row would shift this too; no LuCI table emits one,
|
||||
* and a wrong caption is worse than none.) */
|
||||
let col = 0;
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
const span = (cells[i].colSpan > 1) ? cells[i].colSpan : 1;
|
||||
if (span === 1 && col < titles.length && titles[col] && !cells[i].hasAttribute('data-title'))
|
||||
cells[i].setAttribute('data-title', titles[col]);
|
||||
col += span;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- WCAG 1.3.1: a `display` override must not cost a table its own roles ----
|
||||
*
|
||||
* theme/*.css restyles `display` on `.table`/`.thead`/`.tbody`/`.tfoot`/`.tr`/`.th`/`.td` in over
|
||||
* twenty places — `.fs-stacked`, `.fs-rowstack`, the config table's own `@container`, the phone
|
||||
* `@media`, the Overview meter row, the software page's carded description column, the 560px
|
||||
* key/value card added alongside this file — scattered across selectors this module cannot see the
|
||||
* live state of (a media query, a container query, a class some other pass toggled). A `display`
|
||||
* other than `table`/`table-row`/`table-cell`/… drops the IMPLICIT `table`/`row`/`cell` role
|
||||
* HTML-AAM derives from it: measured on Overview's key/value cards, `display: block`/`grid`/`block`
|
||||
* (table/row/cell) at both 1440 and 390px, and on the 390px card stack, `block`/`none`/`block` —
|
||||
* neither carries a role, so the column a value belongs to is never announced.
|
||||
*
|
||||
* Rather than pair each of those twenty-odd rules with a role write timed to its own trigger — a
|
||||
* race with every one of them — the role is written UNCONDITIONALLY, matching what a plain
|
||||
* `<table>` already carries implicitly. That costs nothing while the table IS a table and survives
|
||||
* the moment any of those rules land. tools/table-contract.mjs holds the pairing: a future
|
||||
* `display` rule on one of these classes with no role to match is what it is written to catch.
|
||||
*
|
||||
* The whole chain, every pass — a `role="cell"` with nothing above it saying `row`, or a `row` with
|
||||
* nothing above IT saying `table`/`rowgroup`, reads worse than no roles at all. One flat query per
|
||||
* class keeps it whole without walking each table by hand: `.tr`/`.th`/`.td` are LuCI's own names,
|
||||
* always written together with `.table` (adoptMarkup() above never adds one without the others), so
|
||||
* a class query alone reaches the whole subtree. `.thead`/`.tbody`/`.tfoot` are the same for a
|
||||
* div-based table; a REAL `<thead>`/`<tbody>`/`<tfoot>` this file never classes (20_lan.js's and
|
||||
* 30_wifi.js's `<tfoot>`, theme/30-tables.css:570/634) is reached by tag name instead. */
|
||||
const TABLE_ROLE_CLASSES = [
|
||||
[ '.table', 'table' ],
|
||||
[ '.thead', 'rowgroup' ],
|
||||
[ '.tbody', 'rowgroup' ],
|
||||
[ '.tfoot', 'rowgroup' ],
|
||||
[ '.tr', 'row' ],
|
||||
[ '.th', 'columnheader' ],
|
||||
[ '.td', 'cell' ],
|
||||
];
|
||||
const TABLE_ROLE_TAGS = [
|
||||
[ 'thead', 'rowgroup' ],
|
||||
[ 'tbody', 'rowgroup' ],
|
||||
[ 'tfoot', 'rowgroup' ],
|
||||
];
|
||||
|
||||
/* idempotent, the same idiom as menu-footstrap-common.js's fsSyncAttr: a node that already carries
|
||||
* the right role is a no-op read, so a poll tick re-rendering the same table on every tick touches
|
||||
* no DOM and wakes no attribute observer. Not imported for one line — this file has none of that
|
||||
* module's other exports to justify the dependency. */
|
||||
function setRole(el, role) {
|
||||
if (el.getAttribute('role') !== role) el.setAttribute('role', role);
|
||||
}
|
||||
|
||||
/* A hidden row/cell (`.tr.cbi-section-table-descr`, `.td.hide-xs`/`.hide-sm` under `.fs-stacked`/
|
||||
* `.fs-drop-xs`) drops out of the accessibility tree on `display: none` alone — the role written
|
||||
* here changes nothing about that, since a role is inert on a box the engine never generates. */
|
||||
function roleTables() {
|
||||
TABLE_ROLE_CLASSES.forEach(([ sel, role ]) => {
|
||||
document.querySelectorAll(inRoots(sel, liveRoots())).forEach((el) => setRole(el, role));
|
||||
});
|
||||
TABLE_ROLE_TAGS.forEach(([ sel, role ]) => {
|
||||
document.querySelectorAll(inRoots(sel, liveRoots())).forEach((el) => setRole(el, role));
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- card-stack a data table that no longer fits ----
|
||||
*
|
||||
* Measuring, scheduling and the observers are fs-fit.js; this file supplies only the decision.
|
||||
* Carding by @container needed three thresholds, two of them carrying their own copy of the card
|
||||
* rules, and all three were really asking "does it overflow?" — a fact the browser computes. So it
|
||||
* is measured, the card rules live once 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, and a widget bakes in the width of the layout it was laid
|
||||
* out in, so un-collapsing it to read it changes what is read — the firewall zone table then
|
||||
* reported needing 1747px against a real 1190px and overflowed its section by 557px. */
|
||||
const stackables = () => inRoots('.table.fs-dt', liveRoots());
|
||||
|
||||
/* "Too cramped to be a table any more" — a design judgement, and the only threshold the fit
|
||||
* decision takes on trust rather than measuring. A four-column table of short values still fits at
|
||||
* 380px of room, technically, as four ribbons of two words each; nothing measures "unreadable",
|
||||
* and stock LuCI says 600px of viewport. 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 var(--fs-space-4) a side, 16px at the default density,
|
||||
* so 600 -> 568 of room. A fixed number and not a re-read of that token on
|
||||
* purpose: the threshold is the DESIGN judgement above, and Compact density
|
||||
* shrinking the gutter to 10px is not a reason to keep a table wider. */
|
||||
|
||||
/* ---- the remedy ladder: cheapest first, re-measured at every rung ----
|
||||
*
|
||||
* theme/30-tables.css gives a data table `overflow-wrap: break-word` for as long as it is a table,
|
||||
* so a column's min-content is an honest floor and the overflow the browser reports is honest with
|
||||
* it. (Letting any value break anywhere lowers min-content to one character, so a column is starved
|
||||
* instead of the table overflowing and `overflows()` goes blind; reconstructing the floor in JS
|
||||
* instead is UAX #14 by hand and wrong in both directions — `WPA2-PSK/CCMP` measured 144px against a
|
||||
* real 93px, so tables carded that had room.)
|
||||
*
|
||||
* That leaves one question — does it fit? — and a ladder of answers when it does not, each
|
||||
* re-measured:
|
||||
*
|
||||
* 1. it fits -> a table, and nothing was written
|
||||
* 2. drop the columns the VIEW marked droppable (`hide-xs`/`hide-sm`) and ask again
|
||||
* 3. let the widest breakable column shred, and ask again
|
||||
* 4. card it
|
||||
*
|
||||
* Rungs 2 and 3 need no threshold: the guard is the second measurement. Where one base64 key is the
|
||||
* entire problem, breaking that column makes the table fit; where every column is over its share,
|
||||
* breaking one changes nothing and the card is right.
|
||||
*
|
||||
* Rung 2 is upstream's own priority hint, honoured by measurement instead of by viewport: several
|
||||
* views mark their least valuable columns `hide-xs` and ui.Table copies the class onto every body
|
||||
* cell. */
|
||||
|
||||
/* Which cells carry the "may shred" mark, as a column index (-1 = none). Kept on the element so
|
||||
* the common case — the same column as last pass — writes nothing. */
|
||||
function markBreakColumn(t, rows, col) {
|
||||
if (t._fsBreakCol === col) return;
|
||||
t._fsBreakCol = col;
|
||||
for (const row of rows) {
|
||||
const cells = row.children;
|
||||
for (let i = 0; i < cells.length; i++)
|
||||
cells[i].classList.toggle('fs-td-break', i === col);
|
||||
}
|
||||
}
|
||||
|
||||
/* Rung 3. The widest column that can be shredded: never the first, which is the row's identity
|
||||
* (issue #36 is that column being starved), and never a `nowrap`/`pre` one, where `overflow-wrap`
|
||||
* is inert so the mark would buy a layout and no width.
|
||||
*
|
||||
* With honest floors nothing is starved, so an overflowing table has every column at its own
|
||||
* min-content and the widest one holds the longest unbreakable token — one `getComputedStyle` and
|
||||
* one rect per column of one row, no text measurement. */
|
||||
function breakWidestColumn(t) {
|
||||
const rows = t.querySelectorAll('.tr:not(.table-titles):not(.cbi-section-table-titles):not(.placeholder)');
|
||||
if (!rows.length) return false;
|
||||
const cells = rows[0].children;
|
||||
let col = -1, widest = 0;
|
||||
for (let i = 1; i < cells.length; i++) {
|
||||
const ws = getComputedStyle(cells[i]).whiteSpace;
|
||||
if (ws === 'nowrap' || ws === 'pre') continue;
|
||||
const w = cells[i].getBoundingClientRect().width;
|
||||
if (w > widest) { widest = w; col = i; }
|
||||
}
|
||||
if (col < 0) return false;
|
||||
markBreakColumn(t, rows, col);
|
||||
if (!fit.overflows(t)) return true;
|
||||
markBreakColumn(t, rows, -1);
|
||||
return false;
|
||||
}
|
||||
|
||||
/* ---- a table that replaces a table inherits its answer ----
|
||||
*
|
||||
* The measurement below has to strip a table's marks first (fs-fit rule 1: a stacked table is a
|
||||
* pile of flex rows and always "fits"), which lays the page out with a full-width table for the
|
||||
* length of the pass — several screens taller than the card stack it is about to be. The poll
|
||||
* REPLACES these tables rather than updating them, so every tick hands the fitter a fresh element
|
||||
* with no marks and the intermediate happens again: at iPhone width it threw the reader 612px out
|
||||
* and back, twice per tick. Compensating for it afterwards was tried four ways and each was wrong
|
||||
* somewhere.
|
||||
*
|
||||
* So the answer is kept on the SLOT instead — the section frame (`slotHome()` says why not the
|
||||
* table's own parent), inside which a table is named by its id, or by its position among that
|
||||
* frame's tables.
|
||||
*
|
||||
* The inherited answer is trusted only while the inputs hold: the tier depends on how many columns
|
||||
* share the room and how wide the room is, so both are remembered with it. Room is `roomFor()`,
|
||||
* which reads the container rather than the table and is unaffected by the marks; the shape is the
|
||||
* column count (`shapeOf()` says why rows are not in it).
|
||||
*
|
||||
* A change in either re-measures, as does a table arriving in an unmeasured slot, as does every
|
||||
* full pass — which happens whenever the reader is not scrolling and is what catches a value that
|
||||
* grew long enough to change the tier. */
|
||||
const _slots = new WeakMap();
|
||||
|
||||
/* The slot is the frame, not the parent, and the difference is the whole cache: a poll tick hands
|
||||
* the container a freshly rendered subtree, so the table's immediate parent is usually new too and
|
||||
* every lookup would miss. What survives a tick is the `.cbi-section` frame. */
|
||||
function slotHome(t) {
|
||||
return (t.closest && t.closest('.cbi-section, .cbi-section-node, #view')) || t.parentElement;
|
||||
}
|
||||
|
||||
function slotKey(t, home) {
|
||||
if (t.id) return '#' + t.id;
|
||||
const kin = home ? Array.from(home.querySelectorAll('.table')) : [];
|
||||
const at = kin.indexOf(t);
|
||||
return at >= 0 ? 'at' + at : 'lone';
|
||||
}
|
||||
|
||||
/* What can change the answer: how many columns share the room, and how wide the room is — not how
|
||||
* many rows there are. A lease list gains and loses a row on almost every tick, so rows in this
|
||||
* signature mean the cache never hits and every tick measures from scratch, which is the tall
|
||||
* intermediate this exists to avoid.
|
||||
*
|
||||
* Read from the tree, never from layout: `children.length` is a DOM question and forces nothing. */
|
||||
function shapeOf(t) {
|
||||
const head = t.querySelector('.tr.table-titles, .cbi-section-table-titles') || t.firstElementChild;
|
||||
return String(head ? head.children.length : 0);
|
||||
}
|
||||
|
||||
function applyDecision(t, d) {
|
||||
/* the mark that lets the table into the layout at all — see theme/30-tables.css */
|
||||
t.classList.add('fs-fitted');
|
||||
t.classList.toggle('fs-stacked', !!d.stack);
|
||||
t.classList.toggle('fs-drop-xs', !!d.drop);
|
||||
if (d.breakCol !== undefined && d.breakCol !== -1)
|
||||
markBreakColumn(t, t.querySelectorAll('.tr'), d.breakCol);
|
||||
}
|
||||
|
||||
function fitTables() {
|
||||
/* mid-scroll, only the tables with no answer yet: one that has an answer needs nothing, one
|
||||
* that does not is held out of the layout by the stylesheet and cannot wait. Neither branch
|
||||
* reads layout while `scrolling()` is true. */
|
||||
const sel = fit.scrolling() ? inRoots('.table.fs-dt:not(.fs-fitted)', liveRoots()) : stackables();
|
||||
document.querySelectorAll(sel).forEach((t) => {
|
||||
/* While the reader scrolls this pass only WRITES: every layout read here forces a
|
||||
* synchronous layout, and a poll tick can land mid-flick, so on a phone with ten tables that
|
||||
* is ten forced layouts in the middle of a flick — the work iOS holds the main thread back
|
||||
* to prevent. */
|
||||
const home = slotHome(t);
|
||||
const slots = home ? (_slots.get(home) || new Map()) : null;
|
||||
const key = slots ? slotKey(t, home) : null;
|
||||
const known = slots ? slots.get(key) : null;
|
||||
|
||||
/* The answer still goes on mid-scroll, and it must: letting a freshly polled table wait for
|
||||
* the scrolling to stop makes the reader watch the tables fold into cards under their
|
||||
* thumb, which is the hardest jerk of all. The answer is a write, forces nothing, and keeps
|
||||
* the fresh table the shape of the one it replaced. */
|
||||
if (fit.scrolling()) {
|
||||
/* the column's width, not the window's: they differ by the sidebar, so an 800px window
|
||||
* whose column is 520px would clear CRAMPED and leave the table unstacked and clipped.
|
||||
* `chrome.contentWidth()` answers from what the last fitter measured, reading no
|
||||
* layout. */
|
||||
applyDecision(t, known || { stack: chrome.contentWidth() < CRAMPED, drop: false, breakCol: -1 });
|
||||
fit.deferMeasurement();
|
||||
return;
|
||||
}
|
||||
|
||||
/* SOMEBODY ELSE'S SCROLLER, SOMEBODY ELSE'S DECISION. An app that puts its table in a box of
|
||||
* its own with `overflow-x: auto` has already answered the question this file asks, and
|
||||
* every answer here would overrule it: inside a 598px box a wide table overflows by
|
||||
* definition, so the ladder ends in a card stack however much room the page has.
|
||||
* luci-app-filemanager parks its listing in a `div.resizeable` of exactly that width and
|
||||
* came out as cards on a 1280px screen, with 1224px of column beside it (docs/third-party-
|
||||
* apps.md: what the theme may do to a foreign table). Asked before the slot's answer is
|
||||
* applied, so a table that was carded before this rule existed is un-carded rather than
|
||||
* re-carded. */
|
||||
if (fit.inScroller(t)) {
|
||||
t.classList.add('fs-fitted');
|
||||
t.classList.remove('fs-stacked', 'fs-drop-xs');
|
||||
return;
|
||||
}
|
||||
|
||||
/* the column count, read from the tree: asking forces no layout */
|
||||
const shape = shapeOf(t);
|
||||
|
||||
/* The answer goes on before anything is measured, and that order is the fix: `roomFor()`
|
||||
* forces layout, and a fresh table from a poll tick is unmarked when it lands, so measuring
|
||||
* first lays the page out with a full-width table — the tall intermediate the engine
|
||||
* re-anchors on. With the slot's answer applied first, the only layout this pass forces is
|
||||
* one where the table already wears the answer it had a second ago. */
|
||||
if (known) applyDecision(t, known);
|
||||
|
||||
const was = t.classList.contains('fs-stacked');
|
||||
/* rounded, because `roomFor()` subtracts parsed padding and answers in fractions, so an
|
||||
* exact compare misses the cache on a width that has not moved */
|
||||
const room = Math.round(fit.roomFor(t));
|
||||
if (!(room > 0)) {
|
||||
/* Detached, hidden, or a closed dialog: keep the previous answer rather than decide
|
||||
* against a width it does not have — but let it into the layout anyway. The stylesheet
|
||||
* keeps an unanswered table out of the flow, and a table whose section is collapsed on
|
||||
* arrival would otherwise stay hidden after the section is opened, since opening one
|
||||
* changes no width this file watches. */
|
||||
t.classList.add('fs-fitted');
|
||||
return;
|
||||
}
|
||||
|
||||
/* On its first pass the table is not measurable, because this file's own gate hides it:
|
||||
* theme/30-tables.css holds a `.fs-dt` out of the layout until something marks it
|
||||
* `.fs-fitted`, so the pass reads `scrollWidth: 0` and 0 overflows nothing. Concluding "it
|
||||
* fits" and caching that leaves the table at its natural width, clipped by
|
||||
* `.fs-main { overflow-x: clip }` with no scrollbar: measured at 768px, 777px of table inside a
|
||||
* 712px column, and a page that renders once and stands still never gets a second pass to
|
||||
* correct it.
|
||||
*
|
||||
* So a zero measurement is not an answer: lift the gate so the next frame can see the
|
||||
* table, ask for that frame, and write nothing to the slot. The schedule is asked for once,
|
||||
* so a table hidden for somebody else's reason cannot turn this into a frame loop. */
|
||||
if (t.scrollWidth === 0) {
|
||||
if (!t.classList.contains('fs-fitted')) {
|
||||
t.classList.add('fs-fitted');
|
||||
fit.schedule();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/* The slot's answer holds for a table of the same shape in the same room — unless the table
|
||||
* has since outgrown it: a poll tick can put longer values into the same columns, and a
|
||||
* table that fitted a second ago then stands 1000px wide in an 810px column, clipped with
|
||||
* nothing to say so. That question uses the layout this pass already has.
|
||||
*
|
||||
* The other direction cannot be asked that way. `overflows()` is put to a table already
|
||||
* wearing its remedy, and every remedy makes a table fit by construction, so the answer is
|
||||
* always yes and a remedy applied once could never be lifted — one long hostname makes
|
||||
* Associated Stations drop its `hide-xs` columns, and they stay hidden after that station
|
||||
* leaves. An honest measurement needs the marks stripped, which is the visible relayout the
|
||||
* deferral above keeps out of a scroll, so the re-decision is asked for by the CONTENT
|
||||
* instead: a remedied table whose row count has gone DOWN has lost something, and that is
|
||||
* the only way it can stop needing the remedy. Rows are counted from the tree. */
|
||||
const rows = t.querySelectorAll('.tr').length;
|
||||
const remedied = !!(known && (known.stack || known.drop || known.breakCol !== -1));
|
||||
const shrank = remedied && known.rows !== undefined && rows < known.rows;
|
||||
if (known && known.room === room && known.shape === shape && !shrank && !fit.overflows(t))
|
||||
return;
|
||||
|
||||
/* 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. Every mark the ladder can
|
||||
* write comes off here. */
|
||||
t.classList.remove('fs-stacked', 'fs-drop-xs');
|
||||
if (t._fsBreakCol !== undefined && t._fsBreakCol !== -1)
|
||||
markBreakColumn(t, t.querySelectorAll('.tr'), -1);
|
||||
|
||||
/* the one judgement a measurement cannot make */
|
||||
let stack = room < CRAMPED;
|
||||
if (!stack && fit.overflows(t)) {
|
||||
/* rung 2 — the view author already said which columns are expendable */
|
||||
if (t.querySelector('.hide-xs, .hide-sm')) t.classList.add('fs-drop-xs');
|
||||
/* rung 3 — one column may shred rather than the whole table becoming a stack of cards */
|
||||
if (fit.overflows(t) && !breakWidestColumn(t)) stack = true;
|
||||
}
|
||||
|
||||
t.classList.add('fs-fitted');
|
||||
/* write only on a real change: the poll re-renders these tables on every tick, and toggling
|
||||
* the class each tick invalidates style for every row for nothing */
|
||||
if (stack) {
|
||||
t.classList.add('fs-stacked');
|
||||
/* a card gives every value the whole row, so neither remedy has anything left to do */
|
||||
t.classList.remove('fs-drop-xs');
|
||||
if (t._fsBreakCol !== undefined && t._fsBreakCol !== -1)
|
||||
markBreakColumn(t, t.querySelectorAll('.tr'), -1);
|
||||
}
|
||||
else if (was) t.classList.remove('fs-stacked');
|
||||
|
||||
if (slots) {
|
||||
slots.set(key, {
|
||||
stack,
|
||||
drop: t.classList.contains('fs-drop-xs'),
|
||||
breakCol: (t._fsBreakCol === undefined) ? -1 : t._fsBreakCol,
|
||||
room,
|
||||
shape,
|
||||
/* what the answer was decided over: a drop below this is the one signal that a
|
||||
* remedy may no longer be needed (see the fast path above) */
|
||||
rows,
|
||||
});
|
||||
_slots.set(home, slots);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- and the table that cannot card: it scrolls, and it says so ----
|
||||
*
|
||||
* A table with no header row has no captions to print, so a card would give a column of values with
|
||||
* nothing saying what they are — a log, a statistics matrix, a key/value include. Comparison across
|
||||
* rows is that shape's purpose, which is the case WCAG 2.2 names in the exception to SC 1.4.10
|
||||
* ("data tables and grids… it is acceptable to provide two-dimensional scrolling for such parts").
|
||||
* theme/30-tables.css already scrolls a bare `<table>` of that shape; this reaches the one it
|
||||
* cannot name — an app rendering the same thing as `<div class="table">` — by measurement rather
|
||||
* than by selector.
|
||||
*
|
||||
* A scrolling table cannot hold a popup: `overflow-x: auto` computes `overflow-y` to `auto` too
|
||||
* (css-overflow-3 §3.1), so it clips everything absolutely positioned inside it, and luci-base's
|
||||
* `openDropdown()` sizes an open list against the nearest scroll parent.
|
||||
*
|
||||
* So the ones that hold CONTROLS stack instead. Their rows are a form, not data, so there is no
|
||||
* comparison across rows to preserve; rows and cells become blocks, each control takes the width it
|
||||
* was given, and nothing becomes a scroll container. Doing nothing for them is a real defect —
|
||||
* Network -> Diagnostics is three controls in one header-less row needing 338px in 320px, and the
|
||||
* Ping button was simply unreachable past `.fs-main`'s clip. The shape is not that page's, and the
|
||||
* width that matters is the room rather than the viewport, so this measures instead of naming a
|
||||
* page. */
|
||||
const scrollables = () => inRoots('.table:not(.fs-dt):not(.cbi-section-table)', liveRoots());
|
||||
const HOLDS_CONTROLS = '.cbi-dropdown, .cbi-dynlist, .cbi-tooltip-container, .cbi-progressbar, select, input, textarea, [data-tooltip]';
|
||||
|
||||
/* ---- making a scroll box reachable, and handing the markup back as it was found ----
|
||||
*
|
||||
* Firefox has made scrollers focusable for years and Chrome since 132, but only when they hold no
|
||||
* focusable child, and WebKit has not shipped it at all (bug 190870). So say it in the markup: a
|
||||
* tab stop (SC 2.1.1) and a name for what receives focus (SC 4.1.2).
|
||||
*
|
||||
* A role is written only where there is none to lose. `<div class="table">` has no implicit role
|
||||
* and takes `group`; a real `<table>` keeps its own, because HTML-AAM maps `<td>` to `cell` and
|
||||
* `<th>` to `columnheader`/`rowheader` only while the table's role is `table` (or `grid`/
|
||||
* `treegrid`), so `group` would drop every cell to generic and leave a screen reader reading a flat
|
||||
* run of text.
|
||||
*
|
||||
* What is removed is what was written, remembered per element rather than inferred from the value:
|
||||
* "is it 0 / is it group" cannot tell whose attribute it is. */
|
||||
function reach(t) {
|
||||
if (!t.hasAttribute('tabindex')) { t.tabIndex = 0; t._fsTab = true; }
|
||||
if (!t.hasAttribute('role') && !(t instanceof HTMLTableElement)) {
|
||||
t.setAttribute('role', 'group');
|
||||
t._fsRole = true;
|
||||
}
|
||||
if (!t.hasAttribute('aria-label') && !t.hasAttribute('aria-labelledby')) {
|
||||
const head = t.closest('.cbi-section, fieldset, #view')?.querySelector('h2, h3, h4, legend');
|
||||
t.setAttribute('aria-label', (head && head.textContent.trim()) || _('Table'));
|
||||
t._fsNamed = true;
|
||||
}
|
||||
}
|
||||
|
||||
function unreach(t) {
|
||||
if (t._fsTab) { t.removeAttribute('tabindex'); delete t._fsTab; }
|
||||
if (t._fsRole) { t.removeAttribute('role'); delete t._fsRole; }
|
||||
if (t._fsNamed) { t.removeAttribute('aria-label'); delete t._fsNamed; }
|
||||
}
|
||||
|
||||
function fitScrollables() {
|
||||
/* a layout read here lands mid-flick once per poll tick — see fitTables(). Put off until the
|
||||
* page is still; fs-fit's sampler runs what was deferred once the offset stops changing. */
|
||||
if (fit.scrolling()) { fit.deferMeasurement(); return; }
|
||||
document.querySelectorAll(scrollables()).forEach((t) => {
|
||||
const was = t.classList.contains('fs-xscroll');
|
||||
const wasStack = t.classList.contains('fs-rowstack');
|
||||
/* rule 1 again: a scrolling box always "fits", its overflow being inside it, and so does a
|
||||
* stacked one, whose rows are blocks */
|
||||
t.classList.remove('fs-xscroll', 'fs-rowstack');
|
||||
if (!(fit.roomFor(t) > 0)) {
|
||||
if (was) t.classList.add('fs-xscroll');
|
||||
if (wasStack) t.classList.add('fs-rowstack');
|
||||
return;
|
||||
}
|
||||
const over = fit.overflows(t);
|
||||
const controls = over && !!t.querySelector(HOLDS_CONTROLS);
|
||||
/* a table of CONTROLS stacks; a table of VALUES scrolls and keeps its shape */
|
||||
if (controls) {
|
||||
t.classList.add('fs-rowstack');
|
||||
if (was) unreach(t);
|
||||
return;
|
||||
}
|
||||
const scroll = over;
|
||||
if (scroll === was) { if (was) t.classList.add('fs-xscroll'); return; }
|
||||
if (scroll) {
|
||||
t.classList.add('fs-xscroll');
|
||||
/* a scroll box the keyboard cannot reach is content the keyboard cannot read — see
|
||||
* reach(). `group`, not `region`: a region is a landmark, and a status page with four
|
||||
* scrolling tables would put four of them in the landmark list. */
|
||||
reach(t);
|
||||
}
|
||||
else if (was) unreach(t);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- a pinned actions column is only valid for the layout it was measured in ----
|
||||
*
|
||||
* luci-base's form.js (stabilizeActionColumnWidth) measures the widest `td.cbi-section-actions > div`
|
||||
* and writes it as an inline `width`/`min-width` on the header, footer and every actions cell,
|
||||
* caching it in `data-action-col-width`. Its resize handler deletes only the CACHE, never the inline
|
||||
* widths, so the fresh measurement reads the width it pinned last time: the pin feeds itself and can
|
||||
* only grow.
|
||||
*
|
||||
* On a stock theme that is invisible, a config table being a table at every width. This theme cards
|
||||
* it under `@container fs-content (max-width: 960px)`, where the actions cell is `flex: 1 1 100%`
|
||||
* and its buttons spread across the whole card, so a measurement taken there is the CARD's width —
|
||||
* carried into table mode, firewall/zones pins 634px and scrolls sideways by 256px, permanently,
|
||||
* against 192px on a fresh load at the same width.
|
||||
*
|
||||
* So drop the pin whenever the layout it was measured in stops being the layout on screen; upstream
|
||||
* re-measures from a clean DOM and pins the right number, and if it does not, the natural width is
|
||||
* what we wanted.
|
||||
*
|
||||
* The key is the ROOM, not the mode: the card getting narrower goes stale the same way, and at 768px
|
||||
* (no sidebar, 712px column) against 800px (sidebar back, 520px column) the table is carded on both
|
||||
* sides, so a mode test sees no change while a 670px cell sits in a 520px column. A mode change
|
||||
* cannot happen without a room change, so the room catches both.
|
||||
*
|
||||
* The room is the parent's content box, 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. */
|
||||
function unpinActionColumn() {
|
||||
/* a layout read here lands mid-flick once per poll tick — see fitTables() */
|
||||
if (fit.scrolling()) { fit.deferMeasurement(); return; }
|
||||
for (const t of document.querySelectorAll(inRoots('.table.cbi-section-table', liveRoots()))) {
|
||||
if (!t.querySelector('.cbi-section-actions')) continue;
|
||||
/* Claim upstream's resize hook, which under SPA navigation is a leak:
|
||||
* stabilizeActionColumnWidth attaches a `resize` listener once per TABLE ELEMENT, guarded by
|
||||
* this expando, and nothing removes it. On a stock theme it dies with the document; here the
|
||||
* document lives for the session, so every visit to a config page leaves another listener
|
||||
* holding another detached table — 1 to 31 listeners over 120 navigations, ~11.8 KB per
|
||||
* navigation that never plateaus.
|
||||
*
|
||||
* Setting the flag first means the listener is never attached, and nothing is lost: the wipe
|
||||
* below re-measures from the room instead of 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; a table reached late simply keeps upstream's
|
||||
* listener. */
|
||||
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 schedules a full
|
||||
* scan, and the poll rewrites content on every tick: three document-wide querySelectorAll plus a
|
||||
* choicesKey() over every option of every enhanced select, per tick, to discover that nothing
|
||||
* 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 */
|
||||
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,
|
||||
* which an <option> is neither — so the batch is dropped, the widget keeps the old list, and
|
||||
* picking from it writes a value the new list does not contain. CBI dependency handling
|
||||
* rebuilds option lists constantly. */
|
||||
if (m.type === 'childList' && m.target.tagName === 'SELECT')
|
||||
return true;
|
||||
}
|
||||
/* `.table`, not `table.table` — the `.table` half of the selector tagDataTables() uses. Additions
|
||||
* only: a select or a table going away costs 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 does only
|
||||
* Esc/Enter/Space/arrows.
|
||||
*
|
||||
* 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; the buffer
|
||||
* resets after a pause; the same letter repeated cycles; the label matches first and the value
|
||||
* second, so "RU" and "Russia" both find it. Space is excluded — ui.Dropdown binds it to "toggle
|
||||
* the focused item" and its handler fires first.
|
||||
*
|
||||
* 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();
|
||||
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;
|
||||
|
||||
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 rather
|
||||
* than swallow 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();
|
||||
|
||||
/* Arm the stylesheet's gate from here, because this is the file that clears it: the rule in
|
||||
* theme/30-tables.css holds an unanswered data table out of the layout, and `.fs-fitted` is
|
||||
* written nowhere but in this module. Armed in fs-fit.js, a document that loaded that file
|
||||
* and not this one (the footer requires them separately) hid every table forever. */
|
||||
fit.armGate();
|
||||
|
||||
/* A table must be tagged .fs-dt before it can be fitted, and re-tagged whenever the poll
|
||||
* brings a fresh one back, so tagging leads; roleTables() follows it directly so a class
|
||||
* adoptMarkup() just added carries its role in the same pass rather than waiting a tick.
|
||||
*
|
||||
* Six registrations, not one callback: fs-fit catches per registered fitter, and the first
|
||||
* of these walks third-party markup. Bundled, one throw in `tagDataTables()` leaves no table
|
||||
* tagged and — with the gate above raised — a page with no tables on it at all. */
|
||||
fit.add(tagDataTables);
|
||||
fit.add(roleTables);
|
||||
fit.add(fitTables);
|
||||
fit.add(fitScrollables);
|
||||
fit.add(unpinActionColumn);
|
||||
fit.add(resyncValues);
|
||||
|
||||
/* one scan per frame, however many mutations arrive (fit.frame is the shared coalescer) */
|
||||
const scanSoon = fit.frame(scan);
|
||||
new MutationObserver((mutations) => {
|
||||
if (relevant(mutations)) scanSoon();
|
||||
}).observe(document.body, {
|
||||
childList: true, subtree: true,
|
||||
/* `disabled` flips and attribute-driven value writes never mutate childList, so watch
|
||||
* them for resync()/enhance() */
|
||||
attributes: true, attributeFilter: [ 'disabled', 'value', 'selected' ]
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,802 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
|
||||
/* ---- a view's injected CSS: never DELETE it; leave a poisoned document to a real load ----
|
||||
*
|
||||
* A view's <style> dies with the document on a full load, but SPA nav never reloads, so it restyles
|
||||
* every page after. `luci-app-filemanager` injects
|
||||
* `.cbi-button-save:not(.custom-save-button) { display: none !important }` — unlayered and
|
||||
* important, outranking every cascade layer, so one visit removes Save/Reset from every config page.
|
||||
*
|
||||
* Deleting such sheets on navigation is not the answer: a stylesheet only returns if its injector
|
||||
* runs again, and a library importing CSS at module eval never does (the module is cached for the
|
||||
* life of the document). ACE's 14 KB editor sheet is imported once, and after a sweep its editor
|
||||
* came back as a black rectangle two million pixels tall. Deletion is silently one-way.
|
||||
*
|
||||
* So: a sheet matching only its own app's widgets is inert elsewhere and is left alone. One
|
||||
* reaching into the widget universe the THEME styles can repaint any page, which spends the
|
||||
* document — the router then refuses to hand it to another view and falls back to a real page load,
|
||||
* trading speed and never correctness (fs-router.js owns that refusal; this module answers the
|
||||
* question).
|
||||
*
|
||||
* `invasiveSheet()` is that test, and its universe is read back from cascade.css itself rather than
|
||||
* from a hand-written list, so it tracks the theme; 0.3 ms per nav. Exempt: anything the server
|
||||
* marked `[data-fs-shell]` (partials/head.ut) and anything inside `#view`, which dies with the
|
||||
* content swap. LuCI core injects no <style> at runtime (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: the vocabulary the invasive-sheet test
|
||||
* is written in. One copy, because widening it in the harvester alone puts names into `names` that
|
||||
* pinnedToApp() can never match, and a selector that does reach the chrome then reads as pinned and
|
||||
* is left unfenced.
|
||||
*
|
||||
* Shared safely because every use is String.match(): a /g regex is stateful under .test(), while
|
||||
* [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 would otherwise read it as syntax ----
|
||||
*
|
||||
* `[title="a,b"]` is one selector part carrying a comma, `[href*="("]` one attribute carrying an
|
||||
* unbalanced paren, `[data-x=".foo"]` names no class at all. Read literally each scanner gets a
|
||||
* different wrong answer: selectorParts() splits the part in two and the tail reads as unpinned,
|
||||
* fenceRules() rejoins it as `[title="a, b"]` and silently rewrites the app's own selector, and
|
||||
* stripPseudoArgs() drives `depth` into a hole and eats the rest of the selector.
|
||||
*
|
||||
* One masker answers it for all three. It replaces the CONTENT of every quoted string with spaces,
|
||||
* length-preserving 1:1, so a scanner can read the mask and still slice the ORIGINAL — the fence
|
||||
* must write back the app's own bytes. 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, and dedupeViewSheets keys on the
|
||||
* original — otherwise the app's next identical copy stops looking like a duplicate */
|
||||
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() masks, so it could never
|
||||
* match — see NAME_RE */
|
||||
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 must exist for it to match, so
|
||||
* it can reach neither another page nor our chrome. A part made entirely of names the theme knows
|
||||
* (`*`, `.nav`, `ul.nav > li > a`) has nothing pinning it anywhere.
|
||||
*
|
||||
* Functional pseudo-class arguments are stripped before looking for the pin, which is the whole
|
||||
* difference between podkop and the file manager: `.cbi-button-save:not(.custom-save-button)` names
|
||||
* an app class inside a NEGATION, so it excludes the app's markup rather than requiring it.
|
||||
*
|
||||
* Shared by invasiveSheet() and fenceRules(), which must agree by construction: a part able to
|
||||
* reach another page is exactly a part able to reach the chrome. */
|
||||
/* Split a selector list on its TOP-LEVEL commas. `String.split(',')` cannot: `:not(.a, .b)` is one
|
||||
* part carrying a comma, and both halves then reach pinnedToApp() as garbage — the tail keeps a
|
||||
* visible app name, so the file manager's motivating rule reads as pinned and is neither judged nor
|
||||
* fenced.
|
||||
*
|
||||
* Scans the mask and slices the original (see maskStrings): a comma inside `[title="a,b"]` is a
|
||||
* character in a value, and fenceRules() joins the parts 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. A regex cannot: stopping at the
|
||||
* first `)` leaves `:not(:is(.app))` as a stray paren plus a `.app` that looks like a pin.
|
||||
*
|
||||
* Works on the mask, so a paren inside `[href*="("]` cannot drive `depth` into a hole and a `.foo`
|
||||
* inside an attribute value cannot read as the app's own pin. The output is read by NAME_RE and
|
||||
* never written back to the CSSOM. */
|
||||
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`, `*`) cannot touch us if none of its DECLARATIONS
|
||||
* can: a custom property this theme never reads is inert wherever it lands, which is the difference
|
||||
* between an app costing a full page load and not.
|
||||
*
|
||||
* Still invasive: any standard property on a bare selector (`:root { color-scheme: light dark }`
|
||||
* re-points every UA widget at the OS preference), and any custom property the theme reads — the
|
||||
* private `--fs-*` tier exists so an app writing `--accent` on `:root` cannot repaint us. */
|
||||
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 must be: once 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.
|
||||
*
|
||||
* Only `true` is kept. A clean sheet can still grow hostile rules — an app building its CSS with
|
||||
* insertRule() has an empty sheet the first time we look — so a clean verdict stays provisional. */
|
||||
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, so
|
||||
* judgeSheet's "unreadable -> invasive" default fires for every linked app stylesheet the
|
||||
* instant the observer sees it; remembering that turns a benign sheet into a permanently spent
|
||||
* document and every navigation from that page into a full load.
|
||||
*
|
||||
* Re-taking is safe and does not break the rule above, because the cases do not overlap: a
|
||||
* <style> in the document always has a sheet, so its verdict is cached when taken, and a <link>
|
||||
* is never edited (the element is disabled and the href re-imported), so a later read still sees
|
||||
* the app's untouched CSS.
|
||||
*
|
||||
* The conservative half is preserved: `v` is returned as taken, so an unreadable sheet is still
|
||||
* fenced on sight. 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 pinned inside the
|
||||
* app's own markup? A part with no such pin matches the same widgets everywhere,
|
||||
* and that is what invasive means.
|
||||
*
|
||||
* Do not ask "does it name anything the theme styles?" first: a pin is a name the
|
||||
* theme does NOT know, so "names nothing of ours" reads as pinned when it often
|
||||
* means the opposite — `*:not(#zzz)` and `[class]` are both unpinned and both match
|
||||
* the whole document, and the sheet was judged clean while flattening 95 of 338
|
||||
* chrome elements. */
|
||||
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, including our own re-hosting shim. An unreadable import is 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: `luci-app-banip` and `luci-app-adblock` append a <link> to <head> at
|
||||
* module eval styling stock widgets, unlayered, on every page. A <link> inside the view tree needs
|
||||
* no handling — it dies with the swap. */
|
||||
const VIEW_SHEETS = 'style:not([data-fs-shell]), link[rel~="stylesheet"]:not([data-fs-shell])';
|
||||
|
||||
/* Does this sheet outlive the page it arrived with? Everything this module decides hangs off that,
|
||||
* and the answer is where the element sits: a sheet inside the view tree dies with the swap, so it
|
||||
* can neither poison the next page, nor need scoping, nor be a duplicate worth removing. Named
|
||||
* because `!el.closest('#view')` states where an element is, not what follows from it. */
|
||||
function outlivesPage(el) {
|
||||
return !el.closest('#view');
|
||||
}
|
||||
|
||||
/* Is `path` — a menu.d node's `css` — already carried by this document in a form that SURVIVES a
|
||||
* swap? The router asks before committing a client navigation, since only a server render emits
|
||||
* that <link> and a page whose stylesheet is missing must arrive by full load (fs-router.js). A
|
||||
* link inside #view does not count: it is about to be deleted with the view.
|
||||
*
|
||||
* Whole path, never a suffix. head.ut prints `{{ resource }}/{{ dispatched.css }}?v=…` from the
|
||||
* same base value the runtime holds, so the server's href is reconstructable rather than guessable
|
||||
* — only the cache key comes off. A suffix match would let a third-party node declaring
|
||||
* `"css": "custom.css"` read as already-carried on the strength of `luci-app-adblock`'s own
|
||||
* custom.css, and the router would swap into a page whose stylesheet was never linked.
|
||||
*
|
||||
* Equality also fails in the direction that only costs speed: L.path() drops a path containing
|
||||
* characters it does not allow (no `+ ~ ( ) @ ! ' $ &`, nothing non-ASCII), while head.ut
|
||||
* interpolates `css` raw, so such a node's `want` collapses to the bare base, matches nothing, and
|
||||
* every entry into that page is a full load. No in-tree node sets `css`, so the shape is
|
||||
* third-party only. */
|
||||
function documentCarries(path) {
|
||||
const want = L.resource(String(path));
|
||||
for (const link of document.querySelectorAll('link[rel~="stylesheet"][href]')) {
|
||||
if (!outlivesPage(link))
|
||||
continue;
|
||||
if ((link.getAttribute('href') || '').split('?')[0] === want)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* An invasive sheet we OWN is contained: scopeToCurrentPage() darkens it the moment the router
|
||||
* stamps the new page. One we could not attribute (an @import at the top, a sheet built with
|
||||
* insertRule(), anything unreadable) still spends the document, which is the conservative half.
|
||||
*
|
||||
* A SILENCED sheet is contained too. Re-hosting a <link> owns the @import shim and silences the
|
||||
* original for good, but the original stays in the document and a disabled sheet still answers
|
||||
* `cssRules` — counting it would re-judge it invasive on every ask, owned by nobody, and turn the
|
||||
* SPA router off for the life of the document on exactly the apps this module was written for.
|
||||
*
|
||||
* Sound because `el.sheet.disabled = true` is what decides whether CSS paints (see silence()) and
|
||||
* nothing re-enables it: scopeToCurrentPage() only touches sheets in `_owner`, and the silenced
|
||||
* original is deliberately not one. */
|
||||
function documentPoisoned() {
|
||||
const names = themeNames();
|
||||
return Array.prototype.some.call(
|
||||
document.querySelectorAll(VIEW_SHEETS),
|
||||
(el) => outlivesPage(el)
|
||||
&& (!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, where 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 `* { margin: 0; padding: 0 }` owns the
|
||||
* chrome outright — on a layer-less theme the same `*` (0,0,0) loses to any class selector. The
|
||||
* layers are what hand it the win (issue #8).
|
||||
*
|
||||
* So put the sheet back on specificity footing by re-hosting it into the EXISTING `theme` layer;
|
||||
* only same-layer arbitrates by specificity. Measured on the real cascade:
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* Do not take the third shape: the theme would then beat the app at any specificity, including the
|
||||
* rules the app aims at its own page (openclash's `#tab-header ul.cbi-tabmenu li` against our
|
||||
* `ul.cbi-tabmenu li`). 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 names.
|
||||
*
|
||||
* 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. An unlayered `*` beats base
|
||||
* today too, and the only way out would re-break the app.
|
||||
*
|
||||
* Never delete the sheet instead. Re-hosting moves where a rule lands in the cascade and 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> up by href still finds it. */
|
||||
/* ---- the fence: the chrome is ours, so make a foreign rule unable to MATCH it ----
|
||||
*
|
||||
* Re-hosting settles a fight on specificity but not one against `!important`, which ranks above
|
||||
* layers. The only pure-CSS answer to a foreign flag is our own flag in an earlier layer — ~550 of
|
||||
* them, and the `color`/`background` ones would beat this theme's own forced-colors block.
|
||||
*
|
||||
* 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 except us, and `!important` has nothing left to win.
|
||||
*
|
||||
* The chrome is not one element, so the fence names no element: naming `.fs-sidebar` left the skip
|
||||
* link and the search overlay exposed while every test said the chrome was defended. With
|
||||
* `data-fs-chrome` an element declares that it is ours where it is written, and the fence and the
|
||||
* pin follow. `npm run chrome-fence` holds the three together.
|
||||
*
|
||||
* `:where()` is load-bearing: it contributes zero specificity, so 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 re-order the app's stylesheet against itself.
|
||||
*
|
||||
* Only unpinned parts are fenced: a pinned part cannot reach the chrome anyway.
|
||||
*
|
||||
* Two silent traps, 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, leaving `*, ul` as `*, ul:where(…)`;
|
||||
* - a pseudo-element must stay last — `a::after` plus a tail append serialises to
|
||||
* `a::after:where()`, the argument silently eaten, matching nothing, and the setter reports
|
||||
* success. The fence goes before it: `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 `::`, legacy `:before` included. The split
|
||||
* point is found on the mask and sliced out of the original, because `::` is legal inside a
|
||||
* quoted attribute value (an IPv6 literal): read raw, the fence lands inside the quotes and the
|
||||
* app's rule comes back matching a value it never wrote, as valid CSS the setter accepts. */
|
||||
const i = maskStrings(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 a failure leaves the rule unfenced rather than
|
||||
* half-written */
|
||||
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 separately fetched sheet, so they are not there when the shim is
|
||||
* inserted: retry until they are, then fence. A sheet that never becomes readable stays unfenced,
|
||||
* which is where we already were.
|
||||
*
|
||||
* The bound is a deadline in ms, not a frame count — 60 frames is 1 s at 60 Hz and 2 s on a 30 Hz
|
||||
* panel, so the budget would move with the display. Frames remain the retry tick, since a cache hit
|
||||
* lands on the first one. */
|
||||
function fenceImported(styleEl, names, until) {
|
||||
/* no initialiser: every path below assigns, so `= null` would be a dead store
|
||||
* (no-useless-assignment) */
|
||||
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 (Date.now() < until) { requestAnimationFrame(() => fenceImported(styleEl, names, until)); return; }
|
||||
/* Giving up is reportable: the irreversible half already happened — the app's original is
|
||||
* silenced and the shim owns the page — so a fence that never lands leaves the app's rules
|
||||
* reaching the chrome while every later pass skips the sheet as handled. rAF does not fire in a
|
||||
* background tab, so a page opened in one can pass the deadline without a single retry. */
|
||||
console.error('footstrap: could not read the re-hosted @import within the deadline — the sheet '
|
||||
+ 'stays unfenced and may repaint the chrome on this page.', styleEl);
|
||||
}
|
||||
|
||||
/* what a sheet IS, as text: the rules that are applying, not the markup that may have produced
|
||||
* them. Serialised only to compare, never re-parsed. */
|
||||
const serializeRules = (rules) => Array.prototype.map.call(rules, (r) => r.cssText).join('\n');
|
||||
|
||||
/* ---- a <style>'s textContent is NOT its sheet ----
|
||||
*
|
||||
* Wrapping re-sets textContent, which re-parses, and whatever the parse does not reproduce is
|
||||
* deleted — by the one fix in this file whose thesis is that deleting a view's CSS is one-way. Two
|
||||
* shapes where the text does not describe the sheet: a <style> filled by insertRule() (the text is
|
||||
* empty while the rules apply, so the wrap writes `@layer theme {}` over a live sheet), and one
|
||||
* carrying @import (invalid inside @layer, so the wrapped copy comes back without it).
|
||||
*
|
||||
* So ask the exact question — does re-parsing this text give back the sheet that is applying? —
|
||||
* rather than enumerate the shapes, which is what missed insertRule(). The probe is a constructible
|
||||
* sheet: never adopted, so nothing paints and no observer sees it. It also drops @import per spec,
|
||||
* so that case needs no test of its own.
|
||||
*
|
||||
* No probe means no answer, and the honest answer to "may I re-parse this?" is then no: the sheet
|
||||
* keeps every rule and the fence still holds the chrome without the wrap. */
|
||||
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. Remembered because a
|
||||
* silenced sheet is still an element that answers `cssRules`, so every later ask re-judges it
|
||||
* invasive; see documentPoisoned(). */
|
||||
const _silenced = new WeakSet();
|
||||
|
||||
/* Take a re-hosted <link> out of the cascade, and mean it.
|
||||
*
|
||||
* `el.disabled = true` alone does not: the IDL attribute forwards to the ELEMENT's flag, while
|
||||
* `el.sheet.disabled` is what decides whether the CSS paints — and a <link> that is still loading
|
||||
* has no `.sheet` for the assignment to reach. Every runtime injection is in that state when the
|
||||
* observer hands it here, so the sheet comes up enabled when the bytes arrive and the app's
|
||||
* original, unfenced CSS paints beside the fenced shim (95 of 338 chrome elements flattened). */
|
||||
function silence(el) {
|
||||
_silenced.add(el);
|
||||
setEnabled(el, false);
|
||||
}
|
||||
|
||||
/* ---- 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. Treating every
|
||||
* invasive sheet as spending the document is correct but is paid by ordinary pages: stock
|
||||
* `luci-app-filemanager` lands two <style>s in <head>, both invasive on their bare selectors, and
|
||||
* `luci-app-ssclash` adds four more as the Ace editor initialises. With ownership, leaving either
|
||||
* page stays an in-place SPA navigation (medians 24 ms and 27 ms) instead of a full load.
|
||||
*
|
||||
* Removing the sheet on the way out is the obvious fix and the wrong one: an append at module top
|
||||
* level happens once, because `L.require` caches the module, so a second visit renders unstyled.
|
||||
* Disabling is reversible, which is the whole difference.
|
||||
*
|
||||
* Owner = the page in `body[data-page]` when the sheet was re-hosted. fs-router's navigate() stamps
|
||||
* data-page BEFORE it require()s the view class, so when a view module evaluates and appends its
|
||||
* <style> the attribute already names its page; on a full load the server stamped it.
|
||||
*
|
||||
* Recorded on the element that PAINTS, never on the permanently silenced one: for a <link> that is
|
||||
* the @import shim, and re-enabling the original would undo silence() and put the app's unfenced
|
||||
* CSS back over the chrome.
|
||||
*
|
||||
* The owner is the APP, not the page: an app whose pages share one injected <style> would have it
|
||||
* owned by whichever page loaded first and arrive dark on the others. `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 onto other apps and onto stock pages.
|
||||
*
|
||||
* Segments, never the dash-joined `data-page`: a dispatch segment may 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; 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 ----
|
||||
*
|
||||
* `L.env.dispatchpath` is the leaf the SERVER resolved this request to, while the address bar holds
|
||||
* what was asked for: LuCI's dispatcher walks a node down to its firstchild without rewriting the
|
||||
* URL, so `/cgi-bin/luci/admin/status` and `/cgi-bin/luci/` both dispatch to
|
||||
* `admin/status/overview`. Keyed on the URL, the first SPA navigation away disables the sheets that
|
||||
* page owns — and the app's original <link> is already silenced for good, so nothing paints them
|
||||
* again for the life of the document. */
|
||||
/* Who a sheet injected right now belongs to, when that is not the page the chrome is showing.
|
||||
*
|
||||
* A require in flight cannot be stopped: click a page whose module injects CSS, click away before
|
||||
* it lands, and the <style> appears after the router has stamped data-page for the page that
|
||||
* superseded it. Credited to currentKey() it is bound to the wrong page for the life of the
|
||||
* document — disabled on its own page and enabled on one it has no business painting. So the router
|
||||
* names the owner for the duration of such a require (fs-router.js), and this is that hint.
|
||||
*
|
||||
* One slot, stamped with the navigation that set it. The router only names an owner for a require
|
||||
* that has yet to evaluate its module. Two cold requires can still overlap, and then the newer one
|
||||
* wins the slot — it is the page the user is looking at, and crediting its sheet to the superseded
|
||||
* page would leave the visible page unpainted. The generation stamp keeps the older require's
|
||||
* `.finally` from clearing a slot the newer one now holds.
|
||||
*
|
||||
* Nothing better is available: LuCI evaluates a view module inside `eval()` in its own require(),
|
||||
* so nothing observable says which module is running when a <style> appears. The remaining hole is
|
||||
* two cold requires overlapping. */
|
||||
let _ownerHint = null;
|
||||
let _ownerGen = -1;
|
||||
function attributeTo(segs, gen) {
|
||||
/* a stale require letting go of a slot somebody else now holds: leave it alone */
|
||||
if (segs == null && gen !== _ownerGen) return;
|
||||
_ownerHint = (segs == null) ? null : appKey(segs);
|
||||
_ownerGen = (segs == null) ? -1 : gen;
|
||||
}
|
||||
|
||||
function ownerKey() {
|
||||
return (_ownerHint !== null) ? _ownerHint : currentKey();
|
||||
}
|
||||
|
||||
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() gives: el.disabled is the element's flag, el.sheet.disabled
|
||||
* decides whether the CSS paints, and a still-loading <link> has no .sheet for the assignment to
|
||||
* reach — so a switch-off re-asserts once the bytes arrive. `once`, because the element is marked
|
||||
* fsLayered by then and a sheet that never loads has nothing to silence. */
|
||||
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 and keeps the full-load path rather than being disabled on its own page.
|
||||
*
|
||||
* `keep` is the page still ON SCREEN. A client navigation has a window in which two pages are real
|
||||
* — the incoming one renders into a hidden stage while the outgoing one is still being read — and
|
||||
* scoping is needed before that staged render, since a view must not measure itself through a sheet
|
||||
* that does not own its page. Doing both halves then would strip the outgoing page's stylesheet off
|
||||
* content the user is looking at, so the router calls this twice: with `keep` before the staged
|
||||
* render, and with nothing at the swap. */
|
||||
function scopeToCurrentPage(segs, keep) {
|
||||
if (segs) _curKey = appKey(segs);
|
||||
const key = currentKey();
|
||||
const spared = (keep && keep.length) ? appKey(keep) : null;
|
||||
document.querySelectorAll(VIEW_SHEETS).forEach((el) => {
|
||||
if (!outlivesPage(el) || !_owner.has(el)) return;
|
||||
const owner = _owner.get(el);
|
||||
if (spared !== null && owner === spared && owner !== key) return;
|
||||
setEnabled(el, owner === key);
|
||||
});
|
||||
}
|
||||
|
||||
/* Take a sheet and scope it in the same breath. scopeToCurrentPage() runs on NAVIGATION, so it
|
||||
* only sees sheets that were already here; one arriving afterwards is scoped by nobody until the
|
||||
* next click — which is exactly the cold-require case the owner hint exists for, where the sheet
|
||||
* paints a page it does not belong to in the meantime.
|
||||
*
|
||||
* `ownerKey()` is the page the sheet belongs to and `currentKey()` the page on screen; on every
|
||||
* ordinary arrival they are the same string and this is a no-op. */
|
||||
function claimOwner(el) {
|
||||
const key = ownerKey();
|
||||
_owner.set(el, key);
|
||||
if (outlivesPage(el)) setEnabled(el, key === currentKey());
|
||||
}
|
||||
|
||||
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 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);
|
||||
claimOwner(s);
|
||||
fenceImported(s, universe.names, Date.now() + 1000); /* a cache hit lands on the first frame */
|
||||
return;
|
||||
}
|
||||
|
||||
let rules;
|
||||
try { rules = el.sheet && el.sheet.cssRules; } catch (e) { return; }
|
||||
/* No rules yet: nothing to re-host, nothing to fence and nothing to MARK. An app that appends an
|
||||
* empty <style> and fills it with insertRule() arrives here first, and marking it handled 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, because pinnedToApp() strips a
|
||||
* functional pseudo-class before looking for the app's name, so an already-fenced selector reads
|
||||
* as unpinned again and a second pass appends a second fence. */
|
||||
el.dataset.fsLayered = '1';
|
||||
|
||||
/* Wrap only if the text still IS the sheet (textIsSheet). When it is not, the sheet stays
|
||||
* unlayered rather than lose rules, and 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 and would throw
|
||||
* away any selector already rewritten. A <style>'s url()s resolve against the document either
|
||||
* way, which is not true of a <link> — cssText serialises `url("img.png")` still relative, so
|
||||
* inlining a linked sheet would re-base every image and font in it. */
|
||||
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 */ }
|
||||
/* Last, because the line above may have re-parsed the sheet: assigning textContent throws the
|
||||
* old CSSStyleSheet away and the new one comes back ENABLED, so claiming before the wrap
|
||||
* switches a sheet off and back on within the same call. ownerKey() is read here either way,
|
||||
* while the router's hint still names the page whose module is evaluating. */
|
||||
claimOwner(el); /* a <style> is re-hosted IN PLACE, so it paints itself */
|
||||
}
|
||||
|
||||
/* Re-hosting needs the theme's own selectors to tell an invasive sheet from an inert one, so if
|
||||
* cascade.css cannot be read, re-host nothing: documentPoisoned() already fails every sheet to the
|
||||
* slow path, and demoting an app we could not judge is the one move with no way back. */
|
||||
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;
|
||||
/* A sheet with no rules yet is not an innocent sheet: `insertRule()` produces no mutation
|
||||
* record, so an app that appends an empty <style> and fills it later is judged empty here
|
||||
* and never looked at again — measured, a `* { padding: 0 !important }` built that way
|
||||
* flattened the chrome and stayed unfenced for the life of the document. One deferred look
|
||||
* per such element is enough, and costs nothing where a <style> arrives with its rules
|
||||
* already in it. */
|
||||
let rules = null;
|
||||
try { rules = el.sheet && el.sheet.cssRules; } catch (e) { /* cross-origin: judged below */ }
|
||||
if (rules && !rules.length && !el.__fsRecheck) {
|
||||
el.__fsRecheck = true;
|
||||
window.setTimeout(() => {
|
||||
let now;
|
||||
try { now = el.isConnected && el.sheet && el.sheet.cssRules; } catch (e) { return; }
|
||||
if (now && now.length) rehostInvasiveSheets();
|
||||
}, 2000);
|
||||
}
|
||||
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` injects 4 KB
|
||||
* from render() with no guard, `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, because the rules do not go away — the surviving copy is byte-identical, so a library's
|
||||
* "have I already imported this?" check 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,
|
||||
* so a wrapped first copy and the app's next identical injection would stop matching and the copies
|
||||
* would pile up again. A <link> keys on href, which re-hosting leaves alone.
|
||||
*
|
||||
* Re-hosting must therefore run BEFORE this: a re-hosted <link>'s shim is appended to <head>, which
|
||||
* is earlier in document order than a template's <link> in .fs-content, so "keep the first" would
|
||||
* keep the raw copy and strand the shim. Re-host first and both copies are equivalent by the time
|
||||
* they are compared. */
|
||||
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 the textContent may not BE the sheet (textIsSheet):
|
||||
* every insertRule-built <style> has an empty one, so keying on the text gives them all the same
|
||||
* key and removes the second as a "duplicate" of a sheet it shares nothing with. 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 is very likely a <style> an app has
|
||||
* appended but not yet filled: removing it strands the handle it will 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 (!outlivesPage(el)) 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, and it holds
|
||||
* only while cascade.css is the FIRST sheet in the document to name a layer: the order is fixed by
|
||||
* first appearance, so an earlier sheet naming `theme` makes theme the weakest layer and inverts
|
||||
* the whole cascade — base's `* { padding: 0 }` then wins over the chrome's own rules.
|
||||
*
|
||||
* Re-hosting an app's sheet into `@layer theme` can cause exactly that, because where the app put
|
||||
* its <style> is the app's choice: Ace inserts its <style> as the FIRST CHILD of <head>, ahead of
|
||||
* cascade.css, and adds more of them on first hover.
|
||||
*
|
||||
* The repair is one declaration, and it works because inserting a NEW sheet re-runs the ordering
|
||||
* while moving an existing one does not (measured both ways): re-declare the canonical order from
|
||||
* a fresh <style> placed first in <head>. Cheap and idempotent, and the one other place that can
|
||||
* see the whole document, which is why the order is repeated here rather than derived. */
|
||||
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
|
||||
* put in front of it — since only a sheet ahead of that can have named a layer before we did */
|
||||
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. Dropping the previous one keeps this at one spare <style> per document. */
|
||||
if (_layerStmt) _layerStmt.remove();
|
||||
_layerStmt = document.createElement('style');
|
||||
_layerStmt.textContent = LAYER_ORDER;
|
||||
head.insertBefore(_layerStmt, head.firstChild);
|
||||
}
|
||||
|
||||
/* Watch <head> rather than deduping on navigation: an app injecting from render() resolves after
|
||||
* the router's require() callback, so a nav-time sweep leaves the document permanently carrying one
|
||||
* stale duplicate. The observer collapses the copy in the microtask it appears in, and cannot loop
|
||||
* — a removal produces a mutation with no added nodes and the handler bails.
|
||||
*
|
||||
* The immediate pass cannot be the observer's job: a legacy Lua page's <link> is in the SERVER's
|
||||
* HTML, parsed and applying before this module is fetched, so there is no mutation to observe. It
|
||||
* is re-hosted on the first pass instead, at the cost of a brief flash of unstyled chrome.
|
||||
*
|
||||
* <head>, deliberately not the whole document: LuCI's poll rewrites content on every tick and this
|
||||
* would fire on every tick. */
|
||||
function watchViewSheets() {
|
||||
/* Dedupe on the immediate pass too, in the observer's order (re-host strictly first): a page
|
||||
* whose server HTML prints the same <link> twice produces no mutation for the observer to see,
|
||||
* so without this it carries both links and both @import shims for the life of the document —
|
||||
* 117 KB of CSS parsed twice on luci-app-openclash's Overwrite Settings page. */
|
||||
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 sheet
|
||||
* that made it necessary */
|
||||
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: a <style>
|
||||
* appended to <body> after chrome init is seen by nothing otherwise — the immediate pass has
|
||||
* run and the mutation is not observed — and the page the user is ON stays broken.
|
||||
*
|
||||
* childList without subtree, as for <head>: this fires only for direct children of <body>, while
|
||||
* LuCI's poll rewrites content inside #view, a descendant. */
|
||||
mo.observe(document.body, { childList: true });
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
attributeTo,
|
||||
documentCarries,
|
||||
documentPoisoned,
|
||||
scopeToCurrentPage,
|
||||
watchViewSheets
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
|
||||
/* The installed version, shown on the Appearance tab with no network call; which version is
|
||||
* available is the package manager's question.
|
||||
*
|
||||
* The Makefile (Build/Prepare), dev-sync.sh and tools/stage.sh sed the literal below BY FILE
|
||||
* NAME, so it cannot move to another file without changing all three. An unstamped checkout
|
||||
* stays 'dev'. */
|
||||
const FS_VERSION = '0.0.0-dev';
|
||||
|
||||
/* The parentheses are load-bearing: jsmin's regex-vs-division test is a one-character lookback
|
||||
* against a fixed allow-list, and `n` (the last letter of `return`) is not on it, so `return /re/`
|
||||
* is read as a division and swallows the rest of the file, exiting 0 (openwrt/luci#8299).
|
||||
*
|
||||
* The sentinel is matched by shape, never by `FS_VERSION !== '0.0.0-dev'`: terser runs before the
|
||||
* Makefile stamps the version, so that comparison folds to a constant and every release reports
|
||||
* '(dev)' — an SDK build has no terser step, so it broke only in releases. A regex test is not
|
||||
* folded. `-dev$` rather than `^\d+\.\d+\.\d+$`, because dev-sync.sh stamps `git describe`
|
||||
* ('0.9.4-12-gabc1234'), which must count as a real version. */
|
||||
function isReal() { return ((/^\d+\.\d+/).test(FS_VERSION)) && !((/-dev$/).test(FS_VERSION)); }
|
||||
|
||||
/* Do not re-add `VERSION` or `isReal` as exports: neither had a caller, and an export nobody
|
||||
* imports is a promise to keep the shape stable. */
|
||||
return baseclass.extend({
|
||||
REPO_URL: 'https://github.com/VizzleTF/luci-theme-footstrap',
|
||||
label: () => (isReal() ? ('Footstrap v' + FS_VERSION) : 'Footstrap (dev)')
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
|
||||
/* The theme's UI primitives: the inline-SVG wrapper, the disclosure pair the menu is built on, and
|
||||
* the colour control behind the Appearance tab's colour axes. Nothing here knows what it is used
|
||||
* for, so the menu and the Appearance tab share it without requiring each other. */
|
||||
|
||||
/* The one inline-SVG wrapper: every theme icon is a 24x24 stroked outline differing only in path
|
||||
* data, so stroke width and linecaps are stated once and cannot drift between call sites.
|
||||
* aria-hidden, because each icon sits beside its own label — an unlabelled <svg> is otherwise
|
||||
* announced as a graphic in its own right. */
|
||||
function svgIcon(body, cls) {
|
||||
return '<svg class="' + (cls || 'fs-ico') + '" aria-hidden="true" viewBox="0 0 24 24" fill="none" '
|
||||
+ 'stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">'
|
||||
+ body + '</svg>';
|
||||
}
|
||||
|
||||
/* ---- disclosure primitives, shared by the menu ----
|
||||
* A section header is a W3C-APG disclosure control: an <a role="button"> owning a panel it shows
|
||||
* and hides. The trigger selector stays a parameter. */
|
||||
|
||||
/* Every open and close goes through here so `.open` and aria-expanded cannot disagree: `.open`
|
||||
* alone tells a sighted user everything and a screen-reader user nothing. `linkSel` is the
|
||||
* layout's trigger (the menu's `:scope > a`). */
|
||||
function setOpen(li, on, linkSel) {
|
||||
li.classList.toggle('open', on);
|
||||
li.querySelector(linkSel)?.setAttribute('aria-expanded', on ? 'true' : 'false');
|
||||
}
|
||||
|
||||
/* 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();
|
||||
});
|
||||
}
|
||||
|
||||
/* A click outside closes; WCAG 2.2 SC 1.4.13 also requires a hover/focus panel to be dismissible
|
||||
* from the keyboard, with focus handed back to the trigger. `when` restricts both to flyout mode,
|
||||
* where `.open` means "popup panel" — an unfolded accordion must not close on an outside click. */
|
||||
function wireDismiss(opts) {
|
||||
const active = () => (opts.when ? opts.when() : true);
|
||||
|
||||
document.addEventListener('click', (ev) => {
|
||||
/* `closest?.`: a document-level listener sees any dispatched click, including one whose
|
||||
* target is not an Element and has no closest(). The throw would kill this listener for
|
||||
* the rest of the session. */
|
||||
if (active() && !ev.target.closest?.(opts.inside))
|
||||
opts.close();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
if (ev.key !== 'Escape' || !active()) return;
|
||||
const open = document.querySelector(opts.open);
|
||||
if (!open) return;
|
||||
const trigger = open.querySelector(opts.trigger);
|
||||
opts.close();
|
||||
trigger?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
/* The Appearance page draws enums with `ui.Select` and numbers with `ui.RangeSlider`: LuCI widgets
|
||||
* are present on every supported release, already dressed by this stylesheet (`select` in
|
||||
* base/30-forms.css, `.cbi-range-slider` in theme/60-inputs.css), and cannot be got wrong here.
|
||||
* Do not re-add a segmented control or range wrapper of our own. */
|
||||
|
||||
return baseclass.extend({
|
||||
svgIcon,
|
||||
setOpen,
|
||||
wireSpaceKey,
|
||||
wireDismiss
|
||||
});
|
||||
@@ -0,0 +1,434 @@
|
||||
'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-prefs as prefs';
|
||||
'require fs-sheets as sheets';
|
||||
|
||||
/* Page modules: `fs-overview` (Status -> Overview) adds to a stock page rather than owning a
|
||||
* route, so it is required only there. A `require` pragma would make it a hard dependency —
|
||||
* luci.js fetches and evaluates before this factory runs — costing 3.8 KB after terser on every
|
||||
* admin page that has no overview grid.
|
||||
*
|
||||
* `fs-appearance` used to be the other entry here (System -> System) until it got a route of its
|
||||
* own (`/admin/system/footstrap`, `view/footstrap/appearance.js`) and the tab it had been stapled
|
||||
* onto came out; a page reached by menu.d is loaded by the dispatcher, not by this map.
|
||||
*
|
||||
* The module keeps its own `body[data-page]` observer; `wire()` re-checks the page
|
||||
* synchronously, so a module arriving after the stamp still starts watching.
|
||||
*
|
||||
* The map duplicates a page name that also lives inside the module; `npm run page-modules`
|
||||
* derives both sides and fails on drift. */
|
||||
const PAGE_MODULES = {
|
||||
'admin-status-overview': 'fs-overview'
|
||||
};
|
||||
const _pageModules = new Map();
|
||||
function wirePageModules() {
|
||||
/* through window.L, never the factory's `L`: that one carries no require() of its own */
|
||||
const RT = window.L;
|
||||
const load = () => {
|
||||
const name = PAGE_MODULES[document.body.getAttribute('data-page') || ''];
|
||||
if (!name || _pageModules.has(name)) return;
|
||||
_pageModules.set(name, RT.require(name).then((m) => m.wire()).catch((e) => {
|
||||
/* a failed page module costs only its own page's extras: drop it and retry the next
|
||||
* time that page comes up */
|
||||
_pageModules.delete(name);
|
||||
console.error('footstrap: ' + name + ' did not load', e);
|
||||
}));
|
||||
};
|
||||
/* the server's stamp is already in the DOM; every later one is the router's */
|
||||
new MutationObserver(load).observe(document.body, { attributes: true, attributeFilter: [ 'data-page' ] });
|
||||
load();
|
||||
}
|
||||
|
||||
/* ---- the search palette, held at arm's length ----
|
||||
*
|
||||
* The palette is 5 KB and opens on a keystroke most sessions never press, so it is not required
|
||||
* here: this holds the shortcut and fetches the module on the first gesture. What CANNOT wait is
|
||||
* the recents list — it has to be written on every navigation, or it is empty on the first open —
|
||||
* and the warm pass that uses it, so both live here, in the file every page already loads.
|
||||
*
|
||||
* The palette reads the list back from localStorage when it opens, so the two halves share the key
|
||||
* and nothing else. */
|
||||
const RECENT_KEY = 'fs-recent';
|
||||
const RECENT_MAX = 8;
|
||||
const RECENT_WARM = 5;
|
||||
|
||||
/* A key is a menu path, or a page path plus the heading of a section inside it
|
||||
* (`admin/system/system#Footstrap`) — a section has no dispatcher node to name it, and only the
|
||||
* source that produced the row can build that half. Exported for exactly that: the writer stays
|
||||
* one function, or the two halves would drift on the cap and the de-duplication. */
|
||||
function remember(key) {
|
||||
if (typeof key !== 'string' || !key) return;
|
||||
const recent = prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string');
|
||||
prefs.lsSet(RECENT_KEY, JSON.stringify([ key ].concat(recent.filter((p) => p !== key)).slice(0, RECENT_MAX)));
|
||||
}
|
||||
|
||||
/* the page half of a key: what the router can navigate to and what warmRecent() prefetches */
|
||||
function pageOf(key) {
|
||||
const h = key.indexOf('#');
|
||||
return h < 0 ? key : key.slice(0, h);
|
||||
}
|
||||
|
||||
/* ---- warm the pages this admin actually uses ----
|
||||
*
|
||||
* The router's per-link prefetch needs a hover, tap or focus first, so a session's first visit to a
|
||||
* page still pays for its module chain. The recents list is the best predictor available and is
|
||||
* already on disk; warming the whole menu instead would pull every view module on the box
|
||||
* (docs/spa-router.md).
|
||||
*
|
||||
* The current page is skipped — remember() has just recorded it and it is loaded by definition.
|
||||
* Under saveData nothing speculative runs; the per-link prefetch stays, since it follows a
|
||||
* deliberate hover or tap. Nothing waits on this, so it runs at idle, with a long fallback delay:
|
||||
* it competes with the view's own module fetches and RPCs and must lose that race. */
|
||||
function warmRecent() {
|
||||
try { if (navigator.connection && navigator.connection.saveData) return; } catch (e) {}
|
||||
const here = (L.env.dispatchpath || []).join('/');
|
||||
/* Keys, not paths: a section key names the page it sits on, and two sections of one page must
|
||||
* warm it once — the module chain is the page's. */
|
||||
const keys = prefs.lsGetArr(RECENT_KEY).filter((p) => typeof p === 'string');
|
||||
const paths = [ ...new Set(keys.map(pageOf)) ].filter((p) => p !== here).slice(0, RECENT_WARM);
|
||||
if (!paths.length) return;
|
||||
const go = () => paths.forEach((p) => router.prefetchSegs(p.split('/')));
|
||||
if (typeof window.requestIdleCallback === 'function')
|
||||
window.requestIdleCallback(go, { timeout: 4000 });
|
||||
else
|
||||
window.setTimeout(go, 2000);
|
||||
}
|
||||
|
||||
function wireSearch() {
|
||||
const btn = document.getElementById('fs-search-btn');
|
||||
if (!btn) return;
|
||||
const RT = window.L;
|
||||
|
||||
/* the page this full load landed on; onNavigate covers the SPA path afterwards */
|
||||
const rememberSegs = (segs) => remember((segs || []).join('/'));
|
||||
rememberSegs(L.env.dispatchpath);
|
||||
router.onNavigate(rememberSegs);
|
||||
warmRecent();
|
||||
|
||||
/* One fetch, on the first gesture. The module builds its overlay and opens itself; every later
|
||||
* gesture reaches the same instance, `require` being a singleton.
|
||||
*
|
||||
* …and then this half stands down: fs-search binds its own toggle to the same button and its
|
||||
* own copies of Ctrl+K and `/`, so while both were live the module's toggle closed the palette
|
||||
* and this one re-opened it in the microtask after, and the button looked broken. */
|
||||
let pending = false, loaded = false;
|
||||
const open = () => {
|
||||
if (pending || loaded) return;
|
||||
pending = true;
|
||||
RT.require('fs-search').then((m) => { pending = false; loaded = true; m.open(); },
|
||||
(e) => { pending = false; console.error('footstrap: fs-search did not load', e); });
|
||||
};
|
||||
|
||||
btn.addEventListener('click', () => open());
|
||||
/* the same two shortcuts the palette used to own, with the same guard: `/` must not steal a
|
||||
* keystroke from someone typing into a field, a contenteditable, or a .cbi-dropdown, where
|
||||
* fs-select.js's typeahead reads it as a search character */
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
if (ev.defaultPrevented || loaded) return;
|
||||
if ((ev.ctrlKey || ev.metaKey) && !ev.altKey && (ev.key === 'k' || ev.key === 'K')) {
|
||||
ev.preventDefault(); open(); 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();
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- optional companion packages ----
|
||||
*
|
||||
* header.ut prints `window.__fsPlugins` from `footstrap.settings.plugin`, a list a package writes
|
||||
* from its own uci-defaults; each entry is a LuCI module name, already whitelisted there. The
|
||||
* chrome requires each one after everything below is wired — a plugin registers itself through the
|
||||
* seams the theme exports (`fs-router.onNavigate`, `fs-search.addSource`) and the theme names
|
||||
* nobody. A plugin that throws costs only itself.
|
||||
*
|
||||
* No plugin, no cost: an empty list is the shipped state and this loop does nothing. */
|
||||
function loadPlugins() {
|
||||
const RT = window.L;
|
||||
const names = Array.isArray(window.__fsPlugins) ? window.__fsPlugins : [];
|
||||
names.forEach((name) => {
|
||||
RT.require(name).catch((e) => console.error('footstrap: plugin ' + name + ' did not load', e));
|
||||
});
|
||||
}
|
||||
|
||||
/* warn/danger split for the meter fill (theme/25-progressbar.css): a DECISION, not a measurement —
|
||||
* 20% of an 84 MiB overlay and 20% of an 8 GiB disk are different news at the same reading, and a
|
||||
* plain percentage is kept anyway so every meter on the page reads the same way. */
|
||||
const FS_METER_WARN = 80;
|
||||
const FS_METER_DANGER = 92;
|
||||
/* The same two thresholds, read off the OTHER end of the bar: forum #134 caught a memory row whose
|
||||
* fill is "how much is left", not "how much is used" — a 91% "Total Available" reading is healthy,
|
||||
* not a warning, and a 100%-full "Swap free" is the BEST possible reading, not permanent danger
|
||||
* (both measured live, docs/design-system.md "Meter polarity"). `100 - FS_METER_WARN/DANGER` rather
|
||||
* than a second pair of numbers: an inverted bar and a used-based one are the same health question
|
||||
* ("how much headroom is left") asked from opposite ends of one fill, so a future change to the
|
||||
* used-based split moves this one with it instead of drifting apart. */
|
||||
const FS_METER_INVERTED_WARN = 100 - FS_METER_WARN;
|
||||
const FS_METER_INVERTED_DANGER = 100 - FS_METER_DANGER;
|
||||
|
||||
/* Row labels this theme has SEEN misread, matched the same way fs-overview.js's `ROLES` matches a
|
||||
* card title: `_(msgid)` against the msgid luci-mod-status's own 20_memory.js used to build the
|
||||
* row, no msgctxt, so it resolves to the exact string that include rendered. Verified against
|
||||
* modules/luci-base/po/ru/base.po (the i18n-scan target for every luci-mod-status string that
|
||||
* carries no msgctxt of its own — the same catalogue "System"/"Memory"/"Storage" already resolve
|
||||
* from for `ROLES`): all four msgids below have a `ru` entry there, so they are in the domain
|
||||
* loaded on EVERY admin page, not a per-view one — unlike `_('Free')`, which has no entry in that
|
||||
* file at all and comes back as the literal English word wherever it is tried. That is what makes
|
||||
* evaluating these two Sets once, at module scope, on whichever page happens to load first, safe:
|
||||
* the string is the same from every admin page, not only from Status -> Overview.
|
||||
*
|
||||
* INVERTED: the fill is "how much is left" — the warn/danger split above has to fire on a LOW
|
||||
* reading. NEUTRAL: a cache or buffer is the kernel doing its job, not a resource running out;
|
||||
* colouring it at any reading says something false, so it gets none, ever. Everything else keeps
|
||||
* the plain fill-based rule below — seventeen bars measured live (storage, active connections) are
|
||||
* used-based with no name this theme recognises, and a third-party app's own meter is unnamed by
|
||||
* construction, so the fill-based default has to stay the FALLBACK, not a shrinking allow-list. */
|
||||
const FS_METER_INVERTED = new Set([ _('Total Available'), _('Swap free') ]);
|
||||
const FS_METER_NEUTRAL = new Set([ _('Buffered'), _('Cached') ]);
|
||||
|
||||
/* Write an attribute only when the value actually changes, so a poll tick that reads the same
|
||||
* numbers back touches no DOM and fires no attribute-mutation observer. `value === null` removes
|
||||
* the attribute instead of writing the string "null". */
|
||||
function fsSyncAttr(el, name, value) {
|
||||
if (value === null) {
|
||||
if (el.hasAttribute(name)) el.removeAttribute(name);
|
||||
} else if (el.getAttribute(name) !== value) {
|
||||
el.setAttribute(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
/* The meter's name, if the markup already states one — never invented. A `.cbi-value` row's own
|
||||
* label (the RSSI/RSRP gallery shape) or a key/value table row's first cell (Memory, Storage, CPU
|
||||
* load on Overview) each stand for the whole row; a bare meter with neither (Software's disk-space
|
||||
* bar, the package manager) gets no `aria-label` written at all, and Chromium's accessible-name
|
||||
* computation then falls back to `title` — which is the reading, not a name ("95 / 100 (95%)"),
|
||||
* measured with `Accessibility.getPartialAXTree`. That fallback cannot be closed from here: `title`
|
||||
* is the ONLY source `::after { content: attr(title) }` has for the visible percentage
|
||||
* (styles/theme/25-progressbar.css), so removing it would blank the bar for a sighted reader too. */
|
||||
function findProgressbarLabel(pg) {
|
||||
const row = pg.closest('.cbi-value');
|
||||
let label = row ? row.querySelector('.cbi-value-title') : null;
|
||||
if (!label) {
|
||||
const tr = pg.closest('.tr');
|
||||
if (tr) {
|
||||
const own = pg.closest('.td, td');
|
||||
const cell = tr.querySelector('.td, td');
|
||||
if (cell && cell !== own) label = cell;
|
||||
}
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
/* The percentage inside a meter's `title`, in any shape a caller writes it in: `window.progressbar`
|
||||
* below composes `'%s / %s (%d%%)'`, parenthesised and preceded by the byte/localised reading; a
|
||||
* bare meter markup may carry just `'97%'` with nothing around it (docs/gallery.html); and the
|
||||
* package-manager's own disk bar (`view/system/packages.js`, not this theme's) leads with it
|
||||
* instead — `'6% used (62.91 GiB used of 1006.85 GiB, 943.95 GiB free)'`, measured live, ru
|
||||
* `'6% использовано (…)'` the same shape. The first two are anchored at the END of the string, so
|
||||
* neither can match a stray "%" earlier in a localised reading; tried first, so an ordinary
|
||||
* `'X / Y (Z%)'` title is never misread by the rule below. The third is anchored at the START and
|
||||
* tried only when both fail: digits immediately followed by `%`, nothing before them — it cannot
|
||||
* match the leading "62" of "62.91 GiB" (a decimal point follows those digits, not `%`), so it
|
||||
* cannot pick a wrong number out of a title carrying several. An empty title or one with no
|
||||
* percentage in any of the three shapes yields null, on purpose — nothing to annotate. `%d` is the
|
||||
* unclamped percentage, so the result can still read past 100 or under 0 and is clamped by the
|
||||
* caller, the same way `window.progressbar` clamps its own `level`, below. Exported for
|
||||
* tests/meter.test.mjs, which is the only caller that needs the parse on its own. */
|
||||
function parseMeterPercent(title) {
|
||||
if (title == null) return null;
|
||||
const m = (/\((-?\d+)%\)\s*$/).exec(title) || (/(-?\d+)%\s*$/).exec(title) || (/^(-?\d+)%/).exec(title);
|
||||
return m ? parseInt(m[1], 10) : null;
|
||||
}
|
||||
|
||||
/* The attribute work `window.progressbar` and a bare `.cbi-progressbar` node both need — Status ->
|
||||
* Overview's own 20_memory.js, 25_storage.js and 30_network.js (luci-mod-status) declare and call
|
||||
* their OWN local `progressbar()`, a straight copy of the same upstream body, so this theme's
|
||||
* global never runs on the meters those stock includes actually draw. `annotateMeters()` below
|
||||
* walks the DOM after the fact instead of owning every writer; the threshold split and the name
|
||||
* logic stay in this one function either way, so the two callers cannot drift apart. */
|
||||
function annotateMeter(pg) {
|
||||
const title = pg.getAttribute('title');
|
||||
const pc = parseMeterPercent(title);
|
||||
if (pc == null) return;
|
||||
const level = pc < 0 ? 0 : (pc > 100 ? 100 : pc);
|
||||
fsSyncAttr(pg, 'role', 'progressbar');
|
||||
fsSyncAttr(pg, 'aria-valuemin', '0');
|
||||
fsSyncAttr(pg, 'aria-valuemax', '100');
|
||||
fsSyncAttr(pg, 'aria-valuenow', String(level));
|
||||
fsSyncAttr(pg, 'aria-valuetext', title);
|
||||
const label = findProgressbarLabel(pg);
|
||||
const name = label ? label.textContent.trim() : '';
|
||||
fsSyncAttr(pg, 'aria-label', label ? name : null);
|
||||
/* Polarity: an unrecognised bar (a third-party app's own meter included — see the Sets above)
|
||||
* keeps the plain fill-based rule, on purpose. A wrong red is worse than a missing colour, but a
|
||||
* MISSING colour on the common case — a used-based fill, which is what an app most often draws —
|
||||
* is worse still, and this theme has no signal at all to tell an unnamed app meter apart from
|
||||
* one of the seventeen used-based bars measured live on Overview alone. */
|
||||
let dataLevel = null;
|
||||
if (FS_METER_NEUTRAL.has(name)) {
|
||||
/* never coloured: see the comment on the Set */
|
||||
} else if (FS_METER_INVERTED.has(name)) {
|
||||
dataLevel = level <= FS_METER_INVERTED_DANGER ? 'danger'
|
||||
: (level <= FS_METER_INVERTED_WARN ? 'warn' : null);
|
||||
} else {
|
||||
dataLevel = level >= FS_METER_DANGER ? 'danger' : (level >= FS_METER_WARN ? 'warn' : null);
|
||||
}
|
||||
fsSyncAttr(pg, 'data-fs-level', dataLevel);
|
||||
}
|
||||
|
||||
/* Every `.cbi-progressbar[title]` under `root` — the markup itself, not who last drew it. Called
|
||||
* from fs-overview.js's own poll-tick observer (see there for why this file does not run a second
|
||||
* one); `fsSyncAttr` above makes a re-run over an unchanged bar a no-op read. */
|
||||
function annotateMeters(root) {
|
||||
(root || document).querySelectorAll('.cbi-progressbar[title]').forEach(annotateMeter);
|
||||
}
|
||||
|
||||
/* The three template globals Status -> Overview needs, defined where ordering is guaranteed.
|
||||
*
|
||||
* `admin_status/index.ut` defines `progressbar`, `renderBox` and `renderBadge` in an inline script
|
||||
* the stock includes (18_cpu, 30_network, 60_wifi…) call bare from their own `render()`. An SPA
|
||||
* arrival never runs that script, so the theme is their only definition — and a late definition is
|
||||
* a `ReferenceError` from a stock include on a page already committed to the document.
|
||||
*
|
||||
* They live here rather than in `fs-overview.js` because a page module is required DURING the
|
||||
* navigation that needs it, racing the router's own require of the view class with nothing
|
||||
* ordering the two. This file is required by the footer on every page and evaluates before the
|
||||
* router exists.
|
||||
*
|
||||
* Bodies are verbatim from upstream except for three deltas: L.itemlist -> window.L.itemlist (the
|
||||
* two-L trap, docs/spa-router.md); renderBox's `[title]` — dom.append parses a scalar child as
|
||||
* innerHTML (luci.js:1395) and an array member as text (:1383), and this file defines the global on
|
||||
* every admin page where upstream defines it on Status -> Overview alone; and progressbar's
|
||||
* accessibility attributes (role, aria-value*, data-fs-level) — upstream's copy writes the reading
|
||||
* into `title` alone, which nothing reads back for a screen reader off a generated `::after`. Same
|
||||
* output on the other two: nothing in 24.10, 25.12 or master calls renderBox, and progressbar's
|
||||
* `title` and fill width are untouched.
|
||||
*
|
||||
* `renderBox`/`renderBadge` keep the `typeof` guard: a full page load runs the template's inline
|
||||
* `<script>` first, in document order, before this module evaluates, so upstream has already
|
||||
* declared both — the guard just avoids overwriting an identical body. `progressbar` CANNOT reuse
|
||||
* that guard: upstream's copy of the same name wins that exact race, and it is the one WITHOUT the
|
||||
* accessibility attributes below. Guarded, the theme's version only ever won when a page other than
|
||||
* Status -> Overview loaded first and the SPA router then navigated here without a reload — which
|
||||
* is a real gap, not the common case. The assignment is unconditional so the accessible copy always
|
||||
* wins, on a full load and on an SPA arrival alike. */
|
||||
function ensureOverviewHelpers() {
|
||||
/* eslint-disable no-var -- these three bodies are copies of LuCI's admin_status/index.ut so
|
||||
they can be diffed against upstream when it changes. Modernising the `var`s would break
|
||||
that property, which is what makes carrying the copies safe. */
|
||||
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),
|
||||
reading = '%s / %s (%d%%)'.format(fv, fm, pc);
|
||||
if (pg) {
|
||||
pg.firstElementChild.style.width = pc + '%';
|
||||
pg.setAttribute('title', reading);
|
||||
/* Accessible value, colour and name: annotateMeter() re-parses `reading` back out
|
||||
* of `title` rather than reusing `pc` here directly, so a bare `.cbi-progressbar`
|
||||
* this function never touched is annotated the identical way — the clamp, the
|
||||
* label lookup and the warn/danger split stay in that one function. */
|
||||
annotateMeter(pg);
|
||||
}
|
||||
};
|
||||
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 */
|
||||
}
|
||||
|
||||
ensureOverviewHelpers();
|
||||
|
||||
/* Chrome bootstrap: load the menu tree once, hand it to the parts that need it and wire them in
|
||||
* order. It renders nothing itself — every piece lives in its own module:
|
||||
*
|
||||
* fs-menutree path <-> menu node, alias/firstchild resolution (a port of dispatcher.uc)
|
||||
* fs-prefs the Appearance axes and their localStorage
|
||||
* fs-widgets the inline-SVG wrapper, the disclosure primitives, the colour control
|
||||
* 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, drawn by its own routed page (menu.d, not this map)
|
||||
* fs-overview the overview grid — a theme module, not a luci-mod-status include
|
||||
* fs-version the shipped version string
|
||||
*
|
||||
* They compose by calling, never by inheriting: LuCI makes every required module a singleton, so
|
||||
* `base.extend` across modules throws (docs/conventions.md). Hence the main menu arriving as a
|
||||
* callback — menu-footstrap.js injects renderMainMenu rather than overriding a method. A
|
||||
* require() cycle raises DependencyError, so the graph is a DAG by construction and the shared
|
||||
* halves (fs-menutree, fs-prefs) are separate modules. */
|
||||
|
||||
return baseclass.extend({
|
||||
/* the seam a companion package writes its own rows into the recents list through; see
|
||||
* remember() for what a key is */
|
||||
remember,
|
||||
|
||||
/* the seam fs-overview.js calls, on its own already-coalesced poll-tick observer, to annotate
|
||||
* the meters a stock Status -> Overview include draws with its own local progressbar() */
|
||||
annotateMeters,
|
||||
|
||||
/* module-private otherwise: tests/meter.test.mjs drives the percent parse and the clamp/threshold
|
||||
* split directly rather than through a live `.cbi-progressbar[title]` walk */
|
||||
parseMeterPercent, /* fs:probe */
|
||||
annotateMeter, /* fs:probe */
|
||||
|
||||
init(renderMainMenu) {
|
||||
/* First, and outside the promise: a third-party sheet that outranks the chrome is already
|
||||
* painting (fs-sheets: openclash's `* { margin: 0; padding: 0 }`). Deferring this to
|
||||
* ui.menu.load() extends the broken frame by a round trip, or forever when the .catch()
|
||||
* below swallows a menu failure. */
|
||||
sheets.watchViewSheets();
|
||||
prefs.guardDarkStamp(); /* same, for a third party stamping :root */
|
||||
prefs.watchThemeColor(); /* the mobile address bar, from the live page colour */
|
||||
|
||||
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: re-run on every #view resize and on content mutations */
|
||||
fit.add(chrome.fitChrome);
|
||||
|
||||
chrome.renderChrome();
|
||||
wireSearch();
|
||||
chrome.wireRail();
|
||||
chrome.wireIndicatorCounts();
|
||||
/* before router.wire(): the router restamps body[data-page] on every SPA navigation,
|
||||
* and that attribute is what the page modules key off */
|
||||
wirePageModules();
|
||||
router.wire();
|
||||
router.wireVisibility();
|
||||
/* last: a plugin registers against the parts above, and a broken one must not be able
|
||||
* to take the chrome with it */
|
||||
loadPlugins();
|
||||
/* no sane partial recovery — a throw above loses the menu, the router and the Appearance
|
||||
* tab together — so this fails loudly rather than silently */
|
||||
}).catch((e) => console.error('footstrap: chrome init failed', e));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,323 @@
|
||||
'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 that the CSS also turns into the top bar and
|
||||
* the rail flyouts — same markup, no second renderer. Disclosure primitives come from fs-widgets,
|
||||
* the auto-collapse preference from fs-prefs; the rest of the chrome is bootstrapped by
|
||||
* menu-footstrap-common, which this file composes with by injecting renderMainMenu into
|
||||
* common.init — a callback, not an override, since a required LuCI module is a singleton and
|
||||
* cannot be subclassed. Spec: docs/chrome.md */
|
||||
|
||||
/* Null prototype: `ICONS[key]` is keyed by a menu node name, which a third-party package picks in
|
||||
* its own menu.d. On a plain literal a node called `constructor` or `__proto__` resolves out of
|
||||
* Object.prototype to a truthy non-string ('[object Object]' for the latter), skips the
|
||||
* `|| ICONS._default` fallback and is concatenated into link.innerHTML. */
|
||||
const ICONS = Object.assign(Object.create(null), {
|
||||
status: '<rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/>',
|
||||
system: '<rect x="5" y="5" width="14" height="14" rx="2"/><rect x="9" y="9" width="6" height="6" rx="1"/><path d="M9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3"/>',
|
||||
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? Reads
|
||||
* the same input as the stylesheet: `data-narrow`, stamped by fitShell() in fs-chrome.js before
|
||||
* the menu renders. Never a viewport breakpoint — the CSS turns the sidebar into a bar when the
|
||||
* content column drops below --fs-content-min, so a media query leaves a band of widths where the
|
||||
* chrome is a bar and the menu still believes it is an accordion — a `max-width: 767px` media
|
||||
* query left exactly that band at 770-775px. */
|
||||
function flyoutMode() {
|
||||
return prefs.currentRail() || prefs.isTopLayout() ||
|
||||
document.documentElement.hasAttribute('data-narrow');
|
||||
}
|
||||
|
||||
/* 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, at every width) ----
|
||||
* A bar panel hangs off its own item (li position:relative, ul left:0 — theme/20-shell.css), so an
|
||||
* item near the right edge would push its panel past the viewport. The rail flies panels out
|
||||
* sideways and needs a different placement, so it is excluded below. */
|
||||
/* how close the panel may come to the viewport edge before it is nudged back in */
|
||||
const EDGE_GAP = 8;
|
||||
/* Is this panel a bar dropdown (anchored under its item) rather than a rail flyout (anchored
|
||||
* beside it)? Same input as the stylesheet: `data-narrow` turns the sidebar into a bar and also
|
||||
* disables the rail (its rules are scoped `:not([data-narrow])`), so a narrow window is a bar even
|
||||
* with the rail on. Gating on isTopLayout() alone leaves such a panel unclamped (issue #19). */
|
||||
function barDropdown() {
|
||||
return prefs.isTopLayout() || document.documentElement.hasAttribute('data-narrow');
|
||||
}
|
||||
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
|
||||
* write-then-read of layout per item crossed, none cancelled once the pointer moves on */
|
||||
if (li._fsClampRaf) window.cancelAnimationFrame(li._fsClampRaf);
|
||||
li._fsClampRaf = window.requestAnimationFrame(() => {
|
||||
li._fsClampRaf = 0;
|
||||
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 revealing the panel has not
|
||||
* applied yet, so it still measures 0x0 */
|
||||
const overflowRight = r.right - (window.innerWidth - EDGE_GAP);
|
||||
if (overflowRight > 0)
|
||||
menu.style.left = -Math.min(overflowRight, r.left - EDGE_GAP) + 'px';
|
||||
});
|
||||
}
|
||||
/* a nudge computed for the old width, or for a bar we have since left, is wrong: drop it 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() is
|
||||
* right going in — a stuck popup is worse than a folded section — but wrong coming out: the markup
|
||||
* is not rebuilt on a rail toggle, so nothing would re-apply the remembered set and "Keep open"
|
||||
* would mean nothing. */
|
||||
function restoreAccordion() {
|
||||
const auto = prefs.currentAutoCollapse();
|
||||
document.querySelectorAll('#topmenu > li.has-sub').forEach((li) => {
|
||||
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 a section the user opened would otherwise refold on every tab switch. Only consulted
|
||||
* in the expanded sidebar with auto-collapse off. Persisted in localStorage because a module-level
|
||||
* Set does not survive a full page load, and many LuCI pages are not SPA-able. */
|
||||
const OPEN_KEY = 'fs-menu-open';
|
||||
/* prefs.lsGetArr owns the parse, the corruption guard and the Array check; a Set is used because
|
||||
* membership is the only question asked of it */
|
||||
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), so the tree's
|
||||
* top-level admin/logout node would show 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` only paints the item; aria-current is what a screen reader reads, and it
|
||||
* belongs on the LEAF alone — a section header is a disclosure button, not a link to
|
||||
* the current page */
|
||||
const link = E('a', {
|
||||
'href': hasSub ? '#' : L.url(url, child.name),
|
||||
'class': (isActive && !hasSub) ? 'active' : '',
|
||||
'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 remembered set is matched back to live <li>s by name */
|
||||
if (!level) li.dataset.name = child.name;
|
||||
|
||||
if (hasSub) {
|
||||
/* W3C APG disclosure navigation: a section header is a button owning a panel, not
|
||||
* 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: node names come from third-party menu.d files and may
|
||||
* differ only in punctuation, and folding them (`[^a-z0-9]+` -> '-') makes two
|
||||
* triggers' aria-controls resolve to the same panel. Escaping to the code point
|
||||
* keeps the ordinary all-alphanumeric case readable and cannot collide. */
|
||||
const subId = 'fs-sub-' +
|
||||
String(child.name).replace(/[^a-z0-9]/gi, (c) => '_' + c.charCodeAt(0).toString(16)) +
|
||||
'-' + idx;
|
||||
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, which one tap on a phone would
|
||||
* otherwise wipe */
|
||||
if (flyoutMode()) {
|
||||
closeFlyouts();
|
||||
setOpen(li, !open);
|
||||
if (!open) clampDropdown(li); /* tap-opened panel must fit too */
|
||||
return;
|
||||
}
|
||||
/* the 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, since a touch
|
||||
* tap fires pointerenter ('touch') before the click and clearing there would break
|
||||
* tap-to-close. */
|
||||
li.addEventListener('pointerenter', (ev) => {
|
||||
if (ev.pointerType === 'mouse' && flyoutMode())
|
||||
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 folds everything, or a section left open as an accordion
|
||||
* reappears as a popup stuck on screen; leaving it restores the accordion.
|
||||
*
|
||||
* Watch the attribute, not the rail button: fs-chrome's wireRail() registers its click
|
||||
* handler from inside the ui.menu.load() promise, i.e. after this runs, so a listener
|
||||
* added here would fire first and read the old data-rail. data-layout rides along —
|
||||
* toggling the layout live is the same transition and needs no menu re-render. */
|
||||
const modeChanged = () => {
|
||||
clearClamps();
|
||||
flyoutMode() ? closeFlyouts() : restoreAccordion();
|
||||
};
|
||||
/* data-narrow is the third attribute flyoutMode() reads (fitShell() writes it): without
|
||||
* it, narrowing the window turns the sidebar into a bar with no transition handler
|
||||
* running and the accordion still unfolded inside it */
|
||||
new MutationObserver(modeChanged).observe(document.documentElement, {
|
||||
attributes: true, attributeFilter: [ 'data-rail', 'data-layout', 'data-narrow' ]
|
||||
});
|
||||
/* No media-query listener: the top bar is measured at every width (fitChrome), so no
|
||||
* breakpoint flips the chrome — the attribute observer and the resize clamp-clear
|
||||
* below cover every transition. */
|
||||
|
||||
/* a clamp computed at the old width is wrong at the new one; coalesced via fit.frame
|
||||
* because resize fires dozens of times a second while a window is dragged. Width only:
|
||||
* iOS fires `resize` continuously while the URL bar slides away, and re-clamping for an
|
||||
* unchanged width is work done on a page the user is scrolling. */
|
||||
let lastWidth = window.innerWidth;
|
||||
const reclamp = fit.frame(clearClamps);
|
||||
window.addEventListener('resize', () => {
|
||||
if (window.innerWidth === lastWidth) return;
|
||||
lastWidth = window.innerWidth;
|
||||
reclamp();
|
||||
});
|
||||
|
||||
/* Appearance -> Submenus -> auto-collapse: fs-prefs.js owns the stored value and only
|
||||
* announces the change; the remembered set, `.open` and aria-expanded are all ours.
|
||||
* restoreAccordion() is already "apply the current setting" — with the set cleared and
|
||||
* auto on it opens the active section alone. Skipped in flyout mode, where force-opening
|
||||
* the active section would leave a popup stuck on screen. */
|
||||
document.addEventListener('fs-autocollapse', (ev) => {
|
||||
if (ev.detail && ev.detail.on) {
|
||||
_openSections.clear();
|
||||
saveOpenSections();
|
||||
}
|
||||
if (!flyoutMode()) restoreAccordion();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require fs-appearance as appearance';
|
||||
|
||||
/* The real Footstrap page. `menu.d/luci-theme-footstrap.json` dispatches
|
||||
* admin/system/footstrap here — an ordinary `view`, built by the menu like any other page, with
|
||||
* none of the compensating machinery a theme once needed to staple a panel onto someone else's
|
||||
* form (docs/architecture.md: a MutationObserver on #maincontent, a 5 s deadline,
|
||||
* `body[data-page]` tracking, a stale-group WeakSet, a sessionStorage return key — one lost tab on
|
||||
* a real router, after a Save & Apply, never reproduced on either stand, is what those four
|
||||
* compensating mechanisms were standing in for). The tab itself is gone too: `fs-appearance.js` no
|
||||
* longer mounts one on System -> System, and this view is the only caller of `renderStandalone()`.
|
||||
*
|
||||
* fs-appearance.js still builds the form: `renderStandalone()` wraps the same `build()` the tab
|
||||
* used to call, minus every one of those mechanisms — a dispatched page needs none of them, the
|
||||
* menu already did the mounting.
|
||||
*
|
||||
* Wrapped in a bare `.cbi-section` for the same reason the tab's content looked like a card:
|
||||
* theme/30-tables.css's `.cbi-section` (background, border, `--fs-card-pad`) is the only
|
||||
* source of that look — a view with no CBI map of its own draws no card unless something asks for
|
||||
* one, and `.cbi-value`/`.cbi-value-title`/`.cbi-value-field` (what every row inside is built
|
||||
* from) do not depend on the wrapper to lay out correctly, only to look like a page instead of
|
||||
* bare rows on the canvas. */
|
||||
return view.extend({
|
||||
render: () => appearance.renderStandalone().then((form) => E('div', { 'class': 'cbi-section' }, [ form ])),
|
||||
|
||||
/* Measured, not assumed (docs/devkit.src.html "handleSaveApply = null instead of hiding stock
|
||||
* buttons"): a view that leaves these unset still gets view.js's default Save & Apply | Apply
|
||||
* unchecked | Save | Reset footer, wired to apply_rollback — a live bar on the real page, not a
|
||||
* dead one, and every click reports "no changes to apply" because nothing here is uci form
|
||||
* state. Every axis applies immediately (fs-appearance.js's `render()` doc comment); nulling all
|
||||
* three is what actually drops the bar, and what makes that comment true. */
|
||||
handleSave: null,
|
||||
handleSaveApply: null,
|
||||
handleReset: null
|
||||
});
|
||||
Reference in new issue
Block a user