Language and theme follow the account: PATCH /me for the theme, /me's language by default
ci / ui (push) Waiting to run
ci / check (push) Failing after 2s
ci / start (push) Failing after 2s

The server's /api/v2 now has PATCH /me {language, theme} and /me lists its languages and
themes. The theme is the account's and choosing one saves it there (a refusal is said);
a server whose /me lists no themes keeps the earlier choice in the app. The language is the
account's unless Settings chose this computer's or another (kept in the app, as the phone
does); a change of the account's opens the window again in it, once.

Fixtures re-pinned to the server's dev (f7f2ff2), with the new /api/v2 parity ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-10 13:19:44 +00:00
1 parent 151fc5fcca
commit 04736db057
23 files changed
+783 -84

No files matched your search

+35 -7
View File
@@ -3,9 +3,10 @@
// script's own table. Do not edit; run the script.
//
// `t(text, fields)` is the page's English with `%(name)s` fields, as on the server; a
// missing entry is the English. The language is the one chosen in Settings (kept in
// localStorage as `lembas.language`), else the system's (navigator.language), English
// unless it is one of these. Elements marked `data-i18n` (their text) and
// missing entry is the English. The language is the account's (kept from /me as
// `lembas.account-language`) unless Settings chose this computer's or another
// (`lembas.language`); the system's is navigator.language. English unless it is one of
// these. Elements marked `data-i18n` (their text) and
// `data-i18n-title`, `data-i18n-aria-label`, `data-i18n-placeholder` (that attribute)
// are translated once the page is parsed; this script is deferred, before the page's.
"use strict"
@@ -253,6 +254,7 @@ const LEMBAS_I18N = {
"Running {{tool}}: {{what}}": "Spouští {{tool}}: {{what}}",
"Runs": "Spuštění",
"Save": "Uložit",
"Saved to your account: the web interface and the phone follow it too.": "Uloženo do vašeho účtu: řídí se jím i webové rozhraní a telefon.",
"Saving starts the reply again; everything after this message is dropped.": "Uložením se odpověď spustí znovu; vše po této zprávě se zahodí.",
"Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.": "Řekněte, co se má stát a kdy — „každý pracovní den v 8:00 shrň zprávy z mého seznamu ke čtení“ — a server to udělá za vás.",
"Scheduled": "Naplánováno",
@@ -298,6 +300,7 @@ const LEMBAS_I18N = {
"Tell me": "Upozornit mě",
"Tell me when a reply or an agent's work is finished while I am elsewhere": "Upozornit mě, když je odpověď nebo práce agenta hotová, zatímco jsem jinde",
"Terminal": "Terminál",
"The account's (%(language)s)": "Podle účtu (%(language)s)",
"The account's (%(theme)s)": "Podle účtu (%(theme)s)",
"The agent asks to run %(tool)s:": "Agent žádá o spuštění %(tool)s:",
"The agent asks:": "Agent se ptá:",
@@ -671,6 +674,7 @@ const LEMBAS_I18N = {
"Running {{tool}}: {{what}}": "Führe {{tool}} aus: {{what}}",
"Runs": "Läufe",
"Save": "Speichern",
"Saved to your account: the web interface and the phone follow it too.": "In Ihrem Konto gespeichert: Die Weboberfläche und das Telefon folgen ihm auch.",
"Saving starts the reply again; everything after this message is dropped.": "Speichern startet die Antwort neu; alles nach dieser Nachricht wird verworfen.",
"Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.": "Sagen Sie, was wann geschehen soll — „jeden Werktag um 8:00 die Nachrichten auf meiner Leseliste zusammenfassen“ — und der Server erledigt es für Sie.",
"Scheduled": "Geplant",
@@ -716,6 +720,7 @@ const LEMBAS_I18N = {
"Tell me": "Benachrichtigen",
"Tell me when a reply or an agent's work is finished while I am elsewhere": "Benachrichtigen, wenn eine Antwort oder die Arbeit des Agenten fertig ist, während ich woanders bin",
"Terminal": "Terminal",
"The account's (%(language)s)": "Wie das Konto (%(language)s)",
"The account's (%(theme)s)": "Wie das Konto (%(theme)s)",
"The agent asks to run %(tool)s:": "Der Agent möchte %(tool)s ausführen:",
"The agent asks:": "Der Agent fragt:",
@@ -1089,6 +1094,7 @@ const LEMBAS_I18N = {
"Running {{tool}}: {{what}}": "Ejecutando {{tool}}: {{what}}",
"Runs": "Ejecuciones",
"Save": "Guardar",
"Saved to your account: the web interface and the phone follow it too.": "Se guarda en su cuenta: la interfaz web y el teléfono también lo siguen.",
"Saving starts the reply again; everything after this message is dropped.": "Al guardar, la respuesta empieza de nuevo; se descarta todo lo que hay después de este mensaje.",
"Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.": "Diga qué debe ocurrir y cuándo —«cada día laborable a las 8:00, resume las noticias de mi lista de lectura»— y el servidor lo hará por usted.",
"Scheduled": "Programado",
@@ -1134,6 +1140,7 @@ const LEMBAS_I18N = {
"Tell me": "Avisarme",
"Tell me when a reply or an agent's work is finished while I am elsewhere": "Avisarme cuando una respuesta o el trabajo de un agente termine mientras estoy en otra parte",
"Terminal": "Terminal",
"The account's (%(language)s)": "El de la cuenta (%(language)s)",
"The account's (%(theme)s)": "El de la cuenta (%(theme)s)",
"The agent asks to run %(tool)s:": "El agente pide ejecutar %(tool)s:",
"The agent asks:": "El agente pregunta:",
@@ -1507,6 +1514,7 @@ const LEMBAS_I18N = {
"Running {{tool}}: {{what}}": "Exécution de {{tool}} : {{what}}",
"Runs": "Exécutions",
"Save": "Enregistrer",
"Saved to your account: the web interface and the phone follow it too.": "Enregistré dans votre compte : l’interface web et le téléphone le suivent aussi.",
"Saving starts the reply again; everything after this message is dropped.": "Enregistrer relance la réponse ; tout ce qui suit ce message est supprimé.",
"Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.": "Dites ce qui doit se passer et quand — « chaque jour ouvré à 8 h, résume les nouvelles de ma liste de lecture » — et le serveur s’en charge pour vous.",
"Scheduled": "Planifié",
@@ -1552,6 +1560,7 @@ const LEMBAS_I18N = {
"Tell me": "Me prévenir",
"Tell me when a reply or an agent's work is finished while I am elsewhere": "Me prévenir quand une réponse ou le travail d'un agent est terminé pendant que je suis ailleurs",
"Terminal": "Terminal",
"The account's (%(language)s)": "Celle du compte (%(language)s)",
"The account's (%(theme)s)": "Celui du compte (%(theme)s)",
"The agent asks to run %(tool)s:": "L'agent demande à exécuter %(tool)s :",
"The agent asks:": "L'agent demande :",
@@ -1925,6 +1934,7 @@ const LEMBAS_I18N = {
"Running {{tool}}: {{what}}": "{{tool}} futtatása: {{what}}",
"Runs": "Futások",
"Save": "Mentés",
"Saved to your account: the web interface and the phone follow it too.": "A fiókjába mentve: a webes felület és a telefon is ezt követi.",
"Saving starts the reply again; everything after this message is dropped.": "A mentés újraindítja a választ; minden, ami ez után az üzenet után jön, elvész.",
"Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.": "Mondja el, mi történjen és mikor — „minden hétköznap 8:00-kor foglald össze az olvasólistám híreit” —, és a szerver elvégzi Ön helyett.",
"Scheduled": "Ütemezett",
@@ -1970,6 +1980,7 @@ const LEMBAS_I18N = {
"Tell me": "Értesítés",
"Tell me when a reply or an agent's work is finished while I am elsewhere": "Szóljon, ha egy válasz vagy az ügynök munkája elkészül, amíg máshol vagyok",
"Terminal": "Terminál",
"The account's (%(language)s)": "A fióké (%(language)s)",
"The account's (%(theme)s)": "A fióké (%(theme)s)",
"The agent asks to run %(tool)s:": "Az ügynök a(z) %(tool)s futtatását kéri:",
"The agent asks:": "Az ügynök kérdezi:",
@@ -2343,6 +2354,7 @@ const LEMBAS_I18N = {
"Running {{tool}}: {{what}}": "Esecuzione di {{tool}}: {{what}}",
"Runs": "Esecuzioni",
"Save": "Salva",
"Saved to your account: the web interface and the phone follow it too.": "Salvato nel tuo account: anche l’interfaccia web e il telefono lo seguono.",
"Saving starts the reply again; everything after this message is dropped.": "Salvando, la risposta riparte; tutto ciò che segue questo messaggio viene scartato.",
"Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.": "Di’ cosa deve succedere e quando — «ogni giorno feriale alle 8:00, riassumi le notizie della mia lista di lettura» — e il server lo fa per te.",
"Scheduled": "Pianificato",
@@ -2388,6 +2400,7 @@ const LEMBAS_I18N = {
"Tell me": "Avvisami",
"Tell me when a reply or an agent's work is finished while I am elsewhere": "Avvisami quando una risposta o il lavoro di un agente è finito mentre sono altrove",
"Terminal": "Terminale",
"The account's (%(language)s)": "Quella dell’account (%(language)s)",
"The account's (%(theme)s)": "Quello dell’account (%(theme)s)",
"The agent asks to run %(tool)s:": "L'agente chiede di eseguire %(tool)s:",
"The agent asks:": "L'agente chiede:",
@@ -2761,6 +2774,7 @@ const LEMBAS_I18N = {
"Running {{tool}}: {{what}}": "Uruchamianie {{tool}}: {{what}}",
"Runs": "Uruchomienia",
"Save": "Zapisz",
"Saved to your account: the web interface and the phone follow it too.": "Zapisane na koncie: interfejs WWW i telefon też się do niego stosują.",
"Saving starts the reply again; everything after this message is dropped.": "Zapisanie uruchamia odpowiedź od nowa; wszystko po tej wiadomości zostanie odrzucone.",
"Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.": "Powiedz, co ma się stać i kiedy — „w każdy dzień roboczy o 8:00 streść wiadomości z mojej listy do czytania” — a serwer zrobi to za Ciebie.",
"Scheduled": "Zaplanowane",
@@ -2806,6 +2820,7 @@ const LEMBAS_I18N = {
"Tell me": "Powiadamiaj",
"Tell me when a reply or an agent's work is finished while I am elsewhere": "Powiadom mnie, gdy odpowiedź lub praca agenta jest gotowa, a ja jestem gdzie indziej",
"Terminal": "Terminal",
"The account's (%(language)s)": "Jak na koncie (%(language)s)",
"The account's (%(theme)s)": "Jak na koncie (%(theme)s)",
"The agent asks to run %(tool)s:": "Agent prosi o uruchomienie %(tool)s:",
"The agent asks:": "Agent pyta:",
@@ -3179,6 +3194,7 @@ const LEMBAS_I18N = {
"Running {{tool}}: {{what}}": "Spúšťa {{tool}}: {{what}}",
"Runs": "Spustenia",
"Save": "Uložiť",
"Saved to your account: the web interface and the phone follow it too.": "Uložené do vášho účtu: riadi sa ním aj webové rozhranie a telefón.",
"Saving starts the reply again; everything after this message is dropped.": "Uložením sa odpoveď spustí znova; všetko po tejto správe sa zahodí.",
"Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.": "Povedzte, čo sa má stať a kedy — „každý pracovný deň o 8:00 zhrň správy z môjho zoznamu na čítanie“ — a server to urobí za vás.",
"Scheduled": "Naplánované",
@@ -3224,6 +3240,7 @@ const LEMBAS_I18N = {
"Tell me": "Upozorniť ma",
"Tell me when a reply or an agent's work is finished while I am elsewhere": "Upozorniť ma, keď je odpoveď alebo práca agenta hotová, kým som inde",
"Terminal": "Terminál",
"The account's (%(language)s)": "Podľa účtu (%(language)s)",
"The account's (%(theme)s)": "Podľa účtu (%(theme)s)",
"The agent asks to run %(tool)s:": "Agent žiada spustiť %(tool)s:",
"The agent asks:": "Agent sa pýta:",
@@ -3365,12 +3382,23 @@ const LEMBAS_SYSTEM_LANG = (() => {
const code = String(navigator.language || "").slice(0, 2).toLowerCase()
return Object.prototype.hasOwnProperty.call(LEMBAS_I18N, code) ? code : "en"
})()
const LEMBAS_CHOSEN_LANG = (() => {
let code = ""
// Settings' choice: "" for the account's language (as /me last said it), "system", or a code.
const LEMBAS_CHOICE = (() => {
try {
code = localStorage.getItem("lembas.language") || ""
return localStorage.getItem("lembas.language") || ""
} catch {
return ""
}
})()
const LEMBAS_CHOSEN_LANG = (() => {
const has = (code) => Object.prototype.hasOwnProperty.call(LEMBAS_LANGS, code)
if (LEMBAS_CHOICE === "system") return ""
if (has(LEMBAS_CHOICE)) return LEMBAS_CHOICE
let account = ""
try {
account = localStorage.getItem("lembas.account-language") || ""
} catch {}
return Object.prototype.hasOwnProperty.call(LEMBAS_LANGS, code) ? code : ""
return has(account) ? account : ""
})()
const LEMBAS_LANG = LEMBAS_CHOSEN_LANG || LEMBAS_SYSTEM_LANG
document.documentElement.lang = LEMBAS_LANG
+76 -32
View File
@@ -1,16 +1,18 @@
// The app's language and theme, as chosen in Settings: kept in this app (localStorage), over the
// system's language and the account's theme. The account's own language and theme are the
// server's to change (`PATCH /me` has neither yet), so a choice here stays on this computer.
// The app's language and theme, as Settings chooses them.
//
// The language is read once, as the page loads (ui/i18n.js, `lembas.language`): every sentence is
// drawn through `t()` as it is needed, so a new one opens the window again. The theme is applied
// at once.
// The language is the account's (`/me`'s `language`) unless Settings chose this computer's or
// another one; that choice is kept in this app (localStorage `lembas.language`), as the phone
// keeps its own. It is read once, as the page loads (ui/i18n.js): every sentence is drawn through
// `t()` as it is needed, so a new language opens the window again.
//
// The theme is the account's, changed with `PATCH /me` so the web interface and the phone follow
// it. A server whose `/me` lists no `themes` cannot be told: there the choice is kept in this app.
import { invoke, letGoAll } from "./bridge.js"
import { invoke, patch, letGoAll } from "./bridge.js"
import { terminalThemeChanged } from "./terminal.js"
const THEMES = ["moria", "shire"]
let account = "moria" // the account's theme, as /me says (a custom one counts as Moria)
const LOOKS = ["moria", "shire"] // the two looks tokens.css draws; any other theme draws as Moria
let me = null // /me, as last read
function read(key) {
try {
@@ -27,21 +29,35 @@ function write(key, value) {
} catch {}
}
/** The theme chosen in this app ("" for the account's). */
export function chosenTheme() {
/** The app's code for a server's language (`pt_BR` -> `pt`), or "" for one the app lacks. */
function known(code) {
const c = String(code || "")
.trim()
.toLowerCase()
.split(/[-_]/)[0]
return Object.prototype.hasOwnProperty.call(LEMBAS_LANGS, c) ? c : ""
}
/** Whether the server takes the theme on `PATCH /me`: its `/me` lists the themes it offers. */
export function accountThemes() {
return Array.isArray(me?.themes) && me.themes.length ? me.themes : null
}
/** The theme chosen in this app, where the server cannot be told ("" for the account's). */
export function localTheme() {
const name = read("lembas.theme")
return THEMES.includes(name) ? name : ""
return !accountThemes() && LOOKS.includes(name) ? name : ""
}
/** The account's theme, as the server says it. */
export function accountTheme() {
return account
return me?.theme || "moria"
}
/** The look the window is drawn in: the one chosen here, else `name` (the account's, or Rust's last). */
/** The look the window is drawn in: `name` (the account's, or Rust's last), or the one kept here. */
export function applyTheme(name) {
name = chosenTheme() || name
if (!THEMES.includes(name)) name = "moria"
name = localTheme() || name
if (!LOOKS.includes(name)) name = "moria"
const root = document.documentElement
root.dataset.theme = name
root.dataset.base = name
@@ -49,29 +65,57 @@ export function applyTheme(name) {
return name
}
/** The account's theme is known (from /me): drawn in it, unless one is chosen here. */
export function noteAccountTheme(name) {
account = THEMES.includes(name) ? name : "moria"
invoke("set_theme", { name: applyTheme(account) })
function paint() {
invoke("set_theme", { name: applyTheme(accountTheme()) })
}
/** Choose a theme here ("" goes back to the account's): drawn at once, and the window painted in it. */
export function chooseTheme(name) {
write("lembas.theme", THEMES.includes(name) ? name : "")
invoke("set_theme", { name: applyTheme(account) })
/**
* `/me` was read: drawn in the account's theme, and, where the language follows the account and
* it is now another, the window opens again in it.
*/
export function noteMe(next) {
me = next || null
paint()
if (!me || !me.language) return
const account = known(me.language)
const before = read("lembas.account-language")
write("lembas.account-language", account)
// Only once it is kept: where it cannot be, opening again would find it unknown again, and again.
if (read("lembas.account-language") !== account) return
if (LEMBAS_CHOICE === "" && account !== before && (account || LEMBAS_SYSTEM_LANG) !== LEMBAS_LANG) reopen()
}
/** The language chosen in this app ("" for the system's). */
/**
* Choose a theme: the account's, through `PATCH /me` (rejects with the server's words); where the
* server cannot be told, kept here ("" goes back to the account's).
*/
export async function chooseTheme(name) {
if (accountThemes()) {
const r = await patch("/me", { theme: name })
me = r && r.theme ? r : { ...me, theme: name }
} else write("lembas.theme", LOOKS.includes(name) ? name : "")
paint()
}
/** Settings' language: "" the account's, "system" this computer's, or a code the app has. */
export function chosenLanguage() {
return typeof LEMBAS_CHOSEN_LANG === "string" ? LEMBAS_CHOSEN_LANG : ""
return LEMBAS_CHOICE === "system" || known(LEMBAS_CHOICE) ? LEMBAS_CHOICE : ""
}
/** Choose a language here ("" goes back to the system's): the window opens again in it. */
export async function chooseLanguage(code) {
if (code === chosenLanguage()) return
write("lembas.language", code && Object.prototype.hasOwnProperty.call(LEMBAS_LANGS, code) ? code : "")
// The replies followed and the shells open are let go first: the page that opens again has
// no handle on them.
/** The account's language, as /me last said it ("" if the app lacks it or the server says none). */
export function accountLanguage() {
return me?.language ? known(me.language) : ""
}
/** Choose the language; the window opens again in it. */
export function chooseLanguage(choice) {
if (choice === chosenLanguage()) return
write("lembas.language", choice === "system" || known(choice) ? choice : "")
return reopen()
}
/** Open the window again: the replies followed and the shells open let go first. */
async function reopen() {
await letGoAll()
location.reload()
}
+2 -2
View File
@@ -14,7 +14,7 @@ import { initExplorer, showLocal, showFilesTab } from "./explorer.js"
import { initList, refreshList, markCurrent } from "./list.js"
import { initChat, openChat, newChat, closeChat, openChatId, chatMenu, statusChanged, draftKind, onThisComputer, modelsChanged, intoComposer } from "./chat.js"
import { initTerminal, showTerminal, terminalOpen, terminalAvailable, toggleTerminal } from "./terminal.js"
import { applyTheme, noteAccountTheme } from "./look.js"
import { applyTheme, noteMe } from "./look.js"
import { initAttach, allowAttach } from "./attach.js"
import { initPalette, openPalette } from "./palette.js"
import { initActivity, showPanel, togglePanel, panelOpen } from "./activity.js"
@@ -92,7 +92,7 @@ async function startApp() {
started = true
try {
me = await get("/me")
noteAccountTheme(me.theme)
noteMe(me)
} catch (err) {
$("thread-note").textContent = why(err)
$("thread-note").hidden = false
+44 -9
View File
@@ -6,7 +6,8 @@
import { $, h, clear, icon, ICONS } from "./dom.js"
import { invoke, listen } from "./bridge.js"
import { agentBlock, onAgentChange, link } from "./agent.js"
import { chosenTheme, chooseTheme, accountTheme, chosenLanguage, chooseLanguage } from "./look.js"
import { localTheme, chooseTheme, accountTheme, accountThemes, chosenLanguage, chooseLanguage, accountLanguage } from "./look.js"
import { why } from "./bridge.js"
let folderError = ""
let current = () => null
@@ -148,27 +149,61 @@ export function renderSettings(s) {
)
}
/** The language and the theme: this app's choice over the system's language and the account's theme. */
let lookError = ""
/**
* The language and the theme. The language: the account's (the default), this computer's, or one
* the app has, kept here. The theme: the account's, saved to it; kept here where the server
* cannot be told.
*/
function lookSection() {
const named = (code) => LEMBAS_LANGS[code] || code
const account = accountLanguage()
const languages = [
["", t("The account's (%(language)s)", { language: named(account || LEMBAS_SYSTEM_LANG) })],
["system", t("This computer's (%(language)s)", { language: named(LEMBAS_SYSTEM_LANG) })],
...Object.keys(LEMBAS_LANGS).map((code) => [code, named(code)]),
]
const language = h(
"select",
{ id: "set-language", "aria-label": t("Language"), onchange: (e) => chooseLanguage(e.target.value) },
[["", t("This computer's (%(language)s)", { language: named(LEMBAS_SYSTEM_LANG) })], ...Object.keys(LEMBAS_LANGS).map((code) => [code, named(code)])].map(([v, label]) =>
h("option", { value: v, text: label, selected: chosenLanguage() === v, lang: v || undefined }),
),
languages.map(([v, label]) => h("option", { value: v, text: label, selected: chosenLanguage() === v, lang: v && v !== "system" ? v : undefined })),
)
const label = (name) => `${name === "shire" ? "Shire" : "Moria"} — ${name === "shire" ? t("light") : t("dark")}`
const scheme = (s) => (s === "light" ? t("light") : t("dark"))
const offered = accountThemes()
const themes = offered
? offered.map((th) => [th.id, `${th.label || th.id} — ${scheme(th.scheme)}`])
: [
["", t("The account's (%(theme)s)", { theme: accountTheme() === "shire" ? `Shire — ${t("light")}` : `Moria — ${t("dark")}` })],
["moria", `Moria — ${t("dark")}`],
["shire", `Shire — ${t("light")}`],
]
const chosen = offered ? accountTheme() : localTheme()
const theme = h(
"select",
{ id: "set-theme", "aria-label": t("Theme"), onchange: (e) => chooseTheme(e.target.value) },
[["", t("The account's (%(theme)s)", { theme: label(accountTheme()) })], ["moria", label("moria")], ["shire", label("shire")]].map(([v, text]) => h("option", { value: v, text, selected: chosenTheme() === v })),
{
id: "set-theme",
"aria-label": t("Theme"),
onchange: async (e) => {
const el = e.target
el.disabled = true
try {
await chooseTheme(el.value)
lookError = ""
} catch (err) {
lookError = why(err)
}
renderSettings(current())
},
},
themes.map(([v, text]) => h("option", { value: v, text, selected: chosen === v })),
)
return section(
"look",
t("Appearance"),
line(t("Language"), `${t("The interface only. Models answer in the language you write to them in, whatever this says.")} ${t("Kept in this app on this computer. The window opens again in the language chosen.")}`, language),
line(t("Theme"), t("Kept in this app on this computer; the account's theme stays as it is."), theme),
line(t("Theme"), offered ? t("Saved to your account: the web interface and the phone follow it too.") : t("Kept in this app on this computer; the account's theme stays as it is."), theme),
lookError ? h("p", { class: "status is-error", role: "alert", text: lookError }) : null,
)
}