Draw the mockup with the screens' own words, compile every text while building, and send the language with the live updates

- The page takes the screens' words its mockup draws from their own texts: screen.ha (Home Assistant's words, filled
  from Home Assistant), screen.time, screen.date, screen.number and screen.tile.page, per language next to _meta and
  editor. The copies under editor.mockup go; the rest of `screen` and all of `addon` stay out of the build.
- Numbers in the mockup follow the screens' language (screen.number, with its minimum grouping) or the chosen style,
  as the firmware writes them.
- @intlify/unplugin-vue-i18n compiles every text at build time, so the page runs vue-i18n's runtime-only build
  without the message compiler; every language's _meta comes with the page. Empty texts stay out while building.
- The live updates carry the editor's language (api/events?language=), as an EventSource sends no headers.
- web/ needs Node 22.13 or newer: vue-i18n 11.4, Vite 7 and the unplugin ask for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-19 14:59:02 +02:00
parent 1dc2eab0cf
commit 94b320074d
34 changed files with 2491 additions and 292 deletions
+6 -6
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
// Everything around the screens: firmware updates, language and region, alerts, Claude.
import { computed, ref } from "vue";
import { haProfile, matchLanguage, numberText, type NumberStyle, t } from "../i18n";
import { anyUpdating, go, installClaudeSkill, runUpdateAll, saveLanguage, setAutoUpdate, state, updateProgress } from "../store";
import { haProfile, matchLanguage, numberText, type NumberMarks, type NumberStyle, STYLE_MARKS, t } from "../i18n";
import { anyUpdating, go, installClaudeSkill, runUpdateAll, saveLanguage, screenLanguageMarks, setAutoUpdate, state, updateProgress } from "../store";
const u = computed(() => state.inventory.updates);
const outdated = computed(() => state.inventory.screens.filter((s) => s.update?.available).length);
@@ -42,7 +42,7 @@ const haName = computed(() => {
});
const unchecked = computed(() => lang.value?.languages.find((own) => own.code === lang.value!.effective && !own.checked));
const clockName = (clock: string | undefined) => t(clock === "12" ? "editor.settings.language.clock_12" : "editor.settings.language.clock_24");
const example = (style: NumberStyle) => numberText("1234.5", style);
const example = (marks: NumberMarks) => numberText("1234.5", marks);
// The user's own Home Assistant profile, where it names a clock or a number format that the screens don't use.
const profile = haProfile();
const different = computed(() => {
@@ -53,7 +53,7 @@ const different = computed(() => {
...(profile.numbers && profile.numbers !== (l.numbers_effective || "point") ? { numbers: profile.numbers } : {}),
};
});
const profileText = computed(() => [different.value.clock && clockName(different.value.clock), different.value.numbers && example(different.value.numbers)]
const profileText = computed(() => [different.value.clock && clockName(different.value.clock), different.value.numbers && example(STYLE_MARKS[different.value.numbers])]
.filter(Boolean).join(" · "));
const saving = ref(false);
async function choose(field: "setting" | "clock" | "numbers", event: Event) {
@@ -133,8 +133,8 @@ async function useProfile() {
<div class="field">
<label class="f-label" for="number-format">{{ t("editor.settings.language.numbers") }}</label>
<select id="number-format" :value="lang.numbers || 'auto'" :disabled="saving" @change="choose('numbers', $event)">
<option value="auto">{{ t("editor.settings.language.numbers_auto", { example: example(lang.numbers_effective || "point") }) }}</option>
<option v-for="style in STYLES" :key="style" :value="style">{{ example(style) }}</option>
<option value="auto">{{ t("editor.settings.language.numbers_auto", { example: example(screenLanguageMarks) }) }}</option>
<option v-for="style in STYLES" :key="style" :value="style">{{ example(STYLE_MARKS[style]) }}</option>
</select>
</div>
<div v-if="profileText" class="actions" id="language-profile">
+27 -18
View File
@@ -3,10 +3,10 @@
// dragged, drawn where it will land.
import { computed, nextTick } from "vue";
import { vDrag } from "../drag";
import { numberText, t } from "../i18n";
import { numberText, t, te } from "../i18n";
import { displayName, effectiveControls, isFull, isWide, pageOf, pageTarget, SLOTS_PER_PAGE } from "../model/layout";
import { clockText, glyph } from "../model/topbar";
import { clock24, entityName, isSelected, liveOf, numberStyle, openTile, placeTile, removeTile, screenText, state, tileIconCp } from "../store";
import { clock24, entityName, isSelected, liveOf, numberMarks, openTile, placeTile, removeTile, screenText, state, tileIconCp } from "../store";
import type { Tile } from "../types";
const props = defineProps<{ tile: Tile; slot: number; placeholder?: boolean }>();
@@ -37,25 +37,33 @@ const isOn = computed(() => ["light", "switch", "input_boolean", "fan"].includes
const unit = computed(() => current.value?.a?.unit_of_measurement as string | undefined);
const capital = (text: string) => text.charAt(0).toUpperCase() + text.slice(1).replace(/_/g, " ");
// Numbers as the screens write them, "1,234.5" or "1.234,5" (app 0.2.90).
const num = (value: unknown) => numberText(value as string | number, numberStyle.value);
// Home Assistant's words for the weather states, for a Home Assistant that hands us none (editor.mockup.weather).
const WEATHER = ["clear-night", "cloudy", "exceptional", "fog", "hail", "lightning", "lightning-rainy", "partlycloudy", "pouring", "rainy", "snowy", "snowy-rainy", "sunny", "windy", "windy-variant"];
const weatherWord = (value: string) => (WEATHER.includes(value) ? screenText(`editor.mockup.weather.${value.replace(/-/g, "_")}`) : "");
const num = (value: unknown) => numberText(value as string | number, numberMarks.value);
// The screens' own words for a state where Home Assistant hands us none (screen.ha, Home Assistant's words in the
// screens' language, app 0.2.90): a binary sensor's by its device class, on and off, and the states of the domains
// the screen names itself. A weather's windy-variant is windy there too.
const HA_WORDS: Record<string, string> = { climate: "climate", cover: "cover", media_player: "media", person: "person", sun: "sun", vacuum: "vacuum", weather: "weather" };
function haWord(c: { state: string; a: Record<string, any> }) {
const key = (path: string) => (te(`screen.ha.${path}`) ? screenText(`screen.ha.${path}`) : "");
const value = c.state === "windy-variant" ? "windy" : c.state.replace(/-/g, "_");
if (domain.value === "binary_sensor" && ["on", "off"].includes(value)) return key(`binary.${c.a?.device_class}_${value}`) || key(value);
if (HA_WORDS[domain.value]) return key(`${HA_WORDS[domain.value]}.${value}`) || (["on", "off"].includes(value) ? key(value) : "");
return ["on", "off"].includes(value) ? key(value) : "";
}
// A scene, script or button has no state worth a word: its state is the moment it last ran.
const NO_STATUS = ["scene", "script", "button", "input_button"];
// The text under the name: Home Assistant's word where it has one, the value with its unit for a sensor.
const status = computed(() => {
const c = current.value;
if (!c || NO_STATUS.includes(domain.value)) return note.value;
if (gone.value) return screenText(c.state === "unknown" ? "editor.mockup.unknown" : "editor.mockup.unavailable");
if (gone.value) return screenText(c.state === "unknown" ? "editor.mockup.unknown" : "screen.ha.unavailable");
const a = c.a || {};
if (domain.value === "climate") return `${a.current_temperature !== undefined ? `${num(a.current_temperature)}° · ` : ""}${c.word || capital(c.state)}`;
if (domain.value === "weather") return `${c.word || weatherWord(c.state) || capital(c.state)}${a.temperature !== undefined ? ` · ${num(a.temperature)}°` : ""}`;
if (domain.value === "cover" && a.current_position !== undefined && a.current_position > 0 && a.current_position < 100) return `${c.word || capital(c.state)} · ${a.current_position} %`;
if (domain.value === "media_player" && a.media_title) return `${c.word || capital(c.state)} · ${a.media_title}`;
const word = c.word || haWord(c) || capital(c.state);
if (domain.value === "climate") return `${a.current_temperature !== undefined ? `${num(a.current_temperature)}° · ` : ""}${word}`;
if (domain.value === "weather") return `${word}${a.temperature !== undefined ? ` · ${num(a.temperature)}°` : ""}`;
if (domain.value === "cover" && a.current_position !== undefined && a.current_position > 0 && a.current_position < 100) return `${word} · ${a.current_position} %`;
if (domain.value === "media_player" && a.media_title) return `${word} · ${a.media_title}`;
if (domain.value === "sensor") return `${num(c.state)}${unit.value ? ` ${unit.value}` : ""}`;
if (domain.value === "timer") return c.word || capital(c.state);
return c.word || capital(c.state);
return word;
});
const bigValue = computed(() => (current.value && !gone.value ? num(current.value.state) : "—"));
// The small slider's fill, from what the entity reports; off is empty, like the screen's grey fill.
@@ -74,8 +82,9 @@ const fill = computed(() => {
}
return 0;
});
// The key on a scene, script or button, as the screen labels it.
const runText = computed(() => screenText(`editor.mockup.run.${["scene", "script"].includes(domain.value) ? domain.value : "press"}`));
// The key on a scene, script or button, and the page a navigation tile opens, as the screen labels them.
const runText = computed(() => screenText(`screen.ha.button.${({ scene: "activate", script: "run" } as Record<string, string>)[domain.value] || "press"}`));
const pageLink = computed(() => `${screenText("screen.tile.page", { n: goesTo.value })} ›`);
const sliderStyle = computed(() => ({ background: `linear-gradient(to right, ${fill.value ? "#ffbf38" : "#c9ccd1"} ${fill.value}%, ${fill.value ? "#fff1d3" : "#e6e8ec"} ${fill.value}%)` }));
const volumeStyle = computed(() => ({ background: `linear-gradient(to right, #2196f3 ${fill.value}%, #d3e8fb ${fill.value}%)` }));
const setpoint = computed(() => {
@@ -119,7 +128,7 @@ async function onKey(e: KeyboardEvent) {
<span class="ic mdi" :class="{ lit: isOn }">{{ glyph(tileIconCp(tile)) }}</span>
<span class="lead">
<span class="nm">{{ name }}</span>
<span v-if="goesTo" class="goto">{{ screenText("editor.mockup.page_link", { page: goesTo }) }}</span>
<span v-if="goesTo" class="goto">{{ pageLink }}</span>
<span v-else-if="display === 'watch'" class="big">{{ bigValue }}<small v-if="unit && !gone">{{ unit }}</small></span>
<span v-else-if="status" class="st" :class="{ off: gone }">{{ status }}</span>
</span>
@@ -139,7 +148,7 @@ async function onKey(e: KeyboardEvent) {
<span class="ic mdi" :class="{ lit: isOn }">{{ glyph(tileIconCp(tile)) }}</span>
<span class="tx">
<span class="nm">{{ name }}</span>
<span v-if="goesTo" class="goto">{{ screenText("editor.mockup.page_link", { page: goesTo }) }}</span>
<span v-if="goesTo" class="goto">{{ pageLink }}</span>
<span v-else-if="display === 'watch'" class="big">{{ bigValue }}<small v-if="unit && !gone">{{ unit }}</small></span>
<span v-else-if="status" class="st" :class="{ off: gone }">{{ status }}</span>
</span>
@@ -167,7 +176,7 @@ async function onKey(e: KeyboardEvent) {
<span class="ic mdi" :class="{ lit: isOn }">{{ glyph(tileIconCp(tile)) }}</span>
<span class="tx">
<span class="nm">{{ name }}</span>
<span v-if="goesTo" class="goto">{{ screenText("editor.mockup.page_link", { page: goesTo }) }}</span>
<span v-if="goesTo" class="goto">{{ pageLink }}</span>
<span v-else-if="display !== 'watch' && status" class="st" :class="{ off: gone }">{{ status }}</span>
</span>
</span>
+40 -52
View File
@@ -1,12 +1,16 @@
// The editor's texts (app 0.2.90). Every language is one file in screen_manager/translations, the same file the firmware
// and the add-on read (docs/TRANSLATING.md); the editor uses its `editor` section and `_meta`, and the build leaves the
// other sections out (vite.config.ts). English is the source and comes with the page. Another language loads the first
// time the editor or a mockup needs it, and a text it doesn't have yet shows in English.
// and the add-on read (docs/TRANSLATING.md). The page takes its `editor` section, and for the mockup the words the screens
// themselves show (`screen.ha`, `time`, `date`, `number`, `tile.page`); the build leaves the rest out and compiles every
// text (vite.config.ts). English is the source and comes with the page. Another language loads the first time the
// editor or a mockup needs it, and a text it doesn't have yet shows in English.
import metas from "virtual:esp-screens-languages";
import { createI18n } from "vue-i18n";
import en from "../../screen_manager/translations/en.json";
import english from "../../screen_manager/translations/en.json";
export type LanguageMeta = { name: string; english: string; script?: string; plural: string; checked: boolean };
type Texts = { _meta: LanguageMeta; editor: Record<string, unknown> };
export type LanguageMeta = { name: string; english: string; script?: string; plural: string; clock?: string; checked: boolean };
// A language's texts as the build hands them over: compiled, and only the page's part (not the file's own shape).
type Texts = Record<string, unknown>;
const en = english as unknown as Texts;
// Which form of a plural text ("1 hour ago | {n} hours ago") fits n, by the file's `_meta.plural`: the same rules as the
// screens (components/smart_display/screen_text_gen.py).
@@ -19,26 +23,27 @@ export const PLURAL_RULES: Record<string, (n: number) => number> = {
none: () => 0,
};
// Every other language by its code, as a chunk of its own.
// Every other language by its code, as a chunk of its own; every language's _meta comes with the page.
const files = import.meta.glob<Texts>(
["../../screen_manager/translations/*.json", "!../../screen_manager/translations/en.json"], { import: "default" });
const LOADERS: Record<string, () => Promise<Texts>> = Object.fromEntries(
Object.entries(files).map(([path, load]) => [path.slice(path.lastIndexOf("/") + 1, -".json".length), load]));
const META: Record<string, LanguageMeta> = { en: en._meta as LanguageMeta };
const META: Record<string, LanguageMeta> = { ...metas };
const LOADED = new Set(["en"]);
// A text with fewer forms than its language's rule uses its last one, as on the screens.
const RULES: Record<string, (choice: number, forms: number) => number> = {};
const addRule = (code: string) =>
(RULES[code] = (choice, forms) => Math.min((PLURAL_RULES[META[code]?.plural] || PLURAL_RULES.one_other)(choice), forms - 1));
/** Every language the editor has, English first. */
export const languages = () => ["en", ...Object.keys(LOADERS).sort(), ...Object.keys(META).filter((code) => code !== "en" && !LOADERS[code])];
export const languages = () => ["en", ...Object.keys(META).filter((code) => code !== "en").sort()];
languages().forEach(addRule);
export const i18n = createI18n({
legacy: false,
locale: "en",
fallbackLocale: "en",
messages: { en: { editor: en.editor } } as Record<string, any>,
messages: { en } as Record<string, any>,
pluralRules: RULES,
// A language without a text falls back to English quietly. Only a key English lacks is a mistake, told while developing.
missingWarn: false,
@@ -48,8 +53,10 @@ export const i18n = createI18n({
},
});
export const t = i18n.global.t;
/** Whether a text exists, in English when no language is named. */
export const te = (key: string, locale = "en"): boolean => (i18n.global.te as (key: string, locale?: string) => boolean)(key, locale);
export const editorLanguage = () => i18n.global.locale.value;
/** The `_meta` of a language once it is loaded. */
/** A language's `_meta`: its own name, its name in English, its plural rule, whether it is checked. */
export const languageMeta = (code: string): LanguageMeta | undefined => META[code];
// Home Assistant opens the page in its ingress iframe, on its own origin, and sets its <html lang> to the language of
@@ -73,45 +80,17 @@ export function matchLanguage(wanted: string | null | undefined, available = lan
}
export const pickLanguage = (wanted: string | null | undefined, available = languages()) => matchLanguage(wanted, available) || "en";
// A text a translator left empty shows in English, as on the screens. So does one vue-i18n can't read, such as a stray
// @ or {: it would stop the page from drawing.
function filled(node: unknown): unknown {
if (typeof node === "string") return node.trim() ? node : undefined;
if (Array.isArray(node)) return node.every((item) => typeof item === "string" && item.trim()) ? node : undefined;
if (!node || typeof node !== "object") return undefined;
const kept = Object.entries(node).map(([key, value]) => [key, filled(value)]).filter(([, value]) => value !== undefined);
return kept.length ? Object.fromEntries(kept) : undefined;
}
function leaves(node: unknown, path: string): string[] {
if (typeof node === "string") return [path];
if (!node || typeof node !== "object") return [];
return Object.entries(node).flatMap(([key, value]) => leaves(value, `${path}.${key}`));
}
/** A language's texts, as its file holds them. */
export function addLanguage(code: string, texts: Texts) {
META[code] = texts._meta;
/** A language's texts, as the build hands them over; `meta` for a language without a file (the tests). */
export function addLanguage(code: string, texts: Texts, meta: LanguageMeta | undefined = META[code]) {
if (meta) META[code] = meta;
if (!RULES[code]) addRule(code);
const editor = (filled(texts.editor) || {}) as Record<string, any>;
i18n.global.setLocaleMessage(code, { editor });
const unreadable = leaves(editor, "editor").filter((key) => {
try {
t(key, {}, { locale: code });
return false;
} catch {
return true;
}
});
if (!unreadable.length) return;
for (const key of unreadable) {
const path = key.split(".").slice(1), last = path.pop()!;
delete path.reduce((node, part) => node[part], editor)[last];
}
i18n.global.setLocaleMessage(code, { editor });
i18n.global.setLocaleMessage(code, texts as any);
LOADED.add(code);
}
const loading: Record<string, Promise<boolean>> = {};
/** Loads a language once; true when the editor has it. */
export function loadLanguage(code: string): Promise<boolean> {
if (META[code]) return Promise.resolve(true);
if (LOADED.has(code)) return Promise.resolve(true);
const load = LOADERS[code];
if (!load) return Promise.resolve(false);
return (loading[code] ??= load().then(
@@ -127,16 +106,25 @@ export async function setEditorLanguage(code = pickLanguage(requestedLanguage())
return chosen;
}
// How the screens write a number (app 0.2.90), as the firmware does (history_view::number): "1,234.5", "1.234,5" or
// "1 234,5". Anything that isn't a plain number stays as it is.
// How the screens write a number (app 0.2.90), as the firmware does (screen_text::localize): the decimal mark, the mark
// between thousands, and from how many digits a whole number gets them. The choice under Language & region is one of
// three styles; "auto" writes numbers as the screens' language does (its screen.number).
export type NumberStyle = "point" | "comma" | "space";
const MARKS: Record<NumberStyle, [string, string]> = { point: [".", ","], comma: [",", "."], space: [",", " "] };
export function numberText(value: string | number, style: NumberStyle) {
export type NumberMarks = { decimal: string; group: string; from: number };
export const STYLE_MARKS: Record<NumberStyle, NumberMarks> = {
point: { decimal: ".", group: ",", from: 4 }, comma: { decimal: ",", group: ".", from: 4 }, space: { decimal: ",", group: " ", from: 4 },
};
/** The marks of a language's own numbers: "1.234,5" in Dutch, 1234 but 12.345 in Italian (CLDR's minimum grouping). */
export function languageMarks(locale: string): NumberMarks {
const say = (key: string) => t(`screen.number.${key}`, {}, { locale });
return { decimal: say("decimal"), group: say("group"), from: Number(say("group_min")) >= 2 ? 5 : 4 };
}
/** A state as Home Assistant sends a number ("1234.5", "-3"), written with these marks; any other text as it is. */
export function numberText(value: string | number, marks: NumberMarks) {
const text = String(value), plain = /^(-?)(\d+)(?:\.(\d+))?$/.exec(text);
if (!plain) return text;
const [decimal, group] = MARKS[style] || MARKS.point;
const whole = plain[2].replace(/\B(?=(\d{3})+$)/g, group);
return `${plain[1]}${whole}${plain[3] !== undefined ? decimal + plain[3] : ""}`;
const whole = plain[2].length >= marks.from ? plain[2].replace(/\B(?=(\d{3})+$)/g, marks.group) : plain[2];
return `${plain[1]}${whole}${plain[3] !== undefined ? marks.decimal + plain[3] : ""}`;
}
// The time and number format of the user's own Home Assistant profile, where it names one: a 12- or 24-hour clock, and
+6 -6
View File
@@ -6,8 +6,8 @@
import { t } from "../i18n";
import type { HeaderItem } from "../types";
// The words are the screens' (app 0.2.90): editor.mockup.date and editor.mockup.time, in the language the screens have,
// which `locale` names; English where none is given.
// The words are the screens' own (app 0.2.90): screen.date and screen.time of the translations, in the language the
// screens have, which `locale` names; English where none is given.
export const BUILTIN_ICONS: Record<string, string> = { clock: "clock-outline", analog: "clock-outline", date: "calendar" };
export const itemKey = (item: HeaderItem) => JSON.stringify(item);
export const glyph = (cp: string) => String.fromCodePoint(parseInt(cp, 16));
@@ -18,15 +18,15 @@ export function clockText(clock24: boolean, now = new Date()) {
if (!clock24) hours = hours % 12 || 12;
return `${String(hours).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
}
export const dateText = (now = new Date(), locale = "en") => t("editor.mockup.date.top_bar", {
weekday: t(`editor.mockup.date.weekdays_min.${now.getDay()}`, {}, { locale }),
export const dateText = (now = new Date(), locale = "en") => t("screen.date.top_bar", {
weekday: t(`screen.date.weekdays_min.${now.getDay()}`, {}, { locale }),
day: now.getDate(),
month: t(`editor.mockup.date.months_short.${now.getMonth()}`, {}, { locale }),
month: t(`screen.date.months_short.${now.getMonth()}`, {}, { locale }),
}, { locale });
// Same thresholds as header_bar::ago_text() in the firmware, and the same words.
export function agoText(then: number, now = Math.floor(Date.now() / 1000), locale = "en") {
const seconds = now - then, span = Math.abs(seconds), per = (unit: number) => Math.floor(span / unit);
const say = (key: string, n?: number) => (n === undefined ? t(`editor.mockup.time.${key}`, {}, { locale }) : t(`editor.mockup.time.${key}`, n, { locale }));
const say = (key: string, n?: number) => (n === undefined ? t(`screen.time.${key}`, {}, { locale }) : t(`screen.time.${key}`, n, { locale }));
if (seconds < 0) {
if (span < 3600) return say("in_minutes", Math.max(1, per(60)));
if (span < 86400) return say("in_hours", per(3600));
+10 -4
View File
@@ -2,7 +2,7 @@
// former app.js state and its calls, with the DOM work moved into the components.
import { computed, reactive, toRaw, watch } from "vue";
import { api, getJson, send, setCsrf } from "./api";
import { andList, languageMeta, loadLanguage, type NumberStyle, pickLanguage, t } from "./i18n";
import { andList, editorLanguage, languageMarks, languageMeta, loadLanguage, type NumberMarks, pickLanguage, STYLE_MARKS, t } from "./i18n";
import {
arrange, cellsOf, entriesOf, firstFree, fits, isFull, isWide, MAX_PAGES, nearestFree, newTile, normalize, occupied, pageCount, pageOf,
pageTarget, rowStart, sizeOf, SLOTS_PER_PAGE, strandedPages, supportsFirmware as supportsVersion, tileLimit as limitFor,
@@ -832,9 +832,14 @@ export const languageName = (code: string | null | undefined) =>
export const needsUpdate = (screen: Screen) => Boolean(screen.update?.available || screen.update?.language);
export const newLanguageText = () => t("editor.update.new_language", { name: languageName(state.inventory.language?.effective) });
// Time and number format, for every screen at once under Settings → Language & region: a 24-hour clock and "1,234.5"
// until the add-on says otherwise. The mockup's clocks and numbers follow them.
// until the add-on says otherwise. The mockup's clocks and numbers follow them; "auto" numbers are the screens'
// language's own (screen.number), as the firmware writes them.
export const clock24 = computed(() => state.inventory.language?.clock_effective !== "12");
export const numberStyle = computed<NumberStyle>(() => state.inventory.language?.numbers_effective || "point");
export const screenLanguageMarks = computed<NumberMarks>(() => languageMarks(screenLanguage.value));
export const numberMarks = computed<NumberMarks>(() => {
const choice = state.inventory.language ? state.inventory.language.numbers || "auto" : "point";
return choice === "auto" ? screenLanguageMarks.value : STYLE_MARKS[choice];
});
/** Saves any of the screen language, the time format and the number format. */
export async function saveLanguage(changes: { setting?: string; clock?: string; numbers?: string }) {
try {
@@ -875,7 +880,8 @@ function applyLive(data: Partial<Inventory>) {
let pollTimer = 0, lastFull = Date.now(), live = false, stream: EventSource | null = null;
function listen() {
if (stream || typeof EventSource === "undefined") return;
stream = new EventSource("api/events");
// An EventSource sends no headers of its own: the editor's language goes along in the address (app 0.2.90).
stream = new EventSource(`api/events?language=${encodeURIComponent(editorLanguage())}`);
stream.onopen = () => { live = true; poll(); };
stream.onmessage = (e) => { if (!document.hidden) applyLive(JSON.parse(e.data)); };
stream.onerror = () => { live = false; poll(); };