mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-09-10 18:34:18 +08:00
937 lines
49 KiB
JavaScript
937 lines
49 KiB
JavaScript
'use strict';
|
||
'require baseclass';
|
||
'require ui';
|
||
'require dom';
|
||
'require fs-prefs as prefs';
|
||
'require fs-axes as axes';
|
||
'require fs-assets as assets';
|
||
'require fs-version as ver';
|
||
|
||
/* The Appearance controls: the DOM that presents the axes. It owns no preference — fs-prefs.js
|
||
* holds the axes and fs-version.js the version string; this file is the form they are shown in.
|
||
*
|
||
* It is a tab on System -> System, beside General Settings / Logging / Time Synchronization /
|
||
* Language and Style. Twenty-one axes, nine of them with a colour field, a swatch and a contrast
|
||
* readout, do not fit a floating popover that has to trap Tab and stay inside a 320px column, and
|
||
* keeping both containers would render every axis twice.
|
||
*
|
||
* The form is appended by a MutationObserver rather than by a route of its own, the same boundary
|
||
* fs-overview.js sits on: a theme may not own a dispatcher node, because the node outlives the
|
||
* theme that registered it and the menu would keep an entry whose view is gone. So the theme owns
|
||
* no menu.d and no view — it watches for the stock page, adds one section and removes nothing.
|
||
*
|
||
* The version line makes no request and must not grow one: which version is INSTALLED is what this
|
||
* page answers, and which is available is the package manager's question. */
|
||
|
||
/* ---- colour: reading what the page is actually painted ----
|
||
*
|
||
* This lived in fs-widgets.js, which the menu and the search palette also require — so the whole
|
||
* colour engine was downloaded on every admin page to be used on this one. It is 3 KB of probe,
|
||
* canvas and WCAG arithmetic that nothing outside this form has ever called: `colorControl` was
|
||
* fs-widgets' only colour export and this file its only consumer. */
|
||
|
||
/* ---- colour: reading what the page is actually painted ----
|
||
*
|
||
* Two questions no stored value answers: what colour a role is right now (the palette's own while
|
||
* the axis is off — there is deliberately no copy of the palette in JS), and what contrast the
|
||
* user's colour lands at. Both are about the computed cascade, so both are asked of the browser.
|
||
*
|
||
* `getComputedStyle(root).getPropertyValue('--fs-accent')` answers neither: a custom property
|
||
* computes to the token stream after var() substitution, so `oklch(from … l c H)` comes back
|
||
* unevaluated. Setting the expression as a real `color` and reading it back makes the browser
|
||
* resolve it — relative colour, color-mix() and the tint's calc() are what the theme is made of.
|
||
* One hidden probe is reused; an element per query would thrash layout on every slider drag. */
|
||
let _probe = null;
|
||
function probeColor(expr) {
|
||
if (!_probe) {
|
||
/* Off-screen rather than display:none, so the reading does not depend on a display:none
|
||
* element computing `color` in every engine. It has no text and no size, so it paints
|
||
* nothing.
|
||
*
|
||
* Every declaration is !important (issue #19): this is an unmarked element in a document
|
||
* shared with `luci-app-*`, and an app's unlayered `span { color: … !important }` outranks
|
||
* a layer and a plain inline style alike. A probe that loses its own colour reports the
|
||
* app's, which then becomes the admin's saved axis on the next confirm. */
|
||
_probe = E('span', { 'aria-hidden': 'true' });
|
||
_probe.style.cssText = 'position:fixed!important;left:-9999px!important;top:0!important;'
|
||
+ 'width:0!important;height:0!important;overflow:hidden!important;'
|
||
+ 'pointer-events:none!important;';
|
||
document.body.appendChild(_probe);
|
||
}
|
||
/* cleared first: an expression the engine rejects leaves the previous colour standing, which
|
||
* would report a stale answer as a fresh one */
|
||
_probe.style.setProperty('color', '');
|
||
_probe.style.setProperty('color', expr, 'important');
|
||
return getComputedStyle(_probe).color;
|
||
}
|
||
|
||
/* A computed colour -> [r,g,b] 0..255, or null. Rasterised, not parsed: a computed `color` keeps
|
||
* the space it was authored in, so `oklch(0.54 0.19 300)` would parse as three numbers in the
|
||
* wrong units and produce a colour nobody chose — measured: #010078, graded "Too faint to read",
|
||
* in the hex field, the swatch and the contrast readout alike. Painting one pixel makes the engine
|
||
* convert instead (tools/export-tier.mjs uses the same method). The string parse remains only as
|
||
* the fallback for an engine with no 2D context, where only the legacy `rgb()`/`color(srgb …)`
|
||
* forms can appear. */
|
||
let _cx = null;
|
||
function rasterCtx() {
|
||
if (_cx !== null) return _cx;
|
||
try {
|
||
const cv = document.createElement('canvas');
|
||
cv.width = cv.height = 1;
|
||
_cx = cv.getContext('2d', { willReadFrequently: true }) || false;
|
||
} catch (e) { _cx = false; }
|
||
return _cx;
|
||
}
|
||
function parseColor(s) {
|
||
const str = String(s || '');
|
||
const cx = rasterCtx();
|
||
if (cx) {
|
||
/* fillStyle keeps the last value it could parse, so a colour this engine rejects would
|
||
* report the previous one as a fresh reading — the trap probeColor() clears for */
|
||
cx.fillStyle = '#000';
|
||
cx.fillStyle = str;
|
||
cx.clearRect(0, 0, 1, 1);
|
||
cx.fillRect(0, 0, 1, 1);
|
||
const d = cx.getImageData(0, 0, 1, 1).data;
|
||
if (d[3] === 255) return [ d[0], d[1], d[2] ];
|
||
/* translucent: composite over nothing is meaningless for a readout, so fall through */
|
||
}
|
||
const nums = str.match(/[\d.]+/g);
|
||
if (!nums || nums.length < 3) return null;
|
||
const unit = (/^color\(/i).test(str) ? 255 : 1;
|
||
return nums.slice(0, 3).map((n) => Math.max(0, Math.min(255, parseFloat(n) * unit)));
|
||
}
|
||
|
||
/* WCAG 2.x relative luminance and contrast ratio, on sRGB. Used only to report: the theme states
|
||
* what a colour costs and leaves the choice with the user, never correcting it (03-palettes.css
|
||
* derives the ink over a fill, which is a different question). */
|
||
function luminance(rgb) {
|
||
const c = rgb.map((v) => {
|
||
const x = v / 255;
|
||
return (x <= .03928) ? (x / 12.92) : Math.pow((x + .055) / 1.055, 2.4);
|
||
});
|
||
return (.2126 * c[0]) + (.7152 * c[1]) + (.0722 * c[2]);
|
||
}
|
||
function contrastRatio(fgExpr, bgExpr) {
|
||
const fg = parseColor(probeColor(fgExpr)), bg = parseColor(probeColor(bgExpr));
|
||
if (!fg || !bg) return null;
|
||
const a = luminance(fg), b = luminance(bg);
|
||
return (Math.max(a, b) + .05) / (Math.min(a, b) + .05);
|
||
}
|
||
|
||
/* #rrggbb, because <input type="color"> accepts nothing else. An unparseable colour becomes black
|
||
* rather than throwing: the text field beside the swatch is the authoritative one. */
|
||
function toHex(s) {
|
||
const rgb = parseColor(s) || [ 0, 0, 0 ];
|
||
return '#' + rgb.map((v) => Math.round(v).toString(16).padStart(2, '0')).join('');
|
||
}
|
||
|
||
/* One colour axis: a native swatch, a hex field and a button back to the palette's own colour.
|
||
* Reports through onPick as a hex string, or 0 for "back to the palette", either of which the
|
||
* caller hands straight to fs-prefs.js's colorAxis.
|
||
*
|
||
* There is no hue slider and one is not coming back: rotating a hue keeps the palette's chroma,
|
||
* so no angle of it reaches a grey. The axis still accepts a stored hue (1–360) and the stylesheet
|
||
* still rotates the palette by one, so a saved value goes on working.
|
||
*
|
||
* `opts.probe` is the live token the effective colour is read back from, so the field shows the
|
||
* palette's colour while the axis is off without a copy of the palette in JS. `opts.contrast` is
|
||
* the pair whose ratio is reported under the row. */
|
||
function colorControl(current, onPick, label, opts) {
|
||
const o = opts || {};
|
||
|
||
/* type=color leaves the picker to the browser: accessible without reimplementing a colour
|
||
* wheel, and native on a phone. The text field beside it takes a pasted hex and is the
|
||
* fallback where the browser draws no picker. */
|
||
const swatch = E('input', { 'type': 'color', 'class': 'fs-color-swatch', 'aria-label': label || '' });
|
||
const field = E('input', {
|
||
'type': 'text', 'class': 'fs-color-hex', 'spellcheck': 'false', 'autocomplete': 'off',
|
||
'inputmode': 'text', 'maxlength': '7', 'aria-label': label || ''
|
||
});
|
||
const clear = E('button', { 'class': 'btn fs-color-clear', 'type': 'button' }, [ _('Palette', 'footstrap') ]);
|
||
const ratio = o.contrast ? E('div', { 'class': 'cbi-value-description fs-color-contrast' }) : null;
|
||
|
||
/* what the axis holds right now: the page can change it behind this control (a preset, Reset
|
||
* to default), so a private copy would go stale. `current` is only the build-time value. */
|
||
const currentOf = o.read || (() => current);
|
||
|
||
/* Repaint everything that mirrors the axis. Called after every edit, and through the returned
|
||
* refresh() after a preset, palette switch or dark-mode flip — each changes what the palette's
|
||
* own colour is while this axis stays off. */
|
||
function reflect(v) {
|
||
const live = probeColor(o.probe);
|
||
const hex = (typeof v === 'string') ? v : toHex(live);
|
||
swatch.value = hex;
|
||
/* do not fight the user mid-edit: `#0` is a legal prefix, and overwriting the field on
|
||
* every keystroke made the input impossible to type into */
|
||
if (document.activeElement !== field) field.value = hex;
|
||
/* the button back to the palette doubles as the axis state readout: enabled means the axis
|
||
* holds a colour of its own, disabled means the field shows the palette's */
|
||
clear.disabled = !v;
|
||
if (!ratio) return;
|
||
const r = contrastRatio(o.contrast.fg, o.contrast.bg);
|
||
if (r === null) { ratio.textContent = ''; ratio.removeAttribute('title'); return; }
|
||
/* The readout states what the ratio means; the number itself stays in the title.
|
||
* Thresholds are WCAG AA: 4.5:1 for body text, 3:1 for large text and for a UI shape, so a
|
||
* hairline is graded on the second (`kind: 'shape'`) and warns rather than fails — a faint
|
||
* border is a legitimate choice.
|
||
*
|
||
* Class names are written out whole: tools/fs-orphans.mjs sweeps dead CSS by matching
|
||
* fs-* tokens in the source, and a concatenated name is invisible to it. */
|
||
const where = o.contrast.label;
|
||
const grade = (o.contrast.kind === 'shape')
|
||
? ((r >= 3)
|
||
? { cls: 'fs-contrast-aa', text: _('Clearly visible %s', 'footstrap').format(where) }
|
||
: { cls: 'fs-contrast-aa-large', text: _('Barely visible %s', 'footstrap').format(where) })
|
||
: (r >= 4.5)
|
||
? { cls: 'fs-contrast-aa', text: _('Easy to read %s', 'footstrap').format(where) }
|
||
: (r >= 3)
|
||
? { cls: 'fs-contrast-aa-large', text: _('Hard to read %s — large text only', 'footstrap').format(where) }
|
||
: { cls: 'fs-contrast-low', text: _('Too faint to read %s', 'footstrap').format(where) };
|
||
ratio.className = 'fs-color-contrast ' + grade.cls;
|
||
ratio.textContent = grade.text;
|
||
ratio.title = _('Contrast %s:1 (WCAG AA wants %s:1 here)', 'footstrap')
|
||
.format(r.toFixed(1), (o.contrast.kind === 'shape') ? '3' : '4.5');
|
||
}
|
||
|
||
const pick = (v) => { onPick(v); reflect(v); };
|
||
|
||
swatch.addEventListener('input', () => pick(swatch.value.toLowerCase()));
|
||
/* commit on blur and Enter, not per keystroke: a half-typed `#0096` would repaint the page
|
||
* under the cursor. An unparseable value snaps back to what the axis holds, so the field
|
||
* cannot claim a colour the page is not painted in. */
|
||
const commit = () => {
|
||
const v = field.value.trim().toLowerCase();
|
||
if ((/^#[0-9a-f]{6}$/).test(v)) pick(v);
|
||
else reflect(currentOf());
|
||
};
|
||
field.addEventListener('blur', commit);
|
||
field.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); commit(); } });
|
||
clear.addEventListener('click', () => pick(0));
|
||
|
||
const wrap = E('div', { 'class': 'fs-colorctl' + (o.cls ? ' ' + o.cls : '') }, [
|
||
E('div', { 'class': 'fs-color-row' }, [ swatch, field, clear ])
|
||
].concat(ratio ? [ ratio ] : []));
|
||
/* the caller decides when this runs: probeColor() needs the document, and this control is not
|
||
* in it yet */
|
||
wrap.fsRefresh = () => reflect(currentOf());
|
||
return wrap;
|
||
}
|
||
|
||
/* Build the whole form. Returns a promise for one element wire() appends to the stock page.
|
||
*
|
||
* Everything applies immediately and there is nothing to save: every axis is this browser's, in
|
||
* localStorage, and the page repaints under the control as it moves. Only "Save as default" writes
|
||
* anything, pushing the current look to the ROUTER for other browsers. That distinction is the
|
||
* model (docs/design-system.md), and why this page has no Save/Reset footer of LuCI's own. */
|
||
function render() {
|
||
/* build() runs inside the promise, not as its argument: `Promise.resolve(build())` evaluates it
|
||
* synchronously, so a throw unwinds out of render() before mount() can attach .catch/.finally
|
||
* and leaves _building set for the life of the document — the tab then never builds again and
|
||
* nothing is logged. */
|
||
return Promise.resolve().then(build);
|
||
}
|
||
|
||
function build() {
|
||
/* every saved axis re-checks the Save button after applying, so it greys the moment this
|
||
* browser matches the saved default and un-greys when it diverges. Wrapped around the appliers
|
||
* because the controls call them directly and have no other seam back here. */
|
||
const bump = (fn) => (v) => { fn(v); refreshSave(); };
|
||
|
||
/* Every colour control mirrors something it does not own: the palette's colour while its own
|
||
* axis is off, and the contrast that colour lands at. A palette switch or dark-mode flip moves
|
||
* all of it under controls nobody touched, so they refresh together. */
|
||
const colourCtls = [];
|
||
const refreshColours = () => colourCtls.forEach((c) => c.fsRefresh());
|
||
/* wrap an applier so the colour readouts follow it: mode and palette change what every axis is
|
||
* measured against */
|
||
const repaint = (fn) => (v) => { fn(v); refreshColours(); };
|
||
|
||
/* One captioned row in LuCI's own shape: `.cbi-value` > `label.cbi-value-title` +
|
||
* `.cbi-value-field`. Nothing here styles those class names — base/30-forms.css and
|
||
* theme/60-inputs.css already lay them out — so this page inherits every future fix to the form
|
||
* layout instead of keeping a private copy. It also puts the row on a shared surface (zone 2,
|
||
* where an app may win on specificity), which is right for a page inside #view.
|
||
*
|
||
* `make` is handed the same label string the caption renders, because every control needs it a
|
||
* second time as its aria-label; stating it twice is how the visible caption and what a screen
|
||
* reader announces drift apart. `extra` carries rows with more than a control, `opts.cls` marks
|
||
* the rows CSS has to single out. */
|
||
const group = (label, make, opts) => {
|
||
const o = opts || {};
|
||
return E('div', { 'class': 'cbi-value' + (o.cls ? ' ' + o.cls : '') }, [
|
||
E('label', { 'class': 'cbi-value-title' }, [ label ]),
|
||
E('div', { 'class': 'cbi-value-field' }, [ make(label) ].concat(o.extra || []))
|
||
]);
|
||
};
|
||
|
||
/* the three literals every colour row repeats; a string literal survives minification intact */
|
||
const CARD_BG = 'var(--fs-panel)', INK = 'var(--fs-text)', ON_CARD = _('on a card', 'footstrap');
|
||
|
||
/* ---- the controls are LuCI's own ----
|
||
*
|
||
* Every enum axis is a `ui.Select` and every number a `ui.RangeSlider`: the widgets the other
|
||
* tabs are built from, already dressed by this stylesheet (`select` in base/30-forms.css,
|
||
* `.cbi-range-slider` in theme/60-inputs.css). Both exist on every release this theme supports
|
||
* — checked against openwrt-24.10, since `ui.RangeSlider` is the one that did not exist further
|
||
* back.
|
||
*
|
||
* Listen for `widget-change` from UIElement, not for an event on the inner element: that is the
|
||
* seam the widget publishes, and reaching past it ties this to how the widget happens to be
|
||
* built. RangeSlider also emits `widget-update` while the handle moves, which is what resizes
|
||
* the page under the drag; the appliers are idempotent, so wiring both costs nothing. */
|
||
const selectCtl = (current, choices, apply, label) => {
|
||
const w = new ui.Select(String(current), choices, { widget: 'select', sort: Object.keys(choices) });
|
||
const node = w.render();
|
||
node.setAttribute('aria-label', label);
|
||
node.addEventListener('widget-change', () => apply(w.getValue()));
|
||
return node;
|
||
};
|
||
|
||
const sliderCtl = (current, min, max, apply, label, opts) => {
|
||
const o = opts || {};
|
||
const w = new ui.RangeSlider(String(current), {
|
||
min: min, max: max, step: o.step || 1
|
||
});
|
||
const node = w.render();
|
||
node.setAttribute('aria-label', label);
|
||
const push = () => apply(parseInt(w.getValue(), 10));
|
||
node.addEventListener('widget-update', push);
|
||
node.addEventListener('widget-change', push);
|
||
return node;
|
||
};
|
||
|
||
/* one colour axis: `probe` is the live token the control reads the effective colour back from,
|
||
* `contrast` the pair it reports */
|
||
const colourGroup = (label, axis, probe, contrast, opts) => group(label, (lbl) => {
|
||
const ctl = colorControl(axis.current(), bump(axis.apply), lbl, {
|
||
probe: probe,
|
||
read: axis.current,
|
||
contrast: contrast,
|
||
cls: (opts && opts.cls) || ''
|
||
});
|
||
colourCtls.push(ctl);
|
||
return ctl;
|
||
}, opts);
|
||
|
||
/* Every label here carries the 'footstrap' context (`_(str, ctx)`, key `ctx\1str`). LuCI serves
|
||
* one merged catalogue — load_catalog() loads every *.<lang>.lmo and a lookup returns the first
|
||
* archive holding the hash — so a bare msgid is a global name any luci-app may take, and
|
||
* readdir order picks the winner: the layout toggle rendered "Максимум" on a Russian router
|
||
* because another catalogue translates "Top" as "maximum" (issue #6). Contexting cannot be
|
||
* selective. The chrome, the login/notice sentences and the System/Memory/Storage headings are
|
||
* deliberately bare — inheriting luci-base's translation covers the ~40 languages this theme
|
||
* has no catalogue for. */
|
||
|
||
/* ---- section 1: the shell ---- */
|
||
const shell = [
|
||
group(_('Layout', 'footstrap'), (label) => selectCtl(prefs.currentLayout(), {
|
||
sidebar: _('Sidebar', 'footstrap'),
|
||
top: _('Top', 'footstrap')
|
||
}, bump(prefs.applyLayout), label)),
|
||
|
||
group(_('Theme', 'footstrap'), (label) => selectCtl(prefs.currentMode(), {
|
||
auto: _('Auto', 'footstrap'),
|
||
light: _('Light', 'footstrap'),
|
||
dark: _('Dark', 'footstrap')
|
||
}, bump(repaint(prefs.applyMode)), label)),
|
||
|
||
group(_('Palette', 'footstrap'), (label) => selectCtl(axes.currentPalette(), {
|
||
footstrap: 'Footstrap',
|
||
hicontrast: 'Hi-Contrast',
|
||
/* names the OTHER package, luci-theme-bootstrap, whose colours this palette is —
|
||
* so it is a proper noun and stays untranslated, like the two above it */
|
||
bootstrap: 'Bootstrap',
|
||
/* names the OTHER package again, luci-theme-openwrt-2020, whose colourway this is */
|
||
'2020': 'OpenWrt 2020'
|
||
}, bump(repaint(axes.applyPalette)), label)),
|
||
|
||
group(_('Density', 'footstrap'), (label) => selectCtl(prefs.currentDensity(), {
|
||
compact: _('Compact', 'footstrap'),
|
||
normal: _('Normal', 'footstrap'),
|
||
large: _('Large', 'footstrap')
|
||
}, bump(prefs.applyDensity), label)),
|
||
|
||
group(_('Rounding', 'footstrap'),
|
||
(label) => sliderCtl(axes.currentRadius(), 0, 20, bump(axes.applyRadius), label)),
|
||
|
||
/* The top layout has no accordion, so this switch is meaningless there: always built,
|
||
* hidden by CSS (:root[data-layout="top"] .fs-ap-submenus). Do not wrap it in an
|
||
* `if (currentLayout() !== 'top')` — the page is built once, so the branch would freeze
|
||
* the control to the layout the page loaded in while CSS morphs the chrome live. */
|
||
group(_('Submenus', 'footstrap'), (label) => selectCtl(
|
||
prefs.currentAutoCollapse() ? 'on' : 'off', {
|
||
off: _('Keep open', 'footstrap'),
|
||
on: _('Auto-collapse', 'footstrap')
|
||
}, bump(prefs.applyAutoCollapse), label),
|
||
{ cls: 'fs-ap-submenus' })
|
||
];
|
||
|
||
/* ---- section 2: colours ---- */
|
||
const colours = [
|
||
/* the caption says what the axis is for: "Tint" alone reads as decoration, and nobody
|
||
* would look for the router-identity cue under it */
|
||
colourGroup(_('Tint (router identification)', 'footstrap'), {
|
||
current: axes.currentTint, apply: axes.applyTint
|
||
}, 'var(--fs-bg)', {
|
||
/* the canvas is the one axis with no derived ink: its text is --fs-text, a palette
|
||
* token this axis must not move, so the ratio is reported instead of corrected */
|
||
fg: 'var(--fs-text)', bg: 'var(--fs-bg)', label: _('on the canvas', 'footstrap')
|
||
}, { cls: 'fs-ap-tint' }),
|
||
|
||
/* The strength half of the Tint, meaningful only in hue mode: a hex canvas is the colour
|
||
* asked for, with no chroma of ours to scale. CSS hides it in the other two states.
|
||
*
|
||
* Not called "Density": that is the select above, and this string is both the caption and
|
||
* the aria-label, so a screen reader would announce two rows under one name. */
|
||
group(_('Tint strength', 'footstrap'),
|
||
(label) => sliderCtl(axes.currentTintStrength(), 0, 200, bump(repaint(axes.applyTintStrength)), label, {
|
||
step: 5
|
||
}), { cls: 'fs-ap-tint fs-ap-tintstr' }),
|
||
|
||
/* recolours the accented controls (buttons/toggles/sliders/focus rings), not the canvas.
|
||
* Measured as text on a card, the use that fails first: as a fill it carries derived ink,
|
||
* as a link or status label it carries only itself. It is also what answers #20 ("sometimes
|
||
* you want grey or black"), taking any #rrggbb — the colour-chip presets that once sat here
|
||
* are not coming back. */
|
||
/* Four status roles, one shape: the role's own colour read against a card. Written out
|
||
* eight times between here and the surfaces below, they cost their repeated literals in
|
||
* full — a string is not mangled — so the rows are data and the row is stated once. */
|
||
...[
|
||
[ _('Accent', 'footstrap'), axes.currentAccent, axes.applyAccent, 'var(--fs-accent)' ],
|
||
[ _('Good', 'footstrap'), axes.currentGood, axes.applyGood, 'var(--fs-good)' ],
|
||
[ _('Warning', 'footstrap'), axes.currentWarn, axes.applyWarn, 'var(--fs-warn)' ],
|
||
[ _('Danger', 'footstrap'), axes.currentDanger, axes.applyDanger, 'var(--fs-danger)' ]
|
||
].map(([ label, current, apply, ink ]) =>
|
||
colourGroup(label, { current, apply }, ink, { fg: ink, bg: CARD_BG, label: ON_CARD }))
|
||
];
|
||
|
||
/* ---- the surfaces: the sheet the UI is drawn on ----
|
||
* Cards, inset controls, the chrome bar and the hairlines between them. Body text is read on
|
||
* each, so each reports --fs-text against itself. No ink is derived here: --fs-text is the
|
||
* palette's, and moving it would recolour the very thing being measured against.
|
||
*
|
||
* The hairline takes the 3:1 UI-component threshold instead — a border is a shape, not a label
|
||
* — and below that it is decoration, which a hairline is entitled to be, so the readout states
|
||
* the number and leaves the call to the admin. */
|
||
const surfaces = [
|
||
/* Same rows, one column wider: a surface reports the ink read ON it, which is --fs-text
|
||
* for the three that carry body text and the hairline itself for the border. */
|
||
...[
|
||
[ _('Cards', 'footstrap'), axes.currentCard, axes.applyCard, CARD_BG, INK, CARD_BG, ON_CARD ],
|
||
[ _('Controls', 'footstrap'), axes.currentControl, axes.applyControl, 'var(--fs-panel2)', INK, 'var(--fs-panel2)', _('on a control', 'footstrap') ],
|
||
[ _('Sidebar and bar', 'footstrap'), axes.currentBar, axes.applyBar, 'var(--fs-bar-bg)', INK, 'var(--fs-bar-bg)', _('in the sidebar', 'footstrap') ],
|
||
[ _('Borders', 'footstrap'), axes.currentLine, axes.applyLine, 'var(--fs-border)', 'var(--fs-border)', CARD_BG, ON_CARD, 'shape' ]
|
||
].map(([ label, current, apply, probe, fg, bg, where, kind ]) =>
|
||
colourGroup(label, { current, apply }, probe, { fg, bg, label: where, kind }))
|
||
];
|
||
|
||
/* ---- section 3: the wallpaper and the rows each value brings ----
|
||
*
|
||
* Wallpaper is three-valued: Off, Pattern (an uploaded SVG, tiled and recoloured) and File (an
|
||
* uploaded photo). The rows a value brings are SIBLINGS of the Wallpaper row, not children of
|
||
* its field: nesting a `.cbi-value` inside a `.cbi-value-field` puts a second caption column
|
||
* inside the first, and those controls started 216px right of every other one on the page,
|
||
* measured on the router. Flat rows hidden as a group is what the stock pages do with a
|
||
* dependent field.
|
||
*
|
||
* The select is the per-browser switch deciding whether to paint an image, so it is what keeps
|
||
* the Save button honest; Choose/Remove only swap the picture and never touch the axis. The
|
||
* native file inputs stay hidden — the styled buttons trigger them. */
|
||
const wallpaper = (() => {
|
||
const err = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' });
|
||
const preview = E('img', { 'class': 'fs-ap-bgprev', 'alt': '', 'hidden': '' });
|
||
/* display:none, not `hidden`: a bare `hidden=""` still renders the native
|
||
* "Choose File / No file chosen" control */
|
||
const fileInput = E('input', { 'type': 'file', 'accept': 'image/*', 'style': 'display:none' });
|
||
const chooseLabel = _('Choose image', 'footstrap');
|
||
const chooseBtn = E('button', { 'class': 'btn cbi-button', 'type': 'button' }, [ chooseLabel ]);
|
||
const removeBtn = E('button', { 'class': 'btn cbi-button-remove', 'type': 'button', 'hidden': '' }, [ _('Remove', 'footstrap') ]);
|
||
|
||
const patErr = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' });
|
||
const patPreview = E('img', { 'class': 'fs-ap-bgprev', 'alt': '', 'hidden': '' });
|
||
const patInput = E('input', { 'type': 'file', 'accept': 'image/svg+xml,.svg', 'style': 'display:none' });
|
||
const patChooseLabel = _('Choose SVG', 'footstrap');
|
||
const patChoose = E('button', { 'class': 'btn cbi-button', 'type': 'button' }, [ patChooseLabel ]);
|
||
const patRemove = E('button', { 'class': 'btn cbi-button-remove', 'type': 'button', 'hidden': '' }, [ _('Remove', 'footstrap') ]);
|
||
|
||
/* Dim: the scrim opacity over the photo. An ordinary per-browser axis — it is in AXIS_KEYS
|
||
* and snapshotAxes(), so it moves this browser toward or away from the router default and
|
||
* must be bump()-ed like every other saved axis, or the Save button misreports its own
|
||
* status. Separate from the Tint strength above. */
|
||
const dimLabel = _('Dim', 'footstrap');
|
||
const scaleLabel = _('Scale', 'footstrap');
|
||
const strengthLabel = _('Strength', 'footstrap');
|
||
const inkLabel = _('Colours', 'footstrap');
|
||
|
||
/* The rows the pattern brings. Scale and Strength are live: the appliers write a custom
|
||
* property, so the tile resizes and fades under the drag. Colours decides whether the
|
||
* file's own palette is kept or replaced by the theme's — a mask uses the alpha only,
|
||
* which is right for line art and wrong for artwork that carries its own colours. */
|
||
const patRows = [
|
||
group(_('Pattern', 'footstrap'),
|
||
() => E('div', { 'class': 'fs-ap-bgrow' }, [ patChoose, patRemove ]),
|
||
{ extra: [ patInput, patPreview, patErr ] }),
|
||
group(scaleLabel, (lbl) => sliderCtl(axes.currentPatternSize(), 40, 1600,
|
||
bump(axes.applyPatternSize), lbl, { step: 20 })),
|
||
group(strengthLabel, (lbl) => sliderCtl(axes.currentPatternStrength(), 0, 100,
|
||
bump(axes.applyPatternStrength), lbl, { step: 5 })),
|
||
group(inkLabel, (lbl) => selectCtl(axes.currentPatternInk(), {
|
||
theme: _('Theme', 'footstrap'),
|
||
original: _('As in file', 'footstrap')
|
||
}, bump(axes.applyPatternInk), lbl))
|
||
];
|
||
/* …and the rows the FILE photo brings. */
|
||
const fileRows = [
|
||
group(_('File', 'footstrap'),
|
||
() => E('div', { 'class': 'fs-ap-bgrow' }, [ chooseBtn, removeBtn ]),
|
||
{ extra: [ fileInput, preview, err ] }),
|
||
group(dimLabel, (lbl) => sliderCtl(axes.currentPhotoDim(), 0, 100,
|
||
bump(axes.applyPhotoDim), lbl, { step: 5 }))
|
||
];
|
||
|
||
function reflect(tok) {
|
||
if (tok) { preview.src = axes.loginBgUrl(tok); preview.hidden = false; removeBtn.hidden = false; }
|
||
else { preview.removeAttribute('src'); preview.hidden = true; removeBtn.hidden = true; }
|
||
}
|
||
function reflectPattern(tok) {
|
||
if (tok) { patPreview.src = axes.patternUrl(tok); patPreview.hidden = false; patRemove.hidden = false; }
|
||
else { patPreview.removeAttribute('src'); patPreview.hidden = true; patRemove.hidden = true; }
|
||
}
|
||
/* `hidden` on the row, which 80-appearance.css restates at a specificity beating
|
||
* `.cbi-value`'s own display (the UA's bare `[hidden]` rule loses to it). Hidden, not
|
||
* removed: each row holds a live control, so rebuilding on every switch would freeze it to
|
||
* the state it was constructed in. */
|
||
function togglePanel(v) {
|
||
patRows.forEach((r) => { r.hidden = (v !== 'pattern'); });
|
||
fileRows.forEach((r) => { r.hidden = (v !== 'file'); });
|
||
}
|
||
reflect(axes.currentLoginBg());
|
||
reflectPattern(axes.currentPattern());
|
||
togglePanel(axes.currentWallpaper());
|
||
|
||
const setWallpaper = (v) => { axes.applyWallpaper(v); refreshSave(); togglePanel(v); refreshColours(); };
|
||
|
||
/* Both uploads present the same three controls and the same four states — pick, upload,
|
||
* report, remove — so the wiring is stated once. What differs is `after`: the pattern also
|
||
* has to move the Wallpaper dropdown, because the upload switched this browser onto the
|
||
* tile and the page would otherwise paint it while the control still read Off.
|
||
*
|
||
* The file input is cleared on every change so re-picking the SAME file fires `change`
|
||
* again, and the button carries its own busy state: the label is restored in `finally`, or
|
||
* a failed upload leaves "Uploading…" standing for the life of the form. */
|
||
const wireUploader = (u) => {
|
||
const fail = (e) => { u.err.textContent = String((e && e.message) || e); u.err.hidden = false; };
|
||
u.choose.addEventListener('click', () => { u.err.hidden = true; u.input.click(); });
|
||
u.input.addEventListener('change', () => {
|
||
const f = u.input.files && u.input.files[0];
|
||
u.input.value = '';
|
||
if (!f) return;
|
||
u.err.hidden = true; u.choose.disabled = true;
|
||
u.choose.textContent = _('Uploading…', 'footstrap');
|
||
u.upload(f)
|
||
.then((tok) => { u.reflect(tok); if (u.after) u.after(tok); })
|
||
.catch(fail)
|
||
.finally(() => { u.choose.disabled = false; u.choose.textContent = u.label; });
|
||
});
|
||
u.remove.addEventListener('click', () => {
|
||
u.err.hidden = true; u.remove.disabled = true;
|
||
u.drop().then(() => u.reflect('')).catch(fail)
|
||
.finally(() => { u.remove.disabled = false; });
|
||
});
|
||
};
|
||
|
||
wireUploader({
|
||
choose: patChoose, remove: patRemove, input: patInput, err: patErr,
|
||
label: patChooseLabel, reflect: reflectPattern,
|
||
upload: assets.uploadPattern, drop: assets.removePattern,
|
||
/* `dom.callClassMethod` is how LuCI moves its own widgets from outside; setWallpaper is
|
||
* then called directly, because a programmatic setValue emits no `widget-change`. */
|
||
after: () => { dom.callClassMethod(seg, 'setValue', 'pattern'); setWallpaper('pattern'); }
|
||
});
|
||
|
||
wireUploader({
|
||
choose: chooseBtn, remove: removeBtn, input: fileInput, err: err,
|
||
label: chooseLabel, reflect: reflect,
|
||
upload: assets.uploadLoginBg, drop: assets.removeLoginBg
|
||
});
|
||
|
||
let seg;
|
||
const wallRow = group(_('Wallpaper', 'footstrap'), (label) => {
|
||
seg = selectCtl(axes.currentWallpaper(), {
|
||
off: _('Off', 'footstrap'),
|
||
pattern: _('Pattern', 'footstrap'),
|
||
file: _('File', 'footstrap')
|
||
}, setWallpaper, label);
|
||
return seg;
|
||
});
|
||
|
||
return [ wallRow ].concat(patRows, fileRows);
|
||
})();
|
||
|
||
/* ---- section 4: the router default and the version ----
|
||
*
|
||
* Save the current look as the router-wide default (fs-prefs writes /etc/config/footstrap over
|
||
* the scoped uci ACL). It does not change this browser — localStorage keeps overriding — so the
|
||
* saved default only shows on a fresh browser. The two Reset buttons below are the escape
|
||
* hatches, and they do not land in the same place. */
|
||
const saveBtn = E('button', { 'class': 'btn cbi-button-action', 'type': 'button' }, [ _('Save as default', 'footstrap') ]);
|
||
/* Two resets, because two things sit underneath a browser's tweaks (fs-prefs.js): "Reset to
|
||
* saved" clears them and lets every axis fall back to whatever the router holds, while "Reset
|
||
* to default" writes the theme's built-ins explicitly — the only way to say "as the theme
|
||
* ships" on a router with a saved default of its own. Neither touches
|
||
* /etc/config/footstrap. */
|
||
const resetSavedBtn = E('button', { 'class': 'btn', 'type': 'button' }, [ _('Reset to saved', 'footstrap') ]);
|
||
/* the stock destructive class, so the button discarding every local tweak is the red one
|
||
* (theme/55-buttons.css). "Reset to saved" stays neutral: it steps back to the shared state
|
||
* rather than discarding. */
|
||
const resetBtn = E('button', { 'class': 'btn cbi-button-negative', 'type': 'button' }, [ _('Reset to default', 'footstrap') ]);
|
||
/* Save's only visible failure surface. The realistic failure is the rpc rejecting — an expired
|
||
* session (403), a missing ACL, ubus down. A DELETED config is not caught: rpcd stages the set
|
||
* in the session and commit then no-ops without writing the file, returning success (measured
|
||
* on the router). The package owns that file and the read side falls back to built-in
|
||
* defaults. */
|
||
const saveErr = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' });
|
||
|
||
/* The Save button is the status: matching disables it, diverging enables it. Called after every
|
||
* axis change (via bump).
|
||
*
|
||
* Unless the browser refuses storage, where the comparison would always be true — nothing was
|
||
* written, so every current*() reads the router default back however far the page has been
|
||
* dragged from it. Say so instead, and leave the button enabled: pushing this browser's look to
|
||
* the router is the one thing that still works. */
|
||
function refreshSave() {
|
||
if (prefs.storageBroken()) {
|
||
saveBtn.disabled = false;
|
||
saveBtn.textContent = _('Save as default', 'footstrap');
|
||
saveErr.textContent = _('This browser is not storing preferences (site data is blocked), so a change here lasts until you reload. Saving as default still works and applies to every browser.', 'footstrap');
|
||
saveErr.hidden = false;
|
||
return;
|
||
}
|
||
const saved = axes.matchesSavedDefault();
|
||
saveBtn.disabled = saved;
|
||
saveBtn.textContent = saved ? _('Saved as default', 'footstrap') : _('Save as default', 'footstrap');
|
||
}
|
||
saveBtn.addEventListener('click', () => {
|
||
saveBtn.disabled = true;
|
||
saveErr.hidden = true;
|
||
axes.saveAsDefault()
|
||
.then(() => { saveErr.hidden = true; })
|
||
/* on failure refreshSave re-enables the button so the user can retry; the usual cause
|
||
* is a stale session, which a reload fixes. The raw rpc error stays in a title
|
||
* tooltip. */
|
||
.catch((e) => {
|
||
saveErr.textContent = _('Could not save the default. Reload the page and try again.', 'footstrap');
|
||
saveErr.title = String((e && e.message) || e);
|
||
saveErr.hidden = false;
|
||
})
|
||
.finally(refreshSave);
|
||
});
|
||
/* Two-click confirm on both: discarding local tweaks is destructive and a native confirm() is
|
||
* banned in this UI. Arming one disarms the other, so a primed button cannot be fired by a
|
||
* click meant for its neighbour. Each reload lands back on this tab — see armReturn(). */
|
||
const armed = new Map();
|
||
function disarm(btn, label) {
|
||
armed.delete(btn);
|
||
btn.textContent = label;
|
||
btn.classList.remove('fs-ap-armed');
|
||
}
|
||
function twoClick(btn, label, run) {
|
||
btn.addEventListener('click', () => {
|
||
if (!armed.has(btn)) {
|
||
[ ...armed.keys() ].forEach((other) => disarm(other, armed.get(other)));
|
||
armed.set(btn, label);
|
||
btn.textContent = _('Confirm reset', 'footstrap');
|
||
btn.classList.add('fs-ap-armed');
|
||
return;
|
||
}
|
||
disarm(btn, label);
|
||
run();
|
||
armReturn();
|
||
location.reload();
|
||
});
|
||
}
|
||
twoClick(resetSavedBtn, _('Reset to saved', 'footstrap'), axes.resetToSaved);
|
||
twoClick(resetBtn, _('Reset to default', 'footstrap'), axes.resetToBuiltin);
|
||
refreshSave(); /* correct label and enabled state before the first paint */
|
||
|
||
const versionLink = E('a', {
|
||
'class': 'fs-ap-version',
|
||
'href': ver.REPO_URL,
|
||
'target': '_blank',
|
||
/* `noreferrer` alone: it implies noopener, and the theme's other outward links spell it
|
||
* that way */
|
||
'rel': 'noreferrer'
|
||
}, [ ver.label() ]);
|
||
|
||
const defaults = [
|
||
/* the one row whose control is a pair of buttons, each named by its own text, so `make`
|
||
* ignores the caption rather than re-using it as an aria-label */
|
||
group(_('Router default', 'footstrap'),
|
||
() => E('div', { 'class': 'fs-ap-actrow' }, [ saveBtn, resetSavedBtn, resetBtn ]),
|
||
{ extra: saveErr })
|
||
];
|
||
|
||
|
||
defaults.push(E('div', { 'class': 'fs-ap-footer' }, [
|
||
E('div', { 'class': 'fs-ap-verrow' }, [ versionLink ])
|
||
]));
|
||
|
||
/* not .cbi-section: inside a tab pane that is a card within a card, and the stock tabs put
|
||
* their rows straight into the pane. These are grouping headings within one pane, styled by
|
||
* pages/80-appearance.css. */
|
||
const section = (title, rows) => E('div', { 'class': 'fs-ap-section' }, [
|
||
E('div', { 'class': 'fs-ap-head' }, [ E('h4', {}, [ title ]) ])
|
||
].concat(rows));
|
||
|
||
/* ---- the folded groups ----
|
||
* Nine colour fields and an uploader are the widest rows on the page and most admins never
|
||
* touch them, so each group is a disclosure, closed by default.
|
||
*
|
||
* A disclosure and not a switch: a switch answers "is this feature on", and turning it off
|
||
* would either revert nine colours or change nothing at all. Opening or closing a fold applies,
|
||
* un-applies and disables nothing.
|
||
*
|
||
* W3C APG disclosure pattern, as the menu's sections use: a <button> owning the region,
|
||
* `aria-expanded` on it and `aria-controls` pointing at the panel. `hidden` on the panel rather
|
||
* than a class, so a closed group leaves the tab order and the accessibility tree for free.
|
||
*
|
||
* The open/closed state is remembered per browser but is not an axis — it changes nothing about
|
||
* how the page looks — so it is absent from AXIS_KEYS, snapshotAxes() and the pre-paint. */
|
||
let foldSeq = 0;
|
||
function foldable(title, rows, key) {
|
||
const id = 'fs-ap-fold-' + (++foldSeq);
|
||
let open = (prefs.lsGet(key) === 'on');
|
||
/* id only: `aria-controls` needs one, and no rule has ever styled the panel itself */
|
||
const body = E('div', { 'id': id }, rows);
|
||
const btn = E('button', {
|
||
'type': 'button', 'class': 'fs-ap-fold', 'aria-expanded': String(open), 'aria-controls': id
|
||
}, [
|
||
E('h4', {}, [ title ]),
|
||
/* the same chevron the overview's card toggles draw: an empty box whose ::after is two
|
||
* borders rotated 45° (pages/20-overview.css), not a second <svg> to keep in step.
|
||
* Empty and aria-hidden — the state is the button's aria-expanded, which is also what
|
||
* CSS rotates it off. */
|
||
E('span', { 'class': 'fs-ap-chev', 'aria-hidden': 'true' })
|
||
]);
|
||
const paint = () => {
|
||
body.hidden = !open;
|
||
btn.setAttribute('aria-expanded', String(open));
|
||
};
|
||
btn.addEventListener('click', () => {
|
||
open = !open;
|
||
prefs.lsSet(key, open ? 'on' : 'off');
|
||
paint();
|
||
/* refreshed on open because the axes below may have moved while it was collapsed;
|
||
* skipped while closed, where nothing is on screen to be wrong */
|
||
if (open) refreshColours();
|
||
});
|
||
paint();
|
||
return E('div', { 'class': 'fs-ap-section' }, [
|
||
E('div', { 'class': 'fs-ap-head' }, [ btn ]), body
|
||
]);
|
||
}
|
||
|
||
/* Colours and Surfaces are one fold: the same job, split into two headings only because a
|
||
* figure and the sheet it sits on are read differently */
|
||
const page = E('div', { 'class': 'fs-ap' }, [
|
||
section(_('Interface', 'footstrap'), shell),
|
||
foldable(_('Colours', 'footstrap'),
|
||
colours.concat([ E('div', { 'class': 'fs-ap-head fs-ap-sub' }, [ E('h4', {}, [ _('Surfaces', 'footstrap') ]) ]) ], surfaces),
|
||
'fs-ui-colours'),
|
||
foldable(_('Background', 'footstrap'), wallpaper, 'fs-ui-background'),
|
||
section(_('Defaults', 'footstrap'), defaults)
|
||
]);
|
||
|
||
/* The first fill, deferred one microtask so the tree above is finished. It does not wait for
|
||
* the form to be in the document: every readout resolves inside fs-widgets against a hidden
|
||
* probe attached to <body>, so a detached form still reads the live palette. */
|
||
Promise.resolve().then(refreshColours);
|
||
return page;
|
||
}
|
||
|
||
/* ---- mounting it on the stock System page ----
|
||
*
|
||
* The same shape as fs-overview.js's: a chrome module is instantiated once per page load, so it
|
||
* notices SPA navigation itself through `body[data-page]`, which the server template and fs-router
|
||
* both stamp with the dispatch path. */
|
||
const PAGE = 'admin-system-system';
|
||
/* A reset reloads the page, and a reload opens the tab LuCI remembers — never this one, since
|
||
* ui.tabs only knows the tabs it built itself. sessionStorage rather than a URL fragment: the
|
||
* fragment is the stock page's business, and a stale one would re-open this tab on every later
|
||
* visit. The key is read once and removed, so it survives exactly one reload. */
|
||
const RETURN_KEY = 'fs-ap-return';
|
||
function armReturn() { try { sessionStorage.setItem(RETURN_KEY, '1'); } catch (e) {} }
|
||
function takeReturn() {
|
||
try {
|
||
if (sessionStorage.getItem(RETURN_KEY) === null) return false;
|
||
sessionStorage.removeItem(RETURN_KEY);
|
||
return true;
|
||
} catch (e) { return false; }
|
||
}
|
||
const MARK = 'fs-ap'; /* the built form's class, and how mount() knows it is already there */
|
||
/* how long the stock view gets to render its tabs before a missing group counts as a failure */
|
||
const TAB_DEADLINE = 5000;
|
||
const TAB = 'fs-appearance'; /* the pane's data-tab, which ui.tabs' click handler matches on */
|
||
|
||
let _routeObserver = null, _viewObserver = null, _observedRoot = null, _building = false;
|
||
|
||
function onPage() { return (document.body.getAttribute('data-page') || '') === PAGE; }
|
||
|
||
function stopWatch() {
|
||
if (_viewObserver) _viewObserver.disconnect();
|
||
_viewObserver = null;
|
||
_observedRoot = null;
|
||
}
|
||
|
||
/* The stock tab GROUP: the element whose children are the panes, which ui.tabs marks
|
||
* data-initialized when it builds the menu; the menu it inserted is that element's previous
|
||
* sibling. Both are read from the DOM, because a group that is not initialised yet is a page still
|
||
* rendering, not a page without tabs.
|
||
*
|
||
* The flag and the sibling are the whole test. The panes are deliberately not looked for by class:
|
||
* a modern pane carries none — form.js gives it `data-tab` and `data-tab-title`, and
|
||
* `.cbi-tabcontainer` is luci-compat vocabulary — so matching on that silently finds nothing on a
|
||
* page that plainly has tabs.
|
||
*
|
||
* Groups belonging to the page just left are disqualified: the router stamps body[data-page]
|
||
* before the incoming view renders, and #view still holds the outgoing page's DOM at that moment,
|
||
* so mount() would append the form and a clickable "Footstrap" <li> to another page's tab strip:
|
||
* arriving at System -> System from Network -> DHCP that was two builds for one arrival, and the
|
||
* tab sat on the DHCP strip for 66 ms on localhost (an RTT or more on a real router).
|
||
* The incoming view's own group is a fresh element and is not in this set. */
|
||
const _staleGroups = new WeakSet();
|
||
function disqualifyCurrentGroups() {
|
||
const view = document.getElementById('view');
|
||
if (!view) return;
|
||
for (const g of view.querySelectorAll('[data-initialized="true"]'))
|
||
_staleGroups.add(g);
|
||
}
|
||
|
||
function tabGroup(view) {
|
||
for (const g of view.querySelectorAll('[data-initialized="true"]')) {
|
||
if (_staleGroups.has(g)) continue;
|
||
const menu = g.previousElementSibling;
|
||
if (menu?.classList.contains('cbi-tabmenu'))
|
||
return { group: g, menu };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/* Append the pane and its tab once the stock view has rendered. LuCI's system.js resolves its own
|
||
* promises before it puts anything in #view, so there is nothing to hook but the DOM — hence the
|
||
* observer, which also covers the view being re-rendered (a Save & Apply redraws the map).
|
||
*
|
||
* The tab is added by hand rather than by calling ui.tabs.initTabGroup again: that returns
|
||
* immediately on a group carrying data-initialized, and clearing the flag to re-run it builds a
|
||
* second menu beside the first and drops the stock tabs' click bindings. One <li>, the same click
|
||
* handler ui.tabs binds to every other tab, and the pane that handler expects.
|
||
*
|
||
* Idempotent through the marker, since the form's own construction is a mutation the observer sees.
|
||
*
|
||
* A map redraw (Save without Apply) rebuilds the group and `ui.tabs` stamps `data-initialized` as
|
||
* an attribute change that can land after the last childList change, so the observer watches that
|
||
* attribute too — otherwise the tab is missing until the next navigation (openwrt/luci#8903).
|
||
*
|
||
* A ladder of retries on a widening delay shipped beside that watch and is gone: instrumented over
|
||
* ten sessions on both package managers — a full load, an SPA return, a Save and a Save & Apply —
|
||
* it mounted the tab zero times, only ever arming a timer that woke to find the work done. The
|
||
* deadline below could once be seen mounting it, but only because watch() was binding the observer
|
||
* to a node the router was about to replace; with that fixed the deadline is a diagnostic again. */
|
||
function mount() {
|
||
const view = document.getElementById('view');
|
||
if (!view || !onPage()) return;
|
||
if (view.querySelector('.' + MARK)) return;
|
||
if (_building) return;
|
||
const tabs = tabGroup(view);
|
||
if (!tabs) return; /* not built yet; the observer calls again when it is */
|
||
_building = true;
|
||
render()
|
||
.then((form) => {
|
||
/* re-check: render() resolves on a microtask, and the view may have been replaced or
|
||
* navigated away from meanwhile */
|
||
const v = document.getElementById('view');
|
||
if (!onPage() || !v || v.querySelector('.' + MARK)) return;
|
||
const t = tabGroup(v);
|
||
if (!t) return;
|
||
/* Named after the theme, not after what it does: beside four stock tabs that are all
|
||
* "what this page configures", a fifth called Appearance would read as another facet
|
||
* of the router. A proper noun, so deliberately untranslated. */
|
||
const title = 'Footstrap';
|
||
/* data-tab-active is deliberately absent: the stock page opens on whichever tab it
|
||
* opened on before, and a theme has no business taking that over. The pane's shape is
|
||
* a stock pane's — `data-tab` + `data-tab-title`, no class. */
|
||
t.group.appendChild(E('div', {
|
||
'data-tab': TAB,
|
||
'data-tab-title': title
|
||
}, [ form ]));
|
||
const link = E('a', { 'href': '#' }, [ title ]);
|
||
link.addEventListener('click', ui.tabs.switchTab.bind(ui.tabs));
|
||
t.menu.appendChild(E('li', { 'class': 'cbi-tab-disabled', 'data-tab': TAB }, [ link ]));
|
||
/* if this load is the one a reset asked for, open on it: clicking the link goes through
|
||
* ui.tabs' own switchTab, so nothing here reimplements the switch */
|
||
if (takeReturn()) link.click();
|
||
})
|
||
.catch((e) => console.error('footstrap: the Appearance tab failed to build', e))
|
||
.finally(() => { _building = false; });
|
||
}
|
||
|
||
function watch() {
|
||
const view = document.getElementById('view');
|
||
/* The CONTAINER, by id — not `#view`, which is not the same element for long. A client
|
||
* navigation builds a fresh one before it is in the document and swaps it in afterwards, while
|
||
* watch() runs on the `data-page` stamp, which comes first: the node bound here then reported
|
||
* `isConnected: false` while `#view` and `#maincontent` were both alive, and a Save's redraw
|
||
* produced four mutation batches that reached no callback at all. `#maincontent` outlives every
|
||
* swap, so the swap is itself a childList record. Save put the tab back in 250 ms after this,
|
||
* against 1,750-2,250 ms of waiting for the deadline before it. */
|
||
const root = document.getElementById('maincontent') || view;
|
||
if (_viewObserver && _observedRoot !== root) stopWatch();
|
||
if (_viewObserver || !view || !onPage()) return;
|
||
_observedRoot = root;
|
||
_viewObserver = new MutationObserver(mount);
|
||
/* `data-initialized` is when the group becomes usable, and the filter is load-bearing — proven
|
||
* by staging openwrt/luci#8903 rather than waiting for it: tear the tab, the pane and the
|
||
* attribute down in one task, restore the attribute ALONE 1,200 ms later, and the tab is back
|
||
* 4 ms after it. Without the filter it never comes back. No ordinary Save separates the two. */
|
||
_viewObserver.observe(root, {
|
||
childList: true, subtree: true,
|
||
attributes: true, attributeFilter: [ 'data-initialized' ],
|
||
});
|
||
mount();
|
||
/* A deadline on an otherwise silent failure. tabGroup() reads two private ui.tabs facts — the
|
||
* `data-initialized` marker and the `cbi-tabmenu` class on the menu — and mount() writes a
|
||
* third, `cbi-tab-disabled` on the item it appends; one such fact has already moved between
|
||
* 24.10 and 25.12 (`data-tab-group` was dropped unannounced). If another does, mount() returns
|
||
* early on every mutation: the stock page renders, nothing throws, and every Appearance axis
|
||
* is unreachable. */
|
||
window.setTimeout(() => {
|
||
/* It asks, it does not repair: with the observer on a node that survives a navigation the
|
||
* repair this used to attempt became unreachable, and the whole matrix passes without it.
|
||
* A group still found here means the observer is working, so there is nothing to report. */
|
||
const v = document.getElementById('view');
|
||
if (!onPage() || !v || v.querySelector('.' + MARK) || _building || tabGroup(v)) return;
|
||
console.error('footstrap: the Appearance tab could not be attached — this page has tabs, but '
|
||
+ 'ui.tabs no longer marks them the way fs-appearance.js looks for. Every Appearance axis '
|
||
+ 'is unreachable until that is updated.');
|
||
}, TAB_DEADLINE);
|
||
}
|
||
|
||
/* called once by menu-footstrap-common's init; everything route-dependent hangs off the data-page
|
||
* observer inside */
|
||
function wire() {
|
||
if (_routeObserver || !document.body) return;
|
||
_routeObserver = new MutationObserver(() => {
|
||
/* before deciding anything: whatever is in #view when data-page changes belongs to the
|
||
* page being left (see _staleGroups) */
|
||
disqualifyCurrentGroups();
|
||
return onPage() ? watch() : stopWatch();
|
||
});
|
||
_routeObserver.observe(document.body, { attributes: true, attributeFilter: [ 'data-page' ] });
|
||
if (onPage()) watch();
|
||
}
|
||
|
||
return baseclass.extend({
|
||
wire
|
||
});
|