update 2026-10-24 23:15:37

This commit is contained in:
action committed 2026-10-24 23:15:37 +08:00
1 parent 6d61e8bf50
commit 852fced2b3
178 files changed
+381 -38050

No files matched your search

@@ -1,33 +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 under <body>,
and this one stays inside <main> so both layouts order it within the flex column.
The version strings are entityencode()d: an unescaped `&` in a distname would 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');L.require('fs-select')</script>
{% endif %}
</body>
</html>
@@ -1,217 +0,0 @@
{#
Copyright 2008 Steven Barth <steven@midlink.org>
Copyright 2012 David Menting <david@nut-bolt.nl>
Copyright 2008-2022 Jo-Philipp Wich <jo@mein.io>
Copyright 2026 footstrap theme
Licensed to the public under the Apache License 2.0.
i18n, and the rule the other templates point at. A msgid is a GLOBAL name: LuCI serves one merged
catalogue and the first archive holding the hash wins, so the Appearance layout toggle rendered
"Максимум" on a Russian router because another catalogue translates `Top` as "maximum". Every
Appearance label and every string of the search overlay therefore carries the `footstrap` msgctxt;
the chrome's strings (the Search trigger included), the login and notice sentences and
System/Memory/Storage in fs-overview.js stay msgctxt-free, so a bare msgid
inherits a translation in the ~40 languages this theme ships no catalogue for. Skip to content
and Log out are luci-base msgids verbatim; the other chrome strings stay bare for it to adopt.
-#}
{%
import { getuid, getspnam } from 'luci.core';
const boardinfo = ubus.call('system', 'board') ?? {};
/* The router's saved Appearance defaults (/etc/config/footstrap): head.ut stamps them before
paint and the client's localStorage overrides every one. Absent = the built-in defaults.
Handed over as the whole section: head.ut validates every key it reads, so no list of axes
lives here to fall out of step. `uci` is the cursor dispatcher.uc builds per request; a
second one here would allocate one per page render. */
const fs_defaults = uci.get_all('footstrap', 'settings') ?? {};
http.prepare_content('text/html; charset=UTF-8');
-%}
{% include('themes/footstrap/partials/head', { boardinfo, fs_defaults }) %}
{#
data-page carries the DISPATCH path (ctx.path), not request_path: on a firstchild route
/admin/status renders the overview while request_path is only ['admin','status'], so a
`[data-page='admin-status-overview']` rule would silently not apply. The SPA router stamps the
resolved leaf path, so both ways of arriving agree. Stamped on <body> for the JS modules and
again on .fs-content below for page-scoped CSS: the modules load too late, and the
Overview's stray <h2 name="content"> was painted for 65-138 ms before them.
#}
{% const fs_page = entityencode(join('-', length(ctx.path) ? ctx.path : ctx.request_path), true) %}
<body class="lang_{{ dispatcher.lang }}" data-page="{{ fs_page }}">
{% if (!blank_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 script in <body>, before
* any script-built node; chrome pages only, the one place fs-router loads. Own property of
* this <body> instance, never Node.prototype. */
(() => {
const body = document.body;
const adds = window.__fsBodyAdds || (window.__fsBodyAdds = []);
[ 'appendChild', 'insertBefore', 'replaceChild', 'append', 'prepend',
'insertAdjacentElement', 'insertAdjacentHTML' ].forEach((name) => {
const orig = body[name];
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;
}
});
});
})();
</script>
{% endif %}
{#
The wallpaper pattern's paint surface: a mask on this element lets an uploaded SVG take the
theme's ink, and a mask applies to the whole rendering, children included, so it needs a
layer of its own with nothing in it. Outside the blank_page guard on purpose: the login
page renders without the shell, and a router whose saved default is Pattern shows it
there too. Inert unless the axis is on (display:none by default).
data-fs-chrome: zone 1, ours; see the <nav> below.
#}
<div class="fs-pattern" data-fs-chrome aria-hidden="true"></div>
{% if (!blank_page): %}
{#
Skip link: jump the menu instead of tabbing through every section on every page.
Visually hidden until focused. data-fs-chrome: zone 1; see the <nav> below.
#}
<a class="fs-skip" data-fs-chrome href="#maincontent">{{ _('Skip to content') }}</a>
<div class="fs-shell">
{#
<nav>, not <aside>: this column IS the site navigation; <aside> gets
role="complementary" and leaves the shell with no navigation landmark.
data-fs-chrome marks a zone-1 ROOT: no third-party rule may match it (the fence in
fs-sheets.js) or reach it by inheritance (the pin). Both key off the attribute, never
a class list: the skip link and search overlay sit outside this <nav>. Roots only,
never nested: a direct declaration beats an inherited one, and nesting cost
.fs-label its nowrap and forced text-align left on 302 elements.
#}
<nav class="fs-sidebar" data-fs-chrome aria-label="{{ _('Menu') }}">
<div class="fs-brandrow">
{# Brand block: logo + hostname wordmark. #}
<a class="fs-brand" href="{{ dispatcher.build_url() }}">
<span class="fs-logo">
{#
The OpenWrt mark, same as the tab favicon (logo.svg): the cyan arcs are baked, the
ring flips with the theme mode through .fs-logo-ring.
#}
<svg aria-hidden="true" viewBox="-9 -1 100 100"><path d="m40.5 50.8a6.6 6.6 0 1 0 0 13.2 6.6 6.6 0 0 0 0-13.2m-40.5-33.9 7 6.8a47.5 47.5 0 0 1 67.2 0l7-6.8a57.2 57.2 0 0 0-81.2 0" fill="#00b5e2"/><path d="m12.5 29.2 6.8 7a30 30 0 0 1 42.6 0l6.8-7a39.7 39.7 0 0 0-56.3 0" fill="#00b5e2"/><path d="m24.8 41.6 6.8 6.9a12.6 12.6 0 0 1 18 0l6.8-6.9a22.3 22.3 0 0 0-31.6 0" fill="#00b5e2"/><path class="fs-logo-ring" d="m64.9 39.7a30.2 30.2 0 1 1-48.7 0l-6.9-7a39.9 39.9 0 1 0 62.5 0z"/></svg>
</span>
<span class="fs-wordmark">{{ entityencode(striptags(boardinfo.hostname ?? 'OpenWrt'), true) }}</span>
</a>
{#
The three chrome icons below are the same 24x24 outline differing only in path data;
the stroke is `.fs-ico` in theme/20-shell.css, not attributes, and fs-widgets.js's
svgIcon() emits the same markup client-side. aria-hidden: each sits beside its own label.
#}
<button type="button" class="fs-railtoggle" id="fs-rail-toggle" aria-label="{{ _('Collapse menu') }}" title="{{ _('Collapse menu') }}">
<svg class="fs-ico" aria-hidden="true" viewBox="0 0 24 24"><path d="M15 6l-6 6 6 6"/></svg>
</button>
</div>
{# LuCI's poll status ("Refreshing") and other indicators land here by id. #}
<div id="indicators"></div>
<div class="fs-navlabel">{{ _('Menu') }}</div>
<ul class="nav" id="topmenu"></ul>
<ul id="modemenu" class="fs-modemenu"></ul>
<div class="fs-spacer"></div>
{#
right cluster, in visual order: the poll pill above, then Search, Log out. The
Appearance controls are their own page, System -> Footstrap, from the theme's own
menu.d entry; nothing of them mounts in this header.
#}
{#
Search trigger (#fs-search-btn), wired by fs-search.js. A `.fs-themerow`, the chrome's
shared row/button class: a square icon button in the bar and the rail, a full-width
labelled row in the vertical sidebar. It shares `order: 2` with the poll pill and Log
out, so DOM order is visual order. 'Search' carries no msgctxt (see the i18n note).
#}
<button type="button" class="fs-themerow" id="fs-search-btn" aria-haspopup="dialog" aria-expanded="false" aria-controls="fs-search-ov" title="{{ _('Search') }}" aria-label="{{ _('Search') }}">
<svg class="fs-ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M16.5 16.5 21 21"/></svg>
<span class="fs-label">{{ _('Search') }}</span>
</button>
{#
Log out, guarded on the node being dispatchable. It is the only logout the theme
renders: fs-chrome.js drops the menu tree's own admin/logout node (fs-search.js likewise).
#}
{% if (dispatcher.lookup("admin/logout")): %}
<a class="fs-logout" href="{{ dispatcher.build_url('admin/logout') }}" title="{{ _('Log out') }}" aria-label="{{ _('Log out') }}">
{#
Mirrored about x=12, so the glyph reads LEAVING rather than entering: each x becomes
24-x and each arc's sweep flag flips with it, leaving stroke widths, corner radii and
the 24x24 box untouched.
#}
<svg class="fs-ico" aria-hidden="true" viewBox="0 0 24 24"><path d="M10 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4"/><path d="M14 12h7m0 0-3-3m3 3-3 3"/></svg>
<span class="fs-label">{{ _('Log out') }}</span>
</a>
{% endif %}
</nav>
<main class="fs-main" id="maincontent" tabindex="-1">
{#
The document's <h1>: LuCI views start at <h2>, so without it the heading outline
begins mid-way down. It must exist for a screen reader without being seen, which
`hidden` cannot do — display:none strips the element from the accessibility tree,
so the SPA router's .fs-title-main sync would update a node no assistive tech can
read. .fs-sr clips instead, and since a foreign `*` outranks that class with one
`!important` it is marked zone 1 too. Inside .fs-main, which is not marked: that
is where app pages render.
#}
<h1 class="fs-title-main fs-sr" data-fs-chrome>{{ entityencode(striptags(dispatched?.title ? _(dispatched.title) : (boardinfo.hostname ?? 'OpenWrt')), true) }}</h1>
{#
The SPA router swaps the page with no reload, which a screen reader cannot
notice: no load event, focus dies with the clicked <a>, the new <title> is not
announced. The router writes the incoming title here after every nav; empty on a
full load, which announces itself.
#}
<div id="fs-nav-status" class="fs-sr" data-fs-chrome role="status" aria-live="polite"></div>
<div class="fs-content" data-page="{{ fs_page }}">
{#
Top-of-content notices: no-root-password, initramfs recovery, the <noscript> fallback
and the #tabmenu container. The sentences carry no msgctxt on purpose (see the i18n
note at the top). empty_password is read here, not above: /etc/shadow is only worth
a read on a page that renders the shell.
#}
{% const empty_password = (getuid() == 0 && getspnam('root')?.pwdp === '') %}
{% if (empty_password): %}
{% include('themes/footstrap/partials/notice', {
title: _('No password set!'),
text: _('There is no password set on this router. Please configure a root password to protect the web interface.'),
path: "admin/system/admin",
label: _('Go to password configuration...')
}) %}
{% endif %}
{% if (boardinfo.rootfs_type == "initramfs"): %}
{% include('themes/footstrap/partials/notice', {
title: _('System running in recovery (initramfs) mode.'),
text: _('No changes to settings will be stored and are lost after rebooting. This mode should only be used to install a firmware upgrade'),
path: "admin/system/flash",
label: _('Go to firmware upgrade...')
}) %}
{% endif %}
<noscript>
{% include('themes/footstrap/partials/notice', {
title: _('JavaScript required!'),
text: _('You must enable JavaScript in your browser or LuCI will not work properly.')
}) %}
</noscript>
<div id="tabmenu" style="display:none"></div>
{% endif %}
@@ -1,411 +0,0 @@
{#
<head> for the ONE template. include(…, { boardinfo, fs_defaults }); everything else comes from
the render scope. fs_defaults = the ROUTER's saved Appearance defaults (luci-theme-footstrap
/etc/config/footstrap), which the client's own localStorage overrides before paint.
Licensed to the public under the Apache License 2.0.
-#}
<!DOCTYPE html>
{%
/* Sanitise the router's saved Appearance defaults into the window.__fsSD object the pre-paint
below reads. Enums are whitelisted and numbers clamped, so a hand-edited config can neither
break the inline script nor inject markup (defence in depth: writing that file needs root).
Absent or invalid becomes '' (or -1 where 0 is a real value), meaning "no router default".
`fs_defaults` is a required include parameter: header.ut is the only caller, so a `?? {}`
here would only hide that contract. */
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', '2020', 'forum' ], fs_defaults.palette) >= 0 ? fs_defaults.palette : '';
let _sd_wall = index([ 'off', 'pattern', 'file' ], fs_defaults.wallpaper) >= 0 ? fs_defaults.wallpaper : '';
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 : '';
/* Absent (null) or '' maps to -1, never 0: 0 is a real value for the four axes whose range
starts at 0 (rounding, tint_strength, photo_dim, pattern_strength) and int(null) is 0. */
function _sd_range(raw, min, max) {
let n = int(raw);
return (type(raw) == 'string' && n >= min && n <= max) ? n : -1;
}
/* A cache-bust TOKEN (the cgi-upload md5 of an uploaded sheet), not a path. Whitelisted to
hex so a hand-edited config cannot inject markup into the inline script that prints 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 */
let _sd_cwidth = _sd_range(fs_defaults.content_width, 1280, 3840);
/* Hex or nothing: the four surface axes (cards, inset controls, the chrome bar, hairlines)
* have no hue mode, where a hue such as '5' would pass _sd_color's number branch. */
function _sd_hex(v) {
return (type(v) == 'string' && match(lc(v), /^#[0-9a-f]{6}$/)) ? lc(v) : '';
}
/* 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: the inline scripts are found by matching
the opening tag, so a mention in prose would match too.) */
function _sd_color(v) {
let n = int(v);
return _sd_hex(v) || ((n >= 1 && n <= 360) ? `${n}` : '');
}
let _sd_tint = _sd_color(fs_defaults.tint);
let _sd_acc = _sd_color(fs_defaults.accent);
let _sd_good = _sd_color(fs_defaults.good);
let _sd_warn = _sd_color(fs_defaults.warn);
let _sd_dang = _sd_color(fs_defaults.danger);
let _sd_card = _sd_hex(fs_defaults.card);
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 _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: font_sans / font_mono name a stack, and `fonts` is the cache-bust
md5 of an @font-face sheet at /etc/footstrap/fonts/fonts.css. The package only serves that
directory (kept across a sysupgrade); it installs and fetches no face.
A stack is printed UNESCAPED, so the charset excludes < > ( ) { } ; and the backslash: no way
to close the style element, start a second declaration or write a url(). Both quote kinds stay
allowed and are scanned in order, never counted: the opener wins until its own kind closes
it, and a quote still open at the end refuses the value (it would eat the next stack). */
function _sd_family(v) {
if (!(type(v) == 'string' && length(v) >= 1 && length(v) <= 120 &&
match(v, /^[A-Za-z0-9 ,._"'-]+$/)))
return '';
let q = '';
for (let c in split(v, '')) {
if (q == '')
q = (c == '"' || c == "'") ? c : '';
else if (c == q)
q = '';
}
return (q == '') ? v : '';
}
let _sd_fsans = _sd_family(fs_defaults.font_sans);
let _sd_fmono = _sd_family(fs_defaults.font_mono);
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
align to the wrong edge and every mirrored affordance points the wrong way. The theme owns
<html>, so this is the one place that can say it.
Matched on the base subtag (a future `ar-EG` is covered); the list is every base subtag whose
CLDR default script is right-to-left, not the four LuCI ships catalogues for. `ltr` is written
explicitly for the same reason data-layout is: every rule then matches positively. */
let _lang = dispatcher.lang;
let _lbase = lc(split(_lang, /[-_]/)[0]);
let _rtl = index([ 'ar', 'arc', 'ckb', 'dv', 'fa', 'he', 'khw', 'ks', 'ps', 'sd', 'ug', 'ur', 'yi' ], _lbase) >= 0;
%}
{#
The server always stamps data-layout with an EXPLICIT value (sidebar|top), so every layout rule
matches positively and a future third layout must opt in rather than inherit another's rules by
not being it — never write a :not([data-layout=…]) guard. The chrome is right with JS off; the
script below only overrides it.
Absent = TOP: the bar is what the theme is built around (every layout rule starts from it, with
the vertical column as the one guarded override) and it is what a phone gets at any setting.
#}
<html lang="{{ dispatcher.lang }}" dir="{{ _rtl ? 'rtl' : 'ltr' }}" data-layout="{{ _sd_lay == 'sidebar' ? 'sidebar' : 'top' }}">
<head>
<meta charset="utf-8">
{#
entityencode around striptags: striptags drops tags but escapes neither `&` nor quotes,
so a hostname with `&` breaks entity parsing
#}
<title>{{ entityencode(striptags(`${boardinfo.hostname ?? 'OpenWrt'}${dispatched?.title ? ` | ${_(dispatched.title)}` : ''}`), true) }}</title>
{#
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.__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>
/* One IIFE pre-paints every Appearance axis before the first frame, duplicating the live
applier in fs-prefs.js (no shared code: this runs before the module loader exists).
It carries no interpolation, unlike the data blob above: an interpolated body is not
JS until rendered, so it is exempt from the inline-script lint and anything merged
into it would go unchecked.
Precedence for every axis: localStorage ?? router default (window.__fsSD) ?? built-in.
Dark mode: a saved preference, else the router default, else the OS. */
(() => {
/* Guarded per read, not once around the block: a browser that refuses storage throws
on the FIRST getItem, and one try around everything aborts the whole pre-paint —
every axis below skipped, so a router with saved defaults paints none of them.
Storage failing must mean "this browser has no override", which is what the live
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; } };
const root = document.documentElement,
sd = window.__fsSD || {};
/* the icon-rail preference, so a reload does not flash the full sidebar first. Not a
router-wide default: a transient chrome collapse, not an appearance choice. */
if (lsGet('fs-rail') === 'true') root.setAttribute('data-rail', 'true');
/* The saved layout overrides the server stamp before paint. Only a known value counts, so
a corrupt one leaves the stamp standing instead of blanking the attribute. */
const lay = lsGet('fs-layout');
if (lay === 'top' || lay === 'sidebar') root.setAttribute('data-layout', lay);
try {
let p = lsGet('fs-palette');
/* A stray or retired palette name fails the list below and paints the bare :root, as
listAxis() in fs-prefs.js does. The default palette is a bare :root, every other
colourway opt-in; the list matches fs-prefs.js, or a name in one only would paint
here and be taken away by the first live change. */
if (p === null) p = sd.palette || null;
if (p === 'hicontrast' || p === 'bootstrap' || p === '2020' || p === 'forum') root.setAttribute('data-palette', p);
else root.removeAttribute('data-palette');
/* Wallpaper: off | pattern (the uploaded SVG, tiled and recoloured) | file (the
uploaded photo). The url() and the two pattern numbers are set BEFORE the
attribute, or the frame paints with the tile at the previous size.
sd.pattern is whitelisted to hex above, so nothing user-controlled reaches
the url(). */
const pu = sd.pattern;
/* /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
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;
if (ps >= 40 && ps <= 1600 && ps !== 440) root.style.setProperty('--fs-pattern-size', ps + 'px');
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 = 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 = 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');
/* Density: a pure token axis (02-tokens multiplies the type and space ladders).
Normal is a bare :root, so the removeAttribute half matters as much as the
set, or a browser switched back to Normal paints one frame compressed. */
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');
/* Content width: how far the column may grow past the 1280px default, as a px length
(the slider is continuous). Written only when it differs from the default.
sd.content_width is -1 when unset. */
const cwRaw = lsGet('fs-content-width');
let cw = parseInt(cwRaw, 10);
if (cwRaw === null) cw = sd.content_width;
if (cw >= 1280 && cw <= 3840 && cw !== 1280)
root.style.setProperty('--fs-content-max', cw + 'px');
/* Rounding 0-20px (the control and chip radii derive from it). The default is a literal
because this runs before the stylesheet; keep it equal to the token. -1 when unset. */
const rRaw = lsGet('fs-radius');
let r = parseInt(rRaw, 10);
if (rRaw === null) r = sd.rounding;
if (r >= 0 && r <= 20 && r !== 12)
root.style.setProperty('--fs-radius-base', r + 'px');
/* The five colour axes: Tint (page canvas), Accent and the three status colours. Each
is off, a hue 1-360 rotating the palette colour, or #rrggbb replacing it; mirrors
colorAxis()/normColor(). The property is set BEFORE the attribute that switches the
rotation on, or a reload paints one frame in the previous colour. Each mode clears
the other's property, as the live applier does. */
const hex = /^#[0-9a-f]{6}$/i;
/* inkFor() is fs-axes.js's function verbatim: black above OKLab L .62, else white;
the two copies must give equal output. */
const inkFor = (hx) => {
const [ lr, lg, lb ] = [ 1, 3, 5 ].map((i) => {
const u = parseInt(hx.slice(i, i + 2), 16) / 255;
return (u <= 0.04045) ? (u / 12.92) : (((u + 0.055) / 1.055) ** 2.4);
});
const f = (x, y, z) => Math.cbrt((x * lr) + (y * lg) + (z * lb));
return (((0.21045 * f(0.41222, 0.53633, 0.05145)) + (0.79362 * f(0.2119, 0.6807, 0.1074)) - (0.00407 * f(0.0883, 0.28172, 0.62998))) > 0.62) ? '#000' : '#fff';
};
const colour = (key, sdVal, attr, hueProp, colorProp, inkProp) => {
const raw = lsGet(key);
const v = String((raw === null ? sdVal : raw) ?? '').trim();
if (hex.test(v)) {
root.style.removeProperty(hueProp);
root.style.setProperty(colorProp, v.toLowerCase());
if (inkProp) root.style.setProperty(inkProp, inkFor(v));
root.setAttribute(attr, 'hex');
return;
}
const h = parseInt(v, 10);
if (h >= 1 && h <= 360) {
root.style.removeProperty(colorProp);
if (inkProp) root.style.removeProperty(inkProp);
root.style.setProperty(hueProp, String(h));
root.setAttribute(attr, 'hue');
}
};
colour('fs-tint', sd.tint, 'data-tint', '--fs-tint-h', '--fs-bg');
colour('fs-accent', sd.accent, 'data-accent', '--fs-accent-h', '--fs-accent', '--fs-on-accent');
colour('fs-good', sd.good, 'data-good', '--fs-good-h', '--fs-good', '--fs-on-good');
colour('fs-warn', sd.warn, 'data-warn', '--fs-warn-h', '--fs-warn', '--fs-on-warn');
colour('fs-danger', sd.danger, 'data-danger', '--fs-danger-h', '--fs-danger', '--fs-on-danger');
/* The four surface axes — cards, inset controls, the chrome bar, hairlines. No
hue mode and therefore no attribute, just the token or nothing. Mirrors
surfaceAxis() in fs-prefs.js. */
const surface = (key, sdVal, prop) => {
const raw = lsGet(key);
const v = String((raw === null ? sdVal : raw) ?? '').trim().toLowerCase();
if (hex.test(v)) root.style.setProperty(prop, v);
};
surface('fs-card', sd.card, '--fs-panel-base');
surface('fs-control', sd.control, '--fs-panel2-base');
surface('fs-bar', sd.bar, '--fs-bar-bg');
surface('fs-line', sd.line, '--fs-border-base');
/* The uploaded photo's URL: router-side, the token coming purely from window.__fsSD.
Set the property whenever a token exists; whether it PAINTS is the wallpaper axis
above, so a router defaulting to 'file' shows it here, pre-login included. The token
is whitelisted to hex above. */
if (sd.login_bg)
root.style.setProperty('--fs-login-bg-url', 'url("/luci-static/footstrap/bg?v=' + sd.login_bg + '")');
/* the Tint's strength (default 100% = the designed chroma) */
const tsRaw = lsGet('fs-tint-strength');
let ts = parseInt(tsRaw, 10);
if (tsRaw === null) ts = sd.tint_strength;
if (ts >= 0 && ts <= 200 && ts !== 100)
root.style.setProperty('--fs-tint-strength', String(ts / 100));
/* the File photo's scrim opacity, same shape as the tint strength above; the 74%
default lives in the CSS token */
const pdRaw = lsGet('fs-photo-dim');
let pd = parseInt(pdRaw, 10);
if (pdRaw === null) pd = sd.photo_dim;
if (pd >= 0 && pd <= 100 && pd !== 74)
root.style.setProperty('--fs-photo-dim', pd + '%');
} catch (e) {}
const saved = lsGet('fs-darkmode');
const mq = window.matchMedia('(prefers-color-scheme: dark)');
/* data-darkmode is what this theme's CSS reads; data-theme and data-bs-theme are outbound
compat for apps that sniff them (justclash keys 21 rules off data-theme). Must stay
identical to stampDark() in fs-prefs.js. */
function set(dark) {
root.setAttribute('data-darkmode', dark ? 'true' : 'false');
root.setAttribute('data-theme', dark ? 'dark' : 'light');
root.setAttribute('data-bs-theme', dark ? 'dark' : 'light');
}
/* localStorage stores 'true'/'false' or 'auto' (explicitly follow the OS); absent
falls to the router default, then the OS */
const explicit = (saved === 'true') ? true
: (saved === 'false') ? false
: (saved === 'auto') ? 'auto'
: (sd.darkmode === 'dark') ? true
: (sd.darkmode === 'light') ? false
: 'auto';
set(explicit === true || (explicit === 'auto' && mq.matches));
/* always registered, and it re-checks storage: the user can switch to Auto after
loading with a saved value, and OS flips must track live from then on */
mq.addEventListener('change', (m) => {
const s = lsGet('fs-darkmode');
if (s === 'true' || s === 'false') return;
if (s === null && (sd.darkmode === 'dark' || sd.darkmode === 'light')) return;
set(m.matches);
});
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="darkreader-lock">
{#
The colour a mobile browser paints its own chrome with (address bar, task switcher,
installed app title bar); without it a dark page keeps a white bar above it. The literal
is the default palette's page colour and the same value manifest.json carries: neither
file can be rendered per request, so the two must stay equal. fs-prefs.js repaints this
tag from the live body background. One tag, no `media` pair: a media-qualified tag would
win for a viewer whose OS is dark but who chose light HERE.
#}
<meta name="theme-color" content="#f6f8fa">
{#
The sheet link below is emitted only when the TOKEN says the sheet exists: set the option
after the file, never before. A preload is not in the stylesheet, so dropping the
@font-face rules alone leaves the preload behind and every page keeps asking the router
for files that are gone: six 404s per page, measured.
#}
{#
`data-fs-shell` marks the shell's own CSS: on every nav the SPA router asks whether a VIEW
put CSS in the document, and this is how it tells its own sheet from an app's.
#}
<link rel="stylesheet" data-fs-shell href="{{ media }}/cascade.css?v={{ pkgs_update_time }}">
{% if (_sd_ftok != ''): %}
{#
The generated @font-face sheet, and only the faces: the families come from the element
below, so setting font_sans by hand takes effect without regenerating anything. The token
is the sheet's md5 — uhttpd sends no Cache-Control, so without it a browser applies
heuristic freshness and keeps the previous face for days.
#}
<link rel="stylesheet" data-fs-shell href="{{ media }}/fonts/fonts.css?v={{ _sd_ftok }}">
{% endif %}
{% if (_sd_fsans != '' || _sd_fmono != ''): %}
{#
The router's own font stacks. Unlayered, so it beats the `tokens` layer declaring the
defaults whatever order the two sheets arrive in. Values are whitelisted at the top of
this file; nothing here escapes them.
#}
<style data-fs-shell>:root { {% if (_sd_fsans != ''): %}--fs-font-sans: {{ _sd_fsans }};{% endif %}{% if (_sd_fmono != ''): %} --fs-font-mono: {{ _sd_fmono }};{% endif %} }</style>
{% endif %}
<link rel="icon" href="{{ media }}/logo_48.png" sizes="48x48">
<link rel="icon" href="{{ media }}/logo.svg" sizes="any">
{#
Lets an admin install this page as an app (icon, own window, no address bar), worth more
than a tab on the phone LuCI is opened from when the network is broken. Both files are
static, a theme having no dispatcher node to render them per request: start_url is
/cgi-bin/luci/, uhttpd's default (a moved LuCI opens the standard path), and
background/theme_color is the default palette's page colour, read once at install time.
.json, not .webmanifest: uhttpd's compiled-in MIME map has no entry for that extension,
so it answers application/octet-stream.
#}
<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.
#}
<link rel="apple-touch-icon" href="{{ media }}/app-icon-192.png">
{% if (dispatched?.css): %}
{#
The `css` property of the dispatched menu.d node: a stylesheet the server links for the
page instead of the view injecting one at module eval. `dispatched`, not `node`: the
dispatcher exports the resolved node as `dispatched` and never binds `node`, so no ucode
theme emitted this link until openwrt/luci#8920. That commit also adds `css: 'string'` to
the node schema; without it the property is filtered out and this stays false, as on 24.10
and 25.12. Versioned, as uhttpd sends no Cache-Control. Not `data-fs-shell`: the sheet
belongs to one page, so the SPA router must judge and contain it like any app sheet.
#}
<link rel="stylesheet" href="{{ resource }}/{{ dispatched.css }}?v={{ pkgs_update_time }}">
{% endif %}
{% if (css): %}
{#
no `title` attribute: on a <style> it names a style sheet SET, making this a "preferred"
alternate sheet the user can switch off in Firefox's Page Style menu. `data-fs-shell` as
above.
#}
<style data-fs-shell>{{ css }}</style>
{% endif %}
{#
The catalogue is a CGI spawn on the critical path (30 ms on the router) that delivers 13
bytes at lang=en, where no en catalogue exists because the msgids already are English.
The process is the cost, so emit those bytes inline when the language has no catalogue
and keep the tag when it has. The probe mirrors the server: load_catalog() reads every
`*.<lang>.lmo` in /usr/lib/lua/luci/i18n, so a router shipping an en catalogue still gets
the tag; deciding by language NAME would drop it. Fails open: a throwing probe keeps the
tag, as a missing catalogue renders English and reports nothing.
#}
{%- let has_catalog = true;
try { has_catalog = length(require('fs').glob(`/usr/lib/lua/luci/i18n/*.${dispatcher.lang}.lmo`)) > 0; } catch (e) {} %}
{% if (has_catalog): %}
<script src="{{ dispatcher.build_url('admin/translations', dispatcher.lang) }}"></script>
{% else %}
<script>window.TR={};</script>
{% endif %}
{#
the one asset stock LuCI leaves without a cache key. Must stay synchronous and un-deferred:
luci.js captures `window.cbi_init` in its constructor, and `defer` is not an option for the
catalogue above either — footer.ut runs `L.require('menu-footstrap')` inline while the
parser is still running, so a module's `_()` would race a deferred window.TR.
#}
<script src="{{ resource }}/cbi.js?v={{ pkgs_update_time }}"></script>
</head>
@@ -1,16 +0,0 @@
{#
One top-of-content warning notice: heading, sentence and an optional "go fix it" button. The
`dispatcher.lookup` guard must not drift: `path` is looked up before it is linked, so a build
without that page, or a user without rights to it, gets the warning and no dead link.
include(…, { title, text }) for a plain notice; add { path, label } for the button.
title/text are emitted unescaped: they are `_()` results over strings this theme ships, not
request data.
Licensed to the public under the Apache License 2.0.
-#}
<div class="alert-message warning">
<h2>{{ title }}</h2>
<p>{{ text }}</p>
{% if (path && dispatcher.lookup(path)): %}
<div class="right"><a class="btn" href="{{ dispatcher.build_url(path) }}">{{ label }}</a></div>
{% endif %}
</div>
@@ -1,186 +0,0 @@
{#
The LOGIN page.
Copyright 2008 Steven Barth <steven@midlink.org>
Copyright 2008-2012 Jo-Philipp Wich <jow@openwrt.org>
Licensed to the public under the Apache License 2.0.
It exists because the generic template includes the header WITHOUT `blank_page: true`: with no
theme-local sysauth the whole chrome renders around the login form with every control dead.
No departure here may drop a field the backend posts.
Do not re-introduce a `<section hidden>` form that a view module reveals: the view bootstraps
before a session exists, its RPCs answer "Access denied", render() never runs and the page is
blank with no way to log in. The server renders the form, so it works with JS off.
The strings carry no msgctxt: luci-base translates them in the ~40 languages without a catalogue.
-#}
{%
/* Which router this is (openwrt/luci#8961): the login page has no chrome, so the only place
the name appeared was the browser tab. A second `ubus.call('system', 'board')`, because an
include cannot hand a local back from header.ut: one extra round trip on a page rendered
once per session. It discloses nothing new: the hostname already reaches an unauthenticated
browser through <title>, as in every stock theme. */
const boardinfo = ubus.call('system', 'board') ?? {};
-%}
{% include('header', { blank_page: true }) %}
{#
the class is the stylesheet's hook: the markup is ours, so the form is named rather than
found by a 49-character `:has()` selector repeated 17 times, ~0.8 KB
#}
<form method="post" class="fs-login">
{#
The router's name, above everything else in the card: an admin with three of these open has no
other way to tell the tabs apart once the form is focused. The `?? 'OpenWrt'` fallback and the
striptags/entityencode pair match head.ut and are load-bearing: the hostname is admin-controlled
text printed to an unauthenticated page. It is the card's visual title but a <p>, not a heading: the h1
`Authorization Required` below stays the one heading (clipped by .fs-sr), or a screen reader
hears the hostname as a section title.
-#}
<p class="fs-login-host">{{ entityencode(striptags(boardinfo.hostname ?? 'OpenWrt'), true) }}</p>
{#
Two independent alerts: neither variable alone says which dispatcher branch rendered this.
`fuser` is set in both branches, including the OTP step after a correct password; and
`auth_message` (copied from get_challenges() BEFORE the check, dispatcher.uc:956) is set on
every pending result. `auth_plugin` is set only once session_setup() accepted the
credentials: gate the credentials alert on `!auth_plugin`, print the message beside it.
entityencode, where upstream prints raw: plugin markup travels in `auth_html`, so this is
text on an open page.
-#}
{% if (fuser && !auth_plugin): %}
<div class="alert-message error">
<p>{{ _('Invalid username and/or password! Please try again.') }}</p>
</div>
{% endif %}
{% if (auth_message): %}
<div class="alert-message{% if (auth_plugin): %} warning{% endif %}">
<p>{{ entityencode(auth_message, true) }}</p>
</div>
{% endif %}
<div class="cbi-map">
{#
An h1, where every other LuCI theme leaves this an h2: the login page has no chrome, so
header.ut's `.fs-title-main` h1 never renders here and the document would have no top-level
heading for a screen reader. Clipped with .fs-sr, not shown: the hostname above is the
card's visible title and the words repeat what the fields say.
-#}
<h1 class="fs-sr">{{ _('Authorization Required') }}</h1>
<div class="cbi-section"><div class="cbi-section-node">
<div class="cbi-value">
<label class="cbi-value-title" for="luci_username">{{ _('Username') }}</label>
<div class="cbi-value-field">
<input class="cbi-input-text" type="text" name="luci_username" id="luci_username" autocomplete="username" value="{{ entityencode(duser, true) }}" />
</div>
</div>
<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" autofocus />
</div>
</div>
{#
The login backend's own fields: a pluggable backend puts its OTP input in `auth_fields`;
a template that drops them never posts it and nobody can log in with 2FA, so this one
stays a SUPERSET of the generic one's. The leading space in each conditional attribute
is load-bearing: ucode's lexer eats whitespace before `{%` and the newline after `%}`;
measured, the generic spelling renders
`inputmode="numeric"pattern="[0-9]*"maxlength="6"required`.
Every attribute value is entityencoded; maxlength goes through a template literal first, as
entityencode(6) is null.
-#}
{% if (auth_fields): %}
{% for (let field in auth_fields): %}
<div class="cbi-value">
<label class="cbi-value-title" for="{{ entityencode(field.name, true) }}">{{ _(field.label ?? field.name) }}</label>
<div class="cbi-value-field">
<input class="cbi-input-text"
type="{{ entityencode(field.type ?? 'text', true) }}"
name="{{ entityencode(field.name, true) }}"
id="{{ entityencode(field.name, true) }}"
{% if (field.placeholder): %} placeholder="{{ entityencode(field.placeholder, true) }}"{% endif %}
{% if (field.inputmode): %} inputmode="{{ entityencode(field.inputmode, true) }}"{% endif %}
{% if (field.pattern): %} pattern="{{ entityencode(field.pattern, true) }}"{% endif %}
{% if (field.maxlength): %} maxlength="{{ entityencode(`${field.maxlength}`, true) }}"{% endif %}
{% if (field.autocomplete): %} autocomplete="{{ entityencode(field.autocomplete, true) }}"{% endif %}
{% if (field.required): %} required{% endif %}
/>
</div>
</div>
{% endfor %}
{% endif %}
{% if (auth_html): %}
<div class="cbi-value">
{{ auth_html }}
</div>
{% endif %}
{% if (auth_assets): %}
{% for (let asset in auth_assets): %}
{% if (asset.type == 'script'): %}
<script src="{{ asset.src }}"></script>
{% endif %}
{% endfor %}
{% endif %}
</div></div>
</div>
<div class="cbi-page-actions">
<input type="submit" value="{{ _('Log in') }}" class="btn cbi-button cbi-button-apply" />
</div>
</form>
{%
let https_ports = uci.get('uhttpd', 'main', 'listen_https') ?? [];
https_ports = uniq(filter(
map(
(type(https_ports) == 'string') ? split(https_ports, /\s+/) : https_ports,
e => +match(e, /\d+$/)?.[0]
),
p => (p >= 0 && p <= 65535)
));
%}
{#
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. The script itself is plain JS and is linted.
#}
<script>window.__fsHttps={ports:{{ https_ports }},resource:"{{ resource }}"};</script>
<script>
(() => {
/* 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.
* The hop can arrive without the session: Chromium treats http to https as cross-site and
* withholds LuCI's `SameSite=strict` cookie, so a reader logged in over https lands back on
* this form (403 to the hop, 200 to curl with the cookie; Firefox sends it). Where this form
* is what the hop landed on, it is asked again once over the same scheme, which carries the
* cookie. The retry is bounded by the REFERRER, not a URL marker: a `#` marker never runs
* the retry on Firefox, so nothing takes the fragment back off the address. */
if (document.location.protocol === 'https:') {
/* the host is compared as a PARSED origin, not as a prefix of the string:
* `http://192.168.1.1.example.com` starts with `http://192.168.1.1` */
const from = document.referrer ? new URL(document.referrer) : null;
if (from && from.protocol === 'http:' && from.hostname === window.location.hostname)
window.location.replace(window.location.href);
return;
}
const { hostname, pathname } = window.location;
(window.__fsHttps.ports || []).forEach((port) => {
const url = `https://${hostname}:${port}${pathname}`;
const img = new Image();
img.onload = () => { window.location = url; };
img.src = `https://${hostname}:${port}${window.__fsHttps.resource}/icons/loading.svg?${Math.random()}`;
setTimeout(() => { img.src = ''; }, 5000);
});
})();
</script>
{% include('footer', { blank_page: true }) %}