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:
@@ -106,7 +106,7 @@
|
||||
<textarea class="composer__input" name="content" rows="1"
|
||||
data-autosize data-max-height="320" data-composer-input
|
||||
placeholder="{% if chat %}Send a message…{% else %}Ask anything…{% endif %}"
|
||||
aria-label="Message" {{ 'autofocus' if not chat }}></textarea>
|
||||
aria-label="{{ t('Message') }}" {{ 'autofocus' if not chat }}></textarea>
|
||||
</div>
|
||||
|
||||
<div class="composer__toolbar">
|
||||
@@ -118,34 +118,34 @@
|
||||
<div class="picker picker--up" data-picker>
|
||||
<button class="btn btn--icon composer__btn" type="button" data-picker-toggle
|
||||
aria-haspopup="menu" aria-expanded="false"
|
||||
aria-label="Attach" title="Attach">
|
||||
aria-label="{{ t('Attach') }}" title="{{ t('Attach') }}">
|
||||
{{ icon("attach") }}
|
||||
</button>
|
||||
|
||||
<div class="picker__menu picker__menu--compact" data-picker-menu role="menu"
|
||||
hidden aria-label="Attach">
|
||||
hidden aria-label="{{ t('Attach') }}">
|
||||
<button class="picker__option" type="button" role="menuitem"
|
||||
data-attach="file">
|
||||
{{ icon("attach", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">File</span>
|
||||
<span class="picker__option-note">PDF, text, code</span>
|
||||
<span class="picker__option-name">{{ t("File") }}</span>
|
||||
<span class="picker__option-note">{{ t("PDF, text, code") }}</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="picker__option" type="button" role="menuitem"
|
||||
data-attach="image">
|
||||
{{ icon("image", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">Image</span>
|
||||
<span class="picker__option-note">Sent only to vision models</span>
|
||||
<span class="picker__option-name">{{ t("Image") }}</span>
|
||||
<span class="picker__option-note">{{ t("Sent only to vision models") }}</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="picker__option" type="button" role="menuitem"
|
||||
data-attach="link">
|
||||
{{ icon("link", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">Link</span>
|
||||
<span class="picker__option-note">Fetch a page and attach its text</span>
|
||||
<span class="picker__option-name">{{ t("Link") }}</span>
|
||||
<span class="picker__option-note">{{ t("Fetch a page and attach its text") }}</span>
|
||||
</span>
|
||||
</button>
|
||||
{% if can.get("library.use") %}
|
||||
@@ -153,8 +153,8 @@
|
||||
data-attach="knowledge">
|
||||
{{ icon("archive", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">Knowledge</span>
|
||||
<span class="picker__option-note">From your library</span>
|
||||
<span class="picker__option-name">{{ t("Knowledge") }}</span>
|
||||
<span class="picker__option-note">{{ t("From your library") }}</span>
|
||||
</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
@@ -209,20 +209,18 @@
|
||||
<div class="picker picker--up" data-picker>
|
||||
<button class="btn btn--icon composer__btn" type="button" data-picker-toggle
|
||||
aria-haspopup="menu" aria-expanded="false"
|
||||
aria-label="Toggle" title="Toggle">
|
||||
aria-label="{{ t('Toggle') }}" title="{{ t('Toggle') }}">
|
||||
{{ icon("sliders") }}
|
||||
</button>
|
||||
|
||||
<div class="picker__menu picker__menu--scope" data-picker-menu role="menu"
|
||||
hidden aria-label="Toggle">
|
||||
hidden aria-label="{{ t('Toggle') }}">
|
||||
{% if has_scope %}
|
||||
<p class="picker__lede">
|
||||
Switched off here only. Everything is on unless you say otherwise.
|
||||
</p>
|
||||
<p class="picker__lede">{{ t("Switched off here only. Everything is on unless you say otherwise.") }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if scope_families %}
|
||||
<p class="picker__group">Tools</p>
|
||||
<p class="picker__group">{{ t("Tools") }}</p>
|
||||
{% for family in scope_families %}
|
||||
<label class="picker__option picker__option--toggle">
|
||||
{% if scope_prospective %}
|
||||
@@ -243,7 +241,7 @@
|
||||
{% endif %}
|
||||
|
||||
{% if scope_skills %}
|
||||
<p class="picker__group">Skills</p>
|
||||
<p class="picker__group">{{ t("Skills") }}</p>
|
||||
{% for skill in scope_skills %}
|
||||
<label class="picker__option picker__option--toggle">
|
||||
{% if scope_prospective %}
|
||||
@@ -277,7 +275,7 @@
|
||||
empty body. #}
|
||||
{% if scope_allow %}
|
||||
<div class="picker__allow">
|
||||
<p class="picker__group">Always allowed here</p>
|
||||
<p class="picker__group">{{ t("Always allowed here") }}</p>
|
||||
{% for entry in scope_allow %}
|
||||
<p class="picker__allow-entry">{{ entry }}</p>
|
||||
{% endfor %}
|
||||
@@ -286,7 +284,7 @@
|
||||
hx-target="closest .picker__allow" hx-swap="outerHTML">
|
||||
{{ icon("trash", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">Ask me about these again</span>
|
||||
<span class="picker__option-name">{{ t("Ask me about these again") }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -316,7 +314,7 @@
|
||||
<input type="hidden" name="kind" value="{{ starting_kind | default('chat') }}"
|
||||
id="chat-kind">
|
||||
|
||||
<div class="segmented" role="group" aria-label="Kind of chat">
|
||||
<div class="segmented" role="group" aria-label="{{ t('Kind of chat') }}">
|
||||
<label class="segmented__option">
|
||||
<input type="radio" name="kind_choice" value="chat"
|
||||
{{ '' if starting_kind == 'agent' else 'checked' }}>
|
||||
@@ -331,7 +329,7 @@
|
||||
|
||||
<span class="composer__agent" data-agent-extra hidden>
|
||||
<select class="select select--sm composer__connection"
|
||||
name="ssh_profile_id" aria-label="Connection">
|
||||
name="ssh_profile_id" aria-label="{{ t('Connection') }}">
|
||||
{% for profile in agent_profiles %}
|
||||
<option value="{{ profile.id }}" data-dir="{{ profile.default_dir }}"
|
||||
{{ 'disabled' if not profile.verified }}>
|
||||
@@ -346,14 +344,14 @@
|
||||
is also something you would rather find than spell. #}
|
||||
<input type="hidden" name="project_dir" value="" data-dir-value>
|
||||
<button class="btn btn--sm composer__dir" type="button" data-dir-browse
|
||||
aria-label="Project directory"
|
||||
title="Choose the directory this chat works in">
|
||||
aria-label="{{ t('Project directory') }}"
|
||||
title="{{ t('Choose the directory this chat works in') }}">
|
||||
{{ icon("folder", "icon--sm") }}
|
||||
<span class="composer__dir-path" data-dir-label>/</span>
|
||||
</button>
|
||||
|
||||
{% if agent_modes %}
|
||||
<select class="select select--sm" name="agent_mode" aria-label="Approval mode">
|
||||
<select class="select select--sm" name="agent_mode" aria-label="{{ t('Approval mode') }}">
|
||||
{% for value, label, hint in agent_modes %}
|
||||
<option value="{{ value }}" title="{{ hint }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
@@ -376,7 +374,7 @@
|
||||
through this element's *ancestors* -- which a sibling form is not.
|
||||
`form=` scopes the values, and only the values. #}
|
||||
<div class="composer__context">
|
||||
<select class="select select--sm" name="agent_mode" aria-label="Approval mode"
|
||||
<select class="select select--sm" name="agent_mode" aria-label="{{ t('Approval mode') }}"
|
||||
form="agent-mode-form"
|
||||
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none">
|
||||
{% for value, label, hint in agent_modes %}
|
||||
@@ -428,7 +426,7 @@
|
||||
#}
|
||||
{% if current_model and current_model.capabilities_json.get("reasoning") %}
|
||||
<select class="select select--sm" name="reasoning_effort" data-effort
|
||||
aria-label="Reasoning effort" title="How hard this model should think"
|
||||
aria-label="{{ t('Reasoning effort') }}" title="{{ t('How hard this model should think') }}"
|
||||
{% if chat %}
|
||||
form="chat-params-form"
|
||||
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
|
||||
@@ -449,7 +447,7 @@
|
||||
#}
|
||||
{% set chosen = resolved_effort if chat
|
||||
else (current_model.params_json or {}).get('reasoning_effort') %}
|
||||
<option value="off" {{ 'selected' if chosen not in efforts }}>Effort: off</option>
|
||||
<option value="off" {{ 'selected' if chosen not in efforts }}>{{ t("Effort: off") }}</option>
|
||||
{% for value in efforts %}
|
||||
<option value="{{ value }}" {{ 'selected' if chosen == value }}>
|
||||
Effort: {{ value }}
|
||||
@@ -464,7 +462,7 @@
|
||||
data-mic-state and the icon with it. #}
|
||||
<button class="btn btn--icon composer__btn composer__mic" type="button"
|
||||
data-mic data-mic-state="idle"
|
||||
aria-label="Dictate a message" title="Dictate a message">
|
||||
aria-label="{{ t('Dictate a message') }}" title="{{ t('Dictate a message') }}">
|
||||
<span class="composer__icon composer__icon--mic">{{ icon("mic") }}</span>
|
||||
<span class="composer__icon composer__icon--recording" aria-hidden="true">
|
||||
{{ icon("stop-circle") }}
|
||||
@@ -483,7 +481,7 @@
|
||||
layout and cannot flash an empty button.
|
||||
#}
|
||||
<button class="btn btn--primary btn--icon composer__btn" type="submit"
|
||||
data-composer-action="send" aria-label="Send">
|
||||
data-composer-action="send" aria-label="{{ t('Send') }}">
|
||||
<span class="composer__icon composer__icon--send">{{ icon("send") }}</span>
|
||||
<span class="composer__icon composer__icon--stop" aria-hidden="true">
|
||||
<span class="composer__stop-square"></span>
|
||||
@@ -514,9 +512,7 @@
|
||||
<p class="composer__hint">
|
||||
Enter to send, Shift+Enter for a new line.
|
||||
<button class="composer__hint-link" type="button"
|
||||
onclick="window.lembasCommands && window.lembasCommands.help()">
|
||||
/ for commands
|
||||
</button>,
|
||||
onclick="window.lembasCommands && window.lembasCommands.help()">{{ t("/ for commands") }}</button>,
|
||||
@ for files.
|
||||
{% if can.get("files.upload") %}
|
||||
Drag files in, or paste an image.
|
||||
@@ -531,7 +527,7 @@
|
||||
{% if can.get("files.upload") %}
|
||||
<div class="dropzone-overlay" aria-hidden="true">
|
||||
{{ icon("attach", "icon--lg") }}
|
||||
<span>Drop to attach</span>
|
||||
<span>{{ t("Drop to attach") }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user