mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-11-02 03:13:56 +08:00
update 2026-10-28 21:59:16
This commit is contained in:
1 parent
d27d8dcd9f
commit
f01306d457
121 files changed
+16870
-88
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,599 @@
|
||||
'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);
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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) => {
|
||||
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
|
||||
});
|
||||
Reference in new issue
Block a user