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