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>