mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-09-14 20:34:19 +08:00
474 lines
29 KiB
Plaintext
474 lines
29 KiB
Plaintext
{#
|
|
<head> for the ONE template. include(…, { boardinfo, fs_defaults }); everything else comes from
|
|
the render scope. fs_defaults = the ROUTER's saved Appearance defaults (luci-theme-footstrap
|
|
/etc/config/footstrap), which the client's own localStorage overrides before paint.
|
|
Licensed to the public under the Apache License 2.0.
|
|
-#}
|
|
<!DOCTYPE html>
|
|
{%
|
|
/* Sanitise the router's saved Appearance defaults into the window.__fsSD object the pre-paint
|
|
below reads. Enums are whitelisted and numbers clamped, so a hand-edited config can neither
|
|
break the inline script nor inject markup (defence in depth — writing that file needs root).
|
|
Absent or invalid becomes '' (or -1 where 0 is a real value), which the JS reads as "no router
|
|
default"; the client's localStorage overrides every one before paint.
|
|
|
|
`fs_defaults` is a required include parameter: header.ut is the only caller and builds it
|
|
before the include, so a `?? {}` here would only hide that contract from a second caller. */
|
|
let _sd_lay = index([ 'sidebar', 'top' ], fs_defaults.layout) >= 0 ? fs_defaults.layout : '';
|
|
let _sd_dark = index([ 'auto', 'light', 'dark' ], fs_defaults.darkmode) >= 0 ? fs_defaults.darkmode : '';
|
|
let _sd_pal = index([ 'footstrap', 'hicontrast', 'bootstrap', '2020', 'forum' ], fs_defaults.palette) >= 0 ? fs_defaults.palette : '';
|
|
let _sd_wall = index([ 'off', 'pattern', 'file' ], fs_defaults.wallpaper) >= 0 ? fs_defaults.wallpaper : '';
|
|
let _sd_pink = index([ 'theme', 'original' ], fs_defaults.pattern_ink) >= 0 ? fs_defaults.pattern_ink : '';
|
|
let _sd_ac = index([ 'on', 'off' ], fs_defaults.autocollapse) >= 0 ? fs_defaults.autocollapse : '';
|
|
let _sd_dens = index([ 'compact', 'normal', 'large' ], fs_defaults.density) >= 0 ? fs_defaults.density : '';
|
|
/* issue #44: how far the content column may grow past --fs-content-max's 1280px default.
|
|
header.ut's FS_AXES carries 'content_width', so a saved router default reaches this. */
|
|
let _cw = int(fs_defaults.content_width);
|
|
let _sd_cwidth = (fs_defaults.content_width != '' && _cw >= 1280 && _cw <= 3840) ? _cw : -1;
|
|
/* The five colour axes each hold one of three things: '' or '0' for off, '1'..'360' for a hue,
|
|
or '#rrggbb'. Both live forms reach the client as a string, and the pre-paint re-reads which
|
|
it is the way fs-prefs.js's normColor() does, so this passes through exactly those two shapes
|
|
and turns everything else into ''. The hex charset is whitelisted because the value is
|
|
interpolated into the inline data block below.
|
|
(Do not write that block's tag name in a comment: tools/lib/ut-scripts.mjs finds the inline
|
|
scripts by matching the opening tag, and a mention in prose is one it will match.) */
|
|
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. */
|
|
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 md5), not the
|
|
image and not a path — the served path is fixed. Whitelisted to hex so a hand-edited config
|
|
cannot inject markup into the inline script that builds the url(). */
|
|
let _lb = fs_defaults.login_bg;
|
|
let _sd_lbg = (type(_lb) == 'string' && match(_lb, /^[a-f0-9]{6,64}$/)) ? _lb : '';
|
|
/* the Tint's strength 0-200 (% of the designed chroma). Unset -> -1, like rounding, because 0
|
|
is a real value. */
|
|
let _ts = int(fs_defaults.tint_strength);
|
|
let _sd_tstr = (fs_defaults.tint_strength != '' && _ts >= 0 && _ts <= 200) ? _ts : -1;
|
|
/* the File photo's scrim opacity 0-100; unset -> -1, 0 being a real value (a transparent
|
|
scrim) */
|
|
let _pd = int(fs_defaults.photo_dim);
|
|
let _sd_pdim = (fs_defaults.photo_dim != '' && _pd >= 0 && _pd <= 100) ? _pd : -1;
|
|
/* the uploaded pattern's cache-bust token, whitelisted like login_bg and for the same reason;
|
|
the served path is fixed */
|
|
let _pt = fs_defaults.pattern;
|
|
let _sd_pat = (type(_pt) == 'string' && match(_pt, /^[a-f0-9]{6,64}$/)) ? _pt : '';
|
|
/* the pattern's tile edge in px and the layer's opacity 0-100; unset -> -1, since 0 is a real
|
|
strength and 0 px is not a tile */
|
|
let _ps = int(fs_defaults.pattern_size);
|
|
let _sd_psize = (fs_defaults.pattern_size != '' && _ps >= 40 && _ps <= 1600) ? _ps : -1;
|
|
let _pst = int(fs_defaults.pattern_strength);
|
|
let _sd_pstr = (fs_defaults.pattern_strength != '' && _pst >= 0 && _pst <= 100) ? _pst : -1;
|
|
/* ---- the fonts ----
|
|
The theme carries no faces, so the two stacks in styles/02-tokens.css are what everyone gets
|
|
unless the router names something else here. Two independent options, either usable alone:
|
|
font_sans / font_mono a font-family stack, printed into a style element further down
|
|
fonts the cache-bust md5 of an @font-face sheet at
|
|
/etc/footstrap/fonts/fonts.css, for when the admin installs the
|
|
files too
|
|
The package is the serving half only: it reads these options, serves whatever is in
|
|
/etc/footstrap/fonts and keeps that directory across a sysupgrade, installing no faces and
|
|
fetching nothing at run time. `fonts/set-font.sh` is a repository tool and not part of this
|
|
package: it names third-party font hosts, which a theme in this tree must not carry.
|
|
Neither is an axis — no browser layer, no Appearance control, no entry in snapshotAxes().
|
|
|
|
The charset is most of the defence: a family stack is free text printed UNESCAPED, so the
|
|
whitelist excludes everything that could end the declaration or the element (< > ( ) { } ;
|
|
and the backslash), leaving no way to close a style element, start a second declaration or
|
|
write a url(). A non-Latin family name is refused too; those faces carry a Latin name as well.
|
|
|
|
The two quotes are what the charset cannot refuse, and neither ends anything — they EXTEND.
|
|
Both must be allowed (the shipped default is `"Manrope",system-ui,…`), and one left open
|
|
starts a CSS string that runs to the end of the sheet, so a typo in the sans stack silently
|
|
costs the mono one.
|
|
|
|
Counting quotes is not the test, tokenizing being stateful: inside '…' a " is literal and vice
|
|
versa, so `"O'Reilly Sans", 'Fira Sans` holds two of each while the second `'` is still open.
|
|
Scan in order instead: the quote that opens wins until its own kind closes it, and one still
|
|
open at the end refuses the value. */
|
|
function _sd_family(v) {
|
|
if (!(type(v) == 'string' && length(v) >= 1 && length(v) <= 120 &&
|
|
match(v, /^[A-Za-z0-9 ,._"'-]+$/)))
|
|
return '';
|
|
let q = '';
|
|
for (let c in split(v, '')) {
|
|
if (q == '')
|
|
q = (c == '"' || c == "'") ? c : '';
|
|
else if (c == q)
|
|
q = '';
|
|
}
|
|
return (q == '') ? v : '';
|
|
}
|
|
let _sd_fsans = _sd_family(fs_defaults.font_sans);
|
|
let _sd_fmono = _sd_family(fs_defaults.font_mono);
|
|
let _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, so a router in Arabic,
|
|
Persian or Hebrew renders right-to-left text inside a left-to-right document — paragraphs
|
|
align to the wrong edge and every mirrored affordance points the wrong way. The theme owns
|
|
<html>, so this is the one place that can say it.
|
|
Matched on the base subtag, so a future `ar-EG` is covered, and the list is the standard RTL
|
|
set rather than the three LuCI ships catalogues for. `ltr` is written explicitly for the same
|
|
reason data-layout is: every rule then matches positively. */
|
|
let _lang = dispatcher.lang;
|
|
let _lbase = lc(split(_lang, /[-_]/)[0]);
|
|
let _rtl = index([ 'ar', 'arc', 'ckb', 'dv', 'fa', 'ha', 'he', 'khw', 'ks', 'ps', 'sd', 'ur', 'yi' ], _lbase) >= 0;
|
|
%}
|
|
{# The server always stamps data-layout with an EXPLICIT value (sidebar|top), so every layout rule
|
|
matches positively and a future third layout must opt in rather than inherit another's rules by
|
|
not being it — never write a :not([data-layout=…]) guard. The chrome is right with JS off; the
|
|
script below only overrides it.
|
|
Absent = TOP: the bar is what the theme is built around (every layout rule starts from it, with
|
|
the vertical column as the one guarded override) and it is what a phone gets at any setting. #}
|
|
<html lang="{{ dispatcher.lang }}" dir="{{ _rtl ? 'rtl' : 'ltr' }}" data-layout="{{ 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 `&` breaks entity parsing #}
|
|
<title>{{ entityencode(striptags(`${boardinfo.hostname ?? 'OpenWrt'}${dispatched?.title ? ` | ${_(dispatched.title)}` : ''}`), true) }}</title>
|
|
{# The router-wide Appearance defaults, read by both the pre-paint below and fs-prefs.js's
|
|
current*(), so the Appearance controls show the effective default when this browser has no
|
|
localStorage. See the sanitiser above for the unset encodings. #}
|
|
<script>window.__fsPlugins={{ sprintf('%J', fs_plugins) }};</script>
|
|
<script>window.__fsSD={layout:"{{ _sd_lay }}",darkmode:"{{ _sd_dark }}",palette:"{{ _sd_pal }}",wallpaper:"{{ _sd_wall }}",tint:"{{ _sd_tint }}",accent:"{{ _sd_acc }}",good:"{{ _sd_good }}",warn:"{{ _sd_warn }}",danger:"{{ _sd_dang }}",card:"{{ _sd_card }}",control:"{{ _sd_ctrl }}",bar:"{{ _sd_bar }}",line:"{{ _sd_line }}",rounding:{{ _sd_round }},autocollapse:"{{ _sd_ac }}",login_bg:"{{ _sd_lbg }}",tint_strength:{{ _sd_tstr }},photo_dim:{{ _sd_pdim }},density:"{{ _sd_dens }}",pattern:"{{ _sd_pat }}",pattern_size:{{ _sd_psize }},pattern_strength:{{ _sd_pstr }},pattern_ink:"{{ _sd_pink }}",content_width:{{ _sd_cwidth }}};</script>
|
|
<script>
|
|
/* Each IIFE below pre-paints one Appearance axis before the first frame, duplicating a
|
|
live applier in fs-prefs.js. They cannot share code — this runs before the module
|
|
loader exists — so tools/axes.mjs derives the contract (keys, attributes, properties,
|
|
ranges, default, order) from the JS and holds this template to it.
|
|
|
|
ONE element for all of them, and exactly two in this file: the data blob above carries
|
|
interpolations and this one carries none. That is the split tools/lib/ut-scripts.mjs
|
|
keys off — an interpolated body is not JS until the server renders it, so it is exempt
|
|
from the lint and everything mergeable into it would go unchecked with it. Two is also
|
|
what a Content-Security-Policy would need nonces for, should uhttpd ever send one.
|
|
Precedence for every axis: localStorage ?? router default (window.__fsSD) ?? built-in.
|
|
Dark mode: a saved preference, else the router default, else the OS. */
|
|
(() => {
|
|
const root = document.querySelector(':root'),
|
|
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 and data-bs-theme are
|
|
outbound compat for apps that sniff them (justclash keys 21 rules off
|
|
data-theme) and are 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' or 'auto' (explicitly follow the OS); absent
|
|
falls to the router default, then the OS */
|
|
const explicit = (saved === 'true') ? true
|
|
: (saved === 'false') ? false
|
|
: (saved === 'auto') ? 'auto'
|
|
: (sd === 'dark') ? true
|
|
: (sd === 'light') ? false
|
|
: 'auto';
|
|
set(explicit === true || (explicit === 'auto' && mq.matches));
|
|
/* always registered, and it re-checks storage: the user can switch to Auto after
|
|
loading with a saved value, and OS flips must track live from then on */
|
|
mq.addEventListener('change', (m) => {
|
|
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);
|
|
});
|
|
})();
|
|
|
|
/* the remaining axes, in the precedence stated above. */
|
|
(() => {
|
|
/* Guarded per read, not once around the block: a browser that refuses storage throws
|
|
on the FIRST getItem, and one try around everything aborts the whole pre-paint —
|
|
every axis below skipped, so a router with saved defaults paints none of them.
|
|
Storage failing must mean "this browser has no override", which is what the live
|
|
applier's lsGet means. The outer try is the last resort for everything else. */
|
|
const lsGet = (k) => { try { return localStorage.getItem(k); } catch (e) { return null; } };
|
|
try {
|
|
const root = document.querySelector(':root'),
|
|
sd = window.__fsSD || {};
|
|
let p = lsGet('fs-palette');
|
|
/* A stray or retired palette name needs no migration here: it fails the list
|
|
below and paints the bare :root, and listAxis() in fs-prefs.js ends the same
|
|
way, so the live applier agrees. The three legacy names ('rvht', 'roman',
|
|
'github') were rewritten here until this comment replaced them — the block
|
|
was measured as changing nothing, and it ran on every page load. */
|
|
/* the default palette is a bare :root; every other colourway is opt-in. 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' || p === '2020' || p === 'forum') root.setAttribute('data-palette', p);
|
|
else root.removeAttribute('data-palette');
|
|
|
|
/* Wallpaper: off | pattern (the uploaded SVG, tiled and recoloured) | file (the
|
|
uploaded photo). The url() and the two pattern numbers are set BEFORE the
|
|
attribute, or the frame paints with the tile at the previous size.
|
|
sd.pattern is whitelisted to hex above, so nothing user-controlled reaches
|
|
the url(). */
|
|
const pu = sd.pattern;
|
|
/* /cgi-bin, not a /www symlink: a plain static SVG is a same-origin document
|
|
that runs script opened directly (OpenWrt forum thread 251930); this path
|
|
is the CGI handler that answers with CSP 'none' + sandbox instead — see
|
|
fs-axes.js's PAT_SERVE, which this string must keep matching. */
|
|
if (pu) root.style.setProperty('--fs-pattern-url', 'url("/cgi-bin/luci-theme-footstrap-pattern?v=' + pu + '")');
|
|
const psRaw = lsGet('fs-pattern-size');
|
|
const ps = (psRaw !== null) ? parseInt(psRaw, 10) : sd.pattern_size;
|
|
if (ps >= 40 && ps <= 1600 && ps !== 440) root.style.setProperty('--fs-pattern-size', ps + 'px');
|
|
const pstRaw = lsGet('fs-pattern-strength');
|
|
const pst = (pstRaw !== null) ? parseInt(pstRaw, 10) : sd.pattern_strength;
|
|
if (pst >= 0 && pst <= 100 && pst !== 20) root.style.setProperty('--fs-pattern-strength', String(pst / 100));
|
|
let pi = lsGet('fs-pattern-ink');
|
|
if (pi === null) pi = sd.pattern_ink || null;
|
|
if (pi === 'original') root.setAttribute('data-pattern-ink', 'original');
|
|
else root.removeAttribute('data-pattern-ink');
|
|
|
|
let w = lsGet('fs-wallpaper');
|
|
if (w === null) w = sd.wallpaper || null;
|
|
if (w === 'pattern' || w === 'file') root.setAttribute('data-wallpaper', w);
|
|
else root.removeAttribute('data-wallpaper');
|
|
|
|
/* Density: a pure token axis (02-tokens multiplies the type and space ladders).
|
|
Normal is a bare :root, so the removeAttribute half matters as much as the
|
|
set, or a browser switched back to Normal paints one frame compressed. */
|
|
let dn = lsGet('fs-density');
|
|
if (dn === null) dn = sd.density || null;
|
|
if (dn === 'compact' || dn === 'large') root.setAttribute('data-density', dn);
|
|
else root.removeAttribute('data-density');
|
|
|
|
/* Content width (issue #44): how far the column may grow past the 1280px
|
|
default, as a px length rather than a step name — the slider is continuous.
|
|
Written only when it differs from the default, so an untouched theme paints
|
|
from the bare :root exactly as it did before this axis existed.
|
|
sd.content_width is -1 when unset. */
|
|
const cwRaw = lsGet('fs-content-width');
|
|
let cw = parseInt(cwRaw, 10);
|
|
if (cwRaw === null) cw = (sd.content_width >= 1280 ? sd.content_width : 1280);
|
|
if (cw >= 1280 && cw <= 3840 && cw !== 1280)
|
|
root.style.setProperty('--fs-content-max', cw + 'px');
|
|
|
|
/* Rounding 0-20px (02-tokens derives the control and chip radii from it). The
|
|
default is a literal, this running before the stylesheet, so axes.mjs is the
|
|
only thing checking the two agree. sd.rounding is -1 when unset. */
|
|
const rRaw = lsGet('fs-radius');
|
|
let r = parseInt(rRaw, 10);
|
|
if (rRaw === null) r = (sd.rounding >= 0 ? sd.rounding : 12);
|
|
if (r >= 0 && r <= 20 && r !== 12)
|
|
root.style.setProperty('--fs-radius-base', r + 'px');
|
|
/* The five colour axes: Tint (the page canvas), Accent (the UI colour) and the
|
|
three status colours. Each is off, a hue 1-360 rotating the palette's own
|
|
colour, or a '#rrggbb' replacing it — mirrors colorAxis()/normColor().
|
|
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: nothing is stale here, but the live applier calls the same
|
|
sequence 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 = lsGet(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 — cards, inset controls, the chrome bar, hairlines. No
|
|
hue mode and therefore no attribute, just the token or nothing. Mirrors
|
|
surfaceAxis() in fs-prefs.js. */
|
|
const surface = (key, sdVal, prop) => {
|
|
const raw = lsGet(key);
|
|
const v = String((raw === null ? sdVal : raw) ?? '').trim().toLowerCase();
|
|
if (hex.test(v)) root.style.setProperty(prop, v);
|
|
};
|
|
surface('fs-card', sd.card, '--fs-panel-base');
|
|
surface('fs-control', sd.control, '--fs-panel2-base');
|
|
surface('fs-bar', sd.bar, '--fs-bar-bg');
|
|
surface('fs-line', sd.line, '--fs-border-base');
|
|
/* The uploaded photo's URL: router-side, the token coming purely from
|
|
window.__fsSD. Set the property whenever a valid token exists; whether it
|
|
PAINTS is the wallpaper axis above, so a router defaulting to 'file' shows it
|
|
here, pre-login included. The token is whitelisted above; the test is repeated
|
|
because this one interpolates into a url() inside a style property, where the
|
|
failure would be silent. */
|
|
const lb = sd.login_bg;
|
|
if (lb && (/^[a-f0-9]{6,64}$/).test(lb))
|
|
root.style.setProperty('--fs-login-bg-url', 'url("/luci-static/footstrap/bg?v=' + lb + '")');
|
|
/* the Tint's strength (default 100% = the designed chroma) */
|
|
const tsRaw = lsGet('fs-tint-strength');
|
|
let ts = parseInt(tsRaw, 10);
|
|
if (tsRaw === null) ts = (sd.tint_strength >= 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, same shape as the tint strength above; the 74%
|
|
default lives in the CSS token */
|
|
const pdRaw = lsGet('fs-photo-dim');
|
|
let pd = parseInt(pdRaw, 10);
|
|
if (pdRaw === null) pd = (sd.photo_dim >= 0 ? sd.photo_dim : 74);
|
|
if (pd >= 0 && pd <= 100 && pd !== 74)
|
|
root.style.setProperty('--fs-photo-dim', pd + '%');
|
|
} catch (e) {}
|
|
})();
|
|
|
|
/* the icon-rail preference, so a reload does not flash the full sidebar first. Not a
|
|
router-wide default: a transient chrome collapse, not an appearance choice. */
|
|
(() => {
|
|
try {
|
|
if (localStorage.getItem('fs-rail') === 'true')
|
|
document.querySelector(':root').setAttribute('data-rail', 'true');
|
|
} catch (e) {}
|
|
})();
|
|
|
|
/* 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">
|
|
{# The colour a mobile browser paints its own chrome with — address bar, task switcher, the
|
|
installed app's title bar. Without it a dark page keeps a white bar above it.
|
|
|
|
The literal is the default palette's page colour and the SAME value manifest.json carries,
|
|
for the same reason: neither file can be rendered per request. It is a starting value
|
|
only — fs-prefs.js repaints this tag from the live body background, so it follows palette,
|
|
mode and every tint axis. tests/theme-color.test.mjs holds the two literals equal.
|
|
|
|
One tag, no `media` pair: a media-qualified tag would win over this one for a viewer whose
|
|
OS is dark but who chose light HERE, and the choice is the theme's to honour. #}
|
|
<meta name="theme-color" content="#f6f8fa">
|
|
{# The sheet link below is emitted only when the TOKEN says the sheet exists — set the option
|
|
after the file, never before. A preload is not in the stylesheet, so dropping the
|
|
@font-face rules alone left the pair behind and every page went on asking the router for
|
|
files that were gone: six 404s per page, measured, and nothing in CSS could say so. #}
|
|
{# `data-fs-shell` marks the shell's own CSS: on every nav the SPA router asks whether a VIEW
|
|
put CSS in the document, and this is how it tells its own sheet from an app's. #}
|
|
<link rel="stylesheet" data-fs-shell href="{{ media }}/cascade.css?v={{ pkgs_update_time }}">
|
|
{% if (_sd_ftok != ''): %}
|
|
{# The generated @font-face sheet, and only the faces: the families come from the element
|
|
below, so setting font_sans by hand takes effect without regenerating anything. The token
|
|
is the sheet's md5 — uhttpd sends no Cache-Control, so without it a browser applies
|
|
heuristic freshness and keeps the previous face for days. #}
|
|
<link rel="stylesheet" data-fs-shell href="{{ media }}/fonts/fonts.css?v={{ _sd_ftok }}">
|
|
{% endif %}
|
|
{% if (_sd_fsans != '' || _sd_fmono != ''): %}
|
|
{# The router's own font stacks. Unlayered, so it beats the `tokens` layer declaring the
|
|
defaults whatever order the two sheets arrive in. Values are whitelisted at the top of
|
|
this file; nothing here escapes them. #}
|
|
<style data-fs-shell>:root { {% if (_sd_fsans != ''): %}--fs-font-sans: {{ _sd_fsans }};{% endif %}{% if (_sd_fmono != ''): %} --fs-font-mono: {{ _sd_fmono }};{% endif %} }</style>
|
|
{% endif %}
|
|
<link rel="icon" href="{{ media }}/logo_48.png" sizes="48x48">
|
|
<link rel="icon" href="{{ media }}/logo.svg" sizes="any">
|
|
{# The manifest lets an admin install this page as an app: an icon, its own window and no
|
|
address bar, which is worth more than a tab on the phone LuCI is opened from when the
|
|
network is broken.
|
|
|
|
Both files are static and cannot be otherwise, a theme having no dispatcher node to render
|
|
them per request. That fixes two values:
|
|
|
|
start_url/scope — `/cgi-bin/luci/`, uhttpd's default and the path this template is
|
|
served from on a stock install. An admin who moved LuCI gets an installed app that
|
|
opens the standard path; the browser tab is unaffected.
|
|
background/theme_color — the default palette's page colour. The manifest is read once
|
|
at install time and cannot follow a live Appearance change, and it only paints the
|
|
splash and the window chrome.
|
|
|
|
Chrome offers its install prompt on a secure context only, so over plain HTTP this is
|
|
iOS's Add to Home Screen plus the icons. Nothing here is on the critical path.
|
|
|
|
`.json`, not the conventional `.webmanifest`: uhttpd's built-in MIME map has no entry for
|
|
that extension, so the same bytes come back `application/octet-stream` there and with a
|
|
JSON type only as `.json`. The map is compiled into uhttpd. #}
|
|
<link rel="manifest" href="{{ media }}/manifest.json?v={{ pkgs_update_time }}">
|
|
{# iOS reads this link and never the manifest's icons array, and it scales the 192 square the
|
|
manifest also uses down to its own 180. One raster serves both — tools/build-icons.mjs. #}
|
|
<link rel="apple-touch-icon" href="{{ media }}/app-icon-192.png">
|
|
{% if (dispatched?.css): %}
|
|
{# The `css` property of the dispatched menu.d node: an app naming a stylesheet the server
|
|
should link for its page, instead of the view injecting one at module eval.
|
|
|
|
`dispatched`, not `node`: the dispatcher exports the resolved node as `dispatched` and
|
|
never binds a `node` — every ucode theme carried that typo until openwrt/luci#8920, so the
|
|
link had never once been emitted. It also requires `css: 'string'` in the dispatcher's node
|
|
schema, added by the same commit; without it the property is filtered out of the tree and
|
|
this stays false, as on 24.10 and 25.12.
|
|
|
|
uhttpd sends no Cache-Control, so without a version key a stale app stylesheet can be
|
|
served for days after an upgrade.
|
|
|
|
Deliberately not `data-fs-shell`: this sheet belongs to one page, so the SPA router must
|
|
judge and contain it like any other app sheet. #}
|
|
<link rel="stylesheet" href="{{ resource }}/{{ dispatched.css }}?v={{ pkgs_update_time }}">
|
|
{% endif %}
|
|
{% if (css): %}
|
|
{# no `title` attribute: on a <style> it names a style sheet SET, making this a "preferred"
|
|
alternate sheet the user can switch off in Firefox's Page Style menu. `data-fs-shell` as
|
|
above. #}
|
|
<style data-fs-shell>{{ css }}</style>
|
|
{% endif %}
|
|
{# The catalogue is a CGI spawn on the critical path — 30 ms on the router — and at lang=en it
|
|
spends them to deliver 13 bytes, there being no en catalogue because the msgids already
|
|
are English. The process is the cost, not the data, so emit those bytes inline when this
|
|
language has no catalogue and keep the tag when it has.
|
|
The probe mirrors the server: determine_request_language() loads the catalogue with
|
|
load_catalog(lang, '/usr/lib/lua/luci/i18n'), i.e. every `*.<lang>.lmo` in that directory,
|
|
so a router that does ship an en catalogue still gets the tag. Deciding by language NAME
|
|
would silently drop it.
|
|
Fails open — a throwing probe keeps the tag — because a missing catalogue makes every
|
|
`_()` render English and report nothing. #}
|
|
{%- let has_catalog = true;
|
|
try { has_catalog = length(require('fs').glob(`/usr/lib/lua/luci/i18n/*.${dispatcher.lang}.lmo`)) > 0; } catch (e) {} %}
|
|
{% if (has_catalog): %}
|
|
<script src="{{ dispatcher.build_url('admin/translations', dispatcher.lang) }}"></script>
|
|
{% else %}
|
|
<script>window.TR={};</script>
|
|
{% endif %}
|
|
{# the one asset stock LuCI leaves without a cache key. Must stay synchronous and un-deferred:
|
|
luci.js captures `window.cbi_init` in its constructor, and `defer` is not an option for the
|
|
catalogue above either — footer.ut runs `L.require('menu-footstrap')` inline while the
|
|
parser is still running, so a module's `_()` would race a deferred window.TR. #}
|
|
<script src="{{ resource }}/cbi.js?v={{ pkgs_update_time }}"></script>
|
|
</head>
|