Language and theme follow the account: PATCH /me for the theme, /me's language by default
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:
23 files changed
+783
-84
No files matched your search
+76
-32
@@ -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
@@ -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
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user