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