🎄 Sync 2026-08-28 01:32:27

This commit is contained in:
github-actions[bot] committed 2026-08-28 01:32:27 +08:00
1 parent a6f45ed02f
commit 4015e0c92a
16 files changed
+1509 -1189

No files matched your search

+5 -1
View File
@@ -17,7 +17,7 @@ LUCI_NAME:=luci-theme-footstrap
FOOTSTRAP_VERSION?= FOOTSTRAP_VERSION?=
ifneq ($(FOOTSTRAP_VERSION),) ifneq ($(FOOTSTRAP_VERSION),)
PKG_VERSION:=$(FOOTSTRAP_VERSION) PKG_VERSION:=$(FOOTSTRAP_VERSION)
PKG_RELEASE:=38 PKG_RELEASE:=39
endif endif
LUCI_TITLE:=Footstrap Theme 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 $(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. # …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 $(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))'#" \ $(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 $(PKG_BUILD_DIR)/htdocs/luci-static/resources/fs-version.js
endef endef
+55 -7
View File
@@ -33,14 +33,52 @@ done
TMP="$OUT.tmp.$$" TMP="$OUT.tmp.$$"
# $TMP.min too: an awk failure used to leave it behind next to the real output. # $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")" 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 # glob expands in filename order
cat "$D"/styles/*.css \ {
"$D"/styles/base/*.css \ emit_layer tokens "$D"/styles/*.css
"$D"/styles/theme/*.css \ emit_layer base "$D"/styles/base/*.css
"$D"/styles/pages/*.css > "$TMP" 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. # Strip /* … */, keep /*! … */ (the licence banner), drop indentation and blank lines.
# #
@@ -135,9 +173,19 @@ squeeze() {
prev = (length(out) ? substr(out, length(out), 1) : lastc) prev = (length(out) ? substr(out, length(out), 1) : lastc)
nxt = (i <= n ? substr(line, i, 1) : "") nxt = (i <= n ? substr(line, i, 1) : "")
# drop it entirely next to a delimiter; otherwise it may be a combinator # 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 continue
if (nxt == "{" || nxt == "}" || nxt == ";" || nxt == "," || nxt == "") if (nxt == "{" || nxt == "}" || nxt == ";" || nxt == "," || nxt == "" || nxt == ">")
continue continue
out = out " "; lastreal = " " out = out " "; lastreal = " "
continue continue
@@ -3,7 +3,8 @@
'require ui'; 'require ui';
'require dom'; 'require dom';
'require fs-prefs as prefs'; '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'; 'require fs-version as ver';
/* The Appearance controls: the DOM that presents the axes. It owns no preference — fs-prefs.js /* 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 * 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. */ * page answers, and which is available is the package manager's question. */
/* ---- colour: reading what the page is actually painted ----
*
* This lived in fs-widgets.js, which the menu and the search palette also require — so the whole
* colour engine was downloaded on every admin page to be used on this one. It is 3 KB of probe,
* canvas and WCAG arithmetic that nothing outside this form has ever called: `colorControl` was
* fs-widgets' only colour export and this file its only consumer. */
/* ---- colour: reading what the page is actually painted ----
*
* Two questions no stored value answers: what colour a role is right now (the palette's own while
* the axis is off — there is deliberately no copy of the palette in JS), and what contrast the
* user's colour lands at. Both are about the computed cascade, so both are asked of the browser.
*
* `getComputedStyle(root).getPropertyValue('--fs-accent')` answers neither: a custom property
* computes to the token stream after var() substitution, so `oklch(from … l c H)` comes back
* unevaluated. Setting the expression as a real `color` and reading it back makes the browser
* resolve it — relative colour, color-mix() and the tint's calc() are what the theme is made of.
* One hidden probe is reused; an element per query would thrash layout on every slider drag. */
let _probe = null;
function probeColor(expr) {
if (!_probe) {
/* Off-screen rather than display:none, so the reading does not depend on a display:none
* element computing `color` in every engine. It has no text and no size, so it paints
* nothing.
*
* Every declaration is !important (issue #19): this is an unmarked element in a document
* shared with `luci-app-*`, and an app's unlayered `span { color: … !important }` outranks
* a layer and a plain inline style alike. A probe that loses its own colour reports the
* app's, which then becomes the admin's saved axis on the next confirm. */
_probe = E('span', { 'aria-hidden': 'true' });
_probe.style.cssText = 'position:fixed!important;left:-9999px!important;top:0!important;'
+ 'width:0!important;height:0!important;overflow:hidden!important;'
+ 'pointer-events:none!important;';
document.body.appendChild(_probe);
}
/* cleared first: an expression the engine rejects leaves the previous colour standing, which
* would report a stale answer as a fresh one */
_probe.style.setProperty('color', '');
_probe.style.setProperty('color', expr, 'important');
return getComputedStyle(_probe).color;
}
/* A computed colour -> [r,g,b] 0..255, or null. Rasterised, not parsed: a computed `color` keeps
* the space it was authored in, so `oklch(0.54 0.19 300)` would parse as three numbers in the
* wrong units and produce a colour nobody chose — measured: #010078, graded "Too faint to read",
* in the hex field, the swatch and the contrast readout alike. Painting one pixel makes the engine
* convert instead (tools/export-tier.mjs uses the same method). The string parse remains only as
* the fallback for an engine with no 2D context, where only the legacy `rgb()`/`color(srgb …)`
* forms can appear. */
let _cx = null;
function rasterCtx() {
if (_cx !== null) return _cx;
try {
const cv = document.createElement('canvas');
cv.width = cv.height = 1;
_cx = cv.getContext('2d', { willReadFrequently: true }) || false;
} catch (e) { _cx = false; }
return _cx;
}
function parseColor(s) {
const str = String(s || '');
const cx = rasterCtx();
if (cx) {
/* fillStyle keeps the last value it could parse, so a colour this engine rejects would
* report the previous one as a fresh reading — the trap probeColor() clears for */
cx.fillStyle = '#000';
cx.fillStyle = str;
cx.clearRect(0, 0, 1, 1);
cx.fillRect(0, 0, 1, 1);
const d = cx.getImageData(0, 0, 1, 1).data;
if (d[3] === 255) return [ d[0], d[1], d[2] ];
/* translucent: composite over nothing is meaningless for a readout, so fall through */
}
const nums = str.match(/[\d.]+/g);
if (!nums || nums.length < 3) return null;
const unit = (/^color\(/i).test(str) ? 255 : 1;
return nums.slice(0, 3).map((n) => Math.max(0, Math.min(255, parseFloat(n) * unit)));
}
/* WCAG 2.x relative luminance and contrast ratio, on sRGB. Used only to report: the theme states
* what a colour costs and leaves the choice with the user, never correcting it (03-palettes.css
* derives the ink over a fill, which is a different question). */
function luminance(rgb) {
const c = rgb.map((v) => {
const x = v / 255;
return (x <= .03928) ? (x / 12.92) : Math.pow((x + .055) / 1.055, 2.4);
});
return (.2126 * c[0]) + (.7152 * c[1]) + (.0722 * c[2]);
}
function contrastRatio(fgExpr, bgExpr) {
const fg = parseColor(probeColor(fgExpr)), bg = parseColor(probeColor(bgExpr));
if (!fg || !bg) return null;
const a = luminance(fg), b = luminance(bg);
return (Math.max(a, b) + .05) / (Math.min(a, b) + .05);
}
/* #rrggbb, because <input type="color"> accepts nothing else. An unparseable colour becomes black
* rather than throwing: the text field beside the swatch is the authoritative one. */
function toHex(s) {
const rgb = parseColor(s) || [ 0, 0, 0 ];
return '#' + rgb.map((v) => Math.round(v).toString(16).padStart(2, '0')).join('');
}
/* One colour axis: a native swatch, a hex field and a button back to the palette's own colour.
* Reports through onPick as a hex string, or 0 for "back to the palette", either of which the
* caller hands straight to fs-prefs.js's colorAxis.
*
* There is no hue slider and one is not coming back: rotating a hue keeps the palette's chroma,
* so no angle of it reaches a grey. The axis still accepts a stored hue (1–360) and the stylesheet
* still rotates the palette by one, so a saved value goes on working.
*
* `opts.probe` is the live token the effective colour is read back from, so the field shows the
* palette's colour while the axis is off without a copy of the palette in JS. `opts.contrast` is
* the pair whose ratio is reported under the row. */
function colorControl(current, onPick, label, opts) {
const o = opts || {};
/* type=color leaves the picker to the browser: accessible without reimplementing a colour
* wheel, and native on a phone. The text field beside it takes a pasted hex and is the
* fallback where the browser draws no picker. */
const swatch = E('input', { 'type': 'color', 'class': 'fs-color-swatch', 'aria-label': label || '' });
const field = E('input', {
'type': 'text', 'class': 'fs-color-hex', 'spellcheck': 'false', 'autocomplete': 'off',
'inputmode': 'text', 'maxlength': '7', 'aria-label': label || ''
});
const clear = E('button', { 'class': 'btn fs-color-clear', 'type': 'button' }, [ _('Palette', 'footstrap') ]);
const ratio = o.contrast ? E('div', { 'class': 'cbi-value-description fs-color-contrast' }) : null;
/* what the axis holds right now: the page can change it behind this control (a preset, Reset
* to default), so a private copy would go stale. `current` is only the build-time value. */
const currentOf = o.read || (() => current);
/* Repaint everything that mirrors the axis. Called after every edit, and through the returned
* refresh() after a preset, palette switch or dark-mode flip — each changes what the palette's
* own colour is while this axis stays off. */
function reflect(v) {
const live = probeColor(o.probe);
const hex = (typeof v === 'string') ? v : toHex(live);
swatch.value = hex;
/* do not fight the user mid-edit: `#0` is a legal prefix, and overwriting the field on
* every keystroke made the input impossible to type into */
if (document.activeElement !== field) field.value = hex;
/* the button back to the palette doubles as the axis state readout: enabled means the axis
* holds a colour of its own, disabled means the field shows the palette's */
clear.disabled = !v;
if (!ratio) return;
const r = contrastRatio(o.contrast.fg, o.contrast.bg);
if (r === null) { ratio.textContent = ''; ratio.removeAttribute('title'); return; }
/* The readout states what the ratio means; the number itself stays in the title.
* Thresholds are WCAG AA: 4.5:1 for body text, 3:1 for large text and for a UI shape, so a
* hairline is graded on the second (`kind: 'shape'`) and warns rather than fails — a faint
* border is a legitimate choice.
*
* Class names are written out whole: tools/fs-orphans.mjs sweeps dead CSS by matching
* fs-* tokens in the source, and a concatenated name is invisible to it. */
const where = o.contrast.label;
const grade = (o.contrast.kind === 'shape')
? ((r >= 3)
? { cls: 'fs-contrast-aa', text: _('Clearly visible %s', 'footstrap').format(where) }
: { cls: 'fs-contrast-aa-large', text: _('Barely visible %s', 'footstrap').format(where) })
: (r >= 4.5)
? { cls: 'fs-contrast-aa', text: _('Easy to read %s', 'footstrap').format(where) }
: (r >= 3)
? { cls: 'fs-contrast-aa-large', text: _('Hard to read %s — large text only', 'footstrap').format(where) }
: { cls: 'fs-contrast-low', text: _('Too faint to read %s', 'footstrap').format(where) };
ratio.className = 'fs-color-contrast ' + grade.cls;
ratio.textContent = grade.text;
ratio.title = _('Contrast %s:1 (WCAG AA wants %s:1 here)', 'footstrap')
.format(r.toFixed(1), (o.contrast.kind === 'shape') ? '3' : '4.5');
}
const pick = (v) => { onPick(v); reflect(v); };
swatch.addEventListener('input', () => pick(swatch.value.toLowerCase()));
/* commit on blur and Enter, not per keystroke: a half-typed `#0096` would repaint the page
* under the cursor. An unparseable value snaps back to what the axis holds, so the field
* cannot claim a colour the page is not painted in. */
const commit = () => {
const v = field.value.trim().toLowerCase();
if ((/^#[0-9a-f]{6}$/).test(v)) pick(v);
else reflect(currentOf());
};
field.addEventListener('blur', commit);
field.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); commit(); } });
clear.addEventListener('click', () => pick(0));
const wrap = E('div', { 'class': 'fs-colorctl' + (o.cls ? ' ' + o.cls : '') }, [
E('div', { 'class': 'fs-color-row' }, [ swatch, field, clear ])
].concat(ratio ? [ ratio ] : []));
/* the caller decides when this runs: probeColor() needs the document, and this control is not
* in it yet */
wrap.fsRefresh = () => reflect(currentOf());
return wrap;
}
/* Build the whole form. Returns a promise for one element wire() appends to the stock page. /* 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 * 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 ---- /* ---- the controls are LuCI's own ----
* *
* Every enum axis is a `ui.Select` and every number a `ui.RangeSlider`: the widgets the other * 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, /* one colour axis: `probe` is the live token the control reads the effective colour back from,
* `contrast` the pair it reports */ * `contrast` the pair it reports */
const colourGroup = (label, axis, probe, contrast, opts) => group(label, (lbl) => { 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, probe: probe,
read: axis.current, read: axis.current,
contrast: contrast, contrast: contrast,
@@ -137,7 +336,7 @@ function build() {
dark: _('Dark', 'footstrap') dark: _('Dark', 'footstrap')
}, bump(repaint(prefs.applyMode)), label)), }, bump(repaint(prefs.applyMode)), label)),
group(_('Palette', 'footstrap'), (label) => selectCtl(prefs.currentPalette(), { group(_('Palette', 'footstrap'), (label) => selectCtl(axes.currentPalette(), {
footstrap: 'Footstrap', footstrap: 'Footstrap',
hicontrast: 'Hi-Contrast', hicontrast: 'Hi-Contrast',
/* names the OTHER package, luci-theme-bootstrap, whose colours this palette is — /* names the OTHER package, luci-theme-bootstrap, whose colours this palette is —
@@ -145,7 +344,7 @@ function build() {
bootstrap: 'Bootstrap', bootstrap: 'Bootstrap',
/* names the OTHER package again, luci-theme-openwrt-2020, whose colourway this is */ /* names the OTHER package again, luci-theme-openwrt-2020, whose colourway this is */
'2020': 'OpenWrt 2020' '2020': 'OpenWrt 2020'
}, bump(repaint(prefs.applyPalette)), label)), }, bump(repaint(axes.applyPalette)), label)),
group(_('Density', 'footstrap'), (label) => selectCtl(prefs.currentDensity(), { group(_('Density', 'footstrap'), (label) => selectCtl(prefs.currentDensity(), {
compact: _('Compact', 'footstrap'), compact: _('Compact', 'footstrap'),
@@ -154,7 +353,7 @@ function build() {
}, bump(prefs.applyDensity), label)), }, bump(prefs.applyDensity), label)),
group(_('Rounding', 'footstrap'), 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, /* 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 * 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 /* the caption says what the axis is for: "Tint" alone reads as decoration, and nobody
* would look for the router-identity cue under it */ * would look for the router-identity cue under it */
colourGroup(_('Tint (router identification)', 'footstrap'), { colourGroup(_('Tint (router identification)', 'footstrap'), {
current: prefs.currentTint, apply: prefs.applyTint current: axes.currentTint, apply: axes.applyTint
}, 'var(--fs-bg)', { }, 'var(--fs-bg)', {
/* the canvas is the one axis with no derived ink: its text is --fs-text, a palette /* 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 */ * 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 * 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. */ * the aria-label, so a screen reader would announce two rows under one name. */
group(_('Tint strength', 'footstrap'), 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 step: 5
}), { cls: 'fs-ap-tint fs-ap-tintstr' }), }), { 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 * 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 * you want grey or black"), taking any #rrggbb — the colour-chip presets that once sat here
* are not coming back. */ * are not coming back. */
colourGroup(_('Accent', 'footstrap'), { /* Four status roles, one shape: the role's own colour read against a card. Written out
current: prefs.currentAccent, apply: prefs.applyAccent * eight times between here and the surfaces below, they cost their repeated literals in
}, 'var(--fs-accent)', { * full — a string is not mangled — so the rows are data and the row is stated once. */
fg: 'var(--fs-accent)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap') ...[
}), [ _('Accent', 'footstrap'), axes.currentAccent, axes.applyAccent, 'var(--fs-accent)' ],
[ _('Good', 'footstrap'), axes.currentGood, axes.applyGood, 'var(--fs-good)' ],
colourGroup(_('Good', 'footstrap'), { [ _('Warning', 'footstrap'), axes.currentWarn, axes.applyWarn, 'var(--fs-warn)' ],
current: prefs.currentGood, apply: prefs.applyGood [ _('Danger', 'footstrap'), axes.currentDanger, axes.applyDanger, 'var(--fs-danger)' ]
}, 'var(--fs-good)', { ].map(([ label, current, apply, ink ]) =>
fg: 'var(--fs-good)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap') colourGroup(label, { current, apply }, ink, { fg: ink, bg: CARD_BG, label: ON_CARD }))
}),
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')
})
]; ];
/* ---- the surfaces: the sheet the UI is drawn on ---- /* ---- 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 * — 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. */ * the number and leaves the call to the admin. */
const surfaces = [ const surfaces = [
colourGroup(_('Cards', 'footstrap'), { /* Same rows, one column wider: a surface reports the ink read ON it, which is --fs-text
current: prefs.currentCard, apply: prefs.applyCard * for the three that carry body text and the hairline itself for the border. */
}, 'var(--fs-panel)', { ...[
fg: 'var(--fs-text)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap') [ _('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') ],
colourGroup(_('Controls', 'footstrap'), { [ _('Borders', 'footstrap'), axes.currentLine, axes.applyLine, 'var(--fs-border)', 'var(--fs-border)', CARD_BG, ON_CARD, 'shape' ]
current: prefs.currentControl, apply: prefs.applyControl ].map(([ label, current, apply, probe, fg, bg, where, kind ]) =>
}, 'var(--fs-panel2)', { colourGroup(label, { current, apply }, probe, { fg, bg, label: where, kind }))
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'
})
]; ];
/* ---- section 3: the wallpaper and the rows each value brings ---- /* ---- section 3: the wallpaper and the rows each value brings ----
@@ -301,30 +472,30 @@ function build() {
group(_('Pattern', 'footstrap'), group(_('Pattern', 'footstrap'),
() => E('div', { 'class': 'fs-ap-bgrow' }, [ patChoose, patRemove ]), () => E('div', { 'class': 'fs-ap-bgrow' }, [ patChoose, patRemove ]),
{ extra: [ patInput, patPreview, patErr ] }), { extra: [ patInput, patPreview, patErr ] }),
group(scaleLabel, (lbl) => sliderCtl(prefs.currentPatternSize(), 40, 1600, group(scaleLabel, (lbl) => sliderCtl(axes.currentPatternSize(), 40, 1600,
bump(prefs.applyPatternSize), lbl, { step: 20 })), bump(axes.applyPatternSize), lbl, { step: 20 })),
group(strengthLabel, (lbl) => sliderCtl(prefs.currentPatternStrength(), 0, 100, group(strengthLabel, (lbl) => sliderCtl(axes.currentPatternStrength(), 0, 100,
bump(prefs.applyPatternStrength), lbl, { step: 5 })), bump(axes.applyPatternStrength), lbl, { step: 5 })),
group(inkLabel, (lbl) => selectCtl(prefs.currentPatternInk(), { group(inkLabel, (lbl) => selectCtl(axes.currentPatternInk(), {
theme: _('Theme', 'footstrap'), theme: _('Theme', 'footstrap'),
original: _('As in file', 'footstrap') original: _('As in file', 'footstrap')
}, bump(prefs.applyPatternInk), lbl)) }, bump(axes.applyPatternInk), lbl))
]; ];
/* …and the rows the FILE photo brings. */ /* …and the rows the FILE photo brings. */
const fileRows = [ const fileRows = [
group(_('File', 'footstrap'), group(_('File', 'footstrap'),
() => E('div', { 'class': 'fs-ap-bgrow' }, [ chooseBtn, removeBtn ]), () => E('div', { 'class': 'fs-ap-bgrow' }, [ chooseBtn, removeBtn ]),
{ extra: [ fileInput, preview, err ] }), { extra: [ fileInput, preview, err ] }),
group(dimLabel, (lbl) => sliderCtl(prefs.currentPhotoDim(), 0, 100, group(dimLabel, (lbl) => sliderCtl(axes.currentPhotoDim(), 0, 100,
bump(prefs.applyPhotoDim), lbl, { step: 5 })) bump(axes.applyPhotoDim), lbl, { step: 5 }))
]; ];
function reflect(tok) { 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; } else { preview.removeAttribute('src'); preview.hidden = true; removeBtn.hidden = true; }
} }
function reflectPattern(tok) { 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; } else { patPreview.removeAttribute('src'); patPreview.hidden = true; patRemove.hidden = true; }
} }
/* `hidden` on the row, which 80-appearance.css restates at a specificity beating /* `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'); }); patRows.forEach((r) => { r.hidden = (v !== 'pattern'); });
fileRows.forEach((r) => { r.hidden = (v !== 'file'); }); fileRows.forEach((r) => { r.hidden = (v !== 'file'); });
} }
reflect(prefs.currentLoginBg()); reflect(axes.currentLoginBg());
reflectPattern(prefs.currentPattern()); reflectPattern(axes.currentPattern());
togglePanel(prefs.currentWallpaper()); 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(); }); /* Both uploads present the same three controls and the same four states — pick, upload,
patInput.addEventListener('change', () => { * report, remove — so the wiring is stated once. What differs is `after`: the pattern also
const f = patInput.files && patInput.files[0]; * has to move the Wallpaper dropdown, because the upload switched this browser onto the
patInput.value = ''; /* so re-picking the same file fires change again */ * tile and the page would otherwise paint it while the control still read Off.
if (!f) return; *
patErr.hidden = true; patChoose.disabled = true; * The file input is cleared on every change so re-picking the SAME file fires `change`
patChoose.textContent = _('Uploading…', 'footstrap'); * again, and the button carries its own busy state: the label is restored in `finally`, or
prefs.uploadPattern(f) * a failed upload leaves "Uploading…" standing for the life of the form. */
.then((tok) => { const wireUploader = (u) => {
reflectPattern(tok); const fail = (e) => { u.err.textContent = String((e && e.message) || e); u.err.hidden = false; };
/* uploadPattern already switched this browser onto the pattern, so the control u.choose.addEventListener('click', () => { u.err.hidden = true; u.input.click(); });
* must catch up or the page paints the tile while the dropdown reads Off. u.input.addEventListener('change', () => {
* `dom.callClassMethod` is how LuCI moves its own widgets from outside; const f = u.input.files && u.input.files[0];
* setWallpaper is then called directly, because a programmatic setValue emits u.input.value = '';
* no `widget-change`. */ if (!f) return;
dom.callClassMethod(seg, 'setValue', 'pattern'); u.err.hidden = true; u.choose.disabled = true;
setWallpaper('pattern'); u.choose.textContent = _('Uploading…', 'footstrap');
}) u.upload(f)
.catch((e) => { patErr.textContent = String((e && e.message) || e); patErr.hidden = false; }) .then((tok) => { u.reflect(tok); if (u.after) u.after(tok); })
.finally(() => { patChoose.disabled = false; patChoose.textContent = patChooseLabel; }); .catch(fail)
}); .finally(() => { u.choose.disabled = false; u.choose.textContent = u.label; });
patRemove.addEventListener('click', () => { });
patErr.hidden = true; patRemove.disabled = true; u.remove.addEventListener('click', () => {
prefs.removePattern() u.err.hidden = true; u.remove.disabled = true;
.then(() => reflectPattern('')) u.drop().then(() => u.reflect('')).catch(fail)
.catch((e) => { patErr.textContent = String((e && e.message) || e); patErr.hidden = false; }) .finally(() => { u.remove.disabled = false; });
.finally(() => { patRemove.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(); }); wireUploader({
fileInput.addEventListener('change', () => { choose: chooseBtn, remove: removeBtn, input: fileInput, err: err,
const f = fileInput.files && fileInput.files[0]; label: chooseLabel, reflect: reflect,
fileInput.value = ''; /* so re-picking the same file fires change again */ upload: assets.uploadLoginBg, drop: assets.removeLoginBg
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; });
}); });
let seg; let seg;
const wallRow = group(_('Wallpaper', 'footstrap'), (label) => { const wallRow = group(_('Wallpaper', 'footstrap'), (label) => {
seg = selectCtl(prefs.currentWallpaper(), { seg = selectCtl(axes.currentWallpaper(), {
off: _('Off', 'footstrap'), off: _('Off', 'footstrap'),
pattern: _('Pattern', 'footstrap'), pattern: _('Pattern', 'footstrap'),
file: _('File', 'footstrap') file: _('File', 'footstrap')
@@ -442,14 +608,14 @@ function build() {
saveErr.hidden = false; saveErr.hidden = false;
return; return;
} }
const saved = prefs.matchesSavedDefault(); const saved = axes.matchesSavedDefault();
saveBtn.disabled = saved; saveBtn.disabled = saved;
saveBtn.textContent = saved ? _('Saved as default', 'footstrap') : _('Save as default', 'footstrap'); saveBtn.textContent = saved ? _('Saved as default', 'footstrap') : _('Save as default', 'footstrap');
} }
saveBtn.addEventListener('click', () => { saveBtn.addEventListener('click', () => {
saveBtn.disabled = true; saveBtn.disabled = true;
saveErr.hidden = true; saveErr.hidden = true;
prefs.saveAsDefault() axes.saveAsDefault()
.then(() => { saveErr.hidden = true; }) .then(() => { saveErr.hidden = true; })
/* on failure refreshSave re-enables the button so the user can retry; the usual cause /* 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 * is a stale session, which a reload fixes. The raw rpc error stays in a title
@@ -485,8 +651,8 @@ function build() {
location.reload(); location.reload();
}); });
} }
twoClick(resetSavedBtn, _('Reset to saved', 'footstrap'), prefs.resetToSaved); twoClick(resetSavedBtn, _('Reset to saved', 'footstrap'), axes.resetToSaved);
twoClick(resetBtn, _('Reset to default', 'footstrap'), prefs.resetToBuiltin); twoClick(resetBtn, _('Reset to default', 'footstrap'), axes.resetToBuiltin);
refreshSave(); /* correct label and enabled state before the first paint */ refreshSave(); /* correct label and enabled state before the first paint */
const versionLink = E('a', { const versionLink = E('a', {
@@ -536,7 +702,8 @@ function build() {
function foldable(title, rows, key) { function foldable(title, rows, key) {
const id = 'fs-ap-fold-' + (++foldSeq); const id = 'fs-ap-fold-' + (++foldSeq);
let open = (prefs.lsGet(key) === 'on'); 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', { const btn = E('button', {
'type': 'button', 'class': 'fs-ap-fold', 'aria-expanded': String(open), 'aria-controls': id '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 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 * the form to be in the document: every readout resolves inside fs-widgets against a hidden
* hidden probe is attached to <body>, so a detached form still reads the live palette. */ * probe attached to <body>, so a detached form still reads the live palette. */
Promise.resolve().then(refreshColours); Promise.resolve().then(refreshColours);
return page; return page;
} }
@@ -761,6 +928,5 @@ function wire() {
} }
return baseclass.extend({ return baseclass.extend({
wire, wire
render
}); });
@@ -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 <svg>, 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 <defs> 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 (`<set attributename="href" to="javascript:…">`), 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: `<svg xmlns="http://www.w3.org/1999/xhtml">` reads as
* `svg` and is admitted although it is an XHTML document that executes on all three engines,
* while `<s:svg xmlns:s="http://www.w3.org/2000/svg">` 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: `<xsl:element name="script">`
* 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
* `<s:script xmlns:s="http://www.w3.org/2000/svg">` 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 <head> 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
});
@@ -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 `<g opacity>` 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
});
@@ -151,7 +151,6 @@ _mqDark.addEventListener('change', () => {
/* Corner radius: the card radius (0–20px) as an inline --fs-radius-base on :root, from which /* 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 * 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. */ * to this one number, hence the named const. */
const FS_RADIUS_DEFAULT = 12;
/* ---- the four axis shapes, each written once ---- /* ---- 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 * `wallpaper` and `density` are three-valued, `palette` outgrew the two-value shape when the third
* one landed, `autoCollapse` has no :root attribute. */ * 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 /* An axis whose values are a list, with one of them stamped as nothing.
* attribute). */ *
function enumAxis(key, attr, on, off) { * `values` are the names that become `attr="<name>"`; `dflt` is the one that leaves :root bare, and
* is what a stray or missing value falls back to. `after` runs once the attribute is stamped, for
* the axis that has to re-take a measurement.
*
* The list IS the validation: a name added to the stylesheet and not here is one head.ut pre-paints
* and this rejects, so the page paints it and the first touch of any other control takes it away. */
function listAxis(key, attr, values, dflt, after) {
/* 'fs-pattern-ink' -> 'pattern_ink', the window.__fsSD field. The underscore is the point: the /* '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 * 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 * 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. */ * 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 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 { return {
def, def,
current() { current() {
const s = lsGet(key); const s = lsGet(key);
if (s === on) return on; if (ok(s)) return s;
if (s === off) return off; if (s === dflt) return dflt;
if (s === null) return def(); 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) { apply(val) {
const root = document.documentElement; const root = document.documentElement;
const isOn = (val === on); const v = ok(val) ? val : dflt;
lsSet(key, isOn ? on : off); /* stored explicitly (including the default), so it overrides a router default */
if (isOn) root.setAttribute(attr, on); lsSet(key, v);
else root.removeAttribute(attr); 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: /* 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 * 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). * 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. * 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 * `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. */ * 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 */ const DENSITIES = [ 'compact', 'large' ]; /* the two non-default values; 'normal' = bare :root */
function densityDefault() { const DENSITY = listAxis('fs-density', 'data-density', DENSITIES, 'normal', () => fit.schedule());
const d = sd('density'); const currentDensity = DENSITY.current, applyDensity = DENSITY.apply,
return (DENSITIES.indexOf(d) >= 0) ? d : 'normal'; densityDefault = DENSITY.def;
}
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);
}
/* Background-tint axis: the canvas the cards float on (--fs-bg), so a whole install reads as one /* 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 * 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 * 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 * 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. */ * 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() { function currentLayout() {
return document.documentElement.getAttribute('data-layout') === 'top' ? 'top' : 'sidebar'; 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 * 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, * 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. */ * and the saved default is for other devices. resetToSaved() drops this browser back onto it. */
const AXIS_KEYS = [ /* Every saved axis's localStorage key: what Save-as-default clears and what a reset walks.
'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 * Tried as one table of [key, field, def] with the resolved defaults derived from it: correct,
* instance is a `const`, so declaring it further down leaves it in the TDZ at init and the whole * and 188 B larger after minification — three lists of short literals compress better than
* module throws, taking the chrome and the menu with it. */ * twenty-one rows of data, because a function name is mangled and a row is not. The copies are
const FS_TSTR_DEFAULT = 100; * held together by tools/axes.mjs instead, which reads snapshotAxes()'s body and holds every
const TSTR = propAxis('fs-tint-strength', 'tint_strength', '--fs-tint-strength', 0, 200, FS_TSTR_DEFAULT, (v) => String(v / 100)); * field against header.ut's FS_AXES. */
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 `<g opacity>` 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 <svg>, 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 <defs> 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 (`<set attributename="href" to="javascript:…">`), 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 <head> 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(''); });
}
return baseclass.extend({ 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, currentMode, applyMode, modeDefault, guardDarkStamp,
currentPalette, applyPalette, currentDensity, applyDensity, densityDefault,
currentWallpaper, applyWallpaper, currentLayout, applyLayout, isTopLayout,
currentDensity, applyDensity, currentAutoCollapse, applyAutoCollapse, autoCollapseDefault,
currentRadius, applyRadius, currentRail, applyRail
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
}); });
@@ -1155,36 +1155,52 @@ function bootDocumentIsOurs() {
* *
* The list is what THIS file calls. `uci` (flushUciCache) and `L.network` are read through their own * The list is what THIS file calls. `uci` (flushUciCache) and `L.network` are read through their own
* guards at their use, being optional there. */ * guards at their use, being optional there. */
const CONTRACT = [ const CONTRACT_FNS = [
[ 'L.require', () => typeof window.L.require === 'function' ], 'L.require',
/* classLoaded() tests `instanceof L.Class` to tell a loaded module from L.env/L.url/L.get */ /* 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.Class',
[ 'L.dom.content', () => window.L.dom && typeof window.L.dom.content === 'function' ], '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 */ /* the L.env keys navigate() re-points, plus the base_url moduleUrl() reads */
[ 'L.env.{base_url,dispatchpath,requestpath,pathinfo,nodespec}', () => { [ 'L.env.{base_url,dispatchpath,requestpath,pathinfo,nodespec}', () => {
const env = window.L.env; const env = window.L.env;
return !!env && [ 'base_url', 'dispatchpath', 'requestpath', 'pathinfo', 'nodespec' ] return !!env && [ 'base_url', 'dispatchpath', 'requestpath', 'pathinfo', 'nodespec' ]
.every((k) => k in env); .every((k) => k in env);
} ], } ],
[ 'L.Poll.queue', () => window.L.Poll && Array.isArray(window.L.Poll.queue) ], [ 'L.Poll.queue', () => 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' ]
]; ];
/* -> the names that are not there, in list order; empty means the document can be navigated. A /* -> 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. */ * probe that throws counts as missing: `L` itself may be a shape nobody here expected. */
function contractBreaks() { function contractBreaks() {
return CONTRACT.filter(([ , present ]) => { const gone = (probe) => {
try { return !present(); } try { return !probe(); }
catch (e) { return true; } 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() { function wireRouter() {
@@ -143,69 +143,28 @@ function search(q, limit) {
const RECENT_KEY = 'fs-recent'; const RECENT_KEY = 'fs-recent';
const RECENT_MAX = 8; const RECENT_MAX = 8;
/* prefs.lsGetArr owns the parse, the corruption guard and the Array check; only the /* The list is WRITTEN by menu-footstrap-common.js, which is on every page — this module is not any
* "these are paths" filter belongs here */ * more, and a palette that only loads when it is opened cannot be what records where the admin has
function loadRecent() { * been. Read here, at open time, so it is always current. `prefs.lsGetArr` owns the parse, the
return prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string'); * corruption guard and the Array check; only the "these are paths" filter belongs here. */
}
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));
}
function recentEntries() { function recentEntries() {
const recent = prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string');
const byPath = new Map(index().map((e) => [ e.path, e ])); const byPath = new Map(index().map((e) => [ e.path, e ]));
return _recent.map((p) => byPath.get(p)).filter(Boolean).slice(0, RECENT_MAX); 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);
} }
/* ---- the palette -------------------------------------------------------- */ /* ---- the palette -------------------------------------------------------- */
const MAX_RESULTS = 20; const MAX_RESULTS = 20;
function wire() { /* Built on the first open and kept — the overlay, its listeners and the index survive for the life
const btn = document.getElementById('fs-search-btn'); * of the document, so a second Ctrl+K costs nothing. Until then this module is not even fetched:
if (!btn) return; * 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 function build() {
* covers an F5, a non-SPA-able node and the first page of a session */ const btn = document.getElementById('fs-search-btn');
remember(L.env.dispatchpath || []); if (!btn) return null;
/* 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();
const input = E('input', { const input = E('input', {
'type': 'text', 'type': 'text',
@@ -384,8 +343,16 @@ function wire() {
ev.preventDefault(); ev.preventDefault();
open(); 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({ return baseclass.extend({
wire open: openPalette
}); });
@@ -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. * 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. */ * 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 <input type="color"> accepts nothing else. An unparseable colour becomes black
* rather than throwing: the text field beside the swatch is the authoritative one. */
function toHex(s) {
const rgb = parseColor(s) || [ 0, 0, 0 ];
return '#' + rgb.map((v) => Math.round(v).toString(16).padStart(2, '0')).join('');
}
/* One colour axis: a native swatch, a hex field and a button back to the palette's own colour.
* Reports through onPick as a hex string, or 0 for "back to the palette", either of which the
* caller hands straight to fs-prefs.js's colorAxis.
*
* There is no hue slider and one is not coming back: rotating a hue keeps the palette's chroma,
* so no angle of it reaches a grey. The axis still accepts a stored hue (1–360) and the stylesheet
* still rotates the palette by one, so a saved value goes on working.
*
* `opts.probe` is the live token the effective colour is read back from, so the field shows the
* palette's colour while the axis is off without a copy of the palette in JS. `opts.contrast` is
* the pair whose ratio is reported under the row. */
function colorControl(current, onPick, label, opts) {
const o = opts || {};
/* type=color leaves the picker to the browser: accessible without reimplementing a colour
* wheel, and native on a phone. The text field beside it takes a pasted hex and is the
* fallback where the browser draws no picker. */
const swatch = E('input', { 'type': 'color', 'class': 'fs-color-swatch', 'aria-label': label || '' });
const field = E('input', {
'type': 'text', 'class': 'fs-color-hex', 'spellcheck': 'false', 'autocomplete': 'off',
'inputmode': 'text', 'maxlength': '7', 'aria-label': label || ''
});
const clear = E('button', { 'class': 'btn fs-color-clear', 'type': 'button' }, [ _('Palette', 'footstrap') ]);
const ratio = o.contrast ? E('div', { 'class': 'cbi-value-description fs-color-contrast' }) : null;
/* what the axis holds right now: the page can change it behind this control (a preset, Reset
* to default), so a private copy would go stale. `current` is only the build-time value. */
const currentOf = o.read || (() => current);
/* Repaint everything that mirrors the axis. Called after every edit, and through the returned
* refresh() after a preset, palette switch or dark-mode flip — each changes what the palette's
* own colour is while this axis stays off. */
function reflect(v) {
const live = probeColor(o.probe);
const hex = (typeof v === 'string') ? v : toHex(live);
swatch.value = hex;
/* do not fight the user mid-edit: `#0` is a legal prefix, and overwriting the field on
* every keystroke made the input impossible to type into */
if (document.activeElement !== field) field.value = hex;
/* the button back to the palette doubles as the axis state readout: enabled means the axis
* holds a colour of its own, disabled means the field shows the palette's */
clear.disabled = !v;
if (!ratio) return;
const r = contrastRatio(o.contrast.fg, o.contrast.bg);
if (r === null) { ratio.textContent = ''; ratio.removeAttribute('title'); return; }
/* The readout states what the ratio means; the number itself stays in the title.
* Thresholds are WCAG AA: 4.5:1 for body text, 3:1 for large text and for a UI shape, so a
* hairline is graded on the second (`kind: 'shape'`) and warns rather than fails — a faint
* border is a legitimate choice.
*
* Class names are written out whole: tools/fs-orphans.mjs sweeps dead CSS by matching
* fs-* tokens in the source, and a concatenated name is invisible to it. */
const where = o.contrast.label;
const grade = (o.contrast.kind === 'shape')
? ((r >= 3)
? { cls: 'fs-contrast-aa', text: _('Clearly visible %s', 'footstrap').format(where) }
: { cls: 'fs-contrast-aa-large', text: _('Barely visible %s', 'footstrap').format(where) })
: (r >= 4.5)
? { cls: 'fs-contrast-aa', text: _('Easy to read %s', 'footstrap').format(where) }
: (r >= 3)
? { cls: 'fs-contrast-aa-large', text: _('Hard to read %s — large text only', 'footstrap').format(where) }
: { cls: 'fs-contrast-low', text: _('Too faint to read %s', 'footstrap').format(where) };
ratio.className = 'fs-color-contrast ' + grade.cls;
ratio.textContent = grade.text;
ratio.title = _('Contrast %s:1 (WCAG AA wants %s:1 here)', 'footstrap')
.format(r.toFixed(1), (o.contrast.kind === 'shape') ? '3' : '4.5');
}
const pick = (v) => { onPick(v); reflect(v); };
swatch.addEventListener('input', () => pick(swatch.value.toLowerCase()));
/* commit on blur and Enter, not per keystroke: a half-typed `#0096` would repaint the page
* under the cursor. An unparseable value snaps back to what the axis holds, so the field
* cannot claim a colour the page is not painted in. */
const commit = () => {
const v = field.value.trim().toLowerCase();
if ((/^#[0-9a-f]{6}$/).test(v)) pick(v);
else reflect(currentOf());
};
field.addEventListener('blur', commit);
field.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); commit(); } });
clear.addEventListener('click', () => pick(0));
const wrap = E('div', { 'class': 'fs-colorctl' + (o.cls ? ' ' + o.cls : '') }, [
E('div', { 'class': 'fs-color-row' }, [ swatch, field, clear ])
].concat(ratio ? [ ratio ] : []));
/* the caller decides when this runs: probeColor() needs the document, and this control is not
* in it yet */
wrap.fsRefresh = () => reflect(currentOf());
return wrap;
}
return baseclass.extend({ return baseclass.extend({
svgIcon, svgIcon,
setOpen, setOpen,
wireSpaceKey, wireSpaceKey,
wireDismiss, wireDismiss
colorControl,
probeColor,
toHex
}); });
@@ -7,7 +7,6 @@
'require fs-router as router'; 'require fs-router as router';
'require fs-prefs as prefs'; 'require fs-prefs as prefs';
'require fs-sheets as sheets'; 'require fs-sheets as sheets';
'require fs-search as search';
/* Page modules: `fs-appearance` (System -> System) and `fs-overview` (Status -> Overview) each /* 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 * serve one page and are required only on it. A `require` pragma would make them a hard
@@ -42,6 +41,85 @@ function wirePageModules() {
load(); 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. /* 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 * `admin_status/index.ut` defines `progressbar`, `renderBox` and `renderBadge` in an inline script
@@ -140,9 +218,7 @@ return baseclass.extend({
fit.add(chrome.fitChrome); fit.add(chrome.fitChrome);
chrome.renderChrome(); chrome.renderChrome();
/* after setTree(): the palette indexes that tree on first open, and records recent wireSearch();
* pages from the first navigation onwards */
search.wire();
chrome.wireRail(); chrome.wireRail();
chrome.wireIndicatorCounts(); chrome.wireIndicatorCounts();
/* before router.wire(): the router restamps body[data-page] on every SPA navigation, /* before router.wire(): the router restamps body[data-page] on every SPA navigation,
+63 -7
View File
@@ -33,19 +33,44 @@ set -e
CSS="${1:-}" CSS="${1:-}"
[ -n "$CSS" ] && [ -f "$CSS" ] || { echo "usage: mangle-tokens.sh <cascade.css> <dir>..." >&2; exit 1; } [ -n "$CSS" ] && [ -f "$CSS" ] || { echo "usage: mangle-tokens.sh <cascade.css> <dir>..." >&2; exit 1; }
shift shift
# --rewrite <dir>… : 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; } [ $# -gt 0 ] || { echo "mangle-tokens: no reserved-source dir given" >&2; exit 1; }
RES="$CSS.reserved.$$" RES="$CSS.reserved.$$"
MAP="$CSS.map.$$" 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 if [ -n "$REWRITE" ]; then
for d in "$@"; do # nothing is reserved: every name is renamed here and in the far side together
[ -d "$d" ] || { echo "mangle-tokens: $d is not a directory" >&2; exit 1; } : > "$RES"
find "$d" -type f \( -name '*.js' -o -name '*.ut' \) -exec cat {} + else
done | grep -oE -- '--fs-[a-z0-9-]+' | sort -u > "$RES" # 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" ' awk -v RESFILE="$RES" -v MAPFILE="$MAP" '
function isname(c) { return (c ~ /[A-Za-z0-9_-]/) } function isname(c) { return (c ~ /[A-Za-z0-9_-]/) }
@@ -122,3 +147,34 @@ before=$(wc -c < "$CSS")
mv "$CSS.tmp.$$" "$CSS" mv "$CSS.tmp.$$" "$CSS"
after=$(wc -c < "$CSS") after=$(wc -c < "$CSS")
echo "mangle-tokens: $before -> $after bytes (-$((before - after))), $(wc -l < "$RES") name(s) reserved" 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
@@ -4,3 +4,4 @@ config footstrap 'settings'
option darkmode 'dark' option darkmode 'dark'
option wallpaper 'pattern' option wallpaper 'pattern'
option layout 'sidebar' option layout 'sidebar'
option autocollapse 'on'
+85
View File
@@ -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 <dir>
set -eu
DIR="${1:-}"
[ -n "$DIR" ] && [ -d "$DIR" ] || { echo "usage: strip-assets.sh <dir>" >&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)"
+6 -1
View File
@@ -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-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); } :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,
:root[data-palette="footstrap"] { :root[data-palette="footstrap"] {
/* LIGHT */ /* LIGHT */
@@ -22,10 +22,6 @@
border-spacing: 0; 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 /* `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 * 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 * `display` a theme sets on a class beats it and the element paints anyway: measured here on
@@ -203,14 +203,11 @@
const root = document.querySelector(':root'), const root = document.querySelector(':root'),
sd = window.__fsSD || {}; sd = window.__fsSD || {};
let p = lsGet('fs-palette'); let p = lsGet('fs-palette');
/* Legacy palette names migrated to the current explicit value, never cleared: /* A stray or retired palette name needs no migration here: it fails the list
an absent key means "inherit the router default", which is not "the built-in below and paints the bare :root, and listAxis() in fs-prefs.js ends the same
default". The migration is already redundant — fs-prefs.js's currentPalette() way, so the live applier agrees. The three legacy names ('rvht', 'roman',
ends `return 'footstrap'`, so a stray value reads as the built-in default there 'github') were rewritten here until this comment replaced them — the block
too — and deleting this block changes nothing unless that line goes with it. */ was measured as changing nothing, and it ran on every page load. */
if (p === 'rvht' || p === 'roman' || p === 'github') {
localStorage.setItem('fs-palette', 'footstrap'); p = 'footstrap';
}
/* the default palette is a bare :root; every other colourway is opt-in. The list /* 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 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. */ other paints here and is taken away by the first live change. */
@@ -307,7 +304,7 @@
failure would be silent. */ failure would be silent. */
const lb = sd.login_bg; const lb = sd.login_bg;
if (lb && (/^[a-f0-9]{6,64}$/).test(lb)) 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) */ /* the Tint's strength (default 100% = the designed chroma) */
const tsRaw = lsGet('fs-tint-strength'); const tsRaw = lsGet('fs-tint-strength');
let ts = parseInt(tsRaw, 10); let ts = parseInt(tsRaw, 10);