diff --git a/luci-theme-footstrap/Makefile b/luci-theme-footstrap/Makefile
index 8eddab9c..498ca941 100644
--- a/luci-theme-footstrap/Makefile
+++ b/luci-theme-footstrap/Makefile
@@ -17,7 +17,7 @@ LUCI_NAME:=luci-theme-footstrap
FOOTSTRAP_VERSION?=
ifneq ($(FOOTSTRAP_VERSION),)
PKG_VERSION:=$(FOOTSTRAP_VERSION)
-PKG_RELEASE:=38
+PKG_RELEASE:=39
endif
LUCI_TITLE:=Footstrap Theme
@@ -170,6 +170,10 @@ define Build/Prepare/luci-theme-footstrap
$(SHELL) $(CURDIR)/strip-probes.sh $(PKG_BUILD_DIR)/htdocs/luci-static/resources
# …and the same for the shell under root/ (71% and 95% comment lines). Whole-line `#` only.
$(SHELL) $(CURDIR)/strip-shell.sh $(PKG_BUILD_DIR)/root
+ # The two static assets luci.mk has no step for: the SVG favicon carries a 753-byte comment and
+ # the manifest its indentation, and both are fetched by every browser over a link uhttpd does
+ # not compress. awk only, so this runs on a buildbot with no node.
+ $(SHELL) $(CURDIR)/strip-assets.sh $(PKG_BUILD_DIR)/htdocs/luci-static/footstrap
$(SED) "s#const FS_VERSION *= *'[^']*'#const FS_VERSION = '$(if $(PKG_VERSION),$(PKG_VERSION),$(PKG_SRC_VERSION))'#" \
$(PKG_BUILD_DIR)/htdocs/luci-static/resources/fs-version.js
endef
diff --git a/luci-theme-footstrap/build-css.sh b/luci-theme-footstrap/build-css.sh
index fd4f1a51..39eab7b6 100755
--- a/luci-theme-footstrap/build-css.sh
+++ b/luci-theme-footstrap/build-css.sh
@@ -33,14 +33,52 @@ done
TMP="$OUT.tmp.$$"
# $TMP.min too: an awk failure used to leave it behind next to the real output.
-trap 'rm -f "$TMP" "$TMP.min"' EXIT
+trap 'rm -f "$TMP" "$TMP.min" "$TMP.layer"' EXIT
mkdir -p "$(dirname "$OUT")"
+# One `@layer X{` per LAYER, not one per FILE. Every source file carries its own wrapper so it can
+# be read and edited alone; concatenated, that is 38 copies of the same six-to-eleven bytes, 554 of
+# them, plus two files that are all comment and emit nothing but `@layer page{}`.
+#
+# The wrapper now comes from the DIRECTORY, so a file filed under the wrong layer would be silently
+# re-layered instead of just being wrong — hence the check that each file opens with the layer its
+# directory means. A file with no wrapper at all (00-header.css: the banner and the layer-order
+# statement) is copied through, and must come before the wrapped ones or it would land inside the
+# block.
+emit_layer() {
+ layer="$1"; shift
+ body="$TMP.layer"
+ : > "$body"
+ for f in "$@"; do
+ if head -1 "$f" | grep -q '^@layer '; then
+ head -1 "$f" | grep -q "^@layer $layer {\$" || {
+ echo "build-css: $f is in a $layer directory but does not open with '@layer $layer {'" >&2
+ rm -f "$body"; exit 1; }
+ # the file's own wrapper: its first line, and its last line, which is that wrapper's `}`
+ sed '1d;$d' "$f" >> "$body"
+ elif [ -s "$body" ]; then
+ echo "build-css: $f has no @layer wrapper but follows one that does — it would be" >&2
+ echo "build-css: swallowed into the $layer block instead of staying above it." >&2
+ rm -f "$body"; exit 1
+ else
+ cat "$f"
+ fi
+ done
+ if [ -s "$body" ]; then
+ printf '@layer %s {\n' "$layer"
+ cat "$body"
+ printf '}\n'
+ fi
+ rm -f "$body"
+}
+
# glob expands in filename order
-cat "$D"/styles/*.css \
- "$D"/styles/base/*.css \
- "$D"/styles/theme/*.css \
- "$D"/styles/pages/*.css > "$TMP"
+{
+ emit_layer tokens "$D"/styles/*.css
+ emit_layer base "$D"/styles/base/*.css
+ emit_layer theme "$D"/styles/theme/*.css
+ emit_layer page "$D"/styles/pages/*.css
+} > "$TMP"
# Strip /* … */, keep /*! … */ (the licence banner), drop indentation and blank lines.
#
@@ -135,9 +173,19 @@ squeeze() {
prev = (length(out) ? substr(out, length(out), 1) : lastc)
nxt = (i <= n ? substr(line, i, 1) : "")
# drop it entirely next to a delimiter; otherwise it may be a combinator
- if (prev == "" || prev == "{" || prev == "}" || prev == ";" || prev == "," || prev == ":")
+ #
+ # `>` is a delimiter too, and the only one that is itself a combinator: a
+ # space either side of it is decoration. 516 of them in the sheet, so it is
+ # 1,032 B — the file header used to guess "~200 bytes" for this whole pass.
+ # Safe because a `>` outside a string can only be the child combinator: the
+ # sheet has no media range syntax (`@media (width > 600px)`), and the 107
+ # `>` inside string literals never reach here, the scanner having copied them
+ # verbatim above. `~` and `+` are deliberately NOT joined: `[attr~=v]` and
+ # `calc(100% - 10px)` make them ambiguous without tracking bracket depth,
+ # and they are worth 14 B and 34 B.
+ if (prev == "" || prev == "{" || prev == "}" || prev == ";" || prev == "," || prev == ":" || prev == ">")
continue
- if (nxt == "{" || nxt == "}" || nxt == ";" || nxt == "," || nxt == "")
+ if (nxt == "{" || nxt == "}" || nxt == ";" || nxt == "," || nxt == "" || nxt == ">")
continue
out = out " "; lastreal = " "
continue
diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-appearance.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-appearance.js
index 7d332408..f2a97c57 100644
--- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-appearance.js
+++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-appearance.js
@@ -3,7 +3,8 @@
'require ui';
'require dom';
'require fs-prefs as prefs';
-'require fs-widgets as widgets';
+'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
@@ -22,6 +23,201 @@
* 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 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
@@ -69,6 +265,9 @@ function build() {
]);
};
+ /* 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
@@ -105,7 +304,7 @@ function build() {
/* 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 = widgets.colorControl(axis.current(), bump(axis.apply), lbl, {
+ const ctl = colorControl(axis.current(), bump(axis.apply), lbl, {
probe: probe,
read: axis.current,
contrast: contrast,
@@ -137,7 +336,7 @@ function build() {
dark: _('Dark', 'footstrap')
}, bump(repaint(prefs.applyMode)), label)),
- group(_('Palette', 'footstrap'), (label) => selectCtl(prefs.currentPalette(), {
+ group(_('Palette', 'footstrap'), (label) => selectCtl(axes.currentPalette(), {
footstrap: 'Footstrap',
hicontrast: 'Hi-Contrast',
/* names the OTHER package, luci-theme-bootstrap, whose colours this palette is —
@@ -145,7 +344,7 @@ function build() {
bootstrap: 'Bootstrap',
/* names the OTHER package again, luci-theme-openwrt-2020, whose colourway this is */
'2020': 'OpenWrt 2020'
- }, bump(repaint(prefs.applyPalette)), label)),
+ }, bump(repaint(axes.applyPalette)), label)),
group(_('Density', 'footstrap'), (label) => selectCtl(prefs.currentDensity(), {
compact: _('Compact', 'footstrap'),
@@ -154,7 +353,7 @@ function build() {
}, bump(prefs.applyDensity), label)),
group(_('Rounding', 'footstrap'),
- (label) => sliderCtl(prefs.currentRadius(), 0, 20, bump(prefs.applyRadius), label)),
+ (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
@@ -173,7 +372,7 @@ function build() {
/* 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: prefs.currentTint, apply: prefs.applyTint
+ 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 */
@@ -186,7 +385,7 @@ function build() {
* 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(prefs.currentTintStrength(), 0, 200, bump(repaint(prefs.applyTintStrength)), label, {
+ (label) => sliderCtl(axes.currentTintStrength(), 0, 200, bump(repaint(axes.applyTintStrength)), label, {
step: 5
}), { cls: 'fs-ap-tint fs-ap-tintstr' }),
@@ -195,29 +394,16 @@ function build() {
* 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. */
- colourGroup(_('Accent', 'footstrap'), {
- current: prefs.currentAccent, apply: prefs.applyAccent
- }, 'var(--fs-accent)', {
- fg: 'var(--fs-accent)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap')
- }),
-
- colourGroup(_('Good', 'footstrap'), {
- current: prefs.currentGood, apply: prefs.applyGood
- }, 'var(--fs-good)', {
- fg: 'var(--fs-good)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap')
- }),
-
- colourGroup(_('Warning', 'footstrap'), {
- current: prefs.currentWarn, apply: prefs.applyWarn
- }, 'var(--fs-warn)', {
- fg: 'var(--fs-warn)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap')
- }),
-
- colourGroup(_('Danger', 'footstrap'), {
- current: prefs.currentDanger, apply: prefs.applyDanger
- }, 'var(--fs-danger)', {
- fg: 'var(--fs-danger)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap')
- })
+ /* 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 ----
@@ -229,30 +415,15 @@ function build() {
* — 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 = [
- colourGroup(_('Cards', 'footstrap'), {
- current: prefs.currentCard, apply: prefs.applyCard
- }, 'var(--fs-panel)', {
- fg: 'var(--fs-text)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap')
- }),
-
- colourGroup(_('Controls', 'footstrap'), {
- current: prefs.currentControl, apply: prefs.applyControl
- }, 'var(--fs-panel2)', {
- fg: 'var(--fs-text)', bg: 'var(--fs-panel2)', label: _('on a control', 'footstrap')
- }),
-
- colourGroup(_('Sidebar and bar', 'footstrap'), {
- current: prefs.currentBar, apply: prefs.applyBar
- }, 'var(--fs-bar-bg)', {
- fg: 'var(--fs-text)', bg: 'var(--fs-bar-bg)', label: _('in the sidebar', 'footstrap')
- }),
-
-
- colourGroup(_('Borders', 'footstrap'), {
- current: prefs.currentLine, apply: prefs.applyLine
- }, 'var(--fs-border)', {
- fg: 'var(--fs-border)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap'), kind: 'shape'
- })
+ /* 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 ----
@@ -301,30 +472,30 @@ function build() {
group(_('Pattern', 'footstrap'),
() => E('div', { 'class': 'fs-ap-bgrow' }, [ patChoose, patRemove ]),
{ extra: [ patInput, patPreview, patErr ] }),
- group(scaleLabel, (lbl) => sliderCtl(prefs.currentPatternSize(), 40, 1600,
- bump(prefs.applyPatternSize), lbl, { step: 20 })),
- group(strengthLabel, (lbl) => sliderCtl(prefs.currentPatternStrength(), 0, 100,
- bump(prefs.applyPatternStrength), lbl, { step: 5 })),
- group(inkLabel, (lbl) => selectCtl(prefs.currentPatternInk(), {
+ 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(prefs.applyPatternInk), lbl))
+ }, 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(prefs.currentPhotoDim(), 0, 100,
- bump(prefs.applyPhotoDim), lbl, { step: 5 }))
+ group(dimLabel, (lbl) => sliderCtl(axes.currentPhotoDim(), 0, 100,
+ bump(axes.applyPhotoDim), lbl, { step: 5 }))
];
function reflect(tok) {
- if (tok) { preview.src = prefs.loginBgUrl(tok); preview.hidden = false; removeBtn.hidden = false; }
+ 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 = prefs.patternUrl(tok); patPreview.hidden = false; patRemove.hidden = false; }
+ 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
@@ -335,64 +506,59 @@ function build() {
patRows.forEach((r) => { r.hidden = (v !== 'pattern'); });
fileRows.forEach((r) => { r.hidden = (v !== 'file'); });
}
- reflect(prefs.currentLoginBg());
- reflectPattern(prefs.currentPattern());
- togglePanel(prefs.currentWallpaper());
+ reflect(axes.currentLoginBg());
+ reflectPattern(axes.currentPattern());
+ togglePanel(axes.currentWallpaper());
- const setWallpaper = (v) => { prefs.applyWallpaper(v); refreshSave(); togglePanel(v); refreshColours(); };
+ const setWallpaper = (v) => { axes.applyWallpaper(v); refreshSave(); togglePanel(v); refreshColours(); };
- patChoose.addEventListener('click', () => { patErr.hidden = true; patInput.click(); });
- patInput.addEventListener('change', () => {
- const f = patInput.files && patInput.files[0];
- patInput.value = ''; /* so re-picking the same file fires change again */
- if (!f) return;
- patErr.hidden = true; patChoose.disabled = true;
- patChoose.textContent = _('Uploading…', 'footstrap');
- prefs.uploadPattern(f)
- .then((tok) => {
- reflectPattern(tok);
- /* uploadPattern already switched this browser onto the pattern, so the control
- * must catch up or the page paints the tile while the dropdown reads Off.
- * `dom.callClassMethod` is how LuCI moves its own widgets from outside;
- * setWallpaper is then called directly, because a programmatic setValue emits
- * no `widget-change`. */
- dom.callClassMethod(seg, 'setValue', 'pattern');
- setWallpaper('pattern');
- })
- .catch((e) => { patErr.textContent = String((e && e.message) || e); patErr.hidden = false; })
- .finally(() => { patChoose.disabled = false; patChoose.textContent = patChooseLabel; });
- });
- patRemove.addEventListener('click', () => {
- patErr.hidden = true; patRemove.disabled = true;
- prefs.removePattern()
- .then(() => reflectPattern(''))
- .catch((e) => { patErr.textContent = String((e && e.message) || e); patErr.hidden = false; })
- .finally(() => { patRemove.disabled = false; });
+ /* 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'); }
});
- chooseBtn.addEventListener('click', () => { err.hidden = true; fileInput.click(); });
- fileInput.addEventListener('change', () => {
- const f = fileInput.files && fileInput.files[0];
- fileInput.value = ''; /* so re-picking the same file fires change again */
- if (!f) return;
- err.hidden = true; chooseBtn.disabled = true;
- chooseBtn.textContent = _('Uploading…', 'footstrap');
- prefs.uploadLoginBg(f)
- .then(reflect)
- .catch((e) => { err.textContent = String((e && e.message) || e); err.hidden = false; })
- .finally(() => { chooseBtn.disabled = false; chooseBtn.textContent = chooseLabel; });
- });
- removeBtn.addEventListener('click', () => {
- err.hidden = true; removeBtn.disabled = true;
- prefs.removeLoginBg()
- .then(() => reflect(''))
- .catch((e) => { err.textContent = String((e && e.message) || e); err.hidden = false; })
- .finally(() => { removeBtn.disabled = false; });
+ 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(prefs.currentWallpaper(), {
+ seg = selectCtl(axes.currentWallpaper(), {
off: _('Off', 'footstrap'),
pattern: _('Pattern', 'footstrap'),
file: _('File', 'footstrap')
@@ -442,14 +608,14 @@ function build() {
saveErr.hidden = false;
return;
}
- const saved = prefs.matchesSavedDefault();
+ 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;
- prefs.saveAsDefault()
+ 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
@@ -485,8 +651,8 @@ function build() {
location.reload();
});
}
- twoClick(resetSavedBtn, _('Reset to saved', 'footstrap'), prefs.resetToSaved);
- twoClick(resetBtn, _('Reset to default', 'footstrap'), prefs.resetToBuiltin);
+ 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', {
@@ -536,7 +702,8 @@ function build() {
function foldable(title, rows, key) {
const id = 'fs-ap-fold-' + (++foldSeq);
let open = (prefs.lsGet(key) === 'on');
- const body = E('div', { 'class': 'fs-ap-body', 'id': id }, rows);
+ /* 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
}, [
@@ -577,8 +744,8 @@ function build() {
]);
/* 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 through widgets.probeColor(), whose
- * hidden probe is attached to
, so a detached form still reads the live palette. */
+ * the form to be in the document: every readout resolves inside fs-widgets against a hidden
+ * probe attached to , so a detached form still reads the live palette. */
Promise.resolve().then(refreshColours);
return page;
}
@@ -761,6 +928,5 @@ function wire() {
}
return baseclass.extend({
- wire,
- render
+ wire
});
diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-assets.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-assets.js
new file mode 100644
index 00000000..0d17efb1
--- /dev/null
+++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-assets.js
@@ -0,0 +1,336 @@
+'use strict';
+'require baseclass';
+'require rpc';
+'require fs-axes as axes';
+
+/* fs-assets — putting a file ON THE ROUTER, and taking it off again.
+ *
+ * Two uploads live here, the pattern tile and the login photo, and everything they need that the
+ * rest of the theme does not: a DOMParser pass over an SVG, a canvas re-encode of a photo, the
+ * chmod that makes a freshly written 0600 file servable, and the rollback that runs when the token
+ * write fails after the bytes have landed.
+ *
+ * It is a module of its own because of WHERE it is needed: this machinery is reached only from the
+ * Appearance tab, one page out of nearly two hundred, and it was ~4 KB of DOMParser, canvas and rpc
+ * plumbing downloaded to a router's browser on the way to the DHCP page.
+ *
+ * The token accessors and the two live appliers live in `fs-axes` beside the axes themselves — the
+ * Appearance previews and head.ut's pre-paint read the same fields — so this file requires that one
+ * and nothing else of the theme's. */
+
+/* `reject: true` is load-bearing: without it a refused write arrives as SUCCESS. rpc.js raises on
+ * the ubus status code only when the declaration asks it to, and otherwise hands the code back as
+ * the resolved value — measured on the router, a per-config ACL refusal resolves with 6
+ * (permission denied) and every `.then()` below runs as if the file had been written, greying the
+ * Save button over a write that never happened. */
+/* The four messages each said twice or three times below. Hoisted because a string literal is not
+ * mangled, so every repeat is paid in full on flash — and because a message with two spellings is a
+ * message that gets fixed in one of them. The msgid and its 'footstrap' context stay literal
+ * arguments here, which is what update-po.sh's extractor reads. */
+const MSG_UPLOAD_FAILED = _('Upload failed.', 'footstrap');
+const MSG_NOT_SVG = _('That file is not an SVG image.', 'footstrap');
+const MSG_BAD_IMAGE = _('Could not process the image.', 'footstrap');
+const MSG_PICK_SVG = _('Please choose an SVG file.', 'footstrap');
+
+const _uciSet = rpc.declare({ object: 'uci', method: 'set', params: [ 'config', 'section', 'values' ], reject: true });
+const _uciCommit = rpc.declare({ object: 'uci', method: 'commit', params: [ 'config' ], reject: true });
+
+/* ---- the pattern: an SVG the admin uploads, tiled and recoloured ----
+ *
+ * The bytes come from the admin, never from a third-party host: a theme in a package feed does not
+ * reach out at run time.
+ *
+ * Router-side, like the login photo and for the same reason — a file cannot live in localStorage,
+ * and a pattern is something a router wears. The path is a fixed server-side constant matched
+ * exactly by the rpcd ACL, so nothing user-controlled reaches a path. It lives under /etc so a
+ * package upgrade cannot delete it (keep.d carries it across a sysupgrade), and the served name
+ * ends in .svg because uhttpd types a file by extension.
+ *
+ * How it is made to fit is 15-wallpaper.css's mask, not anything done to the bytes: the file
+ * supplies the alpha and the theme the colour, so one upload reads correctly in both modes and
+ * under every palette.
+ *
+ * What is refused: an SVG is a document, not a picture, and while a masked or background image
+ * never executes script, the same file fetched from its own URL would. Uploading already needs an
+ * authenticated admin session with uci write rights, so this is defence in depth — but the check is
+ * cheap and the failure mode is somebody else's browser. */
+const PAT_PATH = '/etc/footstrap/pattern.svg'; /* cgi-upload target; the ACL grants exactly this */
+const PAT_MAX = 512 * 1024; /* a tile that has to reach a router's flash and then every page load */
+/* What makes an uploaded SVG unacceptable, decided on the PARSED document and not on its text: a
+ * regex over the source guesses at a grammar the browser already implements, and guesses in both
+ * directions — a handler pattern also matches an ordinary `only_selected="false"`, while an entity
+ * or odd whitespace hides a real handler from it.
+ *
+ * DOMParser is the parser the file will actually be read by, and parsing is inert: no script runs,
+ * no subresource is fetched, no handler is bound. So the questions are exact ones about nodes:
+ *
+ * - is it an SVG at all (a parsererror, or a root that is not , is not an image)
+ * - does it carry an element that executes or embeds (script, foreignObject, iframe, …)
+ * - does it carry a real event-handler attribute — `^on[a-z]+$`
+ * - does any value start a `javascript:` url
+ * - does any href point off this router; `#fragment` and `data:` stay allowed, being how a tile
+ * refers to its own and embeds a bitmap
+ *
+ * The check is for the way the file can be reached that a mask does not cover: its own URL, opened
+ * directly, same-origin with the session.
+ *
+ * `animate`/`set` are listed for a second reason as well: they can retarget an attribute at run
+ * time (``), and a tile that animates repaints a
+ * full-viewport layer behind every page. */
+const PAT_BAD_TAGS = [ 'script', 'foreignobject', 'iframe', 'embed', 'object', 'audio', 'video', 'animate', 'set' ];
+const SVG_NS = 'http://www.w3.org/2000/svg';
+
+/* null if the parsed document is fine, otherwise the sentence to show. */
+function _svgObjection(text) {
+ let doc;
+ try { doc = new DOMParser().parseFromString(text, 'image/svg+xml'); }
+ catch (e) { return MSG_NOT_SVG; }
+ const root = doc && doc.documentElement;
+ /* An SVG is its ROOT'S NAMESPACE, not its root's spelling. `nodeName` is the qualified name, so
+ * it answers both questions wrong at once: `` reads as
+ * `svg` and is admitted although it is an XHTML document that executes on all three engines,
+ * while `` reads as `s:svg` and is turned away
+ * although it is an ordinary picture. */
+ if (!root || doc.querySelector('parsererror') ||
+ root.localName.toLowerCase() !== 'svg' || root.namespaceURI !== SVG_NS)
+ return MSG_NOT_SVG;
+ const refused = _('That SVG contains script or external references, which this theme will not install.', 'footstrap');
+ /* A processing instruction can attach an XSLT stylesheet carried INSIDE this same document, and
+ * the transform's output is a document this walk never sees: ``
+ * builds the element by name, so nothing here is called script. Measured executing on Firefox
+ * (Chromium and WebKit decline to run XSLT on an image/svg+xml document). A tile has no use for
+ * one, and without the PI the embedded stylesheet is never applied. */
+ for (const n of doc.childNodes) if (n.nodeType === Node.PROCESSING_INSTRUCTION_NODE) return refused;
+ const els = [ root ].concat([ ...root.querySelectorAll('*') ]);
+ for (const el of els) {
+ /* localName, never nodeName: in an XML document nodeName carries the namespace PREFIX, so
+ * `` reads as `s:script` and walks straight
+ * past a list of names — measured executing on all three engines, as does the same element
+ * put in the xhtml namespace. localName is `script` for every one of those spellings. */
+ if (PAT_BAD_TAGS.indexOf((el.localName || el.nodeName).toLowerCase()) >= 0) return refused;
+ const attrs = el.attributes || [];
+ for (let i = 0; i < attrs.length; i++) {
+ const n = attrs[i].name.toLowerCase();
+ const v = String(attrs[i].value || '').trim();
+ /* a REAL handler is `on` + letters and nothing else; `only_selected` is not one. The
+ * qualified name is right here, unlike on the element above: a prefixed `s:onload` or
+ * `xlink:onload` fires on none of the three engines, so matching localName would only
+ * refuse files that do nothing. */
+ if ((/^on[a-z]+$/).test(n)) return refused;
+ if ((/^javascript:/i).test(v)) return refused;
+ /* off-router reference. A leading `//` is protocol-relative and just as external. */
+ if ((/(?:^|:)href$/).test(n) && (/^(?:[a-z][a-z0-9+.-]*:)?\/\//i).test(v)) return refused;
+ }
+ }
+ return null;
+}
+
+/* read the picked file as text so it can be inspected before upload, and so what reaches the
+ * router is exactly the bytes that were checked */
+function _readText(file) {
+ return new Promise((resolve, reject) => {
+ const fr = new FileReader();
+ fr.onload = () => resolve(String(fr.result || ''));
+ fr.onerror = () => reject(new Error(_('That file could not be read.', 'footstrap')));
+ fr.readAsText(file);
+ });
+}
+
+/* ---- login/page background upload: router-side, and deliberately not an axis ----
+ * The other axes are per-browser with a router default; this one has no browser layer. An admin
+ * uploads an image once, it becomes the router-wide background for every device and shows
+ * pre-login, so it is absent from AXIS_KEYS, snapshotAxes() and matchesSavedDefault() — it must not
+ * move the Save button — and needs no factory, so tools/axes.mjs never sees it.
+ *
+ * The image is a served file, uhttpd having no gzip to make inlining it in every viable;
+ * only its cache-bust token lives in uci -> window.__fsSD -> the url() head.ut stamps. The path is
+ * a fixed server-side constant matched exactly by the rpcd ACL, so nothing user-controlled reaches
+ * a path. */
+const BG_PATH = '/etc/footstrap/login-bg'; /* cgi-upload target; the ACL grants exactly this */
+const BG_MAX_SIDE = 1920; /* cap the longest side — a router serves this off flash with no gzip, and 1080p covers the screens LuCI is actually admin'd from; still crisp full-screen, far fewer flash/wire bytes */
+const BG_QUALITY = 0.9;
+const BG_SRC_MAX = 25 * 1024 * 1024; /* refuse a source this big before decoding (decode-bomb guard) */
+/* No `reject: true` here, unlike every other declare in this file: with it, "the file was already
+ * gone" and "the router refused to delete it" arrive as the same Error. Without it the promise
+ * resolves with the ubus status as a number, which this code can branch on. */
+const _fileRemoveStatus = rpc.declare({ object: 'file', method: 'remove', params: [ 'path' ] });
+
+/* Delete, treating "not found" as done. Anything else is a real refusal (a read-only or full
+ * overlay, an immutable flag, a path replaced by a directory) and must not be reported as a
+ * removal: the file stays on flash and stays fetchable WITHOUT a session through the /www symlink,
+ * which is what an admin removing a background believes they have stopped. */
+const UBUS_NOT_FOUND = 4;
+function _removeServed(path) {
+ return _fileRemoveStatus(path).then((res) => {
+ const code = (typeof res === 'number') ? res : parseInt(res, 10);
+ if (code === 0 || code === UBUS_NOT_FOUND || isNaN(code)) return;
+ return Promise.reject(new Error(
+ _('The router refused to delete the file (ubus status %d).', 'footstrap').format(code)));
+ });
+}
+/* cgi-upload writes the file 0600 and uhttpd refuses to serve a file that is not world-readable
+ * (0600 -> 403, 0644 -> 200), so make it 0644 first. The rpcd ACL grants exec on exactly two fixed
+ * commands — chmod 644 on the two files this module uploads — with no caller-controlled
+ * argument. */
+const _fileExec = rpc.declare({ object: 'file', method: 'exec', params: [ 'command', 'params' ], reject: true });
+/* …and the ubus status is only half of it: `file.exec` reports the command's exit status inside the
+ * payload, so a chmod that ran and failed still comes back as a successful call — and the upload
+ * then reports success for a file uhttpd will 403, leaving every device a scrim over nothing. */
+function _chmodServeable(path) {
+ return _fileExec('/bin/chmod', [ '644', path ]).then((res) => {
+ if (res && res.code)
+ throw new Error(MSG_UPLOAD_FAILED + ' (chmod ' + res.code + ')');
+ return res;
+ });
+}
+/* Re-encode the picked image to a bounded JPEG on a canvas. A security step as much as a size one:
+ * the canvas keeps only the decoded pixels, so EXIF and any bytes appended past the image are
+ * dropped and the uploaded blob is exactly what the browser drew.
+ *
+ * The whole body is guarded, because a throw inside an event handler does not reject the promise it
+ * sits in — it escapes as an uncaught error and leaves the promise pending forever. Two real ways
+ * out of `onload`: `getContext('2d')` answers null when the canvas cannot be backed, and
+ * drawImage/toBlob can throw. A pending promise leaves the caller's "Uploading…" button disabled
+ * and lying until the form is rebuilt on a later arrival at the page. */
+function _downscale(file) {
+ return new Promise((resolve, reject) => {
+ const url = URL.createObjectURL(file);
+ const img = new Image();
+ img.onload = () => {
+ URL.revokeObjectURL(url);
+ try {
+ const scale = Math.min(1, BG_MAX_SIDE / Math.max(img.width, img.height));
+ const w = Math.max(1, Math.round(img.width * scale));
+ const h = Math.max(1, Math.round(img.height * scale));
+ const cv = document.createElement('canvas');
+ cv.width = w; cv.height = h;
+ const ctx = cv.getContext('2d');
+ if (!ctx) throw new Error('no 2d context');
+ ctx.drawImage(img, 0, 0, w, h);
+ cv.toBlob((blob) => blob ? resolve(blob) : reject(new Error(MSG_BAD_IMAGE)),
+ 'image/jpeg', BG_QUALITY);
+ } catch (e) { reject(new Error(MSG_BAD_IMAGE)); }
+ };
+ img.onerror = () => { URL.revokeObjectURL(url); reject(new Error(_('That file is not a readable image.', 'footstrap'))); };
+ img.src = url;
+ });
+}
+
+/* An upload that has landed but could not be RECORDED must not stay on the router. The two paths
+ * below write the file first and the token second, and the second half can fail on its own (no
+ * `settings` section, a narrowed uci ACL, ubus busy) — the image then sits at mode 0644 and is
+ * served to anyone through the /www symlink, which does not depend on the token, while Remove is
+ * hidden precisely because the token is empty. Roll the file back and report the failure that
+ * started it; a rollback that itself fails is appended, because the admin has to know the file is
+ * there. */
+function _rollbackUpload(path, cause) {
+ return _removeServed(path).then(
+ () => Promise.reject(cause),
+ () => Promise.reject(new Error(String((cause && cause.message) || cause) + ' — '
+ + _('the uploaded file could not be removed either; it is still on the router.', 'footstrap')))
+ );
+}
+
+/* ---- one upload, two assets ----
+ *
+ * Both wallpapers travel the same road: refuse what should not be sent, turn the picked file into
+ * the bytes that will actually be stored, POST them to cgi-upload, take the md5 `checksum` back as
+ * the cache-bust token, make the file servable, write the token to uci, and only then paint it.
+ * Every step of that was written out twice, and the two copies had already drifted — one quoted
+ * the url() it wrote with `"` and the other with `'`.
+ *
+ * What genuinely differs is one function: what `prepare` hands back to be uploaded. The SVG is read
+ * as text and inspected, because an SVG is a document and the check has to see the parsed tree; the
+ * photo is redrawn on a canvas, which both bounds it and drops EXIF, because a raster has nothing
+ * to inspect. Everything either side of that is the same road.
+ *
+ * `rollback` is the reason the order matters. The bytes land before the token does, and the second
+ * half can fail on its own — no `settings` section, a narrowed uci ACL, ubus busy — leaving a file
+ * at 0644 served through the /www symlink while Remove stays hidden, because Remove keys off the
+ * token being non-empty. So a failure after the write takes the file away again. */
+function assetAxis(o) {
+ const upload = (file) => Promise.resolve()
+ .then(() => o.prepare(file))
+ .then((blob) => {
+ const fd = new FormData();
+ fd.append('sessionid', rpc.getSessionID());
+ fd.append('filename', o.path);
+ fd.append('filedata', blob, o.filename);
+ return fetch(L.env.cgi_base + '/cgi-upload',
+ { method: 'POST', body: fd, credentials: 'same-origin' })
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status))));
+ })
+ .then((reply) => {
+ /* cgi-upload answers { name, size, checksum, sha256sum } or { failure: [code, msg] } */
+ if (!reply || reply.failure)
+ return Promise.reject(new Error((reply && reply.failure && reply.failure[1])
+ || MSG_UPLOAD_FAILED));
+ const tok = String(reply.checksum || '').toLowerCase();
+ if (!axes.tokenOk(tok)) return Promise.reject(new Error(MSG_UPLOAD_FAILED));
+ /* cgi-upload writes 0600 and uhttpd refuses to serve a file that is not world-readable
+ * (0600 -> 403, 0644 -> 200); _chmodServeable checks the command's exit status, not
+ * just the ubus call's */
+ return _chmodServeable(o.path)
+ /* uci gets the token and nothing else: putting a file on the router is not the same
+ * act as making every other device paint it */
+ .then(() => _uciSet('footstrap', 'settings', { [o.field]: tok }))
+ .then(() => _uciCommit('footstrap'))
+ .catch((e) => _rollbackUpload(o.path, e))
+ .then(() => {
+ /* switch this browser onto it: the ordinary axis path, localStorage only */
+ axes.applyWallpaper(o.wallpaper);
+ o.apply(tok);
+ return tok;
+ });
+ });
+
+ /* Remove: delete the file, blank the token (uci `set` to '', not delete — the scoped ACL grants
+ * set/commit only), clear the url() live. */
+ const remove = () => _removeServed(o.path)
+ .then(() => _uciSet('footstrap', 'settings', { [o.field]: '' }))
+ .then(() => _uciCommit('footstrap'))
+ .then(() => { o.apply(''); });
+
+ return { upload, remove };
+}
+
+/* The tile. No canvas step, which is what strips a photo's EXIF: an SVG redrawn to a canvas comes
+ * back a raster, so the parsed-document check above stands in for it. */
+const PATTERN = assetAxis({
+ path: PAT_PATH, filename: 'pattern.svg', field: 'pattern', wallpaper: 'pattern',
+ apply: (tok) => axes.applyPattern(tok),
+ prepare: (file) => {
+ if (!file) return Promise.reject(new Error(MSG_PICK_SVG));
+ const isSvg = (/(^image\/svg\+xml$)/i).test(file.type || '') || (/\.svg$/i).test(file.name || '');
+ if (!isSvg) return Promise.reject(new Error(MSG_PICK_SVG));
+ if (file.size > PAT_MAX) return Promise.reject(new Error(_('That file is too large.', 'footstrap')));
+ return _readText(file).then((text) => {
+ const objection = _svgObjection(text);
+ if (objection) return Promise.reject(new Error(objection));
+ return new Blob([ text ], { type: 'image/svg+xml' });
+ });
+ }
+});
+
+/* The photo. cgi-upload is the endpoint L.ui.uploadFile uses — session in the `sessionid` field,
+ * path in `filename`, bytes in `filedata` — and it authorises the write against the ACL's `file`
+ * grant for BG_PATH. */
+const LOGIN_BG = assetAxis({
+ path: BG_PATH, filename: 'login-bg', field: 'login_bg', wallpaper: 'file',
+ apply: (tok) => axes.applyLoginBg(tok),
+ prepare: (file) => {
+ if (!file || !(/^image\//).test(file.type || ''))
+ return Promise.reject(new Error(_('Please choose an image file.', 'footstrap')));
+ if (file.size > BG_SRC_MAX)
+ return Promise.reject(new Error(_('That image is too large.', 'footstrap')));
+ return _downscale(file);
+ }
+});
+
+
+return baseclass.extend({
+ uploadPattern: PATTERN.upload,
+ removePattern: PATTERN.remove,
+ uploadLoginBg: LOGIN_BG.upload,
+ removeLoginBg: LOGIN_BG.remove
+});
diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-axes.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-axes.js
new file mode 100644
index 00000000..2ce4c64c
--- /dev/null
+++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-axes.js
@@ -0,0 +1,482 @@
+'use strict';
+'require baseclass';
+'require rpc';
+'require fs-prefs as prefs';
+
+/* fs-axes — the nineteen Appearance axes and the Save-as-default machinery.
+ *
+ * Split out of fs-prefs.js for one reason: WHERE it is needed. `fs-prefs` is required by the
+ * chrome, the menu and the search palette, so it is fetched on every admin page — and of its
+ * sixty-one exports the cold path called eight. Everything here is reached only from
+ * `fs-appearance` (the form) and `fs-assets` (the two uploads), both page modules, so the router
+ * fetches it on the Appearance tab and nowhere else. Measured: 6.6 KB off what every page
+ * downloads.
+ *
+ * What stayed behind in `fs-prefs`, and why:
+ * - the localStorage wrappers and `sd()`, which everything here calls through `prefs.`
+ * - dark mode, because `guardDarkStamp` defends against a third-party app on every page and
+ * `tools/chrome-fence.mjs` holds `stampDark()` to that file by path
+ * - layout, density, rail and auto-collapse, because the chrome and the menu apply them live —
+ * and because `tools/scroll-anchor.mjs` and `tools/scroll-jank.mjs` stamp layout and density
+ * through `L.require('fs-prefs')` to sweep their matrix
+ *
+ * The pre-paint in head.ut has already stamped every axis before the first frame, so nothing here
+ * is needed to PAINT a page correctly — only to change one from the form. tools/axes.mjs reads the
+ * whole resources directory rather than a path, precisely so an axis may live in a second file. */
+
+const FS_RADIUS_DEFAULT = 12;
+
+const FS_HEX_RE = /^#[0-9a-f]{6}$/i;
+/* 0 | 1..360 | '#rrggbb', from anything: localStorage (always a string), the router default (a uci
+ * string, or a number from a config written before the axes took colours) or a caller. Anything
+ * unrecognised reads as off, the built-in default. */
+function normColor(v) {
+ if (typeof v === 'number') return (v >= 1 && v <= 360) ? v : 0;
+ if (typeof v !== 'string') return 0;
+ const s = v.trim();
+ if (FS_HEX_RE.test(s)) return s.toLowerCase();
+ const h = parseInt(s, 10);
+ return (h >= 1 && h <= 360) ? h : 0;
+}
+function colorAxis(key, attr, hueProp, colorProp) {
+ /* 'fs-tint' -> 'tint', the window.__fsSD field. Every colour key is one word today, so the
+ * hyphen fold changes nothing; it is here because the failure when one is not would be silent
+ * (see enumAxis). */
+ const sdKey = key.slice(3).replace(/-/g, '_');
+ const def = () => normColor(prefs.sd(sdKey));
+ return {
+ def,
+ current() {
+ const raw = prefs.lsGet(key);
+ return (raw !== null) ? normColor(raw) : def();
+ },
+ apply(val) {
+ const root = document.documentElement;
+ const v = normColor(val);
+ prefs.lsSet(key, String(v));
+ if (!v) {
+ root.removeAttribute(attr);
+ root.style.removeProperty(hueProp);
+ root.style.removeProperty(colorProp);
+ } else if (typeof v === 'number') {
+ root.style.removeProperty(colorProp);
+ /* the hue first, then the attribute that switches the rotation on: the other
+ * order paints one frame in the previous colour on a fresh load */
+ root.style.setProperty(hueProp, String(v));
+ root.setAttribute(attr, 'hue');
+ } else {
+ root.style.removeProperty(hueProp);
+ root.style.setProperty(colorProp, v);
+ root.setAttribute(attr, 'hex');
+ }
+ }
+ };
+}
+
+/* A numeric slider axis that sets an inline custom property and no attribute. Each validates to
+ * [min,max], stores the choice explicitly (including the default, so it overrides a router default)
+ * and removes the property AT the default, so 02-tokens' own value shows through; they differ only
+ * in how the number formats onto the property, which is the one varying argument. The prefs.sd() field
+ * name is passed explicitly because one instance needs a rename rather than a spelling
+ * ('fs-radius' -> rounding), and a factory right for four keys out of five is the trap enumAxis and
+ * colorAxis name above. */
+function propAxis(key, sdKey, prop, min, max, dfl, fmt) {
+ const inRange = (n) => (typeof n === 'number' && n >= min && n <= max);
+ const def = () => { const d = prefs.sd(sdKey); return inRange(d) ? d : dfl; };
+ return {
+ def,
+ current() {
+ const raw = prefs.lsGet(key);
+ if (raw !== null) { const v = parseInt(raw, 10); return inRange(v) ? v : dfl; }
+ return def();
+ },
+ apply(n) {
+ const root = document.documentElement;
+ const v = Math.max(min, Math.min(max, n | 0));
+ prefs.lsSet(key, String(v));
+ if (v === dfl) root.style.removeProperty(prop);
+ else root.style.setProperty(prop, fmt(v));
+ }
+ };
+}
+
+/* Palette: footstrap is the default (bare :root); every other colourway is an opt-in data-palette
+ * value, defined in styles/03-palettes.css.
+ *
+ * Not the enumAxis shape, which has one `on` name and reads every other stored string — including a
+ * real palette — as the default. The array is what VALIDATES a stored value: a name added to the
+ * CSS and not here is one head.ut pre-paints and the live applier then rejects, so the page paints
+ * it and the first touch of any other control takes it away.
+ *
+ * Legacy names ('rvht'/'roman'/'github') are migrated by head.ut before paint, so they never reach
+ * currentPalette() on a loaded page; the stray fallthrough covers them anyway. */
+
+const PALETTES = [ 'hicontrast', 'bootstrap', '2020' ]; /* the non-default values; 'footstrap' = bare :root */
+const PALETTE = prefs.listAxis('fs-palette', 'data-palette', PALETTES, 'footstrap');
+const currentPalette = PALETTE.current, applyPalette = PALETTE.apply;
+
+/* Wallpaper is a multi-value axis: off (bare canvas), pattern (the admin-uploaded SVG, tiled and
+ * recoloured — 15-wallpaper.css) or file (the admin-uploaded photo, 16-login-bg.css).
+ * data-wallpaper carries the value, or is absent for 'off'. Both images are router-side; this axis
+ * only decides whether THIS browser paints one, so a router-wide backdrop comes from
+ * Save-as-default, including the pre-login page.
+ *
+ * The list validates a stored value, so adding one means this line, the head.ut whitelist, the
+ * Wallpaper select in fs-appearance.js and the rules in 15-wallpaper.css. A value that is no longer
+ * in the list falls back to 'off'. */
+const WALLPAPERS = [ 'pattern', 'file' ]; /* the non-off values; 'off' = bare :root */
+const WALLPAPER = prefs.listAxis('fs-wallpaper', 'data-wallpaper', WALLPAPERS, 'off');
+const currentWallpaper = WALLPAPER.current, applyWallpaper = WALLPAPER.apply;
+
+/* Density: how much air the UI uses. A three-value axis like wallpaper, and a pure token axis —
+ * 02-tokens.css multiplies the type and space ladders and every size follows, with no layout switch
+ * and no re-render.
+ *
+ * Beyond stamping the attribute it must re-run the measured decisions (fitChrome, fitTables,
+ * fitShell), which were taken against the old metrics: Compact makes more fit and Large less, so
+ * otherwise the bar stays stacked — or stays unstacked and overflows — until the next resize. */
+
+const TINT = colorAxis('fs-tint', 'data-tint', '--fs-tint-h', '--fs-bg');
+const currentTint = TINT.current, applyTint = TINT.apply;
+
+/* Accent axis: the UI accent (solid buttons, toggle knobs, sliders, focus rings, accented links)
+ * while canvas, cards and status colours stay put. On a hue, CSS rotates --fs-accent and keeps the
+ * palette's lightness and chroma, so --fs-on-accent stays legible unrecomputed; on a hex the ink is
+ * recomputed from the entered colour's lightness (03-palettes.css). 0 = off. */
+const ACCENT = colorAxis('fs-accent', 'data-accent', '--fs-accent-h', '--fs-accent');
+const currentAccent = ACCENT.current, applyAccent = ACCENT.apply;
+
+/* The three status colours are the same axis pointed at --fs-good / --fs-warn / --fs-danger, kept
+ * separate because they carry separate meanings and every derived tint is a color-mix() of the
+ * role, so each follows its own axis. They are not protected from recolouring: a status colour is
+ * information, and an admin who paints Danger green has said so. What the theme owes them is
+ * readable ink over the fill (03-palettes.css) and the contrast readout beside each field. */
+/* ---- the surface axes: the sheet the UI is drawn on, rather than the marks on it ----
+ *
+ * The cards, the chrome, the inset controls and the hairlines. Their own factory rather than four
+ * more colorAxis instances, because:
+ *
+ * - there is no hue mode — rotating the hue of a near-white card keeps its chroma (~0.003), so
+ * every angle produces the same white. The Tint axis colours a surface by SETTING a chroma;
+ * - there is no derived ink — what reads on these is --fs-text, a palette token these axes must
+ * not move, so the Appearance page reports the contrast instead;
+ * - they therefore need no attribute: an inline custom property on :root is the whole mechanism,
+ * and every derived token follows because each is a color-mix() of the one this sets. That is
+ * why --fs-bar-bg is a surface of its own — an admin who wants a dark chrome over light cards
+ * has to be able to say so.
+ *
+ * Off is prefs.lsSet('0'), not a deleted key: once a router default exists, clearing means "inherit
+ * it". */
+
+function surfaceAxis(key, sdKey, prop) {
+ const norm = (v) => {
+ const s = (typeof v === 'string') ? v.trim().toLowerCase() : '';
+ return FS_HEX_RE.test(s) ? s : 0;
+ };
+ const def = () => norm(prefs.sd(sdKey));
+ return {
+ def,
+ current() {
+ const raw = prefs.lsGet(key);
+ return (raw !== null) ? norm(raw) : def();
+ },
+ apply(val) {
+ const v = norm(val);
+ prefs.lsSet(key, String(v));
+ if (v) document.documentElement.style.setProperty(prop, v);
+ else document.documentElement.style.removeProperty(prop);
+ }
+ };
+}
+const CARD = surfaceAxis('fs-card', 'card', '--fs-panel-base');
+const currentCard = CARD.current, applyCard = CARD.apply;
+const CONTROL = surfaceAxis('fs-control', 'control', '--fs-panel2-base');
+const currentControl = CONTROL.current, applyControl = CONTROL.apply;
+const BAR = surfaceAxis('fs-bar', 'bar', '--fs-bar-bg');
+const currentBar = BAR.current, applyBar = BAR.apply;
+const LINE = surfaceAxis('fs-line', 'line', '--fs-border-base');
+const currentLine = LINE.current, applyLine = LINE.apply;
+
+
+const GOOD = colorAxis('fs-good', 'data-good', '--fs-good-h', '--fs-good');
+const currentGood = GOOD.current, applyGood = GOOD.apply;
+const WARN = colorAxis('fs-warn', 'data-warn', '--fs-warn-h', '--fs-warn');
+const currentWarn = WARN.current, applyWarn = WARN.apply;
+const DANGER = colorAxis('fs-danger', 'data-danger', '--fs-danger-h', '--fs-danger');
+const currentDanger = DANGER.current, applyDanger = DANGER.apply;
+
+/* Rounding: the propAxis instance (default const and rationale up top), --fs-radius-base in px. */
+const RADIUS = propAxis('fs-radius', 'rounding', '--fs-radius-base', 0, 20, FS_RADIUS_DEFAULT, (v) => (v + 'px'));
+const currentRadius = RADIUS.current, applyRadius = RADIUS.apply, radiusDefault = RADIUS.def;
+
+/* Layout axis: horizontal top bar (the default) vs vertical sidebar. One template, one renderer —
+ * CSS morphs the chrome off :root[data-layout] and toggling re-renders nothing; menu-footstrap.js
+ * observes the attribute and folds the accordion into dropdowns or restores it.
+ *
+ * Read the ATTRIBUTE, not localStorage: head.ut stamps it server-side from the router default and
+ * the pre-paint script overrides it, so it always carries an explicit value. localStorage would
+ * report 'sidebar' on a router defaulting to 'top' until the user first touched the toggle. */
+
+const AXIS_KEYS = [
+ 'fs-layout', 'fs-darkmode', 'fs-palette', 'fs-wallpaper', 'fs-tint',
+ 'fs-accent', 'fs-good', 'fs-warn', 'fs-danger', 'fs-card', 'fs-control',
+ 'fs-bar', 'fs-line', 'fs-radius', 'fs-menu-autocollapse', 'fs-tint-strength',
+ 'fs-density', 'fs-photo-dim', 'fs-pattern-size', 'fs-pattern-strength',
+ 'fs-pattern-ink'
+];
+/* Tint strength: a multiplier on the tint chroma (03-palettes.css), 100% being the designed
+ * strength and 200% the cap. 0 is not quite "no tint" — the relative colour that applies the tint
+ * replaces chroma outright, so 0 leaves a neutral canvas at the same lightness rather than the
+ * untinted one; clearing the Tint hue is the real off. It only bites while a Tint hue is set, and
+ * is moot under the File wallpaper, where the photo covers the canvas.
+ *
+ * This axis and its default live above _resolvedDefault()'s module-init call below: a propAxis
+ * instance is a `const`, so declaring it further down leaves it in the TDZ at init and the whole
+ * module throws, taking the chrome and the menu with it. */
+const FS_TSTR_DEFAULT = 100;
+const TSTR = propAxis('fs-tint-strength', 'tint_strength', '--fs-tint-strength', 0, 200, FS_TSTR_DEFAULT, (v) => String(v / 100));
+const currentTintStrength = TSTR.current, applyTintStrength = TSTR.apply, tintStrengthDefault = TSTR.def;
+
+/* Photo dim: the scrim opacity over the FILE photo (0–100%). The photo is shared; how strongly
+ * this browser dims it is not, and it reaches the router through Save-as-default. Only bites while
+ * the wallpaper is 'file'. Declared up here for the TDZ reason above. */
+const FS_PDIM_DEFAULT = 74;
+const PDIM = propAxis('fs-photo-dim', 'photo_dim', '--fs-photo-dim', 0, 100, FS_PDIM_DEFAULT, (v) => (v + '%'));
+const currentPhotoDim = PDIM.current, applyPhotoDim = PDIM.apply, photoDimDefault = PDIM.def;
+
+/* The pattern's two live knobs, and the third that is an enum. All three bite only while the
+ * wallpaper is 'pattern'; the FILE is shared, how this browser draws it is not.
+ *
+ * Size is the tile's edge in px, with a wide range because "how big is one repeat" is a property of
+ * the artwork. Strength is the layer's opacity 0-100, which is the knob a `` baked into
+ * the file would put out of CSS's reach. Declared up here for the TDZ reason above. */
+const FS_PSIZE_DEFAULT = 440;
+const PSIZE = propAxis('fs-pattern-size', 'pattern_size', '--fs-pattern-size', 40, 1600, FS_PSIZE_DEFAULT, (v) => (v + 'px'));
+const currentPatternSize = PSIZE.current, applyPatternSize = PSIZE.apply, patternSizeDefault = PSIZE.def;
+const FS_PSTR_DEFAULT = 20;
+const PSTR = propAxis('fs-pattern-strength', 'pattern_strength', '--fs-pattern-strength', 0, 100, FS_PSTR_DEFAULT, (v) => String(v / 100));
+const currentPatternStrength = PSTR.current, applyPatternStrength = PSTR.apply, patternStrengthDefault = PSTR.def;
+/* Ink: 'theme' (the file's alpha, the theme's colour) or 'original' (the file's own colours, no
+ * mask). Two-valued with the default as a bare :root, i.e. the enumAxis shape. */
+const PINK = prefs.enumAxis('fs-pattern-ink', 'data-pattern-ink', 'original', 'theme');
+const currentPatternInk = PINK.current, applyPatternInk = PINK.apply;
+/* `reject: true` is load-bearing: without it a refused write arrives as SUCCESS. rpc.js raises on
+ * the ubus status code only when the declaration asks it to, and otherwise hands the code back as
+ * the resolved value — measured on the router, a per-config ACL refusal resolves with 6
+ * (permission denied) and every `.then()` below runs as if the file had been written, greying the
+ * Save button over a write that never happened. */
+
+const _uciSet = rpc.declare({ object: 'uci', method: 'set', params: [ 'config', 'section', 'values' ], reject: true });
+const _uciCommit = rpc.declare({ object: 'uci', method: 'commit', params: [ 'config' ], reject: true });
+
+function snapshotAxes() {
+ return {
+ layout: prefs.currentLayout(),
+ darkmode: prefs.currentMode(),
+ palette: currentPalette(),
+ wallpaper: currentWallpaper(),
+ tint: String(currentTint()),
+ accent: String(currentAccent()),
+ good: String(currentGood()),
+ warn: String(currentWarn()),
+ danger: String(currentDanger()),
+ card: String(currentCard()),
+ control: String(currentControl()),
+ bar: String(currentBar()),
+ line: String(currentLine()),
+ rounding: String(currentRadius()),
+ autocollapse: prefs.currentAutoCollapse() ? 'on' : 'off',
+ tint_strength: String(currentTintStrength()),
+ density: prefs.currentDensity(),
+ photo_dim: String(currentPhotoDim()),
+ pattern_size: String(currentPatternSize()),
+ pattern_strength: String(currentPatternStrength()),
+ pattern_ink: currentPatternInk()
+ };
+}
+/* The resolved router default (the uci value if set, else the built-in) in snapshotAxes() string
+ * form, so the Appearance tab can grey the Save button when this browser already shows exactly it.
+ * Seeded from window.__fsSD at load and replaced with the just-saved snapshot, so a save flips the
+ * match without a reload.
+ *
+ * Every field is the axis's own def(): a second copy of a validation drifts with no symptom beyond
+ * matchesSavedDefault() lying, which is the one thing the Save button is. `layout` is the exception,
+ * since prefs.currentLayout() reads the attribute — its fallback must stay `top`, matching head.ut's
+ * stamp and resetToBuiltin(), or a fresh install shows dirty before anything is touched and
+ * resetToSaved() lands on the wrong layout. */
+function _resolvedDefault() {
+ return {
+ layout: prefs.sd('layout') || 'top',
+ darkmode: prefs.modeDefault(),
+ palette: PALETTE.def(),
+ wallpaper: WALLPAPER.def(),
+ tint: String(TINT.def()),
+ accent: String(ACCENT.def()),
+ good: String(GOOD.def()),
+ warn: String(WARN.def()),
+ danger: String(DANGER.def()),
+ card: String(CARD.def()),
+ control: String(CONTROL.def()),
+ bar: String(BAR.def()),
+ line: String(LINE.def()),
+ rounding: String(radiusDefault()),
+ autocollapse: (prefs.autoCollapseDefault() ? 'on' : 'off'),
+ tint_strength: String(tintStrengthDefault()),
+ density: prefs.densityDefault(),
+ photo_dim: String(photoDimDefault()),
+ pattern_size: String(patternSizeDefault()),
+ pattern_strength: String(patternStrengthDefault()),
+ pattern_ink: PINK.def()
+ };
+}
+let _savedDefault = _resolvedDefault();
+function matchesSavedDefault() {
+ const cur = snapshotAxes();
+ return Object.keys(cur).every((k) => cur[k] === _savedDefault[k]);
+}
+
+/* ---- no axis reaches /etc/config/footstrap except through Save-as-default ----
+ * Every axis is per-browser. An axis that wrote through on change — on the argument that the photo
+ * it relates to is router-side — re-pointed the router-wide default for every other device from one
+ * browser, and moved the Save baseline with it, so the button did not even light up. A per-browser
+ * preference must never mutate shared state invisibly.
+ *
+ * Only the photo's bytes and its cache-bust token are router-side. Whether a browser paints it is
+ * `fs-wallpaper` and how dim is `fs-photo-dim`: ordinary axes, saved with the rest or not at
+ * all. */
+function saveAsDefault() {
+ const snap = snapshotAxes();
+ return _uciSet('footstrap', 'settings', snap)
+ .then(() => _uciCommit('footstrap'))
+ .then(() => { _savedDefault = snap; });
+}
+/* ---- the two resets, which are not the same escape hatch ----
+ *
+ * Both drop this browser's tweaks and differ in what is underneath:
+ *
+ * resetToSaved() clears the keys, so every axis falls back to the router default where one is
+ * set and to the built-in where it is not. The browser goes back to inheriting.
+ * resetToBuiltin() writes the theme's own defaults explicitly, the only way to say "as the theme
+ * ships" — clearing the keys is the sentence that means "inherit the router
+ * default".
+ *
+ * Both leave /etc/config/footstrap alone: neither un-saves a router default.
+ *
+ * The caller reloads so head.ut re-applies everything in one pass — the appliers repaint correctly,
+ * but the controls on the page were built from the values they had at render time. */
+function resetToSaved() {
+ AXIS_KEYS.forEach(prefs.lsDel);
+}
+
+/* The built-in defaults, written through the ordinary appliers so each validates its own value and
+ * stamps :root as usual. Stated rather than derived: a default is a default because it is what a
+ * bare :root paints, and the five with a named const use it, so the numbers cannot drift from the
+ * CSS. */
+function resetToBuiltin() {
+ /* `top` for layout, not sidebar: the bar is what a bare :root paints (head.ut stamps it when uci
+ * says nothing), so it is what "as the theme ships" means. The colour and surface axes reset to
+ * 0, which is "the palette's own".
+ *
+ * Stated as calls rather than carried in AXES: a fourth column of thunks measured 297 B against
+ * this list, and a wrong value here is what the Save button's "Reset to default" shows on the
+ * first click — the one state no static gate can see and the live check does. */
+ prefs.applyLayout('top');
+ prefs.applyMode('auto');
+ applyPalette('footstrap');
+ applyWallpaper('off');
+ applyTint(0);
+ applyAccent(0);
+ applyGood(0);
+ applyWarn(0);
+ applyDanger(0);
+ applyCard(0);
+ applyControl(0);
+ applyBar(0);
+ applyLine(0);
+ applyRadius(FS_RADIUS_DEFAULT);
+ prefs.applyAutoCollapse('off');
+ applyTintStrength(FS_TSTR_DEFAULT);
+ prefs.applyDensity('normal');
+ applyPhotoDim(FS_PDIM_DEFAULT);
+ applyPatternSize(FS_PSIZE_DEFAULT);
+ applyPatternStrength(FS_PSTR_DEFAULT);
+ applyPatternInk('theme');
+}
+
+/* ---- the two uploaded wallpapers, browser side ----
+ *
+ * What the router last saved, what URL that is, and how to paint it. Putting the file THERE is
+ * fs-assets.js: a DOMParser pass, a canvas re-encode, a chmod and a rollback, reached only from the
+ * Appearance tab and so not worth downloading on every admin page. The token accessors stay here
+ * because `prefs.sd()` is private to this module and because head.ut's pre-paint reads the same fields.
+ *
+ * Neither is an axis: an axis is per-browser with a router default, and these have no browser
+ * layer — one admin uploads once and every device sees it, pre-login included. So they are absent
+
+ * from AXIS_KEYS, snapshotAxes() and matchesSavedDefault(), and must not move the Save button. */
+const PAT_SERVE = '/luci-static/footstrap/pattern.svg'; /* the uhttpd symlink the uci-default makes */
+function currentPattern() {
+ const t = prefs.sd('pattern');
+ return (typeof t === 'string' && BG_TOKEN_RE.test(t)) ? t : '';
+}
+function patternUrl(tok) { return PAT_SERVE + '?v=' + tok; }
+
+/* set/clear the tile URL live. This only supplies the url(); whether it PAINTS is the Wallpaper
+ * axis (data-wallpaper="pattern"). Exported because fs-assets.js applies the token it just wrote. */
+function applyPattern(tok) {
+ const root = document.documentElement;
+ if (tok) root.style.setProperty('--fs-pattern-url', 'url("' + patternUrl(tok) + '")');
+ else root.style.removeProperty('--fs-pattern-url');
+ prefs.setSD('pattern', tok || '');
+}
+
+const BG_SERVE = '/luci-static/footstrap/bg'; /* the uhttpd symlink the uci-default makes */
+/* the cache-bust token charset, an md5/sha hex string. One copy here; head.ut's ucode sanitiser
+ * and the pre-paint inline script keep their own identical copies unavoidably, running before this
+ * module — see the axes contract in head.ut. */
+const BG_TOKEN_RE = /^[a-f0-9]{6,64}$/;
+/* the same question asked from fs-assets.js, which validates the checksum an upload replies with.
+ * A predicate rather than the pattern itself, so the charset stays stated once. */
+function tokenOk(t) { return BG_TOKEN_RE.test(t); }
+
+/* the token the server last saved, validated to the same hex charset head.ut's sanitiser and
+ * pre-paint use, so the Appearance tab can build a cache-busted preview src. '' = none. */
+function currentLoginBg() {
+ const t = prefs.sd('login_bg');
+ return (typeof t === 'string' && BG_TOKEN_RE.test(t)) ? t : '';
+}
+function loginBgUrl(tok) { return BG_SERVE + '?v=' + tok; }
+
+/* applyPattern's twin for the photo; data-wallpaper="file" decides whether it paints. */
+function applyLoginBg(tok) {
+ const root = document.documentElement;
+ if (tok) root.style.setProperty('--fs-login-bg-url', 'url("' + loginBgUrl(tok) + '")');
+ else root.style.removeProperty('--fs-login-bg-url');
+ prefs.setSD('login_bg', tok || '');
+}
+
+return baseclass.extend({
+ currentPalette, applyPalette,
+ currentWallpaper, applyWallpaper,
+ currentTint, applyTint,
+ currentAccent, applyAccent,
+ currentGood, applyGood,
+ currentWarn, applyWarn,
+ currentDanger, applyDanger,
+ currentCard, applyCard,
+ currentControl, applyControl,
+ currentBar, applyBar,
+ currentLine, applyLine,
+ currentRadius, applyRadius,
+ currentTintStrength, applyTintStrength,
+ currentPhotoDim, applyPhotoDim,
+ currentPatternSize, applyPatternSize,
+ currentPatternStrength, applyPatternStrength,
+ currentPatternInk, applyPatternInk,
+
+ currentPattern, patternUrl, applyPattern,
+ currentLoginBg, loginBgUrl, applyLoginBg,
+ tokenOk,
+
+ snapshotAxes, matchesSavedDefault, saveAsDefault, resetToSaved, resetToBuiltin
+});
diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-prefs.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-prefs.js
index b8dbac02..ee8d7bba 100644
--- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-prefs.js
+++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-prefs.js
@@ -151,7 +151,6 @@ _mqDark.addEventListener('change', () => {
/* Corner radius: the card radius (0–20px) as an inline --fs-radius-base on :root, from which
* 02-tokens derives every other radius. head.ut pre-paints it and tools/axes.mjs holds JS/CSS/head
* to this one number, hence the named const. */
-const FS_RADIUS_DEFAULT = 12;
/* ---- the four axis shapes, each written once ----
*
@@ -171,34 +170,50 @@ const FS_RADIUS_DEFAULT = 12;
* `wallpaper` and `density` are three-valued, `palette` outgrew the two-value shape when the third
* one landed, `autoCollapse` has no :root attribute. */
-/* A two-value axis: `on` is stamped as the attribute's value, `off` is a bare :root (no
- * attribute). */
-function enumAxis(key, attr, on, off) {
+/* An axis whose values are a list, with one of them stamped as nothing.
+ *
+ * `values` are the names that become `attr=""`; `dflt` is the one that leaves :root bare, and
+ * is what a stray or missing value falls back to. `after` runs once the attribute is stamped, for
+ * the axis that has to re-take a measurement.
+ *
+ * The list IS the validation: a name added to the stylesheet and not here is one head.ut pre-paints
+ * and this rejects, so the page paints it and the first touch of any other control takes it away. */
+function listAxis(key, attr, values, dflt, after) {
/* 'fs-pattern-ink' -> 'pattern_ink', the window.__fsSD field. The underscore is the point: the
* localStorage key is hyphenated and the uci option is not, so a bare slice(3) names a field
* head.ut never emits, sd() returns undefined forever, and the axis reports the built-in
* default however the router is set — Save-as-default then writes it over the admin's value. */
const sdKey = key.slice(3).replace(/-/g, '_');
- const def = () => (sd(sdKey) === on ? on : off);
+ const ok = (v) => (values.indexOf(v) >= 0);
+ const def = () => (ok(sd(sdKey)) ? sd(sdKey) : dflt);
return {
def,
current() {
const s = lsGet(key);
- if (s === on) return on;
- if (s === off) return off;
+ if (ok(s)) return s;
+ if (s === dflt) return dflt;
if (s === null) return def();
- return off; /* a stray value reads as the built-in default */
+ return dflt; /* a stray value reads as the built-in default */
},
apply(val) {
const root = document.documentElement;
- const isOn = (val === on);
- lsSet(key, isOn ? on : off);
- if (isOn) root.setAttribute(attr, on);
- else root.removeAttribute(attr);
+ const v = ok(val) ? val : dflt;
+ /* stored explicitly (including the default), so it overrides a router default */
+ lsSet(key, v);
+ if (v === dflt) root.removeAttribute(attr);
+ else root.setAttribute(attr, v);
+ if (after) after();
}
};
}
+/* A two-value axis: `on` is stamped as the attribute's value, `off` is a bare :root. The list shape
+ * with a list of one — kept as its own name because tools/axes.mjs matches the call, and because
+ * "two-valued" is what most of these axes are. */
+function enumAxis(key, attr, on, off) {
+ return listAxis(key, attr, [ on ], off);
+}
+
/* A colour axis — Tint, Accent and the three status colours are one axis pointed at five tokens:
* same validation, same "0 is off", same ordering rule (set the custom property BEFORE the
* attribute, or a fresh load paints one frame in the previous colour).
@@ -215,253 +230,15 @@ function enumAxis(key, attr, on, off) {
* a third to say which is in effect, and that third is the one a pre-paint script forgets.
* `hueProp` carries the degrees, `colorProp` the live token a hex value overwrites; each mode
* clears the other's property, so the two can never both be half-applied. */
-const FS_HEX_RE = /^#[0-9a-f]{6}$/i;
-/* 0 | 1..360 | '#rrggbb', from anything: localStorage (always a string), the router default (a uci
- * string, or a number from a config written before the axes took colours) or a caller. Anything
- * unrecognised reads as off, the built-in default. */
-function normColor(v) {
- if (typeof v === 'number') return (v >= 1 && v <= 360) ? v : 0;
- if (typeof v !== 'string') return 0;
- const s = v.trim();
- if (FS_HEX_RE.test(s)) return s.toLowerCase();
- const h = parseInt(s, 10);
- return (h >= 1 && h <= 360) ? h : 0;
-}
-function colorAxis(key, attr, hueProp, colorProp) {
- /* 'fs-tint' -> 'tint', the window.__fsSD field. Every colour key is one word today, so the
- * hyphen fold changes nothing; it is here because the failure when one is not would be silent
- * (see enumAxis). */
- const sdKey = key.slice(3).replace(/-/g, '_');
- const def = () => normColor(sd(sdKey));
- return {
- def,
- current() {
- const raw = lsGet(key);
- return (raw !== null) ? normColor(raw) : def();
- },
- apply(val) {
- const root = document.documentElement;
- const v = normColor(val);
- lsSet(key, String(v));
- if (!v) {
- root.removeAttribute(attr);
- root.style.removeProperty(hueProp);
- root.style.removeProperty(colorProp);
- } else if (typeof v === 'number') {
- root.style.removeProperty(colorProp);
- /* the hue first, then the attribute that switches the rotation on: the other
- * order paints one frame in the previous colour on a fresh load */
- root.style.setProperty(hueProp, String(v));
- root.setAttribute(attr, 'hue');
- } else {
- root.style.removeProperty(hueProp);
- root.style.setProperty(colorProp, v);
- root.setAttribute(attr, 'hex');
- }
- }
- };
-}
-
-/* A numeric slider axis that sets an inline custom property and no attribute. Each validates to
- * [min,max], stores the choice explicitly (including the default, so it overrides a router default)
- * and removes the property AT the default, so 02-tokens' own value shows through; they differ only
- * in how the number formats onto the property, which is the one varying argument. The sd() field
- * name is passed explicitly because one instance needs a rename rather than a spelling
- * ('fs-radius' -> rounding), and a factory right for four keys out of five is the trap enumAxis and
- * colorAxis name above. */
-function propAxis(key, sdKey, prop, min, max, dfl, fmt) {
- const inRange = (n) => (typeof n === 'number' && n >= min && n <= max);
- const def = () => { const d = sd(sdKey); return inRange(d) ? d : dfl; };
- return {
- def,
- current() {
- const raw = lsGet(key);
- if (raw !== null) { const v = parseInt(raw, 10); return inRange(v) ? v : dfl; }
- return def();
- },
- apply(n) {
- const root = document.documentElement;
- const v = Math.max(min, Math.min(max, n | 0));
- lsSet(key, String(v));
- if (v === dfl) root.style.removeProperty(prop);
- else root.style.setProperty(prop, fmt(v));
- }
- };
-}
-
-/* Palette: footstrap is the default (bare :root); every other colourway is an opt-in data-palette
- * value, defined in styles/03-palettes.css.
- *
- * Not the enumAxis shape, which has one `on` name and reads every other stored string — including a
- * real palette — as the default. The array is what VALIDATES a stored value: a name added to the
- * CSS and not here is one head.ut pre-paints and the live applier then rejects, so the page paints
- * it and the first touch of any other control takes it away.
- *
- * Legacy names ('rvht'/'roman'/'github') are migrated by head.ut before paint, so they never reach
- * currentPalette() on a loaded page; the stray fallthrough covers them anyway. */
-const PALETTES = [ 'hicontrast', 'bootstrap', '2020' ]; /* the non-default values; 'footstrap' = bare :root */
-function paletteDefault() {
- const d = sd('palette');
- return (PALETTES.indexOf(d) >= 0) ? d : 'footstrap';
-}
-function currentPalette() {
- const s = lsGet('fs-palette');
- if (PALETTES.indexOf(s) >= 0) return s;
- if (s === 'footstrap') return 'footstrap';
- if (s === null) return paletteDefault();
- return 'footstrap';
-}
-function applyPalette(val) {
- const root = document.documentElement;
- const v = (PALETTES.indexOf(val) >= 0) ? val : 'footstrap';
- /* stored explicitly (including 'footstrap'), so it overrides a router default — see the
- * header */
- lsSet('fs-palette', v);
- if (v === 'footstrap') root.removeAttribute('data-palette');
- else root.setAttribute('data-palette', v);
-}
-
-/* Wallpaper is a multi-value axis: off (bare canvas), pattern (the admin-uploaded SVG, tiled and
- * recoloured — 15-wallpaper.css) or file (the admin-uploaded photo, 16-login-bg.css).
- * data-wallpaper carries the value, or is absent for 'off'. Both images are router-side; this axis
- * only decides whether THIS browser paints one, so a router-wide backdrop comes from
- * Save-as-default, including the pre-login page.
- *
- * The list validates a stored value, so adding one means this line, the head.ut whitelist, the
- * Wallpaper select in fs-appearance.js and the rules in 15-wallpaper.css. A value that is no longer
- * in the list falls back to 'off'. */
-const WALLPAPERS = [ 'pattern', 'file' ]; /* the non-off values; 'off' = bare :root */
-function wallpaperDefault() {
- const d = sd('wallpaper');
- return (WALLPAPERS.indexOf(d) >= 0) ? d : 'off';
-}
-function currentWallpaper() {
- const s = lsGet('fs-wallpaper');
- if (WALLPAPERS.indexOf(s) >= 0) return s;
- if (s === 'off') return 'off';
- if (s === null) return wallpaperDefault();
- return 'off';
-}
-
-/* Density: how much air the UI uses. A three-value axis like wallpaper, and a pure token axis —
- * 02-tokens.css multiplies the type and space ladders and every size follows, with no layout switch
- * and no re-render.
- *
- * Beyond stamping the attribute it must re-run the measured decisions (fitChrome, fitTables,
- * fitShell), which were taken against the old metrics: Compact makes more fit and Large less, so
- * otherwise the bar stays stacked — or stays unstacked and overflows — until the next resize. */
const DENSITIES = [ 'compact', 'large' ]; /* the two non-default values; 'normal' = bare :root */
-function densityDefault() {
- const d = sd('density');
- return (DENSITIES.indexOf(d) >= 0) ? d : 'normal';
-}
-function currentDensity() {
- const s = lsGet('fs-density');
- if (DENSITIES.indexOf(s) >= 0) return s;
- if (s === 'normal') return 'normal';
- if (s === null) return densityDefault();
- return 'normal';
-}
-function applyDensity(val) {
- const root = document.documentElement;
- const v = (DENSITIES.indexOf(val) >= 0) ? val : 'normal';
- lsSet('fs-density', v);
- if (v === 'normal') root.removeAttribute('data-density');
- else root.setAttribute('data-density', v);
- fit.schedule();
-}
-function applyWallpaper(val) {
- const root = document.documentElement;
- const v = (WALLPAPERS.indexOf(val) >= 0) ? val : 'off';
- lsSet('fs-wallpaper', v);
- if (v === 'off') root.removeAttribute('data-wallpaper');
- else root.setAttribute('data-wallpaper', v);
-}
-
+const DENSITY = listAxis('fs-density', 'data-density', DENSITIES, 'normal', () => fit.schedule());
+const currentDensity = DENSITY.current, applyDensity = DENSITY.apply,
+ densityDefault = DENSITY.def;
/* Background-tint axis: the canvas the cards float on (--fs-bg), so a whole install reads as one
* colour and a tab or a screenshot says which router it belongs to. Cards, chrome and the status
* colours keep the palette's values — the cue colours the paper, not the UI. On a hue it is mixed
* in CSS (03-palettes.css explains why that stays contrast-safe at every angle); on a hex it IS the
* canvas. 0 is off rather than red, a hue wheel wrapping, so one end of the range is free. */
-const TINT = colorAxis('fs-tint', 'data-tint', '--fs-tint-h', '--fs-bg');
-const currentTint = TINT.current, applyTint = TINT.apply;
-
-/* Accent axis: the UI accent (solid buttons, toggle knobs, sliders, focus rings, accented links)
- * while canvas, cards and status colours stay put. On a hue, CSS rotates --fs-accent and keeps the
- * palette's lightness and chroma, so --fs-on-accent stays legible unrecomputed; on a hex the ink is
- * recomputed from the entered colour's lightness (03-palettes.css). 0 = off. */
-const ACCENT = colorAxis('fs-accent', 'data-accent', '--fs-accent-h', '--fs-accent');
-const currentAccent = ACCENT.current, applyAccent = ACCENT.apply;
-
-/* The three status colours are the same axis pointed at --fs-good / --fs-warn / --fs-danger, kept
- * separate because they carry separate meanings and every derived tint is a color-mix() of the
- * role, so each follows its own axis. They are not protected from recolouring: a status colour is
- * information, and an admin who paints Danger green has said so. What the theme owes them is
- * readable ink over the fill (03-palettes.css) and the contrast readout beside each field. */
-/* ---- the surface axes: the sheet the UI is drawn on, rather than the marks on it ----
- *
- * The cards, the chrome, the inset controls and the hairlines. Their own factory rather than four
- * more colorAxis instances, because:
- *
- * - there is no hue mode — rotating the hue of a near-white card keeps its chroma (~0.003), so
- * every angle produces the same white. The Tint axis colours a surface by SETTING a chroma;
- * - there is no derived ink — what reads on these is --fs-text, a palette token these axes must
- * not move, so the Appearance page reports the contrast instead;
- * - they therefore need no attribute: an inline custom property on :root is the whole mechanism,
- * and every derived token follows because each is a color-mix() of the one this sets. That is
- * why --fs-bar-bg is a surface of its own — an admin who wants a dark chrome over light cards
- * has to be able to say so.
- *
- * Off is lsSet('0'), not a deleted key: once a router default exists, clearing means "inherit
- * it". */
-function surfaceAxis(key, sdKey, prop) {
- const norm = (v) => {
- const s = (typeof v === 'string') ? v.trim().toLowerCase() : '';
- return FS_HEX_RE.test(s) ? s : 0;
- };
- const def = () => norm(sd(sdKey));
- return {
- def,
- current() {
- const raw = lsGet(key);
- return (raw !== null) ? norm(raw) : def();
- },
- apply(val) {
- const v = norm(val);
- lsSet(key, String(v));
- if (v) document.documentElement.style.setProperty(prop, v);
- else document.documentElement.style.removeProperty(prop);
- }
- };
-}
-const CARD = surfaceAxis('fs-card', 'card', '--fs-panel-base');
-const currentCard = CARD.current, applyCard = CARD.apply;
-const CONTROL = surfaceAxis('fs-control', 'control', '--fs-panel2-base');
-const currentControl = CONTROL.current, applyControl = CONTROL.apply;
-const BAR = surfaceAxis('fs-bar', 'bar', '--fs-bar-bg');
-const currentBar = BAR.current, applyBar = BAR.apply;
-const LINE = surfaceAxis('fs-line', 'line', '--fs-border-base');
-const currentLine = LINE.current, applyLine = LINE.apply;
-
-
-const GOOD = colorAxis('fs-good', 'data-good', '--fs-good-h', '--fs-good');
-const currentGood = GOOD.current, applyGood = GOOD.apply;
-const WARN = colorAxis('fs-warn', 'data-warn', '--fs-warn-h', '--fs-warn');
-const currentWarn = WARN.current, applyWarn = WARN.apply;
-const DANGER = colorAxis('fs-danger', 'data-danger', '--fs-danger-h', '--fs-danger');
-const currentDanger = DANGER.current, applyDanger = DANGER.apply;
-
-/* Rounding: the propAxis instance (default const and rationale up top), --fs-radius-base in px. */
-const RADIUS = propAxis('fs-radius', 'rounding', '--fs-radius-base', 0, 20, FS_RADIUS_DEFAULT, (v) => (v + 'px'));
-const currentRadius = RADIUS.current, applyRadius = RADIUS.apply, radiusDefault = RADIUS.def;
-
-/* Layout axis: horizontal top bar (the default) vs vertical sidebar. One template, one renderer —
- * CSS morphs the chrome off :root[data-layout] and toggling re-renders nothing; menu-footstrap.js
- * observes the attribute and folds the accordion into dropdowns or restores it.
- *
- * Read the ATTRIBUTE, not localStorage: head.ut stamps it server-side from the router default and
- * the pre-paint script overrides it, so it always carries an explicit value. localStorage would
- * report 'sidebar' on a router defaulting to 'top' until the user first touched the toggle. */
function currentLayout() {
return document.documentElement.getAttribute('data-layout') === 'top' ? 'top' : 'sidebar';
}
@@ -525,524 +302,23 @@ function currentRail() {
* snapshotAxes() reads the effective values, which already fold in this browser's localStorage, so
* Save captures what the user sees. It does not touch localStorage: this browser keeps overriding,
* and the saved default is for other devices. resetToSaved() drops this browser back onto it. */
-const AXIS_KEYS = [
- 'fs-layout', 'fs-darkmode', 'fs-palette', 'fs-wallpaper',
- 'fs-tint', 'fs-accent', 'fs-good', 'fs-warn', 'fs-danger',
- 'fs-card', 'fs-control', 'fs-bar', 'fs-line',
- 'fs-radius', 'fs-menu-autocollapse', 'fs-tint-strength', 'fs-density',
- 'fs-photo-dim', 'fs-pattern-size', 'fs-pattern-strength', 'fs-pattern-ink'
-];
-/* Tint strength: a multiplier on the tint chroma (03-palettes.css), 100% being the designed
- * strength and 200% the cap. 0 is not quite "no tint" — the relative colour that applies the tint
- * replaces chroma outright, so 0 leaves a neutral canvas at the same lightness rather than the
- * untinted one; clearing the Tint hue is the real off. It only bites while a Tint hue is set, and
- * is moot under the File wallpaper, where the photo covers the canvas.
+/* Every saved axis's localStorage key: what Save-as-default clears and what a reset walks.
*
- * This axis and its default live above _resolvedDefault()'s module-init call below: a propAxis
- * instance is a `const`, so declaring it further down leaves it in the TDZ at init and the whole
- * module throws, taking the chrome and the menu with it. */
-const FS_TSTR_DEFAULT = 100;
-const TSTR = propAxis('fs-tint-strength', 'tint_strength', '--fs-tint-strength', 0, 200, FS_TSTR_DEFAULT, (v) => String(v / 100));
-const currentTintStrength = TSTR.current, applyTintStrength = TSTR.apply, tintStrengthDefault = TSTR.def;
-
-/* Photo dim: the scrim opacity over the FILE photo (0–100%). The photo is shared; how strongly
- * this browser dims it is not, and it reaches the router through Save-as-default. Only bites while
- * the wallpaper is 'file'. Declared up here for the TDZ reason above. */
-const FS_PDIM_DEFAULT = 74;
-const PDIM = propAxis('fs-photo-dim', 'photo_dim', '--fs-photo-dim', 0, 100, FS_PDIM_DEFAULT, (v) => (v + '%'));
-const currentPhotoDim = PDIM.current, applyPhotoDim = PDIM.apply, photoDimDefault = PDIM.def;
-
-/* The pattern's two live knobs, and the third that is an enum. All three bite only while the
- * wallpaper is 'pattern'; the FILE is shared, how this browser draws it is not.
- *
- * Size is the tile's edge in px, with a wide range because "how big is one repeat" is a property of
- * the artwork. Strength is the layer's opacity 0-100, which is the knob a `` baked into
- * the file would put out of CSS's reach. Declared up here for the TDZ reason above. */
-const FS_PSIZE_DEFAULT = 440;
-const PSIZE = propAxis('fs-pattern-size', 'pattern_size', '--fs-pattern-size', 40, 1600, FS_PSIZE_DEFAULT, (v) => (v + 'px'));
-const currentPatternSize = PSIZE.current, applyPatternSize = PSIZE.apply, patternSizeDefault = PSIZE.def;
-const FS_PSTR_DEFAULT = 20;
-const PSTR = propAxis('fs-pattern-strength', 'pattern_strength', '--fs-pattern-strength', 0, 100, FS_PSTR_DEFAULT, (v) => String(v / 100));
-const currentPatternStrength = PSTR.current, applyPatternStrength = PSTR.apply, patternStrengthDefault = PSTR.def;
-/* Ink: 'theme' (the file's alpha, the theme's colour) or 'original' (the file's own colours, no
- * mask). Two-valued with the default as a bare :root, i.e. the enumAxis shape. */
-const PINK = enumAxis('fs-pattern-ink', 'data-pattern-ink', 'original', 'theme');
-const currentPatternInk = PINK.current, applyPatternInk = PINK.apply;
-/* `reject: true` is load-bearing: without it a refused write arrives as SUCCESS. rpc.js raises on
- * the ubus status code only when the declaration asks it to, and otherwise hands the code back as
- * the resolved value — measured on the router, a per-config ACL refusal resolves with 6
- * (permission denied) and every `.then()` below runs as if the file had been written, greying the
- * Save button over a write that never happened. */
-const _uciSet = rpc.declare({ object: 'uci', method: 'set', params: [ 'config', 'section', 'values' ], reject: true });
-const _uciCommit = rpc.declare({ object: 'uci', method: 'commit', params: [ 'config' ], reject: true });
-
-function snapshotAxes() {
- return {
- layout: currentLayout(),
- darkmode: currentMode(),
- palette: currentPalette(),
- wallpaper: currentWallpaper(),
- tint: String(currentTint()),
- accent: String(currentAccent()),
- good: String(currentGood()),
- warn: String(currentWarn()),
- danger: String(currentDanger()),
- card: String(currentCard()),
- control: String(currentControl()),
- bar: String(currentBar()),
- line: String(currentLine()),
- rounding: String(currentRadius()),
- autocollapse: currentAutoCollapse() ? 'on' : 'off',
- tint_strength: String(currentTintStrength()),
- density: currentDensity(),
- photo_dim: String(currentPhotoDim()),
- pattern_size: String(currentPatternSize()),
- pattern_strength: String(currentPatternStrength()),
- pattern_ink: currentPatternInk()
- };
-}
-/* The resolved router default (the uci value if set, else the built-in) in snapshotAxes() string
- * form, so the Appearance tab can grey the Save button when this browser already shows exactly it.
- * Seeded from window.__fsSD at load and replaced with the just-saved snapshot, so a save flips the
- * match without a reload.
- *
- * Every field is the axis's own def(): a second copy of a validation drifts with no symptom beyond
- * matchesSavedDefault() lying, which is the one thing the Save button is. `layout` is the exception,
- * since currentLayout() reads the attribute — its fallback must stay `top`, matching head.ut's
- * stamp and resetToBuiltin(), or a fresh install shows dirty before anything is touched and
- * resetToSaved() lands on the wrong layout. */
-function _resolvedDefault() {
- return {
- layout: sd('layout') || 'top',
- darkmode: modeDefault(),
- palette: paletteDefault(),
- wallpaper: wallpaperDefault(),
- tint: String(TINT.def()),
- accent: String(ACCENT.def()),
- good: String(GOOD.def()),
- warn: String(WARN.def()),
- danger: String(DANGER.def()),
- card: String(CARD.def()),
- control: String(CONTROL.def()),
- bar: String(BAR.def()),
- line: String(LINE.def()),
- rounding: String(radiusDefault()),
- autocollapse: autoCollapseDefault() ? 'on' : 'off',
- tint_strength: String(tintStrengthDefault()),
- density: densityDefault(),
- photo_dim: String(photoDimDefault()),
- pattern_size: String(patternSizeDefault()),
- pattern_strength: String(patternStrengthDefault()),
- pattern_ink: PINK.def()
- };
-}
-let _savedDefault = _resolvedDefault();
-function matchesSavedDefault() {
- const cur = snapshotAxes();
- return Object.keys(cur).every((k) => cur[k] === _savedDefault[k]);
-}
-
-/* ---- no axis reaches /etc/config/footstrap except through Save-as-default ----
- * Every axis is per-browser. An axis that wrote through on change — on the argument that the photo
- * it relates to is router-side — re-pointed the router-wide default for every other device from one
- * browser, and moved the Save baseline with it, so the button did not even light up. A per-browser
- * preference must never mutate shared state invisibly.
- *
- * Only the photo's bytes and its cache-bust token are router-side. Whether a browser paints it is
- * `fs-wallpaper` and how dim is `fs-photo-dim`: ordinary axes, saved with the rest or not at
- * all. */
-function saveAsDefault() {
- const snap = snapshotAxes();
- return _uciSet('footstrap', 'settings', snap)
- .then(() => _uciCommit('footstrap'))
- .then(() => { _savedDefault = snap; });
-}
-/* ---- the two resets, which are not the same escape hatch ----
- *
- * Both drop this browser's tweaks and differ in what is underneath:
- *
- * resetToSaved() clears the keys, so every axis falls back to the router default where one is
- * set and to the built-in where it is not. The browser goes back to inheriting.
- * resetToBuiltin() writes the theme's own defaults explicitly, the only way to say "as the theme
- * ships" — clearing the keys is the sentence that means "inherit the router
- * default".
- *
- * Both leave /etc/config/footstrap alone: neither un-saves a router default.
- *
- * The caller reloads so head.ut re-applies everything in one pass — the appliers repaint correctly,
- * but the controls on the page were built from the values they had at render time. */
-function resetToSaved() {
- AXIS_KEYS.forEach(lsDel);
-}
-
-/* The built-in defaults, written through the ordinary appliers so each validates its own value and
- * stamps :root as usual. Stated rather than derived: a default is a default because it is what a
- * bare :root paints, and the five with a named const use it, so the numbers cannot drift from the
- * CSS. */
-function resetToBuiltin() {
- /* top, not sidebar: the bar is what a bare :root paints (head.ut stamps it when uci says
- * nothing), so it is what "as the theme ships" means */
- applyLayout('top');
- applyMode('auto');
- applyPalette('footstrap');
- applyDensity('normal');
- applyWallpaper('off');
- applyAutoCollapse('off');
- applyRadius(FS_RADIUS_DEFAULT);
- applyTintStrength(FS_TSTR_DEFAULT);
- applyPhotoDim(FS_PDIM_DEFAULT);
- applyPatternSize(FS_PSIZE_DEFAULT);
- applyPatternStrength(FS_PSTR_DEFAULT);
- applyPatternInk('theme');
- /* every colour and surface axis back to "the palette's own" */
- [ applyTint, applyAccent, applyGood, applyWarn, applyDanger,
- applyCard, applyControl, applyBar, applyLine ].forEach((fn) => fn(0));
-}
-
-/* ---- the pattern: an SVG the admin uploads, tiled and recoloured ----
- *
- * The bytes come from the admin, never from a third-party host: a theme in a package feed does not
- * reach out at run time.
- *
- * Router-side, like the login photo and for the same reason — a file cannot live in localStorage,
- * and a pattern is something a router wears. The path is a fixed server-side constant matched
- * exactly by the rpcd ACL, so nothing user-controlled reaches a path. It lives under /etc so a
- * package upgrade cannot delete it (keep.d carries it across a sysupgrade), and the served name
- * ends in .svg because uhttpd types a file by extension.
- *
- * How it is made to fit is 15-wallpaper.css's mask, not anything done to the bytes: the file
- * supplies the alpha and the theme the colour, so one upload reads correctly in both modes and
- * under every palette.
- *
- * What is refused: an SVG is a document, not a picture, and while a masked or background image
- * never executes script, the same file fetched from its own URL would. Uploading already needs an
- * authenticated admin session with uci write rights, so this is defence in depth — but the check is
- * cheap and the failure mode is somebody else's browser. */
-const PAT_PATH = '/etc/footstrap/pattern.svg'; /* cgi-upload target; the ACL grants exactly this */
-const PAT_SERVE = '/luci-static/footstrap/pattern.svg'; /* the uhttpd symlink to PAT_PATH (uci-defaults) */
-const PAT_MAX = 512 * 1024; /* a tile that has to reach a router's flash and then every page load */
-/* What makes an uploaded SVG unacceptable, decided on the PARSED document and not on its text: a
- * regex over the source guesses at a grammar the browser already implements, and guesses in both
- * directions — a handler pattern also matches an ordinary `only_selected="false"`, while an entity
- * or odd whitespace hides a real handler from it.
- *
- * DOMParser is the parser the file will actually be read by, and parsing is inert: no script runs,
- * no subresource is fetched, no handler is bound. So the questions are exact ones about nodes:
- *
- * - is it an SVG at all (a parsererror, or a root that is not , is not an image)
- * - does it carry an element that executes or embeds (script, foreignObject, iframe, …)
- * - does it carry a real event-handler attribute — `^on[a-z]+$`
- * - does any value start a `javascript:` url
- * - does any href point off this router; `#fragment` and `data:` stay allowed, being how a tile
- * refers to its own and embeds a bitmap
- *
- * The check is for the way the file can be reached that a mask does not cover: its own URL, opened
- * directly, same-origin with the session.
- *
- * `animate`/`set` are listed for a second reason as well: they can retarget an attribute at run
- * time (``), and a tile that animates repaints a
- * full-viewport layer behind every page. */
-const PAT_BAD_TAGS = [ 'script', 'foreignobject', 'iframe', 'embed', 'object', 'audio', 'video', 'animate', 'set' ];
-
-/* null if the parsed document is fine, otherwise the sentence to show. */
-function _svgObjection(text) {
- let doc;
- try { doc = new DOMParser().parseFromString(text, 'image/svg+xml'); }
- catch (e) { return _('That file is not an SVG image.', 'footstrap'); }
- const root = doc && doc.documentElement;
- if (!root || doc.querySelector('parsererror') || root.nodeName.toLowerCase() !== 'svg')
- return _('That file is not an SVG image.', 'footstrap');
- const refused = _('That SVG contains script or external references, which this theme will not install.', 'footstrap');
- const els = [ root ].concat([ ...root.querySelectorAll('*') ]);
- for (const el of els) {
- if (PAT_BAD_TAGS.indexOf(el.nodeName.toLowerCase()) >= 0) return refused;
- const attrs = el.attributes || [];
- for (let i = 0; i < attrs.length; i++) {
- const n = attrs[i].name.toLowerCase();
- const v = String(attrs[i].value || '').trim();
- /* a REAL handler is `on` + letters and nothing else; `only_selected` is not one */
- if ((/^on[a-z]+$/).test(n)) return refused;
- if ((/^javascript:/i).test(v)) return refused;
- /* off-router reference. A leading `//` is protocol-relative and just as external. */
- if ((/(?:^|:)href$/).test(n) && (/^(?:[a-z][a-z0-9+.-]*:)?\/\//i).test(v)) return refused;
- }
- }
- return null;
-}
-
-/* read the picked file as text so it can be inspected before upload, and so what reaches the
- * router is exactly the bytes that were checked */
-function _readText(file) {
- return new Promise((resolve, reject) => {
- const fr = new FileReader();
- fr.onload = () => resolve(String(fr.result || ''));
- fr.onerror = () => reject(new Error(_('That file could not be read.', 'footstrap')));
- fr.readAsText(file);
- });
-}
-
-/* the token the server last saved, validated to the same hex charset head.ut's sanitiser and the
- * pre-paint use. '' = nothing uploaded. */
-function currentPattern() {
- const t = sd('pattern');
- return (typeof t === 'string' && BG_TOKEN_RE.test(t)) ? t : '';
-}
-function patternUrl(tok) { return PAT_SERVE + '?v=' + tok; }
-
-/* set/clear the tile URL live. This only supplies the url(); whether it PAINTS is the Wallpaper
- * axis (data-wallpaper="pattern"). */
-function _applyPattern(tok) {
- const root = document.documentElement;
- if (tok) root.style.setProperty('--fs-pattern-url', 'url("' + patternUrl(tok) + '")');
- else root.style.removeProperty('--fs-pattern-url');
- setSD('pattern', tok || '');
-}
-
-/* Upload flow, the login photo's exactly: validate -> multipart POST to cgi-upload -> take the md5
- * `checksum` as the cache-bust token -> save it in uci -> apply live. No canvas step, which is what
- * strips a photo's EXIF: an SVG redrawn to a canvas comes back a raster. The text check above
- * stands in for it. */
-function uploadPattern(file) {
- if (!file) return Promise.reject(new Error(_('Please choose an SVG file.', 'footstrap')));
- const isSvg = (/(^image\/svg\+xml$)/i).test(file.type || '') || (/\.svg$/i).test(file.name || '');
- if (!isSvg) return Promise.reject(new Error(_('Please choose an SVG file.', 'footstrap')));
- if (file.size > PAT_MAX) return Promise.reject(new Error(_('That file is too large.', 'footstrap')));
- return _readText(file).then((text) => {
- const objection = _svgObjection(text);
- if (objection) return Promise.reject(new Error(objection));
- const fd = new FormData();
- fd.append('sessionid', rpc.getSessionID());
- fd.append('filename', PAT_PATH);
- fd.append('filedata', new Blob([ text ], { type: 'image/svg+xml' }), 'pattern.svg');
- return fetch(L.env.cgi_base + '/cgi-upload', { method: 'POST', body: fd, credentials: 'same-origin' })
- .then((r) => (r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status))));
- }).then((reply) => {
- if (!reply || reply.failure)
- return Promise.reject(new Error((reply && reply.failure && reply.failure[1]) || _('Upload failed.', 'footstrap')));
- const tok = String(reply.checksum || '').toLowerCase();
- if (!BG_TOKEN_RE.test(tok))
- return Promise.reject(new Error(_('Upload failed.', 'footstrap')));
- /* cgi-upload writes 0600 and uhttpd refuses to serve a file that is not world-readable
- * (0600 -> 403, 0644 -> 200); _chmodServeable checks the command's exit status, not just
- * the ubus call's */
- return _chmodServeable(PAT_PATH)
- /* uci gets the token and nothing else: putting a file on the router is not the same act
- * as making every other device paint it */
- .then(() => _uciSet('footstrap', 'settings', { pattern: tok }))
- .then(() => _uciCommit('footstrap'))
- .catch((e) => _rollbackUpload(PAT_PATH, e))
- .then(() => {
- /* switch this browser onto it: the ordinary axis path, localStorage only */
- applyWallpaper('pattern');
- _applyPattern(tok);
- return tok;
- });
- });
-}
-
-/* Remove: delete the file, blank the token (uci `set` to '', not delete — the scoped ACL grants
- * set/commit only), clear the tile live. */
-function removePattern() {
- return _removeServed(PAT_PATH)
- .then(() => _uciSet('footstrap', 'settings', { pattern: '' }))
- .then(() => _uciCommit('footstrap'))
- .then(() => { _applyPattern(''); });
-}
-
-/* ---- login/page background upload: router-side, and deliberately not an axis ----
- * The other axes are per-browser with a router default; this one has no browser layer. An admin
- * uploads an image once, it becomes the router-wide background for every device and shows
- * pre-login, so it is absent from AXIS_KEYS, snapshotAxes() and matchesSavedDefault() — it must not
- * move the Save button — and needs no factory, so tools/axes.mjs never sees it.
- *
- * The image is a served file, uhttpd having no gzip to make inlining it in every viable;
- * only its cache-bust token lives in uci -> window.__fsSD -> the url() head.ut stamps. The path is
- * a fixed server-side constant matched exactly by the rpcd ACL, so nothing user-controlled reaches
- * a path. */
-const BG_PATH = '/etc/footstrap/login-bg'; /* cgi-upload target; the ACL grants exactly this */
-const BG_SERVE = '/luci-static/footstrap/bg'; /* the uhttpd symlink to BG_PATH (uci-defaults) */
-const BG_MAX_SIDE = 1920; /* cap the longest side — a router serves this off flash with no gzip, and 1080p covers the screens LuCI is actually admin'd from; still crisp full-screen, far fewer flash/wire bytes */
-const BG_QUALITY = 0.9;
-const BG_SRC_MAX = 25 * 1024 * 1024; /* refuse a source this big before decoding (decode-bomb guard) */
-/* No `reject: true` here, unlike every other declare in this file: with it, "the file was already
- * gone" and "the router refused to delete it" arrive as the same Error. Without it the promise
- * resolves with the ubus status as a number, which this code can branch on. */
-const _fileRemoveStatus = rpc.declare({ object: 'file', method: 'remove', params: [ 'path' ] });
-
-/* Delete, treating "not found" as done. Anything else is a real refusal (a read-only or full
- * overlay, an immutable flag, a path replaced by a directory) and must not be reported as a
- * removal: the file stays on flash and stays fetchable WITHOUT a session through the /www symlink,
- * which is what an admin removing a background believes they have stopped. */
-const UBUS_NOT_FOUND = 4;
-function _removeServed(path) {
- return _fileRemoveStatus(path).then((res) => {
- const code = (typeof res === 'number') ? res : parseInt(res, 10);
- if (code === 0 || code === UBUS_NOT_FOUND || isNaN(code)) return;
- return Promise.reject(new Error(
- _('The router refused to delete the file (ubus status %d).', 'footstrap').format(code)));
- });
-}
-/* cgi-upload writes the file 0600 and uhttpd refuses to serve a file that is not world-readable
- * (0600 -> 403, 0644 -> 200), so make it 0644 first. The rpcd ACL grants exec on exactly two fixed
- * commands — chmod 644 on the two files this module uploads — with no caller-controlled
- * argument. */
-const _fileExec = rpc.declare({ object: 'file', method: 'exec', params: [ 'command', 'params' ], reject: true });
-/* …and the ubus status is only half of it: `file.exec` reports the command's exit status inside the
- * payload, so a chmod that ran and failed still comes back as a successful call — and the upload
- * then reports success for a file uhttpd will 403, leaving every device a scrim over nothing. */
-function _chmodServeable(path) {
- return _fileExec('/bin/chmod', [ '644', path ]).then((res) => {
- if (res && res.code)
- throw new Error(_('Upload failed.', 'footstrap') + ' (chmod ' + res.code + ')');
- return res;
- });
-}
-/* the cache-bust token charset, an md5/sha hex string. One copy here; head.ut's ucode sanitiser
- * and the pre-paint inline script keep their own identical copies unavoidably, running before this
- * module — see the axes contract in head.ut. */
-const BG_TOKEN_RE = /^[a-f0-9]{6,64}$/;
-
-/* the token the server last saved, validated to the same hex charset head.ut's sanitiser and
- * pre-paint use, so the Appearance tab can build a cache-busted preview src. '' = none. */
-function currentLoginBg() {
- const t = sd('login_bg');
- return (typeof t === 'string' && BG_TOKEN_RE.test(t)) ? t : '';
-}
-function loginBgUrl(tok) { return BG_SERVE + '?v=' + tok; }
-
-/* _applyPattern's twin for the photo; data-wallpaper="file" decides whether it paints. */
-function _applyLoginBg(tok) {
- const root = document.documentElement;
- if (tok) root.style.setProperty('--fs-login-bg-url', "url('" + loginBgUrl(tok) + "')");
- else root.style.removeProperty('--fs-login-bg-url');
- setSD('login_bg', tok || '');
-}
-
-/* Re-encode the picked image to a bounded JPEG on a canvas. A security step as much as a size one:
- * the canvas keeps only the decoded pixels, so EXIF and any bytes appended past the image are
- * dropped and the uploaded blob is exactly what the browser drew.
- *
- * The whole body is guarded, because a throw inside an event handler does not reject the promise it
- * sits in — it escapes as an uncaught error and leaves the promise pending forever. Two real ways
- * out of `onload`: `getContext('2d')` answers null when the canvas cannot be backed, and
- * drawImage/toBlob can throw. A pending promise leaves the caller's "Uploading…" button disabled
- * and lying until the form is rebuilt on a later arrival at the page. */
-function _downscale(file) {
- return new Promise((resolve, reject) => {
- const url = URL.createObjectURL(file);
- const img = new Image();
- img.onload = () => {
- URL.revokeObjectURL(url);
- try {
- const scale = Math.min(1, BG_MAX_SIDE / Math.max(img.width, img.height));
- const w = Math.max(1, Math.round(img.width * scale));
- const h = Math.max(1, Math.round(img.height * scale));
- const cv = document.createElement('canvas');
- cv.width = w; cv.height = h;
- const ctx = cv.getContext('2d');
- if (!ctx) throw new Error('no 2d context');
- ctx.drawImage(img, 0, 0, w, h);
- cv.toBlob((blob) => blob ? resolve(blob) : reject(new Error(_('Could not process the image.', 'footstrap'))),
- 'image/jpeg', BG_QUALITY);
- } catch (e) { reject(new Error(_('Could not process the image.', 'footstrap'))); }
- };
- img.onerror = () => { URL.revokeObjectURL(url); reject(new Error(_('That file is not a readable image.', 'footstrap'))); };
- img.src = url;
- });
-}
-
-/* An upload that has landed but could not be RECORDED must not stay on the router. The two paths
- * below write the file first and the token second, and the second half can fail on its own (no
- * `settings` section, a narrowed uci ACL, ubus busy) — the image then sits at mode 0644 and is
- * served to anyone through the /www symlink, which does not depend on the token, while Remove is
- * hidden precisely because the token is empty. Roll the file back and report the failure that
- * started it; a rollback that itself fails is appended, because the admin has to know the file is
- * there. */
-function _rollbackUpload(path, cause) {
- return _removeServed(path).then(
- () => Promise.reject(cause),
- () => Promise.reject(new Error(String((cause && cause.message) || cause) + ' — '
- + _('the uploaded file could not be removed either; it is still on the router.', 'footstrap')))
- );
-}
-
-/* Upload flow: validate -> canvas re-encode -> multipart POST to cgi-upload (the endpoint
- * L.ui.uploadFile uses; session in the `sessionid` field, path in `filename`, bytes in `filedata`)
- * -> take the md5 `checksum` as the cache-bust token -> save it in uci -> apply live. cgi-upload
- * authorises the write against the ACL's `file` grant for BG_PATH. */
-function uploadLoginBg(file) {
- if (!file || !(/^image\//).test(file.type || ''))
- return Promise.reject(new Error(_('Please choose an image file.', 'footstrap')));
- if (file.size > BG_SRC_MAX)
- return Promise.reject(new Error(_('That image is too large.', 'footstrap')));
- return _downscale(file).then((blob) => {
- const fd = new FormData();
- fd.append('sessionid', rpc.getSessionID());
- fd.append('filename', BG_PATH);
- fd.append('filedata', blob, 'login-bg');
- return fetch(L.env.cgi_base + '/cgi-upload', { method: 'POST', body: fd, credentials: 'same-origin' })
- .then((r) => r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status)));
- }).then((reply) => {
- /* cgi-upload answers { name, size, checksum, sha256sum } or { failure: [code, msg] } */
- if (!reply || reply.failure)
- return Promise.reject(new Error((reply && reply.failure && reply.failure[1]) || _('Upload failed.', 'footstrap')));
- const tok = String(reply.checksum || '').toLowerCase();
- if (!BG_TOKEN_RE.test(tok))
- return Promise.reject(new Error(_('Upload failed.', 'footstrap')));
- /* make the just-written 0600 file world-readable, or uhttpd 403s it (see _fileExec) */
- return _chmodServeable(BG_PATH)
- /* uci gets the token and nothing else: which browsers paint it is the wallpaper axis,
- * and writing `wallpaper:file` here would re-point every other device's default from
- * one upload */
- .then(() => _uciSet('footstrap', 'settings', { login_bg: tok }))
- .then(() => _uciCommit('footstrap'))
- .catch((e) => _rollbackUpload(BG_PATH, e))
- .then(() => {
- /* switch this browser to the photo: the ordinary axis path, localStorage only */
- applyWallpaper('file');
- _applyLoginBg(tok);
- return tok;
- });
- });
-}
-
-/* removePattern's twin for the photo. */
-function removeLoginBg() {
- return _removeServed(BG_PATH)
- .then(() => _uciSet('footstrap', 'settings', { login_bg: '' }))
- .then(() => _uciCommit('footstrap'))
- .then(() => { _applyLoginBg(''); });
-}
-
+ * Tried as one table of [key, field, def] with the resolved defaults derived from it: correct,
+ * and 188 B larger after minification — three lists of short literals compress better than
+ * twenty-one rows of data, because a function name is mangled and a row is not. The copies are
+ * held together by tools/axes.mjs instead, which reads snapshotAxes()'s body and holds every
+ * field against header.ut's FS_AXES. */
return baseclass.extend({
- lsGet, lsSet, lsDel, lsGetArr, storageBroken,
+ /* the storage wrappers and the router-default reader: fs-axes.js is built on these */
+ lsGet, lsSet, lsDel, lsGetArr, storageBroken, sd, setSD,
+ /* the two axis shapes, so the nineteen axes in fs-axes.js can be built from them */
+ listAxis, enumAxis,
- currentMode, applyMode, guardDarkStamp,
- currentPalette, applyPalette,
- currentWallpaper, applyWallpaper,
- currentDensity, applyDensity,
- currentRadius, applyRadius,
- currentTint, applyTint,
- currentAccent, applyAccent,
- currentGood, applyGood,
- currentCard, applyCard,
- currentControl, applyControl,
- currentBar, applyBar,
- currentLine, applyLine,
- currentWarn, applyWarn,
- currentDanger, applyDanger,
- currentLayout, isTopLayout, applyLayout,
- currentAutoCollapse, applyAutoCollapse,
- currentRail, applyRail,
-
- currentLoginBg, loginBgUrl, uploadLoginBg, removeLoginBg,
- currentPattern, patternUrl, uploadPattern, removePattern,
- currentPatternSize, applyPatternSize,
- currentPatternStrength, applyPatternStrength,
- currentPatternInk, applyPatternInk,
- currentTintStrength, applyTintStrength,
- currentPhotoDim, applyPhotoDim,
-
- saveAsDefault, resetToSaved, resetToBuiltin, matchesSavedDefault
+ currentMode, applyMode, modeDefault, guardDarkStamp,
+ currentDensity, applyDensity, densityDefault,
+ currentLayout, applyLayout, isTopLayout,
+ currentAutoCollapse, applyAutoCollapse, autoCollapseDefault,
+ currentRail, applyRail
});
diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-router.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-router.js
index 5cf8d1d9..a12b819c 100644
--- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-router.js
+++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-router.js
@@ -1155,36 +1155,52 @@ function bootDocumentIsOurs() {
*
* The list is what THIS file calls. `uci` (flushUciCache) and `L.network` are read through their own
* guards at their use, being optional there. */
-const CONTRACT = [
- [ 'L.require', () => typeof window.L.require === 'function' ],
+const CONTRACT_FNS = [
+ 'L.require',
/* classLoaded() tests `instanceof L.Class` to tell a loaded module from L.env/L.url/L.get */
- [ 'L.Class', () => typeof window.L.Class === 'function' ],
- [ 'L.dom.content', () => window.L.dom && typeof window.L.dom.content === 'function' ],
+ 'L.Class',
+ 'L.dom.content',
+ /* a slash in the leaf is several functions on one object: the pair is only ever there or gone
+ * together, so one name in the report is the whole finding */
+ 'L.Poll.start/stop',
+ 'L.Request.addInterceptor',
+ 'rpc.addInterceptor',
+ 'ui.instantiateView',
+ 'ui.hideModal',
+ 'ui.hideIndicator',
+ 'ui.addNotification'
+];
+
+/* the roots are resolved per probe, not once: `window.L` is what the two-L trap makes load-bearing
+ * (docs/spa-router.md), and `ui`/`rpc` are this module's own requires */
+function hasFns(path) {
+ const seg = path.split('.');
+ const leaf = seg.pop();
+ let node = { L: window.L, ui: ui, rpc: rpc }[seg.shift()];
+ for (const k of seg) node = node[k];
+ return leaf.split('/').every((n) => typeof node[n] === 'function');
+}
+
+/* the two surfaces that are not functions */
+const CONTRACT_REST = [
/* the L.env keys navigate() re-points, plus the base_url moduleUrl() reads */
[ 'L.env.{base_url,dispatchpath,requestpath,pathinfo,nodespec}', () => {
const env = window.L.env;
return !!env && [ 'base_url', 'dispatchpath', 'requestpath', 'pathinfo', 'nodespec' ]
.every((k) => k in env);
} ],
- [ 'L.Poll.queue', () => window.L.Poll && Array.isArray(window.L.Poll.queue) ],
- [ 'L.Poll.start/stop', () => window.L.Poll &&
- typeof window.L.Poll.start === 'function' && typeof window.L.Poll.stop === 'function' ],
- [ 'L.Request.addInterceptor', () => window.L.Request &&
- typeof window.L.Request.addInterceptor === 'function' ],
- [ 'rpc.addInterceptor', () => typeof rpc.addInterceptor === 'function' ],
- [ 'ui.instantiateView', () => typeof ui.instantiateView === 'function' ],
- [ 'ui.hideModal', () => typeof ui.hideModal === 'function' ],
- [ 'ui.hideIndicator', () => typeof ui.hideIndicator === 'function' ],
- [ 'ui.addNotification', () => typeof ui.addNotification === 'function' ]
+ [ 'L.Poll.queue', () => Array.isArray(window.L.Poll.queue) ]
];
/* -> the names that are not there, in list order; empty means the document can be navigated. A
* probe that throws counts as missing: `L` itself may be a shape nobody here expected. */
function contractBreaks() {
- return CONTRACT.filter(([ , present ]) => {
- try { return !present(); }
+ const gone = (probe) => {
+ try { return !probe(); }
catch (e) { return true; }
- }).map(([ name ]) => name);
+ };
+ return CONTRACT_FNS.filter((path) => gone(() => hasFns(path)))
+ .concat(CONTRACT_REST.filter(([ , probe ]) => gone(probe)).map(([ name ]) => name));
}
function wireRouter() {
diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-search.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-search.js
index b0f2f5b8..0fc1aea6 100644
--- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-search.js
+++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-search.js
@@ -143,69 +143,28 @@ function search(q, limit) {
const RECENT_KEY = 'fs-recent';
const RECENT_MAX = 8;
-/* prefs.lsGetArr owns the parse, the corruption guard and the Array check; only the
- * "these are paths" filter belongs here */
-function loadRecent() {
- return prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string');
-}
-
-let _recent = loadRecent();
-
-function remember(segs) {
- if (!Array.isArray(segs) || !segs.length) return;
- const path = segs.join('/');
- _recent = [ path ].concat(_recent.filter((p) => p !== path)).slice(0, RECENT_MAX);
- prefs.lsSet(RECENT_KEY, JSON.stringify(_recent));
-}
-
+/* The list is WRITTEN by menu-footstrap-common.js, which is on every page — this module is not any
+ * more, and a palette that only loads when it is opened cannot be what records where the admin has
+ * been. Read here, at open time, so it is always current. `prefs.lsGetArr` owns the parse, the
+ * corruption guard and the Array check; only the "these are paths" filter belongs here. */
function recentEntries() {
+ const recent = prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string');
const byPath = new Map(index().map((e) => [ e.path, e ]));
- return _recent.map((p) => byPath.get(p)).filter(Boolean).slice(0, RECENT_MAX);
-}
-
-/* ---- warm the pages this admin actually uses ----
- *
- * The router's per-link prefetch needs a hover, tap or focus first, so a session's first visit to
- * a page still pays for its module chain. The recents list is the best predictor available and is
- * already on disk; warming the whole menu instead would pull every view module on the box
- * (docs/spa-router.md).
- *
- * The current page is skipped — wire() has just remembered it and it is loaded by definition.
- * Under saveData nothing speculative runs; the per-link prefetch stays, since it follows a
- * deliberate hover or tap. */
-const RECENT_WARM = 5;
-
-function warmRecent() {
- try { if (navigator.connection && navigator.connection.saveData) return; } catch (e) {}
- const here = (L.env.dispatchpath || []).join('/');
- const paths = _recent.filter((p) => p !== here).slice(0, RECENT_WARM);
- if (!paths.length) return;
- /* Nothing waits on this, so it runs at idle, with a timeout for a page that never goes idle (a
- * busy poll). The fallback delay is long on purpose: this competes with the view's own module
- * fetches and RPCs and must lose that race. */
- const go = () => paths.forEach((p) => router.prefetchSegs(p.split('/')));
- if (typeof window.requestIdleCallback === 'function')
- window.requestIdleCallback(go, { timeout: 4000 });
- else
- window.setTimeout(go, 2000);
+ return recent.map((p) => byPath.get(p)).filter(Boolean).slice(0, RECENT_MAX);
}
/* ---- the palette -------------------------------------------------------- */
const MAX_RESULTS = 20;
-function wire() {
- const btn = document.getElementById('fs-search-btn');
- if (!btn) return;
+/* Built on the first open and kept — the overlay, its listeners and the index survive for the life
+ * of the document, so a second Ctrl+K costs nothing. Until then this module is not even fetched:
+ * menu-footstrap-common.js holds the shortcut and requires this on the first gesture. */
+let _built = null;
- /* remember the page this full load landed on: onNavigate below covers the SPA path, this
- * covers an F5, a non-SPA-able node and the first page of a session */
- remember(L.env.dispatchpath || []);
- /* the callback is handed the resolved segments of the INCOMING page; L.env still points at
- * the outgoing one when the router fires its callbacks */
- router.onNavigate(remember);
- /* after remember(), so the page we stand on heads the list and is the one skipped */
- warmRecent();
+function build() {
+ const btn = document.getElementById('fs-search-btn');
+ if (!btn) return null;
const input = E('input', {
'type': 'text',
@@ -384,8 +343,16 @@ function wire() {
ev.preventDefault();
open();
});
+
+ return { open, close };
+}
+
+/* the one entry point: build if this is the first gesture, then open */
+function openPalette() {
+ if (!_built) _built = build();
+ if (_built) _built.open();
}
return baseclass.extend({
- wire
+ open: openPalette
});
diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-widgets.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-widgets.js
index 13600cb9..0a35a3cd 100644
--- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-widgets.js
+++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-widgets.js
@@ -66,200 +66,9 @@ function wireDismiss(opts) {
* base/30-forms.css, `.cbi-range-slider` in theme/60-inputs.css), and cannot be got wrong here.
* Do not re-add a segmented control or range wrapper of our own. */
-/* ---- 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;
-}
-
return baseclass.extend({
svgIcon,
setOpen,
wireSpaceKey,
- wireDismiss,
- colorControl,
- probeColor,
- toHex
+ wireDismiss
});
diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/menu-footstrap-common.js b/luci-theme-footstrap/htdocs/luci-static/resources/menu-footstrap-common.js
index 9b1fcc7a..e91b8793 100644
--- a/luci-theme-footstrap/htdocs/luci-static/resources/menu-footstrap-common.js
+++ b/luci-theme-footstrap/htdocs/luci-static/resources/menu-footstrap-common.js
@@ -7,7 +7,6 @@
'require fs-router as router';
'require fs-prefs as prefs';
'require fs-sheets as sheets';
-'require fs-search as search';
/* Page modules: `fs-appearance` (System -> System) and `fs-overview` (Status -> Overview) each
* serve one page and are required only on it. A `require` pragma would make them a hard
@@ -42,6 +41,85 @@ function wirePageModules() {
load();
}
+/* ---- the search palette, held at arm's length ----
+ *
+ * The palette is 5 KB and opens on a keystroke most sessions never press, so it is not required
+ * here: this holds the shortcut and fetches the module on the first gesture. What CANNOT wait is
+ * the recents list — it has to be written on every navigation, or it is empty on the first open —
+ * and the warm pass that uses it, so both live here, in the file every page already loads.
+ *
+ * The palette reads the list back from localStorage when it opens, so the two halves share the key
+ * and nothing else. */
+const RECENT_KEY = 'fs-recent';
+const RECENT_MAX = 8;
+const RECENT_WARM = 5;
+
+function remember(segs) {
+ if (!Array.isArray(segs) || !segs.length) return;
+ const path = segs.join('/');
+ const recent = prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string');
+ prefs.lsSet(RECENT_KEY, JSON.stringify([ path ].concat(recent.filter((p) => p !== path)).slice(0, RECENT_MAX)));
+}
+
+/* ---- warm the pages this admin actually uses ----
+ *
+ * The router's per-link prefetch needs a hover, tap or focus first, so a session's first visit to a
+ * page still pays for its module chain. The recents list is the best predictor available and is
+ * already on disk; warming the whole menu instead would pull every view module on the box
+ * (docs/spa-router.md).
+ *
+ * The current page is skipped — remember() has just recorded it and it is loaded by definition.
+ * Under saveData nothing speculative runs; the per-link prefetch stays, since it follows a
+ * deliberate hover or tap. Nothing waits on this, so it runs at idle, with a long fallback delay:
+ * it competes with the view's own module fetches and RPCs and must lose that race. */
+function warmRecent() {
+ try { if (navigator.connection && navigator.connection.saveData) return; } catch (e) {}
+ const here = (L.env.dispatchpath || []).join('/');
+ const paths = prefs.lsGetArr(RECENT_KEY)
+ .filter((p) => typeof p === 'string' && p !== here).slice(0, RECENT_WARM);
+ if (!paths.length) return;
+ const go = () => paths.forEach((p) => router.prefetchSegs(p.split('/')));
+ if (typeof window.requestIdleCallback === 'function')
+ window.requestIdleCallback(go, { timeout: 4000 });
+ else
+ window.setTimeout(go, 2000);
+}
+
+function wireSearch() {
+ const btn = document.getElementById('fs-search-btn');
+ if (!btn) return;
+ const RT = window.L;
+
+ /* the page this full load landed on; onNavigate covers the SPA path afterwards */
+ remember(L.env.dispatchpath || []);
+ router.onNavigate(remember);
+ warmRecent();
+
+ /* One fetch, on the first gesture. The module builds its overlay and opens itself; every later
+ * gesture reaches the same instance, `require` being a singleton. */
+ let pending = false;
+ const open = () => {
+ if (pending) return;
+ pending = true;
+ RT.require('fs-search').then((m) => { pending = false; m.open(); },
+ (e) => { pending = false; console.error('footstrap: fs-search did not load', e); });
+ };
+
+ btn.addEventListener('click', open);
+ /* the same two shortcuts the palette used to own, with the same guard: `/` must not steal a
+ * keystroke from someone typing into a field, a contenteditable, or a .cbi-dropdown, where
+ * fs-select.js's typeahead reads it as a search character */
+ document.addEventListener('keydown', (ev) => {
+ if (ev.defaultPrevented) return;
+ if ((ev.ctrlKey || ev.metaKey) && !ev.altKey && (ev.key === 'k' || ev.key === 'K')) {
+ ev.preventDefault(); open(); return;
+ }
+ if (ev.key !== '/' || ev.ctrlKey || ev.metaKey || ev.altKey) return;
+ if (ev.target.closest?.('input, textarea, select, [contenteditable], .cbi-dropdown')) return;
+ ev.preventDefault(); open();
+ });
+}
+
/* The three template globals Status -> Overview needs, defined where ordering is guaranteed.
*
* `admin_status/index.ut` defines `progressbar`, `renderBox` and `renderBadge` in an inline script
@@ -140,9 +218,7 @@ return baseclass.extend({
fit.add(chrome.fitChrome);
chrome.renderChrome();
- /* after setTree(): the palette indexes that tree on first open, and records recent
- * pages from the first navigation onwards */
- search.wire();
+ wireSearch();
chrome.wireRail();
chrome.wireIndicatorCounts();
/* before router.wire(): the router restamps body[data-page] on every SPA navigation,
diff --git a/luci-theme-footstrap/mangle-tokens.sh b/luci-theme-footstrap/mangle-tokens.sh
index 98dc18b5..aa84db8e 100755
--- a/luci-theme-footstrap/mangle-tokens.sh
+++ b/luci-theme-footstrap/mangle-tokens.sh
@@ -33,19 +33,44 @@ set -e
CSS="${1:-}"
[ -n "$CSS" ] && [ -f "$CSS" ] || { echo "usage: mangle-tokens.sh ..." >&2; exit 1; }
shift
+# --rewrite … : the seam names are mangled TOO, and the same map is applied to the JS and
+# templates in those directories. Without it they are reserved, which is the safe default and what
+# an SDK build (no second pass to rewrite) needs.
+#
+# The seam is safe to rename only because every `--fs-` reference on the far side is a WHOLE string
+# literal — `setProperty('--fs-accent', …)`, never `'--fs-' + role`. Checked across all 89 sites;
+# if one is ever composed, this flag renames the CSS and the JS keeps asking for a name that no
+# longer exists, silently. The 36 seam names cost 8,574 B in the sheet, `--fs-accent` alone 1,452.
+REWRITE=""
+RESERVE_DIRS=""
+REWRITE_DIRS=""
+seen=""
+for a in "$@"; do
+ if [ "$a" = "--rewrite" ]; then seen=1; REWRITE=1; continue; fi
+ if [ -n "$seen" ]; then REWRITE_DIRS="$REWRITE_DIRS $a"; else RESERVE_DIRS="$RESERVE_DIRS $a"; fi
+done
+# shellcheck disable=SC2086 -- the dirs are ours, and a path with a space would already have broken
+# every other loop in this package's build
+set -- $RESERVE_DIRS
+
[ $# -gt 0 ] || { echo "mangle-tokens: no reserved-source dir given" >&2; exit 1; }
RES="$CSS.reserved.$$"
MAP="$CSS.map.$$"
-trap 'rm -f "$RES" "$MAP" "$CSS.tmp.$$"' EXIT
+trap 'rm -f "$RES" "$MAP" "$MAP.ord" "$CSS.tmp.$$"' EXIT
-# every --fs- name mentioned anywhere in the JS or the templates keeps its name
-for d in "$@"; do
- [ -d "$d" ] || { echo "mangle-tokens: $d is not a directory" >&2; exit 1; }
- find "$d" -type f \( -name '*.js' -o -name '*.ut' \) -exec cat {} +
-done | grep -oE -- '--fs-[a-z0-9-]+' | sort -u > "$RES"
+if [ -n "$REWRITE" ]; then
+ # nothing is reserved: every name is renamed here and in the far side together
+ : > "$RES"
+else
+ # every --fs- name mentioned anywhere in the JS or the templates keeps its name
+ for d in "$@"; do
+ [ -d "$d" ] || { echo "mangle-tokens: $d is not a directory" >&2; exit 1; }
+ find "$d" -type f \( -name '*.js' -o -name '*.ut' \) -exec cat {} +
+ done | grep -oE -- '--fs-[a-z0-9-]+' | sort -u > "$RES"
-[ -s "$RES" ] || { echo "mangle-tokens: reserved set came out EMPTY — refusing (a seam name would be renamed and the theme would break silently)" >&2; exit 1; }
+ [ -s "$RES" ] || { echo "mangle-tokens: reserved set came out EMPTY — refusing (a seam name would be renamed and the theme would break silently)" >&2; exit 1; }
+fi
awk -v RESFILE="$RES" -v MAPFILE="$MAP" '
function isname(c) { return (c ~ /[A-Za-z0-9_-]/) }
@@ -122,3 +147,34 @@ before=$(wc -c < "$CSS")
mv "$CSS.tmp.$$" "$CSS"
after=$(wc -c < "$CSS")
echo "mangle-tokens: $before -> $after bytes (-$((before - after))), $(wc -l < "$RES") name(s) reserved"
+
+# ---- the far side of the seam, renamed with the same map ----
+if [ -n "$REWRITE" ]; then
+ [ -s "$MAP" ] || { echo "mangle-tokens: --rewrite asked for, but the map is empty" >&2; exit 1; }
+ # NEVER the checkout. This rewrites files in place, so a target under the directory this script
+ # itself lives in is the source tree, and renaming the seam there destroys it — measured the
+ # hard way: a mistake in the argument split sent $SRC here instead of $STAGE and rewrote eight
+ # shipped modules and a template before anything noticed.
+ SELF_DIR=$(cd "$(dirname "$0")" && pwd -P)
+ for d in $REWRITE_DIRS; do
+ abs=$(cd "$d" 2>/dev/null && pwd -P) || { echo "mangle-tokens: --rewrite target $d is not a directory" >&2; exit 1; }
+ case "$abs/" in
+ "$SELF_DIR"/*) echo "mangle-tokens: --rewrite target $d is inside the source tree ($SELF_DIR) — refusing, this rewrites in place" >&2; exit 1 ;;
+ esac
+ done
+ # longest first, or `--fs-accent` would rewrite the head of `--fs-accent-h`
+ awk '{ print $1, $3 }' "$MAP" | awk '{ print length($1), $0 }' | sort -rn | cut -d" " -f2- > "$MAP.ord"
+ touched=0
+ for d in $REWRITE_DIRS; do
+ [ -d "$d" ] || { echo "mangle-tokens: --rewrite target $d is not a directory" >&2; exit 1; }
+ for f in $(find "$d" -type f \( -name '*.js' -o -name '*.ut' \)); do
+ awk -v MAPF="$MAP.ord" '
+ BEGIN { while ((getline l < MAPF) > 0) { split(l, a, " "); from[++k] = a[1]; to[k] = a[2] } }
+ { for (x = 1; x <= k; x++) gsub(from[x], to[x]); print }
+ ' "$f" > "$f.tmp$$" && mv "$f.tmp$$" "$f"
+ touched=$((touched + 1))
+ done
+ done
+ rm -f "$MAP.ord"
+ echo "mangle-tokens: seam renamed in $touched file(s)"
+fi
diff --git a/luci-theme-footstrap/root/etc/config/footstrap b/luci-theme-footstrap/root/etc/config/footstrap
index d1cb3849..56534589 100644
--- a/luci-theme-footstrap/root/etc/config/footstrap
+++ b/luci-theme-footstrap/root/etc/config/footstrap
@@ -4,3 +4,4 @@ config footstrap 'settings'
option darkmode 'dark'
option wallpaper 'pattern'
option layout 'sidebar'
+ option autocollapse 'on'
diff --git a/luci-theme-footstrap/strip-assets.sh b/luci-theme-footstrap/strip-assets.sh
new file mode 100755
index 00000000..5865a9e2
--- /dev/null
+++ b/luci-theme-footstrap/strip-assets.sh
@@ -0,0 +1,85 @@
+#!/bin/sh
+# Strip the two static assets nothing else strips: the SVG favicon's comment and the manifest's
+# indentation. Over a BUILD TREE, never the checkout — the comment is a "why" git keeps, and
+# `logo.svg` is also the source `tools/build-icons.mjs` rasterises the PNGs from.
+#
+# Small, but honest bytes: the favicon is fetched by every browser on every cold visit, and uhttpd
+# serves it uncompressed like everything else. 1,360 -> ~590 B for the SVG, 366 -> ~310 B for the
+# manifest.
+#
+# Why not `strip-templates.sh`: that one is line-oriented and only removes comments from column one,
+# which is right for a template and wrong for a single-line XML document.
+#
+# Usage: strip-assets.sh
+set -eu
+
+DIR="${1:-}"
+[ -n "$DIR" ] && [ -d "$DIR" ] || { echo "usage: strip-assets.sh " >&2; exit 2; }
+
+found=0
+
+# ---- SVG: drop XML comments, then the whitespace BETWEEN tags only ----
+# Never inside a tag: `viewBox="-9 -1 100 100"` and `d="M2 3 L4 5"` are attribute values whose
+# spaces are data. Only `> <` is collapsed to `><`.
+for f in $(find "$DIR" -type f -name '*.svg' | sort); do
+ tmp="$f.tmp$$"
+ awk '
+ BEGIN { RS = "\0" }
+ {
+ # comments first: they may span lines and may contain angle brackets
+ while (match($0, //)) {
+ $0 = substr($0, 1, RSTART - 1) substr($0, RSTART + RLENGTH)
+ }
+ gsub(/>[ \t\r\n]+, "><")
+ gsub(/^[ \t\r\n]+|[ \t\r\n]+$/, "")
+ printf "%s", $0
+ }
+ ' "$f" > "$tmp"
+ # a truncated write must never ship: the same floor build-css.sh keeps
+ if [ ! -s "$tmp" ] || [ "$(wc -c < "$tmp")" -lt 100 ]; then
+ rm -f "$tmp"
+ echo "strip-assets: $f came out implausibly small — refusing" >&2
+ exit 1
+ fi
+ mv "$tmp" "$f"
+ found=$((found + 1))
+done
+
+# ---- JSON: one line, no indentation ----
+# Structure only. A value keeps every byte, so a name or a URL with a space survives.
+for f in $(find "$DIR" -type f -name '*.json' ! -path '*/rpcd/acl.d/*' | sort); do
+ tmp="$f.tmp$$"
+ # acl.d is excluded on purpose: rpcd skips a malformed ACL SILENTLY, so the grant would go to
+ # nobody and only Save-as-default and the upload would break, on someone else's router. Those
+ # files are also never fetched over the wire. Not worth the risk for ~200 B.
+ awk '
+ BEGIN { RS = "\0"; q = 0 }
+ {
+ out = ""
+ n = length($0)
+ for (i = 1; i <= n; i++) {
+ c = substr($0, i, 1)
+ if (q) {
+ out = out c
+ if (c == "\\") { out = out substr($0, i + 1, 1); i++; continue }
+ if (c == "\"") q = 0
+ continue
+ }
+ if (c == "\"") { q = 1; out = out c; continue }
+ if (c == " " || c == "\t" || c == "\n" || c == "\r") continue
+ out = out c
+ }
+ printf "%s", out
+ }
+ ' "$f" > "$tmp"
+ if [ ! -s "$tmp" ]; then
+ rm -f "$tmp"
+ echo "strip-assets: $f came out empty — refusing" >&2
+ exit 1
+ fi
+ mv "$tmp" "$f"
+ found=$((found + 1))
+done
+
+[ "$found" -gt 0 ] || { echo "strip-assets: no .svg or .json in $DIR" >&2; exit 1; }
+echo "strip-assets: $found file(s)"
diff --git a/luci-theme-footstrap/styles/03-palettes.css b/luci-theme-footstrap/styles/03-palettes.css
index f8c4f6b7..eee9f559 100644
--- a/luci-theme-footstrap/styles/03-palettes.css
+++ b/luci-theme-footstrap/styles/03-palettes.css
@@ -210,7 +210,12 @@
:root[data-warn="hex"][data-warn] { --fs-on-warn: oklch(from var(--fs-warn) clamp(0, (l - .62) * -100, 1) 0 0); }
:root[data-danger="hex"][data-danger] { --fs-on-danger: oklch(from var(--fs-danger) clamp(0, (l - .62) * -100, 1) 0 0); }
- /* ---- footstrap — GitHub Primer (DEFAULT, also fills bare :root) ---- */
+ /* ---- footstrap — GitHub Primer (DEFAULT, also fills bare :root) ----
+ * `[data-palette="footstrap"]` never matches — both appliers REMOVE the attribute for the
+ * default rather than stamp it (fs-prefs.js listAxis, partials/head.ut:216) — and it is kept
+ * anyway: dropping it leaves a bare `:root` that duplicates the derive rule above, and merging
+ * those two would put one palette's raw values in the block that derives every palette's. 86 B
+ * to keep the two roles apart. */
:root,
:root[data-palette="footstrap"] {
/* LIGHT */
diff --git a/luci-theme-footstrap/styles/base/10-reset.css b/luci-theme-footstrap/styles/base/10-reset.css
index fe9bf308..57620816 100644
--- a/luci-theme-footstrap/styles/base/10-reset.css
+++ b/luci-theme-footstrap/styles/base/10-reset.css
@@ -22,10 +22,6 @@
border-spacing: 0;
}
- ol, ul {
- list-style: none;
- }
-
/* `hidden` is how a VIEW says an element is not there, and the only way available to code that
* ships no stylesheet. The UA gives it `display: none` at the weakest possible strength, so any
* `display` a theme sets on a class beats it and the element paints anyway: measured here on
diff --git a/luci-theme-footstrap/ucode/template/themes/footstrap/partials/head.ut b/luci-theme-footstrap/ucode/template/themes/footstrap/partials/head.ut
index beeb6064..477a88d7 100644
--- a/luci-theme-footstrap/ucode/template/themes/footstrap/partials/head.ut
+++ b/luci-theme-footstrap/ucode/template/themes/footstrap/partials/head.ut
@@ -203,14 +203,11 @@
const root = document.querySelector(':root'),
sd = window.__fsSD || {};
let p = lsGet('fs-palette');
- /* Legacy palette names migrated to the current explicit value, never cleared:
- an absent key means "inherit the router default", which is not "the built-in
- default". The migration is already redundant — fs-prefs.js's currentPalette()
- ends `return 'footstrap'`, so a stray value reads as the built-in default there
- too — and deleting this block changes nothing unless that line goes with it. */
- if (p === 'rvht' || p === 'roman' || p === 'github') {
- localStorage.setItem('fs-palette', 'footstrap'); p = 'footstrap';
- }
+ /* A stray or retired palette name needs no migration here: it fails the list
+ below and paints the bare :root, and listAxis() in fs-prefs.js ends the same
+ way, so the live applier agrees. The three legacy names ('rvht', 'roman',
+ 'github') were rewritten here until this comment replaced them — the block
+ was measured as changing nothing, and it ran on every page load. */
/* the default palette is a bare :root; every other colourway is opt-in. The list
is the same one fs-prefs.js validates against — a name in one and not the
other paints here and is taken away by the first live change. */
@@ -307,7 +304,7 @@
failure would be silent. */
const lb = sd.login_bg;
if (lb && (/^[a-f0-9]{6,64}$/).test(lb))
- root.style.setProperty('--fs-login-bg-url', "url('/luci-static/footstrap/bg?v=" + lb + "')");
+ root.style.setProperty('--fs-login-bg-url', 'url("/luci-static/footstrap/bg?v=' + lb + '")');
/* the Tint's strength (default 100% = the designed chroma) */
const tsRaw = lsGet('fs-tint-strength');
let ts = parseInt(tsRaw, 10);