The interface speaks Slovak

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>
This commit is contained in:
2026-09-26 14:46:33 +00:00
co-authored by Claude Opus 5
parent da0797ccad
commit a16510aba8
104 changed files with 3833 additions and 1788 deletions
+90 -119
View File
@@ -17,7 +17,7 @@
<main class="main">
<header class="topbar">
{% include "partials/_sidebar_toggle.html" %}
<h1 class="topbar__title">Your settings</h1>
<h1 class="topbar__title">{{ t("Your settings") }}</h1>
</header>
{#
@@ -37,7 +37,7 @@
the matching panel keys off exactly that. Being an honest radio group beats
claiming to be a tab interface and then not behaving as one.
#}
<div class="tabs__bar" role="radiogroup" aria-label="Settings sections">
<div class="tabs__bar" role="radiogroup" aria-label="{{ t('Settings sections') }}">
<input class="visually-hidden" type="radio" name="settings-tab" id="tab-account" checked>
<label class="tabs__tab" for="tab-account">{{ icon("user", "icon--sm") }} Account</label>
@@ -77,7 +77,7 @@
{# --- Account --- #}
<section class="tabs__panel" data-tab="tab-account">
<div class="card">
<h2 class="card__title">Account</h2>
<h2 class="card__title">{{ t("Account") }}</h2>
<dl class="detail-list">
<dt>Name</dt><dd>{{ user.name }}</dd>
<dt>Email</dt><dd class="mono">{{ user.email }}</dd>
@@ -88,14 +88,14 @@
{% if user.groups %}
{% for group in user.groups %}<span class="badge">{{ group.name }}</span> {% endfor %}
{% else %}
<span class="faint">None</span>
<span class="faint">{{ t("None") }}</span>
{% endif %}
</dd>
</dl>
</div>
<div class="card">
<h2 class="card__title">What you can do</h2>
<h2 class="card__title">{{ t("What you can do") }}</h2>
<p class="card__lede">
{% if user.is_admin %}
You are an administrator, so every permission applies.
@@ -119,15 +119,13 @@
{# --- Models --- #}
<section class="tabs__panel" data-tab="tab-models">
<div class="card">
<h2 class="card__title">Default model</h2>
<p class="card__lede">
What a new chat starts with. Existing chats keep their model.
</p>
<h2 class="card__title">{{ t("Default model") }}</h2>
<p class="card__lede">{{ t("What a new chat starts with. Existing chats keep their model.") }}</p>
{% if models %}
<form method="post" action="/api/preferences/default-model">
<div class="field">
<select class="select" name="model_id" aria-label="Default model">
<option value="">Use the instance default</option>
<select class="select" name="model_id" aria-label="{{ t('Default model') }}">
<option value="">{{ t("Use the instance default") }}</option>
{% for model in models %}
<option value="{{ model.model_id }}"
{{ 'selected' if model.model_id == user.settings_json.get('default_model') }}>
@@ -136,17 +134,17 @@
{% endfor %}
</select>
</div>
<button class="btn btn--primary" type="submit">Save</button>
<button class="btn btn--primary" type="submit">{{ t("Save") }}</button>
</form>
{% else %}
<p class="muted text-sm">No models are available to you yet.</p>
<p class="muted text-sm">{{ t("No models are available to you yet.") }}</p>
{% endif %}
</div>
{% if models %}
<div class="card">
<h2 class="card__title">Available to you</h2>
<p class="card__lede">In the order an administrator arranged them.</p>
<h2 class="card__title">{{ t("Available to you") }}</h2>
<p class="card__lede">{{ t("In the order an administrator arranged them.") }}</p>
<ul class="model-list">
{% for model in models %}
<li class="model-list__item">
@@ -163,7 +161,7 @@
{% for name, on in (model.capabilities_json or {}).items() %}
{% if on %}<span class="badge badge--leaf">{{ name }}</span>{% endif %}
{% endfor %}
{% if model.pinned %}<span class="badge">pinned</span>{% endif %}
{% if model.pinned %}<span class="badge">{{ t("pinned") }}</span>{% endif %}
</div>
</li>
{% endfor %}
@@ -175,10 +173,30 @@
{# --- Appearance --- #}
<section class="tabs__panel" data-tab="tab-appearance">
<div class="card">
<h2 class="card__title">Theme</h2>
<p class="card__lede">
Saved to this browser and to your account, so it follows you.
</p>
<h2 class="card__title">{{ t("Language") }}</h2>
<p class="card__lede">{{ t("What you see the interface in. Saved to your account rather than to this browser, so it follows you — and it overrides whatever the instance is set to.") }}</p>
<form method="post" action="/api/preferences/language">
<div class="field">
<label class="field__label visually-hidden" for="language">{{ t("Language") }}</label>
<div class="row" style="gap: var(--sp-2)">
<select class="select" id="language" name="language" style="flex: 1">
<option value="">Follow the instance ({{ instance_language }})</option>
{% for code, name in languages %}
<option value="{{ code }}" {{ 'selected' if chosen_language == code }}>
{{ name }}
</option>
{% endfor %}
</select>
<button class="btn btn--primary" type="submit">{{ t("Save") }}</button>
</div>
<p class="field__hint">{{ t("The interface only. Models answer in the language you write to them in, whatever this says.") }}</p>
</div>
</form>
</div>
<div class="card">
<h2 class="card__title">{{ t("Theme") }}</h2>
<p class="card__lede">{{ t("Saved to this browser and to your account, so it follows you.") }}</p>
{#
A loop rather than two buttons, because an administrator can
define more. The names come from the branding snapshot, so a
@@ -197,18 +215,15 @@
</div>
<div class="card">
<h2 class="card__title">Timezone</h2>
<p class="card__lede">
What time a model is told it is, and the zone anything you
schedule runs in. Leave it unset to follow the server.
</p>
<h2 class="card__title">{{ t("Timezone") }}</h2>
<p class="card__lede">{{ t("What time a model is told it is, and the zone anything you schedule runs in. Leave it unset to follow the server.") }}</p>
{#
A plain form posting to its own route, not the composer's
pattern: this page has no htmx target to swap into and a
redirect back is what stops a refresh re-submitting it.
#}
<form method="post" action="/api/preferences/timezone" class="btn-row">
<select class="select" name="timezone" style="flex: 1" aria-label="Your timezone">
<select class="select" name="timezone" style="flex: 1" aria-label="{{ t('Your timezone') }}">
<option value="" {{ 'selected' if not timezone }}>
Follow the server ({{ server_timezone }})
</option>
@@ -216,24 +231,20 @@
<option value="{{ zone }}" {{ 'selected' if zone == timezone }}>{{ zone }}</option>
{% endfor %}
</select>
<button class="btn" type="submit">Save</button>
<button class="btn" type="submit">{{ t("Save") }}</button>
</form>
{% if saved == "timezone" %}
<p class="field__hint">Saved. It is {{ local_now }} where you are.</p>
{% elif error == "timezone" %}
<p class="field__hint">That is not a timezone this server knows about.</p>
<p class="field__hint">{{ t("That is not a timezone this server knows about.") }}</p>
{% else %}
<p class="field__hint">It is currently {{ local_now }} where you are.</p>
{% endif %}
</div>
<div class="card">
<h2 class="card__title">Notifications</h2>
<p class="card__lede">
A reply, a filed report or a scheduled run arriving while you are
looking at something else. In the page always; from the browser
as well if you allow it here.
</p>
<h2 class="card__title">{{ t("Notifications") }}</h2>
<p class="card__lede">{{ t("A reply, a filed report or a scheduled run arriving while you are looking at something else. In the page always; from the browser as well if you allow it here.") }}</p>
{# A button rather than a checkbox, and that is not a style choice:
the browser refuses `requestPermission` unless it is called from
something somebody pressed. A checkbox restored on load and
@@ -241,26 +252,15 @@
and the line below it, because the true state lives in the
browser's own permission and not in anything rendered here. #}
<div class="btn-row">
<button class="btn" type="button" data-notify-toggle aria-pressed="false">
Turn on notifications
</button>
<button class="btn" type="button" data-notify-toggle aria-pressed="false">{{ t("Turn on notifications") }}</button>
</div>
<p class="field__hint" data-notify-state>
Checking what this browser allows…
</p>
<p class="field__hint">
Kept per browser, because the permission is. Nothing is shown
while you are looking at the page — the toast is there for that.
</p>
<p class="field__hint" data-notify-state>{{ t("Checking what this browser allows…") }}</p>
<p class="field__hint">{{ t("Kept per browser, because the permission is. Nothing is shown while you are looking at the page — the toast is there for that.") }}</p>
</div>
<div class="card">
<h2 class="card__title">Install as an app</h2>
<p class="card__lede">
Runs in its own window, without browser chrome. Everything still
comes from your server — there is no offline mode beyond a page
saying so.
</p>
<h2 class="card__title">{{ t("Install as an app") }}</h2>
<p class="card__lede">{{ t("Runs in its own window, without browser chrome. Everything still comes from your server — there is no offline mode beyond a page saying so.") }}</p>
{# Revealed by app.js only when the browser actually offers an
install. Firefox and desktop Safari never do, and a button that
does nothing is worse than no button. #}
@@ -275,11 +275,7 @@
line below it used to be the only explanation, and it named the
one cause that is least likely on a home network. #}
<p class="field__hint" data-install-status hidden></p>
<p class="field__hint">
Installing needs a secure connection — HTTPS with a certificate
this device trusts, or localhost — and some browsers never offer
it. On iOS, use Share → Add to Home Screen.
</p>
<p class="field__hint">{{ t("Installing needs a secure connection — HTTPS with a certificate this device trusts, or localhost — and some browsers never offer it. On iOS, use Share → Add to Home Screen.") }}</p>
</div>
</section>
@@ -289,14 +285,12 @@
<form method="post" action="/api/preferences/audio">
{% if audio.tts_enabled %}
<div class="card">
<h2 class="card__title">Reading replies aloud</h2>
<p class="card__lede">
Overrides what the administrator chose, for you only.
</p>
<h2 class="card__title">{{ t("Reading replies aloud") }}</h2>
<p class="card__lede">{{ t("Overrides what the administrator chose, for you only.") }}</p>
<div class="grid grid--2">
<div class="field">
<label class="field__label" for="audio-voice">Voice</label>
<label class="field__label" for="audio-voice">{{ t("Voice") }}</label>
{# Options are fetched from the speech endpoint rather than
stored, so the list follows the server. #}
<select class="select" id="audio-voice" name="voice">
@@ -314,12 +308,12 @@
</p>
</div>
<div class="field">
<label class="field__label" for="audio-speed">Speed</label>
<label class="field__label" for="audio-speed">{{ t("Speed") }}</label>
<input class="input" id="audio-speed" name="speed" type="number"
min="0.25" max="4" step="0.05"
value="{{ user_audio.get('speed', '') }}"
placeholder="{{ audio.tts_speed }}">
<p class="field__hint">Leave empty to use the instance default.</p>
<p class="field__hint">{{ t("Leave empty to use the instance default.") }}</p>
</div>
</div>
@@ -327,20 +321,18 @@
<label class="checkbox">
<input type="checkbox" name="autoplay" value="true"
{{ 'checked' if user_audio.get('autoplay', audio.tts_autoplay) }}>
<span>Read each reply aloud as it finishes</span>
<span>{{ t("Read each reply aloud as it finishes") }}</span>
</label>
<p class="field__hint">
Only replies that arrive while you are looking at the chat.
</p>
<p class="field__hint">{{ t("Only replies that arrive while you are looking at the chat.") }}</p>
</div>
</div>
{% endif %}
{% if audio.stt_enabled %}
<div class="card">
<h2 class="card__title">Dictation</h2>
<h2 class="card__title">{{ t("Dictation") }}</h2>
<div class="field">
<label class="field__label" for="audio-language">Language</label>
<label class="field__label" for="audio-language">{{ t("Language") }}</label>
<input class="input" id="audio-language" name="language" maxlength="16"
value="{{ user_audio.get('language', '') }}"
placeholder="{{ audio.stt_language or 'detect' }}">
@@ -349,15 +341,12 @@
lets the server work it out, which is usually best.
</p>
</div>
<p class="field__hint">
The microphone needs HTTPS or localhost — browsers do not grant
it over plain HTTP.
</p>
<p class="field__hint">{{ t("The microphone needs HTTPS or localhost — browsers do not grant it over plain HTTP.") }}</p>
</div>
{% endif %}
<div class="btn-row">
<button class="btn btn--primary" type="submit">Save audio preferences</button>
<button class="btn btn--primary" type="submit">{{ t("Save audio preferences") }}</button>
</div>
</form>
</section>
@@ -386,39 +375,36 @@
class="row" style="flex: 1; gap: var(--sp-2); min-width: 0">
<input class="input" name="content" value="{{ memory.content }}"
maxlength="{{ memory_limit }}" style="flex: 1"
aria-label="What this memory says">
<button class="btn btn--sm" type="submit">Save</button>
aria-label="{{ t('What this memory says') }}">
<button class="btn btn--sm" type="submit">{{ t("Save") }}</button>
<button class="btn btn--sm btn--danger" type="submit"
formaction="/api/library/memories/{{ memory.id }}/delete"
data-confirm-button="Forget this?"
data-confirm-title="Forget"
aria-label="Forget this" title="Forget this">
data-confirm-button="{{ t('Forget this?') }}"
data-confirm-title="{{ t('Forget') }}"
aria-label="{{ t('Forget this') }}" title="{{ t('Forget this') }}">
{{ icon("trash", "icon--sm") }}
</button>
</form>
{% if memory.author == "model" %}
<span class="badge badge--leaf">remembered</span>
<span class="badge badge--leaf">{{ t("remembered") }}</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted text-sm">
Nothing yet. A model with the memory tool adds to this when you
tell it something worth keeping.
</p>
<p class="muted text-sm">{{ t("Nothing yet. A model with the memory tool adds to this when you tell it something worth keeping.") }}</p>
{% endif %}
</div>
<div class="card">
<h2 class="card__title">Add one</h2>
<h2 class="card__title">{{ t("Add one") }}</h2>
<form method="post" action="/api/library/memories" class="row"
style="gap: var(--sp-2)">
<input class="input" name="content" required style="flex: 1"
maxlength="{{ memory_limit }}"
aria-label="Something worth remembering"
placeholder="Prefers metric units and a 24-hour clock.">
<button class="btn btn--primary" type="submit">Remember</button>
aria-label="{{ t('Something worth remembering') }}"
placeholder="{{ t('Prefers metric units and a 24-hour clock.') }}">
<button class="btn btn--primary" type="submit">{{ t("Remember") }}</button>
</form>
<p class="field__hint">
One fact per record, at most {{ memory_limit }} characters. Never
@@ -436,20 +422,14 @@
Who each model is with you
<span class="badge">{{ personalities|length }}</span>
</h2>
<p class="card__lede">
A model's character is something it works out with a particular
person, so this is yours — somebody else talking to the same model
is talking to a different one, and neither of you can see the
other's. Delete one and that model starts again from the default
its administrator wrote.
</p>
<p class="card__lede">{{ t("A model's character is something it works out with a particular person, so this is yours — somebody else talking to the same model is talking to a different one, and neither of you can see the other's. Delete one and that model starts again from the default its administrator wrote.") }}</p>
<ul class="model-list">
{% for personality in personalities %}
<li class="model-list__item">
<div style="min-width: 0">
<strong>{{ personality.model_key }}</strong>
{% if personality.author == "model" %}
<span class="badge badge--leaf">its own words</span>
<span class="badge badge--leaf">{{ t("its own words") }}</span>
{% endif %}
<div class="text-sm">{{ personality.content }}</div>
{% if personality.revisions %}
@@ -469,9 +449,9 @@
<form method="post"
action="/api/library/personalities/{{ personality.id }}/delete">
<button class="btn btn--sm btn--danger" type="submit"
data-confirm-button="Reset this model's personality with you?"
data-confirm-title="Reset"
aria-label="Reset this" title="Reset this">
data-confirm-button="{{ t('Reset this model\'s personality with you?') }}"
data-confirm-title="{{ t('Reset') }}"
aria-label="{{ t('Reset this') }}" title="{{ t('Reset this') }}">
{{ icon("trash", "icon--sm") }}
</button>
</form>
@@ -487,13 +467,7 @@
What models make of you
<span class="badge">{{ impressions|length }}</span>
</h2>
<p class="card__lede">
Each model's own impression of how you work, kept by that model and
read back to it in every conversation. Opinions rather than facts,
and each one is that model's alone — the others cannot see it, and
neither can anybody else. Delete any of them; it will form another
if it has reason to.
</p>
<p class="card__lede">{{ t("Each model's own impression of how you work, kept by that model and read back to it in every conversation. Opinions rather than facts, and each one is that model's alone — the others cannot see it, and neither can anybody else. Delete any of them; it will form another if it has reason to.") }}</p>
<ul class="model-list">
{% for impression in impressions %}
<li class="model-list__item">
@@ -504,9 +478,9 @@
<form method="post"
action="/api/library/impressions/{{ impression.id }}/delete">
<button class="btn btn--sm btn--danger" type="submit"
data-confirm-button="Delete what this model makes of you?"
data-confirm-title="Delete"
aria-label="Delete this" title="Delete this">
data-confirm-button="{{ t('Delete what this model makes of you?') }}"
data-confirm-title="{{ t('Delete') }}"
aria-label="{{ t('Delete this') }}" title="{{ t('Delete this') }}">
{{ icon("trash", "icon--sm") }}
</button>
</form>
@@ -521,38 +495,35 @@
{# --- Security --- #}
<section class="tabs__panel" data-tab="tab-security">
<div class="card">
<h2 class="card__title">Change password</h2>
<p class="card__lede">
Every other session is signed out when the password changes.
You stay signed in here.
</p>
<h2 class="card__title">{{ t("Change password") }}</h2>
<p class="card__lede">{{ t("Every other session is signed out when the password changes. You stay signed in here.") }}</p>
<form method="post" action="/api/preferences/password">
<div class="grid grid--2">
<div class="field">
<label class="field__label" for="current-password">Current password</label>
<label class="field__label" for="current-password">{{ t("Current password") }}</label>
<input class="input" type="password" id="current-password"
name="current_password" required autocomplete="current-password">
</div>
<div class="field"></div>
<div class="field">
<label class="field__label" for="new-password">New password</label>
<label class="field__label" for="new-password">{{ t("New password") }}</label>
<input class="input" type="password" id="new-password" name="new_password"
required minlength="8" autocomplete="new-password">
<p class="field__hint">At least 8 characters.</p>
<p class="field__hint">{{ t("At least 8 characters.") }}</p>
</div>
<div class="field">
<label class="field__label" for="confirm-password">Confirm new password</label>
<label class="field__label" for="confirm-password">{{ t("Confirm new password") }}</label>
<input class="input" type="password" id="confirm-password"
name="confirm_password" required minlength="8"
autocomplete="new-password">
</div>
</div>
<button class="btn btn--primary" type="submit">Change password</button>
<button class="btn btn--primary" type="submit">{{ t("Change password") }}</button>
</form>
</div>
<div class="card">
<h2 class="card__title">Session</h2>
<h2 class="card__title">{{ t("Session") }}</h2>
<form method="post" action="/auth/logout">
<button class="btn btn--danger" type="submit">
{{ icon("logout", "icon--sm") }} Sign out