🐶 Sync 2026-10-20 22:40:23

This commit is contained in:
github-actions[bot] committed 2026-10-20 22:40:23 +08:00
1 parent e813f5cc54
commit 64cea2243a
216 files changed
+11913 -2968

No files matched your search

@@ -1 +1,35 @@
{% include('themes/footstrap/partials/footer') %}
{#
Footer body. One layout template and one client menu renderer: sidebar and top bar are the same
markup, morphed by :root[data-layout].
Licensed to the public under the Apache License 2.0.
-#}
{% if (!blank_page): %}
</div>{# /.fs-content #}
{#
role="contentinfo" is explicit because a <footer> gets it implicitly only when
its nearest ancestor is <body>, and this one must stay inside <main>: both
layouts lay .fs-main out as a flex column and order the footer within it.
The version strings are entityencode()d — /etc/openwrt_release is root-owned,
but an unescaped `&` in a distname would still break the markup.
#}
<footer class="fs-footer" role="contentinfo">
<span>
Powered by
<a href="https://github.com/openwrt/luci" target="_blank" rel="noreferrer">
{{ entityencode(version.luciname, true) }} ({{ entityencode(version.luciversion, true) }})</a>
/
<a href="{{ entityencode(version.disturl ?? '#', true) }}" target="_blank" rel="noreferrer">
{{ entityencode(version.distname, true) }} {{ entityencode(version.distversion, true) }} ({{ entityencode(version.distrevision, true) }})</a>
{% if (lua_active): %}
/ {{ _('Lua compatibility mode active') }}
{% endif %}
</span>
</footer>
</main>{# /.fs-main #}
</div>{# /.fs-shell #}
<script>L.require('menu-footstrap')</script>
<script>L.require('fs-select')</script>
{% endif %}
</body>
</html>
@@ -8,7 +8,7 @@
i18n, and the rule the other templates point at. A msgid is a GLOBAL name: LuCI serves one merged
catalogue — load_catalog() loads every *.<lang>.lmo and a lookup returns the first archive holding
the hash — so readdir order decides who owns a string, and the Appearance layout toggle rendered
"Максимум" on a Russian router because another catalogue translates `Top` as "maximum" (issue #6).
"Максимум" on a Russian router because another catalogue translates `Top` as "maximum".
Anything left bare is a name anyone may take, and contexting cannot be selective.
So every Appearance label carries the `footstrap` msgctxt, while the chrome's own strings — Skip
@@ -35,7 +35,7 @@
/* Every option saveAsDefault() writes, listed once and read back in a loop. A hand-written
* literal with one line per axis drops an axis silently: Save to router reports success, the
* file on disk is correct, and "Reset to router" lands on the BUILT-IN look because the value
* the server hands the client was never there. tools/axes.mjs checks this list against
* the server hands the client was never there. This list is checked against
* snapshotAxes().
*
* '' rather than a raw undefined when unset, so head.ut's `!= ''` guards fire: int(undefined)
@@ -69,22 +69,10 @@
it, so the CI gate cannot see the difference. */
fs_defaults.layout = fsd.layout || config.main?.footstrap_layout || '';
/* Optional companion packages announce themselves in the theme's own config —
`uci add_list footstrap.settings.plugin=<module>` from the package's uci-defaults — rather
than being named in this tree. The chrome requires each name and knows nothing else about it,
so the edge points inwards exactly as fs-router.onNavigate does.
Whitelisted to the shape of a LuCI module name before it is printed into a <script>: writing
that file needs root, but a value that reaches an inline script is checked anyway. */
let fs_plugins = fsd.plugin ?? [];
if (type(fs_plugins) != 'array')
fs_plugins = [ fs_plugins ];
fs_plugins = filter(fs_plugins, (m) => type(m) == 'string' && match(m, /^[a-zA-Z0-9._-]{1,48}$/));
http.prepare_content('text/html; charset=UTF-8');
-%}
{% include('themes/footstrap/partials/head', { boardinfo, fs_defaults, fs_plugins }) %}
{% include('themes/footstrap/partials/head', { boardinfo, fs_defaults }) %}
{#
data-page carries the DISPATCH path (ctx.path), not request_path: on a firstchild route
@@ -97,6 +85,43 @@
#}
{% const fs_page = entityencode(join('-', length(ctx.path) ? ctx.path : ctx.request_path), true) %}
<body class="lang_{{ dispatcher.lang }}" data-page="{{ fs_page }}">
<script>
/* Records every node a script parks directly on <body>: a page that does so makes the next
* navigation a full load, because the document is spent. fs-router.js's
* strayBodyNode()/bodyLittered() read window.__fsBodyAdds. First byte of <body>, before
* any chrome element or the dispatched page's own <div id="view">. Own property of this
* <body> instance, never Node.prototype. */
(() => {
const body = document.body;
if (!body || body.__fsBodyHooked) return;
body.__fsBodyHooked = true;
const adds = window.__fsBodyAdds || (window.__fsBodyAdds = []);
const names = [ 'appendChild', 'insertBefore', 'replaceChild', 'append', 'prepend',
'insertAdjacentElement', 'insertAdjacentHTML' ];
names.forEach((name) => {
const orig = body[name];
if (typeof orig !== 'function') return;
try {
Object.defineProperty(body, name, {
configurable: true,
writable: true,
value() {
const before = Array.prototype.slice.call(this.children);
const ret = orig.apply(this, arguments);
const after = this.children;
for (let i = 0; i < after.length; i++) {
if (before.indexOf(after[i]) === -1) adds.push(after[i]);
}
return ret;
}
});
}
/* a host that refuses to redefine this one name leaves it unwrapped; the rest
* still are */
catch (e) {}
});
})();
</script>
{#
The wallpaper pattern's paint surface: 15-wallpaper.css masks this element so an
uploaded SVG takes the theme's ink. A mask applies to an element's whole rendering,
@@ -1,35 +0,0 @@
{#
Footer body. One layout template and one client menu renderer: sidebar and top bar are the same
markup, morphed by :root[data-layout].
Licensed to the public under the Apache License 2.0.
-#}
{% if (!blank_page): %}
</div>{# /.fs-content #}
{#
role="contentinfo" is explicit because a <footer> gets it implicitly only when
its nearest ancestor is <body>, and this one must stay inside <main>: both
layouts lay .fs-main out as a flex column and order the footer within it.
The version strings are entityencode()d — /etc/openwrt_release is root-owned,
but an unescaped `&` in a distname would still break the markup.
#}
<footer class="fs-footer" role="contentinfo">
<span>
Powered by
<a href="https://github.com/openwrt/luci" target="_blank" rel="noreferrer">
{{ entityencode(version.luciname, true) }} ({{ entityencode(version.luciversion, true) }})</a>
/
<a href="{{ entityencode(version.disturl ?? '#', true) }}" target="_blank" rel="noreferrer">
{{ entityencode(version.distname, true) }} {{ entityencode(version.distversion, true) }} ({{ entityencode(version.distrevision, true) }})</a>
{% if (lua_active): %}
/ {{ _('Lua compatibility mode active') }}
{% endif %}
</span>
</footer>
</main>{# /.fs-main #}
</div>{# /.fs-shell #}
<script>L.require('menu-footstrap')</script>
<script>L.require('fs-select')</script>
{% endif %}
</body>
</html>
@@ -21,17 +21,28 @@
let _sd_pink = index([ 'theme', 'original' ], fs_defaults.pattern_ink) >= 0 ? fs_defaults.pattern_ink : '';
let _sd_ac = index([ 'on', 'off' ], fs_defaults.autocollapse) >= 0 ? fs_defaults.autocollapse : '';
let _sd_dens = index([ 'compact', 'normal', 'large' ], fs_defaults.density) >= 0 ? fs_defaults.density : '';
/* issue #44: how far the content column may grow past --fs-content-max's 1280px default.
/* Six numeric axes share this shape: '' (unset) must map to -1, never to 0, because 0 is a real
value for every one of them (sharp corners, a transparent scrim, no tile). */
function _sd_range(raw, min, max) {
let n = int(raw);
return (raw != '' && n >= min && n <= max) ? n : -1;
}
/* a cache-bust TOKEN (the cgi-upload md5 for an uploaded background/pattern/font sheet), not a
path — the served path is fixed. Whitelisted to hex so a hand-edited config cannot inject
markup into an inline script that interpolates it. */
function _sd_token(v) {
return (type(v) == 'string' && match(v, /^[a-f0-9]{6,64}$/)) ? v : '';
}
/* how far the content column may grow past --fs-content-max's 1280px default.
header.ut's FS_AXES carries 'content_width', so a saved router default reaches this. */
let _cw = int(fs_defaults.content_width);
let _sd_cwidth = (fs_defaults.content_width != '' && _cw >= 1280 && _cw <= 3840) ? _cw : -1;
let _sd_cwidth = _sd_range(fs_defaults.content_width, 1280, 3840);
/* The five colour axes each hold one of three things: '' or '0' for off, '1'..'360' for a hue,
or '#rrggbb'. Both live forms reach the client as a string, and the pre-paint re-reads which
it is the way fs-prefs.js's normColor() does, so this passes through exactly those two shapes
and turns everything else into ''. The hex charset is whitelisted because the value is
interpolated into the inline data block below.
(Do not write that block's tag name in a comment: tools/lib/ut-scripts.mjs finds the inline
scripts by matching the opening tag, and a mention in prose is one it will match.) */
(Do not write that block's tag name in a comment: the inline scripts are found by matching
the opening tag, so a mention in prose would match too.) */
function _sd_color(v) {
if (type(v) == 'string' && match(lc(v), /^#[0-9a-f]{6}$/))
return lc(v);
@@ -53,32 +64,14 @@
let _sd_ctrl = _sd_hex(fs_defaults.control);
let _sd_bar = _sd_hex(fs_defaults.bar);
let _sd_line = _sd_hex(fs_defaults.line);
let _rr = int(fs_defaults.rounding);
/* rounding 0 is SHARP corners, a real choice, so '' (unset) must map to -1, not 0 */
let _sd_round = (fs_defaults.rounding != '' && _rr >= 0 && _rr <= 20) ? _rr : -1;
/* login_bg is the cache-bust TOKEN for the uploaded background (the cgi-upload md5), not the
image and not a path — the served path is fixed. Whitelisted to hex so a hand-edited config
cannot inject markup into the inline script that builds the url(). */
let _lb = fs_defaults.login_bg;
let _sd_lbg = (type(_lb) == 'string' && match(_lb, /^[a-f0-9]{6,64}$/)) ? _lb : '';
/* the Tint's strength 0-200 (% of the designed chroma). 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; unset -> -1, 0 being a real value (a transparent
scrim) */
let _pd = int(fs_defaults.photo_dim);
let _sd_pdim = (fs_defaults.photo_dim != '' && _pd >= 0 && _pd <= 100) ? _pd : -1;
/* the uploaded pattern's cache-bust token, whitelisted like login_bg and for the same reason;
the served path is fixed */
let _pt = fs_defaults.pattern;
let _sd_pat = (type(_pt) == 'string' && match(_pt, /^[a-f0-9]{6,64}$/)) ? _pt : '';
/* the pattern's tile edge in px and the layer's opacity 0-100; unset -> -1, since 0 is a real
strength and 0 px is not a tile */
let _ps = int(fs_defaults.pattern_size);
let _sd_psize = (fs_defaults.pattern_size != '' && _ps >= 40 && _ps <= 1600) ? _ps : -1;
let _pst = int(fs_defaults.pattern_strength);
let _sd_pstr = (fs_defaults.pattern_strength != '' && _pst >= 0 && _pst <= 100) ? _pst : -1;
let _sd_round = _sd_range(fs_defaults.rounding, 0, 20);
let _sd_lbg = _sd_token(fs_defaults.login_bg);
let _sd_tstr = _sd_range(fs_defaults.tint_strength, 0, 200);
let _sd_pdim = _sd_range(fs_defaults.photo_dim, 0, 100);
let _sd_pat = _sd_token(fs_defaults.pattern);
/* the pattern's tile edge in px and the layer's opacity 0-100 */
let _sd_psize = _sd_range(fs_defaults.pattern_size, 40, 1600);
let _sd_pstr = _sd_range(fs_defaults.pattern_strength, 0, 100);
/* ---- the fonts ----
The theme carries no faces, so the two stacks in styles/02-tokens.css are what everyone gets
unless the router names something else here. Two independent options, either usable alone:
@@ -121,8 +114,7 @@
}
let _sd_fsans = _sd_family(fs_defaults.font_sans);
let _sd_fmono = _sd_family(fs_defaults.font_mono);
let _ft = fs_defaults.fonts;
let _sd_ftok = (type(_ft) == 'string' && match(_ft, /^[a-f0-9]{6,64}$/)) ? _ft : '';
let _sd_ftok = _sd_token(fs_defaults.fonts);
/* ---- writing direction ----
Nothing in LuCI emits `dir`: the dispatcher stamps `lang` and stops, so a router in Arabic,
Persian or Hebrew renders right-to-left text inside a left-to-right document — paragraphs
@@ -150,23 +142,23 @@
{# The router-wide Appearance defaults, read by both the pre-paint below and fs-prefs.js's
current*(), so the Appearance controls show the effective default when this browser has no
localStorage. See the sanitiser above for the unset encodings. #}
<script>window.__fsPlugins={{ sprintf('%J', fs_plugins) }};</script>
<script>window.__fsSD={layout:"{{ _sd_lay }}",darkmode:"{{ _sd_dark }}",palette:"{{ _sd_pal }}",wallpaper:"{{ _sd_wall }}",tint:"{{ _sd_tint }}",accent:"{{ _sd_acc }}",good:"{{ _sd_good }}",warn:"{{ _sd_warn }}",danger:"{{ _sd_dang }}",card:"{{ _sd_card }}",control:"{{ _sd_ctrl }}",bar:"{{ _sd_bar }}",line:"{{ _sd_line }}",rounding:{{ _sd_round }},autocollapse:"{{ _sd_ac }}",login_bg:"{{ _sd_lbg }}",tint_strength:{{ _sd_tstr }},photo_dim:{{ _sd_pdim }},density:"{{ _sd_dens }}",pattern:"{{ _sd_pat }}",pattern_size:{{ _sd_psize }},pattern_strength:{{ _sd_pstr }},pattern_ink:"{{ _sd_pink }}",content_width:{{ _sd_cwidth }}};</script>
<script>
/* Each IIFE below pre-paints one Appearance axis before the first frame, duplicating a
live applier in fs-prefs.js. They cannot share code — this runs before the module
loader exists — so tools/axes.mjs derives the contract (keys, attributes, properties,
ranges, default, order) from the JS and holds this template to it.
loader exists — so the contract (keys, attributes, properties,
ranges, default, order) is derived from the JS and this template is held to it.
ONE element for all of them, and exactly two in this file: the data blob above carries
interpolations and this one carries none. That is the split tools/lib/ut-scripts.mjs
keys off — an interpolated body is not JS until the server renders it, so it is exempt
from the lint and everything mergeable into it would go unchecked with it. Two is also
what a Content-Security-Policy would need nonces for, should uhttpd ever send one.
interpolations and this one carries none. That is the split the inline-script
lint keys off — an interpolated body is not JS until the server renders it, so it is
exempt from the lint and everything mergeable into it would go unchecked with it. Two
is also what a Content-Security-Policy would need nonces for, should uhttpd ever send
one.
Precedence for every axis: localStorage ?? router default (window.__fsSD) ?? built-in.
Dark mode: a saved preference, else the router default, else the OS. */
(() => {
const root = document.querySelector(':root'),
const root = document.documentElement,
sd = (window.__fsSD || {}).darkmode;
let saved = null;
try { saved = localStorage.getItem('fs-darkmode'); } catch (e) {}
@@ -210,7 +202,7 @@
applier's lsGet means. The outer try is the last resort for everything else. */
const lsGet = (k) => { try { return localStorage.getItem(k); } catch (e) { return null; } };
try {
const root = document.querySelector(':root'),
const root = document.documentElement,
sd = window.__fsSD || {};
let p = lsGet('fs-palette');
/* A stray or retired palette name needs no migration here: it fails the list
@@ -234,7 +226,7 @@
/* /cgi-bin, not a /www symlink: a plain static SVG is a same-origin document
that runs script opened directly (OpenWrt forum thread 251930); this path
is the CGI handler that answers with CSP 'none' + sandbox instead — see
fs-axes.js's PAT_SERVE, which this string must keep matching. */
the serve path of fs-axes.js's PATTERN, which this string must keep matching. */
if (pu) root.style.setProperty('--fs-pattern-url', 'url("/cgi-bin/luci-theme-footstrap-pattern?v=' + pu + '")');
const psRaw = lsGet('fs-pattern-size');
const ps = (psRaw !== null) ? parseInt(psRaw, 10) : sd.pattern_size;
@@ -260,7 +252,7 @@
if (dn === 'compact' || dn === 'large') root.setAttribute('data-density', dn);
else root.removeAttribute('data-density');
/* Content width (issue #44): how far the column may grow past the 1280px
/* Content width: how far the column may grow past the 1280px
default, as a px length rather than a step name — the slider is continuous.
Written only when it differs from the default, so an untouched theme paints
from the bare :root exactly as it did before this axis existed.
@@ -351,7 +343,7 @@
(() => {
try {
if (localStorage.getItem('fs-rail') === 'true')
document.querySelector(':root').setAttribute('data-rail', 'true');
document.documentElement.setAttribute('data-rail', 'true');
} catch (e) {}
})();
@@ -362,7 +354,7 @@
try {
const saved = localStorage.getItem('fs-layout');
if (saved === 'top' || saved === 'sidebar')
document.querySelector(':root').setAttribute('data-layout', saved);
document.documentElement.setAttribute('data-layout', saved);
} catch (e) {}
})();
</script>
@@ -374,7 +366,7 @@
The literal is the default palette's page colour and the SAME value manifest.json carries,
for the same reason: neither file can be rendered per request. It is a starting value
only — fs-prefs.js repaints this tag from the live body background, so it follows palette,
mode and every tint axis. tests/theme-color.test.mjs holds the two literals equal.
mode and every tint axis. The two literals must stay equal.
One tag, no `media` pair: a media-qualified tag would win over this one for a viewer whose
OS is dark but who chose light HERE, and the choice is the theme's to honour. #}
@@ -423,7 +415,7 @@
JSON type only as `.json`. The map is compiled into uhttpd. #}
<link rel="manifest" href="{{ media }}/manifest.json?v={{ pkgs_update_time }}">
{# iOS reads this link and never the manifest's icons array, and it scales the 192 square the
manifest also uses down to its own 180. One raster serves both — tools/build-icons.mjs. #}
manifest also uses down to its own 180. One raster serves both. #}
<link rel="apple-touch-icon" href="{{ media }}/app-icon-192.png">
{% if (dispatched?.css): %}
{# The `css` property of the dispatched menu.d node: an app naming a stylesheet the server
@@ -107,7 +107,7 @@
<div class="cbi-value cbi-value-last">
<label class="cbi-value-title" for="luci_password">{{ _('Password') }}</label>
<div class="cbi-value-field">
<input class="cbi-input-text" type="password" name="luci_password" id="luci_password" autocomplete="current-password" />
<input class="cbi-input-text" type="password" name="luci_password" id="luci_password" autocomplete="current-password" autofocus />
</div>
</div>
{#
@@ -177,15 +177,10 @@
{# The two server values the script below needs, handed over as DATA: a `{{ … }}` block is not JS
until the server renders it, so this line carries no logic at all and eslint's .ut processor
enforces that (tools/lib/ut-scripts.mjs). The script itself is plain JS and is linted. #}
enforces that. The script itself is plain JS and is linted. #}
<script>window.__fsHttps={ports:{{ https_ports }},resource:"{{ resource }}"};</script>
<script>
(() => {
const input = document.getElementsByName('luci_password')[0];
if (input)
input.focus();
/* if the router also serves HTTPS, hop over to it before the password goes over this
* connection; the <img> probe is upstream's trick for proving the TLS port answers first
*