mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-05 01:44:16 +08:00
🌴 Sync 2026-08-05 03:17:09
This commit is contained in:
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 }) %}
|
||||
Reference in new issue
Block a user