diff --git a/luci-theme-footstrap/Makefile b/luci-theme-footstrap/Makefile
index ec406bac..f638dd5d 100644
--- a/luci-theme-footstrap/Makefile
+++ b/luci-theme-footstrap/Makefile
@@ -17,7 +17,7 @@ LUCI_NAME:=luci-theme-footstrap
FOOTSTRAP_VERSION?=
ifneq ($(FOOTSTRAP_VERSION),)
PKG_VERSION:=$(FOOTSTRAP_VERSION)
-PKG_RELEASE:=12
+PKG_RELEASE:=13
endif
LUCI_TITLE:=Footstrap Theme
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 3d9db6ca..be6ef22f 100644
--- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-router.js
+++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-router.js
@@ -412,9 +412,13 @@ function navigate(pathname, push, kbd) {
* decided by how the user got there.
*
* So decline, exactly as the poisoned-document bail above does: speed is traded for correctness,
- * never the other way. It costs ONE full load per such page — after it the is in the
- * document, this test passes, and every later visit is a swap again (fs-sheets.js owns the sheet
- * from then on and re-lights it per page).
+ * never the other way. The cost is one full load per ENTRY INTO A DOCUMENT that lacks the sheet,
+ * not one per page ever: head.ut emits the link for the DISPATCHED node only, so each full load
+ * starts a document carrying exactly one such sheet and discards what the previous one had
+ * gathered. Within that document the page is a swap from then on (fs-sheets.js owns the sheet and
+ * re-lights it per page) — but two `css`-bearing pages alternating are a full load every time, in
+ * both directions. That is the trade taken knowingly: an unstyled page is worse than a reload, and
+ * no in-tree node sets `css` today.
*
* Injecting the here instead would work and is deliberately not done: it would put the theme
* in charge of fetching and ordering a foreign stylesheet, which is the job fs-sheets.js exists to
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 3b1dcdfb..a31185d2 100644
--- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-sheets.js
+++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-sheets.js
@@ -325,14 +325,29 @@ function outlivesPage(el) {
* this module's because the answer is: a link inside #view is about to be deleted with the rest of
* the view, and counting it would hand the router a sheet the next dom.content() throws away —
* `luci-app-nlbwmon` returns E('link', …, L.resource('view/nlbw.css')) from render(), so that shape
- * is real. Compared by PATH and by suffix: head.ut prints `{{ resource }}/{{ dispatched.css }}?v=…`,
- * so neither the resource base nor the cache key is anyone's to assume. */
+ * is real.
+ *
+ * WHOLE PATH, NOT A SUFFIX. head.ut prints `{{ resource }}/{{ dispatched.css }}?v=…`, and the base in
+ * that line is the SAME value the runtime holds: header.ut hands `resource` to `new LuCI({…})`, and
+ * `L.resource()` joins it back exactly, so the server's href is reconstructable rather than guessable
+ * — only the cache key has to come off. A suffix match is what a guess costs: anchored at nothing but
+ * a `/`, `custom.css` matches any sheet ending in that filename, and two in-tree apps append exactly
+ * that to
at module eval — `luci-app-adblock` and `luci-app-banip` both add
+ * `L.resource('view//custom.css')`, outside #view, so outlivesPage() keeps them and this module
+ * disables rather than removes them: they stay for the life of the document. A third-party node
+ * declaring `"css": "custom.css"` would then read as already-carried the moment the user had passed
+ * through Adblock → Feeds, and the router would swap into a page whose stylesheet was never linked —
+ * the one outcome the guard exists to prevent.
+ *
+ * Equality also keeps the failure safe: L.path() drops a part that leaves its charset, so a malformed
+ * `css` yields the bare base and matches no href at all — a full load, which is the correct answer for
+ * a value nobody can serve. */
function documentCarries(path) {
- const want = '/' + String(path).replace(/^\/+/, '');
+ const want = L.resource(String(path));
for (const link of document.querySelectorAll('link[rel~="stylesheet"][href]')) {
if (!outlivesPage(link))
continue;
- if ((link.getAttribute('href') || '').split('?')[0].endsWith(want))
+ if ((link.getAttribute('href') || '').split('?')[0] === want)
return true;
}
return false;
diff --git a/luci-theme-footstrap/styles/02-tokens.css b/luci-theme-footstrap/styles/02-tokens.css
index d44df05a..e3b31382 100644
--- a/luci-theme-footstrap/styles/02-tokens.css
+++ b/luci-theme-footstrap/styles/02-tokens.css
@@ -469,14 +469,22 @@
--background-color-medium: color-mix(in srgb, var(--fs-panel) 55%, var(--fs-panel2));
--background-color-low: var(--fs-panel2);
- /* --text-color-low cannot move: --fs-dim IS the muted text colour and already
- * sits ON the AA floor (4.56:1 on --fs-panel2 in dark), so a fainter step would
- * be an illegible grey. It stays equal to -medium — the ramp is still wide, -high
- * being --fs-text — and a duplicate beats a value nobody can read. */
+ /* -low used to be a DUPLICATE of -medium, both --fs-dim, on the grounds that a fainter
+ * step would be illegible: --fs-dim sits on the AA floor in dark (4.56:1 on --fs-panel2)
+ * and --fs-faint is the fainter one. That held while the two were a hair apart. Light's
+ * secondary tier has since moved to 11.8:1 (see 03-palettes.css), so the duplicate stopped
+ * being a rounding decision and started flattening the export: `spread(high, low)` fell to
+ * 0.082 in the default palette and 0.094 in hicontrast, under the 0.10 the ramp promises —
+ * an app asking for a gradation would have got one colour twice.
+ *
+ * So -low is --fs-faint, the theme's own third ink, and the illegibility that argument
+ * feared is what the gate below measures rather than assumes: every level is held to AA as
+ * text on all three surfaces AND as a fill under its --on-* ink, in both modes, across the
+ * whole palette x tint matrix. --fs-faint clears it everywhere. */
--text-color-highest: var(--fs-text);
--text-color-high: var(--fs-text);
--text-color-medium: var(--fs-dim);
- --text-color-low: var(--fs-dim);
+ --text-color-low: var(--fs-faint);
/* borders carry no text, so here -low MAY fade toward the surface: a hairline
* is supposed to disappear into it. */
diff --git a/luci-theme-footstrap/styles/03-palettes.css b/luci-theme-footstrap/styles/03-palettes.css
index 75680259..7f995e14 100644
--- a/luci-theme-footstrap/styles/03-palettes.css
+++ b/luci-theme-footstrap/styles/03-palettes.css
@@ -257,9 +257,15 @@
--fs-panel2-base: #f6f8fa;
--fs-border-base: #d0d7de;
--fs-text: #1f2328;
- --fs-dim: #656d76;
- --fs-faint: #68727e;
- --fs-select-chevron: url('data:image/svg+xml;utf8,');
+ /* The SECONDARY tier carries LuCI's page descriptions and every field title, and at the
+ * GitHub-light greys it started from (#656d76/#68727e, 5.2:1 and 4.9:1 on the card) that is
+ * help text a reader has to lean into — reported on the upstream PR by someone reading the
+ * light theme on a real router. LuCI's pages are technical and their descriptions are the
+ * part you read once and act on, so this tier is now 11.8:1 and 10.0:1: still a tier below
+ * the 15.8:1 body ink, which is what keeps a label a label, but no longer a whisper. */
+ --fs-dim: #33383d;
+ --fs-faint: #3d4348;
+ --fs-select-chevron: url('data:image/svg+xml;utf8,');
--fs-accent-base: #0969da;
/* a tint OF --fs-accent, so mixed FROM it. It used to restate the accent's RGB as a
* literal, which goes stale silently the moment the accent is recoloured (or the
@@ -315,9 +321,13 @@
--fs-panel2-base: #f6f8fb;
--fs-border-base: #e4e9f1;
--fs-text: #101725;
- --fs-dim: #5b6675;
- --fs-faint: #69727f;
- --fs-select-chevron: url('data:image/svg+xml;utf8,');
+ /* …and this palette's secondary tier moves with it, because the promise in the comment above
+ * is RELATIVE: 5.8:1 and 4.9:1 were above the default when the default was 5.2:1 and 4.9:1,
+ * and below it the moment that changed. 13.5:1 and 12.0:1 against the default's 11.8 and
+ * 10.0. */
+ --fs-dim: #272f3d;
+ --fs-faint: #2e3745;
+ --fs-select-chevron: url('data:image/svg+xml;utf8,');
--fs-accent-base: #0b6fbd;
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 10%, transparent);
--fs-track: #eaeef4;
@@ -388,10 +398,17 @@
--fs-panel-base: #ffffff;
--fs-panel2-base: #f5f5f5;
--fs-border-base: #cccccc;
- --fs-text: #404040;
- --fs-dim: #6a6a6a;
- --fs-faint: #6e6e6e;
- --fs-select-chevron: url('data:image/svg+xml;utf8,');
+ /* THE INK IS THE ONE PLACE THIS COLOURWAY LEAVES BOOTSTRAP'S OWN VALUES BEHIND, and it is
+ * the same decision the default palette made: bootstrap's #404040 body and #6a6a6a muted
+ * tier measure 10.4:1 and 5.0:1 on white, and the muted one carries LuCI's page
+ * descriptions and every field title — help text you have to lean into. Matched to the
+ * default palette's light ramp instead, ratio for ratio: 15.7 / 11.7 / 10.0 against its
+ * 15.8 / 11.8 / 10.0. Neutral greys, so the colourway still reads as bootstrap's — what is
+ * copied is its hue, not its readability. */
+ --fs-text: #232323;
+ --fs-dim: #383838;
+ --fs-faint: #424242;
+ --fs-select-chevron: url('data:image/svg+xml;utf8,');
/* --primary-color-medium; -high (#1976d2) measured 4.22:1 on --fs-panel2 */
--fs-accent-base: #1564c0;
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 10%, transparent);
@@ -417,8 +434,13 @@
--fs-panel2-base: #303030;
--fs-border-base: #444444;
--fs-text: #bfbfbf;
+ /* faint == dim here, and that is the deviation this palette allows itself: bootstrap's dark
+ * has no third ink, and the export tier's -low now follows --fs-faint (02-tokens.css), so
+ * the #969696 this used to carry landed at 4.46:1 on a tinted --fs-panel2 — under AA for a
+ * name apps print text in. The step above it is bootstrap's own, so the fainter one gives
+ * way rather than the ramp. */
--fs-dim: #9a9a9a;
- --fs-faint: #969696;
+ --fs-faint: #9a9a9a;
--fs-select-chevron: url('data:image/svg+xml;utf8,');
--fs-accent-base: #53a4c2;
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 15%, transparent);