Files

181 lines
9.9 KiB
Plaintext

{#
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 and a lookup returns the first archive holding
the hash — so readdir order decides who owns a string, and the Appearance layout toggle rendered
"Максимум" on a Russian router because another catalogue translates `Top` as "maximum" (issue #6).
Anything left bare is a name anyone may take, and contexting cannot be selective.
So every Appearance label carries the `footstrap` msgctxt, while the chrome's own strings — Skip
to content, Menu, Collapse menu, Search, Log out — are deliberately msgctxt-free, because a bare
msgid is how a string inherits a translation in the ~40 languages this theme ships no catalogue
for. Skip to content and Log out are luci-base msgids verbatim; the other three are ours alone and
stay bare so a future luci-base string can adopt them. Same for the login/notice sentences and for
System/Memory/Storage in fs-overview.js, which match the stock headings.
-#}
{%
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 (/etc/config/footstrap): head.ut stamps them before
paint and the client's localStorage overrides every one. Absent = the built-in defaults.
`uci` and `config` come from the render scope dispatcher.uc builds per request: `uci` is
already a cursor and `config.main` is already `uci.get_all('luci','main')`. Opening a second
cursor here would allocate one per page render and re-read a package the dispatcher has
already loaded. */
const fsd = uci.get_all('footstrap', 'settings') ?? {};
/* Every option saveAsDefault() writes, listed once and read back in a loop. A hand-written
* literal with one line per axis drops an axis silently: Save to router reports success, the
* file on disk is correct, and "Reset to router" lands on the BUILT-IN look because the value
* the server hands the client was never there. tools/axes.mjs checks this list against
* snapshotAxes().
*
* '' rather than a raw undefined when unset, so head.ut's `!= ''` guards fire: int(undefined)
* is 0, a valid value, and an axis whose 0 is a real choice would default to it. */
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', 'content_width',
/* the two uploaded images' cache-bust tokens (head.ut whitelists both to hex); the files
themselves are served. Not axes — they have no browser layer — but they reach the client
the same way. */
'login_bg', 'pattern',
/* the fonts, same shape: no browser layer, no Appearance control, no entry in
snapshotAxes(). font_sans/font_mono are font-family stacks (head.ut whitelists the
charset); `fonts` is the cache-bust token of the @font-face sheet the admin installs
beside the faces. All three are written 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 an admin may set to give the router a default top-nav
theme (a shell script cannot write localStorage).
`config.main?.`, not `config.main.`: ucode raises a hard reference error on a property read
through null, and this template renders every page, so a dispatcher that stopped defaulting
that section would answer 500 everywhere. `ucode -T -c` compiles the template and never runs
it, so the CI gate cannot see the difference. */
fs_defaults.layout = fsd.layout || config.main?.footstrap_layout || '';
/* Optional companion packages announce themselves in the theme's own config —
`uci add_list footstrap.settings.plugin=<module>` from the package's uci-defaults — rather
than being named in this tree. The chrome requires each name and knows nothing else about it,
so the edge points inwards exactly as fs-router.onNavigate does.
Whitelisted to the shape of a LuCI module name before it is printed into a <script>: writing
that file needs root, but a value that reaches an inline script is checked anyway. */
let fs_plugins = fsd.plugin ?? [];
if (type(fs_plugins) != 'array')
fs_plugins = [ fs_plugins ];
fs_plugins = filter(fs_plugins, (m) => type(m) == 'string' && match(m, /^[a-zA-Z0-9._-]{1,48}$/));
http.prepare_content('text/html; charset=UTF-8');
-%}
{% include('themes/footstrap/partials/head', { boardinfo, fs_defaults, fs_plugins }) %}
{#
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 every
`body[data-page='admin-status-overview']` rule would silently not apply. The SPA router
stamps the resolved leaf path, so both ways of arriving agree.
#}
<body class="lang_{{ dispatcher.lang }}" data-page="{{ entityencode(join('-', length(ctx.path) ? ctx.path : ctx.request_path), true) }}">
{#
The wallpaper pattern's paint surface: 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 needs a layer of its own with nothing in it.
Outside the blank_page guard on purpose: the login page renders without the shell, and a
router whose saved default is Pattern has to show it there too.
Inert unless the axis is on — .fs-pattern is display:none by default.
data-fs-chrome: zone 1, ours — see the <nav> below.
#}
<div class="fs-pattern" data-fs-chrome aria-hidden="true"></div>
{% if (!blank_page): %}
{#
Skip link: jump the menu instead of tabbing through every section on every page.
Visually hidden until focused (.fs-skip, theme/10-chrome.css).
data-fs-chrome: zone 1 — see the <nav> below.
#}
<a class="fs-skip" data-fs-chrome href="#maincontent">{{ _('Skip to content') }}</a>
<div class="fs-shell">
{#
<nav>, not <aside>: this column IS the site navigation, and an <aside> gets
role="complementary", leaving the shell with 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: the chrome is not one element — the skip link and the search overlay
are outside this <nav> — so a fence naming `.fs-sidebar` protects the menu and
leaves them exposed.
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 — nesting cost .fs-label its nowrap and forced
text-align from start to left on 302 elements. `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. The
Appearance controls are their own page, System -> Footstrap, registered by the
theme's own menu.d entry (docs/architecture.md) — no tab, no fs-appearance.js
mounting of any kind reaches this header. #}
{% 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, which
`hidden` cannot do — display:none strips the element from the accessibility tree,
so the SPA router's .fs-title-main sync would update a node no assistive tech can
read. .fs-sr clips instead, and since a foreign `*` outranks that class with one
`!important` it is marked zone 1 too. Inside .fs-main, which is not marked: that
is where app pages render. #}
<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, which a screen reader cannot
notice: no load event, focus dies with the clicked <a>, the new <title> is not
announced. The router writes the incoming title here after every nav; empty on a
full load, which announces itself. #}
<div id="fs-nav-status" class="fs-sr" data-fs-chrome role="status" aria-live="polite"></div>
<div class="fs-content">
{% include('themes/footstrap/partials/notices', { boardinfo, empty_password }) %}
{% endif %}