The editor's mockup writes like the screens, and speaks its own language around them

- Numbers in the mockup follow what the app sends the screens (style, grouping from five digits, the space before
  %), a state without a unit stays as it is, and built-in cards carry the screens' names (Instellingen, not the
  editor's Einstellungen).
- Automatic in Language & region is labelled with what it means now (Home Assistant's language decides).
- A screen's status (Synced, Loading tiles, ...) shows in the editor's language; the screens keep reporting it in
  English, which the app reads.
- The doorbell example uses the alert examples of the editor's language; control labels keep their capitals.
- Editor bundle rebuilt with every language's texts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-19 16:57:35 +02:00
parent 125346d94e
commit 365ed158b9
37 changed files with 217 additions and 73 deletions
+7 -5
View File
@@ -47,16 +47,18 @@ const exampleYaml = computed(() => {
const lines = (alerts.value?.fields || []).map((f: any) => ` ${f.name}: ${fieldValue(f)}`);
return `action: ${chosenAction.value || "esphome.<device_name>_show_alert"}\ndata:\n${lines.join("\n")}`;
});
// The doorbell example in the editor's language: the fields' own examples, and two lines of its own.
const example = (name: string, fallback: string) => yamlString((alerts.value?.fields || []).find((f: any) => f.name === name)?.example || fallback);
const waitYaml = computed(() => [
`# Doorbell: show the alert and wait until someone presses the button.`,
`# ${t("editor.alerts.wait_yaml.comment")}`,
`actions:`,
` - action: ${chosenAction.value || "esphome.<device_name>_show_alert"}`,
` data:`,
` title: "Someone is at the door"`,
` subtitle: "Door 3, back"`,
` title: ${example("title", "Someone is at the door")}`,
` subtitle: ${example("subtitle", "Door 3, back")}`,
` icon: doorbell`,
` color: orange`,
` button_text: "Coming"`,
` button_text: ${example("button_text", "Coming")}`,
` timeout: 0`,
` flash: true`,
` - wait_for_trigger:`,
@@ -71,7 +73,7 @@ const waitYaml = computed(() => [
` then:`,
` - action: notify.notify`,
` data:`,
` message: "Someone is coming to the door."`,
` message: ${yamlString(t("editor.alerts.wait_yaml.message"))}`,
].join("\n"));
// One event for every screen (app 0.2.45): an action for "Edit in YAML" of the Event action.
const allYaml = computed(() => {
+3 -3
View File
@@ -2,7 +2,7 @@
// Everything around the screens: firmware updates, language and region, alerts, Claude.
import { computed, ref } from "vue";
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";
import { anyUpdating, autoMarks, go, installClaudeSkill, runUpdateAll, saveLanguage, setAutoUpdate, state, updateProgress } from "../store";
const u = computed(() => state.inventory.updates);
const outdated = computed(() => state.inventory.screens.filter((s) => s.update?.available).length);
@@ -125,7 +125,7 @@ async function useProfile() {
<div class="field">
<label class="f-label" for="time-format">{{ t("editor.settings.language.time") }}</label>
<select id="time-format" :value="lang.clock || 'auto'" :disabled="saving" @change="choose('clock', $event)">
<option value="auto">{{ t("editor.settings.language.time_auto", { clock: clockName(lang.clock_effective) }) }}</option>
<option value="auto">{{ t("editor.settings.language.time_auto", { clock: clockName(lang.clock_auto || lang.clock_effective) }) }}</option>
<option value="24">{{ clockName("24") }}</option>
<option value="12">{{ clockName("12") }}</option>
</select>
@@ -133,7 +133,7 @@ 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(screenLanguageMarks) }) }}</option>
<option value="auto">{{ t("editor.settings.language.numbers_auto", { example: example(autoMarks) }) }}</option>
<option v-for="style in STYLES" :key="style" :value="style">{{ example(STYLE_MARKS[style]) }}</option>
</select>
</div>
+11 -6
View File
@@ -6,11 +6,12 @@ import { vDrag } from "../drag";
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, numberMarks, openTile, placeTile, removeTile, screenText, state, tileIconCp } from "../store";
import { clock24, entityName, isSelected, liveOf, numberMarks, openTile, placeTile, removeTile, screenBuiltinName, screenText, state, tileIconCp, unitSuffix } from "../store";
import type { Tile } from "../types";
const props = defineProps<{ tile: Tile; slot: number; placeholder?: boolean }>();
const name = computed(() => props.tile.name || entityName(props.tile.entity));
// A built-in card is named as the screens name it, in their language (app 0.2.90).
const name = computed(() => props.tile.name || (domain.value === "screen" && screenBuiltinName(props.tile.entity)) || entityName(props.tile.entity));
const full = computed(() => isFull(props.tile));
const wide = computed(() => isWide(props.tile) && !full.value);
const goesTo = computed(() => pageTarget(props.tile.entity));
@@ -36,8 +37,11 @@ const on = computed(() => Boolean(current.value) && !gone.value && current.value
const isOn = computed(() => ["light", "switch", "input_boolean", "fan"].includes(domain.value) && current.value?.state === "on");
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).
// Numbers as the screens write them, "1,234.5" or "1.234,5" (app 0.2.90): a state only with a unit, or of an entity
// that is a number itself, as the firmware does (value_text); an id-like "1234" without a unit stays as it is.
const num = (value: unknown) => numberText(value as string | number, numberMarks.value);
const NUMERIC = ["number", "input_number", "counter"];
const value = (state: string) => (unit.value || NUMERIC.includes(domain.value) ? `${num(state)}${unitSuffix(unit.value)}` : state);
// 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.
@@ -60,12 +64,13 @@ const status = computed(() => {
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 === "cover" && a.current_position !== undefined && a.current_position > 0 && a.current_position < 100) return `${word} · ${a.current_position}${unitSuffix("%")}`;
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 === "sensor" || NUMERIC.includes(domain.value)) return value(c.state);
return word;
});
const bigValue = computed(() => (current.value && !gone.value ? num(current.value.state) : "—"));
// The big value of the watch display; its unit sits beside it in small letters.
const bigValue = computed(() => (current.value && !gone.value ? (unit.value || NUMERIC.includes(domain.value) ? num(current.value.state) : current.value.state) : "—"));
// The small slider's fill, from what the entity reports; off is empty, like the screen's grey fill.
const fill = computed(() => {
const c = current.value;
+2 -1
View File
@@ -156,7 +156,8 @@ export function effectiveControls(tile: Tile, inventory: Inventory): string | nu
export function controlsLabel(tile: Tile, inventory: Inventory) {
const key = effectiveControls(tile, inventory);
if (!key) return t("editor.inspect.control_none");
return inventory.controls?.[tile.entity.split(".")[0]]?.choices.find((c) => c.key === key)?.label.toLocaleLowerCase() || key;
// As the choice itself is labelled, like the other values in the summary: German writes its nouns with a capital.
return inventory.controls?.[tile.entity.split(".")[0]]?.choices.find((c) => c.key === key)?.label || key;
}
export const parseVersion = (v: string | undefined | null) => (/^(\d+)\.(\d+)\.(\d+)$/.exec(v || "") || []).slice(1).map(Number);
+19 -6
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, editorLanguage, languageMarks, languageMeta, loadLanguage, type NumberMarks, pickLanguage, STYLE_MARKS, t } from "./i18n";
import { andList, editorLanguage, 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,14 +832,27 @@ 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; "auto" numbers are the screens'
// language's own (screen.number), as the firmware writes them.
// until the add-on says otherwise. The mockup's clocks and numbers follow what the add-on sends the screens: the style,
// from how many digits a number is grouped, and the space before "%" (Home Assistant's language decides "auto").
export const clock24 = computed(() => state.inventory.language?.clock_effective !== "12");
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];
const language = state.inventory.language;
const marks = STYLE_MARKS[language?.numbers_effective || "point"] || STYLE_MARKS.point;
return { ...marks, from: (language?.group_min || 1) >= 2 ? 5 : 4 };
});
/** How Automatic writes numbers: the marks of the language that decides, for the label of that choice. */
export const autoMarks = computed<NumberMarks>(() => {
const language = state.inventory.language;
return { ...(STYLE_MARKS[language?.numbers_auto || "point"] || STYLE_MARKS.point), from: (language?.group_min_auto || 1) >= 2 ? 5 : 4 };
});
/** What follows a number for its unit, as Home Assistant spaces it: "°", "%" or " %" by the language, " kWh". */
export function unitSuffix(unit: string | undefined | null) {
if (!unit || unit === "°") return unit || "";
if (unit === "%") return state.inventory.language?.percent_space ? " %" : "%";
return ` ${unit}`;
}
/** A built-in card's name as the screens show it (Settings, Clock, Go to page 2), in their language. */
export const screenBuiltinName = (id: string) => state.inventory.builtin?.find((e) => e.id === id)?.screen_name;
/** 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 {
+5 -1
View File
@@ -45,9 +45,13 @@ export type Languages = {
setting: string; effective: string; ha: string | null; languages: LanguageInfo[];
clock?: "auto" | "24" | "12"; clock_effective?: "24" | "12";
numbers?: "auto" | "point" | "comma" | "space"; numbers_effective?: "point" | "comma" | "space";
/** From how many digits a whole number gets separators: 2 is 1234 but 12.345 (CLDR); a space before "%". */
group_min?: number; percent_space?: boolean;
/** What Automatic means now: the clock and numbers of Home Assistant's language (or the chosen one). */
clock_auto?: "24" | "12"; numbers_auto?: "point" | "comma" | "space"; group_min_auto?: number;
};
export type ChangelogSection = { app: string; firmware: string; lines: string[] };
export type Entity = { id: string; name: string; area?: string; device?: string; icon?: string; state?: string; tile?: boolean };
export type Entity = { id: string; name: string; area?: string; device?: string; icon?: string; state?: string; tile?: boolean; screen_name?: string };
export type IconInfo = { name: string; cp: string; label: string };
export type Inventory = {
csrf?: string;
+2 -1
View File
@@ -181,7 +181,8 @@ describe("the mockup speaks the screens' language", () => {
}, meta("one_other"));
state.inventory = {
screens: [], entities: [{ id: "light.b", name: "Lamp B", state: "unavailable" }],
language: { setting: "td", effective: "td", ha: "en", languages: [], numbers: "auto" },
// The add-on says how the screens write numbers: Automatic, worked out for their language (1.234,5).
language: { setting: "td", effective: "td", ha: "en", languages: [], numbers: "auto", numbers_effective: "comma", group_min: 2 },
controls: { script: { default: "run", choices: [{ key: "run", label: "Run" }, { key: "none", label: "None" }] } },
} as any;
state.layout = { title: "Living room", tiles: [] };
+1
View File
@@ -13,6 +13,7 @@ const GUIDE = "https://github.com/MaxGramser/homeassistant_espscreen/blob/main/d
function language(extra: Partial<Languages> = {}): Languages {
return {
setting: "auto", effective: "nl", ha: "nl", clock: "auto", clock_effective: "24", numbers: "auto", numbers_effective: "comma",
clock_auto: "24", numbers_auto: "comma", group_min: 1, group_min_auto: 1, percent_space: false,
languages: [
{ code: "en", name: "English (US)", english: "English (US)", checked: true },
{ code: "en-GB", name: "English (UK)", english: "English (UK)", checked: true },
+1 -1
View File
@@ -94,7 +94,7 @@ describe("defaults, controls and versions", () => {
expect(effectiveControls(tile("light.a", 0, { size: "wide", inline: "slider" }), inventory)).toBeNull();
expect(effectiveControls(tile("light.a", 0, { size: "wide", display: "watch" }), inventory)).toBeNull();
expect(effectiveControls(tile("light.a", 0), inventory)).toBeNull();
expect(controlsLabel(tile("light.a", 0, { size: "wide" }), inventory)).toBe("on/off switch");
expect(controlsLabel(tile("light.a", 0, { size: "wide" }), inventory)).toBe("On/off switch");
expect(controlsLabel(tile("sensor.t", 0, { size: "wide" }), inventory)).toBe("none");
});
it("compares firmware versions and knows the tile limit", () => {