🌴 Sync 2026-08-05 03:17:09

This commit is contained in:
github-actions[bot] committed 2026-08-05 03:17:09 +08:00
1 parent 2f3ffbb7d9
commit fffafa4359
91 files changed
+18955 -44

No files matched your search

@@ -0,0 +1 @@
{% include('themes/footstrap/partials/footer') %}
@@ -0,0 +1,184 @@
{#
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 — load_catalog() loads every *.<lang>.lmo in /usr/lib/lua/luci/i18n and a lookup
returns the first archive holding the hash — so readdir order decides who owns a string. The
Appearance layout toggle rendered "Максимум" on a Russian router (issue #6), because somebody
else's catalogue translates the msgid `Top` as "maximum". Anything we leave BARE is a name anyone
may take, and contexting cannot be selective.
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.
-#}
{%
import { getuid, getspnam } from 'luci.core';
const boardinfo = ubus.call('system', 'board') ?? {};
const empty_password = (getuid() == 0 && getspnam('root')?.pwdp === '');
/* The ROUTER's saved Appearance defaults (Appearance -> Save as default, in /etc/config/footstrap):
head.ut stamps them before paint and the client's own localStorage overrides every one — the
browser choice always wins. Layout falls back to 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). Absent = the built-in defaults.
`uci` and `config` come from the render scope, which dispatcher.uc builds per request: `uci` is
already a cursor (dispatcher.uc `let uci = cursor()`) and `config.main` is already
`uci.get_all('luci','main')`. Importing cursor from 'uci' and opening a second one allocated a
cursor per page render and re-read a package the dispatcher had loaded before calling us —
and left this template reaching for UCI a different way than sysauth.ut does. */
const fsd = uci.get_all('footstrap', 'settings') ?? {};
/* EVERY option saveAsDefault() writes, listed ONCE and read back in a loop.
*
* This used to be a hand-written object literal with one line per axis, and the shape of that
* mistake is on record twice: `density` was saved by saveAsDefault() and then never read back
* here, so the router-wide Density default reached no browser — and when the colour axes landed,
* the same thing happened again to all seven of them at once. The symptom is the worst kind:
* Save as default reports success, the file on disk is correct, and "Reset to saved" drops the
* browser to the BUILT-IN default instead of the saved one, because the value the server hands
* the client for that axis was never there. Nothing else notices — tools/axes.mjs held the
* pre-paint against the live applier and both were right; the gap was the SERVER read that feeds
* them, which is now what tools/axes.mjs checks against snapshotAxes().
*
* '' (not the raw undefined) when unset, so head.ut's `!= ''` guards fire — otherwise
* int(undefined) is 0, a VALID value, and an axis whose 0 is a real choice (rounding, the tint
* strength, the photo dim) would default to 0 instead of to its own default. */
const FS_AXES = [
'layout', 'darkmode', 'palette', 'wallpaper', 'density',
'tint', 'accent', 'good', 'warn', 'danger',
'card', 'control', 'bar', 'line',
'rounding', 'autocollapse', 'tint_strength', 'photo_dim',
'pattern_size', 'pattern_strength', 'pattern_ink',
/* the two uploaded images' cache-bust tokens (head.ut whitelists both to hex) — the files
themselves are served, only these tokens travel in the page. NOT axes: they have no
browser layer at all, but they reach the client the same way. */
'login_bg', 'pattern',
/* the fonts, same shape and for the same reason: no browser layer, no Appearance control,
no entry in snapshotAxes(). font_sans/font_mono are font-family stacks (head.ut
whitelists the charset and prints them into a style element); `fonts` is the cache-bust
token of the generated @font-face sheet, hex like the two above. All three are written by
fonts/set-font.sh or by hand with `uci set`, never by Save as default. */
'font_sans', 'font_mono', 'fonts'
];
const fs_defaults = {};
for (let k in FS_AXES)
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 || '';
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
data-page came out "admin-status" and every `body[data-page='admin-status-overview']`
rule — and the overview layout include, which gates on it — silently did not apply
there. The SPA router stamps the resolved leaf path, so both ways of arriving agree.
#}
<body class="lang_{{ dispatcher.lang }} {{ entityencode(striptags(node?.title ?? ''), true) }}" data-page="{{ entityencode(join('-', length(ctx.path) ? ctx.path : ctx.request_path), true) }}">
{#
The wallpaper PATTERN's paint surface, and nothing else: 15-wallpaper.css masks this
element so an uploaded SVG takes the theme's ink. A mask applies to an element's whole
rendering, children included, so it cannot go on .fs-shell or on <body> — it needs a
layer of its own with nothing in it.
OUTSIDE the blank_page guard on purpose. The login page renders with blank_page: true
(no .fs-shell, no chrome at all), and it is the first page anyone sees: a router whose
saved default is Pattern has to show it there too, exactly as the File photo does.
Inert unless the axis is on — .fs-pattern is display:none by default, so on every other
setting this is one empty div that paints nothing.
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 (.fs-skip, theme/10-chrome.css).
data-fs-chrome: this element and its subtree are 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, and an <aside> gets
role="complementary" — a screen reader could not reach the menu by landmark, and
the shell had no navigation landmark at all. CSS keys off .fs-sidebar.
data-fs-chrome marks a Zone 1 ROOT: everything from here down is ours, and no
third-party rule may match it (the fence, fs-sheets.js) or reach it from html/body
by inheritance (the pin, theme/10-chrome.css). Both key off the ATTRIBUTE, never off
a class list, because the alternative rotted: chrome is not one element — the skip
link and the Appearance popover are not inside this <nav> — so a fence naming
`.fs-sidebar` protected the menu and left them out, and nothing said so. Marking the
element is what a new chrome root cannot forget to do somewhere else.
It marks ROOTS ONLY: never nest one marker inside another. The pin states inherited
properties on the marked element, and a direct declaration beats an inherited one
EVEN WHEN the inherited one is ours — measured, that cost .fs-label its nowrap and
forced text-align from start to left on 302 elements (RTL). `npm run chrome-fence`
fails on a nested marker.
#}
<nav class="fs-sidebar" data-fs-chrome aria-label="{{ _('Menu') }}">
<div class="fs-brandrow">
{% include('themes/footstrap/partials/brand', { boardinfo }) %}
<button type="button" class="fs-railtoggle" id="fs-rail-toggle" aria-label="{{ _('Collapse menu') }}" title="{{ _('Collapse menu') }}">
{% include('themes/footstrap/partials/icon', { body: '<path d="M15 6l-6 6 6 6"/>' }) %}
</button>
</div>
{# LuCI's poll status ("Refreshing") and other indicators land here by id. #}
<div id="indicators" class="fs-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.
APPEARANCE USED TO SIT HERE, a button opening a popover on <body>. It is a tab
now — System -> System (admin/system/system), appended to the stock page by
fs-appearance.js's MutationObserver, because a THEME may not own a dispatcher
node. The axes had outgrown a floating panel; fs-appearance.js's header has the
reasoning. So the chrome carries one control fewer. #}
{% include('themes/footstrap/partials/search') %}
{% include('themes/footstrap/partials/logout') %}
</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, and
`hidden` cannot do that — it is display:none, which strips the element from the
accessibility tree too, so the h1 was never there and the SPA router's
.fs-title-main sync updated a node no assistive tech could read. .fs-sr clips
instead (like .fs-skip): clipped stays in the tree — and what keeps it clipped is
a class a foreign `*` outranks with one `!important`, so it is marked Zone 1 too.
Inside .fs-main, which is NOT marked: that is where app pages render. #}
<div class="fs-title fs-sr" data-fs-chrome>
<h1 class="fs-title-main">{{ entityencode(striptags(dispatched?.title ? _(dispatched.title) : (boardinfo.hostname ?? 'OpenWrt')), true) }}</h1>
</div>
{# The SPA router swaps the page with no reload, and a screen reader cannot notice:
no load event, focus dies with the clicked <a> (renderChrome() empties
#topmenu), the new <title> is not announced. The router writes the incoming
title here after every nav; empty on a full load — that announces itself. #}
<div id="fs-nav-status" class="fs-sr" data-fs-chrome role="status" aria-live="polite"></div>
<div class="fs-content">
{% include('themes/footstrap/partials/notices', { boardinfo, empty_password }) %}
{% endif %}
@@ -0,0 +1,16 @@
{#
Shared brand block (logo + hostname wordmark). Included as
include('themes/footstrap/partials/brand', { boardinfo }); the look lives in
styles/theme/10-chrome.css.
Licensed to the public under the Apache License 2.0.
-#}
<a class="fs-brand" href="{{ dispatcher.build_url() }}">
<span class="fs-logo">
{# The OpenWrt mark, same as the tab favicon (logo.svg): cyan arcs are baked, the ring
flips with the theme mode via the .fs-logo-ring rule in styles/theme/10-chrome.css —
dark ring on the light UI, light ring on dark. It sits in a bordered box (no accent
fill), matching the square buttons in the right cluster. #}
<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>
@@ -0,0 +1,37 @@
{#
Footer body. ONE layout template and ONE client menu renderer — sidebar and top bar are
the same markup, morphed by :root[data-layout] — so the old `menu_module` parameter that
picked between two renderers is gone with the second one.
Licensed to the public under the Apache License 2.0.
-#}
{% if (!blank_page): %}
</div>{# /.fs-content #}
{#
role="contentinfo" is explicit because a <footer> only gets it IMPLICITLY
when its nearest ancestor is <body>, and this one lives inside <main> —
where it must stay: both layouts lay .fs-main out as a flex column and order
the footer within it. The explicit role escapes that scoping rule.
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>
@@ -0,0 +1,417 @@
{#
<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 (Appearance -> Save as default, stored in
/etc/config/footstrap) into the window.__fsSD object the pre-paint below reads. Enums are
whitelisted and the two hues + rounding clamped to range, so a hand-edited config can neither
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 ?? {};
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 : '';
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 : '';
/* The five COLOUR axes (tint, accent, good, warn, danger) each hold one of three things: '' or
'0' for off, '1'..'360' for a hue, or '#rrggbb' for a colour typed into the Appearance page.
Both live forms reach the client as a STRING — the pre-paint below re-reads which it is, the
way fs-prefs.js's normColor() does — so this sanitiser passes through exactly the two shapes
and turns everything else into '' (= no router default). The hex charset is whitelisted for
the same reason login_bg's is: the value is interpolated into the inline data block below, and
writing this file already needs root, so this is defence in depth rather than the only line.
(Do not write the tag name for that block 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.)
A config written before the axes took colours holds a bare number, which parses identically. */
function _sd_color(v) {
if (type(v) == 'string' && match(lc(v), /^#[0-9a-f]{6}$/))
return lc(v);
let n = int(v);
return (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);
/* the four SURFACE axes (cards, inset controls, the chrome bar, hairlines). Hex or nothing —
* they have no hue mode, so _sd_color's number branch would let a bare '0' through as a value;
* these read '' for off. */
function _sd_hex(v) {
return (type(v) == 'string' && match(lc(v), /^#[0-9a-f]{6}$/)) ? lc(v) : '';
}
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 _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 checksum, an
md5), not the image and not a path — the served path is FIXED (/luci-static/footstrap/bg).
Whitelist it to hex so a hand-edited config cannot inject markup into the inline script that
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). */
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
-> -1, because 0 is a real value (a fully 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: its cache-bust token, whitelisted to the same hex charset as login_bg
and for the same reason — it is interpolated into the inline data block below and then into a
url(); the served path is FIXED (/luci-static/footstrap/pattern.svg). */
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 like rounding: 0 is a
real strength (invisible), 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;
/* ---- THE FONTS ----
The theme carries no faces (see the note by the stylesheet link below), so the two stacks in
styles/02-tokens.css are what everyone gets unless the ROUTER names something else here.
Two independent things, either usable alone:
font_sans / font_mono a font-family stack, printed into a style element further down.
fonts the cache-bust token of the @font-face sheet fonts/set-font.sh
generates when an admin also installs the FILE. Hex, exactly like
login_bg and pattern, and for the same reason.
Neither is an axis: there is no browser layer, no Appearance control and no entry in
snapshotAxes() — a font is a router-wide decision here, not a per-visitor one.
THE CHARSET IS THE WHOLE DEFENCE. A family stack is free text and it is printed UNESCAPED,
so the whitelist has to exclude everything that could end the declaration or the element:
< > ( ) { } ; and the backslash are all absent, which leaves no way to close a style element,
start a second declaration or write a url(). Anything else becomes '' = "no router font".
A Cyrillic or CJK family name is refused by this too — those faces carry a Latin name as well,
and fonts/README.md says to use it. */
function _sd_family(v) {
return (type(v) == 'string' && length(v) >= 1 && length(v) <= 120 &&
match(v, /^[A-Za-z0-9 ,._"'-]+$/)) ? v : '';
}
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 : '';
/* ---- WRITING DIRECTION ----
Nothing in LuCI emits `dir`: the dispatcher stamps `lang` and stops there, and no stock theme
adds one — so a router in Arabic, Persian or Hebrew renders right-to-left text inside a
left-to-right document. That is not a font problem the browser fixes on its own: with `dir`
absent the whole document is `ltr`, so paragraphs align to the wrong edge, a line's trailing
punctuation jumps to the wrong end, and every mirrored affordance (the sidebar, the flyouts,
the chevrons, the tab strip) points the wrong way. The theme owns the <html> element, so this
is the one place that can say it.
Matched on the BASE subtag, so a future `ar-EG` or `fa_IR` is covered; the list is the standard
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 _lbase = lc(split(_lang, /[-_]/)[0]);
let _rtl = index([ 'ar', 'arc', 'ckb', 'dv', 'fa', 'ha', 'he', 'khw', 'ks', 'ps', 'sd', 'ur', 'yi' ], _lbase) >= 0;
%}
{# The SERVER always stamps data-layout with an EXPLICIT value (sidebar|top): every layout rule
then matches POSITIVELY, so a future third layout must opt in rather than inherit the other's
rules by not being it — never write a :not([data-layout=…]) guard. And the chrome is right with
JS off; the script below only overrides it.
fs_defaults.layout: the router-wide default (Appearance -> Save as default, or uci-defaults'
`top` seed when migrating an old top-nav install). ABSENT = TOP: the bar is the theme's default
layout and the sidebar is the opt-in. Deliberate — 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="{{ fs_defaults.layout == 'sidebar' ? 'sidebar' : 'top' }}">
<head>
<meta charset="utf-8">
{# entityencode AROUND striptags: striptags drops tags but escapes neither `&` nor quotes,
so a hostname with `&` broke entity parsing. No XSS (the tags are gone), but the markup
must be well-formed. #}
<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 page's controls show the effective default when this browser
has no localStorage of its own). Enums are '', the colours '' (=off), rounding -1 (=unset)
— see the sanitiser above. #}
<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 }}"};</script>
<script>
/* Each block 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, attrs, properties,
ranges, default, ORDER) from the JS and holds this template to it.
PRECEDENCE, every axis: this browser's localStorage wins; absent -> the router default
(window.__fsSD); absent -> the built-in default. So the effective value is
localStorage ?? router-default ?? built-in, and the browser choice always overrides.
Dark mode: a saved preference wins, else the router default, else the OS. */
(() => {
const root = document.querySelector(':root'),
sd = (window.__fsSD || {}).darkmode;
let saved = null;
try { saved = localStorage.getItem('fs-darkmode'); } catch (e) {}
const mq = window.matchMedia('(prefers-color-scheme: dark)');
/* data-darkmode is what THIS theme's CSS reads. data-theme / data-bs-theme are
outbound compat for apps that sniff them (justclash keys 21 rules off
data-theme, ssclash reads data-bs-theme first); never read from styles/. Must
stay identical to stampDark() — axes.mjs proves it. */
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' (explicit) or 'auto' (explicitly follow OS);
absent falls to the router default, then the OS. */
const explicit = (saved === 'true') ? true
: (saved === 'false') ? false
: (saved === 'auto') ? 'auto'
: (sd === 'dark') ? true
: (sd === 'light') ? false
: 'auto';
set(explicit === true || (explicit === 'auto' && mq.matches));
/* ALWAYS registered (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. An
explicit browser choice or an explicit router default does NOT follow the OS. */
mq.addEventListener('change', (m) => {
try {
const s = localStorage.getItem('fs-darkmode');
if (s === 'true' || s === 'false') return;
if (s === null && (sd === 'dark' || sd === 'light')) return;
} catch (e) {}
set(m.matches);
});
})();
</script>
<script>
/* palette, wallpaper, density, rounding and the five colour axes. Each: localStorage
(this browser) wins; absent -> the router default in window.__fsSD; absent -> the
built-in. */
(() => {
try {
const root = document.querySelector(':root'),
sd = window.__fsSD || {};
let p = localStorage.getItem('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
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
name as off. */
if (p === 'rvht' || p === 'roman' || p === 'github') {
localStorage.setItem('fs-palette', 'footstrap'); p = 'footstrap';
}
/* the default palette is a bare :root; every other colourway is opt-in and
carries its own value. The list is the same one fs-prefs.js validates
against — a name in one and not the other paints here and is taken away by
the first live change. */
if (p === null) p = sd.palette || null;
if (p === 'hicontrast' || p === 'bootstrap') 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 for the reason every axis here is: the other order paints one frame
with the tile at the previous size. sd.pattern is the cache-bust token, already
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 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 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');
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');
if (w === null) w = sd.wallpaper || null;
if (w === 'pattern' || w === 'file') root.setAttribute('data-wallpaper', w);
else root.removeAttribute('data-wallpaper');
/* Density: Compact/Normal/Large, 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 — without it a browser that switched back
to Normal would paint one frame still compressed. */
let dn = localStorage.getItem('fs-density');
if (dn === null) dn = sd.density || null;
if (dn === 'compact' || dn === 'large') root.setAttribute('data-density', dn);
else root.removeAttribute('data-density');
/* Rounding 0-20px (02-tokens derives the control/chip radii from it). The
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');
let r = parseInt(rRaw, 10);
if (rRaw === null) r = (sd.rounding >= 0 ? sd.rounding : 12);
if (r >= 0 && r <= 20 && r !== 12)
root.style.setProperty('--fs-radius-base', r + 'px');
/* The five COLOUR axes: Tint (the page CANVAS, so this router is recognisable
at a glance), Accent (the UI colour) and the three status colours. Each is
off, a HUE 1-360 that rotates the palette's own colour, or a '#rrggbb' that
replaces it outright — mirrors colorAxis()/normColor() in fs-prefs.js.
Set the PROPERTY BEFORE THE ATTRIBUTE that switches the rotation on, or a
reload paints one frame in the previous colour.
The two modes CLEAR each other's property: this script runs once per load, so
nothing here is stale — but the live applier calls the same sequence on a
browser where the other mode was in effect, and axes.mjs holds the two copies
to one shape. */
const hex = /^#[0-9a-f]{6}$/i;
const colour = (key, sdVal, attr, hueProp, colorProp) => {
const raw = localStorage.getItem(key);
const v = String((raw === null ? sdVal : raw) ?? '').trim();
if (hex.test(v)) {
root.style.removeProperty(hueProp);
root.style.setProperty(colorProp, v.toLowerCase());
root.setAttribute(attr, 'hex');
return;
}
const h = parseInt(v, 10);
if (h >= 1 && h <= 360) {
root.style.removeProperty(colorProp);
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');
colour('fs-good', sd.good, 'data-good', '--fs-good-h', '--fs-good');
colour('fs-warn', sd.warn, 'data-warn', '--fs-warn-h', '--fs-warn');
colour('fs-danger', sd.danger, 'data-danger', '--fs-danger-h', '--fs-danger');
/* The four SURFACE axes — the cards, the inset controls, the chrome bar and the
hairlines. Simpler than the colour axes above and deliberately so: 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 = localStorage.getItem(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 comes purely from
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). */
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');
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');
let pd = parseInt(pdRaw, 10);
if (pdRaw === null) pd = (sd.photo_dim >= 0 ? sd.photo_dim : 74);
if (pd >= 0 && pd <= 100 && pd !== 74)
root.style.setProperty('--fs-photo-dim', pd + '%');
} catch (e) {}
})();
</script>
<script>
/* the icon-rail preference, so a reload doesn't flash the full sidebar first. Not a
router-wide default (it is a transient chrome collapse, not an appearance choice). */
(() => {
try {
if (localStorage.getItem('fs-rail') === 'true')
document.querySelector(':root').setAttribute('data-rail', 'true');
} catch (e) {}
})();
</script>
<script>
/* the CLIENT's saved layout overrides the server's stamp above, before paint. Only a
KNOWN value is honoured — a corrupt one leaves the stamp standing rather than
blanking the attribute every layout rule matches on. */
(() => {
try {
const saved = localStorage.getItem('fs-layout');
if (saved === 'top' || saved === 'sidebar')
document.querySelector(':root').setAttribute('data-layout', saved);
} catch (e) {}
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="darkreader-lock">
{# TWO <link rel=preload> FOR THE CHROME FACES USED TO SIT HERE, and the package still carries
no webfonts: --fs-font-sans and --fs-font-mono name Manrope and JetBrains Mono FIRST and
the system stack after, so a browser that has either installed locally uses it and one
that does not falls through silently — no request, no 404, no flash. What follows the
stylesheet below is the OPT-IN: a router whose admin ran fonts/set-font.sh.
The preload pair is why both tags down there are written the way they are. A preload is
not in the stylesheet, so deleting the @font-face rules alone left these two behind and
every page went on asking the router for files that were gone: SIX 404s per page, and
nothing in CSS could have told anyone. So the sheet link is emitted only when the token
says the sheet exists, and the token is written by set-font.sh only after the file is. #}
{# `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 themselves come from the
element below, so `uci set footstrap.settings.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 that declares the
defaults no matter what order the two sheets arrive in — a layered rule always loses to an
unlayered one, which is the same reason a theme rule never needs !important to outrank
base. 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">
{% if (node?.css): %}
{# uhttpd sends no Cache-Control, so without a version key the browser applies HEURISTIC
freshness and can serve a stale app stylesheet for days after an upgrade. #}
<link rel="stylesheet" href="{{ resource }}/{{ node.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 — every OTHER sheet in <head> came from an app, and the SPA router treats an
invasive one as a page it must not hand on (filemanager, banip, adblock inject one). #}
<style data-fs-shell>{{ css }}</style>
{% endif %}
{# The catalogue is a CGI SPAWN on the critical path — 30 ms measured on the router — and at
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.
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;
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. `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 and win silently. #}
<script src="{{ resource }}/cbi.js?v={{ pkgs_update_time }}"></script>
</head>
@@ -0,0 +1,18 @@
{#
The chrome's ONE inline-SVG wrapper, server side. Every icon the templates draw is the same 24x24
stroked outline and differs only in its path data, but the wrapper was written out at each call
site — header.ut's rail toggle plus search and logout — so `stroke-width` and the two
linecap attributes were independent copies of a value that is meant to make one icon SET.
fs-widgets.js's svgIcon() is the same wrapper for the icons the JS builds; the two are the server
and client halves of one look.
`body` is the shape, and it is emitted RAW on purpose: it is author-written markup from the caller
(paths and circles), never anything a request can influence, and `{{ }}` in a ucode template does
not escape — escaping here would print the path data as visible text. `cls` is optional and
defaults to the icon class every chrome control uses.
aria-hidden: each of these sits beside its own label (or in a control that carries one), and an
unlabelled <svg> would otherwise be announced as a graphic in its own right.
Licensed to the public under the Apache License 2.0.
-#}
<svg class="{{ cls ?? 'fs-ico' }}" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">{{ body }}</svg>
@@ -0,0 +1,15 @@
{#
The logout control (guarded on the node being dispatchable). ONE markup for every layout,
which CSS renders as a square icon button in the bar and the rail: ONE markup for every layout,
because a layout is a presentation choice and must not fork the markup. So the old `with_label`
parameter and .fs-top-logout are gone.
It is also the ONLY logout the theme renders: menu-footstrap.js drops the menu tree's own
admin/logout node, so the bar carries no duplicate "Log out" item beside this one.
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') }}">
{% 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>
</a>
{% endif %}
@@ -0,0 +1,21 @@
{#
ONE top-of-content warning notice: heading, sentence, and an optional "go fix it" button. The
three notices in notices.ut were three copies of this markup — and the button half was written
out twice down to the `dispatcher.lookup` guard around it, which is the part that must not drift:
the guard is what keeps the button from pointing at a page this user's ACLs do not grant.
include(…, { title, text }) for a plain notice; add { path, label } for the button. `path` is a
dispatcher path ("admin/system/admin"); it is looked up before it is linked, so a build with that
page absent — or a user without rights to it — gets the warning without a dead link.
title/text are emitted with `{{ }}`, i.e. unescaped, exactly as the three copies did: 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>
@@ -0,0 +1,36 @@
{#
Top-of-content notices: no-root-password warning, initramfs recovery warning, the <noscript>
fallback, the #tabmenu container. include(…, { boardinfo, empty_password }) — empty_password
is computed in the header, which already imports luci.core.
The markup of a notice lives ONCE, in partials/notice.ut; this file decides only WHICH notices
there are and what they say.
The sentences carry no msgctxt on purpose: inheriting luci-base's translation is a feature in
the ~40 languages this theme has no catalogue for (see header.ut).
Licensed to the public under the Apache License 2.0.
-#}
{% 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>
@@ -0,0 +1,17 @@
{#
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
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.
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
(fs-search.js: 'Search pages', 'Recently visited', …) do carry the `footstrap` context, because a
msgid is a GLOBAL name shared with every luci-app on the router — see header.ut.
Licensed to the public under the Apache License 2.0.
-#}
<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') }}">
{% include('themes/footstrap/partials/icon', { body: '<circle cx="11" cy="11" r="7"/><path d="M16.5 16.5 21 21"/>' }) %}
<span class="fs-label">{{ _('Search') }}</span>
</button>
@@ -0,0 +1,172 @@
{#
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 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 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
IN — the view bootstraps before a session exists, its RPCs answer "Access denied", the promise
rejects and render() never runs. Do not re-introduce it. The SERVER renders the form: works
with JS off, cannot be broken by a rejected promise, needs no luci-theme-bootstrap installed.
styles/pages/10-login.css makes it the centred card, keyed off `.fs-login` below. The strings
carry no msgctxt on purpose — luci-base translates them in the ~40 languages this theme has no
catalogue for.
-#}
{% include('header', { blank_page: true }) %}
{# The class is what 10-login.css hooks: the form used to be found by
`form:has(> .cbi-map input[name="luci_username"])` — 49 characters written 17 times, ~0.8 KB —
because the markup was assumed to be stock LuCI's. It is OURS: name the form instead. #}
<form method="post" class="fs-login">
{#
THE BACKEND'S MESSAGE WINS OVER THE CREDENTIALS ONE, and the order matters more than it looks.
`fuser` is not "the password was wrong" — it is the USERNAME the form posted, set in BOTH
branches of dispatcher.uc that render a sysauth. On the second step of a pluggable login (the
OTP prompt, dispatcher.uc:1018) the credentials were CORRECT and `fuser` is still set, with
`auth_message` carrying what actually happened. The generic template guards its message with
`&& !fuser`, so that step tells the user "Invalid username and/or password!" — about a password
that was right — and swallows "Too many failed attempts" / "System time is not calibrated"
entirely. luci-theme-bootstrap does not make that trade, and neither does this: message first,
credentials as the fallback. `auth_plugin` names the challenge that stopped the login, and it
is the one signal separating "the backend is asking for something" from an ordinary notice.
-#}
{% if (auth_message): %}
<div class="alert-message{% if (auth_plugin): %} warning{% endif %}">
<p>{{ auth_message }}</p>
</div>
{% elif (fuser): %}
<div class="alert-message warning">
<p>{{ _('Invalid username and/or password! Please try again.') }}</p>
</div>
{% endif %}
<div class="cbi-map">
<h2 name="content">{{ _('Authorization Required') }}</h2>
<div class="cbi-map-descr">
{{ _('Please enter your username and password.') }}
</div>
<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" />
</div>
</div>
{#
THE LOGIN BACKEND'S OWN FIELDS. The dispatcher hands `auth_fields`, `auth_message`,
`auth_html` and `auth_assets` to whichever sysauth template renders, and a pluggable
backend (luci-plugin-2fa is the in-tree one) puts its OTP input in there. A theme-local
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.
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
AFTER a `%}` — plain tags, no `{%-`/`-%}` needed — so the indentation these lines are
written with never reaches the output. Rendered by a router's own ucode, the generic
template's spelling gives `inputmode="numeric"pattern="[0-9]*"maxlength="6"required`:
an HTML parse error browsers recover from and a validator does not. Measured, not
assumed — `A\n\t{% if (1): %}B{% endif %}\n\t{% if (1): %}C{% endif %}\nD` renders as
`A\nBCD`.
-#}
{% 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="{{ field.type ?? 'text' }}"
name="{{ field.name }}"
id="{{ entityencode(field.name, true) }}"
{% if (field.placeholder): %} placeholder="{{ field.placeholder }}"{% endif %}
{% if (field.inputmode): %} inputmode="{{ field.inputmode }}"{% endif %}
{% if (field.pattern): %} pattern="{{ field.pattern }}"{% endif %}
{% if (field.maxlength): %} maxlength="{{ field.maxlength }}"{% endif %}
{% if (field.autocomplete): %} autocomplete="{{ field.autocomplete }}"{% 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. It is the one shape the
linter cannot read — a `{{ … }}` block is not JS until the server renders it — so it carries no
logic at all, and eslint's .ut processor enforces exactly that (tools/lib/ut-scripts.mjs). The
script itself is then plain JS and gets linted like every other file in the theme. #}
<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 — the password is about to go over this
* connection. The <img> probe is upstream's trick: it proves the TLS port actually answers
* before we send the user there. */
if (document.location.protocol === 'https:') 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 }) %}