update 2026-10-30 18:48:17

This commit is contained in:
action committed 2026-10-30 18:48:17 +08:00
1 parent 850d89d8e3
commit a60cfc3dee
84 files changed
+16421

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-9 -1 100 100" fill="#00b5e2">
<!-- The OpenWrt mark, and it stays OpenWrt's on purpose: the tab icon says which DEVICE this
is, not which theme is painting it. The mark carries no flat cyan tile: a tile fights every
browser's tab strip, so the background is transparent and the icon sits in whatever the
browser draws.
Colours are the mark's own: cyan arcs (the root's fill, stated once), dark-blue ring (its
own fill). 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"/>
<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"/>
<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"/>
<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: 291 B

@@ -0,0 +1,15 @@
{
"name": "LuCI",
"start_url": "/cgi-bin/luci/",
"display": "standalone",
"background_color": "#f6f8fa",
"theme_color": "#f6f8fa",
"icons": [
{
"src": "app-icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
}
]
}
@@ -0,0 +1,637 @@
'use strict';
'require baseclass';
'require ui';
'require fs-prefs as prefs';
'require fs-axes as axes';
'require fs-router as router';
'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 and
* fs-axes.js hold the axes and fs-version.js the version string. `view/footstrap/appearance.js`
* calls `renderStandalone()`, the whole surface this file ships.
*
* 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 readout. Two questions no stored value answers: what colour a role is right now (the
* palette's own while the axis is off) and what contrast it lands at. */
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. Every declaration is !important: an app's CSS can style a
* plain <div>. */
_probe = E('span', { 'id': 'fs-appearance-probe', '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)` parses as three numbers in the wrong units
* (measured: #010078, graded "Too faint to read"). Painting one pixel makes the engine convert. */
let _cx = null;
function rasterCtx() {
if (!_cx) {
const cv = document.createElement('canvas');
cv.width = cv.height = 1;
_cx = cv.getContext('2d', { willReadFrequently: true });
}
return _cx;
}
function parseColor(s) {
const cx = rasterCtx();
/* 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 = String(s || '');
cx.clearRect(0, 0, 1, 1);
cx.fillRect(0, 0, 1, 1);
const d = cx.getImageData(0, 0, 1, 1).data;
/* translucent: composite over nothing is meaningless for a readout */
return d[3] === 255 ? [ d[0], d[1], d[2] ] : null;
}
/* 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);
}
function toHex(s) {
const rgb = parseColor(s) || [ 0, 0, 0 ];
return '#' + rgb.map((v) => 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". */
function colorControl(onPick, label, o) {
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;
let typed = false;
/* 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; typed = false; }
/* 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.hidden = true; 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. */
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;
/* a pass says nothing: nine "easy to read" lines buried the one that was not */
ratio.hidden = (grade.cls === 'fs-contrast-aa');
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. An untouched field returns first, so a
* bare Tab-through never pins the shown palette colour on any of the 9 axes. */
const commit = () => {
if (!typed) return;
const v = field.value.trim().toLowerCase();
if ((/^#[0-9a-f]{6}$/).test(v)) pick(v);
/* re-read rather than a cached copy: the page can change what the axis holds behind this
* control (a preset, Reset to built-in), which a snapshot taken at build time would miss */
else reflect(o.read());
};
field.addEventListener('input', () => { typed = true; });
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' }, [
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(o.read());
return wrap;
}
/* Every stamp that moves a colour lands on :root, including head.ut's OS listener and an app
* rewriting data-darkmode, neither of which calls back here. */
let _watch = null;
function unwatchColours() {
if (_watch) _watch.disconnect();
_watch = null;
}
router.onNavigate(unwatchColours);
function watchColours(refresh) {
unwatchColours();
_watch = new MutationObserver(refresh);
_watch.observe(document.documentElement, {
attributes: true, attributeFilter: [ 'data-darkmode', 'data-palette', 'data-wallpaper' ]
});
}
/* Build the page; renderStandalone() hands it over. Four tabs, most-used first: the colour scheme,
* the interface, the background with its glass and the colours nobody touches twice; Saving sits
* under them. Everything applies at once and is this browser's, in localStorage; only "Save to
* router" writes anything, pushing the current look to the router for other browsers. */
function build() {
/* 24.10.0-24.10.2 have no ui.RangeSlider and a package from the feed still installs there (opkg does
* not version luci-base); one readable line beats the raw "not a constructor" TypeError. */
if (!ui.RangeSlider)
return E('p', { 'class': 'alert-message warning' }, [ _('Footstrap needs OpenWrt 24.10.3 or newer.', 'footstrap') ]);
/* 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());
const repaint = (fn) => (v) => { fn(v); refreshColours(); };
/* One captioned row in LuCI's own shape: `.cbi-value` > `label.cbi-value-title` +
* `.cbi-value-field`, which base/30-forms.css and theme/60-inputs.css already lay out. `make` is
* handed the caption because each control needs it again as the aria-label of its inner
* input/select (a stock widget's wrapper div cannot carry a name). `o.desc` is a help line under
* the control, `o.extra` the rows with more than a control, `o.cls` marks the rows CSS singles out. */
const group = (label, make, opts) => {
const o = opts || {};
const desc = o.desc ? [ E('div', { 'class': 'cbi-value-description' }, [ o.desc ]) ] : [];
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(desc, 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');
/* A list of more than five, the palette, is a `ui.Select`; every number a `ui.RangeSlider`, already
* dressed by base/30-forms.css and theme/60-inputs.css (`ui.RangeSlider` reached openwrt-24.10 in
* 24.10.3). Listen for `widget-change`, the seam UIElement publishes, not an event on the inner
* element. RangeSlider also emits `widget-update` while the handle moves, which resizes the page
* under the drag; the appliers are idempotent, so wiring both costs nothing. */
const selectCtl = (current, choices, apply, label) => {
/* an array of [value, label] pairs when a key is integer-like: Object.keys hoists it ahead of
* the authored order ('2020' rendered above Footstrap, the default); `sort` pins the order */
const pairs = Array.isArray(choices) ? choices : Object.entries(choices);
const w = new ui.Select(String(current), Object.fromEntries(pairs), { widget: 'select', sort: pairs.map((p) => p[0]) });
const node = w.render();
node.querySelector('select, input').setAttribute('aria-label', label);
node.addEventListener('widget-change', () => apply(w.getValue()));
return node;
};
/* Two to three short choices are one row of pills, every choice in sight: native radios under one
* name, so the arrow keys move between them and a screen reader counts them; 80-appearance.css
* draws the pills. */
let segSeq = 0;
const segCtl = (current, choices, apply, label) => {
const name = 'fs-ap-seg-' + (++segSeq);
const pairs = Array.isArray(choices) ? choices : Object.entries(choices);
return E('div', { 'class': 'fs-seg', 'role': 'radiogroup', 'aria-label': label }, pairs.map(([ v, text ]) => {
const input = E('input', { 'type': 'radio', 'name': name, 'value': v });
input.checked = (String(current) === v);
input.addEventListener('change', () => { if (input.checked) apply(v); });
return E('label', {}, [ input, E('span', {}, [ text ]) ]);
}));
};
const sliderCtl = (axis, apply, label, opts) => {
const o = opts || {};
const w = new ui.RangeSlider(String(axis.current()), {
min: axis.min, max: axis.max, step: o.step || 1
});
const node = w.render();
node.querySelector('select, input').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
* page is drawn in, so applying mid-drag slides the handle from under the pointer and one small
* drag runs to the maximum. A keyboard user still applies at once (an arrow key fires `change`). */
if (o.live !== false) node.addEventListener('widget-update', push);
node.addEventListener('widget-change', push);
return node;
};
const colourGroup = (label, axis, probe, contrast, opts = {}) => group(label, (lbl) => {
const ctl = colorControl(bump(axis.apply), lbl, {
probe: probe,
read: axis.current,
contrast: contrast
});
colourCtls.push(ctl);
return ctl;
}, opts);
/* a card is a stock `.cbi-section` under its own title; a tab pane is one whose title is its tab
* (ui.tabs reads data-tab-title); a subheading splits either in two */
const card = (title, rows) => E('div', { 'class': 'cbi-section fs-ap-card' }, [ E('h3', {}, [ title ]) ].concat(rows));
const pane = (name, title, rows) => E('div', { 'class': 'cbi-section fs-ap-card', 'data-tab': name, 'data-tab-title': title }, rows);
const sub = (title, cls) => E('div', { 'class': 'fs-ap-sub' + (cls ? ' ' + cls : '') }, [ E('h4', {}, [ title ]) ]);
/* Every label here carries the 'footstrap' context: LuCI serves one merged catalogue and the first
* archive holding the hash wins, so a bare msgid is a global name any luci-app can win. */
/* ---- the colour scheme: what most visits come to change ---- */
const scheme = pane('scheme', _('Colour scheme', 'footstrap'), [
group(_('Mode', 'footstrap'), (label) => segCtl(prefs.currentMode(), [
[ 'auto', _('Auto', 'footstrap') ],
[ 'light', _('Light', 'footstrap') ],
[ 'dark', _('Dark', 'footstrap') ]
], bump(prefs.applyMode), label)),
group(_('Palette', 'footstrap'), (label) => selectCtl(axes.palette.current(), [
[ 'footstrap', 'Footstrap' ],
[ 'hicontrast', 'Hi-Contrast' ],
[ 'bootstrap', 'Bootstrap' ],
/* names another package, luci-theme-openwrt-2020, whose colourway this is */
[ '2020', 'OpenWrt 2020' ],
[ 'forum', 'Forum' ]
], bump(axes.palette.apply), label)),
/* Accent recolours the accented controls (buttons, toggles, sliders, focus rings), not the
* canvas; any #rrggbb is taken. Read as text on a card, the use that fails first (as a fill it
* carries derived ink). */
colourGroup(_('Accent', 'footstrap'), axes.accent, 'var(--fs-accent)',
{ fg: 'var(--fs-accent)', bg: CARD_BG, label: ON_CARD })
]);
/* ---- the interface ---- */
const iface = pane('interface', _('Interface', 'footstrap'), [
group(_('Layout', 'footstrap'), (label) => segCtl(prefs.currentLayout(), [
[ 'sidebar', _('Sidebar', 'footstrap') ],
[ 'top', _('Top', 'footstrap') ]
], bump(prefs.applyLayout), label)),
/* always built and hidden by CSS in the top layout (:root[data-layout="top"] .fs-ap-submenus):
* the page is built once, so a currentLayout() branch would freeze it to the layout the page
* loaded in while CSS morphs the chrome live */
group(_('Submenus', 'footstrap'), (label) => segCtl(prefs.currentAutoCollapse() ? 'on' : 'off', [
[ 'off', _('Keep open', 'footstrap') ],
[ 'on', _('Auto-collapse', 'footstrap') ]
], bump(prefs.applyAutoCollapse), label), { cls: 'fs-ap-submenus' }),
group(_('Density', 'footstrap'), (label) => segCtl(axes.density.current(), [
[ 'compact', _('Compact', 'footstrap') ],
[ 'normal', _('Normal', 'footstrap') ],
[ 'large', _('Large', 'footstrap') ]
], bump(axes.density.apply), label)),
/* the content column's own cap, separate from Density. The slider starts at the 1280px every
* page was designed against and only widens, which also keeps --fs-content-min (500px, what
* the sidebar-to-bar fold is measured against) out of reach by construction. */
group(_('Content width', 'footstrap'),
(label) => sliderCtl(axes.contentWidth, bump(axes.contentWidth.apply), label, { step: 40, live: false })),
group(_('Rounding', 'footstrap'),
(label) => sliderCtl(axes.radius, bump(axes.radius.apply), label))
]);
/* ---- the background: the canvas, the wallpaper and the glass over it ----
* The rows a value brings are siblings of the Wallpaper row: nested in its `.cbi-value-field`
* they started 216px right of every other control (measured on the router). They are hidden as
* a group, as stock pages do with a dependent field. The pills are the per-browser switch that
* keeps the Save button honest; Choose/Remove only swap the picture and never touch the axis. */
const REMOVE = _('Remove', 'footstrap');
const background = (() => {
const err = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' });
const preview = E('img', { 'class': 'fs-ap-bgprev', 'alt': '', 'hidden': '' });
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 ]);
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 ]);
/* Colours decides whether the file's own palette is kept or replaced by the theme's: a mask
* uses the alpha only, right for line art and wrong for artwork with its own colours. */
const patRows = [
group(_('Pattern', 'footstrap'),
() => E('div', { 'class': 'fs-ap-bgrow' }, [ patChoose, patRemove ]),
{ extra: [ patInput, patPreview, patErr ] }),
group(_('Scale', 'footstrap'), (lbl) => sliderCtl(axes.patternSize, bump(axes.patternSize.apply), lbl, { step: 20 })),
group(_('Strength', 'footstrap'), (lbl) => sliderCtl(axes.patternStrength, bump(axes.patternStrength.apply), lbl, { step: 5 })),
group(_('Colours', 'footstrap'), (lbl) => segCtl(axes.patternInk.current(), [
[ 'theme', _('Theme', 'footstrap') ],
[ 'original', _('As in file', 'footstrap') ]
], bump(axes.patternInk.apply), lbl))
];
/* Dim is an ordinary saved axis, so it is bump()-ed or the Save button misreports its status */
const fileRows = [
group(_('File', 'footstrap'),
() => E('div', { 'class': 'fs-ap-bgrow' }, [ chooseBtn, removeBtn ]),
{ extra: [ fileInput, preview, err ] }),
group(_('Dim', 'footstrap'), (lbl) => sliderCtl(axes.photoDim, bump(axes.photoDim.apply), lbl, { step: 5 }))
];
function reflect(tok) {
if (tok) { preview.src = axes.loginBg.url(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.pattern.url(tok); patPreview.hidden = false; patRemove.hidden = false; }
else { patPreview.removeAttribute('src'); patPreview.hidden = true; patRemove.hidden = true; }
}
/* `hidden` on the row, which base/10-reset.css restates with !important (the UA's bare
* `[hidden]` loses to `.cbi-value`'s display). Hidden, not removed: each row holds a live
* control that a rebuild would freeze to its construction-time state. */
function togglePanel(v) {
patRows.forEach((r) => { r.hidden = (v !== 'pattern'); });
fileRows.forEach((r) => { r.hidden = (v !== 'file'); });
}
reflect(axes.loginBg.current());
reflectPattern(axes.pattern.current());
togglePanel(axes.wallpaper.current());
const setWallpaper = (v) => { axes.wallpaper.apply(v); refreshSave(); togglePanel(v); };
/* Both uploads share one wiring (pick, upload, report, remove) and never move the Wallpaper
* pills, which the user may change mid-upload. The input is cleared on every change so
* re-picking the same file fires `change`; the label is restored in `finally`, or a failed
* upload leaves "Uploading…" standing. */
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(u.reflect)
.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
});
wireUploader({
choose: chooseBtn, remove: removeBtn, input: fileInput, err: err,
label: chooseLabel, reflect: reflect,
upload: assets.uploadLoginBg, drop: assets.removeLoginBg
});
/* The glass rows show only over a wallpaper (CSS: .fs-ap-glass), Material only while a
* surface is glass at all, Blur only for a material that blurs. An OS asking for less
* transparency or more contrast keeps the surfaces solid whatever the sliders say, and the
* page says so. */
const glassRows = [
sub(_('Transparency', 'footstrap'), 'fs-ap-glass'),
group(_('Card opacity', 'footstrap'),
(lbl) => sliderCtl(axes.cardOpacity, bump(axes.cardOpacity.apply), lbl, { step: 5 }), {
cls: 'fs-ap-glass',
desc: axes.glassBlocked() ? _('The system asks for less transparency or more contrast, so surfaces stay solid.', 'footstrap') : null
}),
group(_('Menu opacity', 'footstrap'),
(lbl) => sliderCtl(axes.barOpacity, bump(axes.barOpacity.apply), lbl, { step: 5 }), { cls: 'fs-ap-glass' }),
group(_('Material', 'footstrap'), (lbl) => segCtl(axes.material.current(), [
[ 'flat', _('Flat', 'footstrap') ],
[ 'frosted', _('Frosted', 'footstrap') ],
[ 'acrylic', _('Acrylic', 'footstrap') ]
], bump(axes.material.apply), lbl), { cls: 'fs-ap-glass fs-ap-material' }),
group(_('Blur', 'footstrap'),
(lbl) => sliderCtl(axes.blur, bump(axes.blur.apply), lbl, { step: 10 }), { cls: 'fs-ap-glass fs-ap-material fs-ap-blur' })
];
return pane('background', _('Background', 'footstrap'), [
/* the canvas colour that tells routers apart; CSS hides it under a photo, which covers it */
colourGroup(_('Tint', 'footstrap'), axes.tint, 'var(--fs-bg)', {
fg: 'var(--fs-text)', bg: 'var(--fs-bg)', label: _('on the canvas', 'footstrap')
}, { cls: 'fs-ap-tint', desc: _('Colours the page so you can tell routers apart.', 'footstrap') }),
/* the strength half of the Tint, meaningful only in hue mode (a hex canvas has no chroma of
* ours to scale); CSS hides it otherwise. Not named "Density": the string is also the
* aria-label, so a screen reader would announce two rows under one name. */
group(_('Tint strength', 'footstrap'),
(label) => sliderCtl(axes.tintStrength, bump(repaint(axes.tintStrength.apply)), label, {
step: 5
}), { cls: 'fs-ap-tint fs-ap-tintstr' }),
group(_('Wallpaper', 'footstrap'), (label) => segCtl(axes.wallpaper.current(), [
[ 'off', _('Off', 'footstrap') ],
[ 'pattern', _('Pattern', 'footstrap') ],
[ 'file', _('File', 'footstrap') ]
], setWallpaper, label))
].concat(patRows, fileRows, glassRows));
})();
/* ---- the colours nobody touches twice: the last tab ----
* The three status colours, then the surfaces: the sheet the UI is drawn on. Body text is read on
* each surface, so each reports --fs-text against itself; no ink is derived, since moving
* --fs-text would recolour what is measured against. The hairline takes the 3:1 UI-component
* threshold (a border is a shape). The rows are data because a string literal repeated eight
* times is not mangled by the minifier. */
const colourRows = [
...[
[ _('Good', 'footstrap'), axes.good, 'var(--fs-good)' ],
[ _('Warning', 'footstrap'), axes.warn, 'var(--fs-warn)' ],
[ _('Danger', 'footstrap'), axes.danger, 'var(--fs-danger)' ]
].map(([ label, axis, ink ]) =>
colourGroup(label, axis, ink, { fg: ink, bg: CARD_BG, label: ON_CARD })),
sub(_('Surfaces', 'footstrap')),
...[
[ _('Cards', 'footstrap'), axes.card, CARD_BG, INK, CARD_BG, ON_CARD ],
[ _('Controls', 'footstrap'), axes.control, 'var(--fs-panel2)', INK, 'var(--fs-panel2)', _('on a control', 'footstrap') ],
[ _('Sidebar and bar', 'footstrap'), axes.bar, 'var(--fs-bar-bg)', INK, 'var(--fs-bar-bg)', _('in the sidebar', 'footstrap') ],
[ _('Borders', 'footstrap'), axes.line, 'var(--fs-border)', 'var(--fs-border)', CARD_BG, ON_CARD, 'shape' ]
].map(([ label, axis, probe, fg, bg, where, kind ]) =>
colourGroup(label, axis, probe, { fg, bg, label: where, kind }))
];
/* ---- saving: the router default and the version ----
* Save writes the current look to /etc/config/footstrap over the scoped uci ACL. It does not
* change this browser (localStorage keeps overriding), so it only shows on a fresh browser. */
/* each label is said twice in build(); hoisted, a msgid is paid once (24 B x2 -> 30 B) */
const SAVE_TO_ROUTER = _('Save to router', 'footstrap');
const RESET_TO_ROUTER = _('Reset to router', 'footstrap');
const RESET_TO_BUILTIN = _('Reset to built-in', '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: "Reset to router" clears them
* and lets every axis fall back to the router, "Reset to built-in" writes the theme's defaults
* explicitly. Neither touches /etc/config/footstrap. "Default" is not a label: it named the
* router's look in "Save as default" and the theme's in "Reset to default", three names for two
* states (forum topic 251930, post 92). */
const resetSavedBtn = E('button', { 'class': 'btn', 'type': 'button' }, [ RESET_TO_ROUTER ]);
const resetBtn = E('button', { 'class': 'btn cbi-button-negative', 'type': 'button' }, [ RESET_TO_BUILTIN ]);
/* Save's only visible failure surface: the rpc rejecting (expired session 403, missing ACL, ubus
* down). A deleted config is not caught: commit no-ops and returns 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. A browser that
* refuses storage keeps its choices in fs-prefs's in-memory map, so the comparison stays honest;
* the note waits while a failed Save's error shows, or the .finally below would overwrite it. */
function refreshSave() {
if (prefs.storageBroken() && saveErr.hidden) {
saveErr.textContent = _('This browser is not storing preferences (site data is blocked), so a change here lasts until you reload. Saving to the router still works and applies to every browser.', 'footstrap');
saveErr.hidden = false;
}
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;
saveErr.title = ''; /* a stale rpc error would tooltip the storage note (measured: 'ubus code 6' over it) */
axes.saveAsDefault()
/* 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 to the router. 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. */
let armed = null;
function disarm() {
if (!armed) return;
armed.btn.textContent = armed.label;
armed.btn.classList.remove('fs-ap-armed');
armed = null;
}
function twoClick(btn, label, run) {
btn.addEventListener('click', () => {
if (armed && armed.btn === btn) {
disarm();
run();
location.reload();
return;
}
disarm();
armed = { btn: btn, label: label };
btn.textContent = _('Confirm reset', 'footstrap');
btn.classList.add('fs-ap-armed');
});
}
twoClick(resetSavedBtn, RESET_TO_ROUTER, axes.resetToSaved);
twoClick(resetBtn, RESET_TO_BUILTIN, 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() ]);
/* The theme's translations are their own packages, and nothing in a package manager can read `uci
* luci.main.lang` to fetch the right one. */
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 ])
]) : '';
const saving = card(_('Saving', 'footstrap'), [
E('p', { 'class': 'fs-ap-lead' }, [ _('Footstrap theme settings apply at once and are stored permanently in this browser.', 'footstrap') ]),
E('div', { 'class': 'fs-ap-actrow' }, [ saveBtn, E('span', { 'class': 'fs-ap-actgap', 'aria-hidden': 'true' }), resetSavedBtn, resetBtn ]),
saveErr,
E('ul', { 'class': 'fs-ap-help' }, [
E('li', {}, [ _('Save to router — store this look on the router. Browsers with a look of their own keep it.', 'footstrap') ]),
E('li', {}, [ _('Reset to router — drop this browser\'s changes and follow the router.', 'footstrap') ]),
E('li', {}, [ _('Reset to built-in — go back to the look the theme ships with.', 'footstrap') ])
]),
E('div', { 'class': 'fs-ap-footer' }, [
E('div', { 'class': 'fs-ap-verrow' }, [ versionLink ]),
missing
])
]);
/* LuCI's own tab group, as on every tabbed form: it puts the strip before the panes and keeps
* the open tab for the session. A pane shown again re-reads its colours, which a palette or
* dark-mode change may have moved while it was hidden. */
const panes = E('div', { 'class': 'fs-ap-panes' }, [
scheme,
iface,
background,
pane('colours', _('Colours', 'footstrap'), colourRows)
]);
const page = E('div', { 'class': 'fs-ap' }, [ panes, saving ]);
ui.tabs.initTabGroup(panes.childNodes);
panes.addEventListener('cbi-tab-active', refreshColours);
Promise.resolve().then(refreshColours);
watchColours(refreshColours);
return page;
}
return baseclass.extend({
/* the dispatched page's whole render(), called by view/footstrap/appearance.js */
renderStandalone: () => Promise.resolve().then(build),
});
@@ -0,0 +1,291 @@
'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: the pattern and login-photo uploads,
* the SVG sanitizer, the rollback. A module of its own: only the Appearance page needs these ~4 KB. */
/* Hoisted: a string literal is not mangled, so each repeat is paid on flash; msgid stays literal
* for update-po.sh. */
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: a translator places both %d in one sentence instead of agreeing
* plurals. */
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');
/* ---- the pattern: an SVG the admin uploads, tiled and recoloured ----
* Fixed path matched by the rpcd ACL, under /etc so an upgrade keeps it. Not the security boundary: a
* `curl` to cgi-upload skips every check below; a direct open is stopped by the CGI's
* `Content-Security-Policy: default-src 'none'; sandbox`. */
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 `_sanitizeSvg` removes, judged on the parsed document, not the text. Painted as an image nothing
* loads; these rules cover a direct open of the file:
* - elements: only the SVG namespace stays (`<h:frame src="javascript:…">` ran same-origin), minus
* script, foreignObject, a `<style>` with an element or off-document ref, an animation whose
* attributeName is a URL attribute (SMIL retargets it at run time)
* - href, xlink:href, ping: a `#fragment`, or a raster `data:` on an `<image>`; else removed
* - on* and `xml:base` removed; `style` and un-namespaced attributes by `_externalStyleRef` */
const PAT_BAD_TAGS = [ 'script', 'foreignobject' ];
const PAT_ANIM_TAGS = [ 'animate', 'set', 'animatetransform', 'animatemotion', 'animatecolor' ];
const PAT_URL_ATTR = /(?:^|:)(?:href|ping)$/;
const PAT_RASTER_DATA = /^data:image\/(?:png|jpe?g|gif|webp|avif|bmp)[;,]/i;
const SVG_NS = 'http://www.w3.org/2000/svg';
/* CSS Syntax §4.3.7: `\` + 1-6 hex digits (+ one whitespace) is a code point, so `u\72l(` is
* `url(`; decode first. */
function _cssUnescape(v) {
/* §3.3 folds CR LF to LF first, so one whitespace ends `\72<CR><LF>`; unfolded,
* `u\72<CR><LF>l(` hid a `url(`. */
v = v.replace(/\r\n?|\f/g, '\n');
/* U+FFFD for zero, a surrogate or > U+10FFFF: fromCodePoint throws on `\FFFFFF`. */
return v.replace(/\\([0-9a-fA-F]{1,6})[ \t\n\r\f]?|\\([^\r\n\f])/g, (m, hex, lit) => {
if (hex === undefined) return lit;
const cp = parseInt(hex, 16);
return String.fromCodePoint(cp === 0 || cp > 0x10FFFF || (cp >= 0xD800 && cp <= 0xDFFF) ? 0xFFFD : cp);
});
}
/* True if CSS text (a declaration or a whole <style>) reaches off this document. `url(` is matched
* everywhere. Whitespace is CSS's — space, TAB, LF, CR, FF; `\s` also takes NBSP, which CSS keeps in the
* URL (a fetch of /%C2%A0). `url(`/`expression(` allow space before `(`; `image (` is no function. */
function _externalStyleRef(v) {
v = _cssUnescape(v);
if ((/@import\b/i).test(v) || (/expression[ \t\n\r\f]*\(/i).test(v) || (/(?:image-set|cross-fade|image|src)\(/i).test(v)) return true;
const urlRe = /url[ \t\n\r\f]*\([ \t\n\r\f]*(['"]?)[ \t\n\r\f]*([^) \t\n\r\f'"]*)/gi;
let m;
while ((m = urlRe.exec(v)) !== null)
if (m[2].charAt(0) !== '#') return true;
return false;
}
/* A `style` minus its off-document declarations: one Inkscape export carried 278 style attributes,
* none external. */
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 judges and cleans, so two traversals cannot disagree. `text` is the original when nothing was
* removed: an XMLSerializer round-trip rewrites namespaces. */
function _sanitizeSvg(text) {
const doc = new DOMParser().parseFromString(text, 'image/svg+xml');
const root = doc.documentElement;
/* An SVG is its root's namespace, not its spelling: `<svg xmlns=xhtml>` is an executing XHTML
* document. */
if (!root || doc.querySelector('parsererror') ||
root.localName.toLowerCase() !== 'svg' || root.namespaceURI !== SVG_NS)
return { error: MSG_NOT_SVG };
let elements = 0, refs = 0;
/* A PI can attach an XSLT stylesheet whose output this walk never sees (executes on Firefox). */
/* An external-DTD DOCTYPE is left: no engine resolves it, and the CSP answers a direct open. */
for (const n of [ ...doc.childNodes ])
if (n.nodeType === Node.PROCESSING_INSTRUCTION_NODE) { n.remove(); elements++; }
/* Snapshot first: removing a node detaches its subtree; isConnected skips it. */
const els = [ root ].concat([ ...root.querySelectorAll('*') ]);
for (const el of els) {
if (el !== root && !el.isConnected) continue;
/* localName, never nodeName: the prefix hid `s:script` from a name list (executing on 3
* engines). */
const tag = el.localName.toLowerCase();
/* A browser reads a <style> from its direct text children and steps over an element:
* `@imp<x>z</x>ort` is `@import` to it and `@impzort` to textContent: an element removes it,
* else textContent is those children. */
if (el.namespaceURI !== SVG_NS || PAT_BAD_TAGS.indexOf(tag) >= 0 ||
(PAT_ANIM_TAGS.indexOf(tag) >= 0 &&
PAT_URL_ATTR.test(String(el.getAttribute('attributeName') || '').trim().toLowerCase())) ||
(tag === 'style' && (el.children.length || _externalStyleRef(String(el.textContent || ''))))) {
el.remove();
elements++;
continue;
}
/* Snapshot: removing while indexing the live NamedNodeMap shifts the indexes. */
for (const a of Array.from(el.attributes)) {
const n = a.name.toLowerCase();
const raw = a.value;
const v = raw.trim();
/* A real handler is `on` + letters (`only_selected` is not); a prefixed `s:onload`
* binds nothing. */
if ((/^on[a-z]+$/).test(n)) { el.removeAttribute(a.name); refs++; continue; }
/* `xml:base` moves what every relative reference below it resolves against, even `#g`;
* removed on every element. */
if (n === 'xml:base') { el.removeAttribute(a.name); refs++; continue; }
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 (PAT_URL_ATTR.test(n)) {
/* Allowlist, case-sensitive as XML. Strip as the URL parser does: every TAB/LF/CR, then
* leading spaces (XML 1.0 admits no other C0 control); trim() also takes NBSP and U+FEFF,
* which the parser keeps. */
const u = raw.replace(/[\t\n\r]/g, '').replace(/^ +/, '');
if (u.charAt(0) !== '#' && !(el.localName === 'image' && PAT_RASTER_DATA.test(u)))
{ el.removeAttribute(a.name); refs++; }
} else if (n.indexOf(':') < 0 && _externalStyleRef(v)) { el.removeAttribute(a.name); refs++; }
}
}
if (!(elements + refs)) return { text }; /* untouched: hand back the original bytes */
if (root.children.length === 0) return { error: MSG_NOTHING_LEFT };
return { text: new XMLSerializer().serializeToString(doc), elements, refs };
}
/* ---- login/page background upload: router-side, and not an axis ----
* One router-wide image shown pre-login: absent from AXIS_KEYS, snapshotAxes() and matchesSavedDefault()
* so it never moves Save. Only its cache-bust token lives in uci -> window.__fsSD -> head.ut. */
const BG_PATH = '/etc/footstrap/login-bg'; /* cgi-upload target; the ACL grants exactly this */
const BG_MAX_SIDE = 1920; /* longest side: 1080p covers the screens LuCI is administered from */
const BG_QUALITY = 0.9;
const BG_SRC_MAX = 25 * 1024 * 1024; /* refuse a source this big before decoding */
/* No `reject: true`: it would make "already gone" and "refused" the same Error. */
const _fileRemoveStatus = rpc.declare({ object: 'file', method: 'remove', params: [ 'path' ] });
/* "Not found" counts as done; any other refusal must surface: the file stays fetchable via /www. */
const UBUS_NOT_FOUND = 4;
function _removeServed(path) {
return _fileRemoveStatus(path).then((res) => {
if (res === 0 || res === UBUS_NOT_FOUND) return;
return Promise.reject(new Error(
_('The router refused to delete the file (ubus status %d).', 'footstrap').format(res)));
});
}
/* Re-encode to a bounded JPEG: only decoded pixels survive (no EXIF). A throw inside onload would
* leave the promise pending. */
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 could not be recorded would stay served via /www with Remove hidden: roll it back. */
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: `prepare` the bytes, POST, take the md5 as cache-bust token, write it to uci, paint. Bytes
* land before the token, so a later failure rolls back. */
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);
/* cgi-io writes 0600 and uhttpd 403s that; filemode makes it chmod 644 itself, inside its ACL check */
fd.append('filemode', '644');
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] }; a failed
* chmod leaves the file in place, so only that message rolls back */
if (reply && reply.message === 'Failed to chmod target file')
return _rollbackUpload(o.path, new Error(MSG_UPLOAD_FAILED));
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));
/* uci gets the token only: uploading is not making every device paint it */
return axes.saveSettings({ [o.field]: tok })
.catch((e) => _rollbackUpload(o.path, e))
/* an upload never touches the Wallpaper axis: the dropdown alone sets it */
.then(() => { o.apply(tok); return tok; });
});
/* Remove: delete the file, blank the token (the scoped ACL grants set/commit only), clear the
* url(). */
const remove = () => _removeServed(o.path)
.then(() => axes.saveSettings({ [o.field]: '' }))
.then(() => { o.apply(''); });
return { upload, remove };
}
/* The tile: no canvas step (it would return a raster); the parsed check stands in. */
const PATTERN = assetAxis({
path: PAT_PATH, filename: 'pattern.svg', field: 'pattern',
apply: (tok) => axes.pattern.apply(tok),
prepare: (file) => {
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')));
/* same message: what reaches the router is the bytes that were checked */
const readErr = () => Promise.reject(new Error(_('That file could not be read.', 'footstrap')));
return file.text().catch(readErr).then((text) => {
const cleaned = _sanitizeSvg(text);
if (cleaned.error) return Promise.reject(new Error(cleaned.error));
/* Told once, whether or not the upload succeeds; `prepare()` has no channel back past
* its Blob. */
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 L.ui.uploadFile's endpoint, authorised by the ACL for BG_PATH. */
const LOGIN_BG = assetAxis({
path: BG_PATH, filename: 'login-bg', field: 'login_bg',
apply: (tok) => axes.loginBg.apply(tok),
prepare: (file) => {
if (!(/^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,
});
@@ -0,0 +1,368 @@
'use strict';
'require baseclass';
'require rpc';
'require fs-prefs as prefs';
'require fs-fit as fit';
/* fs-axes — the Appearance axes and the Save-as-default machinery.
*
* Split out of fs-prefs.js because only `fs-appearance` (the form) and `fs-assets` (the uploads)
* need it, so the router fetches it on the Appearance page and nowhere else: 5.9 KB off what every
* page downloads. fs-prefs keeps what the chrome applies live: the localStorage wrappers and sd(),
* dark mode (guardDarkStamp defends it on every page), layout, rail and auto-collapse.
*
* head.ut's pre-paint 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. */
/* 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 localStorage key is hyphenated
* and the uci option is not */
const sdKey = key.slice(3).replace(/-/g, '_');
const ok = (v) => (values.indexOf(v) >= 0);
const def = () => (ok(prefs.sd(sdKey)) ? prefs.sd(sdKey) : dflt);
return {
def,
current() {
const s = prefs.lsGet(key);
if (ok(s)) return s;
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 */
prefs.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 "two-valued" is what most of these
* axes are. */
function enumAxis(key, attr, on, off) {
return listAxis(key, attr, [ on ], off);
}
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 an older config) */
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;
}
/* Black or white ink for a '#rrggbb' fill: OKLab L above .62 reads black, else white (black clears
* 4.5:1 on #ffcc00, white on #0033aa). Computed here, not in CSS. */
function inkFor(hex) {
const [ lr, lg, lb ] = [ 1, 3, 5 ].map((i) => {
const u = parseInt(hex.slice(i, i + 2), 16) / 255;
return (u <= 0.04045) ? (u / 12.92) : (((u + 0.055) / 1.055) ** 2.4);
});
const f = (x, y, z) => Math.cbrt((x * lr) + (y * lg) + (z * lb));
return (((0.21045 * f(0.41222, 0.53633, 0.05145)) + (0.79362 * f(0.2119, 0.6807, 0.1074)) - (0.00407 * f(0.0883, 0.28172, 0.62998))) > 0.62) ? '#000' : '#fff';
}
function colorAxis(key, attr, hueProp, colorProp, inkProp) {
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);
if (inkProp) root.style.removeProperty(inkProp);
} else if (typeof v === 'number') {
root.style.removeProperty(colorProp);
if (inkProp) root.style.removeProperty(inkProp);
/* 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);
if (inkProp) root.style.setProperty(inkProp, inkFor(v));
root.setAttribute(attr, 'hex');
}
}
};
}
/* A numeric slider axis that sets an inline custom property and no attribute: it validates to
* [min,max], stores the choice explicitly and removes the property at the default. */
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, min, max,
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 (styles/03-palettes.css). Not the enumAxis shape. */
const PALETTES = [ 'hicontrast', 'bootstrap', '2020', 'forum' ]; /* the non-default values; 'footstrap' = bare :root */
const PALETTE = listAxis('fs-palette', 'data-palette', PALETTES, 'footstrap');
/* Wallpaper: off (bare canvas), pattern (the admin-uploaded SVG, tiled and recoloured) or file
* (the admin-uploaded photo). */
const WALLPAPERS = [ 'pattern', 'file' ]; /* the non-off values; 'off' = bare :root */
const WALLPAPER = listAxis('fs-wallpaper', 'data-wallpaper', WALLPAPERS, 'off');
/* Density is a pure token axis; it re-runs the fit because Compact makes more fit and Large less. */
const DENSITIES = [ 'compact', 'large' ]; /* the two non-default values; 'normal' = bare :root */
const DENSITY = listAxis('fs-density', 'data-density', DENSITIES, 'normal', () => fit.schedule());
const TINT = colorAxis('fs-tint', 'data-tint', '--fs-tint-h', '--fs-bg');
/* Accent axis: the UI accent (solid buttons, toggle knobs, sliders, focus rings, accented links)
* while canvas, cards and status colours stay put. */
const ACCENT = colorAxis('fs-accent', 'data-accent', '--fs-accent-h', '--fs-accent', '--fs-on-accent');
/* The surface axes (cards, chrome, inset controls, hairlines) are their own factory, not four more
* colorAxis instances: there is no hue mode, as rotating a near-white card keeps it near-white. */
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 CONTROL = surfaceAxis('fs-control', 'control', '--fs-panel2-base');
const BAR = surfaceAxis('fs-bar', 'bar', '--fs-bar-bg');
const LINE = surfaceAxis('fs-line', 'line', '--fs-border-base');
/* The three status colours are colorAxis pointed at --fs-good / --fs-warn / --fs-danger, separate
* because each derived tint is a color-mix() of its own role. */
const GOOD = colorAxis('fs-good', 'data-good', '--fs-good-h', '--fs-good', '--fs-on-good');
const WARN = colorAxis('fs-warn', 'data-warn', '--fs-warn-h', '--fs-warn', '--fs-on-warn');
const DANGER = colorAxis('fs-danger', 'data-danger', '--fs-danger-h', '--fs-danger', '--fs-on-danger');
/* Rounding: the propAxis instance, --fs-radius-base in px. */
const RADIUS = propAxis('fs-radius', 'rounding', '--fs-radius-base', 0, 20, FS_RADIUS_DEFAULT, (v) => (v + 'px'));
/* Content width: how far the reader lets the column grow past --fs-content-max's own 1280px; the
* range is bounded in 02-tokens.css. It re-runs the fit. */
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());
/* Tint strength: a multiplier on the tint chroma, 100% the designed strength and 200% the cap. 0
* is not "no tint". */
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));
/* Photo dim: the scrim opacity over the file photo (0–100%); how strongly this browser dims the
* shared photo reaches the router through Save-as-default. */
const FS_PDIM_DEFAULT = 74;
const PDIM = propAxis('fs-photo-dim', 'photo_dim', '--fs-photo-dim', 0, 100, FS_PDIM_DEFAULT, (v) => (v + '%'));
/* The pattern's live knobs bite only while the wallpaper is 'pattern'. */
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 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 PINK = enumAxis('fs-pattern-ink', 'data-pattern-ink', 'original', 'theme');
/* Transparency (issue #87): the cards and the menu as glass over a wallpaper, 100% = solid. The
* attribute that switches the glass on is derived, not stored: set below 100% unless the OS asks
* for less transparency or more contrast, which a glass card cannot honour. 17-glass.css adds the
* wallpaper gate. head.ut's pre-paint repeats both rules. */
const GLASS_BLOCKERS = [ '(prefers-reduced-transparency: reduce)', '(prefers-contrast: more)', '(forced-colors: active)' ];
const glassBlocked = () => GLASS_BLOCKERS.some((q) => window.matchMedia(q).matches);
function glassAxis(key, sdKey, prop, attr) {
const ax = propAxis(key, sdKey, prop, 0, 100, 100, (v) => (v + '%'),
() => document.documentElement.toggleAttribute(attr, ax.current() < 100 && !glassBlocked()));
return ax;
}
const CARD_OP = glassAxis('fs-card-opacity', 'card_opacity', '--fs-card-opacity', 'data-glass');
const BAR_OP = glassAxis('fs-bar-opacity', 'bar_opacity', '--fs-bar-opacity', 'data-glass-bar');
/* how the glass is made: Flat (own colours, no blur), Frosted (a veil of ink, blurred) or Acrylic */
const MATERIALS = [ 'flat', 'acrylic' ]; /* the non-default values; 'frosted' = bare :root */
const MATERIAL = listAxis('fs-material', 'data-material', MATERIALS, 'frosted');
/* Frosted's and Acrylic's blur as a share of the material's own radius, 100 = as designed */
const BLUR = propAxis('fs-blur', 'blur', '--fs-blur', 0, 200, 100, (v) => String(v / 100));
/* `reject: true` is load-bearing: without it rpc.js hands the ubus status back as the resolved
* value, so a per-config ACL refusal (6, permission denied) runs every `.then()` as a success. */
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 });
const saveSettings = (values) => _uciSet('footstrap', 'settings', values).then(() => _uciCommit('footstrap'));
/* The one list of axes. Its keys are the uci option names Save-as-default writes (and partials/head.ut
* stamps back into window.__fsSD); `key` is the localStorage key, `ax` the axis object. */
const AXES = {
layout: { key: 'fs-layout', ax: { current: prefs.currentLayout, def: () => prefs.sd('layout') || 'top', apply: prefs.applyLayout }, builtin: 'top' },
darkmode: { key: 'fs-darkmode', ax: { current: prefs.currentMode, def: prefs.modeDefault, apply: prefs.applyMode }, builtin: 'auto' },
palette: { key: 'fs-palette', ax: PALETTE, builtin: 'footstrap' },
wallpaper: { key: 'fs-wallpaper', ax: WALLPAPER, builtin: 'off' },
tint: { key: 'fs-tint', ax: TINT, builtin: 0 },
accent: { key: 'fs-accent', ax: ACCENT, builtin: 0 },
good: { key: 'fs-good', ax: GOOD, builtin: 0 },
warn: { key: 'fs-warn', ax: WARN, builtin: 0 },
danger: { key: 'fs-danger', ax: DANGER, builtin: 0 },
card: { key: 'fs-card', ax: CARD, builtin: 0 },
control: { key: 'fs-control', ax: CONTROL, builtin: 0 },
bar: { key: 'fs-bar', ax: BAR, builtin: 0 },
line: { key: 'fs-line', ax: LINE, builtin: 0 },
rounding: { key: 'fs-radius', ax: RADIUS, builtin: FS_RADIUS_DEFAULT },
autocollapse: { key: 'fs-menu-autocollapse', ax: {
current: () => (prefs.currentAutoCollapse() ? 'on' : 'off'),
def: () => (prefs.autoCollapseDefault() ? 'on' : 'off'),
apply: prefs.applyAutoCollapse }, builtin: 'off' },
tint_strength: { key: 'fs-tint-strength', ax: TSTR, builtin: FS_TSTR_DEFAULT },
density: { key: 'fs-density', ax: DENSITY, builtin: 'normal' },
photo_dim: { key: 'fs-photo-dim', ax: PDIM, builtin: FS_PDIM_DEFAULT },
pattern_size: { key: 'fs-pattern-size', ax: PSIZE, builtin: FS_PSIZE_DEFAULT },
pattern_strength: { key: 'fs-pattern-strength', ax: PSTR, builtin: FS_PSTR_DEFAULT },
pattern_ink: { key: 'fs-pattern-ink', ax: PINK, builtin: 'theme' },
content_width: { key: 'fs-content-width', ax: CWIDTH, builtin: FS_CWIDTH_DEFAULT },
card_opacity: { key: 'fs-card-opacity', ax: CARD_OP, builtin: 100 },
bar_opacity: { key: 'fs-bar-opacity', ax: BAR_OP, builtin: 100 },
material: { key: 'fs-material', ax: MATERIAL, builtin: 'frosted' },
blur: { key: 'fs-blur', ax: BLUR, builtin: 100 }
};
const AXIS_KEYS = Object.values(AXES).map((a) => a.key);
const snapshotAxes = () => Object.fromEntries(Object.entries(AXES).map(([ k, a ]) => [ k, String(a.ax.current()) ]));
/* The resolved router default (the uci value if set, else the built-in) in snapshotAxes() string
* form, so the Appearance page can grey the Save button when this browser already matches. */
const _resolvedDefault = () => Object.fromEntries(Object.entries(AXES).map(([ k, a ]) => [ k, String(a.ax.def()) ]));
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: an axis that wrote through
* on change re-pointed the router-wide default for every other device. */
function saveAsDefault() {
const snap = snapshotAxes();
return saveSettings(snap)
.then(() => { _savedDefault = snap; });
}
/* The two resets differ in what is underneath: resetToSaved() clears the keys, so every axis falls
* back to the router default where one is set and the built-in where not. */
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. */
function resetToBuiltin() {
Object.values(AXES).forEach((a) => a.ax.apply(a.builtin));
}
/* The two uploaded wallpapers, browser side: what the router last saved, its URL, and how to paint
* it. The upload itself is fs-assets.js. */
/* the cache-bust token charset, an md5/sha hex string; head.ut's ucode sanitiser keeps its own
* copy, running before this module */
const BG_TOKEN_RE = /^[a-f0-9]{6,64}$/;
function tokenOk(t) { return BG_TOKEN_RE.test(t); }
/* The pattern and the login-bg photo are the same shape twice: a router-saved cache-bust token,
* read back through prefs.sd(), that sets or clears one url() custom property. */
function tokenAsset(sdKey, serve, prop) {
const current = () => {
const t = prefs.sd(sdKey);
return (typeof t === 'string' && BG_TOKEN_RE.test(t)) ? t : '';
};
const url = (tok) => serve + '?v=' + tok;
const apply = (tok) => {
const root = document.documentElement;
if (tok) root.style.setProperty(prop, 'url("' + url(tok) + '")');
else root.style.removeProperty(prop);
prefs.setSD(sdKey, tok || '');
};
return { current, url, apply };
}
/* Not a /www symlink, unlike LOGIN_BG: an SVG served as a plain static file is a same-origin
* document that runs script opened directly. */
const PATTERN = tokenAsset('pattern', '/cgi-bin/luci-theme-footstrap-pattern', '--fs-pattern-url');
/* Plain /www symlink, extension-less, so uhttpd serves it as octet-stream (the uci-default says why). */
const LOGIN_BG = tokenAsset('login_bg', '/luci-static/footstrap/bg', '--fs-login-bg-url');
return baseclass.extend({
palette: PALETTE,
wallpaper: WALLPAPER,
density: DENSITY,
tint: TINT,
accent: ACCENT,
good: GOOD,
warn: WARN,
danger: DANGER,
card: CARD,
control: CONTROL,
bar: BAR,
line: LINE,
radius: RADIUS,
tintStrength: TSTR,
photoDim: PDIM,
patternSize: PSIZE,
patternStrength: PSTR,
patternInk: PINK,
contentWidth: CWIDTH,
cardOpacity: CARD_OP,
barOpacity: BAR_OP,
material: MATERIAL,
blur: BLUR,
pattern: PATTERN,
loginBg: LOGIN_BG,
tokenOk, glassBlocked,
matchesSavedDefault, saveSettings, saveAsDefault, resetToSaved, resetToBuiltin
});
@@ -0,0 +1,660 @@
'use strict';
'require baseclass';
'require ui';
'require fs-fit as fit';
'require fs-prefs as prefs';
'require fs-menutree as tree';
'require fs-widgets as widgets';
/* The chrome around the content: the main menu, the mode menu, the section tabs, the rail toggle
* and the measurements deciding how much room each gets. menu-footstrap-common.js calls wireMenu()
* and start(); nothing else here needs wiring from outside. Spec: docs/chrome.md */
/* section tabs -> #tabmenu (horizontal) */
function renderTabMenu(node, url, level) {
/* #tabmenu is emitted whenever this module loads (header.ut, !blank_page) */
const container = document.querySelector('#tabmenu');
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);
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;
container.appendChild(ul);
container.style.display = '';
if (activeNode)
renderTabMenu(activeNode, url + '/' + activeNode.name, (level || 0) + 1);
}
/* ---- tab-strip auto-fit ----
* A strip can carry ~11 pills (luci-app-justclash) that overflow one row: rather than wrap, two
* density classes (fs-dense1, fs-dense2) shrink it. */
function stripFitsOneRow(ul) {
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 || first.offsetTop === last.offsetTop;
}
function fitTabStrips() {
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;
}
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 yields to the bar when what is left for the content is too narrow. A viewport
* breakpoint cannot say that: the cut depends on the slice (224 px expanded, 68 px rail). */
let _probe = null;
/* The probe is a plain <div> in the shared document (no chrome mark; the fence does not spare it),
* so a third-party app's CSS can style it and every declaration is !important. */
function resolveLen(token, dflt) {
if (!_probe) {
_probe = document.createElement('div');
_probe.id = 'fs-chrome-geom-probe';
_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;
* keyed on the density, the one thing that changes these widths at runtime. */
const GEOM_DFLT = { contentMin: 500, sidebarW: 224, railW: 68, contentPad: 56 };
const CLASS_IND_COMPACT = 'fs-ind-compact';
let _geom = null, _geomKey = null, _geomWarned = false;
function shellGeometry() {
const density = document.documentElement.getAttribute('data-density') || '';
if (_geom && _geomKey === density) {
_geom.contentPad = _shellPad ?? GEOM_DFLT.contentPad;
return _geom;
}
_geomKey = density;
const g = {
contentMin: resolveLen('--fs-content-min', GEOM_DFLT.contentMin),
sidebarW: resolveLen('--fs-sidebar-w', GEOM_DFLT.sidebarW),
railW: resolveLen('--fs-rail-w', GEOM_DFLT.railW)
};
/* Plausibility, one comparison: the rail is the sidebar collapsed, so 0 < railW < sidebarW holds
* by construction; a hijacked probe reports a foreign width and fails it. */
const sane = (g.railW > 0 && g.railW < g.sidebarW && g.contentMin > 0);
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);
_geom.contentPad = _shellPad ?? GEOM_DFLT.contentPad;
return _geom;
}
/* The window's width and the column's gutter are read here (from a fitter or from
* `contentWidth()`'s staleness check) and nowhere else; the gutter is measured where it is
* applied. */
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;
/* memoised on what moves the gutter: the width (a media query re-pads the column below 767px) and
* the density */
const key = (document.documentElement.getAttribute('data-density') || '') + '|' + _shellOuter +
'|' + (document.body ? document.body.getAttribute('data-page') || '' : '');
if (_padAt === key) 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;
}
}
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);
return Math.max(0, state.outerW - cut - g.contentPad);
}
function fitShell() {
const root = document.documentElement;
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,
* toggleAttribute on a present attribute queues none, and fitShell runs on every mutation batch */
root.toggleAttribute('data-narrow', content < g.contentMin);
}
function fitChrome() {
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. */
const topBar = prefs.isTopLayout();
/* The bar may not change height while it is being measured, in either direction: with the three
* classes off and fs-dense1/2 stripped, the bar's own box answers any height its content needs, so
* a min-height pin holds it for the pass (230 -> 123 px inside one pass, 107 px of growth). */
const pinned = Math.round(bar.getBoundingClientRect().height);
if (pinned > 0) { bar.style.minHeight = pinned + 'px'; bar.style.height = pinned + 'px'; }
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), so it is
* measured, not decided by a `max-width: 1199px` breakpoint. */
if (topBar && !stripFitsOneRow(menu)) {
/* first step before stacking: collapse the poll pill (~90px) to an icon square and re-measure */
bar.classList.add(CLASS_IND_COMPACT);
fitTabStrips();
if (!stripFitsOneRow(menu)) {
bar.classList.add('fs-bar-stack');
fitTabStrips();
}
}
if (topBar || document.documentElement.hasAttribute('data-narrow'))
fitCluster(bar, menu);
if (pinned > 0) bar.style.minHeight = bar.style.height = '';
publishBarHeight(bar);
}
/* ---- how tall the bar actually is, for whoever sticks underneath it ----
* A data table's header row sticks (theme/30-tables.css) and must clear the sticky bar;
* `--fs-bar-h` is only the bar's designed height, so the real one is measured here. */
let _barH = 0;
function publishBarHeight(bar) {
const root = document.documentElement;
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 three siblings (indicators, Search, Log out), which flexbox wraps one at a time:
* on a narrow bar Log out alone drops to a second row. */
function fitCluster(bar, menu) {
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 offsetTop: align-items:center over children of different
* heights makes it differ even on one row. The menu is excluded, as on a row of its own it is not
* competing. */
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;
}
/* Null prototype: `ICONS[key]` is keyed by a menu node name a third-party package picks, and on a
* plain literal a node called `constructor` or `__proto__` resolves out of Object.prototype. */
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);
}
/* Is a section's panel a popup (flyout / bar dropdown) rather than an unfolded accordion? `.open`
* means two things: in the expanded sidebar an unfolded accordion section (several may be open); in
* the rail or the bar a popup panel (exactly one open). Reads the stylesheet's own input,
* `data-narrow`, stamped by fitShell() before the menu renders; never a viewport query. */
function flyoutMode() {
return prefs.currentRail() || prefs.isTopLayout() ||
document.documentElement.hasAttribute('data-narrow');
}
/* The trigger — a bare <a>. setOpen() below keeps `.open` and aria-expanded in step. */
const TRIGGER = ':scope > a';
const OPEN_LI = '#topmenu > li.open';
/* ---- disclosure primitives ----
* A section header is a W3C-APG disclosure control: an <a role="button"> owning a panel it shows
* and hides. */
/* Every open and close goes through here so `.open` and aria-expanded cannot disagree. */
function setOpen(li, on) {
li.classList.toggle('open', on);
li.querySelector(TRIGGER)?.setAttribute('aria-expanded', on ? 'true' : 'false');
}
/* ---- dropdown edge-clamp (the bar, at every width) ----
* A bar panel hangs off its own item (theme/20-shell.css), so an item near the inline-end edge
* overflows the viewport: the panel is nudged back inside. */
/* 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`. */
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;
if (li._fsClampRaf) window.cancelAnimationFrame(li._fsClampRaf);
li._fsClampRaf = window.requestAnimationFrame(() => {
li._fsClampRaf = 0;
menu.style.insetInlineStart = ''; /* back to the CSS anchor before measuring */
const r = menu.getBoundingClientRect();
if (!r.width) return; /* still hidden — nothing to place */
/* RTL anchors at right:0, so only the left edge can leave (a 220 px panel on a 390 px viewport
* sits at -130 px); both edges use the logical property. */
const rtl = window.getComputedStyle(menu).direction === 'rtl';
const over = rtl ? EDGE_GAP - r.left : r.right - (window.innerWidth - EDGE_GAP);
if (over > 0)
menu.style.insetInlineStart = -Math.min(over,
rtl ? window.innerWidth - EDGE_GAP - r.right : r.left - EDGE_GAP) + 'px';
});
}
function clearClamps() {
document.querySelectorAll('#topmenu ul').forEach((m) => { m.style.insetInlineStart = ''; });
}
function closeFlyouts() {
document.querySelectorAll(OPEN_LI).forEach((o) => setOpen(o, false));
}
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 an opened section would otherwise refold on every tab switch. */
const OPEN_KEY = 'fs-menu-open';
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(node, url, level, parentActive = true) {
const ul = level ? E('ul', {}) : document.querySelector('#topmenu');
const children = ui.menu.getChildren(node);
/* #topmenu is emitted whenever this module loads (header.ut, !blank_page) */
if (children.length === 0)
return E([]);
const idx = (level || 0) + 1;
/* read once per render: the click and pointerenter handlers below re-read at event time */
const flyout = flyoutMode(), auto = prefs.currentAutoCollapse();
children.forEach((child, i) => {
if (!level && child.name === 'logout')
return;
const isActive = parentActive && L.env.dispatchpath[idx] === child.name;
/* only sections carry a panel; a leaf's own children are never rendered */
const submenu = level ? E([]) : renderMainMenu(child, url + '/' + child.name, 1, isActive);
const hasSub = !!submenu.firstElementChild;
const keepOpen = hasSub && !flyout && !auto;
const startOpen = hasSub && !flyout &&
(isActive || (keepOpen && _openSections.has(child.name)));
if (keepOpen && startOpen && !_openSections.has(child.name)) {
_openSections.add(child.name);
saveOpenSections();
}
const chevron = hasSub
? widgets.svgIcon('<path d="M9 6l6 6-6 6"/>', 'fs-chevron')
: '';
/* `active` only paints the item; aria-current is what a screen reader reads, and belongs on the
* leaf alone. */
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);
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' : '',
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": site navigation must not take on the menubar pattern's arrow-key semantics. */
const subId = 'fs-sub-' + i;
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');
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.wireActivate(link, () => link.click());
/* hybrid devices: once a real mouse enters, drop the tap-opened panel so hover is authoritative;
* guarded on pointerType, since a touch tap fires pointerenter ('touch') first */
li.addEventListener('pointerenter', (ev) => {
if (ev.pointerType === 'mouse' && flyoutMode())
closeFlyouts();
clampDropdown(li);
});
}
ul.appendChild(li);
});
return ul;
}
/* modes -> #modemenu; drives renderMainMenu for the active mode */
function renderModeMenu(node) {
/* #modemenu is emitted whenever this module loads (header.ut, !blank_page) */
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;
ul.appendChild(E('li', { 'class': isActive ? 'active' : '' }, [
E('a', { 'href': L.url(child.name) }, [ _(child.title) ])
]));
if (isActive)
renderMainMenu(child, child.name);
});
if (children.length <= 1)
ul.classList.add('single');
if (ul.children.length > 1)
ul.style.display = '';
}
function renderChrome() {
const root = tree.tree();
const modemenu = document.querySelector('#modemenu');
const topmenu = document.querySelector('#topmenu');
const tabmenu = document.querySelector('#tabmenu');
modemenu.innerHTML = ''; modemenu.style.display = 'none'; modemenu.classList.remove('single');
topmenu.innerHTML = '';
tabmenu.innerHTML = ''; tabmenu.style.display = 'none';
renderModeMenu(root);
const segs = L.env.dispatchpath.slice(0, 3);
const node = segs.length === 3 && tree.nodeForSegs(segs);
if (node)
renderTabMenu(node, segs.join('/'));
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. */
function wireRail() {
/* #fs-rail-toggle is emitted whenever this module loads (header.ut, !blank_page) */
const btn = document.getElementById('fs-rail-toggle');
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();
fit.schedule();
});
sync();
}
/* An indicator pill carries its meaning as prose ("Unsaved Changes: 2") and the collapsed rail is
* 68px wide, so the pill would wrap onto three lines: CSS squares the pill and draws the count
* written here. */
const IND_DOT = '•';
/* 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 text with no role. */
function wireIndicatorKeyboard(el) {
if (el.dataset.fsWired) return;
el.dataset.fsWired = '1';
widgets.wireActivate(el, () => el.click());
}
function wireIndicatorCounts() {
/* #indicators is emitted whenever this module loads (header.ut, !blank_page) */
const box = document.getElementById('indicators');
function stamp() {
box.querySelectorAll('[data-indicator]').forEach((el) => {
const txt = el.textContent || '';
const m = txt.match(/(\d+)\s*$/);
widgets.syncAttr(el, 'data-fs-badge', m ? m[1] : IND_DOT);
/* the rail hides the prose; the tooltip is where it stays reachable by pointer */
widgets.syncAttr(el, 'title', txt);
/* Only a clickable indicator becomes a control: `ui.showIndicator` sets `data-clickable` only when
* it was handed a handler (luci-base ui.js). */
if (el.hasAttribute('data-clickable')) {
widgets.syncAttr(el, 'role', 'button');
widgets.syncAttr(el, 'tabindex', '0');
widgets.syncAttr(el, 'aria-label', txt.trim() || null);
wireIndicatorKeyboard(el);
}
});
}
/* ui.showIndicator sets the label's data on an update and appends the span on the session's first
* change, so childList, subtree and characterData matter; attributes are not observed, so our own
* writes do not loop. */
new MutationObserver(() => {
stamp();
fitChrome();
})
.observe(box, { childList: true, subtree: true, characterData: true });
stamp();
}
function wireMenu() {
/* click-outside and Escape close an open flyout, gated on flyoutMode(): outside it `.open` means
* an unfolded accordion, which must not fold on a click elsewhere (WCAG 2.2 SC 1.4.13). */
document.addEventListener('click', (ev) => {
if (flyoutMode() && !ev.target.closest?.('#topmenu > li.has-sub'))
closeFlyouts();
});
document.addEventListener('keydown', (ev) => {
/* the search palette preventDefaults the Escape it handles; without this guard the same keypress
* also folds a keyboard-opened flyout */
if (ev.defaultPrevented || ev.key !== 'Escape' || !flyoutMode()) return;
const open = document.querySelector(OPEN_LI);
if (!open) return;
const trigger = open.querySelector(TRIGGER);
closeFlyouts();
trigger?.focus();
});
/* 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. */
const modeChanged = () => {
clearClamps();
flyoutMode() ? closeFlyouts() : restoreAccordion();
};
/* data-narrow is the third attribute flyoutMode() reads (fitShell() writes it): narrowing the
* window must also run the transition handler. */
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 the attribute
* observer and the resize clamp-clear cover every transition. */
/* a clamp computed at the old width is wrong at the new one; coalesced via fit.frame (resize fires
* dozens of times a second while dragging) */
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 ours. */
document.addEventListener('fs-autocollapse', (ev) => {
if (ev.detail?.on) {
_openSections.clear();
saveOpenSections();
}
if (!flyoutMode()) restoreAccordion();
});
}
/* The one call the theme's init() makes once the menu tree is loaded; the bar's fit joins fs-fit
* (re-run on every #view resize and mutation) before the first measure. */
function start() {
fit.add(fitChrome);
renderChrome();
wireRail();
wireIndicatorCounts();
}
return baseclass.extend({
renderChrome,
wireMenu,
start,
/* 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 needs it. */
contentWidth() {
/* `_shellOuter` is refreshed only by measureShell(), and fitChrome() steps aside for the whole
* SCROLL_IDLE window (400ms, fs-fit.js) while fit.scrolling() answers yes; a resize that moves the
* offset first would read a stale one. */
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()
});
},
});
@@ -0,0 +1,488 @@
'use strict';
'require baseclass';
/* 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. */
/* Exported, not armed at module eval: only fs-select.js clears the rule this raises, and arming
* without it left every data table invisible. */
function armGate() {
if (!fittersEnabled()) return;
document.documentElement.dataset.fsFit = '1';
}
const _fitters = [];
let _ro = null, _mo = null;
function runAll(list, what) {
for (const fit of list) {
try { fit(); }
catch (e) { console.error('fs-fit: a ' + what + ' threw', e); }
}
}
/* dev switch */
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. */
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. */
function holdFloor(records) {
if (scrolling() || !fittersEnabled()) return;
const host = document.getElementById('view');
if (!host) return; /* the login page has no view */
const boxes = new Set();
host.querySelectorAll(SHRINKS).forEach((el) => {
let box = el, cs = window.getComputedStyle(el);
while (box !== host && cs.display.startsWith('table')) {
box = box.parentElement;
cs = window.getComputedStyle(box);
}
if (box !== host) boxes.add(box);
});
host.querySelectorAll(FLOORED).forEach((box) => boxes.add(box));
let dirty = [...boxes];
if (records && records.length) {
const targets = new Set();
for (const r of records) if (r.target) targets.add(r.target);
dirty = dirty.filter((box) => {
for (const t of targets) if (box.contains(t) || t.contains(box)) return true;
return false;
});
}
if (!dirty.length) return;
const page = scroller() || 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. */
const hs = dirty.map((box) => 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. */
const landed = scrollTop();
if (landed >= at) return;
if (page.scrollHeight >= tall) writeOffset(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. */
const SCROLL_IDLE = 400;
let _deferred = false;
function deferMeasurement() { _deferred = true; }
let _movingUntil = 0;
let _lastOffset = null;
let _sampling = false;
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. */
let _clampedTo = null;
function sawClamp() {
if (scrollTop() === _clampedTo) return true;
_clampedTo = null;
return false;
}
function writeOffset(value) {
const sc = scroller();
if (sc) sc.scrollTop = value; else window.scrollTo(0, value);
_ownWrite = scrollTop();
}
/* Which element scrolls, cached per width: a probe per frame is a forced layout mid-flick, and the
* stylesheet decides it. */
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 (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. */
/* Only a box wholly above the viewport moves the reader when it gives its floor up. */
const floorsAbove = [..._deferredFloors].filter((b) => b.isConnected && b.getBoundingClientRect().bottom <= 0)
.map((b) => [ b, parseFloat(b.style.minHeight) || 0 ]);
_deferredFloors.clear();
const offsetBefore = scrollTop();
holdFloor();
let shrink = 0;
for (const [ b, h ] of floorsAbove) shrink += h - (parseFloat(b.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();
}
/* Read on the next frame, never in the scroll event: a layout forced while WebKit dispatches one
* skips its anchoring adjustment and leaves the scroller marked as queued, so the engine anchors
* nothing for the rest of the document's life (ScrollAnchoringController::
* adjustScrollPositionForAnchoring returns before it clears the mark). Any sticky or fixed box —
* `.th`, `#modal_overlay`, the bar — leaves layout dirty there, so one `scrollY` read is enough. */
const noteScroll = frame(noteMotion);
(function watchMotion() {
const opts = { passive: true, capture: true };
window.addEventListener('scroll', noteScroll, 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 frame(fn) {
let pending = false;
return () => {
if (pending) return;
pending = true;
requestAnimationFrame(() => { pending = false; fn(); });
};
}
const schedule = frame(run);
/* Width only: iOS resizes the viewport height as the URL bar slides away, and twenty height-only
* steps rewrote 1054 class attributes. */
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 (!_ro) _ro = new ResizeObserver(onResize);
_ro.observe(el);
}
/* ---- scroll anchoring, where the engine has none ----
* Safari and iOS before 27 have no `overflow-anchor`, and a tick's growth above the reader moves
* them by all of it. Whether the engine anchors is asked of the platform, never of a browser name;
* where it does, the observer leaves the reader to it. Elsewhere the drift is computed from a
* reference element, never from the offset. */
const ENGINE_ANCHORS = (() => {
try { return CSS.supports('overflow-anchor', 'auto'); }
catch (e) { return true; }
})();
let _rest = null;
let _restAt = null, _restPage = null;
/* Every floored box a tick refilled while the reader scrolled: one tick refills several, and keeping
* one lost the lease table's 172px. */
const _deferredFloors = new Set();
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. */
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('[data-fs-chrome]');
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) => {
for (const cand of document.elementsFromPoint(x, yy))
if (cand !== host && host.contains(cand)) return cand;
return 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.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 === host) keep = null;
return { el, top: el.getBoundingClientRect().top,
sec: keep, secTop: keep ? keep.getBoundingClientRect().top : 0 };
}
let _anchorPending = null;
function anchorEnabled() {
try { return localStorage.getItem('fsAnchor') !== 'off'; }
catch (e) { return true; }
}
/* Floors `holdFloor()` could not clear while the reader scrolled, cleared once they let go: written
* in the frame the floors came off, so no frame is painted with the reader moved. The offset as
* laid out after the clear tells whether the engine already gave it back. */
function settleDeferredFloor(offsetBefore, shrink) {
if (!anchorEnabled() || Date.now() < _userUntil) return;
const seen = scrollTop();
const gap = offsetBefore - shrink - seen;
/* 16: a 32-36 row lease table rounds by 8-12.25px at 390 wide */
if (Math.abs(gap) <= 16) return; /* the engine already gave it back */
/* no cap at the viewport: four leases stacked at 390px give up 1032px, all of it above */
writeOffset(seen + gap);
if (_rest) { _rest.top -= gap; if (_rest.sec) _rest.secTop -= gap; _rest.at = scrollTop(); }
_restAt = scrollTop();
}
function scheduleAnchor(ref) {
if (!ref) return;
if (!anchorEnabled()) return;
if (_anchorPending) return;
_anchorPending = ref;
requestAnimationFrame(() => {
const pending = _anchorPending;
_anchorPending = null;
const wrote = applyAnchor(pending);
rememberRest(wrote);
});
}
function applyAnchor(ref) {
/* Not into a moving page — unless the motion is the clamp this correction is for, which held a
* correct -60px correction unwritten. */
if (scrolling() && !sawClamp()) return;
const at = scrollTop();
if (ref.by != null) {
if (ref.by < 1) return;
writeOffset(at + ref.by);
return true;
}
if (at <= 0) return;
if (!ref.el.isConnected) return;
const drift = ref.el.getBoundingClientRect().top - ref.top;
if (Math.abs(drift) < 1) return; /* nothing needed correcting here */
if (Math.abs(drift) > (window.innerHeight || 0) + 200 + (ref.slack || 0)) return;
writeOffset(at + 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 ref = ENGINE_ANCHORS ? null : anchorFor();
const wasScrolling = scrolling();
/* every floored box a record sits in, since `dom.content()` refills a node inside the pinned box
* on 1 of 12 nodes, and one batch refills several: only the first lost the lease table's.
* Only a box already floored before run(): one run() floors now has nothing to settle later. */
if (wasScrolling)
for (const m of records) {
const b = m.type === 'childList' && m.target.closest(FLOORED);
if (b && (parseFloat(b.style.minHeight) || 0) > 0) _deferredFloors.add(b);
}
run(records);
if (!wasScrolling) _deferredFloors.clear();
/* `#view` itself emptied and refilled is a page swap: a reference measured mid-swap is read 431px
* out and written over a correct restoreScroll(). */
let gone = 0, came = 0;
for (const m of records)
if (m.target === viewHost) { gone += m.removedNodes.length; came += m.addedNodes.length; }
if (gone && came) {
forgetRest();
return;
}
scheduleAnchor(ref);
});
for (const host of hosts) {
_mo.observe(host, { childList: true, subtree: true });
watch(host);
}
const _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. 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. */
const _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) {
_fitters.push(fit);
observeContent();
try { fit(); }
catch (e) { console.error('fs-fit: a fitter threw on registration', e); }
},
scrolling,
deferMeasurement,
forgetRest,
armGate,
schedule,
frame,
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,133 @@
'use strict';
'require baseclass';
/* The menu tree as a routing table: path <-> node, with LuCI's alias/firstchild resolution. Pure
* lookup, no DOM. */
/* 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'] */
function segsFromPath(pathname) {
const base = L.env.scriptname || '';
if (base && !pathname.startsWith(base))
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 (4 `alias`, 3 `firstchild`); the client resolves them as the
* dispatcher does. */
/* 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). */
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. */
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 ----
* See apply_tree_acls() (dispatcher.uc:442). */
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. */
function viewClassFor(node) {
if (!node || !node.action || node.satisfied === false)
return null;
if (node.action.type === 'view')
return 'view.' + String(node.action.path).replace(/\//g, '.');
if (node.action.type === 'template' && node.action.path === 'admin_status/index')
return 'view.status.index';
return null;
}
/* The node the current full load landed on, i.e. what L.env.dispatchpath points at. */
function currentNode() {
return nodeForSegs(L.env.dispatchpath || []);
}
return baseclass.extend({
setTree,
tree: () => _tree,
segsFromPath,
nodeForSegs,
currentNode,
resolveSegs,
readonlyForSegs,
viewClassFor
});
@@ -0,0 +1,320 @@
'use strict';
'require baseclass';
'require dom';
'require network';
'require fs-fit as fit';
'require fs-widgets as widgets';
/* Overview layout and meters: re-arranges the stock System / Memory / Storage sections into a grid,
* marks the meters up (aria, and data-fs-level warn/danger at the FS_METER_* thresholds), turns
* card headings into disclosure buttons and draws progressively through a wrapped poll_status.
* Content and data stay luci-mod-status's. */
/* the data-page value this module acts on; the loader's PAGE_MODULES map must name the same page */
const PAGE = 'admin-status-overview';
const onPage = () => document.body.getAttribute('data-page') === PAGE;
const ROLES = { [_('System')]: 'sys', [_('Memory')]: 'mem', [_('Storage')]: 'sto' };
/* 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. */
const FS_METER_WARN = 80;
const FS_METER_DANGER = 92;
/* Row labels this theme has seen misread, matched like `ROLES`: `_(msgid)` against
* luci-mod-status's own msgids, no msgctxt. */
const FS_METER_INVERTED = new Set([ _('Total Available'), _('Swap free') ]);
const FS_METER_NEUTRAL = new Set([ _('Buffered'), _('Cached') ]);
/* The meter's name, if the markup already states one — never invented: a `.cbi-value` row's own
* label or a key/value table row's first cell. */
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`: `'X / Y (Z%)'` or a bare `'97%'`, both end-anchored so
* a stray "%" earlier in a localised reading is ignored. */
function parseMeterPercent(title) {
if (title == null) return null;
const m = (/\((-?\d+)%\)\s*$/).exec(title) || (/(-?\d+)%\s*$/).exec(title);
return m ? parseInt(m[1], 10) : null;
}
/* The attribute work for one `.cbi-progressbar`: Overview's 20_memory.js, 25_storage.js and
* 30_network.js declare and call their own local `progressbar()`, so the theme's
* window.progressbar never stamps them. */
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);
widgets.syncAttr(pg, 'role', 'progressbar');
widgets.syncAttr(pg, 'aria-valuemin', '0');
widgets.syncAttr(pg, 'aria-valuemax', '100');
widgets.syncAttr(pg, 'aria-valuenow', String(level));
widgets.syncAttr(pg, 'aria-valuetext', title);
const label = findProgressbarLabel(pg);
const name = label ? label.textContent.trim() : '';
widgets.syncAttr(pg, 'aria-label', label ? name : null);
/* Polarity: an unrecognised bar keeps the plain fill-based rule, on purpose: a wrong red is worse
* than a missing colour. */
let dataLevel = null;
if (!FS_METER_NEUTRAL.has(name)) {
const v = FS_METER_INVERTED.has(name) ? 100 - level : level;
dataLevel = v >= FS_METER_DANGER ? 'danger' : (v >= FS_METER_WARN ? 'warn' : null);
}
widgets.syncAttr(pg, 'data-fs-level', dataLevel);
}
function annotateMeters(root) {
root.querySelectorAll('.cbi-progressbar[title]').forEach(annotateMeter);
}
function headerEl(sec) {
return sec.querySelector('.cbi-title h3, :scope > h3');
}
function sectionTitle(sec) {
const h = headerEl(sec);
if (!h) return '';
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 ----
* On 25.12 Status -> Overview carries 14 `[data-clickable]` bare <span>s (the topbar poll pill plus
* one Hide/Show pill per card), clickable by mouse only. */
function pillExpanded(label) {
return label.getAttribute('data-style') !== 'active';
}
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 it wired
* touches only aria-expanded. */
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++);
widgets.syncAttr(h, 'role', 'button');
widgets.syncAttr(h, 'tabindex', '0');
widgets.syncAttr(h, 'aria-controls', panel ? panel.id : null);
widgets.syncAttr(h, 'aria-expanded', pillExpanded(label) ? 'true' : 'false');
widgets.syncAttr(label, 'aria-hidden', 'true');
if (h.dataset.fsWired) return;
h.dataset.fsWired = '1';
h.addEventListener('click', (ev) => {
if (ev.target.closest?.('[data-indicator="poll-status"]') !== label) label.click();
widgets.syncAttr(h, 'aria-expanded', pillExpanded(label) ? 'true' : 'false');
});
widgets.wireActivate(h, () => 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. */
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: the stylesheet ellipses it at one line,
* and the name cannot be guessed from the rest of the card. */
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() {
if (!onPage()) {
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, so the reading and colour those bars need are stamped here. */
annotateMeters(view);
tidyCards(view);
/* Fast path: the poll lands here on every tick, and the stock poll never rebuilds the .cbi-section
* wrappers, so the grid survives. Deliberately not a disconnect(). */
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 #maincontent and re-run arrange(),
* coalesced. #maincontent outlives every SPA swap, so one observer is attached once. */
let _observer = null;
function stopWatch() {
if (_observer) _observer.disconnect();
_observer = 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, so an observer on it would watch a detached node. */
const root = document.getElementById('maincontent');
arrange();
if (_observer || !view)
return;
_observer = new MutationObserver(fit.frame(arrange));
_observer.observe(root, { childList: true, subtree: true });
}
/* Called by the loader on every arrival at the overview, full load or SPA navigation;
* patchOverview() and watch() are idempotent. */
function wire() {
if (!onPage()) {
stopWatch();
return;
}
patchOverview();
watch();
}
/* ---- progressive paint ----
* Stock `view.status.index` calls poll_status(first_load=true), which Promise.all's over every
* include's load(): #view stays empty until the slowest is in (182 ms, most sections). */
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;
let _inflightFor = null;
function pollProgressive(includes, containers, first_load) {
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);
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, so a rejection would surface as an unhandled one; `run`
* rejects for one ordinary reason: flushCache() on an expired session. */
_inflight.catch(() => {});
/* first load resolves now, so index.render() returns its tree 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 wire(), not at module eval, which would pull the whole status view in on
* every page. */
function patchOverview() {
/* `window.L`, never the bare `L` this factory was handed: index.js loads its own includes with the
* `L` require() passes the loaded module (the two-L trap). */
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: a stock
* include calls them bare from its own render(), so they must exist before the view class does. */
return baseclass.extend({
/* called by menu-footstrap-common's loader on every data-page stamp that maps here */
wire,
});
@@ -0,0 +1,163 @@
'use strict';
'require baseclass';
'require fs-fit as fit';
/* The live appliers every page needs (dark mode, layout, auto-collapse, rail) and the localStorage
* wrappers under them. The other axes, the snapshot and the resets live in fs-axes.js. */
/* A browser can refuse storage (blocked cookies, a partitioned WebView, a full quota) and then
* reads or writes throw: every access is wrapped, and an unreadable preference is a default, not
* an error. */
const _mem = new Map();
let _lsBroken = false;
function storageBroken() { return _lsBroken; }
function lsGet(k) { if (_mem.has(k)) return _mem.get(k); try { return localStorage.getItem(k); } catch (e) { _lsBroken = true; return null; } }
function lsSet(k, v) { try { localStorage.setItem(k, v); _mem.delete(k); } catch (e) { _lsBroken = true; _mem.set(k, String(v)); } }
function lsDel(k) { _mem.delete(k); try { localStorage.removeItem(k); } catch (e) { _lsBroken = true; } }
/* A stored JSON array, or [] — the shape of the two remembered lists (the search palette's recent
* paths, the menu's open sections). */
function lsGetArr(k) {
try {
const a = JSON.parse(lsGet(k) || '[]');
return Array.isArray(a) ? a : [];
} catch (e) { return []; }
}
const RECENT_KEY = 'fs-recent';
const RECENT_MAX = 8;
function recentPaths() {
return lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string').slice(0, RECENT_MAX);
}
/* the router-wide defaults the server stamped (head.ut), read at runtime so current*() reports the
* effective default when this browser has no localStorage value */
function sd(k) { return (window.__fsSD || {})[k]; }
function setSD(field, val) { (window.__fsSD = window.__fsSD || {})[field] = val; }
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 === null) return modeDefault();
return 'auto';
}
/* Dark mode is announced in three dialects because apps sniff for it: `data-theme="dark"`
* (luci-app-justclash keys 21 rules off it), Bootstrap's `data-bs-theme` and `data-darkmode`. */
function stampDark(root, dark) {
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)');
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 */
if (val === 'auto') lsSet('fs-darkmode', 'auto');
else lsSet('fs-darkmode', val === 'dark' ? 'true' : 'false');
stampDark(root, intendedDark());
}
/* Third parties write the dialects too: luci-app-openclash stamps `data-darkmode="true"` onto
* :root from seven templates, so the guard re-asserts the stamp. */
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);
};
check();
new MutationObserver(check).observe(root, {
attributes: true,
attributeFilter: [ 'data-darkmode', 'data-theme', 'data-bs-theme' ]
});
}
/* `<meta name="theme-color">` colours a mobile address bar and an installed PWA's title bar;
* head.ut ships the default palette's page colour and this tracks the live one. */
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() {
paintThemeColor();
new MutationObserver(fit.frame(paintThemeColor)).observe(document.documentElement,
{ attributes: true, attributeFilter: [ 'style', 'class', 'data-darkmode', 'data-palette', 'data-wallpaper' ] });
}
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 */
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. */
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 the open ones are folded. */
document.dispatchEvent(new CustomEvent('fs-autocollapse', { detail: { on } }));
}
/* The sidebar rail's collapsed flag, flipped by fs-chrome.js: a transient chrome collapse, not an
* axis, so snapshotAxes() and resetToSaved() skip it. */
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';
}
return baseclass.extend({
/* the storage wrappers and the router-default reader: fs-axes.js is built on these */
lsGet, lsSet, lsDel, lsGetArr, RECENT_KEY, RECENT_MAX, recentPaths, storageBroken, sd, setSD,
currentMode, applyMode, modeDefault, guardDarkStamp, watchThemeColor,
currentLayout, applyLayout, isTopLayout,
currentAutoCollapse, applyAutoCollapse, autoCollapseDefault,
currentRail, applyRail
});
@@ -0,0 +1,812 @@
'use strict';
'require baseclass';
'require ui';
'require rpc';
'require fs-menutree as tree';
'require fs-chrome as chrome';
'require fs-sheets as sheets';
'require fs-fit as fit';
/* The SPA client router. Re-instantiates a `view` node in place instead of a full reload (54 of 74
* menu leaves on the dev router); anything else, a modified click or any error falls through to a
* normal navigation. A revisit runs `new v.constructor()` on the cached singleton. Path->node:
* fs-menutree.js; poisoned documents: fs-sheets.js. */
/* ---- stray-interval teardown ----
* A full load kills every window.setInterval the outgoing page set; SPA nav does not, so view-set
* ids are tracked and cleared per navigation. Hooked at module eval. */
const _viewIntervals = (window.__fsViewIntervals || (window.__fsViewIntervals = new Map()));
const _si = window.setInterval, _ci = window.clearInterval;
window.setInterval = function (fn, ms, ...rest) {
const id = _si.apply(window, arguments);
_viewIntervals.set(id, { fn, ms, rest, live: id });
return id;
};
window.clearInterval = function (id) {
const spec = _viewIntervals.get(id);
_viewIntervals.delete(id);
if (spec) return (spec.live == null) ? undefined : _ci.call(window, spec.live);
return _ci.apply(window, arguments);
};
/* A hidden tab must not keep calling the router: view timers are disarmed on hide and re-armed on
* show. A paused timer stays in the registry, so the navigation sweep still sees it. */
let _pollWasActive = false;
document.addEventListener('visibilitychange', () => {
try {
if (document.hidden) {
/* remembered so a manual pause (the "Refreshing" indicator) is not undone on show */
_pollWasActive = !!(L.Poll && L.Poll.active());
if (_pollWasActive) L.Poll.stop();
for (const spec of _viewIntervals.values()) {
if (spec.live == null) continue;
_ci.call(window, spec.live);
spec.live = null;
}
}
else {
for (const spec of _viewIntervals.values()) {
if (spec.live != null) continue;
spec.live = _si.call(window, spec.fn, spec.ms, ...spec.rest);
}
/* never resume a poll the session can no longer answer (luci-base stopped it behind its modal) */
if (_pollWasActive && !_expired) L.Poll.start();
}
} catch (e) { console.error('footstrap: the hidden-tab pause/resume threw', e); }
});
function clearViewIntervals() {
/* Map, not Set: the key is the timer id and the value is what it takes to re-arm it */
for (const id of [ ..._viewIntervals.keys() ]) window.clearInterval(id);
}
function hidePollIndicatorIfEmpty() {
if (!(L.Poll && L.Poll.queue && L.Poll.queue.length === 0)) return;
ui.hideIndicator('poll-status');
}
/* ---- uci cache teardown ----
* uci.load() skips packages in its document-scoped cache, so a view reading it as an existence
* check rendered "no config found" on the second SPA visit. Navigation drops the cache with
* unload() via `window.L.uci` (the two-L trap); never rejects. */
function flushUciCache() {
const uci = window.L.uci;
if (!uci || typeof uci.unload !== 'function') return null;
if (!uci.state || typeof uci.state.values !== 'object' || typeof uci.loaded !== 'object') {
if (!_uciCacheWarned) {
_uciCacheWarned = true;
console.error('footstrap: LuCI.uci keeps its cache somewhere this router does not know, so '
+ 'it is left alone. An app that reads uci.load()\'s return value as an existence check '
+ 'will report a missing config on the second SPA visit. fs-router.js needs updating for '
+ 'this luci-base.');
}
return null;
}
const names = Object.keys(uci.state.values).concat(Object.keys(uci.loaded));
if (!names.length) return null;
uci.unload(names);
/* …and put back the three packages network.js reads but never reloads, or every consumer sees an
* empty config until F5. The refill costs one uci `get` (136 -> 159 ms median). */
if (!window.L.network) return null;
const refill = [ 'network', 'wireless', 'luci' ].filter((p) => names.indexOf(p) !== -1);
if (!refill.length) return null;
return uci.load(refill).catch((e) => {
console.error('footstrap: reloading uci ' + refill.join(', ') + ' after a navigation failed', e);
});
}
let _uciCacheWarned = false;
/* ---- a dead session ends the document ----
* An expired session gets luci-base's modal, which reloads into the login form; navigating on would
* dismiss it, so the router stops claiming navigations. Signals: a 403 with `X-LuCI-Login-Required:
* yes`, and a rejected `session.access` probe; `access: false` is an ACL answer. Interceptors must
* not throw. */
let _expired = false;
function markExpired() {
if (_expired) return;
_expired = true;
console.warn('footstrap: the LuCI session is gone — every navigation from here is a full load.');
}
function markAlive() {
if (!_expired) return;
_expired = false;
console.warn('footstrap: the LuCI session answers again — client navigation is back on.');
}
function watchSession() {
const req = window.L.Request;
if (req && typeof req.addInterceptor === 'function')
req.addInterceptor((res) => {
try {
if (res && res.status === 403 && res.headers &&
res.headers.get('X-LuCI-Login-Required') === 'yes')
markExpired();
}
catch (e) { /* see above: an interceptor that throws rejects the caller's request */ }
});
if (rpc && typeof rpc.addInterceptor === 'function')
rpc.addInterceptor((msg, r) => {
try {
if (!r || r.object !== 'session' || r.method !== 'access') return;
if (!msg || msg.jsonrpc !== '2.0') return;
if (msg.error && msg.error.code && msg.error.message) { markExpired(); return; }
if (Array.isArray(msg.result) && msg.result[1] && msg.result[1].access === true)
markAlive();
}
catch (e) { /* ditto */ }
});
}
/* ---- throw an element away the way luci-base does ----
* Only `dom.content()` deletes a `dom.registry` entry; a plain `remove()` keeps the element
* reachable (83 entries after lap one and after lap three). The node itself is never reaped, so it
* is first moved into a detached container. */
function discard(el) {
const bin = document.createElement('div');
bin.appendChild(el);
window.L.dom.content(bin, null);
}
/* ---- a page that parks nodes on <body> makes the next navigation a full load ----
* Some apps append nodes to document.body on every visit (one leaves 7). The recorder is
* header.ut's first <body> <script>; this file only reads it. */
function strayBodyNode(el) {
if (!el || el.nodeType !== 1) return false;
/* never rendered on their own, wherever a script parks them */
switch (el.nodeName) {
case 'SCRIPT': case 'STYLE': case 'LINK': case 'TEMPLATE': case 'NOSCRIPT': case 'META':
return false;
}
if (el.dataset && el.dataset.fsChrome !== undefined) return false;
if (el.id && el.id.indexOf('fs-') === 0) return false;
for (const c of el.classList) if (c.indexOf('fs-') === 0) return false;
if (el.id === 'modal_overlay') return false;
if (el.classList.contains('cbi-tooltip')) return false;
if (el.nodeName === 'A' && el.hasAttribute('download') && el.style.display === 'none') return false;
return true;
}
function bodyLittered() {
const adds = window.__fsBodyAdds;
if (!adds || !adds.length) return false;
let littered = false;
for (let i = adds.length - 1; i >= 0; i--) {
if (!document.body || adds[i].parentNode !== document.body) { adds.splice(i, 1); continue; }
if (strayBodyNode(adds[i])) littered = true;
}
return littered;
}
let _curPath = window.location.pathname;
let _curSearch = window.location.search;
let _navGen = 0;
let _shown = null;
/* ---- Back must restore the scroll of whichever element is the scroller ----
* The sidebar layout scrolls #maincontent, the top layout the document; the UA restores the
* document's offset before #view is swapped and the clamp takes it to 0. Offsets are not saved by
* replaceState (Safari allows 100 history writes per 30 s): each entry carries a session-prefixed
* id in history.state, the offsets live in a Map. */
const _scrollMem = new Map();
const _scrollSess = Date.now().toString(36) + Math.floor(Math.random() * 1e6).toString(36);
let _histN = 0;
let _curId = null;
const SCROLL_MEM_MAX = 50;
let _pendingRestore = null;
function newEntryId() { return _scrollSess + ':' + (++_histN); }
function adoptEntry() {
const st = history.state;
if (st && st.fsid) { _curId = st.fsid; return; }
_curId = newEntryId();
try { history.replaceState(Object.assign({}, st, { fsid: _curId }), '', window.location.href); } catch (e) {}
}
/* must run before _curId moves on to the incoming entry */
function saveScroll() {
if (!_curId) return;
const sc = document.getElementById('maincontent');
_scrollMem.delete(_curId);
_scrollMem.set(_curId, { win: Math.round(window.scrollY) || 0, main: sc ? sc.scrollTop : 0 });
while (_scrollMem.size > SCROLL_MEM_MAX)
_scrollMem.delete(_scrollMem.keys().next().value);
}
/* Put the scrollers back once the incoming view is tall enough: restoring earlier is clamped to 0
* and reads as "worked". Polls by frame; a newer navigation cancels via the generation. */
function restoreScroll(pos, gen) {
const until = Date.now() + 5000;
/* The user outranks the saved position. A `scroll` is not intent on an axis `pos` does not carry,
* when it is our own write coming back, or when the engine settles (below). */
let cancelled = false, wroteWin = -1, wroteMain = -1;
const stop = () => { cancelled = true; off(); };
/* The engine settling is not input: commitStage() leaves the page shorter than the offset for a
* frame and the clamp fires a `scroll` before any write (overview <- package-manager: 2684
* restored, `y=0` 5 s early). */
const onScroll = (ev) => {
const t = ev.target;
const isWin = (t === document || t === document.documentElement || t === document.body);
if (isWin) {
if (!pos.win) return;
if (Math.round(window.scrollY) === wroteWin) return; /* our own write coming back */
const de = document.documentElement;
const ceiling = Math.max(0, de.scrollHeight - de.clientHeight);
if (ceiling < pos.win && Math.round(window.scrollY) === ceiling) return; /* the page settling */
}
else {
if (!pos.main) return;
if (t !== document.getElementById('maincontent')) return;
if (t.scrollTop === wroteMain) return; /* our own write coming back */
const ceiling = Math.max(0, t.scrollHeight - t.clientHeight);
if (ceiling < pos.main && t.scrollTop === ceiling) return; /* the page settling */
}
stop();
};
/* the keys that scroll, and only those: typing in a field must not cancel anything */
const SCROLL_KEYS = new Set([ 'PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowDown', ' ' ]);
const onKey = (ev) => { if (SCROLL_KEYS.has(ev.key)) stop(); };
const opts = { passive: true, capture: true };
function off() {
window.removeEventListener('wheel', stop, opts);
window.removeEventListener('touchstart', stop, opts);
window.removeEventListener('keydown', onKey, opts);
window.removeEventListener('scroll', onScroll, opts);
}
window.addEventListener('wheel', stop, opts);
window.addEventListener('touchstart', stop, opts);
window.addEventListener('keydown', onKey, opts);
window.addEventListener('scroll', onScroll, opts);
(function tick() {
if (cancelled) return;
if (gen !== _navGen || Date.now() > until) { off(); return; }
const de = document.documentElement;
const sc = document.getElementById('maincontent');
let pending = false;
if (pos.main) {
if (sc && sc.scrollHeight - sc.clientHeight >= pos.main) { wroteMain = pos.main; sc.scrollTop = pos.main; }
else pending = true;
}
if (pos.win) {
if (de.scrollHeight - de.clientHeight >= pos.win) { wroteWin = pos.win; window.scrollTo(0, pos.win); }
else pending = true;
}
if (pending) requestAnimationFrame(tick);
else off();
})();
}
/* ---- the host half of "<host> | <page>" is read once ----
* head.ut stamps it; re-deriving it from document.title would let a page that renames the tab
* become the host for every later page. */
let _titleHost = '';
function moduleUrl(className) {
const v = L.env.resource_version ? ('?v=' + L.env.resource_version) : '';
return (L.env.base_url || '') + '/' + className.replace(/\./g, '/') + '.js' + v;
}
/* ---- link prefetch: warm the module cache for a page the user is about to open ----
* A plain fetch(), not require(): a view's __init__ is its render. Transitive, which is most of the
* win: warming the view class alone leaves its `require` pragmas a round trip behind (418 -> 296 ms
* on a first visit at 120 ms RTT). The scan is not line-anchored: shipped files carry every pragma
* on one line. */
const PRAGMA_HEAD = 2000;
const PREFETCH_DEPTH = 3;
function pragmaDeps(src) {
const re = /(['"])require[ \t]+([^'"]+?)\1/g;
const head = src.slice(0, PRAGMA_HEAD);
const out = [];
let m;
while ((m = re.exec(head)))
out.push(m[2].split(/[ \t]+as[ \t]+/)[0]);
return out;
}
/* Only dotted class names are prefetched: a name with no dot is a virtual class with no file (a
* 404) or a flat library already loaded (a seven-page walk fetched 10 files, every one nested). */
const _seen = new Set();
const _prefetched = new Set();
const _warming = new Map();
const _committed = new Set();
function warmClass(name, depth, root) {
if (_prefetched.has(name)) return;
_prefetched.add(name);
if (name.indexOf('.') < 0 || _seen.has(name)) return;
const req = fetch(moduleUrl(name), { credentials: 'same-origin' });
const body = req.then((res) => (res.ok ? res.text() : '')).catch(() => '');
_warming.set(name, body.then(() => {}));
if (depth < PREFETCH_DEPTH)
body.then((src) => {
if (_committed.has(root)) return;
for (const d of pragmaDeps(src)) warmClass(d, depth + 1, root);
});
}
function prefetchSegs(segs) {
if (!Array.isArray(segs) || !segs.length) return;
const res = tree.resolveSegs(segs);
const className = tree.viewClassFor(res && res.node);
if (className) warmClass(className, 0, className);
}
/* Capped: a wedged prefetch must never wedge a navigation. */
const WARM_WAIT_MS = 5000;
function warmedThen(className) {
_committed.add(className);
const body = _warming.get(className);
if (!body) return Promise.resolve();
let t = 0;
return Promise.race([ body, new Promise((r) => { t = window.setTimeout(r, WARM_WAIT_MS); }) ])
.finally(() => window.clearTimeout(t));
}
function seed() {
_shown = (L.env.dispatchpath || []).slice();
const here = tree.viewClassFor(tree.currentNode());
if (here)
_seen.add(here);
_titleHost = (document.title.split('|')[0] || '').trim(); /* before any view can rename the tab — see there */
adoptEntry();
const curPage = document.body ? (document.body.getAttribute('data-page') || '') : '';
/* The document's own first render is the first link in the chain: a LuCI view chain resolves
* `#view` at paint time and would paint into the newer stage (at 350 ms latency a click 150 ms
* after DOMContentLoaded ended on the new URL with the old page painted over it). */
const vp = document.getElementById('view');
if (vp) {
vp.setAttribute('data-page', curPage);
_inflight = renderedIn(vp).catch(() => {});
}
}
/* ---- the incoming page is rendered off screen and swapped in when ready ----
* Rendering into the live `#view` shows an empty page while module and data load (1800 ms on a
* first visit at 600 ms latency). The view writes into a hidden stage, and renders are serialized:
* a chain paints into whatever `#view` is first at paint time. */
const RENDER_TIMEOUT = 15000;
let _inflight = Promise.resolve();
/* A fresh `<div id="view">` in a hidden wrapper as the first child of `.fs-content`
* (getElementById returns the first match). Hidden but laid out (`visibility: hidden; height: 0;
* overflow: clip`, never `display: none`): views size themselves from the element they render
* into. */
function stageView(contentHost) {
const wrapper = document.createElement('div');
wrapper.className = 'fs-staging';
const view = document.createElement('div');
view.id = 'view';
wrapper.appendChild(view);
/* first in tree order, or getElementById() would keep answering with the live one */
contentHost.insertBefore(wrapper, contentHost.firstChild);
return { wrapper, view };
}
function renderedIn(view) {
const painted = () => view.querySelector(':scope > :not(.spinning):not(script)') !== null;
if (painted()) return Promise.resolve();
return new Promise((resolve, reject) => {
const timer = window.setTimeout(() => {
finish(() => reject(new Error('the view did not render within ' + RENDER_TIMEOUT + ' ms')));
}, RENDER_TIMEOUT);
const mo = new MutationObserver(() => {
if (painted() || view.childElementCount === 0) finish(resolve);
});
function finish(settle) {
window.clearTimeout(timer);
mo.disconnect();
settle();
}
mo.observe(view, { childList: true });
});
}
function dropStage(stage) {
if (stage.wrapper.parentNode) discard(stage.wrapper);
}
/* Put the staged page on screen: move its children into the live `#view` through `dom.content()`,
* drop the wrapper. The nodes move, not the element: fs-fit's content observer is bound to the
* `#view` that existed at chrome init. No startViewTransition(): WebKit's capture held the main
* thread 2,143 ms vs 213 ms at 390px. */
function commitStage(stage, contentHost) {
sheets.scopeToCurrentPage();
sweepAround(contentHost, stage);
const live = liveView(contentHost, stage);
const page = stage.view.getAttribute('data-page');
if (page != null) {
live.setAttribute('data-page', page);
contentHost.setAttribute('data-page', page);
}
const nodes = Array.from(stage.view.childNodes);
window.L.dom.content(live, nodes);
dropStage(stage);
}
function liveView(contentHost, stage) {
for (const el of contentHost.querySelectorAll(':scope > #view'))
if (el !== stage.view) return el;
const v = document.createElement('div');
v.id = 'view';
contentHost.appendChild(v);
return v;
}
/* Clear what the outgoing page left beside #view: keep tabs, server notices, <noscript> and the
* stage. Only `stage.banners` go (what #maincontent held when staging began): the incoming view's
* own banner lands before the commit (nftables' "Legacy rules detected") and must survive. */
function sweepAround(contentHost, stage) {
Array.from(contentHost.children).forEach((c) => {
if (c.id !== 'view' && c.id !== 'tabmenu' && !c.classList.contains('fs-staging') &&
!c.classList.contains('alert-message') && c.nodeName !== 'NOSCRIPT')
discard(c);
});
stage.banners.forEach((b) => { if (b.parentNode) discard(b); });
}
/* ---- a slow navigation is a navigation ----
* A cold route looks like a click that did nothing. A hairline shows when the navigation outlives
* `PROGRESS_DELAY`; below it a bar flashes on every warm click. */
const PROGRESS_DELAY = 150;
let _progressPending = 0;
let _progressTimer = 0;
let _fadeTimer = 0;
function progressBar() {
let bar = document.getElementById('fs-nav-progress');
if (!bar) {
bar = document.createElement('div');
bar.id = 'fs-nav-progress';
bar.setAttribute('aria-hidden', 'true');
document.body.insertBefore(bar, document.body.firstChild);
}
return bar;
}
function progressStart() {
_progressPending++;
window.clearTimeout(_progressTimer);
_progressTimer = window.setTimeout(() => {
const bar = progressBar();
if (bar.dataset.state === 'done') { delete bar.dataset.state; getComputedStyle(bar).transform; }
bar.dataset.state = 'active';
}, PROGRESS_DELAY);
}
function progressEnd() {
if (--_progressPending > 0) return;
_progressPending = 0;
window.clearTimeout(_progressTimer);
const bar = progressBar();
if (bar.dataset.state !== 'active') return;
bar.dataset.state = 'done';
window.clearTimeout(_fadeTimer);
_fadeTimer = window.setTimeout(() => {
if (bar.dataset.state === 'done') delete bar.dataset.state;
}, 300);
}
function navigate(pathname, push, kbd) {
const segs = tree.segsFromPath(pathname);
if (!segs) return false;
if (sheets.documentPoisoned()) return false;
if (bodyLittered()) return false;
/* an extension's lodash/underscore replaced window._: LuCI's own `_('x').format()` then throws on every view; a full load restores it */
if (typeof _ !== 'function' || typeof _('') !== 'string') return false;
if (_expired) return false;
/* `segs` is what the user clicked, `rsegs` the leaf it resolves to (they differ for an
* alias/firstchild link); a full load keeps URL and pathinfo as requested but the rest resolved */
const res = tree.resolveSegs(segs);
const node = res && res.node;
const className = tree.viewClassFor(node);
if (!className)
return false;
/* A page whose stylesheet only the server can emit (a menu.d node's `"css"`) is not ours to swap
* into: a click would show it without the app's CSS. Decline, as the poisoned-document bail does. */
if (typeof node.css === 'string' && node.css !== '' && !sheets.documentCarries(node.css))
return false;
const rsegs = res.segs;
/* declined before the first write below, or the refusal would still supersede the navigation in
* flight */
const contentHost = document.querySelector('.fs-content');
if (!contentHost) return false;
/* from here on the navigation is committed */
const gen = ++_navGen;
const restoreTo = _pendingRestore;
_pendingRestore = null;
_curPath = pathname; /* what is on screen from now on — read by the popstate handler */
_curSearch = '';
/* Run every registered navigation callback with the resolved segments (before L.env is
* re-pointed). A throwing registrant is logged and isolated. */
for (const fn of _navCbs) {
try { fn(rsegs); }
catch (e) { console.error('footstrap: a navigation callback threw', e); }
}
ui.hideModal();
L.env.requestpath = rsegs.slice();
L.env.dispatchpath = rsegs.slice();
L.env.pathinfo = '/' + segs.join('/');
/* `readonly` is load-bearing: luci.js's hasViewPermission() is `!env.nodespec.readonly`; it comes
* from the whole path (readonlyForSegs()) */
L.env.nodespec = { satisfied: true, action: node.action, title: node.title,
depends: node.depends, readonly: tree.readonlyForSegs(rsegs) };
/* re-navigating to the page on screen replaces its entry: a duplicate makes Back do nothing */
if (push) {
const same = pathname === window.location.pathname;
if (!same) _curId = newEntryId();
history[same ? 'replaceState' : 'pushState']({ fsid: _curId }, '', pathname);
}
const title = node.title ? _(node.title) : '';
document.title = title ? (_titleHost + ' | ' + title) : _titleHost;
const tmain = document.querySelector('.fs-title-main');
if (tmain) tmain.textContent = title || _titleHost;
chrome.renderChrome();
/* A full load starts at the top and so must the swap (the write is at commitStage).
* `fit.forgetRest()` runs for a click and a Back alike: a mutation the outgoing page's poller
* makes during staging must not be read against the old rest. scrollRestoration stays 'auto'. */
fit.forgetRest();
/* ---- what a full load does for a keyboard/screen-reader user ----
* renderChrome() has emptied #topmenu, so the <a> activated with Enter is gone and focus falls
* to <body>. A keyboard activation (ev.detail === 0) moves focus to the skip link; a pointer
* activation or a popstate replay keeps the wrapper focus. preventScroll: the scroll is still
* the outgoing page's. */
const skip = kbd ? document.querySelector('.fs-skip') : null;
const main = skip || document.getElementById('maincontent');
if (main) main.focus({ preventScroll: true });
const live = document.getElementById('fs-nav-status');
if (live) live.textContent = title;
/* Require through `window.L`, not the bare `L` a factory is handed (the two-L trap: "L.itemlist is
* not a function"). On a first visit the require is the render, so only a revisit gets a singleton
* whose __init__ already ran: `_seen` is read before the require resolves. */
const RT = window.L;
const cached = _seen.has(className);
/* Wait for an in-flight prefetch of this class: two requests for one URL do not coalesce. `_seen`
* is marked after the wait, or a superseded navigation would make the next one render twice. */
const previous = _inflight;
let release;
_inflight = new Promise((r) => { release = r; });
progressStart();
Promise.all([ warmedThen(className), previous.catch(() => {}) ]).then(() => {
if (gen !== _navGen) return null;
/* ---- teardown, now that the previous render is finished ----
* Drop the outgoing view's pollers and re-arm the poll loop: LuCI fires an entry only when
* `tick % interval == 0`, so the outgoing tick makes the incoming poller wait up to a full
* `pollinterval` (Wireless drew its station list at 4950 ms against ~360 ms on a full
* load). stop()+start() is upstream's own sequence. */
L.Poll.queue.length = 0;
L.Poll.stop();
clearViewIntervals();
L.Poll.start();
const uciWarm = flushUciCache();
/* Keep <body data-page> in sync with the route: chrome modules read it as the route'S identity,
* never as a CSS scope. Page-scoped CSS keys off the `#view`/`.fs-content` stamps below: keyed off
* body, 33 rules of the Overview sheet matched the outgoing page for the whole staging window
* (1407 ms), growing the document 211px and moving the reader 140px. */
document.body.setAttribute('data-page', rsegs.join('-'));
/* …and hand the new page to fs-sheets, which darkens foreign sheets of other pages so an invasive
* one can stay in the document. Enabling the incoming page's sheets is what the staged render
* needs; disabling the outgoing page's runs at the swap (commitStage), or it would strip an app's
* CSS off content still being read. */
sheets.scopeToCurrentPage(rsegs, _shown);
const mainHost = document.getElementById('maincontent');
const outgoingBanners = mainHost
? Array.from(mainHost.children).filter((c) => c.classList.contains('alert-message')) : [];
const stage = stageView(contentHost);
stage.banners = outgoingBanners;
stage.view.setAttribute('data-page', rsegs.join('-'));
const painted = renderedIn(stage.view);
_seen.add(className);
if (!cached) sheets.attributeTo(rsegs, gen);
return Promise.resolve(uciWarm)
.then(() => RT.require(className))
.finally(() => { if (!cached) sheets.attributeTo(null, gen); })
.then((view) => {
if (!(view instanceof RT.view))
throw new TypeError('Loaded class ' + className + ' is not a view');
if (cached) new view.constructor();
return painted;
})
.then(() => {
if (gen !== _navGen) { dropStage(stage); return; }
commitStage(stage, contentHost);
_shown = rsegs.slice();
const sc = document.getElementById('maincontent');
const at = restoreTo || {};
if (push || !at.win) window.scrollTo(0, 0);
if (sc && (push || !at.main)) sc.scrollTo(0, 0);
/* now, and only now, is there one height to read: the incoming page's */
if (at.win || at.main) restoreScroll(restoreTo, gen);
})
.catch((e) => { dropStage(stage); throw e; });
}).catch((e) => {
console.error('footstrap: SPA nav to ' + className + ' failed, falling back to a full load', e);
if (gen === _navGen) window.location = pathname;
}).then(() => {
progressEnd();
release();
});
return true;
}
/* The same-origin nav URL an event's link points at, or null when it is not ours (new-tab target,
* download, bare #hash, cross-origin, ?query / #hash). navigate() pushes only the pathname, so a
* link with ?query or #hash must full-load. */
function linkUrlFrom(ev) {
const a = ev.target.closest?.('a[href]');
if (!a || (a.target && a.target !== '_self') || a.hasAttribute('download'))
return null;
const raw = a.getAttribute('href');
if (!raw || raw.charAt(0) === '#') return null;
let url;
try { url = new URL(a.href, window.location.href); } catch (e) { return null; }
return (url.origin === window.location.origin && !url.search && !url.hash) ? url : null;
}
function prefetchFrom(ev) {
const url = linkUrlFrom(ev);
if (url) prefetchSegs(tree.segsFromPath(url.pathname));
}
/* ---- a document the router could not have rendered is not one it may navigate away from ----
* A `call`, `cbi` or `function` node, and any `template` other than the Overview, may carry inline
* scripts and timers the interval hook never saw, so the first click away is a full load. A
* wildcard URL resolves to nothing; refusing to wire there would turn the router off on some of the
* most-used pages. */
function bootDocumentIsOurs() {
const node = tree.currentNode();
if (!node || !node.action)
return true; /* unknown to the tree: a wildcard page, where the router is right to run */
return tree.viewClassFor(node) != null;
}
/* ---- the boot contract: the luci-base surfaces this router calls ----
* These were never an API (`L.Poll` is a deprecated alias). Existence is checked once at boot, and
* a missing name turns the router off: the page is the server-dispatched MPA and the console names
* the gone surface. */
const CONTRACT_FNS = [
'L.require',
'L.dom.content',
'L.Poll.start/stop',
'L.Request.addInterceptor',
'rpc.addInterceptor',
'ui.hideModal',
'ui.hideIndicator'
];
function hasFns(path) {
const seg = path.split('.');
const leaf = seg.pop();
let node = { L: window.L, ui: ui, rpc: rpc }[seg.shift()];
for (const k of seg) node = node[k];
return leaf.split('/').every((n) => typeof node[n] === 'function');
}
/* the two surfaces that are not functions */
const CONTRACT_REST = [
/* the L.env keys navigate() re-points, plus the base_url moduleUrl() reads */
[ 'L.env.{base_url,dispatchpath,requestpath,pathinfo,nodespec}', () => {
const env = window.L.env;
return !!env && [ 'base_url', 'dispatchpath', 'requestpath', 'pathinfo', 'nodespec' ]
.every((k) => k in env);
} ],
[ 'L.Poll.queue', () => Array.isArray(window.L.Poll.queue) ]
];
function contractBreaks() {
const gone = (probe) => {
try { return !probe(); }
catch (e) { return true; }
};
return CONTRACT_FNS.filter((path) => gone(() => hasFns(path)))
.concat(CONTRACT_REST.filter(([ , probe ]) => gone(probe)).map(([ name ]) => name));
}
function wireRouter() {
const broken = contractBreaks();
if (broken.length) {
/* the URL, not a repository path: this package also ships inside openwrt/luci, where no `docs/`
* directory exists */
console.error('footstrap: this luci-base has no ' + broken.join(', ') +
' — the client router stays off and every link is a full page load, which is what the ' +
'theme did before it existed. Please report this line: ' +
'https://github.com/VizzleTF/luci-theme-footstrap/blob/main/docs/spa-router.md');
return;
}
if (!bootDocumentIsOurs())
return;
document.addEventListener('click', (ev) => {
if (ev.defaultPrevented || ev.button !== 0 ||
ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)
return;
const url = linkUrlFrom(ev);
if (!url) return;
saveScroll();
if (navigate(url.pathname, true, ev.detail === 0))
ev.preventDefault();
}, false);
document.addEventListener('pointerover', prefetchFrom, { passive: true });
document.addEventListener('focusin', prefetchFrom, { passive: true });
document.addEventListener('pointerdown', prefetchFrom, { passive: true });
window.addEventListener('popstate', () => {
const loc = window.location;
/* A fragment change is not a navigation: Chrome fires `popstate` for a same-document fragment nav,
* and re-running navigate() would wipe the state the click set. A query counts only when it is not
* the one the page booted with. The entry is adopted (its state is null). */
if (loc.pathname === _curPath && (!loc.search || loc.search === _curSearch)) {
adoptEntry();
return;
}
/* an entry carrying a query belongs to a full load: replaying it as a bare path would drop the
* query */
if (loc.search) {
loc.reload();
return;
}
saveScroll();
adoptEntry();
/* handed to navigate(), not started here: while the incoming page renders off screen the outgoing
* one satisfies the scroller height (restored at 386, clamped to 197) */
_pendingRestore = _scrollMem.get(_curId) || null;
if (!navigate(window.location.pathname, false)) {
_pendingRestore = null;
window.location.reload();
}
});
}
/* ---- the poll-status pill must not outlive the poll it reports on ----
* LuCI shows the pill on `poll-start`, flips it to "Paused" on `poll-stop` and never hides it. The
* queue empties through navigate()'s teardown and `L.Poll.remove()`, both reaching `L.Poll.stop()`.
* The hide runs a microtask later, after luci.js's own `poll-stop` listener. */
document.addEventListener('poll-stop', () => {
queueMicrotask(hidePollIndicatorIfEmpty);
});
watchSession();
const _navCbs = [];
function onNavigate(fn) { if (typeof fn === 'function') _navCbs.push(fn); }
return baseclass.extend({
seed,
wire: wireRouter,
onNavigate,
prefetchSegs
});
@@ -0,0 +1,297 @@
'use strict';
'require baseclass';
'require fs-menutree as tree';
'require fs-prefs as prefs';
'require fs-router as router';
/* Find a page by typing its name instead of knowing which section owns it: a loaded router carries
* ~200 menu nodes across 11 sections. */
/* ---- the index ---- */
/* How deep below the mode the walk goes (`depth` starts at 1 on a node that already has two
* segments). */
const MAX_DEPTH = 4;
let _index = null;
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 (header.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(),
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. */
function index() {
if (!_index) _index = buildIndex();
return _index;
}
/* ---- matching ---- */
/* Every whitespace-separated token must hit something: a second word means and. Deliberately not a
* fuzzy subsequence match. */
const HIT_NONE = 99;
function tokenScore(e, tok) {
if (e.t.startsWith(tok)) return 0; /* the title begins with it */
if (e.t.includes(tok)) return 1; /* somewhere in the title */
if (e.n.includes(tok)) return 2; /* the English path segment */
if (e.p.includes(tok)) return 3; /* an ancestor's title */
return HIT_NONE;
}
function search(q, limit) {
const toks = q.toLowerCase().split(/\s+/).filter(Boolean);
if (!toks.length) return [];
const hits = [];
for (const e of index()) {
let sum = 0;
for (const tok of toks) {
const s = tokenScore(e, tok);
if (s === HIT_NONE) { sum = HIT_NONE; break; }
sum += s;
}
if (sum !== HIT_NONE)
hits.push({ e: e, score: sum + (e.depth * 0.1) }); /* ties: 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 path is stored. */
/* The list is written by menu-footstrap-common.js, which is on every page; this module is loaded
* only on open. */
function recentEntries() {
const byPath = new Map(index().map((e) => [ e.path, e ]));
return prefs.recentPaths().map((k) => byPath.get(k)).filter(Boolean);
}
/* ---- 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. */
let _built = null;
function build() {
const SEARCH_PAGES = _('Search pages', 'footstrap');
const btn = document.getElementById('fs-search-btn');
if (!btn) return null;
const ID_SEARCH_LIST = 'fs-search-list';
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,
'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.appendChild(btn.querySelector('svg').cloneNode(true));
const box = E('div', { 'class': 'fs-search-box' }, [
E('div', { 'class': 'fs-search-row' }, [ ico, input ]),
note,
list
]);
/* data-fs-chrome marks a zone-1 root: 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 */
const ov = E('div', {
'id': 'fs-search-ov',
'class': 'fs-search-ov',
'data-fs-chrome': '',
'role': 'dialog',
'aria-modal': 'true',
'aria-label': SEARCH_PAGES,
'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 each keystroke. */
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. */
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(' › ') ]) : ''
]);
a.addEventListener('click', () => close(false));
a.addEventListener('pointermove', () => { if (at !== i) setActive(i); });
list.appendChild(a);
return a;
});
note.textContent = opts.length
? (q ? '' : _('Recently visited', 'footstrap'))
: (q ? _('No pages found', 'footstrap') : _('Start typing to find a page', 'footstrap'));
note.hidden = !note.textContent;
setActive(0);
}
function open() {
if (!ov.hidden) return;
ov.hidden = false;
btn.setAttribute(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. */
ev.preventDefault();
input.focus();
return;
}
});
/* a click on the scrim — anywhere outside the box — closes */
ov.addEventListener('click', (ev) => { if (ev.target === ov) close(); });
/* Back and Forward are navigations no listener above can see, so without this an open palette
* rides along onto the previous page. */
router.onNavigate(() => close(false));
return { open, toggle: () => (ov.hidden ? open() : close()) };
}
/* the one entry point: built on the first gesture, null when the page has no #fs-search-btn */
function palette() {
if (!_built) _built = build();
return _built;
}
return baseclass.extend({
open: () => palette()?.open(),
toggle: () => palette()?.toggle()
});
@@ -0,0 +1,609 @@
'use strict';
'require baseclass';
'require ui';
'require dom';
'require fs-fit as fit';
'require fs-chrome as chrome';
'require fs-widgets as widgets';
/* Three jobs; 2, 3 and the value resync register with fs-fit (measuring and scheduling stay there):
* 1. <select> enhancement: a styled cbi-dropdown beside each plain LuCI <select>, values mirrored
* both ways. It keeps its own observer on body (childList and `disabled`) so it also catches
* selects added later by client CBI, and schedules the scan through fit.frame.
* 2. data-table tagging and a11y roles: `.fs-dt`, adopted markup, data-title captions, ARIA roles.
* 3. table fit decisions: the card/drop/shred ladder, scrollers, the action-column pin.
* The native <select> must remain frameEl.firstChild, because ui.Select.getValue() returns
* `this.node.firstChild.value`; the widget goes after it and shares frameEl's lifecycle. */
function readChoices(sel) {
const choices = {};
for (const o of sel.options) choices[o.value] = o.textContent;
return choices;
}
function choicesKey(sel) {
return Array.from(sel.options, (o) => o.value + '\u0000' + o.textContent).join('\u0001');
}
function teardown(sel) {
if (sel._fsAbort) sel._fsAbort.abort();
sel._fsNode?.remove();
delete sel.dataset.fsSelect;
sel._fsDd = sel._fsNode = sel._fsKey = sel._fsAbort = null;
sel.removeAttribute('aria-hidden');
sel.style.display = '';
}
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 (`sel.value = x`, what ui.Select.setValue() does on every
* dependency pass) sets no content attribute and adds no node, so no MutationRecord wakes
* relevant() and the widget would show the old label while Save reads the new value. So this runs
* from the fitter once per content mutation batch: the cheap half of resync(), a value compare per
* enhanced select. */
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 */
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: [...new Set(Array.from(sel.options, (o) => o.value))],
optional: Object.prototype.hasOwnProperty.call(choices, '')
});
} catch (e) {
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';
const title = sel.closest('.cbi-value')?.querySelector('.cbi-value-title');
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 is `display: none`. Focus only. */
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.after(node);
/* The dropdown re-dispatches its own bubbling `widget-change` on every pick, open/close and
* setValue(), reaching form.js while `sel.value` still holds the old value (fs-appearance's
* selectCtl applied 3x per pick). The form is to hear only ui.Select's own `widget-change`, which
* the `change` we dispatch on `sel` below produces. */
node.addEventListener('widget-change', (ev) => ev.stopPropagation(), { signal: ac.signal });
/* 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 });
sel.addEventListener('change', () => {
if (syncing) return;
if (dd.getValue() !== sel.value)
dd.setValue(sel.value);
}, { signal: ac.signal });
/* A failing validator lands on the hidden <select> (class, data-tooltip, data-tooltip-style), and
* ui.js finds a tooltip through the hovered element's ancestors, so a display:none select never
* shows it: mirror the two attributes onto the widget. The class is not copied: ui.updateTabs
* counts every '.cbi-input-invalid', and a copy counts the field twice. */
const mirrorError = () => [ 'data-tooltip', 'data-tooltip-style' ]
.forEach((a) => widgets.syncAttr(node, a, sel.getAttribute(a)));
mirrorError();
sel.addEventListener('validation-failure', mirrorError, { signal: ac.signal });
sel.addEventListener('validation-success', mirrorError, { signal: ac.signal });
}
/* Tag standalone data tables so the stacking rules key off a static `.fs-dt`, not a live
* `:has(.tr.table-titles)` re-evaluated on every mutation of these polled tables. Not a
* .cbi-section-table: config forms keep their own layout. Polling swaps rows inside a table we
* already claimed, so both passes below are additive. */
const ROOTS = [ '#view', '#modal_overlay' ];
const liveRoots = () => (document.body.classList.contains('modal-overlay-active') ? ROOTS : ROOTS.slice(0, 1));
/* every call passes liveRoots(); folded in here rather than threaded as a parameter */
const inRoots = (sel) => liveRoots().map((r) => `${r} ${sel}`).join(', ');
const foreignTables = () => inRoots(':is(.table, table):not(.cbi-section-table)');
/* The fourth header markup, only a foreign table produces: `<table><tr><th>…` with no `<thead>`
* and none of LuCI's class names. "Every cell in the first row is a `<th>`" is the test. */
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 headOf(t) {
return t.querySelector('.tr.table-titles, .tr.cbi-section-table-titles, thead') || headerRow(t);
}
const titleRow = (head) =>
(head.firstElementChild && head.firstElementChild.tagName === 'TR') ? head.firstElementChild : head;
function tagDataTables() {
document.querySelectorAll(foreignTables()).forEach((t) => {
const head = headOf(t);
if (!head) return;
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 card is written one level down (`.table.fs-stacked .tr`, `.td[data-title]::before`). */
function adoptMarkup(t, head) {
/* decided once at claim time: asked afresh it would answer "already adopted" the moment we
* adopted, so polled bare rows would never be taken */
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');
for (const c of titleRow(head).children) c.classList.add('th');
if (!t.rows) return;
for (const row of t.rows) {
if (head.contains(row)) 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 prints
* `attr(data-title)` above each value (theme/30-tables.css); LuCI's builders fill it and a foreign
* table does not. */
function labelCells(t, head) {
const rows = t.querySelectorAll('.tr, tbody tr');
const titles = [ ...titleRow(head).children ].map((c) => (c.textContent || '').trim());
if (!titles.some(Boolean)) return;
for (const row of rows) {
if (row === head) continue;
const cells = row.children;
if (cells.length && cells[0].hasAttribute('data-title')) continue;
/* column cursor, not the cell index: a cell spanning N columns takes N header slots */
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 sets `display` on `.table`/`.thead`/`.tbody`/`.tfoot`/`.tr`/`.th`/`.td` in over
* twenty places, and any `display` other than table/table-row/table-cell drops the implicit role
* HTML-AAM derives (measured on Overview's key/value cards at 1440 and 390px). So the role is
* written unconditionally, the whole chain every pass: a `cell` with no `row` above it reads worse
* than no roles. */
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' ],
];
function roleTables() {
[ ...TABLE_ROLE_CLASSES, ...TABLE_ROLE_TAGS ].forEach(([ sel, role ]) => {
document.querySelectorAll(inRoots(sel)).forEach((el) => widgets.syncAttr(el, 'role', role));
});
}
/* ---- card-stack a data table that no longer fits ----
* Measuring, scheduling and the observers are fs-fit.js; this file supplies the decision. Measured,
* not @container thresholds: a table of unknowable width works. A config table (.cbi-section-table)
* keeps its @container (960) and is not measured: a widget bakes in the width of the layout it was
* laid out in (firewall zone table: 1747px against a real 1190px). */
const stackables = () => inRoots('.table.fs-dt');
/* "Too cramped to be a table any more": a design judgement, the one threshold taken on trust.
* Stock LuCI cards at a 600px viewport; below 767px .fs-content pads 16px a side, so 568px of
* room. */
const CRAMPED = 568;
/* ---- the remedy ladder: cheapest first, re-measured at every rung ----
* theme/30-tables.css gives a data table `overflow-wrap: break-word`, so a column's min-content is
* an honest floor (`WPA2-PSK/CCMP` measured 144px against a real 93px with `anywhere`). Ladder: 1
* it fits, write nothing; 2 drop the columns the view marked `hide-xs`/`hide-sm`; 3 shred the
* widest breakable column; 4 card it. The second measurement is the guard: one base64 key breaking
* makes the table fit, every column over its share breaking changes nothing. */
function markBreakColumn(t, rows, col) {
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);
}
}
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 ----
* Measuring strips the table's marks first (fs-fit rule 1), laying the page out with a full-width
* table. The poll replaces these tables, so each tick repeated that and at iPhone width threw the
* reader 612px out and back, twice per tick. So the answer is kept on the slot (the section frame)
* and holds while column count (shapeOf) and room (roomFor) hold. */
const _slots = new WeakMap();
function shapeOf(t) {
const head = headOf(t);
return String(head ? titleRow(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 !== -1) markBreakColumn(t, t.querySelectorAll('.tr'), d.breakCol);
}
/* A first cell with no break opportunity (no space, no hyphen: "/dev/mtdblock6") is marked so a carded half-cell can
* widen to it: CSS cannot tell a token from a phrase, and `anywhere` cut it mid-word at 94-106px.
* textContent reads no layout; the class is a function of the text, so a poll's identical copy gets
* the same answer, and an unchanged mark is not rewritten (no mutation for the observers). */
function markTokenCells(t) {
t.querySelectorAll('.tr > .td:first-child').forEach((c) => {
const txt = c.textContent.trim();
const on = !!txt && !(/[\s\u002d\u00ad\u2010-\u2014]/).test(txt);
if (c.classList.contains('fs-td-token') !== on) c.classList.toggle('fs-td-token', on);
});
}
const OPEN_LIST = '.cbi-dropdown[open]';
function fitTables() {
let cw;
document.querySelectorAll(stackables()).forEach((t) => {
markTokenCells(t);
if (fit.scrolling() && t.classList.contains('fs-fitted')) {
if (t._fsBreakCol >= 0) markBreakColumn(t, t.querySelectorAll('.tr'), t._fsBreakCol);
return;
}
const home = t.closest('.cbi-section, .cbi-section-node, #view, #modal_overlay');
const slots = _slots.get(home) || new Map();
const key = t.id ? '#' + t.id : 'at' + Array.from(home.querySelectorAll('.table')).indexOf(t);
const known = slots.get(key);
if (fit.scrolling()) {
if (!known && cw === undefined) cw = chrome.contentWidth();
applyDecision(t, known || { stack: cw < CRAMPED, drop: false, breakCol: -1 });
fit.deferMeasurement();
return;
}
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);
if (known) applyDecision(t, known);
/* an open list is out of flow yet widens `scrollWidth` (75-245px past the room on Wireless'
* Disconnect): the table carded under the pointer. It holds its answer until the list closes. */
if (t.querySelector(OPEN_LIST)) {
t.classList.add('fs-fitted');
return;
}
const room = Math.round(fit.roomFor(t));
if (!(room > 0)) {
t.classList.add('fs-fitted');
return;
}
if (t.scrollWidth === 0) {
if (!t.classList.contains('fs-fitted')) {
t.classList.add('fs-fitted');
fit.schedule();
}
return;
}
/* the slot's answer holds for the same shape in the same room, unless the table outgrew it (a poll
* put longer values in: 1000px in an 810px column). A remedied table cannot be asked the other
* direction (every remedy fits by construction), so one whose row count went down is re-decided. */
const rows = t.querySelectorAll('.tr').length;
const remedied = !!(known && (known.stack || known.drop || known.breakCol !== -1));
const shrank = remedied && rows < known.rows;
if (known && known.room === room && known.shape === shape && !shrank && !fit.overflows(t))
return;
t.classList.remove('fs-stacked', 'fs-drop-xs');
if (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');
if (fit.overflows(t) && !breakWidestColumn(t)) stack = true;
}
t.classList.add('fs-fitted');
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');
}
slots.set(key, {
stack,
drop: t.classList.contains('fs-drop-xs'),
breakCol: (t._fsBreakCol === undefined) ? -1 : t._fsBreakCol,
room,
shape,
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, so a card is unlabelled values; comparison across
* rows is its purpose (WCAG 2.2 SC 1.4.10 excepts two-dimensional scrolling). But `overflow-x:
* auto` computes `overflow-y` to `auto` too, clipping every popup inside, so a header-less table
* that holds controls stacks instead: Network -> Diagnostics needs 338px in 320px. */
const scrollables = () => inRoots('.table:not(.fs-dt):not(.cbi-section-table)');
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 makes scrollers focusable, Chrome since 132 only when they hold no focusable child,
* WebKit not at all, so the markup says it: a tab stop (SC 2.1.1) and a name (SC 4.1.2). A role is
* written only where there is none to lose (`<div class="table">` takes `group`; a real `<table>`
* keeps its own). What is removed is what was written, remembered per element. */
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() {
if (fit.scrolling()) { fit.deferMeasurement(); return; }
document.querySelectorAll(scrollables()).forEach((t) => {
if (t.querySelector(OPEN_LIST)) return; /* see fitTables() */
const was = t.classList.contains('fs-xscroll');
const wasStack = t.classList.contains('fs-rowstack');
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);
/* a table of controls stacks; a table of values scrolls and keeps its shape */
if (over && t.querySelector(HOLDS_CONTROLS)) {
t.classList.add('fs-rowstack');
if (was) unreach(t);
return;
}
if (over) {
t.classList.add('fs-xscroll');
if (!was) 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) pins the widest `td.cbi-section-actions > div`
* as inline width/min-width and its resize handler never drops the pin, so the next measurement can
* only grow: firewall/zones pins 634px (scrolls 256px sideways) against 192px fresh. Drop the pin
* when the room changes, not the mode: 768px and 800px are both carded. */
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'))) {
if (!t.querySelector('.cbi-section-actions')) continue;
/* claim upstream's resize hook: stabilizeActionColumnWidth attaches a `resize` listener once per
* table element (this expando) and never removes it, which leaks under SPA navigation (1 to 31
* listeners over 120 navigations, ~11.8 KB each). Set first, it is never attached. */
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');
});
}
}
function relevant(mutations) {
for (const m of mutations) if (m.target.tagName === 'SELECT') return true;
/* Additions only: a select going away costs nothing to notice. */
return fit.touches(mutations, 'select.cbi-input-select');
}
/* ---- type-ahead: jump to an option by typing its first letters ----
* A native <select> gives this free (picking a country out of 248 entries); enhance() hides it and
* ui.Dropdown.handleKeydown does only Esc/Enter/Space/arrows. One document-level listener for every
* .cbi-dropdown: only while open, printable keys without modifiers, buffer reset after a pause, a
* repeated letter cycles, label first then value. Only highlights (setFocus); 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');
if (!ul) return [];
return [...ul.children].filter((li) =>
li.tagName === 'LI' &&
!li.querySelector('input:not([type="hidden"]):not([type="checkbox"])') &&
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'));
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) {
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);
const inst = dom.findClassInstance(sb);
if (inst) inst.setFocus(sb, hit, true);
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();
fit.armGate();
fit.add(tagDataTables);
fit.add(roleTables);
fit.add(fitTables);
fit.add(fitScrollables);
fit.add(unpinActionColumn);
fit.add(resyncValues);
const scanSoon = fit.frame(scan);
new MutationObserver((mutations) => {
if (relevant(mutations)) scanSoon();
}).observe(document.body, {
childList: true, subtree: true,
/* a `disabled` flip mutates no childList, so watch it for resync()/enhance() */
attributes: true, attributeFilter: [ 'disabled' ]
});
}
});
@@ -0,0 +1,455 @@
'use strict';
'require baseclass';
/* A view's injected CSS is never deleted; a poisoned document is left to a real load. A view's
* <style> outlives its page under SPA nav (filemanager's unlayered `display: none !important` hides
* Save/Reset on every later page). Deleting is one-way: a library importing CSS at module eval never
* re-runs (ACE's 14 KB sheet came back as a black rectangle). A sheet reaching into the widget
* universe the theme styles spends the document: invasiveSheet() is the test (universe read from
* cascade.css, 0.3 ms per nav) and the router falls back to a real load. Exempt: `[data-fs-shell]`
* and anything in `#view`. */
let _themeNames = null;
const NAME_RE = /[.#][A-Za-z_][\w-]*/g;
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;
}
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) {
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;
}
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);
}
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;
}
/* 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`)? Such a part can reach neither another page nor
* our chrome. */
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. */
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;
}
/* true when this sheet can repaint a page that is not its own; an unreadable one (loading, 404,
* cross-origin) is invasive by default, so unknown CSS takes the slow path, never the broken one */
function invasiveSheet(el, universe) {
const sheet = el.sheet;
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) {
for (const p of selectorParts(r.selectorText)) {
if (pinnedToApp(p, names)) continue;
if (inertDeclarations(r, props)) continue;
invasive = true;
return;
}
}
if (r.cssRules) walk(r.cssRules);
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;
}
const VIEW_SHEETS = 'style:not([data-fs-shell]), link[rel~="stylesheet"]:not([data-fs-shell])';
/* Does this sheet outlive the page it arrived with? One inside the view tree dies with the swap. */
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? Whole path, never a suffix: a suffix match would let a third party's `"css": "custom.css"`
* pass on luci-app-adblock's own. No in-tree node sets `css`. */
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 on the next stamp. One we
* could not attribute (a pending @import, insertRule()) still spends the document. */
function documentPoisoned() {
const names = themeNames();
return Array.from(document.querySelectorAll(VIEW_SHEETS)).some(
(el) => outlivesPage(el)
&& (!names || (!_owner.has(el) && !_silenced.has(el) && invasiveSheet(el, names))));
}
/* ---- the fence: the chrome is ours, so make a foreign rule unable to match it ----
* Re-hosting settles specificity but not `!important` (the pure-CSS answer is ~550 flags).
* Appending `:where(:not([data-fs-chrome], [data-fs-chrome] *))` to a foreign selector's subject
* matches everything except us at zero specificity, so the app's rules keep their weight against
* each other. */
const CHROME_FENCE = ':where(:not([data-fs-chrome],[data-fs-chrome] *))';
function fenceSelector(part) {
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.some((p) => !pinnedToApp(p, names))) {
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);
if (r.styleSheet) {
try { fenceRules(r.styleSheet.cssRules, names); } catch (e) { /* unfenced */ }
}
}
}
function fenceImported(styleEl, names) {
const fence = () => {
let ready;
try { ready = !!styleEl.sheet.cssRules[0].styleSheet.cssRules; } catch (e) { ready = false; }
if (ready) fenceRules(styleEl.sheet.cssRules, names);
return ready;
};
if (fence()) return;
styleEl.addEventListener('load', () => {
if (!fence()) {
console.error('footstrap: the re-hosted @import loaded but could not be read — the sheet '
+ 'stays unfenced and may repaint the chrome on this page.', styleEl);
}
}, { once: true });
}
const serializeRules = (rules) => Array.from(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 (a <style> filled by insertRule() has empty text over live rules): textIsSheet()
* decides. */
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; }
}
const _silenced = new WeakSet();
/* Take a re-hosted <link> out of the cascade: `el.disabled = true` alone does not;
* `el.sheet.disabled` decides whether the CSS paints, and a still-loading <link> has no .sheet
* yet. */
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 spending the document for each is paid by ordinary
* pages (filemanager lands two <style>s, ssclash four more). Removal on the way out is wrong
* (L.require caches, so a revisit renders unstyled); disabling is reversible. Owner =
* `body[data-page]` when re-hosted. */
const _owner = new WeakMap();
const APP_DEPTH = 3;
let _curKey = null;
function appKey(segs) {
return (segs || []).slice(0, APP_DEPTH).join('/');
}
/* Who a sheet injected right now belongs to, when that is not the page the chrome shows: click a
* page whose module injects CSS, click away before it lands, and the <style> appears under the
* superseding page's data-page. The router names the owner during such a require; of two
* overlapping cold requires the newer wins. */
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;
return appKey(L.env.dispatchpath);
}
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; `keep` is the page still on screen, whose sheets stay lit during staging. */
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);
});
}
function claimOwner(el) {
const key = ownerKey();
_owner.set(el, key);
if (outlivesPage(el)) setEnabled(el, key === currentKey());
}
/* Re-host an invasive sheet into the existing `theme` layer so it still renders its page: an
* unlayered declaration beats every layered one, so an app's `* { margin: 0 }` would own the
* chrome. Measured: unlayered = chrome broken, app OK; `@layer theme` = both OK; a layer before
* theme = app broken. Never delete the sheet: an app's "already imported?" check must find it. */
function rehostIntoThemeLayer(el, universe) {
if (el.dataset.fsLayered) return;
if (el.tagName === 'LINK') {
/* A link the app marked alternate or disabled paints nothing and is left unmarked and watched:
* enabling it adds no node, so only an attribute observer sees it. */
if ((/\balternate\b/i).test(el.rel) || el.hasAttribute('disabled')) {
enableWatch().observe(el, { attributes: true, attributeFilter: [ 'disabled', 'rel' ] });
return;
}
const s = document.createElement('style');
s.dataset.fsLayered = '1';
s.textContent = '@import url("' + el.href.replace(/["\\]/g, '\\$&') + '") layer(theme)'
+ (el.media ? ' ' + el.media : '') + ';';
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);
return;
}
const rules = el.sheet && el.sheet.cssRules;
if (!rules || !rules.length) return;
/* Handled, and never twice: fenceRules() is not idempotent, since an already-fenced selector reads
* as unpinned again. */
el.dataset.fsLayered = '1';
/* Wrap only if the text still is the sheet; otherwise the sheet stays unlayered rather than lose
* rules, and is still fenced below (pure CSSOM, the half that answers `!important`). */
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 */ }
claimOwner(el); /* a <style> is re-hosted in place, so it paints itself */
}
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;
/* `insertRule()` produces no mutation record, so an empty <style> filled later is never looked at
* again: a sheet with no rules yet is not an innocent one. */
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 ----
* `luci-app-podkop` injects 4 KB from render() with no guard and `luci-app-mosdns` re-appends three
* CodeMirror <link>s, so every SPA re-visit adds a copy parsed forever. The first copy keeps the
* rules and any app handle. Re-hosting runs before this, or "keep the first" would keep the raw
* copy and strand the shim. A duplicate needs the same owner. */
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 the textContent may not be the sheet: insertRule-built <style>s all have an
* empty one, and keying on the text would remove the second. */
const rules = el.sheet && el.sheet.cssRules;
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;
/* owner first: a page key never holds '\n', so the first one always ends the owner part */
const k = (_owner.get(el) || '') + '\n' + key;
if (seen.has(k)) el.remove();
else seen.add(k);
});
}
/* ---- the layer order is a document-wide fact, and a sheet inserted first can rewrite it ----
* `@layer tokens, base, theme, page;` holds only while cascade.css is the first sheet to name a
* layer: Ace inserts its <style> first in <head>, and an earlier sheet naming `theme` makes it the
* weakest layer. Inserting a new sheet re-runs the ordering while moving one does not (measured),
* so the order is re-declared from a fresh <style> placed first. */
const LAYER_ORDER = '@layer tokens, base, theme, page;';
let _layerStmt = null;
function reassertLayerOrder() {
const head = document.head;
if (!head) return;
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;
if (_layerStmt) _layerStmt.remove();
_layerStmt = document.createElement('style');
_layerStmt.textContent = LAYER_ORDER;
/* before the first sheet, not into <head>: a document_start script (an extension) appends its
* <style> to <html> ahead of <head>, and Firefox then laid Overview out 2125px short at 390px */
const first = document.querySelector('style, link[rel~="stylesheet"]');
first.parentNode.insertBefore(_layerStmt, first);
}
function sweepViewSheets() {
rehostInvasiveSheets();
dedupeViewSheets();
reassertLayerOrder();
}
let _enableWatch = null;
function enableWatch() {
return _enableWatch || (_enableWatch = new MutationObserver(sweepViewSheets));
}
function watchViewSheets() {
sweepViewSheets();
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;
sweepViewSheets();
return;
}
});
mo.observe(document.head, { childList: true });
/* …and <body>: a <style> appended there after chrome init is seen by nothing otherwise. */
mo.observe(document.body, { childList: true });
}
return baseclass.extend({
attributeTo,
documentCarries,
documentPoisoned,
scopeToCurrentPage,
watchViewSheets,
});
@@ -0,0 +1,14 @@
'use strict';
'require baseclass';
/* Must stay in this file: Makefile, stage.sh and dev-sync.sh stamp it by name. */
const FS_VERSION = '0.0.0-dev';
/* By shape: the minifier folds `!== '0.0.0-dev'`; a dev deploy stamps `git describe`, so only
* `-dev$` is unstamped. */
function isReal() { return ((/^\d+\.\d+/).test(FS_VERSION)) && !((/-dev$/).test(FS_VERSION)); }
return baseclass.extend({
REPO_URL: 'https://github.com/VizzleTF/luci-theme-footstrap',
label: () => (isReal() ? ('Footstrap v' + FS_VERSION) : 'Footstrap (dev)')
});
@@ -0,0 +1,35 @@
'use strict';
'require baseclass';
/* UI primitives shared by the chrome modules. */
/* aria-hidden: each icon sits beside its own label. Only `.fs-ico` and `.fs-chevron` are stroked,
* by theme/20-shell.css. */
function svgIcon(body, cls) {
return '<svg class="' + (cls || 'fs-ico') + '" aria-hidden="true" viewBox="0 0 24 24">' + body + '</svg>';
}
/* Idempotent: a poll tick that changes nothing fires no mutation record. null removes the attribute. */
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);
}
}
/* Enter/Space activation for an element with no native key handling; once per press, not per key
* repeat (~30/s). */
function wireActivate(el, activate) {
el.addEventListener('keydown', (ev) => {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
ev.preventDefault();
if (!ev.repeat) activate();
});
}
return baseclass.extend({
svgIcon,
syncAttr,
wireActivate
});
@@ -0,0 +1,159 @@
'use strict';
'require baseclass';
'require ui';
'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';
/* Chrome bootstrap: load the menu tree once, hand it to the parts that need it and wire them in
* order; it renders nothing itself. */
/* Page modules: `fs-overview` adds to a stock page rather than owning a route, so it is required
* only there: a `require` pragma would fetch it on every admin page. A map from `data-page` to
* module name drives the lazy load. */
const PAGE_MODULES = {
'admin-status-overview': 'fs-overview'
};
function wirePageModules() {
/* through window.L, never the factory's `L`: the factory's `L` has no ui helpers, and require() caches the first requirer's binding */
const RT = window.L;
const load = () => {
const name = PAGE_MODULES[document.body.getAttribute('data-page') || ''];
if (!name) return;
RT.require(name).then((m) => m.wire()).catch((e) => {
console.error('footstrap: ' + name + ' did not load', e);
});
};
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: it is fetched on the first gesture. */
const RECENT_WARM = 5;
/* A key is a menu path — what the router can navigate to and what warmRecent() prefetches. */
function remember(key) {
if (!key) return;
prefs.lsSet(prefs.RECENT_KEY, JSON.stringify([ key ].concat(prefs.recentPaths().filter((p) => p !== key)).slice(0, prefs.RECENT_MAX)));
}
/* ---- warm the pages this admin actually uses ----
* The router's per-link prefetch needs a hover, tap or focus first, so the recently visited pages
* are warmed ahead. */
function warmRecent() {
if (navigator.connection?.saveData) return;
const here = (L.env.dispatchpath || []).join('/');
/* already de-duplicated: remember() drops the key from its old slot before re-adding it */
const paths = prefs.recentPaths().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');
const RT = window.L;
const rememberSegs = (segs) => remember((segs || []).join('/'));
rememberSegs(L.env.dispatchpath);
router.onNavigate(rememberSegs);
warmRecent();
/* One fetch, on the first gesture; `require` is a singleton, so every later gesture reaches the
* same palette. This is the only owner of the button. */
let pending = false;
const gesture = (act) => {
if (pending) return;
pending = true;
RT.require('fs-search').then((m) => { pending = false; m[act](); },
(e) => { pending = false; console.error('footstrap: fs-search did not load', e); });
};
btn.addEventListener('click', () => gesture('toggle'));
/* `/` must not steal a keystroke from someone typing into a field, a contenteditable, or a
* .cbi-dropdown (fs-select's typeahead) */
document.addEventListener('keydown', (ev) => {
if (ev.defaultPrevented) return;
if ((ev.ctrlKey || ev.metaKey) && !ev.altKey && (ev.key === 'k' || ev.key === 'K')) {
ev.preventDefault(); gesture('toggle'); return;
}
if (ev.key !== '/' || ev.ctrlKey || ev.metaKey || ev.altKey) return;
if (ev.target.closest?.('input, textarea, select, [contenteditable], .cbi-dropdown')) return;
ev.preventDefault(); gesture('open');
});
}
/* The three template globals Status -> Overview needs (progressbar, renderBox, renderBadge), which
* stock includes call bare and an SPA arrival never defines. */
function ensureOverviewHelpers() {
/* Adapted copies of LuCI's admin_status/index.ut (window.L, an array child for E(), the title
string hoisted into `reading`); keep the shape, `var`s included, so a diff against upstream
stays readable when it changes. */
if (typeof window.progressbar !== 'function')
window.progressbar = function(query, value, max, byte) {
var pg = document.querySelector(query),
vn = parseInt(value) || 0,
mn = parseInt(max) || 100,
fv = byte ? String.format('%1024.2mB', value) : value,
fm = byte ? String.format('%1024.2mB', max) : max,
pc = Math.floor((100 / mn) * vn),
reading = '%s / %s (%d%%)'.format(fv, fm, pc);
if (pg) {
pg.firstElementChild.style.width = pc + '%';
pg.setAttribute('title', reading);
}
};
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))
]);
};
}
ensureOverviewHelpers();
return baseclass.extend({
init() {
/* First, and outside the promise: a third-party sheet that outranks the chrome is already painting
* (fs-sheets). */
sheets.watchViewSheets();
prefs.guardDarkStamp(); /* same, for a third party stamping :root */
prefs.watchThemeColor(); /* the mobile address bar, from the live page colour */
chrome.wireMenu(); /* flyout dismissal listeners: no wait on the menu fetch either */
ui.menu.load().then((menu) => {
tree.setTree(menu);
/* the view this full load already rendered — see fs-router's seed() */
router.seed();
chrome.start();
wireSearch();
/* 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();
/* no sane partial recovery — a throw above loses the menu, the router and the Appearance
* page together — so the .catch fails loudly rather than silently */
}).catch((e) => console.error('footstrap: chrome init failed', e));
}
});
@@ -0,0 +1,10 @@
'use strict';
'require baseclass';
'require menu-footstrap-common as common';
/* footer.ut's entry: menu-footstrap-common wires the chrome, fs-chrome renders it. */
return baseclass.extend({
__init__() {
common.init();
}
});
@@ -0,0 +1,13 @@
'use strict';
'require view';
'require fs-appearance as appearance';
/* The Footstrap page, dispatched by menu.d; fs-appearance.js draws its own cards. */
return view.extend({
render: () => appearance.renderStandalone(),
/* null: a set handler makes View.addFooter() draw Save & Apply. */
handleSave: null,
handleSaveApply: null,
handleReset: null
});