⛄ Sync 2026-08-12 20:59:19

This commit is contained in:
github-actions[bot] committed 2026-08-12 20:59:19 +08:00
1 parent 99ea32a4ad
commit 5c59dccedc
62 files changed
+2165 -809

No files matched your search

@@ -14,9 +14,13 @@
So every label in the Appearance page carries the `footstrap` msgctxt (fs-appearance.js), while
the CHROME's own strings — Skip to content, Menu, Collapse menu, Search, Log out — are
deliberately msgctxt-FREE: inheriting luci-base's translation is a feature in the ~40 languages
this theme ships no catalogue for. Same for the login/notice sentences and for
System/Memory/Storage in fs-overview.js, which MATCH the stock headings on purpose.
deliberately msgctxt-FREE, because a bare msgid is how a string INHERITS a translation in the ~40
languages this theme ships no catalogue for. Two of the five collect on that today: Skip to
content and Log out are luci-base msgids verbatim (the latter was `Logout`, which is nobody's
msgid, so it rendered in English everywhere our own catalogue does not reach). Menu, Collapse menu
and Search are ours alone for now and stay bare so a future luci-base string can adopt them. Same
for the login/notice sentences and for System/Memory/Storage in fs-overview.js, which MATCH the
stock headings on purpose.
-#}
{%
@@ -74,8 +78,14 @@
fs_defaults[k] = fsd[k] || '';
/* the one value with a fallback of its own: the legacy migration seed
luci.main.footstrap_layout=top, which uci-defaults sets for a router upgraded from the old
top-nav theme (a shell script cannot write localStorage). */
fs_defaults.layout = fsd.layout || config.main.footstrap_layout || '';
top-nav theme (a shell script cannot write localStorage).
`config.main?.` and not `config.main.`: ucode raises a hard reference error on a property read
through null and this template is the ONE that renders every page, so a dispatcher that stopped
defaulting that section would answer 500 everywhere. It defaults it today — dispatcher.uc has
`main: uci.get_all('luci', 'main') ?? {}` on both release branches — and the CI gate cannot see
the difference either way: `ucode -T -c` COMPILES the template, it never runs it. */
fs_defaults.layout = fsd.layout || config.main?.footstrap_layout || '';
http.prepare_content('text/html; charset=UTF-8');
-%}
@@ -12,8 +12,12 @@
break the inline script nor inject markup (defence in depth — writing that file already needs
root). Absent/invalid becomes '' (or 0, or -1 for rounding whose 0 is a real value), which the
JS reads as "no router default". The client's own localStorage overrides every one of these
before paint — the browser choice always wins (see fs-prefs.js). */
fs_defaults = fs_defaults ?? {};
before paint — the browser choice always wins (see fs-prefs.js).
`fs_defaults` is a required include parameter, not an optional one: header.ut is the only
caller and builds it as an object literal before the include, so there is nothing to default
it to — a `?? {}` here only hid that contract from a second caller that would need to honour
it. */
let _sd_lay = index([ 'sidebar', 'top' ], fs_defaults.layout) >= 0 ? fs_defaults.layout : '';
let _sd_dark = index([ 'auto', 'light', 'dark' ], fs_defaults.darkmode) >= 0 ? fs_defaults.darkmode : '';
let _sd_pal = index([ 'footstrap', 'hicontrast', 'bootstrap' ], fs_defaults.palette) >= 0 ? fs_defaults.palette : '';
@@ -61,8 +65,9 @@
builds the url() below; anything else becomes '' = "no background". */
let _lb = fs_defaults.login_bg;
let _sd_lbg = (type(_lb) == 'string' && match(_lb, /^[a-f0-9]{6,64}$/)) ? _lb : '';
/* the router-identity Tint's DENSITY 0-200 (% of the designed chroma); '' (unset) -> -1, like
rounding, because 0 is a real value (no tint). */
/* the router-identity Tint's STRENGTH 0-200 (% of the designed chroma; the Appearance row is
"Tint strength" — "Density" is the other axis entirely). '' (unset) -> -1, like rounding,
because 0 is a real value. */
let _ts = int(fs_defaults.tint_strength);
let _sd_tstr = (fs_defaults.tint_strength != '' && _ts >= 0 && _ts <= 200) ? _ts : -1;
/* the File photo's scrim opacity 0-100 (Wallpaper -> File -> Dim), a SHARED router value; unset
@@ -150,7 +155,7 @@
RTL set rather than only the three LuCI ships catalogues for today (ar, fa, he), because the
cost of a language never installed is one array entry. `ltr` is written explicitly for the same
reason data-layout is: every rule then matches positively. */
let _lang = dispatcher.lang ?? 'en';
let _lang = dispatcher.lang;
let _lbase = lc(split(_lang, /[-_]/)[0]);
let _rtl = index([ 'ar', 'arc', 'ckb', 'dv', 'fa', 'ha', 'he', 'khw', 'ks', 'ps', 'sd', 'ur', 'yi' ], _lbase) >= 0;
%}
@@ -226,14 +231,28 @@
(this browser) wins; absent -> the router default in window.__fsSD; absent -> the
built-in. */
(() => {
/* PER READ, not once around the block. A browser that refuses storage (Firefox with
dom.storage.enabled=false, "block all cookies" in Chrome/Safari, several WebViews)
throws a SecurityError on the FIRST getItem, and with one try around everything that
aborted the whole pre-paint: dark mode still applied — it guards narrowly, three
blocks up — while palette, density, rounding, wallpaper, the pattern axes, the five
colour axes, the four surfaces, tint strength and photo dim were all skipped, so a
router that had saved defaults painted none of them. Storage failing must mean "this
browser has no override", which is exactly what the live applier's lsGet already
means (fs-prefs.js). The outer try stays as the last resort for everything else. */
const lsGet = (k) => { try { return localStorage.getItem(k); } catch (e) { return null; } };
try {
const root = document.querySelector(':root'),
sd = window.__fsSD || {};
let p = localStorage.getItem('fs-palette');
let p = lsGet('fs-palette');
/* legacy 'rvht'/'roman' = default colours + a doodle wallpaper, and legacy
'github' = the footstrap default, now stored explicitly. Migrate to the current
explicit value, do NOT clear (an absent key means "inherit the router default",
which is no longer the same as "the built-in default"). The doodles they used to
which is no longer the same as "the built-in default"). The migration is
already redundant, and worth knowing before it is carried forward forever: a
stray value reads as the built-in default in the live applier too
(fs-prefs.js's currentPalette ends `return 'footstrap'`), so deleting this
block changes nothing UNLESS that line goes with it. The doodles they used to
turn on are gone — the Wallpaper axis now paints an SVG the admin uploads — so
the wallpaper half of that migration has nothing to point at and is dropped;
`fs-wallpaper` keeps whatever it holds and the whitelist below reads a doodle
@@ -256,18 +275,18 @@
whitelisted to hex above, so nothing user-controlled reaches the url(). */
const pu = sd.pattern;
if (pu) root.style.setProperty('--fs-pattern-url', 'url("/luci-static/footstrap/pattern.svg?v=' + pu + '")');
const psRaw = localStorage.getItem('fs-pattern-size');
const psRaw = lsGet('fs-pattern-size');
const ps = (psRaw !== null) ? parseInt(psRaw, 10) : sd.pattern_size;
if (ps >= 40 && ps <= 1600 && ps !== 440) root.style.setProperty('--fs-pattern-size', ps + 'px');
const pstRaw = localStorage.getItem('fs-pattern-strength');
const pstRaw = lsGet('fs-pattern-strength');
const pst = (pstRaw !== null) ? parseInt(pstRaw, 10) : sd.pattern_strength;
if (pst >= 0 && pst <= 100 && pst !== 20) root.style.setProperty('--fs-pattern-strength', String(pst / 100));
let pi = localStorage.getItem('fs-pattern-ink');
let pi = lsGet('fs-pattern-ink');
if (pi === null) pi = sd.pattern_ink || null;
if (pi === 'original') root.setAttribute('data-pattern-ink', 'original');
else root.removeAttribute('data-pattern-ink');
let w = localStorage.getItem('fs-wallpaper');
let w = lsGet('fs-wallpaper');
if (w === null) w = sd.wallpaper || null;
if (w === 'pattern' || w === 'file') root.setAttribute('data-wallpaper', w);
else root.removeAttribute('data-wallpaper');
@@ -276,7 +295,7 @@
type and space ladders). Normal is a bare :root, so the removeAttribute
half matters as much as the set — without it a browser that switched back
to Normal would paint one frame still compressed. */
let dn = localStorage.getItem('fs-density');
let dn = lsGet('fs-density');
if (dn === null) dn = sd.density || null;
if (dn === 'compact' || dn === 'large') root.setAttribute('data-density', dn);
else root.removeAttribute('data-density');
@@ -285,7 +304,7 @@
default is a LITERAL: this runs before the stylesheet and cannot read the
CSS token, so axes.mjs is the only thing checking the two agree. sd.rounding
is -1 when the router has no default. */
const rRaw = localStorage.getItem('fs-radius');
const rRaw = lsGet('fs-radius');
let r = parseInt(rRaw, 10);
if (rRaw === null) r = (sd.rounding >= 0 ? sd.rounding : 12);
if (r >= 0 && r <= 20 && r !== 12)
@@ -302,7 +321,7 @@
to one shape. */
const hex = /^#[0-9a-f]{6}$/i;
const colour = (key, sdVal, attr, hueProp, colorProp) => {
const raw = localStorage.getItem(key);
const raw = lsGet(key);
const v = String((raw === null ? sdVal : raw) ?? '').trim();
if (hex.test(v)) {
root.style.removeProperty(hueProp);
@@ -327,7 +346,7 @@
mode and therefore no attribute, just the token or nothing. Mirrors
surfaceAxis() in fs-prefs.js. */
const surface = (key, sdVal, prop) => {
const raw = localStorage.getItem(key);
const raw = lsGet(key);
const v = String((raw === null ? sdVal : raw) ?? '').trim().toLowerCase();
if (hex.test(v)) root.style.setProperty(prop, v);
};
@@ -339,19 +358,22 @@
window.__fsSD — no localStorage). Set the property whenever a valid token
exists; whether it PAINTS is the wallpaper axis above (data-wallpaper="file"),
so a router whose default wallpaper is 'file' shows it here, pre-login included.
Re-validate the token (defence in depth, same charset as the server sanitiser). */
The token is already whitelisted to hex above, the same as sd.pattern before it, so
nothing user-controlled reaches the url(); the test is repeated here anyway
because this one interpolates into a url() inside a STYLE property, where a
second reading costs one regex and the failure would be silent. */
const lb = sd.login_bg;
if (lb && (/^[a-f0-9]{6,64}$/).test(lb))
root.style.setProperty('--fs-login-bg-url', "url('/luci-static/footstrap/bg?v=" + lb + "')");
/* the router-identity Tint's density (default 100% = the designed chroma). */
const tsRaw = localStorage.getItem('fs-tint-strength');
const tsRaw = lsGet('fs-tint-strength');
let ts = parseInt(tsRaw, 10);
if (tsRaw === null) ts = (sd.tint_strength >= 0 ? sd.tint_strength : 100);
if (ts >= 0 && ts <= 200 && ts !== 100)
root.style.setProperty('--fs-tint-strength', String(ts / 100));
/* the File photo's scrim opacity — an ordinary per-browser axis, same shape as
the tint strength above; the 74% default lives in the CSS token. */
const pdRaw = localStorage.getItem('fs-photo-dim');
const pdRaw = lsGet('fs-photo-dim');
let pd = parseInt(pdRaw, 10);
if (pdRaw === null) pd = (sd.photo_dim >= 0 ? sd.photo_dim : 74);
if (pd >= 0 && pd <= 100 && pd !== 74)
@@ -444,9 +466,10 @@
lang=en it spends them to deliver 13 bytes (`window.TR={};`): there is no en catalogue,
because the msgids already ARE English. The process is the cost, not the data. So emit
those 13 bytes inline when this language has no catalogue, and keep the tag when it has.
The probe MIRRORS the server: action_translations calls load_catalog(lang,
'/usr/lib/lua/luci/i18n'), which loads every `*.<lang>.lmo` there — so a router that does
ship an en catalogue still gets the tag. Deciding by language NAME would silently drop it.
The probe MIRRORS the server: determine_request_language() (dispatcher.uc) loads this
request's catalogue with load_catalog(lang, '/usr/lib/lua/luci/i18n'), which takes every
`*.<lang>.lmo` in that directory — so a router that does ship an en catalogue still gets
the tag. Deciding by language NAME would silently drop it.
Fails OPEN (a throwing probe keeps the tag): a missing catalogue makes every `_()` render
English and report nothing, which is the one failure this must never risk. #}
{%- let has_catalog = true;
@@ -8,8 +8,8 @@
Licensed to the public under the Apache License 2.0.
-#}
{% if (dispatcher.lookup("admin/logout")): %}
<a class="fs-logout" href="{{ dispatcher.build_url('admin/logout') }}" title="{{ _('Logout') }}" aria-label="{{ _('Logout') }}">
<a class="fs-logout" href="{{ dispatcher.build_url('admin/logout') }}" title="{{ _('Log out') }}" aria-label="{{ _('Log out') }}">
{% include('themes/footstrap/partials/icon', { body: '<path d="M14 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4"/><path d="M10 12H3m0 0 3-3m-3 3 3 3"/>' }) %}
<span class="fs-label">{{ _('Logout') }}</span>
<span class="fs-label">{{ _('Log out') }}</span>
</a>
{% endif %}
@@ -1,9 +1,11 @@
{#
The page-search trigger (#fs-search-btn), wired by fs-search.js. It is a `.fs-themerow` — the
SAME class the Appearance trigger uses, not a look of its own — so it is one of the right
chrome's shared row/button class rather than a look of its own — so it is one of the right
cluster's square icon buttons in the bar and the rail, and a full-width labelled row in the
vertical sidebar, with no new CSS to keep in step. It sits between the poll pill (#indicators)
and Appearance: those three share `order: 2`, so the DOM order here IS the visual order.
and Log out, which share `order: 2` with it, so the DOM order here IS the visual order. (The
Appearance trigger was the third member until the panel became a tab on System -> System; this is
the only .fs-themerow the document carries now.)
i18n: 'Search' is CHROME and deliberately carries no msgctxt — inheriting luci-base's translation
is a feature in the ~40 languages this theme ships no catalogue for. The palette's OWN strings
@@ -5,10 +5,13 @@
Copyright 2008-2012 Jo-Philipp Wich <jow@openwrt.org>
Licensed to the public under the Apache License 2.0.
It exists to pass `blank_page: true`, the only thing it changes about the generic template:
without a theme-local sysauth the dispatcher falls back to that one, which includes the header
WITHOUT blank_page — so the whole chrome (sidebar, menu, footer) rendered around the login
form, every control on it dead.
It exists because the generic template includes the header WITHOUT `blank_page: true`: with no
theme-local sysauth the dispatcher falls back to that one and the whole chrome (sidebar, menu,
footer) rendered around the login form, every control on it dead. Everything else here is a
DEPARTURE the notes below each justify — the `fs-login` hook the page CSS needs, the id/for/
autocomplete attributes, the alert gating, and an HTTPS-redirect script that also fixes the
generic's `split(https_port, …)` typo — and the rule is that none of them may drop a field the
backend posts.
It is NOT a copy of bootstrap's sysauth.ut, which hides the form in a `<section hidden>` and
needs a view module to reveal it: tried here once and it gave a BLANK PAGE WITH NO WAY TO LOG
@@ -88,8 +91,8 @@
sysauth that drops them is not a login form with a missing extra: the field is never
posted, so on a router with 2FA enabled NOBODY CAN LOG IN while this theme is active,
and the backend's "Too many failed attempts" / "System time is not calibrated" messages
never reach the screen either. This template exists only to pass `blank_page: true`, so
it must stay a superset of the generic one.
never reach the screen either. Whatever else this template changes, it must stay a
SUPERSET of the generic one's fields.
The leading space inside each conditional attribute is load-bearing. ucode's template
lexer eats the whitespace immediately BEFORE a `{%` tag and the newline immediately