Files

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>