Files
LLeMbas/src/lembas/web/templates/base.html
T
HomerandClaude Opus 5.5 db22962164 A reload offer only for a page that is older, and a tab switch that moves one scroller
The update toast fired whenever a service worker was waiting, so after every
release it appeared on pages that were already the release -- including one
fetched with Ctrl+Shift+R. It now compares the waiting worker's release with
the page's own, and so does the reload that follows another tab accepting it.

On Admin -> Prompts, visually hidden labels were positioned against the page
and made the document 6771px tall behind an overflow-hidden root; the tab
handler's scrollIntoView then scrolled that root and lifted the shell 56px.
Every scroll region is now a containing block, and the handler moves only the
container that scrolls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 06:17:18 +00:00

188 lines
8.9 KiB
HTML

<!doctype html>
{#
`data-base` is what makes a custom theme inherit: tokens.css matches
`[data-base="shire"]` as well as `[data-theme="shire"]`, so a custom light
theme gets the whole parchment palette underneath its own handful of colours.
Without it, four light colours would sit on Moria's near-black surfaces.
`data-themes` is the list of `id:base` pairs, which is what the browser needs
in order to set both attributes when somebody switches -- one attribute to
split rather than a JSON island to parse.
#}
<html lang="{{ language() }}" dir="{{ text_direction() }}" data-theme="{{ theme }}" data-base="{{ brand.theme(theme).base }}"
data-themes="{{ brand.theme_list }}"{% if layout %} style="{{ layout }}"{% endif %}>
<head>
<meta charset="utf-8">
{#
`viewport-fit=cover` is what lets `env(safe-area-inset-*)` resolve to
anything but zero, and without it the `black-translucent` status bar style
below is a promise with nothing behind it: iOS puts the page under the clock
and the notch and the tokens that would have paid for it stay at 0.
No `maximum-scale` and no `user-scalable=no` -- pinch-zoom is somebody's
accessibility setting, not a layout problem to be suppressed.
#}
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>{% block title %}{{ brand.name }}{% endblock %}</title>
<meta name="description" content="{{ brand.tagline or brand.name ~ ' — a web UI for your language models.' }}">
<meta name="color-scheme" content="dark light">
{# An uploaded favicon wins; then the icon derived from an uploaded logo; then
the shipped leaf. Three rungs rather than two because somebody who uploads a
logo and no favicon still expects the tab to change. #}
{% if brand.favicon_path %}
<link rel="icon" href="/branding/{{ brand.favicon_path }}">
{% elif brand.icon_paths.favicon %}
<link rel="icon" href="/branding/{{ brand.icon_paths.favicon }}">
{% else %}
<link rel="icon" href="{{ asset('img/favicon.svg') }}" type="image/svg+xml">
{% endif %}
{#
Installing as an app. The manifest is a route, not a file, because it carries
the instance name; the icons are PNG because a launcher will not take an SVG.
theme-color is rewritten by app.js when the theme changes -- the value here is
only what the browser paints with before the stylesheet has resolved.
#}
<link rel="manifest" href="/manifest.webmanifest">
{#
Two, scoped by preference, so the browser has an answer before any of our CSS
or JavaScript has run. There used to be one and it was Moria's near-black, so
every reader of the light theme got a dark browser chrome on every page load
until `app.js` -- which is deferred -- corrected it. `applyTheme` still has
the last word, and still reads the value from `--bg` rather than repeating a
hex here; these two are only what is painted before it can.
#}
<meta name="theme-color" content="#101317" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#F6F1E4" media="(prefers-color-scheme: light)">
{% if brand.icon_paths['apple-touch'] %}
<link rel="apple-touch-icon" href="/branding/{{ brand.icon_paths['apple-touch'] }}">
{% else %}
<link rel="apple-touch-icon" href="{{ asset('img/apple-touch-icon-180.png') }}">
{% endif %}
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="{{ brand.name }}">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="stylesheet" href="{{ asset('css/tokens.css') }}">
<link rel="stylesheet" href="{{ asset('css/app.css') }}">
{#
Last, so an administrator's rules win, and before {% block head %} so a page's
own stylesheet still comes after it. The query string is a hash of everything
the route builds, so the URL changes exactly when the stylesheet does -- with a
fixed URL the browser's cache would be what decided when a rebrand took effect.
Deliberately NOT in the service worker's precache list for the same reason:
that cache is versioned by the release, and branding changes between releases.
#}
<link rel="stylesheet" href="/branding.css?v={{ brand.revision }}">
{% block head %}{% endblock %}
{#
Applied before first paint so a reload never flashes the wrong theme. The
server already rendered data-theme from the signed-in user's preference; this
only corrects it for a visitor whose local choice differs (or who is signed
out, where the server has nothing to go on).
#}
<script>
(function () {
try {
var stored = localStorage.getItem("lembas-theme");
if (stored && stored !== document.documentElement.dataset.theme) {
document.documentElement.dataset.theme = stored;
}
} catch (e) { /* private mode: the server-rendered theme stands */ }
/* Panel widths, for the same reason and in the same breath. app.js also
applies these, but it is deferred -- so without this a panel dragged
wider opens at its default and jumps once the script runs. */
try {
var widths = JSON.parse(localStorage.getItem("lembas-panel-widths") || "{}");
Object.keys(widths).forEach(function (name) {
if (name.indexOf("--") === 0) {
document.documentElement.style.setProperty(name, widths[name] + "px");
}
});
} catch (e) { /* the stylesheet's defaults stand */ }
})();
</script>
</head>
<body{% block body_attrs %}{% endblock %}>
{% include "partials/icons.html" %}
{#
Every fetch this application makes, said out loud.
htmx has had `htmx-request` on the triggering element since the beginning and
nothing here has ever used it, so a click that saved a setting, opened a
panel or loaded a page of a list looked exactly like a click that did nothing
until the answer arrived. On a local endpoint that is a few milliseconds and
on anything else it is long enough to click again.
One bar for the whole page rather than a spinner per control: the interesting
question is "is the application busy", and an indicator on the control would
need adding to every control ever written, which is how the last one came to
be used nowhere. `aria-hidden` because the answer arriving is the thing worth
announcing, and htmx already moves focus for that.
#}
<div class="progress" data-progress aria-hidden="true"><span></span></div>
{% block body %}{% endblock %}
<script src="{{ asset('vendor/htmx.min.js') }}" defer></script>
<script src="{{ asset('vendor/htmx-ext-sse.js') }}" defer></script>
<script src="{{ asset('vendor/alpine.min.js') }}" defer></script>
<script src="{{ asset('js/app.js') }}" defer></script>
<script src="{{ asset('js/ui.js') }}" defer></script>
{# commands.js before composer.js: the second reads the first's table to draw
the `/` menu, and both are deferred so the order here is the run order. #}
<script src="{{ asset('js/commands.js') }}" defer></script>
<script src="{{ asset('js/composer.js') }}" defer></script>
<script src="{{ asset('js/audio.js') }}" defer></script>
{#
The version in the query string is what versions the worker's cache, so a
release invalidates it without anyone remembering to bump a constant.
🚨 The outcome is *recorded*, not swallowed. serviceWorker is absent over plain
http and registration is refused on a page with a certificate error, and in
both cases the only symptom was that the Install button never appeared -- with
a hint beside it saying installing needs HTTPS, which is true and is not an
answer. A self-signed or private-CA certificate the phone does not trust looks
exactly like a browser that cannot install at all. `window.lembasWorker` is
what `app.js` turns into a sentence on the settings page.
#}
<script>
/* The release this page was rendered by, for `app.js` to hold a waiting
worker up against -- see "A release that arrived while you were reading". */
window.lembasRelease = {{ version | tojson }};
window.lembasWorker = { state: "unsupported" };
if (!window.isSecureContext) {
/* Reported separately from an outright failure: the fix is different. */
window.lembasWorker = { state: "insecure" };
} else if ("serviceWorker" in navigator) {
window.lembasWorker = { state: "registering" };
window.addEventListener("load", function () {
/* Two callbacks rather than .then().catch(): a throw inside the success
path must not be reported as a registration failure. */
navigator.serviceWorker.register("/sw.js?v={{ version }}").then(
function () {
window.lembasWorker = { state: "ready" };
document.dispatchEvent(new CustomEvent("lembas:worker"));
},
function (error) {
window.lembasWorker = {
state: "failed",
reason: (error && error.name) || "Error",
detail: (error && error.message) || ""
};
document.dispatchEvent(new CustomEvent("lembas:worker"));
/* An install failure must never break the page it was loaded from. */
}
);
});
}
</script>
{% block scripts %}{% endblock %}
</body>
</html>