969 strings, an instance default and a per-person choice, and no half-done corner: the admin prose is translated too. Design and reasoning: LLeMbas.wiki/Translations. KEYED BY THE ENGLISH SENTENCE A missing entry renders the key, which is the English -- so an untranslated string looks as it always did, an English instance is byte-for-byte 1.6.0, and a half-finished catalogue is a half-translated page rather than a page of dotted key names. The cost is that editing an English sentence orphans its translation silently, which is what tests/test_translations.py asserts in both directions. No gettext: .po -> .mo is a build step and this project does not have one. A JINJA GLOBAL, AND THEREFORE A CONTEXTVAR `t()` is a global for the reason `brand` already documents -- render() is bypassed by 25 TemplateResponse calls and 8 get_template().render() calls, the latter being the SSE frames, which have no Request at all. A global is bound once at import and the language is per person, so the active language is a ContextVar set per request. 🚨 `get_current_user` had to become `async def`. FastAPI runs a sync dependency in a threadpool, and anyio copies the context in and discards it on the way out -- so the language was set where nothing could see it and every page rendered in the instance's language whatever anybody had chosen, with no error anywhere. NOT TRANSLATED, ON PURPOSE Everything a model reads: the 60 prompt fragments, and the dates in harness.py, schedule/runner.py and schedule/compile.py. Only `i18n.stamp` is localised, and only where a person reads it -- with the *format* translatable as well as the words, because "26. septembra 2026" is a different pattern rather than the same one with different words in it. A process locale is not an option: global, not thread-safe, two people's pages at once. A second fragment telling models to answer in the reader's language was written during this work and removed: `core.style` has said it since long before, and test_an_empty_override_turns_a_fragment_off caught the duplicate. THE BULK PASS 1213 sites wrapped by a one-off script that only touched patterns it could not misread. It got three wrong in a way that mattered -- `t('…')` inside `attr="…"` where the sentence held an apostrophe, closing the Jinja string and 500ing two pages whose partials no test renders. tests/test_translations.py now compiles all 110 templates. It also wrapped the product's own name, an SSH key header, a keystroke hint and an example URL, all taken back out: a string is not translatable just because it is a string. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
185 lines
8.7 KiB
HTML
185 lines
8.7 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>
|
|
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>
|