From 0d442e083085c4d6f61812b6e24a03b3eeed9883 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 25 Sep 2026 19:20:10 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=89=20Sync=202026-09-25=2019:20:10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- luci-theme-footstrap/Makefile | 50 +- luci-theme-footstrap/README.md | 2 +- luci-theme-footstrap/build-css.sh | 273 ++++---- luci-theme-footstrap/dev-sync.sh | 5 +- .../luci-static/resources/fs-appearance.js | 132 ++-- .../htdocs/luci-static/resources/fs-assets.js | 75 +-- .../htdocs/luci-static/resources/fs-axes.js | 232 ++++--- .../htdocs/luci-static/resources/fs-chrome.js | 73 +-- .../htdocs/luci-static/resources/fs-fit.js | 71 +- .../luci-static/resources/fs-menutree.js | 6 +- .../luci-static/resources/fs-overview.js | 59 +- .../htdocs/luci-static/resources/fs-prefs.js | 86 +-- .../htdocs/luci-static/resources/fs-router.js | 47 +- .../htdocs/luci-static/resources/fs-search.js | 82 +-- .../htdocs/luci-static/resources/fs-select.js | 53 +- .../htdocs/luci-static/resources/fs-sheets.js | 14 +- .../luci-static/resources/fs-widgets.js | 70 +- .../resources/menu-footstrap-common.js | 83 +-- .../luci-static/resources/menu-footstrap.js | 58 +- luci-theme-footstrap/mangle-tokens.sh | 59 +- luci-theme-footstrap/po/es/footstrap.po | 196 +++--- luci-theme-footstrap/po/ru/footstrap.po | 196 +++--- .../po/templates/footstrap.pot | 192 +++--- luci-theme-footstrap/strip-assets.sh | 30 +- luci-theme-footstrap/styles/00-header.css | 15 +- luci-theme-footstrap/styles/02-tokens.css | 440 ++----------- luci-theme-footstrap/styles/03-palettes.css | 421 ++---------- luci-theme-footstrap/styles/04-nocolormix.css | 41 +- luci-theme-footstrap/styles/base/10-reset.css | 36 +- .../styles/base/20-typography.css | 35 +- luci-theme-footstrap/styles/base/30-forms.css | 123 +--- .../styles/base/40-tables.css | 95 +-- luci-theme-footstrap/styles/base/60-modal.css | 35 +- .../styles/base/70-buttons.css | 42 +- .../styles/base/90-widgets.css | 46 +- luci-theme-footstrap/styles/base/95-luci.css | 249 ++----- .../styles/pages/10-login.css | 66 +- .../styles/pages/20-overview.css | 377 ++--------- .../styles/pages/30-software.css | 84 +-- .../styles/pages/40-sshkeys.css | 18 +- .../styles/pages/50-leases.css | 65 +- .../styles/pages/60-assoclist.css | 15 - .../styles/pages/70-syslog.css | 46 +- .../styles/pages/80-appearance.css | 139 +--- .../styles/pages/90-processes.css | 14 - .../styles/theme/10-chrome.css | 138 +--- .../styles/theme/15-wallpaper.css | 42 +- .../styles/theme/16-login-bg.css | 24 +- .../styles/theme/20-shell.css | 404 +++--------- .../styles/theme/25-progressbar.css | 60 +- .../styles/theme/30-tables.css | 616 +++--------------- .../styles/theme/35-alerts.css | 167 ++--- luci-theme-footstrap/styles/theme/40-tabs.css | 58 +- luci-theme-footstrap/styles/theme/45-misc.css | 156 +---- .../styles/theme/50-toplayout.css | 64 +- .../styles/theme/55-buttons.css | 192 ++---- .../styles/theme/60-inputs.css | 573 +++------------- .../styles/theme/65-dropdown.css | 309 +++------ .../styles/theme/70-modal.css | 75 +-- .../styles/theme/75-search.css | 48 +- .../styles/theme/90-responsive.css | 159 +---- .../styles/theme/95-a11y-media.css | 91 +-- .../styles/theme/97-print.css | 27 +- .../ucode/template/themes/footstrap/footer.ut | 36 +- .../ucode/template/themes/footstrap/header.ut | 14 +- .../themes/footstrap/partials/footer.ut | 35 - .../themes/footstrap/partials/head.ut | 61 +- .../template/themes/footstrap/sysauth.ut | 7 +- luci-theme-footstrap/update-po.sh | 15 +- openwrt-fastfetch/Makefile | 4 +- 70 files changed, 2016 insertions(+), 5905 deletions(-) delete mode 100644 luci-theme-footstrap/styles/pages/60-assoclist.css delete mode 100644 luci-theme-footstrap/styles/pages/90-processes.css delete mode 100644 luci-theme-footstrap/ucode/template/themes/footstrap/partials/footer.ut diff --git a/luci-theme-footstrap/Makefile b/luci-theme-footstrap/Makefile index fecf8c945..ebe75e392 100644 --- a/luci-theme-footstrap/Makefile +++ b/luci-theme-footstrap/Makefile @@ -12,14 +12,9 @@ PKG_NAME:=luci-theme-footstrap # the CSS build. Pin it. LUCI_NAME:=luci-theme-footstrap -# Version is git-derived; never hardcode PKG_VERSION. CI injects FOOTSTRAP_VERSION from the -# tag (an SDK build has no .git, so it cannot derive one). -FOOTSTRAP_VERSION?= -ifneq ($(FOOTSTRAP_VERSION),) -PKG_VERSION:=$(FOOTSTRAP_VERSION) -PKG_RELEASE:=75 -endif - +# Version is git-derived; never hardcode PKG_VERSION. luci.mk falls back to PKG_SRC_VERSION, +# which it always defines, when nothing here sets it — see docs/package.md for why no override +# lives here. LUCI_TITLE:=Footstrap Theme # +luci-base is the WHOLE list, and keeping it that way is the constraint. Nothing this package ships # fetches anything at run time, so there is no second name to justify — and the rule that got it here @@ -37,27 +32,12 @@ LUCI_URL:=https://github.com/VizzleTF/luci-theme-footstrap # color-mix() and nested calc() and break the layout. build-css.sh minifies instead. LUCI_MINIFY_CSS:=0 # -# JS: two minify paths, chosen by who is building. -# -# CI (the released packages) pre-minifies with terser BEFORE the SDK build — tools/minify-js.mjs -# mangles top-level identifiers too, which jsmin cannot: ~41 KB against jsmin's ~57 KB, −27% — and -# signals it with FOOTSTRAP_PREMIN=1. -# jsmin must then stay away from the already-minified output: terser legitimately emits -# `return/^v/.test(s)`, the exact one-character-lookback trap below, so LUCI_MINIFY_JS goes to 0. -# -# Everyone else (an SDK user, the buildbot — no node) builds the untouched source and keeps -# luci.mk's default jsmin: ~105 KB of this tree's JS is comments, and uhttpd serves /www with NO -# compression, so those are wire and flash bytes either way. -# -# The catch on the jsmin path, and why the gates exist: jsmin decides regex-or-division from a -# one-character lookback, and `n` (of `return`) and `>` (of `=>`) are not on its allow-list, so -# `return /re/` makes it eat the rest of the file AND EXIT 0 (openwrt/luci#8299). eslint's -# `wrap-regex` bans the shape in the source; tools/jsmin-verify.mjs proves jsmin's output is -# token-identical to it. -FOOTSTRAP_PREMIN?= -ifneq ($(FOOTSTRAP_PREMIN),) -LUCI_MINIFY_JS:=0 -endif +# JS: the released package is built by owfeed (tools/stage.sh), never by this Makefile through +# the SDK — so LUCI_MINIFY_JS keeps luci.mk's default jsmin here. jsmin decides regex-or-division +# from a one-character lookback, and `n` (of `return`) and `>` (of `=>`) are not on its +# allow-list, so `return /re/` makes it eat the rest of the file AND EXIT 0 (openwrt/luci#8299). +# eslint's `wrap-regex` bans the shape in the source; tools/jsmin-verify.mjs proves jsmin's output +# is token-identical to it. # Apache-2.0, and not a free choice: styles/base began as a fork of luci-theme-bootstrap's # cascade.css, the ucode templates derive from LuCI's own, and a few JS helpers are verbatim copies. @@ -131,9 +111,7 @@ define Package/luci-theme-footstrap/postinst #!/bin/sh [ -n "$${IPKG_INSTROOT}" ] || { # uci-defaults registers the theme; drop the LuCI caches so it is seen without a reboot. - # Calling it ourselves is belt-and-braces: OpenWrt's default_postinst also runs (then - # deletes) every /etc/uci-defaults/* we ship, so it executes twice per install. It is - # idempotent: the second pass finds the theme already registered and changes nothing. + # default_postinst also runs it; the second pass is idempotent. [ -f /etc/uci-defaults/30_luci-theme-footstrap ] && \ sh /etc/uci-defaults/30_luci-theme-footstrap >/dev/null 2>&1 || true rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* >/dev/null 2>&1 || true @@ -221,12 +199,8 @@ endef # falls back to PKG_SRC_VERSION, which luci.mk always defines, so only an un-built source # checkout keeps the literal '0.0.0-dev'. # -# The catalogue is luci.mk's business and nothing here compiles it. `po/` is the directory -# LUCI_LANGUAGES globs, so luci.mk emits one `luci-i18n-footstrap-` package per language, the -# way it does for every luci-app — which is what a package in the luci tree has to look like, and -# what Weblate can see. It was `i18n/` while a fielded self-update script mis-picked a multi-asset -# release with `head -1` (issue #6); that script is retired and owfeed builds one theme artifact -# per format regardless. +# The catalogue is luci.mk's business and nothing here compiles it: `po/` is the directory +# LUCI_LANGUAGES globs, so luci.mk emits one `luci-i18n-footstrap-` package per language. define Build/Prepare/luci-theme-footstrap # luci.mk's Build/Prepare copies only src/ luasrc/ htdocs/ root/ ucode/ into # $(PKG_BUILD_DIR), and PKG_LICENSE_FILES resolves against THAT — so the Apache text has to diff --git a/luci-theme-footstrap/README.md b/luci-theme-footstrap/README.md index 010ccb777..4090763fa 100644 --- a/luci-theme-footstrap/README.md +++ b/luci-theme-footstrap/README.md @@ -52,7 +52,7 @@ Colours go in `styles/03-palettes.css`, scales and tokens in `styles/02-tokens.c ## Working on a router -The normal dev stand is four containers driven by `owlab` from `../owlab.yaml` — see +The normal dev stand is the disposable routers `../owlab.yaml` defines, driven by `owlab` — see [`../docs/development.md`](../docs/development.md). `dev-sync.sh` targets a **hardware** router over ssh: diff --git a/luci-theme-footstrap/build-css.sh b/luci-theme-footstrap/build-css.sh index 39eab7b6d..dc3f7356e 100755 --- a/luci-theme-footstrap/build-css.sh +++ b/luci-theme-footstrap/build-css.sh @@ -37,8 +37,8 @@ trap 'rm -f "$TMP" "$TMP.min" "$TMP.layer"' EXIT mkdir -p "$(dirname "$OUT")" # One `@layer X{` per LAYER, not one per FILE. Every source file carries its own wrapper so it can -# be read and edited alone; concatenated, that is 38 copies of the same six-to-eleven bytes, 554 of -# them, plus two files that are all comment and emit nothing but `@layer page{}`. +# be read and edited alone; concatenated, that is 37 copies of the same six-to-eleven bytes, 542 of +# them. # # 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 @@ -53,13 +53,13 @@ emit_layer() { 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; } + 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 + exit 1 else cat "$f" fi @@ -69,6 +69,7 @@ emit_layer() { cat "$body" printf '}\n' fi + # not covered by the EXIT trap: the success path runs `trap - EXIT` first rm -f "$body" } @@ -80,20 +81,35 @@ emit_layer() { emit_layer page "$D"/styles/pages/*.css } > "$TMP" -# Strip /* … */, keep /*! … */ (the licence banner), drop indentation and blank lines. -# -# String-aware: a scanner that just hunts for the next "/*" lets `content: "/*"` open a comment and -# eat every rule up to the next "*/", with only the brace count below as a guard — and two such -# literals balance each other, so rules can vanish in silence. Quoted data-URIs run through here on -# every build. -strip_comments() { - awk ' - BEGIN { inc = 0; q = "" } +# Comments, and — unless told not to — the whitespace CSS ignores anyway, in one string-aware +# pass rather than two chained ones. squeeze=0 (the pre-compression rule count below, and every +# `--dev` build) strips comments and trims each line; squeeze=1 also does the ~9.5 KB whitespace +# pass. Why a scan has to be string-aware, what the whitespace pass leaves alone and why, and the +# sed bug this replaced: docs/css.md, "The build". +squeeze() { + awk -v SQUEEZE="$2" ' + BEGIN { q = ""; inc = 0; banner = 0; lastc = ""; buf = ""; lastreal = "" } { - line = $0; out = ""; i = 1; n = length(line) + line = $0 + # a banner already open on an earlier line: nothing on a pure continuation line can + # change q or inc, so the whole line is banner content until its own closing "*/". + if (banner) { + trimmed = line + sub(/^[ \t]+/, "", trimmed); sub(/[ \t]+$/, "", trimmed) + print trimmed + lastc = ""; lastreal = "" + if (index(line, "*/")) banner = 0 + next + } + # The line BREAK a squeeze pass is about to swallow is whitespace, and a declaration + # may be wrapped across it. Feed it to the whitespace-run logic below as a leading + # space: it survives only where a space would and is dropped next to { } ; , : — + # lastc == "" means output is already at the start of a line, with nothing to glue to. + if (SQUEEZE && lastc != "" && q == "" && !inc) line = " " line + out = ""; i = 1; n = length(line); opened = 0 while (i <= n) { c = substr(line, i, 1) - if (inc) { # inside /* ... */ + if (inc) { # inside a stripped /* ... */ if (c == "*" && substr(line, i + 1, 1) == "/") { inc = 0; i += 2; continue } i++; continue } @@ -101,120 +117,90 @@ strip_comments() { out = out c if (c == "\\") { out = out substr(line, i + 1, 1); i += 2; continue } if (c == q) q = "" - i++; continue - } - if (c == "\"" || c == "'"'"'") { q = c; out = out c; i++; continue } - if (c == "/" && substr(line, i + 1, 1) == "*") { - # the banner: keep it, and everything after it on this line - if (substr(line, i + 2, 1) == "!") { out = out substr(line, i); break } - inc = 1; i += 2; continue - } - out = out c; i++ - } - sub(/^[ \t]+/, "", out) - sub(/[ \t]+$/, "", out) - if (length(out)) print out - } - ' "$1" -} - -# Squeeze the whitespace CSS ignores — wire AND flash bytes, since uhttpd does not compress. -# -# Removed (~9.5 KB): the space after `:`, the spaces around `{ } ; ,`, the last `;` of a block, the -# newline after every declaration. lightningcss would save ~13 KB, but its extra 3.5 KB comes from -# rewriting colours and merging rules — transforms that can change behaviour. These cannot. -# -# Left alone, each for a reason: -# - the single space between selectors: `.a .b` is a DESCENDANT combinator, `.a.b` is not; -# - spaces inside calc(): required around `*`, `/` and the `-` of `calc(100% - 8px)`; -# - the LINE BREAK inside a declaration, which is whitespace too — joining lines with nothing -# between them turns a wrapped calc() into `…))- .004 …`, and a `-` with no space before it is -# a parse error, so the declaration drops and the token it defined goes undefined, silently — -# --fs-bg then became invalid at computed-value time and the canvas fell back to white -# (export-tier.mjs caught it: contrast 1.5:1); -# - `>` `+` `~` spaces: stripping them is safe but buys only ~200 bytes; -# - anything inside a string: every data-URI here is quoted and full of `:`, `;` and spaces; -# - one newline after `}`, so the shipped file stays greppable. -squeeze() { - awk ' - BEGIN { q = ""; ban = 0; lastc = ""; buf = ""; lastreal = "" } - { - line = $0 - # The /*! banner is an Apache-2.0 attribution, not formatting: it must survive - # BYTE FOR BYTE. Squeezing it made "Twitter, Inc" into "Twitter,Inc" and glued its - # lines together. Copy it out untouched. - if (ban) { print line; lastc = ""; lastreal = ""; if (index(line, "*/")) ban = 0; next } - if (substr(line, 1, 3) == "/*!") { - print line; lastc = ""; lastreal = "" - if (!index(substr(line, 4), "*/")) ban = 1 - next - } - # The line BREAK we are about to swallow is whitespace, and a declaration may be - # wrapped across it. Feed it to the whitespace-run logic below as a leading space: - # it survives only where a space would (between two tokens) and is dropped next to - # { } ; , : — lastc == "" means output is already at the start of a line, with - # nothing to glue to. - if (lastc != "" && q == "") line = " " line - out = ""; i = 1; n = length(line) - while (i <= n) { - c = substr(line, i, 1) - if (q != "") { # inside a string: copy verbatim - out = out c - if (c == "\\") { out = out substr(line, i + 1, 1); i += 2; continue } - if (c == q) q = "" - lastreal = "" # a char inside a string is not structure + lastreal = "" i++; continue } if (c == "\"" || c == "'"'"'") { q = c; out = out c; lastreal = ""; i++; continue } - if (c == " " || c == "\t") { # collapse a run of whitespace to one space - while (i <= n && (substr(line, i, 1) == " " || substr(line, i, 1) == "\t")) i++ - # the last char EMITTED, which on a continuation line lives on the - # previous output line — hence lastc, not just `out`. - prev = (length(out) ? substr(out, length(out), 1) : lastc) - nxt = (i <= n ? substr(line, i, 1) : "") - # drop it entirely next to a delimiter; otherwise it may be a combinator + if (c == "/" && substr(line, i + 1, 1) == "*") { + if (substr(line, i + 2, 1) == "!") { + # a genuine banner open: reached only with q=="" and inc==0 AT THIS + # CHARACTER, the same guard strip_comments used before this pass absorbed + # it — so a "/*!" that is really bytes inside a string or an already-open + # ordinary comment never lands here. Copied raw from the marker on, byte + # for byte: it is an Apache-2.0 attribution, not formatting. + banner = 1 + rest = substr(line, i) + out = out rest + if (index(rest, "*/")) banner = 0 + opened = 1 + break + } + inc = 1; i += 2; continue + } + if (SQUEEZE) { + if (c == " " || c == "\t") { # collapse a run of whitespace to one space + while (i <= n && (substr(line, i, 1) == " " || substr(line, i, 1) == "\t")) i++ + # the last char EMITTED, which on a continuation line lives on the + # previous output line — hence lastc, not just `out`. + prev = (length(out) ? substr(out, length(out), 1) : lastc) + nxt = (i <= n ? substr(line, i, 1) : "") + # drop it entirely next to a delimiter; otherwise it may be a combinator + # + # `>` is a delimiter too, and the only one that is itself a combinator: a + # space either side of it is decoration. Safe because a `>` outside a + # string can only be the child combinator — the sheet has no media range + # syntax (`@media (width > 600px)`) — and a `>` inside a string never + # reaches here, the scanner having copied it verbatim above. `~` and `+` + # are deliberately NOT joined: `[attr~=v]` and `calc(100% - 10px)` make + # them ambiguous without tracking bracket depth. + if (prev == "" || prev == "{" || prev == "}" || prev == ";" || prev == "," || prev == ":" || prev == ">") + continue + if (nxt == "{" || nxt == "}" || nxt == ";" || nxt == "," || nxt == "" || nxt == ">") + continue + out = out " "; lastreal = " " + continue + } + # THE LAST `;` OF A BLOCK IS REDUNDANT — dropped as the `}` is emitted, i.e. + # INSIDE the string-aware scanner. It used to be a `| sed "s/;}/}/g"` bolted + # onto the awk output, and sed cannot see strings: `content: ";}"` came out as + # `content: "}"`, and a data-URI containing `;}` was corrupted the same way + # (both reproduced). Nothing in the tree holds that byte pair today — which is + # how such a bug waits for whoever adds the first one. # - # `>` is a delimiter too, and the only one that is itself a combinator: a - # space either side of it is decoration. 516 of them in the sheet, so it is - # 1,032 B — the file header used to guess "~200 bytes" for this whole pass. - # Safe because a `>` outside a string can only be the child combinator: the - # sheet has no media range syntax (`@media (width > 600px)`), and the 107 - # `>` inside string literals never reach here, the scanner having copied them - # verbatim above. `~` and `+` are deliberately NOT joined: `[attr~=v]` and - # `calc(100% - 10px)` make them ambiguous without tracking bracket depth, - # and they are worth 14 B and 34 B. - if (prev == "" || prev == "{" || prev == "}" || prev == ";" || prev == "," || prev == ":" || prev == ">") - continue - if (nxt == "{" || nxt == "}" || nxt == ";" || nxt == "," || nxt == "" || nxt == ">") - continue - out = out " "; lastreal = " " - continue + # The `;` may already sit in the previous line output, so text is held in + # `buf` until the rule closes: a `;` already printed cannot be taken back. + if (c == "}") { + if (length(out) && substr(out, length(out), 1) == ";") + out = substr(out, 1, length(out) - 1) + else if (!length(out) && length(buf) && substr(buf, length(buf), 1) == ";") + buf = substr(buf, 1, length(buf) - 1) + } + out = out c; lastreal = c; i++ + } else { + out = out c; i++ } - # THE LAST `;` OF A BLOCK IS REDUNDANT — dropped as the `}` is emitted, i.e. - # INSIDE the string-aware scanner. It used to be a `| sed "s/;}/}/g"` bolted onto - # the awk output, and sed cannot see strings: `content: ";}"` came out as - # `content: "}"`, and a data-URI containing `;}` was corrupted the same way (both - # reproduced). Nothing in the tree holds that byte pair today — which is how such - # a bug waits for whoever adds the first one. - # - # The `;` may already sit in the previous line output, so text is held in `buf` - # until the rule closes: a `;` already printed cannot be taken back. - if (c == "}") { - if (length(out) && substr(out, length(out), 1) == ";") - out = substr(out, 1, length(out) - 1) - else if (!length(out) && length(buf) && substr(buf, length(buf), 1) == ";") - buf = substr(buf, 1, length(buf) - 1) - } - out = out c; lastreal = c; i++ } - buf = buf out - if (length(out)) lastc = substr(out, length(out), 1) - # newline only after a closing brace — one rule per line. lastreal, not lastc: a - # line ending in a QUOTED `}` (content: "}") is not the end of a rule, and flushing - # there would split the rule and lose the space before its next token. - if (lastreal == "}") { print buf; buf = ""; lastc = ""; lastreal = "" } + if (opened) { + trimmed = out + sub(/^[ \t]+/, "", trimmed); sub(/[ \t]+$/, "", trimmed) + print trimmed + lastc = ""; lastreal = "" + next + } + if (SQUEEZE) { + buf = buf out + if (length(out)) lastc = substr(out, length(out), 1) + # newline only after a closing brace — one rule per line. lastreal, not lastc: a + # line ending in a QUOTED `}` (content: "}") is not the end of a rule, and + # flushing there would split the rule and lose the space before its next token. + if (lastreal == "}") { print buf; buf = ""; lastc = ""; lastreal = "" } + } else { + sub(/^[ \t]+/, "", out) + sub(/[ \t]+$/, "", out) + if (length(out)) print out + } } - END { if (length(buf)) print buf; else printf "\n" } + END { if (SQUEEZE) { if (length(buf)) print buf; else printf "\n" } } ' "$1" } @@ -250,47 +236,38 @@ brace_count() { }' "$1" } -# Always brace-check a COMMENT-STRIPPED copy, --dev included: counting braces in the raw -# file made a stray "{" in prose fail the build on perfectly valid CSS. -strip_comments "$TMP" > "$TMP.min" +# Always brace-check a COMMENT-STRIPPED, UNSQUEEZED copy, --dev included: counting braces in the +# raw file made a stray "{" in prose fail the build on perfectly valid CSS. +squeeze "$TMP" 0 > "$TMP.min" RULES_BEFORE=$(brace_count "$TMP.min") || exit 1 +rm -f "$TMP.min" if [ "$DEV" -eq 0 ]; then - # comments gone; now squeeze the whitespace CSS ignores - squeeze "$TMP.min" > "$TMP" - rm -f "$TMP.min" + squeeze "$TMP" 1 > "$TMP.min" + mv "$TMP.min" "$TMP" # AND AGAIN, on what actually ships: the check above only saw the squeeze's INPUT, yet # the squeeze is the pass most able to corrupt the sheet — it tracks strings, joins lines # and deletes the `;` before a `}`. An unchanged rule count is what says it did not. + # + # That equality — not brace balance on its own — is also why the 80 KB FS_CSS_FLOOR size + # guard once here is gone. Balance alone misses a cut right after any of the four + # `@layer NAME { … }` wrappers closes: everything up to that point is still a matched set, so + # a truncated write — a full disk, a compression that ate the tail — that stops exactly there + # reads as a valid, complete-looking file. Fed the real sheet, a cut right after the base + # layer's own close counts a balanced 283 rules against the file's true 1057. What catches + # that is the COUNT: RULES_BEFORE is measured from the untruncated concatenation before this + # pass runs, so a write that stops early can only ever come up short against it. RULES_AFTER=$(brace_count "$TMP") || exit 1 if [ "$RULES_BEFORE" != "$RULES_AFTER" ]; then echo "build-css: the squeeze changed the rule count ($RULES_BEFORE -> $RULES_AFTER)." >&2 exit 1 fi -else - # --dev keeps comments AND formatting: this output is for reading, not shipping - rm -f "$TMP.min" fi +# --dev: $TMP is still the raw concatenation — comments and formatting intact, for reading on a +# router rather than shipping. -# BROKEN-BUILD FLOOR (no upper size budget — removed). With only an upper bound, every way of -# producing a SHORT file — a truncated write, a full disk, a squeeze that ate the tail — passed -# and shipped a stylesheet with its second half missing. The floor catches that; it is a -# correctness guard, not a size limit. -# -# Measured on $TMP and BEFORE the mv, with the EXIT trap still armed — like the brace checks above, -# which had this order right. It used to run after, so the guard's own failure path LEFT the mangled -# sheet at $OUT with nothing to clean it up: make aborts in Build/Prepare, fine, but dev-sync.sh -# writes straight into htdocs/luci-static/footstrap/cascade.css, so a truncated sheet stayed in the -# working tree and the CSS-only iterate loop (scp that same file) would ship it. SIZE=$(wc -c < "$TMP" | tr -d ' ') -FLOOR=${FS_CSS_FLOOR:-81920} # 80 KB — well under the real sheet; only a mangled build lands here -if [ "$DEV" -eq 0 ] && [ "$SIZE" -lt "$FLOOR" ]; then - echo "build-css: cascade.css would be only $SIZE bytes, under the $FLOOR-byte floor." >&2 - echo "build-css: that is not a smaller stylesheet, that is a broken one. $OUT left untouched." >&2 - exit 1 -fi - mv "$TMP" "$OUT" trap - EXIT diff --git a/luci-theme-footstrap/dev-sync.sh b/luci-theme-footstrap/dev-sync.sh index 8988b7ff7..e11956d8e 100755 --- a/luci-theme-footstrap/dev-sync.sh +++ b/luci-theme-footstrap/dev-sync.sh @@ -98,10 +98,7 @@ for _f in "$D"/root/etc/config/*; do scp -q "$_f" "$R":"/tmp/.fs-conf-$_b" ssh "$R" "[ -f /etc/config/$_b ] || { mv /tmp/.fs-conf-$_b /etc/config/$_b; echo ' installed /etc/config/$_b (was absent)'; }; rm -f /tmp/.fs-conf-$_b" done -# There is no self-update backend to deploy any more: the theme upgrades through the package feed -# the installer adds, which is what a package manager is for. -# -# No rpcd reload here either — same race as the Makefile's postinst note (Package/luci-theme- +# No rpcd reload here — same race as the Makefile's postinst note (Package/luci-theme- # footstrap/postinst): this copies files onto a router where the theme registers no rpcd object, # rpcd re-reads acl.d/*.json at LOGIN (not only on reload), and a developer's root session already # carries read='*' write='*', so the theme's ACL group is irrelevant to it regardless. diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-appearance.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-appearance.js index 65352fa30..9d9dc8a5a 100644 --- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-appearance.js +++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-appearance.js @@ -73,37 +73,28 @@ function probeColor(expr) { * 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. */ + * convert instead (tools/export-tier.mjs uses the same method). Canvas 2D is universal on every + * engine 24.10 ships against, so there is no second path. */ let _cx = null; function rasterCtx() { - if (_cx !== null) return _cx; - try { + if (!_cx) { const cv = document.createElement('canvas'); cv.width = cv.height = 1; - _cx = cv.getContext('2d', { willReadFrequently: true }) || false; - } catch (e) { _cx = false; } + _cx = cv.getContext('2d', { willReadFrequently: true }); + } 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))); + /* 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 = String(s || ''); + cx.clearRect(0, 0, 1, 1); + cx.fillRect(0, 0, 1, 1); + const d = cx.getImageData(0, 0, 1, 1).data; + /* translucent: composite over nothing is meaningless for a readout */ + return d[3] === 255 ? [ d[0], d[1], d[2] ] : null; } /* WCAG 2.x relative luminance and contrast ratio, on sRGB. Used only to report: the theme states @@ -141,8 +132,8 @@ function toHex(s) { * `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 || {}; +function colorControl(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 @@ -155,10 +146,6 @@ function colorControl(current, onPick, label, opts) { 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. */ @@ -207,7 +194,9 @@ function colorControl(current, onPick, label, opts) { const commit = () => { const v = field.value.trim().toLowerCase(); if ((/^#[0-9a-f]{6}$/).test(v)) pick(v); - else reflect(currentOf()); + /* re-read rather than a cached copy: the page can change what the axis holds behind this + * control (a preset, Reset to default), which a snapshot taken at build time would miss */ + else reflect(o.read()); }; field.addEventListener('blur', commit); field.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); commit(); } }); @@ -218,7 +207,7 @@ function colorControl(current, onPick, label, opts) { ].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()); + wrap.fsRefresh = () => reflect(o.read()); return wrap; } @@ -309,14 +298,15 @@ function build() { return node; }; - /* one colour axis: `probe` is the live token the control reads the effective colour back from, - * `contrast` the pair it reports */ - const colourGroup = (label, axis, probe, contrast, opts) => group(label, (lbl) => { - const ctl = colorControl(axis.current(), bump(axis.apply), lbl, { + /* one colour axis: `axis` is the `{current, apply}` object fs-axes.js exports it as; `probe` is + * the live token the control reads the effective colour back from, `contrast` the pair it + * reports */ + const colourGroup = (label, axis, probe, contrast, opts = {}) => group(label, (lbl) => { + const ctl = colorControl(bump(axis.apply), lbl, { probe: probe, read: axis.current, contrast: contrast, - cls: (opts && opts.cls) || '' + cls: opts.cls }); colourCtls.push(ctl); return ctl; @@ -344,7 +334,7 @@ function build() { dark: _('Dark', 'footstrap') }, bump(repaint(prefs.applyMode)), label)), - group(_('Palette', 'footstrap'), (label) => selectCtl(axes.currentPalette(), { + group(_('Palette', 'footstrap'), (label) => selectCtl(axes.palette.current(), { footstrap: 'Footstrap', hicontrast: 'Hi-Contrast', /* names the OTHER package, luci-theme-bootstrap, whose colours this palette is — @@ -355,7 +345,7 @@ function build() { /* names the OpenWrt forum (forum.openwrt.org), whose Discourse colourway this is — * a proper noun like the three above it, not the English common noun "forum" */ forum: 'Forum' - }, bump(repaint(axes.applyPalette)), label)), + }, bump(repaint(axes.palette.apply)), label)), group(_('Density', 'footstrap'), (label) => selectCtl(prefs.currentDensity(), { compact: _('Compact', 'footstrap'), @@ -370,11 +360,11 @@ function build() { * --fs-content-min (500px, what the sidebar-to-bar fold is measured against) out of reach * by construction rather than by a rule someone has to remember. */ group(_('Content width', 'footstrap'), - (label) => sliderCtl(axes.currentContentWidth(), 1280, 3840, - bump(axes.applyContentWidth), label, { step: 40, live: false })), + (label) => sliderCtl(axes.contentWidth.current(), 1280, 3840, + bump(axes.contentWidth.apply), label, { step: 40, live: false })), group(_('Rounding', 'footstrap'), - (label) => sliderCtl(axes.currentRadius(), 0, 20, bump(axes.applyRadius), label)), + (label) => sliderCtl(axes.radius.current(), 0, 20, bump(axes.radius.apply), label)), /* The top layout has no accordion, so this switch is meaningless there: always built, * hidden by CSS (:root[data-layout="top"] .fs-ap-submenus). Do not wrap it in an @@ -392,9 +382,7 @@ function build() { const colours = [ /* the caption says what the axis is for: "Tint" alone reads as decoration, and nobody * would look for the router-identity cue under it */ - colourGroup(_('Tint (router identification)', 'footstrap'), { - current: axes.currentTint, apply: axes.applyTint - }, 'var(--fs-bg)', { + colourGroup(_('Tint (router identification)', 'footstrap'), axes.tint, 'var(--fs-bg)', { /* the canvas is the one axis with no derived ink: its text is --fs-text, a palette * token this axis must not move, so the ratio is reported instead of corrected */ fg: 'var(--fs-text)', bg: 'var(--fs-bg)', label: _('on the canvas', 'footstrap') @@ -406,7 +394,7 @@ function build() { * Not called "Density": that is the select above, and this string is both the caption and * the aria-label, so a screen reader would announce two rows under one name. */ group(_('Tint strength', 'footstrap'), - (label) => sliderCtl(axes.currentTintStrength(), 0, 200, bump(repaint(axes.applyTintStrength)), label, { + (label) => sliderCtl(axes.tintStrength.current(), 0, 200, bump(repaint(axes.tintStrength.apply)), label, { step: 5 }), { cls: 'fs-ap-tint fs-ap-tintstr' }), @@ -419,12 +407,12 @@ function build() { * eight times between here and the surfaces below, they cost their repeated literals in * full — a string is not mangled — so the rows are data and the row is stated once. */ ...[ - [ _('Accent', 'footstrap'), axes.currentAccent, axes.applyAccent, 'var(--fs-accent)' ], - [ _('Good', 'footstrap'), axes.currentGood, axes.applyGood, 'var(--fs-good)' ], - [ _('Warning', 'footstrap'), axes.currentWarn, axes.applyWarn, 'var(--fs-warn)' ], - [ _('Danger', 'footstrap'), axes.currentDanger, axes.applyDanger, 'var(--fs-danger)' ] - ].map(([ label, current, apply, ink ]) => - colourGroup(label, { current, apply }, ink, { fg: ink, bg: CARD_BG, label: ON_CARD })) + [ _('Accent', 'footstrap'), axes.accent, 'var(--fs-accent)' ], + [ _('Good', 'footstrap'), axes.good, 'var(--fs-good)' ], + [ _('Warning', 'footstrap'), axes.warn, 'var(--fs-warn)' ], + [ _('Danger', 'footstrap'), axes.danger, 'var(--fs-danger)' ] + ].map(([ label, axis, ink ]) => + colourGroup(label, axis, ink, { fg: ink, bg: CARD_BG, label: ON_CARD })) ]; /* ---- the surfaces: the sheet the UI is drawn on ---- @@ -439,12 +427,12 @@ function build() { /* Same rows, one column wider: a surface reports the ink read ON it, which is --fs-text * for the three that carry body text and the hairline itself for the border. */ ...[ - [ _('Cards', 'footstrap'), axes.currentCard, axes.applyCard, CARD_BG, INK, CARD_BG, ON_CARD ], - [ _('Controls', 'footstrap'), axes.currentControl, axes.applyControl, 'var(--fs-panel2)', INK, 'var(--fs-panel2)', _('on a control', 'footstrap') ], - [ _('Sidebar and bar', 'footstrap'), axes.currentBar, axes.applyBar, 'var(--fs-bar-bg)', INK, 'var(--fs-bar-bg)', _('in the sidebar', 'footstrap') ], - [ _('Borders', 'footstrap'), axes.currentLine, axes.applyLine, 'var(--fs-border)', 'var(--fs-border)', CARD_BG, ON_CARD, 'shape' ] - ].map(([ label, current, apply, probe, fg, bg, where, kind ]) => - colourGroup(label, { current, apply }, probe, { fg, bg, label: where, kind })) + [ _('Cards', 'footstrap'), axes.card, CARD_BG, INK, CARD_BG, ON_CARD ], + [ _('Controls', 'footstrap'), axes.control, 'var(--fs-panel2)', INK, 'var(--fs-panel2)', _('on a control', 'footstrap') ], + [ _('Sidebar and bar', 'footstrap'), axes.bar, 'var(--fs-bar-bg)', INK, 'var(--fs-bar-bg)', _('in the sidebar', 'footstrap') ], + [ _('Borders', 'footstrap'), axes.line, 'var(--fs-border)', 'var(--fs-border)', CARD_BG, ON_CARD, 'shape' ] + ].map(([ label, axis, probe, fg, bg, where, kind ]) => + colourGroup(label, axis, probe, { fg, bg, label: where, kind })) ]; /* ---- section 3: the wallpaper and the rows each value brings ---- @@ -493,30 +481,30 @@ function build() { group(_('Pattern', 'footstrap'), () => E('div', { 'class': 'fs-ap-bgrow' }, [ patChoose, patRemove ]), { extra: [ patInput, patPreview, patErr ] }), - group(scaleLabel, (lbl) => sliderCtl(axes.currentPatternSize(), 40, 1600, - bump(axes.applyPatternSize), lbl, { step: 20 })), - group(strengthLabel, (lbl) => sliderCtl(axes.currentPatternStrength(), 0, 100, - bump(axes.applyPatternStrength), lbl, { step: 5 })), - group(inkLabel, (lbl) => selectCtl(axes.currentPatternInk(), { + group(scaleLabel, (lbl) => sliderCtl(axes.patternSize.current(), 40, 1600, + bump(axes.patternSize.apply), lbl, { step: 20 })), + group(strengthLabel, (lbl) => sliderCtl(axes.patternStrength.current(), 0, 100, + bump(axes.patternStrength.apply), lbl, { step: 5 })), + group(inkLabel, (lbl) => selectCtl(axes.patternInk.current(), { theme: _('Theme', 'footstrap'), original: _('As in file', 'footstrap') - }, bump(axes.applyPatternInk), lbl)) + }, bump(axes.patternInk.apply), lbl)) ]; /* …and the rows the FILE photo brings. */ const fileRows = [ group(_('File', 'footstrap'), () => E('div', { 'class': 'fs-ap-bgrow' }, [ chooseBtn, removeBtn ]), { extra: [ fileInput, preview, err ] }), - group(dimLabel, (lbl) => sliderCtl(axes.currentPhotoDim(), 0, 100, - bump(axes.applyPhotoDim), lbl, { step: 5 })) + group(dimLabel, (lbl) => sliderCtl(axes.photoDim.current(), 0, 100, + bump(axes.photoDim.apply), lbl, { step: 5 })) ]; function reflect(tok) { - if (tok) { preview.src = axes.loginBgUrl(tok); preview.hidden = false; removeBtn.hidden = false; } + if (tok) { preview.src = axes.loginBg.url(tok); preview.hidden = false; removeBtn.hidden = false; } else { preview.removeAttribute('src'); preview.hidden = true; removeBtn.hidden = true; } } function reflectPattern(tok) { - if (tok) { patPreview.src = axes.patternUrl(tok); patPreview.hidden = false; patRemove.hidden = false; } + if (tok) { patPreview.src = axes.pattern.url(tok); patPreview.hidden = false; patRemove.hidden = false; } else { patPreview.removeAttribute('src'); patPreview.hidden = true; patRemove.hidden = true; } } /* `hidden` on the row, which 80-appearance.css restates at a specificity beating @@ -527,11 +515,11 @@ function build() { patRows.forEach((r) => { r.hidden = (v !== 'pattern'); }); fileRows.forEach((r) => { r.hidden = (v !== 'file'); }); } - reflect(axes.currentLoginBg()); - reflectPattern(axes.currentPattern()); - togglePanel(axes.currentWallpaper()); + reflect(axes.loginBg.current()); + reflectPattern(axes.pattern.current()); + togglePanel(axes.wallpaper.current()); - const setWallpaper = (v) => { axes.applyWallpaper(v); refreshSave(); togglePanel(v); refreshColours(); }; + const setWallpaper = (v) => { axes.wallpaper.apply(v); refreshSave(); togglePanel(v); refreshColours(); }; /* Both uploads present the same three controls and the same four states — pick, upload, * report, remove — so the wiring is stated once. What differs is `after`: the pattern also @@ -579,7 +567,7 @@ function build() { let seg; const wallRow = group(_('Wallpaper', 'footstrap'), (label) => { - seg = selectCtl(axes.currentWallpaper(), { + seg = selectCtl(axes.wallpaper.current(), { off: _('Off', 'footstrap'), pattern: _('Pattern', 'footstrap'), file: _('File', 'footstrap') diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-assets.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-assets.js index eea4360a7..78497335a 100644 --- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-assets.js +++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-assets.js @@ -212,20 +212,8 @@ function _sanitizeSvg(text) { * (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. */ - /* Residual, verified NOT exploitable here (2026-09 security review): a prologue of - * `` is a document-level construct this walk - * never visits — `doc.doctype` is left as parsed, the same as everything else here that is not an - * element or an attribute — and it survives `XMLSerializer` byte for byte on the same round trip - * that runs below when anything else on the document needs cleaning. It costs nothing today - * because no shipping engine resolves an external DTD for any document at all, `image/svg+xml` - * included (external entity resolution has been off since the XXE era); a browser that ever did - * would only reach that fetch by opening the file directly, which the CGI answers with - * `Content-Security-Policy: default-src 'none'; sandbox` — every other consumer sees this bundle - * only as a `mask-image`/`background-image`/`` source, none of which parses a DOCTYPE at all. - * Not closed: dropping `doc.doctype` would carve out one more node type in a walk that otherwise - * judges only elements and their attributes, for a fetch nothing in this project's support matrix - * performs. Starts mattering the day some consumer of this bundle DOES resolve an external DTD, or - * the file is ever served as a navigable document without that header. */ + /* An external-DTD DOCTYPE prologue is left unvisited on purpose: verified-safe residual finding, + * docs/architecture.md "The SVG sanitizer's residual, verified-safe findings". */ for (const n of [ ...doc.childNodes ]) if (n.nodeType === Node.PROCESSING_INSTRUCTION_NODE) { n.remove(); elements++; } @@ -265,21 +253,9 @@ function _sanitizeSvg(text) { * one the spec gives them — a change to how SVG itself is interpreted, not something a * check here could see coming or catch. */ if ((/^on[a-z]+$/).test(n)) { el.removeAttribute(a.name); refs++; continue; } - /* Residual, verified NOT exploitable here (2026-09 security review): XML attribute-value - * normalisation does not fold a numeric character reference the way HTML does, so - * `href="javas cript:alert(1)"` reaches `v` as a DOM string with a LITERAL TAB still in - * it — `/^javascript:/i` does not match a value starting with a control character, and the - * fixture round-trips through `XMLSerializer` byte for byte, so it survives both this - * filter and the serializer. It still resolves to `javascript:` in a real browser, because - * a URL parser strips TAB/LF/CR from the scheme before comparing it (WHATWG URL, "scheme - * start state") — the same class of hole every naive `^scheme:` regex has. Not closed: - * matching every control character a URL parser discards is the grammar-guessing this file - * exists to avoid, for a value that never resolves as a URL here in the first place — this - * bundle only ever leaves the router as `mask-image`/`background-image`/``, none of - * which reads `href` as navigable, and the CGI serving it back for a direct open sends - * `Content-Security-Policy: default-src 'none'; sandbox` regardless. Starts mattering the - * day the tile is served without that header, or inlined into a page rather than - * referenced as an image. */ + /* A TAB-obfuscated `javascript:` (`href="javas cript:alert(1)"`) passes this regex: + * verified-safe residual finding, docs/architecture.md "The SVG sanitizer's residual, + * verified-safe findings". */ if ((/^javascript:/i).test(v)) { el.removeAttribute(a.name); refs++; continue; } /* Security review finding (LOW): `xml:base` (XML Base, W3C) is not itself a reference — * it changes what every RELATIVE one in the subtree UNDER IT resolves against. A perfectly @@ -296,22 +272,9 @@ function _sanitizeSvg(text) { * prefix to the XML namespace just to carry it is a narrower attack this does not chase, * consistent with this file avoiding a second grammar it would have to maintain. */ if (n === 'xml:base') { el.removeAttribute(a.name); refs++; continue; } - /* Residual, verified NOT exploitable here (2026-09 security review): the branch below is - * the ONLY place a `url(` is ever inspected. A PRESENTATION ATTRIBUTE that takes the same - * function — `fill`, `stroke`, `filter`, `clip-path`, `mask`, `cursor` (SVG 1.1 §11, §15) — - * is a plain attribute value to this walk, not a CSS declaration, so `` matches nothing above and passes with its ORIGINAL - * bytes untouched. What it can do is start a second fetch — no script, no navigation, - * exactly the reach `_externalStyleRef` already closes for `style` — and that fetch runs - * only where the SVG is rendered with its own resource-loading context, which this bundle - * never is: it is only ever a `mask-image`/`background-image`/`` source, and a direct - * open is answered by the CGI's `Content-Security-Policy: default-src 'none'; sandbox` - * regardless. Not closed: doing here what `_sanitizeStyleValue` does for `style` means the - * same check against every presentation attribute the SVG spec lets carry a `url()`, a list - * this file would then have to track as the spec grows it — the grammar this file exists to - * avoid guessing at, moved from one attribute to a dozen. Starts mattering the day a - * presentation `url()` gets a fetch path this parser-only pass does not already block by - * other means, or the tile stops being image-only. */ + /* A presentation attribute's `url(` (`fill`, `stroke`, `filter`, …) is only checked below + * for `style`, not here: verified-safe residual finding, docs/architecture.md "The SVG + * sanitizer's residual, verified-safe findings". */ if (n === 'style') { const cleaned = _sanitizeStyleValue(v); if (cleaned.removed) { @@ -337,17 +300,6 @@ function _sanitizeSvg(text) { return { text: new XMLSerializer().serializeToString(doc), elements, refs }; } -/* 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 @@ -490,7 +442,7 @@ function assetAxis(o) { .catch((e) => _rollbackUpload(o.path, e)) .then(() => { /* switch this browser onto it: the ordinary axis path, localStorage only */ - axes.applyWallpaper(o.wallpaper); + axes.wallpaper.apply(o.wallpaper); o.apply(tok); return tok; }); @@ -510,13 +462,16 @@ function assetAxis(o) { * 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), + apply: (tok) => axes.pattern.apply(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) => { + /* the catch keeps the same message, so what reaches the router is still exactly the bytes + * that were checked */ + const readErr = () => Promise.reject(new Error(_('That file could not be read.', 'footstrap'))); + return file.text().catch(readErr).then((text) => { const cleaned = _sanitizeSvg(text); if (cleaned.error) return Promise.reject(new Error(cleaned.error)); /* Told once, here, regardless of whether the upload that follows succeeds — what left @@ -535,7 +490,7 @@ const PATTERN = assetAxis({ * grant for BG_PATH. */ const LOGIN_BG = assetAxis({ path: BG_PATH, filename: 'login-bg', field: 'login_bg', wallpaper: 'file', - apply: (tok) => axes.applyLoginBg(tok), + apply: (tok) => axes.loginBg.apply(tok), prepare: (file) => { if (!file || !(/^image\//).test(file.type || '')) return Promise.reject(new Error(_('Please choose an image file.', 'footstrap'))); diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-axes.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-axes.js index fcdccbba7..c6822e864 100644 --- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-axes.js +++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-axes.js @@ -121,7 +121,6 @@ function propAxis(key, sdKey, prop, min, max, dfl, fmt, after) { const PALETTES = [ 'hicontrast', 'bootstrap', '2020', 'forum' ]; /* 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). @@ -134,7 +133,6 @@ const currentPalette = PALETTE.current, applyPalette = PALETTE.apply; * 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 @@ -145,14 +143,12 @@ const currentWallpaper = WALLPAPER.current, applyWallpaper = WALLPAPER.apply; * 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 @@ -197,25 +193,16 @@ function surfaceAxis(key, sdKey, 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; /* Content width (issue #44): how far the reader lets the column grow past --fs-content-max's own * 1280px. A propAxis like Rounding, pointed at that one token — the range comment lives in @@ -228,7 +215,6 @@ const currentRadius = RADIUS.current, applyRadius = RADIUS.apply, radiusDefault const FS_CWIDTH_DEFAULT = 1280; const CWIDTH = propAxis('fs-content-width', 'content_width', '--fs-content-max', 1280, 3840, FS_CWIDTH_DEFAULT, (v) => (v + 'px'), () => fit.schedule()); -const currentContentWidth = CWIDTH.current, applyContentWidth = CWIDTH.apply, contentWidthDefault = CWIDTH.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 @@ -256,14 +242,12 @@ const AXIS_KEYS = [ * 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. @@ -273,14 +257,11 @@ const currentPhotoDim = PDIM.current, applyPhotoDim = PDIM.apply, photoDimDefaul * 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 @@ -294,26 +275,26 @@ 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()), + palette: PALETTE.current(), + wallpaper: WALLPAPER.current(), + tint: String(TINT.current()), + accent: String(ACCENT.current()), + good: String(GOOD.current()), + warn: String(WARN.current()), + danger: String(DANGER.current()), + card: String(CARD.current()), + control: String(CONTROL.current()), + bar: String(BAR.current()), + line: String(LINE.current()), + rounding: String(RADIUS.current()), autocollapse: prefs.currentAutoCollapse() ? 'on' : 'off', - tint_strength: String(currentTintStrength()), + tint_strength: String(TSTR.current()), density: prefs.currentDensity(), - photo_dim: String(currentPhotoDim()), - pattern_size: String(currentPatternSize()), - pattern_strength: String(currentPatternStrength()), - pattern_ink: currentPatternInk(), - content_width: String(currentContentWidth()) + photo_dim: String(PDIM.current()), + pattern_size: String(PSIZE.current()), + pattern_strength: String(PSTR.current()), + pattern_ink: PINK.current(), + content_width: String(CWIDTH.current()) }; } /* The resolved router default (the uci value if set, else the built-in) in snapshotAxes() string @@ -341,15 +322,15 @@ function _resolvedDefault() { control: String(CONTROL.def()), bar: String(BAR.def()), line: String(LINE.def()), - rounding: String(radiusDefault()), + rounding: String(RADIUS.def()), autocollapse: (prefs.autoCollapseDefault() ? 'on' : 'off'), - tint_strength: String(tintStrengthDefault()), + tint_strength: String(TSTR.def()), density: prefs.densityDefault(), - photo_dim: String(photoDimDefault()), - pattern_size: String(patternSizeDefault()), - pattern_strength: String(patternStrengthDefault()), + photo_dim: String(PDIM.def()), + pattern_size: String(PSIZE.def()), + pattern_strength: String(PSTR.def()), pattern_ink: PINK.def(), - content_width: String(contentWidthDefault()) + content_width: String(CWIDTH.def()) }; } let _savedDefault = _resolvedDefault(); @@ -405,26 +386,26 @@ function resetToBuiltin() { * 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); + PALETTE.apply('footstrap'); + WALLPAPER.apply('off'); + TINT.apply(0); + ACCENT.apply(0); + GOOD.apply(0); + WARN.apply(0); + DANGER.apply(0); + CARD.apply(0); + CONTROL.apply(0); + BAR.apply(0); + LINE.apply(0); + RADIUS.apply(FS_RADIUS_DEFAULT); prefs.applyAutoCollapse('off'); - applyTintStrength(FS_TSTR_DEFAULT); + TSTR.apply(FS_TSTR_DEFAULT); prefs.applyDensity('normal'); - applyPhotoDim(FS_PDIM_DEFAULT); - applyPatternSize(FS_PSIZE_DEFAULT); - applyPatternStrength(FS_PSTR_DEFAULT); - applyPatternInk('theme'); - applyContentWidth(FS_CWIDTH_DEFAULT); + PDIM.apply(FS_PDIM_DEFAULT); + PSIZE.apply(FS_PSIZE_DEFAULT); + PSTR.apply(FS_PSTR_DEFAULT); + PINK.apply('theme'); + CWIDTH.apply(FS_CWIDTH_DEFAULT); } /* ---- the two uploaded wallpapers, browser side ---- @@ -438,31 +419,6 @@ function resetToBuiltin() { * 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. */ -/* NOT a /www symlink, unlike BG_SERVE below: an SVG served as a plain static file is a same-origin - * document that runs script opened directly (OpenWrt forum thread 251930). This CGI handler reads - * the fixed /etc/footstrap/pattern.svg path itself and answers with CSP 'none' + sandbox and - * nosniff, headers uhttpd cannot attach to a static file — see the uci-default and the handler for - * the rest of the reasoning. Painting stays exactly as before: a URL is a URL to a mask-image. */ -const PAT_SERVE = '/cgi-bin/luci-theme-footstrap-pattern'; -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 || ''); -} - -/* Still the plain uhttpd symlink the uci-default makes, unlike PAT_SERVE above: fs-assets.js - * re-encodes every login-bg upload to a JPEG on a canvas before it is sent, so the stored bytes - * are pixels only — there is no script grammar left in a raster for a direct hit to run. */ -const BG_SERVE = '/luci-static/footstrap/bg'; /* 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. */ @@ -471,45 +427,79 @@ const BG_TOKEN_RE = /^[a-f0-9]{6,64}$/; * 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 || ''); +/* The pattern and the login-bg photo are the same shape twice: a router-saved cache-bust TOKEN, + * read back through prefs.sd(), that sets or clears one url() custom property. Neither is an axis + * (see the comment above) — this factory is what a bare url() setter shares with a real axis's + * apply(), not membership in AXIS_KEYS. `serve` differs for a reason stated at each call below. */ +function tokenAsset(sdKey, serve, prop) { + const current = () => { + const t = prefs.sd(sdKey); + return (typeof t === 'string' && BG_TOKEN_RE.test(t)) ? t : ''; + }; + const url = (tok) => serve + '?v=' + tok; + /* set/clear the url() live; whether it PAINTS is the Wallpaper axis (data-wallpaper="pattern" + * or "file"). Exported because fs-assets.js applies the token it just wrote. */ + const apply = (tok) => { + const root = document.documentElement; + if (tok) root.style.setProperty(prop, 'url("' + url(tok) + '")'); + else root.style.removeProperty(prop); + prefs.setSD(sdKey, tok || ''); + }; + return { current, url, apply }; } +/* NOT a /www symlink, unlike LOGIN_BG's plain static path below: an SVG served as a plain static + * file is a same-origin document that runs script opened directly (OpenWrt forum thread 251930). + * This CGI handler reads the fixed /etc/footstrap/pattern.svg path itself and answers with CSP + * 'none' + sandbox and nosniff, headers uhttpd cannot attach to a static file — see the uci-default + * and the handler for the rest of the reasoning. Painting stays exactly as before: a URL is a URL + * to a mask-image. */ +const PATTERN = tokenAsset('pattern', '/cgi-bin/luci-theme-footstrap-pattern', '--fs-pattern-url'); + +/* Still the plain uhttpd symlink the uci-default makes, unlike PATTERN's CGI handler above: + * fs-assets.js re-encodes every login-bg upload to a JPEG on a canvas before it is sent, so the + * stored bytes are pixels only — there is no script grammar left in a raster for a direct hit to + * run. */ +const LOGIN_BG = tokenAsset('login_bg', '/luci-static/footstrap/bg', '--fs-login-bg-url'); + +/* Every axis exported as the OBJECT itself — `{ current, apply, def? }` — not a flat + * currentX()/applyX() pair per axis: a caller wanting both already had to carry them as a pair + * (fs-appearance.js's colourGroup() takes `{ current, apply }`, built ad hoc from two separate + * lookups before this), and a flat name is one more thing every consumer of THIS FILE has to keep + * spelled the same way fs-axes.js does — 18 axis names doubled to 36 lookups, now 18. `pattern` + * and `loginBg` are not axes (no AXIS_KEYS entry, no router-default `def()`) but the same + * `tokenAsset()` shape, so they export the same way. `tokenOk`, `matchesSavedDefault`, + * `saveAsDefault`, `resetToSaved` and `resetToBuiltin` stay flat: none of them is a + * `{current, apply}` pair. `snapshotAxes` stays module-private: fs-appearance.js, its only would-be + * caller, builds the Appearance form from the per-axis exports above, not from the router-default + * snapshot matchesSavedDefault()/saveAsDefault() read internally. + * + * No flat `currentX`/`applyX` fallback kept: grepped `../luci-app-footstrap-*` (the sibling + * checkouts) and this repo's docs for either spelling — nothing outside fs-appearance.js and + * fs-assets.js, both updated alongside this file, ever called one. */ 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, - currentContentWidth, applyContentWidth, contentWidthDefault, + palette: PALETTE, + wallpaper: WALLPAPER, + tint: TINT, + accent: ACCENT, + good: GOOD, + warn: WARN, + danger: DANGER, + card: CARD, + control: CONTROL, + bar: BAR, + line: LINE, + radius: RADIUS, + tintStrength: TSTR, + photoDim: PDIM, + patternSize: PSIZE, + patternStrength: PSTR, + patternInk: PINK, + contentWidth: CWIDTH, + pattern: PATTERN, + loginBg: LOGIN_BG, - currentPattern, patternUrl, applyPattern, - currentLoginBg, loginBgUrl, applyLoginBg, tokenOk, - snapshotAxes, matchesSavedDefault, saveAsDefault, resetToSaved, resetToBuiltin + matchesSavedDefault, saveAsDefault, resetToSaved, resetToBuiltin }); diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-chrome.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-chrome.js index dd39d65f3..53e580447 100644 --- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-chrome.js +++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-chrome.js @@ -4,6 +4,7 @@ 'require fs-fit as fit'; 'require fs-prefs as prefs'; 'require fs-menutree as tree'; +'require fs-widgets as widgets'; /* The chrome around the content: the mode menu, the section tabs, the rail toggle and the * measurements deciding how much room each gets. The main menu is injected by menu-footstrap.js as @@ -18,11 +19,8 @@ function setRenderMain(fn) { /* section tabs -> #tabmenu (horizontal) */ function renderTabMenu(node, url, level) { + /* #tabmenu is emitted whenever this module loads (notices.ut, !blank_page) */ const container = document.querySelector('#tabmenu'); - /* a template without the container must not reject: an unhandled rejection here kills the - * whole ui.menu.load() chain, i.e. every menu */ - if (!container) - return E([]); const ul = E('ul', { 'class': 'tabs' }); const children = ui.menu.getChildren(node); let activeNode = null; @@ -39,15 +37,13 @@ function renderTabMenu(node, url, level) { }); if (ul.children.length === 0) - return E([]); + return; container.appendChild(ul); container.style.display = ''; if (activeNode) renderTabMenu(activeNode, url + '/' + activeNode.name, (level || 0) + 1); - - return ul; } /* ---- tab-strip auto-fit ---- @@ -150,17 +146,16 @@ function shellGeometry() { /* the gutter is re-asked even on a memo hit: it moves with the width, not the density */ if (_geom && _geomDensity === key) return _geom; _geomDensity = key; - const px = (name, dflt) => resolveLen(name, dflt); const g = { - contentMin: px('--fs-content-min', GEOM_DFLT.contentMin), - sidebarW: px('--fs-sidebar-w', GEOM_DFLT.sidebarW), - railW: px('--fs-rail-w', GEOM_DFLT.railW), + contentMin: resolveLen('--fs-content-min', GEOM_DFLT.contentMin), + sidebarW: resolveLen('--fs-sidebar-w', GEOM_DFLT.sidebarW), + railW: resolveLen('--fs-rail-w', GEOM_DFLT.railW), /* the token is one side's padding; the column loses it twice. It is only the fallback — * measureShell() overwrites this with the gutter the column actually got, which nothing * has measured before the first fitter (and the login page has no `.fs-content`). */ - contentPad: px('--fs-content-pad', GEOM_DFLT.contentPad / 2) * 2, + contentPad: resolveLen('--fs-content-pad', GEOM_DFLT.contentPad / 2) * 2, /* where the column stops growing, i.e. where surplus becomes margin — see columnWidth() */ - contentMax: px('--fs-content-max', GEOM_DFLT.contentMax) + contentMax: resolveLen('--fs-content-max', GEOM_DFLT.contentMax) }; /* Plausibility, at the cost of one comparison: the rail is the sidebar collapsed, so * 0 < railW < sidebarW holds by construction. Both known failures destroy it — a hijacked probe @@ -401,6 +396,7 @@ function clusterFitsBrandRow(bar, menu) { /* modes -> #modemenu; drives the injected renderMainMenu for the active mode */ function renderModeMenu(node, renderMainMenu) { + /* #modemenu is emitted whenever this module loads (header.ut, !blank_page) */ const ul = document.querySelector('#modemenu'); const children = ui.menu.getChildren(node); @@ -409,18 +405,14 @@ function renderModeMenu(node, renderMainMenu) { ? child.name === L.env.requestpath[0] : index === 0; - /* the main menu must render even where a template has no #modemenu */ - if (ul) - ul.appendChild(E('li', { 'class': isActive ? 'active' : '' }, [ - E('a', { 'href': L.url(child.name) }, [ _(child.title) ]) - ])); + ul.appendChild(E('li', { 'class': isActive ? 'active' : '' }, [ + E('a', { 'href': L.url(child.name) }, [ _(child.title) ]) + ])); if (isActive) renderMainMenu(child, child.name); }); - if (!ul) - return; if (children.length <= 1) ul.classList.add('single'); if (ul.children.length > 1) @@ -431,13 +423,15 @@ function renderModeMenu(node, renderMainMenu) { * every SPA nav. Containers are cleared first so a re-render does not stack duplicates. */ function renderChrome() { const root = tree.tree(); + /* #modemenu/#topmenu/#tabmenu are emitted whenever this module loads (header.ut/notices.ut, + * !blank_page) */ const modemenu = document.querySelector('#modemenu'); const topmenu = document.querySelector('#topmenu'); const tabmenu = document.querySelector('#tabmenu'); - if (modemenu) { modemenu.innerHTML = ''; modemenu.style.display = 'none'; modemenu.classList.remove('single'); } - if (topmenu) topmenu.innerHTML = ''; - if (tabmenu) { tabmenu.innerHTML = ''; tabmenu.style.display = 'none'; } + modemenu.innerHTML = ''; modemenu.style.display = 'none'; modemenu.classList.remove('single'); + topmenu.innerHTML = ''; + tabmenu.innerHTML = ''; tabmenu.style.display = 'none'; renderModeMenu(root, _renderMain); @@ -461,8 +455,8 @@ function renderChrome() { * (head.ut re-applies it before paint) and in localStorage; everything else is * CSS keyed off that attribute. */ function wireRail() { + /* #fs-rail-toggle is emitted whenever this module loads (header.ut, !blank_page) */ const btn = document.getElementById('fs-rail-toggle'); - if (!btn) return; function sync() { const on = prefs.currentRail(); @@ -494,38 +488,23 @@ function wireRail() { * stays in the label for screen readers, and in `title` for the pointer. */ const IND_DOT = '•'; -/* Idempotent attribute write, so a poll tick that finds nothing changed touches no DOM — same - * shape as `fsSyncAttr` in menu-footstrap-common.js, restated rather than imported (that file does - * not export it). */ -function syncIndAttr(el, name, value) { - if (value === null) { - if (el.hasAttribute(name)) el.removeAttribute(name); - } else if (el.getAttribute(name) !== value) { - el.setAttribute(name, value); - } -} - /* A CLICKABLE `[data-indicator]` (the poll pill, "Unsaved Changes: N", …) ships as a bare * span: no role, name or tabindex, so Tab skips it and a screen reader * announces a run of text with no name, role or state (WCAG 2.1.1, 4.1.2). ui.showIndicator's own * click handler already lives on this exact element — this only adds the second, W3C-APG way to * reach it; it does not add a competing one. The name is the pill's own prose ("Refreshing"), - * never invented. Enter/Space call el.click() because a , unlike an , gets neither key - * for free (contrast fs-widgets.js's wireSpaceKey, written for an ); calling - * click() cannot double-fire the mouse handler, since a keydown is not a click. */ + * never invented. widgets.wireActivate() calls el.click() because a , unlike an , gets + * neither Enter nor Space for free; calling click() cannot double-fire the mouse handler, since a + * keydown is not a click. */ function wireIndicatorKeyboard(el) { if (el.dataset.fsWired) return; el.dataset.fsWired = '1'; - el.addEventListener('keydown', (ev) => { - if (ev.key !== 'Enter' && ev.key !== ' ' && ev.key !== 'Spacebar') return; - ev.preventDefault(); - el.click(); - }); + widgets.wireActivate(el, () => el.click()); } function wireIndicatorCounts() { + /* #indicators is emitted whenever this module loads (header.ut, !blank_page) */ const box = document.getElementById('indicators'); - if (!box) return; function stamp() { box.querySelectorAll('[data-indicator]').forEach((el) => { @@ -541,9 +520,9 @@ function wireIndicatorCounts() { * replaces. The ring and the pointer cursor in theme/20-shell.css are scoped to * `[data-clickable]` for the same reason; the two must not disagree. */ if (el.hasAttribute('data-clickable')) { - syncIndAttr(el, 'role', 'button'); - syncIndAttr(el, 'tabindex', '0'); - syncIndAttr(el, 'aria-label', txt.trim() || null); + widgets.syncAttr(el, 'role', 'button'); + widgets.syncAttr(el, 'tabindex', '0'); + widgets.syncAttr(el, 'aria-label', txt.trim() || null); wireIndicatorKeyboard(el); } }); diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-fit.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-fit.js index 67224e46e..568332eae 100644 --- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-fit.js +++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-fit.js @@ -65,23 +65,34 @@ function holdFloor(records) { if (scrolling()) return; const host = document.getElementById('view'); if (!host) return; /* the login page has no view */ - const boxes = [], hs = []; + /* boxes stays an array — order feeds the parallel `hs` below and dirty's own .filter() — with a + * Set alongside it purely for the O(1) "already have this one" check .indexOf() was doing in + * O(n). */ + const boxes = [], boxSeen = new Set(), hs = []; host.querySelectorAll(SHRINKS).forEach((el) => { let box = el, cs = window.getComputedStyle(el); while (box && box !== host && cs.display.startsWith('table')) { box = box.parentElement; if (box) cs = window.getComputedStyle(box); } - if (!box || box === host || boxes.indexOf(box) !== -1) return; + if (!box || box === host || boxSeen.has(box)) return; + boxSeen.add(box); + boxes.push(box); + }); + host.querySelectorAll(FLOORED).forEach((box) => { + if (boxSeen.has(box)) return; + boxSeen.add(box); boxes.push(box); }); - host.querySelectorAll(FLOORED).forEach((box) => { if (boxes.indexOf(box) === -1) boxes.push(box); }); let dirty = boxes; if (records && records.length) { - const targets = []; - for (const r of records) if (r.target && targets.indexOf(r.target) === -1) targets.push(r.target); - dirty = boxes.filter((box) => targets.some((t) => box.contains(t) || t.contains(box))); + const targets = new Set(); + for (const r of records) if (r.target) targets.add(r.target); + dirty = boxes.filter((box) => { + for (const t of targets) if (box.contains(t) || t.contains(box)) return true; + return false; + }); } if (!dirty.length) return; @@ -363,14 +374,28 @@ function anchorRef() { sec: keep, secTop: keep ? keep.getBoundingClientRect().top : 0 }; } -let _anchorPending = null; -let _anchorWhy = null; -const _anchorTrail = []; -function awhy(w) { - _anchorWhy = w; - _anchorTrail.push(w + '@' + Math.round(performance.now())); - if (_anchorTrail.length > 8) _anchorTrail.shift(); +/* Two independent diagnostic trails — one per correction path (the frame-coalesced anchor below, + * the late drift correction further down) — same shape twice: the last reason recorded, and the + * last 8 with a timestamp, for tools/scroll-anchor.mjs to read back after a probe run. One factory, + * so the cap and the timestamp format cannot drift between the two. */ +const TRAIL_MAX = 8; +function makeTrail() { + let last = null; + const entries = []; + return { + record(w) { + last = w; + entries.push(w + '@' + Math.round(performance.now())); + if (entries.length > TRAIL_MAX) entries.shift(); + }, + last: () => last, + entries: () => entries + }; } + +let _anchorPending = null; +const _anchor = makeTrail(); +function awhy(w) { _anchor.record(w); } let _anchorFrame = 0; function anchorEnabled() { try { return localStorage.getItem('fsAnchor') !== 'off'; } @@ -380,14 +405,8 @@ function anchorEnabled() { * It keeps a reference still; it does not promise that a section can vanish and come back. An * older WebKit moved 180px for 120px of growth, so the element is asked where it is now. */ let _lateFrame = 0; -/* why the last late correction did or did not write — eight exits, one symptom from outside */ -let _lateWhy = null; -const _lateTrail = []; -function why(w) { - _lateWhy = w; - _lateTrail.push(w + '@' + Math.round(performance.now())); - if (_lateTrail.length > 8) _lateTrail.shift(); -} +const _late = makeTrail(); +function why(w) { _late.record(w); } function lateDrift(ref, grow, floorShrink) { if (_lateFrame) return why('busy'); @@ -607,11 +626,11 @@ return baseclass.extend({ }, scrolling, - /* unmarked, for tools/scroll-anchor.mjs — see `_lateWhy` */ - lateWhy: () => _lateWhy, - lateTrail: () => _lateTrail.slice(), - anchorWhy: () => _anchorWhy, - anchorTrail: () => _anchorTrail.slice(), + /* unmarked, for tools/scroll-anchor.mjs — see `makeTrail()` above */ + lateWhy: () => _late.last(), + lateTrail: () => _late.entries().slice(), + anchorWhy: () => _anchor.last(), + anchorTrail: () => _anchor.entries().slice(), deferMeasurement, /* -> the offset this file last took a reference at, so a probe does not measure the guard instead diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-menutree.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-menutree.js index c7e870c91..cb1427d87 100644 --- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-menutree.js +++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-menutree.js @@ -19,7 +19,7 @@ function setTree(tree) { * reserved for a path outside LuCI's scriptname. */ function segsFromPath(pathname) { const base = L.env.scriptname || ''; - if (base && pathname.indexOf(base) !== 0) + if (base && !pathname.startsWith(base)) return null; const rest = pathname.slice(base.length).replace(/^\/+|\/+$/g, ''); return rest.length ? rest.split('/') : []; @@ -159,10 +159,6 @@ function currentNode() { return baseclass.extend({ setTree, tree: () => _tree, - /* raw presence, no alias or firstchild resolution: fs-commands gates each command on the menu - * node whose `depends.acl` names the group that command needs, and a node that resolves - * elsewhere would answer for a permission the session may not hold */ - nodeForSegs, segsFromPath, currentNode, resolveSegs, diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-overview.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-overview.js index 92beac33a..6d97450b1 100644 --- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-overview.js +++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-overview.js @@ -3,6 +3,7 @@ 'require dom'; 'require network'; 'require fs-fit as fit'; +'require fs-widgets as widgets'; 'require menu-footstrap-common as common'; /* Overview layout only: renders nothing of its own, it re-arranges the STOCK System / Memory / @@ -21,8 +22,6 @@ /* section title -> grid role. _() with no msgctxt on purpose: these must resolve to exactly what * luci-mod-status resolves to, or the titles stop matching. Built once, not per poll tick. */ const ROLES = { [_('System')]: 'sys', [_('Memory')]: 'mem', [_('Storage')]: 'sto' }; -/* the data-page value four call sites compare against; a string literal is not mangled, so a - * repeat is paid in full on flash every time (measured: 24 B x4 -> 37 B, 59 B saved) */ function headerEl(sec) { /* two title markups, one per release: 25.12 wraps the heading (`.cbi-title > h3`), 24.10 emits @@ -50,23 +49,11 @@ function sectionTitle(sec) { * topbar poll pill plus one Hide/Show toggle per card — and every one is a bare : no * tabindex, no role, no aria-expanded. Tab never reaches one and a screen reader announces a run * of text with no name, role or state. WCAG 2.1.1 Keyboard (A), 4.1.2 Name, Role, Value (A). - * docs/findings.md, "A card cannot be collapsed from the keyboard". * * index.js's own pill keeps doing the actual show/hide — untouched, so mouse behaviour for anyone * clicking it does not change. The header becomes a second, W3C-APG way to reach the SAME handler, * not a competing one. */ -/* Idempotent attribute write, so a poll tick that finds nothing changed touches no DOM and fires - * no mutation record. Same shape as `fsSyncAttr` in menu-footstrap-common.js — restated rather than - * imported, since that file does not export it. */ -function syncAttr(el, name, value) { - if (value === null) { - if (el.hasAttribute(name)) el.removeAttribute(name); - } else if (el.getAttribute(name) !== value) { - el.setAttribute(name, value); - } -} - /* index.js's own attribute: "inactive" is expanded (the pill reads "Hide"), "active" is collapsed * (it reads "Show") — the chevron mirror in pages/20-overview.css reads the same attribute the * same way. */ @@ -96,14 +83,14 @@ function wireDisclosure(sec) { const panel = panelFor(h); if (panel && !panel.id) panel.id = 'fs-ovl-panel-' + (_panelSeq++); - syncAttr(h, 'role', 'button'); - syncAttr(h, 'tabindex', '0'); - syncAttr(h, 'aria-controls', panel ? panel.id : null); - syncAttr(h, 'aria-expanded', pillExpanded(label) ? 'true' : 'false'); + widgets.syncAttr(h, 'role', 'button'); + widgets.syncAttr(h, 'tabindex', '0'); + widgets.syncAttr(h, 'aria-controls', panel ? panel.id : null); + widgets.syncAttr(h, 'aria-expanded', pillExpanded(label) ? 'true' : 'false'); /* the header now carries the pill's name, role and state; a screen reader user tabbing past it * to a second, unlabelled clickable span would hear an unexplained duplicate control — same * reasoning as the aria-hidden on svgIcon()'s output, fs-widgets.js:12 */ - syncAttr(label, 'aria-hidden', 'true'); + widgets.syncAttr(label, 'aria-hidden', 'true'); if (h.dataset.fsWired) return; h.dataset.fsWired = '1'; @@ -112,16 +99,12 @@ function wireDisclosure(sec) { /* a click landing on the pill itself already ran index.js's own handler; forwarding here * too would toggle the card twice */ if (ev.target.closest?.('[data-indicator="poll-status"]') !== label) label.click(); - syncAttr(h, 'aria-expanded', pillExpanded(label) ? 'true' : 'false'); + widgets.syncAttr(h, 'aria-expanded', pillExpanded(label) ? 'true' : 'false'); }); /* the pill is an -less , so neither key is native here — contrast - * fs-widgets.js's wireSpaceKey, written for an , which gets Enter for free - * and needs only Space added */ - h.addEventListener('keydown', (ev) => { - if (ev.key !== 'Enter' && ev.key !== ' ' && ev.key !== 'Spacebar') return; - ev.preventDefault(); - label.click(); - }); + * menu-footstrap.js's wireSpaceKey, written for an , which gets Enter for + * free and needs only Space added */ + widgets.wireActivate(h, () => label.click()); } /* A `.cbi-section` LuCI still renders when a stock include has nothing to show this tick: title @@ -216,15 +199,13 @@ function arrange() { _wrapEl = wrap; } -/* Stock sections render async and repaint every poll, so watch #view and re-run arrange(), - * coalesced and one observer per #view node. The SPA router may replace #view between visits, so - * re-attach when the observed node is no longer the current one — a singleton bound to the first - * #view would watch a detached tree and the grid would never apply again. */ -let _observer = null, _observedRoot = null, _routeObserver = null; +/* Stock sections render async and repaint every poll, so watch #maincontent and re-run arrange(), + * coalesced. #maincontent (unlike #view) outlives every SPA swap, so one observer, attached once, + * is never stale and never needs to be re-attached. */ +let _observer = null, _routeObserver = null; function stopWatch() { if (_observer) _observer.disconnect(); _observer = null; - _observedRoot = null; _wrapEl = null; /* the grid belongs to the #view we are leaving */ } function watch() { @@ -234,16 +215,12 @@ function watch() { * first — so the observer bound here read `isConnected: false` after one round trip and the * grid stopped being re-arranged on every poll tick, silently, until the next full load. The * shell's column outlives every swap. Same fault, same fix as fs-appearance.js. */ - const root = document.getElementById('maincontent') || view; - if (_observer && _observedRoot !== root) - stopWatch(); + const root = document.getElementById('maincontent'); arrange(); - /* a chrome module is alive on every page, so without the route check an observer would attach - * to #view on, say, the firewall page and re-run arrange() for every table mutation */ - if (_observer || !view || - (document.body.getAttribute('data-page') || '') !== 'admin-status-overview') + /* the caller (onOverview(), via wire()'s data-page observer) already confirmed the overview + * route before reaching here; !view alone covers the mid-navigation race above */ + if (_observer || !view) return; - _observedRoot = root; /* one arrange() per frame, however many mutations a poll tick delivers (fit.frame — the * theme's shared coalescer, fs-fit.js) */ _observer = new MutationObserver(fit.frame(arrange)); diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-prefs.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-prefs.js index 5d3070bfc..6aa6f0481 100644 --- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-prefs.js +++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-prefs.js @@ -1,6 +1,5 @@ 'use strict'; 'require baseclass'; -'require rpc'; 'require fs-fit as fit'; /* The Appearance axes this file owns; the controls that present them are fs-appearance.js. The @@ -44,13 +43,15 @@ function lsGetArr(k) { } /* the router-wide defaults the server stamped (head.ut), read at runtime so current*() reports the - * effective default when this browser has no localStorage */ -function sd(k) { try { return (window.__fsSD || {})[k]; } catch (e) { return undefined; } } + * effective default when this browser has no localStorage. No try/catch: unlike localStorage, + * reading or writing a plain object property cannot throw, and window.__fsSD is always one (head.ut + * stamps it as an object literal). */ +function sd(k) { return (window.__fsSD || {})[k]; } /* …and the write back: an applier that persists to the router must update the blob the server * stamped, or current*() keeps reporting the old router default until the next full load and * matchesSavedDefault() lies about whether anything is left to save */ -function setSD(field, val) { try { (window.__fsSD = window.__fsSD || {})[field] = val; } catch (e) {} } +function setSD(field, val) { (window.__fsSD = window.__fsSD || {})[field] = val; } /* ---- every axis owns its ROUTER DEFAULT, and nothing else may restate it ---- * `def()` is the sd() branch of current() alone: the effective value with no localStorage. Exposed @@ -65,7 +66,6 @@ function currentMode() { const s = lsGet('fs-darkmode'); if (s === 'true') return 'dark'; if (s === 'false') return 'light'; - if (s === 'auto') return 'auto'; if (s === null) return modeDefault(); return 'auto'; } @@ -169,18 +169,11 @@ function paintThemeColor() { } function watchThemeColor() { - let queued = false; - const paint = () => { - queued = false; - paintThemeColor(); - }; - const schedule = () => { - if (queued) return; - queued = true; - window.requestAnimationFrame(paint); - }; paintThemeColor(); - new MutationObserver(schedule).observe(document.documentElement, + /* fit.frame() is the same next-frame coalescer every geometry fitter schedules through: the + * tint and strength sliders write on every input event, and getComputedStyle forces style + * resolution, so a dozen mutations in one frame must still repaint the meta tag once. */ + new MutationObserver(fit.frame(paintThemeColor)).observe(document.documentElement, { attributes: true, attributeFilter: [ 'style', 'class', 'data-darkmode', 'data-palette', 'data-wallpaper' ] }); } @@ -192,28 +185,6 @@ _mqDark.addEventListener('change', () => { stampDark(root, intendedDark()); } }); -/* Corner radius: the card radius (0–20px) as an inline --fs-radius-base on :root, from which - * 02-tokens derives every other radius. head.ut pre-paints it and tools/axes.mjs holds JS/CSS/head - * to this one number, hence the named const. */ - -/* ---- the four axis shapes, each written once ---- - * - * Sixteen axes are four shapes, so the shape lives in a factory and each instance is one line: - * enumAxis (pattern ink), colorAxis (tint, accent, good, warn, danger), surfaceAxis (cards, - * controls, bar, borders), propAxis (rounding, tint strength, photo dim, pattern size, pattern - * strength, content width). Same contract throughout: `current()` is localStorage ?? def(), `def()` is the router - * default alone, `apply()` stores the choice explicitly. None use `this` — every export is a - * detached method reference, so a `this` here would throw on the first call. - * - * Each factory takes its localStorage key as the first argument, and tools/axes.mjs matches the - * call by its literal args: an axis built by a factory has no lsGet('fs-…') call site for the gate - * to find. - * - * The remaining axes stay separate, each with a quirk a shared table would need an option for: - * `mode` stores a value it does not apply and owns an MQL listener, `layout` reads the attribute, - * `wallpaper` and `density` are three-valued, `palette` outgrew the two-value shape when the third - * one landed, `autoCollapse` has no :root attribute. */ - /* An axis whose values are a list, with one of them stamped as nothing. * * `values` are the names that become `attr=""`; `dflt` is the one that leaves :root bare, and @@ -235,7 +206,6 @@ function listAxis(key, attr, values, dflt, after) { current() { const s = lsGet(key); if (ok(s)) return s; - if (s === dflt) return dflt; if (s === null) return def(); return dflt; /* a stray value reads as the built-in default */ }, @@ -258,22 +228,6 @@ function enumAxis(key, attr, on, off) { return listAxis(key, attr, [ on ], off); } -/* A colour axis — Tint, Accent and the three status colours are one axis pointed at five tokens: - * same validation, same "0 is off", same ordering rule (set the custom property BEFORE the - * attribute, or a fresh load paints one frame in the previous colour). - * - * A value is one of three things, and the attribute says which (03-palettes.css matches on it): - * - * 0 off — no attribute, the palette as it shipped - * 1–360 a HUE: CSS rotates the palette's own colour through oklch(from … l c H), so - * lightness, chroma and every contrast margin stay the palette's - * '#rrggbb' a COLOUR, stamped inline on :root as the live token; the ink over it is derived - * from its lightness in CSS - * - * Both live in one localStorage key rather than a colour key beside a hue key: two keys would need - * a third to say which is in effect, and that third is the one a pre-paint script forgets. - * `hueProp` carries the degrees, `colorProp` the live token a hex value overwrites; each mode - * clears the other's property, so the two can never both be half-applied. */ const DENSITIES = [ 'compact', 'large' ]; /* the two non-default values; 'normal' = bare :root */ const DENSITY = listAxis('fs-density', 'data-density', DENSITIES, 'normal', () => fit.schedule()); const currentDensity = DENSITY.current, applyDensity = DENSITY.apply, @@ -283,11 +237,6 @@ const currentDensity = DENSITY.current, applyDensity = DENSITY.apply, * Density it sets no attribute, so nothing here or in fs-chrome.js needs to read one, and * currentContentWidth()/applyContentWidth() have no caller outside the Appearance tab — the one * thing that kept Density's shape in this cold-path file. */ -/* Background-tint axis: the canvas the cards float on (--fs-bg), so a whole install reads as one - * colour and a tab or a screenshot says which router it belongs to. Cards, chrome and the status - * colours keep the palette's values — the cue colours the paper, not the UI. On a hue it is mixed - * in CSS (03-palettes.css explains why that stays contrast-safe at every angle); on a hex it IS the - * canvas. 0 is off rather than red, a hue wheel wrapping, so one end of the range is free. */ function currentLayout() { return document.documentElement.getAttribute('data-layout') === 'top' ? 'top' : 'sidebar'; } @@ -342,23 +291,6 @@ function currentRail() { return document.documentElement.getAttribute('data-rail') === 'true'; } -/* ---- Save to router: write the current effective axes to /etc/config/footstrap ---- - * The scoped rpcd ACL (config 'footstrap' only) lets the admin's session set and commit those - * options; rpcd validates the config/section/option names, so no value reaches a shell. The server - * reads them back on the next load and head.ut's sanitiser clamps each before it becomes - * window.__fsSD. - * - * snapshotAxes() reads the effective values, which already fold in this browser's localStorage, so - * Save captures what the user sees. It does not touch localStorage: this browser keeps overriding, - * and the saved default is for other devices. resetToSaved() drops this browser back onto it. */ -/* Every saved axis's localStorage key: what Save-as-default clears and what a reset walks. - * - * Tried as one table of [key, field, def] with the resolved defaults derived from it: correct, - * and 188 B larger after minification — three lists of short literals compress better than - * twenty-one rows of data, because a function name is mangled and a row is not. The copies are - * held together by tools/axes.mjs instead, which reads snapshotAxes()'s body and holds every - * field against header.ut's FS_AXES. */ - return baseclass.extend({ /* the storage wrappers and the router-default reader: fs-axes.js is built on these */ lsGet, lsSet, lsDel, lsGetArr, storageBroken, sd, setSD, diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-router.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-router.js index 73e134d12..c68f02e9a 100644 --- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-router.js +++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-router.js @@ -43,9 +43,9 @@ const _viewIntervals = (window.__fsViewIntervals || (window.__fsViewIntervals = * keyed by that first id and the entry carries `live`, the id armed right now (null while * paused), so a view holding its handle can still stop its own poller after a trip through a * hidden tab. The arguments are kept because a `setInterval` id carries none of them back. */ - window.setInterval = function (fn, ms) { + window.setInterval = function (fn, ms, ...rest) { const id = _si.apply(window, arguments); - _viewIntervals.set(id, { fn, ms, rest: Array.prototype.slice.call(arguments, 2), live: id }); + _viewIntervals.set(id, { fn, ms, rest, live: id }); return id; }; window.clearInterval = function (id) { @@ -102,28 +102,19 @@ const _viewIntervals = (window.__fsViewIntervals || (window.__fsViewIntervals = * * `L.Poll.timer` is that id, and it is private state: `add`/`remove`/`start`/`stop`/`active` are the * documented surface, and the whole `L.Poll` alias is already deprecated (`'require poll'` replaces - * it, but no supported release ships poll.js yet). Read blind, a renamed field would make LuCI's - * tick look like a view's — cleared on the next navigation, every poll on every later page silently - * dead. So a missing field is a reason to do nothing, once, loudly. - * - * Asked through the documented half first: `active()` says whether the tick is running, and `timer` - * is deleted by `stop()`, so an absent field is the ordinary "nothing to protect" case. The anomaly - * worth reporting is the pair disagreeing — a tick running while the id it runs on has no name we - * know. - * - * The alias itself is guarded for the same reason: the sweep runs inside the staged render, and a - * TypeError there would leave every click showing the previous page's content under the new page's - * title. */ + * it, but no supported release ships poll.js yet). `active()` below is called unguarded: this + * caller runs from the visibilitychange listener at module eval (:69-81), before CONTRACT_FNS ever + * checks anything, so what backs it is the floor, not the boot contract — `L.Poll.active` is + * unconditional in every 24.10+ luci.js. Read blind, a renamed `timer` field would make LuCI's tick + * look like a view's — cleared on the next navigation, every poll on every later page silently + * dead. So a missing field is a reason to do nothing, once, loudly: `active()` says whether the + * tick is running, and `timer` is deleted by `stop()`, so an absent field is the ordinary "nothing + * to protect" case. The anomaly worth reporting is the pair disagreeing — a tick running while the + * id it runs on has no name we know. */ /* -> the tick's id; null when LuCI is not polling; false when the two cannot be told apart, which * every caller reads as "leave every interval alone" */ function pollTickId() { - if (!L.Poll) { - warnPollUnreadable('footstrap: L.Poll is gone from this luci-base, so LuCI\'s own tick cannot be ' - + 'told apart from a view\'s timers — leaving view intervals alone. fs-router.js needs ' - + 'updating for this luci-base.'); - return false; - } - const running = (typeof L.Poll.active === 'function') ? L.Poll.active() : (L.Poll.timer != null); + const running = L.Poll.active(); if (running && L.Poll.timer == null) { warnPollUnreadable('footstrap: LuCI is polling but L.Poll.timer is not readable — leaving view ' + 'intervals alone rather than risking its tick. fs-router.js needs updating for this ' @@ -338,11 +329,9 @@ function watchSession() { * which also takes it out of the live tree. Public API only; no reaching into `dom.registry`. */ function discard(el) { try { - const dom = window.L ? window.L.dom : null; - if (!dom || typeof dom.content !== 'function') { el.remove(); return; } const bin = document.createElement('div'); bin.appendChild(el); - dom.content(bin, null); + window.L.dom.content(bin, null); } catch (e) { el.remove(); @@ -479,7 +468,7 @@ function restoreScroll(pos, gen) { stop(); }; /* the keys that scroll, and only those: typing in a field must not cancel anything */ - const SCROLL_KEYS = new Set([ 'PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowDown', ' ', 'Spacebar' ]); + const SCROLL_KEYS = new Set([ 'PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowDown', ' ' ]); const onKey = (ev) => { if (SCROLL_KEYS.has(ev.key)) stop(); }; const opts = { passive: true, capture: true }; function off() { @@ -784,11 +773,7 @@ function commitStage(stage, contentHost) { if (contentHost) contentHost.setAttribute('data-page', page); } const nodes = Array.from(stage.view.childNodes); - const dom = window.L ? window.L.dom : null; - if (live && dom && typeof dom.content === 'function') - dom.content(live, nodes); - else if (live) - live.replaceChildren(...nodes); + window.L.dom.content(live, nodes); dropStage(stage); } @@ -1434,7 +1419,7 @@ function wireRouter() { * "Paused" is always shown with `handler: null`), and which of the two ran first here used to decide * everything — luci.js registers its listener from `setupDOM()`, after an async chain * (DOMContentLoaded + ui/rpc/form + probeRPCBaseURL), this module at eval, from the inline - * `L.require('menu-footstrap')` in `partials/footer.ut`, so network/cache timing picked the order. + * `L.require('menu-footstrap')` in `footer.ut`, so network/cache timing picked the order. * Run with this listener first, its hide removed the span before luci.js re-created it for "Paused" * with no handler, and the next `poll-start` found that span already there and only changed its * text — clickless for the rest of the document. A microtask runs only once the whole synchronous diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-search.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-search.js index 1dde51f3d..050441549 100644 --- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-search.js +++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-search.js @@ -99,48 +99,6 @@ function index() { return _index; } -/* ---- extra sources ------------------------------------------------------- - * - * An optional package can add rows to the same list — one indexes the SECTION titles inside each - * page, so "footstrap" finds System -> Appearance. A source hands over entries in the shape - * buildIndex() produces and nothing else: the matching, the ranking and the rendering stay here, - * or two lists would disagree about what a hit is. Two fields are the source's alone: `onTake`, - * called when the row is chosen, and `key`, what the recents list stores it under when its `path` - * is not its own (see keyOf). - * - * Registration is a GLOBAL ARRAY, not an export a package requires. This module is fetched on the - * first gesture and most sessions never make it; a package that had to `require` it to register - * would pull it onto every page and pay its 4.5 KB for a palette nobody opened. Pushing a function - * onto `window.__fsSearchSources` costs the package nothing and names no one in either direction. - * - * `window.__fsSearchGen` is how a source says its data grew — a harvester fills in over a session - * — and the stamp below is what rebuilds the pool when it does. */ -const _sources = []; -let _pool = null, _stamp = -1; - -function globalSources() { - return Array.isArray(window.__fsSearchSources) ? window.__fsSearchSources : []; -} - -function addSource(fn) { - _sources.push(fn); - _pool = null; -} -function refresh() { - _pool = null; -} -function pool() { - const all = _sources.concat(globalSources()); - const stamp = all.length + (window.__fsSearchGen || 0); - if (_pool && stamp === _stamp) return _pool; - _stamp = stamp; - _pool = all.reduce((rows, fn) => { - try { return rows.concat(fn() || []); } - catch (e) { console.error('footstrap: a search source threw', e); return rows; } - }, index().slice()); - return _pool; -} - /* ---- matching ----------------------------------------------------------- */ /* Every whitespace-separated token must hit something: a second word means AND. Deliberately not @@ -160,7 +118,7 @@ function search(q, limit) { if (!toks.length) return []; const hits = []; - for (const e of pool()) { + for (const e of index()) { let sum = 0; for (const tok of toks) { const s = tokenScore(e, tok); @@ -179,31 +137,21 @@ function search(q, limit) { /* What the palette shows before anything is typed: an admin lives in three or four pages, so the * empty state is its most-used view. * - * Only the KEY is stored, never the title — the title is resolved through the pool on every - * render, so it follows the UI language and a row whose package went away drops out instead of - * lingering as a dead row. A page's key is its menu path; a row from a source carries its own - * `key`, because a section has no dispatcher node and therefore no path that is only its own — - * the sections source keys one `admin/system/system#Footstrap`, the page it is on plus its own - * heading. */ + * Only the PATH is stored, never the title — the title is resolved through the index on every + * render, so it follows the UI language and a page an ACL change hides drops out instead of + * lingering as a dead row. RECENT_KEY is the one name menu-footstrap-common.js's remember() also + * writes; the two modules stay decoupled (see there), so the string is the only thing shared. */ const RECENT_KEY = 'fs-recent'; const RECENT_MAX = 8; -/* the string a row is remembered under, and the one menu-footstrap-common's remember() writes */ -function keyOf(e) { - return e.key || e.path; -} - /* The list is WRITTEN by menu-footstrap-common.js, which is on every page — this module is not any * more, and a palette that only loads when it is opened cannot be what records where the admin has * been. Read here, at open time, so it is always current. `prefs.lsGetArr` owns the parse, the * corruption guard and the Array check; only the "these are keys" filter belongs here. */ function recentEntries() { const recent = prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string'); - /* pool(), not index(): a section is recalled exactly as a page is. Against the index alone a - * section key resolved to nothing and the row silently vanished, so taking "Footstrap" left - * only "System" in the list — the page path is all either row carries. */ - const byKey = new Map(pool().map((e) => [ keyOf(e), e ])); - return recent.map((k) => byKey.get(k)).filter(Boolean).slice(0, RECENT_MAX); + const byPath = new Map(index().map((e) => [ e.path, e ])); + return recent.map((k) => byPath.get(k)).filter(Boolean).slice(0, RECENT_MAX); } /* ---- the palette -------------------------------------------------------- */ @@ -311,16 +259,8 @@ function build() { e.trail.length ? E('span', { 'class': 'fs-search-opt-path' }, [ e.trail.join(' › ') ]) : '' ]); /* close before the click reaches the router, which re-renders the chrome underneath; - * no focus return, the user is going elsewhere. - * - * `onTake` is how a row from an extra source finishes the job the href cannot: a - * section row's href can only reach the PAGE, so the source that produced it opens the - * tab and scrolls to the section itself. It fires for a click and for Enter alike — - * Enter synthesises this very click. */ - a.addEventListener('click', () => { - close(false); - if (typeof e.onTake === 'function') e.onTake(); - }); + * no focus return, the user is going elsewhere */ + a.addEventListener('click', () => close(false)); a.addEventListener('pointermove', () => { if (at !== i) setActive(i); }); list.appendChild(a); return a; @@ -421,7 +361,5 @@ function openPalette() { } return baseclass.extend({ - open: openPalette, - /* the seam an optional package registers through; see addSource() */ - addSource, refresh + open: openPalette }); diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-select.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-select.js index 3b6a6822e..35ed27a2e 100644 --- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-select.js +++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-select.js @@ -5,6 +5,7 @@ 'require fs-fit as fit'; /* for the content column's width without a layout read — see the mid-scroll branch in fitTables */ 'require fs-chrome as chrome'; +'require fs-widgets as widgets'; /* Theme plain LuCI popup cannot be CSS-styled. @@ -19,14 +20,14 @@ function readChoices(sel) { const choices = {}; - Array.prototype.forEach.call(sel.options, (o) => { choices[o.value] = o.textContent; }); + for (const o of sel.options) choices[o.value] = o.textContent; return choices; } /* cheap identity of the option list, to detect a script rebuilding it * (select.replaceChildren, dependency-driven re-population, …) */ function choicesKey(sel) { - return Array.prototype.map.call(sel.options, (o) => o.value + '\u0000' + o.textContent).join('\u0001'); + return Array.from(sel.options, (o) => o.value + '\u0000' + o.textContent).join('\u0001'); } /* Undo enhance(): drop the widget, unhide the select and cut every listener enhance() installed. @@ -36,8 +37,7 @@ function choicesKey(sel) { * anonymous listener. */ function teardown(sel) { if (sel._fsAbort) sel._fsAbort.abort(); - if (sel._fsNode && sel._fsNode.parentNode) - sel._fsNode.parentNode.removeChild(sel._fsNode); + sel._fsNode?.remove(); delete sel.dataset.fsSelect; sel._fsDd = sel._fsNode = sel._fsKey = sel._fsAbort = null; sel.removeAttribute('aria-hidden'); @@ -132,7 +132,7 @@ function enhance(sel) { sel._fsAbort = ac; /* after the select: it must stay frameEl.firstChild for ui.Select to read its value on save */ - sel.parentNode.insertBefore(node, sel.nextSibling); + sel.after(node); /* stops our own dd->sel dispatch from echoing back through the sel->dd listener */ let syncing = false; @@ -184,11 +184,12 @@ function enhance(sel) { * is the same fact the CSS uses. The flag flips AFTER the content is written, so this is asked per * PASS rather than fixed at load, and fs-fit.js watches that class. */ const ROOTS = [ '#view', '#modal_overlay' ]; -const inRoots = (sel, roots) => roots.map((r) => `${r} ${sel}`).join(', '); const liveRoots = () => (document.body.classList.contains('modal-overlay-active') ? ROOTS : ROOTS.slice(0, 1)); +/* every call passes liveRoots(); folded in here rather than threaded as a parameter */ +const inRoots = (sel) => liveRoots().map((r) => `${r} ${sel}`).join(', '); -const foreignTables = () => inRoots('.table:not(.cbi-section-table)', liveRoots()) + ', ' + - inRoots('table:not(.table):not(.cbi-section-table)', liveRoots()); +const foreignTables = () => inRoots('.table:not(.cbi-section-table)') + ', ' + + inRoots('table:not(.table):not(.cbi-section-table)'); /* The fourth header markup, and the one only a foreign table produces: `` for the parser to imply and none of LuCI's class names. @@ -341,6 +342,9 @@ function labelCells(t, head) { * a class query alone reaches the whole subtree. `.thead`/`.tbody`/`.tfoot` are the same for a * div-based table; a REAL ``/``/`` this file never classes (20_lan.js's and * 30_wifi.js's ``, theme/30-tables.css:570/634) is reached by tag name instead. */ +/* tools/table-contract.mjs reads these two names and shapes by regex, so a `display` rule on any of + * these classes/tags is checked against a matching role — keep both names, and each entry a + * `'.class'` or `'tag'` string literal, even though roleTables() below now reads them as one list. */ const TABLE_ROLE_CLASSES = [ [ '.table', 'table' ], [ '.thead', 'rowgroup' ], @@ -356,23 +360,12 @@ const TABLE_ROLE_TAGS = [ [ 'tfoot', 'rowgroup' ], ]; -/* idempotent, the same idiom as menu-footstrap-common.js's fsSyncAttr: a node that already carries - * the right role is a no-op read, so a poll tick re-rendering the same table on every tick touches - * no DOM and wakes no attribute observer. Not imported for one line — this file has none of that - * module's other exports to justify the dependency. */ -function setRole(el, role) { - if (el.getAttribute('role') !== role) el.setAttribute('role', role); -} - /* A hidden row/cell (`.tr.cbi-section-table-descr`, `.td.hide-xs`/`.hide-sm` under `.fs-stacked`/ * `.fs-drop-xs`) drops out of the accessibility tree on `display: none` alone — the role written * here changes nothing about that, since a role is inert on a box the engine never generates. */ function roleTables() { - TABLE_ROLE_CLASSES.forEach(([ sel, role ]) => { - document.querySelectorAll(inRoots(sel, liveRoots())).forEach((el) => setRole(el, role)); - }); - TABLE_ROLE_TAGS.forEach(([ sel, role ]) => { - document.querySelectorAll(inRoots(sel, liveRoots())).forEach((el) => setRole(el, role)); + [ ...TABLE_ROLE_CLASSES, ...TABLE_ROLE_TAGS ].forEach(([ sel, role ]) => { + document.querySelectorAll(inRoots(sel)).forEach((el) => widgets.syncAttr(el, 'role', role)); }); } @@ -388,7 +381,7 @@ function roleTables() { * NOT be measured: its rows hold widgets, and a widget bakes in the width of the layout it was laid * out in, so un-collapsing it to read it changes what is read — the firewall zone table then * reported needing 1747px against a real 1190px and overflowed its section by 557px. */ -const stackables = () => inRoots('.table.fs-dt', liveRoots()); +const stackables = () => inRoots('.table.fs-dt'); /* "Too cramped to be a table any more" — a design judgement, and the only threshold the fit * decision takes on trust rather than measuring. A four-column table of short values still fits at @@ -527,7 +520,7 @@ function fitTables() { /* mid-scroll, only the tables with no answer yet: one that has an answer needs nothing, one * that does not is held out of the layout by the stylesheet and cannot wait. Neither branch * reads layout while `scrolling()` is true. */ - const sel = fit.scrolling() ? inRoots('.table.fs-dt:not(.fs-fitted)', liveRoots()) : stackables(); + const sel = fit.scrolling() ? inRoots('.table.fs-dt:not(.fs-fitted)') : stackables(); document.querySelectorAll(sel).forEach((t) => { /* While the reader scrolls this pass only WRITES: every layout read here forces a * synchronous layout, and a poll tick can land mid-flick, so on a phone with ten tables that @@ -694,7 +687,7 @@ function fitTables() { * Ping button was simply unreachable past `.fs-main`'s clip. The shape is not that page's, and the * width that matters is the room rather than the viewport, so this measures instead of naming a * page. */ -const scrollables = () => inRoots('.table:not(.fs-dt):not(.cbi-section-table)', liveRoots()); +const scrollables = () => inRoots('.table:not(.fs-dt):not(.cbi-section-table)'); const HOLDS_CONTROLS = '.cbi-dropdown, .cbi-dynlist, .cbi-tooltip-container, .cbi-progressbar, select, input, textarea, [data-tooltip]'; /* ---- making a scroll box reachable, and handing the markup back as it was found ---- @@ -795,7 +788,7 @@ function fitScrollables() { function unpinActionColumn() { /* a layout read here lands mid-flick once per poll tick — see fitTables() */ if (fit.scrolling()) { fit.deferMeasurement(); return; } - for (const t of document.querySelectorAll(inRoots('.table.cbi-section-table', liveRoots()))) { + for (const t of document.querySelectorAll(inRoots('.table.cbi-section-table'))) { if (!t.querySelector('.cbi-section-actions')) continue; /* Claim upstream's resize hook, which under SPA navigation is a leak: * stabilizeActionColumnWidth attaches a `resize` listener once per TABLE ELEMENT, guarded by @@ -864,7 +857,10 @@ const TYPEAHEAD_RESET_MS = 1000; let _taBuf = '', _taTimer = null, _taLast = null; function typeaheadItems(sb) { - const ul = sb.querySelector('ul.dropdown') || sb.querySelector('ul'); + /* sb is always `.cbi-dropdown[open]` here, and stock ui.js sets that attribute and adds + * `ul.dropdown` in the same openDropdown() call (openwrt/luci ui.js), so the two never + * disagree — verified on openwrt-24.10. */ + const ul = sb.querySelector('ul.dropdown'); if (!ul) return []; return [...ul.children].filter((li) => li.tagName === 'LI' && @@ -923,10 +919,7 @@ function wireTypeahead() { /* the widget's own highlighter: adds .focus, scrolls the item into view and focuses it, * so Enter (ui.Dropdown's handler) commits exactly what is highlighted */ const inst = dom.findClassInstance(sb); - if (inst && typeof inst.setFocus === 'function') - inst.setFocus(sb, hit, true); - else - hit.focus(); + if (inst) inst.setFocus(sb, hit, true); ev.preventDefault(); ev.stopPropagation(); diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-sheets.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-sheets.js index 1b108ccf1..d7a891944 100644 --- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-sheets.js +++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-sheets.js @@ -301,8 +301,7 @@ function documentCarries(path) { * original is deliberately not one. */ function documentPoisoned() { const names = themeNames(); - return Array.prototype.some.call( - document.querySelectorAll(VIEW_SHEETS), + return Array.from(document.querySelectorAll(VIEW_SHEETS)).some( (el) => outlivesPage(el) && (!names || (invasiveSheet(el, names) && !_owner.has(el) && !_silenced.has(el)))); } @@ -417,7 +416,7 @@ function fenceImported(styleEl, names, until) { /* what a sheet IS, as text: the rules that are applying, not the markup that may have produced * them. Serialised only to compare, never re-parsed. */ -const serializeRules = (rules) => Array.prototype.map.call(rules, (r) => r.cssText).join('\n'); +const serializeRules = (rules) => Array.from(rules, (r) => r.cssText).join('\n'); /* ---- a
…`, with no * `