'use strict';
'require baseclass';
'require ui';
'require dom';
'require fs-prefs as prefs';
'require fs-axes as axes';
'require fs-assets as assets';
'require fs-version as ver';
/* The Appearance controls: the DOM that presents the axes. It owns no preference — fs-prefs.js
* holds the axes and fs-version.js the version string; this file is the form they are shown in.
*
* Built once, for the dispatched page: `view/footstrap/appearance.js` — a menu entry, an ordinary
* route — calls `renderStandalone()` below, the whole surface this file exposes. Before the page
* existed the same form was stapled onto a tab on System -> System instead, watching for the stock
* form's own tab strip with a MutationObserver, a 5 s deadline, a `body[data-page]` tracker, a
* stale-group WeakSet disqualifying the outgoing page's own DOM from a false match, and a
* sessionStorage flag so a reset's reload landed back on the tab rather than whichever one LuCI
* remembered. A Save & Apply on a real router once lost that tab outright, never reproduced on
* either stand — a mount stapled onto another app's own render is a timing dependency on that
* render, by construction, and the most likely account of a user report that could never be
* reproduced either way. All of it is gone now that the page owns a route of its own. Twenty-one
* axes, nine of them with a colour field, a swatch and a contrast readout, do not fit a floating
* popover that has to trap Tab and stay inside a 320px column, and keeping both containers would
* render every axis twice.
*
* The version line makes no request and must not grow one: which version is INSTALLED is what this
* page answers, and which is available is the package manager's question. */
/* ---- colour: reading what the page is actually painted ----
*
* This lived in fs-widgets.js, which the menu and the search palette also require — so the whole
* colour engine was downloaded on every admin page to be used on this one. It is 3 KB of probe,
* canvas and WCAG arithmetic that nothing outside this form has ever called: `colorControl` was
* fs-widgets' only colour export and this file its only consumer. */
/* ---- colour: reading what the page is actually painted ----
*
* Two questions no stored value answers: what colour a role is right now (the palette's own while
* the axis is off — there is deliberately no copy of the palette in JS), and what contrast the
* user's colour lands at. Both are about the computed cascade, so both are asked of the browser.
*
* `getComputedStyle(root).getPropertyValue('--fs-accent')` answers neither: a custom property
* computes to the token stream after var() substitution, so `oklch(from … l c H)` comes back
* unevaluated. Setting the expression as a real `color` and reading it back makes the browser
* resolve it — relative colour, color-mix() and the tint's calc() are what the theme is made of.
* One hidden probe is reused; an element per query would thrash layout on every slider drag. */
let _probe = null;
function probeColor(expr) {
if (!_probe) {
/* Off-screen rather than display:none, so the reading does not depend on a display:none
* element computing `color` in every engine. It has no text and no size, so it paints
* nothing.
*
* Every declaration is !important (issue #19): this is an unmarked element in a document
* shared with `luci-app-*`, and an app's unlayered `span { color: … !important }` outranks
* a layer and a plain inline style alike. A probe that loses its own colour reports the
* app's, which then becomes the admin's saved axis on the next confirm. */
_probe = E('span', { 'aria-hidden': 'true' });
_probe.style.cssText = 'position:fixed!important;left:-9999px!important;top:0!important;'
+ 'width:0!important;height:0!important;overflow:hidden!important;'
+ 'pointer-events:none!important;';
document.body.appendChild(_probe);
}
/* cleared first: an expression the engine rejects leaves the previous colour standing, which
* would report a stale answer as a fresh one */
_probe.style.setProperty('color', '');
_probe.style.setProperty('color', expr, 'important');
return getComputedStyle(_probe).color;
}
/* A computed colour -> [r,g,b] 0..255, or null. Rasterised, not parsed: a computed `color` keeps
* the space it was authored in, so `oklch(0.54 0.19 300)` would parse as three numbers in the
* wrong units and produce a colour nobody chose — measured: #010078, graded "Too faint to read",
* in the hex field, the swatch and the contrast readout alike. Painting one pixel makes the engine
* convert instead (tools/export-tier.mjs uses the same method). The string parse remains only as
* the fallback for an engine with no 2D context, where only the legacy `rgb()`/`color(srgb …)`
* forms can appear. */
let _cx = null;
function rasterCtx() {
if (_cx !== null) return _cx;
try {
const cv = document.createElement('canvas');
cv.width = cv.height = 1;
_cx = cv.getContext('2d', { willReadFrequently: true }) || false;
} catch (e) { _cx = false; }
return _cx;
}
function parseColor(s) {
const str = String(s || '');
const cx = rasterCtx();
if (cx) {
/* fillStyle keeps the last value it could parse, so a colour this engine rejects would
* report the previous one as a fresh reading — the trap probeColor() clears for */
cx.fillStyle = '#000';
cx.fillStyle = str;
cx.clearRect(0, 0, 1, 1);
cx.fillRect(0, 0, 1, 1);
const d = cx.getImageData(0, 0, 1, 1).data;
if (d[3] === 255) return [ d[0], d[1], d[2] ];
/* translucent: composite over nothing is meaningless for a readout, so fall through */
}
const nums = str.match(/[\d.]+/g);
if (!nums || nums.length < 3) return null;
const unit = (/^color\(/i).test(str) ? 255 : 1;
return nums.slice(0, 3).map((n) => Math.max(0, Math.min(255, parseFloat(n) * unit)));
}
/* WCAG 2.x relative luminance and contrast ratio, on sRGB. Used only to report: the theme states
* what a colour costs and leaves the choice with the user, never correcting it (03-palettes.css
* derives the ink over a fill, which is a different question). */
function luminance(rgb) {
const c = rgb.map((v) => {
const x = v / 255;
return (x <= .03928) ? (x / 12.92) : Math.pow((x + .055) / 1.055, 2.4);
});
return (.2126 * c[0]) + (.7152 * c[1]) + (.0722 * c[2]);
}
function contrastRatio(fgExpr, bgExpr) {
const fg = parseColor(probeColor(fgExpr)), bg = parseColor(probeColor(bgExpr));
if (!fg || !bg) return null;
const a = luminance(fg), b = luminance(bg);
return (Math.max(a, b) + .05) / (Math.min(a, b) + .05);
}
/* #rrggbb, because accepts nothing else. An unparseable colour becomes black
* rather than throwing: the text field beside the swatch is the authoritative one. */
function toHex(s) {
const rgb = parseColor(s) || [ 0, 0, 0 ];
return '#' + rgb.map((v) => Math.round(v).toString(16).padStart(2, '0')).join('');
}
/* One colour axis: a native swatch, a hex field and a button back to the palette's own colour.
* Reports through onPick as a hex string, or 0 for "back to the palette", either of which the
* caller hands straight to fs-prefs.js's colorAxis.
*
* There is no hue slider and one is not coming back: rotating a hue keeps the palette's chroma,
* so no angle of it reaches a grey. The axis still accepts a stored hue (1–360) and the stylesheet
* still rotates the palette by one, so a saved value goes on working.
*
* `opts.probe` is the live token the effective colour is read back from, so the field shows the
* palette's colour while the axis is off without a copy of the palette in JS. `opts.contrast` is
* the pair whose ratio is reported under the row. */
function colorControl(current, onPick, label, opts) {
const o = opts || {};
/* type=color leaves the picker to the browser: accessible without reimplementing a colour
* wheel, and native on a phone. The text field beside it takes a pasted hex and is the
* fallback where the browser draws no picker. */
const swatch = E('input', { 'type': 'color', 'class': 'fs-color-swatch', 'aria-label': label || '' });
const field = E('input', {
'type': 'text', 'class': 'fs-color-hex', 'spellcheck': 'false', 'autocomplete': 'off',
'inputmode': 'text', 'maxlength': '7', 'aria-label': label || ''
});
const clear = E('button', { 'class': 'btn fs-color-clear', 'type': 'button' }, [ _('Palette', 'footstrap') ]);
const ratio = o.contrast ? E('div', { 'class': 'cbi-value-description fs-color-contrast' }) : null;
/* what the axis holds right now: the page can change it behind this control (a preset, Reset
* to default), so a private copy would go stale. `current` is only the build-time value. */
const currentOf = o.read || (() => current);
/* Repaint everything that mirrors the axis. Called after every edit, and through the returned
* refresh() after a preset, palette switch or dark-mode flip — each changes what the palette's
* own colour is while this axis stays off. */
function reflect(v) {
const live = probeColor(o.probe);
const hex = (typeof v === 'string') ? v : toHex(live);
swatch.value = hex;
/* do not fight the user mid-edit: `#0` is a legal prefix, and overwriting the field on
* every keystroke made the input impossible to type into */
if (document.activeElement !== field) field.value = hex;
/* the button back to the palette doubles as the axis state readout: enabled means the axis
* holds a colour of its own, disabled means the field shows the palette's */
clear.disabled = !v;
if (!ratio) return;
const r = contrastRatio(o.contrast.fg, o.contrast.bg);
if (r === null) { ratio.textContent = ''; ratio.removeAttribute('title'); return; }
/* The readout states what the ratio means; the number itself stays in the title.
* Thresholds are WCAG AA: 4.5:1 for body text, 3:1 for large text and for a UI shape, so a
* hairline is graded on the second (`kind: 'shape'`) and warns rather than fails — a faint
* border is a legitimate choice.
*
* Class names are written out whole: tools/fs-orphans.mjs sweeps dead CSS by matching
* fs-* tokens in the source, and a concatenated name is invisible to it. */
const where = o.contrast.label;
const grade = (o.contrast.kind === 'shape')
? ((r >= 3)
? { cls: 'fs-contrast-aa', text: _('Clearly visible %s', 'footstrap').format(where) }
: { cls: 'fs-contrast-aa-large', text: _('Barely visible %s', 'footstrap').format(where) })
: (r >= 4.5)
? { cls: 'fs-contrast-aa', text: _('Easy to read %s', 'footstrap').format(where) }
: (r >= 3)
? { cls: 'fs-contrast-aa-large', text: _('Hard to read %s — large text only', 'footstrap').format(where) }
: { cls: 'fs-contrast-low', text: _('Too faint to read %s', 'footstrap').format(where) };
ratio.className = 'fs-color-contrast ' + grade.cls;
ratio.textContent = grade.text;
ratio.title = _('Contrast %s:1 (WCAG AA wants %s:1 here)', 'footstrap')
.format(r.toFixed(1), (o.contrast.kind === 'shape') ? '3' : '4.5');
}
const pick = (v) => { onPick(v); reflect(v); };
swatch.addEventListener('input', () => pick(swatch.value.toLowerCase()));
/* commit on blur and Enter, not per keystroke: a half-typed `#0096` would repaint the page
* under the cursor. An unparseable value snaps back to what the axis holds, so the field
* cannot claim a colour the page is not painted in. */
const commit = () => {
const v = field.value.trim().toLowerCase();
if ((/^#[0-9a-f]{6}$/).test(v)) pick(v);
else reflect(currentOf());
};
field.addEventListener('blur', commit);
field.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); commit(); } });
clear.addEventListener('click', () => pick(0));
const wrap = E('div', { 'class': 'fs-colorctl' + (o.cls ? ' ' + o.cls : '') }, [
E('div', { 'class': 'fs-color-row' }, [ swatch, field, clear ])
].concat(ratio ? [ ratio ] : []));
/* the caller decides when this runs: probeColor() needs the document, and this control is not
* in it yet */
wrap.fsRefresh = () => reflect(currentOf());
return wrap;
}
/* Build the whole form. Returns the root element, for renderStandalone() below to hand to the
* page's own render().
*
* Everything applies immediately and there is nothing to save: every axis is this browser's, in
* localStorage, and the page repaints under the control as it moves. Only "Save to router" writes
* anything, pushing the current look to the ROUTER for other browsers. That distinction is the
* model (docs/design-system.md), and why this page has no Save/Reset footer of LuCI's own —
* view/footstrap/appearance.js nulls handleSave/handleSaveApply/handleReset, which is what actually
* drops it: view.js's own default footer (Save & Apply | Apply unchecked | Save | Reset, wired to
* apply_rollback) renders and runs otherwise, POSTing on a form with nothing staged (measured,
* live, before that null). */
function build() {
/* every saved axis re-checks the Save button after applying, so it greys the moment this
* browser matches the saved default and un-greys when it diverges. Wrapped around the appliers
* because the controls call them directly and have no other seam back here. */
const bump = (fn) => (v) => { fn(v); refreshSave(); };
/* Every colour control mirrors something it does not own: the palette's colour while its own
* axis is off, and the contrast that colour lands at. A palette switch or dark-mode flip moves
* all of it under controls nobody touched, so they refresh together. */
const colourCtls = [];
const refreshColours = () => colourCtls.forEach((c) => c.fsRefresh());
/* wrap an applier so the colour readouts follow it: mode and palette change what every axis is
* measured against */
const repaint = (fn) => (v) => { fn(v); refreshColours(); };
/* One captioned row in LuCI's own shape: `.cbi-value` > `label.cbi-value-title` +
* `.cbi-value-field`. Nothing here styles those class names — base/30-forms.css and
* theme/60-inputs.css already lay them out — so this page inherits every future fix to the form
* layout instead of keeping a private copy. It also puts the row on a shared surface (zone 2,
* where an app may win on specificity), which is right for a page inside #view.
*
* `make` is handed the same label string the caption renders, because every control needs it a
* second time as its aria-label; stating it twice is how the visible caption and what a screen
* reader announces drift apart. `extra` carries rows with more than a control, `opts.cls` marks
* the rows CSS has to single out. */
const group = (label, make, opts) => {
const o = opts || {};
return E('div', { 'class': 'cbi-value' + (o.cls ? ' ' + o.cls : '') }, [
E('label', { 'class': 'cbi-value-title' }, [ label ]),
E('div', { 'class': 'cbi-value-field' }, [ make(label) ].concat(o.extra || []))
]);
};
/* the three literals every colour row repeats; a string literal survives minification intact */
const CARD_BG = 'var(--fs-panel)', INK = 'var(--fs-text)', ON_CARD = _('on a card', 'footstrap');
/* ---- the controls are LuCI's own ----
*
* Every enum axis is a `ui.Select` and every number a `ui.RangeSlider`: the widgets the other
* tabs are built from, already dressed by this stylesheet (`select` in base/30-forms.css,
* `.cbi-range-slider` in theme/60-inputs.css). Both exist on every release this theme supports
* — checked against openwrt-24.10, since `ui.RangeSlider` is the one that did not exist further
* back.
*
* Listen for `widget-change` from UIElement, not for an event on the inner element: that is the
* seam the widget publishes, and reaching past it ties this to how the widget happens to be
* built. RangeSlider also emits `widget-update` while the handle moves, which is what resizes
* the page under the drag; the appliers are idempotent, so wiring both costs nothing. */
const selectCtl = (current, choices, apply, label) => {
const w = new ui.Select(String(current), choices, { widget: 'select', sort: Object.keys(choices) });
const node = w.render();
node.setAttribute('aria-label', label);
node.addEventListener('widget-change', () => apply(w.getValue()));
return node;
};
const sliderCtl = (current, min, max, apply, label, opts) => {
const o = opts || {};
const w = new ui.RangeSlider(String(current), {
min: min, max: max, step: o.step || 1
});
const node = w.render();
node.setAttribute('aria-label', label);
const push = () => apply(parseInt(w.getValue(), 10));
/* `live: false` for an axis whose value moves THIS CONTROL. Content width widens the column
* the Appearance page is drawn in, so applying mid-drag slides the handle out from under
* the pointer, the pointer catches up, and one small drag runs the value to the maximum —
* reported from a router, and not a thing any other slider here can do: rounding and the
* tint sliders repaint around a control that stays where it is. On release the page still
* follows, and a keyboard user is unaffected either way: an arrow key on a range input
* fires `change` as well as `input`, so the value applies at once. */
if (o.live !== false) node.addEventListener('widget-update', push);
node.addEventListener('widget-change', push);
return node;
};
/* one colour axis: `probe` is the live token the control reads the effective colour back from,
* `contrast` the pair it reports */
const colourGroup = (label, axis, probe, contrast, opts) => group(label, (lbl) => {
const ctl = colorControl(axis.current(), bump(axis.apply), lbl, {
probe: probe,
read: axis.current,
contrast: contrast,
cls: (opts && opts.cls) || ''
});
colourCtls.push(ctl);
return ctl;
}, opts);
/* Every label here carries the 'footstrap' context (`_(str, ctx)`, key `ctx\1str`). LuCI serves
* one merged catalogue — load_catalog() loads every *..lmo and a lookup returns the first
* archive holding the hash — so a bare msgid is a global name any luci-app may take, and
* readdir order picks the winner: the layout toggle rendered "Максимум" on a Russian router
* because another catalogue translates "Top" as "maximum" (issue #6). Contexting cannot be
* selective. The chrome, the login/notice sentences and the System/Memory/Storage headings are
* deliberately bare — inheriting luci-base's translation covers the ~40 languages this theme
* has no catalogue for. */
/* ---- section 1: the shell ---- */
const shell = [
group(_('Layout', 'footstrap'), (label) => selectCtl(prefs.currentLayout(), {
sidebar: _('Sidebar', 'footstrap'),
top: _('Top', 'footstrap')
}, bump(prefs.applyLayout), label)),
group(_('Theme', 'footstrap'), (label) => selectCtl(prefs.currentMode(), {
auto: _('Auto', 'footstrap'),
light: _('Light', 'footstrap'),
dark: _('Dark', 'footstrap')
}, bump(repaint(prefs.applyMode)), label)),
group(_('Palette', 'footstrap'), (label) => selectCtl(axes.currentPalette(), {
footstrap: 'Footstrap',
hicontrast: 'Hi-Contrast',
/* names the OTHER package, luci-theme-bootstrap, whose colours this palette is —
* so it is a proper noun and stays untranslated, like the two above it */
bootstrap: 'Bootstrap',
/* names the OTHER package again, luci-theme-openwrt-2020, whose colourway this is */
'2020': 'OpenWrt 2020',
/* names the OpenWrt forum (forum.openwrt.org), whose Discourse colourway this is —
* a proper noun like the three above it, not the English common noun "forum" */
forum: 'Forum'
}, bump(repaint(axes.applyPalette)), label)),
group(_('Density', 'footstrap'), (label) => selectCtl(prefs.currentDensity(), {
compact: _('Compact', 'footstrap'),
normal: _('Normal', 'footstrap'),
large: _('Large', 'footstrap')
}, bump(prefs.applyDensity), label)),
/* issue #44: the content column's own cap, separate from Density (which moves type and
* air, not the column's ceiling). The slider STARTS at the 1280px the theme has always
* shipped and only ever widens — there is no reason to offer a column narrower than the
* one every page was designed against, and keeping 1280 as the left end also keeps
* --fs-content-min (500px, what the sidebar-to-bar fold is measured against) out of reach
* by construction rather than by a rule someone has to remember. */
group(_('Content width', 'footstrap'),
(label) => sliderCtl(axes.currentContentWidth(), 1280, 3840,
bump(axes.applyContentWidth), label, { step: 40, live: false })),
group(_('Rounding', 'footstrap'),
(label) => sliderCtl(axes.currentRadius(), 0, 20, bump(axes.applyRadius), label)),
/* The top layout has no accordion, so this switch is meaningless there: always built,
* hidden by CSS (:root[data-layout="top"] .fs-ap-submenus). Do not wrap it in an
* `if (currentLayout() !== 'top')` — the page is built once, so the branch would freeze
* the control to the layout the page loaded in while CSS morphs the chrome live. */
group(_('Submenus', 'footstrap'), (label) => selectCtl(
prefs.currentAutoCollapse() ? 'on' : 'off', {
off: _('Keep open', 'footstrap'),
on: _('Auto-collapse', 'footstrap')
}, bump(prefs.applyAutoCollapse), label),
{ cls: 'fs-ap-submenus' })
];
/* ---- section 2: colours ---- */
const colours = [
/* the caption says what the axis is for: "Tint" alone reads as decoration, and nobody
* would look for the router-identity cue under it */
colourGroup(_('Tint (router identification)', 'footstrap'), {
current: axes.currentTint, apply: axes.applyTint
}, 'var(--fs-bg)', {
/* the canvas is the one axis with no derived ink: its text is --fs-text, a palette
* token this axis must not move, so the ratio is reported instead of corrected */
fg: 'var(--fs-text)', bg: 'var(--fs-bg)', label: _('on the canvas', 'footstrap')
}, { cls: 'fs-ap-tint' }),
/* The strength half of the Tint, meaningful only in hue mode: a hex canvas is the colour
* asked for, with no chroma of ours to scale. CSS hides it in the other two states.
*
* Not called "Density": that is the select above, and this string is both the caption and
* the aria-label, so a screen reader would announce two rows under one name. */
group(_('Tint strength', 'footstrap'),
(label) => sliderCtl(axes.currentTintStrength(), 0, 200, bump(repaint(axes.applyTintStrength)), label, {
step: 5
}), { cls: 'fs-ap-tint fs-ap-tintstr' }),
/* recolours the accented controls (buttons/toggles/sliders/focus rings), not the canvas.
* Measured as text on a card, the use that fails first: as a fill it carries derived ink,
* as a link or status label it carries only itself. It is also what answers #20 ("sometimes
* you want grey or black"), taking any #rrggbb — the colour-chip presets that once sat here
* are not coming back. */
/* Four status roles, one shape: the role's own colour read against a card. Written out
* eight times between here and the surfaces below, they cost their repeated literals in
* full — a string is not mangled — so the rows are data and the row is stated once. */
...[
[ _('Accent', 'footstrap'), axes.currentAccent, axes.applyAccent, 'var(--fs-accent)' ],
[ _('Good', 'footstrap'), axes.currentGood, axes.applyGood, 'var(--fs-good)' ],
[ _('Warning', 'footstrap'), axes.currentWarn, axes.applyWarn, 'var(--fs-warn)' ],
[ _('Danger', 'footstrap'), axes.currentDanger, axes.applyDanger, 'var(--fs-danger)' ]
].map(([ label, current, apply, ink ]) =>
colourGroup(label, { current, apply }, ink, { fg: ink, bg: CARD_BG, label: ON_CARD }))
];
/* ---- the surfaces: the sheet the UI is drawn on ----
* Cards, inset controls, the chrome bar and the hairlines between them. Body text is read on
* each, so each reports --fs-text against itself. No ink is derived here: --fs-text is the
* palette's, and moving it would recolour the very thing being measured against.
*
* The hairline takes the 3:1 UI-component threshold instead — a border is a shape, not a label
* — and below that it is decoration, which a hairline is entitled to be, so the readout states
* the number and leaves the call to the admin. */
const surfaces = [
/* Same rows, one column wider: a surface reports the ink read ON it, which is --fs-text
* for the three that carry body text and the hairline itself for the border. */
...[
[ _('Cards', 'footstrap'), axes.currentCard, axes.applyCard, CARD_BG, INK, CARD_BG, ON_CARD ],
[ _('Controls', 'footstrap'), axes.currentControl, axes.applyControl, 'var(--fs-panel2)', INK, 'var(--fs-panel2)', _('on a control', 'footstrap') ],
[ _('Sidebar and bar', 'footstrap'), axes.currentBar, axes.applyBar, 'var(--fs-bar-bg)', INK, 'var(--fs-bar-bg)', _('in the sidebar', 'footstrap') ],
[ _('Borders', 'footstrap'), axes.currentLine, axes.applyLine, 'var(--fs-border)', 'var(--fs-border)', CARD_BG, ON_CARD, 'shape' ]
].map(([ label, current, apply, probe, fg, bg, where, kind ]) =>
colourGroup(label, { current, apply }, probe, { fg, bg, label: where, kind }))
];
/* ---- section 3: the wallpaper and the rows each value brings ----
*
* Wallpaper is three-valued: Off, Pattern (an uploaded SVG, tiled and recoloured) and File (an
* uploaded photo). The rows a value brings are SIBLINGS of the Wallpaper row, not children of
* its field: nesting a `.cbi-value` inside a `.cbi-value-field` puts a second caption column
* inside the first, and those controls started 216px right of every other one on the page,
* measured on the router. Flat rows hidden as a group is what the stock pages do with a
* dependent field.
*
* The select is the per-browser switch deciding whether to paint an image, so it is what keeps
* the Save button honest; Choose/Remove only swap the picture and never touch the axis. The
* native file inputs stay hidden — the styled buttons trigger them. */
const wallpaper = (() => {
const err = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' });
const preview = E('img', { 'class': 'fs-ap-bgprev', 'alt': '', 'hidden': '' });
/* display:none, not `hidden`: a bare `hidden=""` still renders the native
* "Choose File / No file chosen" control */
const fileInput = E('input', { 'type': 'file', 'accept': 'image/*', 'style': 'display:none' });
const chooseLabel = _('Choose image', 'footstrap');
const chooseBtn = E('button', { 'class': 'btn cbi-button', 'type': 'button' }, [ chooseLabel ]);
const removeBtn = E('button', { 'class': 'btn cbi-button-remove', 'type': 'button', 'hidden': '' }, [ _('Remove', 'footstrap') ]);
const patErr = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' });
const patPreview = E('img', { 'class': 'fs-ap-bgprev', 'alt': '', 'hidden': '' });
const patInput = E('input', { 'type': 'file', 'accept': 'image/svg+xml,.svg', 'style': 'display:none' });
const patChooseLabel = _('Choose SVG', 'footstrap');
const patChoose = E('button', { 'class': 'btn cbi-button', 'type': 'button' }, [ patChooseLabel ]);
const patRemove = E('button', { 'class': 'btn cbi-button-remove', 'type': 'button', 'hidden': '' }, [ _('Remove', 'footstrap') ]);
/* Dim: the scrim opacity over the photo. An ordinary per-browser axis — it is in AXIS_KEYS
* and snapshotAxes(), so it moves this browser toward or away from the router default and
* must be bump()-ed like every other saved axis, or the Save button misreports its own
* status. Separate from the Tint strength above. */
const dimLabel = _('Dim', 'footstrap');
const scaleLabel = _('Scale', 'footstrap');
const strengthLabel = _('Strength', 'footstrap');
const inkLabel = _('Colours', 'footstrap');
/* The rows the pattern brings. Scale and Strength are live: the appliers write a custom
* property, so the tile resizes and fades under the drag. Colours decides whether the
* file's own palette is kept or replaced by the theme's — a mask uses the alpha only,
* which is right for line art and wrong for artwork that carries its own colours. */
const patRows = [
group(_('Pattern', 'footstrap'),
() => E('div', { 'class': 'fs-ap-bgrow' }, [ patChoose, patRemove ]),
{ extra: [ patInput, patPreview, patErr ] }),
group(scaleLabel, (lbl) => sliderCtl(axes.currentPatternSize(), 40, 1600,
bump(axes.applyPatternSize), lbl, { step: 20 })),
group(strengthLabel, (lbl) => sliderCtl(axes.currentPatternStrength(), 0, 100,
bump(axes.applyPatternStrength), lbl, { step: 5 })),
group(inkLabel, (lbl) => selectCtl(axes.currentPatternInk(), {
theme: _('Theme', 'footstrap'),
original: _('As in file', 'footstrap')
}, bump(axes.applyPatternInk), lbl))
];
/* …and the rows the FILE photo brings. */
const fileRows = [
group(_('File', 'footstrap'),
() => E('div', { 'class': 'fs-ap-bgrow' }, [ chooseBtn, removeBtn ]),
{ extra: [ fileInput, preview, err ] }),
group(dimLabel, (lbl) => sliderCtl(axes.currentPhotoDim(), 0, 100,
bump(axes.applyPhotoDim), lbl, { step: 5 }))
];
function reflect(tok) {
if (tok) { preview.src = axes.loginBgUrl(tok); preview.hidden = false; removeBtn.hidden = false; }
else { preview.removeAttribute('src'); preview.hidden = true; removeBtn.hidden = true; }
}
function reflectPattern(tok) {
if (tok) { patPreview.src = axes.patternUrl(tok); patPreview.hidden = false; patRemove.hidden = false; }
else { patPreview.removeAttribute('src'); patPreview.hidden = true; patRemove.hidden = true; }
}
/* `hidden` on the row, which 80-appearance.css restates at a specificity beating
* `.cbi-value`'s own display (the UA's bare `[hidden]` rule loses to it). Hidden, not
* removed: each row holds a live control, so rebuilding on every switch would freeze it to
* the state it was constructed in. */
function togglePanel(v) {
patRows.forEach((r) => { r.hidden = (v !== 'pattern'); });
fileRows.forEach((r) => { r.hidden = (v !== 'file'); });
}
reflect(axes.currentLoginBg());
reflectPattern(axes.currentPattern());
togglePanel(axes.currentWallpaper());
const setWallpaper = (v) => { axes.applyWallpaper(v); refreshSave(); togglePanel(v); refreshColours(); };
/* Both uploads present the same three controls and the same four states — pick, upload,
* report, remove — so the wiring is stated once. What differs is `after`: the pattern also
* has to move the Wallpaper dropdown, because the upload switched this browser onto the
* tile and the page would otherwise paint it while the control still read Off.
*
* The file input is cleared on every change so re-picking the SAME file fires `change`
* again, and the button carries its own busy state: the label is restored in `finally`, or
* a failed upload leaves "Uploading…" standing for the life of the form. */
const wireUploader = (u) => {
const fail = (e) => { u.err.textContent = String((e && e.message) || e); u.err.hidden = false; };
u.choose.addEventListener('click', () => { u.err.hidden = true; u.input.click(); });
u.input.addEventListener('change', () => {
const f = u.input.files && u.input.files[0];
u.input.value = '';
if (!f) return;
u.err.hidden = true; u.choose.disabled = true;
u.choose.textContent = _('Uploading…', 'footstrap');
u.upload(f)
.then((tok) => { u.reflect(tok); if (u.after) u.after(tok); })
.catch(fail)
.finally(() => { u.choose.disabled = false; u.choose.textContent = u.label; });
});
u.remove.addEventListener('click', () => {
u.err.hidden = true; u.remove.disabled = true;
u.drop().then(() => u.reflect('')).catch(fail)
.finally(() => { u.remove.disabled = false; });
});
};
wireUploader({
choose: patChoose, remove: patRemove, input: patInput, err: patErr,
label: patChooseLabel, reflect: reflectPattern,
upload: assets.uploadPattern, drop: assets.removePattern,
/* `dom.callClassMethod` is how LuCI moves its own widgets from outside; setWallpaper is
* then called directly, because a programmatic setValue emits no `widget-change`. */
after: () => { dom.callClassMethod(seg, 'setValue', 'pattern'); setWallpaper('pattern'); }
});
wireUploader({
choose: chooseBtn, remove: removeBtn, input: fileInput, err: err,
label: chooseLabel, reflect: reflect,
upload: assets.uploadLoginBg, drop: assets.removeLoginBg
});
let seg;
const wallRow = group(_('Wallpaper', 'footstrap'), (label) => {
seg = selectCtl(axes.currentWallpaper(), {
off: _('Off', 'footstrap'),
pattern: _('Pattern', 'footstrap'),
file: _('File', 'footstrap')
}, setWallpaper, label);
return seg;
});
return [ wallRow ].concat(patRows, fileRows);
})();
/* ---- section 4: the router default and the version ----
*
* Save the current look as the router-wide default (fs-prefs writes /etc/config/footstrap over
* the scoped uci ACL). It does not change this browser — localStorage keeps overriding — so the
* saved default only shows on a fresh browser. The two Reset buttons below are the escape
* hatches, and they do not land in the same place. */
/* the button/status label said three times below, inside this one build() call — hoisting the
* RESULT, not the msgid, so update-po.sh still sees the literal `_()` argument elsewhere
* (measured: 22 B x3 -> 27 B, 39 B saved) */
const SAVE_TO_ROUTER = _('Save to router', 'footstrap');
/* said twice below, same build() call (measured: 24 B x2 -> 30 B, 18 B saved) */
const RESET_TO_ROUTER = _('Reset to router', 'footstrap');
const saveBtn = E('button', { 'class': 'btn cbi-button-action', 'type': 'button' }, [ SAVE_TO_ROUTER ]);
/* Two resets, because two things sit underneath a browser's tweaks (fs-prefs.js): "Reset to
* router" clears them and lets every axis fall back to whatever the router holds, while "Reset
* to built-in" writes the theme's built-ins explicitly — the only way to say "as the theme
* ships" on a router that has a look of its own. Neither touches /etc/config/footstrap.
*
* ONE word per state, and "default" is not one of them: it used to name the router's look in
* "Save as default" and the theme's in "Reset to default", while the router's look also
* answered to "saved" — three names for two states, asked about on the forum (topic 251930,
* post 92). The row now reads as one save and two resets, over `router` and `built-in`. */
const resetSavedBtn = E('button', { 'class': 'btn', 'type': 'button' }, [ RESET_TO_ROUTER ]);
/* the stock destructive class, so the button discarding every local tweak is the red one
* (theme/55-buttons.css). "Reset to router" stays neutral: it steps back to the shared state
* rather than discarding. */
const resetBtn = E('button', { 'class': 'btn cbi-button-negative', 'type': 'button' }, [ _('Reset to built-in', 'footstrap') ]);
/* Save's only visible failure surface. The realistic failure is the rpc rejecting — an expired
* session (403), a missing ACL, ubus down. A DELETED config is not caught: rpcd stages the set
* in the session and commit then no-ops without writing the file, returning success (measured
* on the router). The package owns that file and the read side falls back to built-in
* defaults. */
const saveErr = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' });
/* The Save button is the status: matching disables it, diverging enables it. Called after every
* axis change (via bump).
*
* Unless the browser refuses storage, where the comparison would always be true — nothing was
* written, so every current*() reads the router default back however far the page has been
* dragged from it. Say so instead, and leave the button enabled: pushing this browser's look to
* the router is the one thing that still works. */
function refreshSave() {
if (prefs.storageBroken()) {
saveBtn.disabled = false;
saveBtn.textContent = SAVE_TO_ROUTER;
saveErr.textContent = _('This browser is not storing preferences (site data is blocked), so a change here lasts until you reload. Saving as default still works and applies to every browser.', 'footstrap');
saveErr.hidden = false;
return;
}
const saved = axes.matchesSavedDefault();
saveBtn.disabled = saved;
saveBtn.textContent = saved ? _('Saved to router', 'footstrap') : SAVE_TO_ROUTER;
}
saveBtn.addEventListener('click', () => {
saveBtn.disabled = true;
saveErr.hidden = true;
axes.saveAsDefault()
.then(() => { saveErr.hidden = true; })
/* on failure refreshSave re-enables the button so the user can retry; the usual cause
* is a stale session, which a reload fixes. The raw rpc error stays in a title
* tooltip. */
.catch((e) => {
saveErr.textContent = _('Could not save the default. Reload the page and try again.', 'footstrap');
saveErr.title = String((e && e.message) || e);
saveErr.hidden = false;
})
.finally(refreshSave);
});
/* Two-click confirm on both: discarding local tweaks is destructive and a native confirm() is
* banned in this UI. Arming one disarms the other, so a primed button cannot be fired by a
* click meant for its neighbour. Each reload lands back on this same page — an ordinary route,
* unlike the tab this once needed a sessionStorage flag to return to. */
const armed = new Map();
function disarm(btn, label) {
armed.delete(btn);
btn.textContent = label;
btn.classList.remove('fs-ap-armed');
}
function twoClick(btn, label, run) {
btn.addEventListener('click', () => {
if (!armed.has(btn)) {
[ ...armed.keys() ].forEach((other) => disarm(other, armed.get(other)));
armed.set(btn, label);
btn.textContent = _('Confirm reset', 'footstrap');
btn.classList.add('fs-ap-armed');
return;
}
disarm(btn, label);
run();
location.reload();
});
}
twoClick(resetSavedBtn, RESET_TO_ROUTER, axes.resetToSaved);
twoClick(resetBtn, _('Reset to built-in', 'footstrap'), axes.resetToBuiltin);
refreshSave(); /* correct label and enabled state before the first paint */
const versionLink = E('a', {
'class': 'fs-ap-version',
'href': ver.REPO_URL,
'target': '_blank',
/* `noreferrer` alone: it implies noopener, and the theme's other outward links spell it
* that way */
'rel': 'noreferrer'
}, [ ver.label() ]);
/* One clause per button, in LuCI's own help-sentence idiom and under the buttons themselves,
* because that is where the question is asked: with all three named "save"/"reset" and two of
* them resets, the row does not say which state each lands in (forum topic 251930, post 90 —
* "what is the difference on 'reset to saved' and 'reset to default'?"). Three lines rather
* than a paragraph: three parts want a list, and each is read on its own.
*
* Text nodes separated by , NOT a child per line: `.cbi-value-field *` in
* theme/60-inputs.css hands mono to every descendant and excludes `.cbi-value-description`
* itself, so a wrapped line becomes a descendant that the exclusion does not reach — measured,
* all three lines came out monospace. Text nodes inherit the sans face from the container, and
* one container also means one `?` glyph with every line aligned under it. */
const buttonHelp = E('div', { 'class': 'cbi-value-description' }, [
_('Save to router — store this look on the router. Browsers with a look of their own keep it.', 'footstrap'),
E('br'),
_('Reset to router — drop this browser\'s changes and follow the router.', 'footstrap'),
E('br'),
_('Reset to built-in — go back to the look the theme ships with.', 'footstrap')
]);
const defaults = [
/* the one row whose control is a pair of buttons, each named by its own text, so `make`
* ignores the caption rather than re-using it as an aria-label */
group(_('Saved look', 'footstrap'),
() => E('div', { 'class': 'fs-ap-actrow' }, [ saveBtn, E('span', { 'class': 'fs-ap-actgap', 'aria-hidden': 'true' }), resetSavedBtn, resetBtn ]),
{ extra: [ buttonHelp, saveErr ] })
];
/* ---- the catalogue this router has not got ----
*
* The theme's translations are their own packages since 0.14.4, the way every `luci-app-*`
* ships them, and nothing in a package manager can read `uci luci.main.lang` to fetch the right
* one: apk learns it from `install-if` against `luci-i18n-base-` (owfeed.yml), opkg has no
* conditional form of that at all, and a router upgraded from 0.14.3 through the feed simply
* loses the catalogue that used to ride inside the theme (issue #41). `install.sh` covers its
* own path; this covers the one nobody ran a script on.
*
* ASKED OF THE PAGE, not of the package list: the theme has no ubus call of its own and must not
* grow one for this. `_()` returns its argument unchanged when no catalogue answers, so asking
* for a string the catalogue certainly carries is the whole test.
*
* `Layout` is that string — a caption this very form renders, present in every catalogue under `po/`. A word
* that only LOOKS certain is worse than no check: `Appearance` is in the source but obsolete in
* the catalogues (`#~ msgid`), so testing it reported "not translated" on a router whose
* Russian catalogue was installed and working.
*
* The language comes from the document, not from `L.env`, which carries no language field at
* all — the dispatcher stamps `` and that is what the page knows.
*
* Nothing is shown on an English or `auto` router, where there is no catalogue to miss. */
const lang = (document.documentElement.getAttribute('lang') || '').trim();
const untranslated = lang && lang !== 'en' && lang !== 'auto' &&
_('Layout', 'footstrap') === 'Layout';
const missing = untranslated ? E('div', { 'class': 'fs-ap-verrow fs-ap-i18n' }, [
E('span', {}, [ _('This theme is not translated on this router yet.', 'footstrap') + ' ' ]),
E('code', {}, [ 'luci-i18n-footstrap-' + lang ])
]) : '';
defaults.push(E('div', { 'class': 'fs-ap-footer' }, [
E('div', { 'class': 'fs-ap-verrow' }, [ versionLink ]),
missing
]));
/* not .cbi-section: inside a tab pane that is a card within a card, and the stock tabs put
* their rows straight into the pane. These are grouping headings within one pane, styled by
* pages/80-appearance.css. */
const section = (title, rows) => E('div', { 'class': 'fs-ap-section' }, [
E('div', { 'class': 'fs-ap-head' }, [ E('h4', {}, [ title ]) ])
].concat(rows));
/* ---- the folded groups ----
* Nine colour fields and an uploader are the widest rows on the page and most admins never
* touch them, so each group is a disclosure, closed by default.
*
* A disclosure and not a switch: a switch answers "is this feature on", and turning it off
* would either revert nine colours or change nothing at all. Opening or closing a fold applies,
* un-applies and disables nothing.
*
* W3C APG disclosure pattern, as the menu's sections use: a