Speak the editor in each person's Home Assistant language: every text in the editor section of en.json, the mockup in the screens' language, and one Language & region card for all screens

- vue-i18n 11 with the Composition API: English comes with the page, any other language loads the first time it is
  needed from screen_manager/translations, and a text a language lacks shows in English. The build keeps only _meta
  and editor of each file (vite.config.ts), never the screens' or the add-on's texts.
- The editor follows the language of the user's Home Assistant profile (<html lang> of Home Assistant's page), else
  the browser's; plural forms follow the file's _meta.plural, with the screens' rules.
- Every request tells the add-on the editor's language (X-ESP-Screens-Language), so its own words can follow it.
- The mockup draws the screens' words (Unavailable, Page 2 ›, the date, "5 min ago") in the screens' language, with
  their clock and number format.
- Settings → Language & region: the screen language, time format and number format for every screen, with the user's
  own profile offered when it differs. The per-screen clock choice goes; the top bar points there. A screen that
  still needs the new language says so in its update badge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-19 14:40:29 +02:00
parent c2cf3391d6
commit 1dc2eab0cf
58 changed files with 2573 additions and 672 deletions
+14 -1
View File
@@ -2,8 +2,10 @@
The page is a Vue app in web/ (app 0.2.73); `npm run build` writes it to screen_manager/app/static. Tests look at
the source, not the build: every .ts and .vue file under web/src as one text (SCRIPT, which is also the markup:
a .vue file carries its template), and the stylesheets as another (CSS).
a .vue file carries its template), and the stylesheets as another (CSS). Its English words are in the `editor` section
of screen_manager/translations/en.json since app 0.2.90: text() reads one of them.
"""
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
@@ -23,3 +25,14 @@ def source(name):
def component(name):
return source(f'components/{name}.vue')
TEXTS = json.loads((ROOT / 'screen_manager/translations/en.json').read_text(encoding='utf-8'))['editor']
def text(key):
"""One English text of the editor by its key under `editor`, e.g. 'empty.choose.title'."""
node = TEXTS
for part in key.split('.'):
node = node[int(part)] if isinstance(node, list) else node[part]
return node
+6 -3
View File
@@ -185,10 +185,13 @@ class Page(unittest.TestCase):
def test_cheatsheet_explains_the_event(self):
import editor_sources
cheatsheet = editor_sources.component('AlertsView')
for marker in ('id="alerts-all"', '["alerts-all", "All screens"]', 'id="alerts-all-example"', 'id="alerts-all-copy"',
'esp_screens_show_alert', 'esp_screens_dismiss_alert', 'Settings → Claude'):
for marker in ('id="alerts-all"', '"alerts-all",', 'id="alerts-all-example"', 'id="alerts-all-copy"',
'esp_screens_show_alert', 'esp_screens_dismiss_alert', 'editor.alerts.tips.claude'):
self.assertIn(marker, cheatsheet, marker)
for marker in ('const allYaml = computed', 'broadcast?.show', 'copyText(allYaml, undefined, \'YAML\')'):
# The words are in en.json (app 0.2.90).
self.assertEqual(editor_sources.text('alerts.nav.all'), 'All screens')
self.assertIn('Settings → Claude', editor_sources.text('alerts.tips.claude'))
for marker in ('const allYaml = computed', 'broadcast?.show', 'copyText(allYaml, undefined, \'yaml\')'):
self.assertIn(marker, cheatsheet, marker)
+4 -2
View File
@@ -225,10 +225,12 @@ class Editor(unittest.TestCase):
for marker in ('export const SIZES: Size[] = ["single", "wide", "full"];', 'export const pageTarget', 'versionAtLeast(firmware, "0.2.62") ? MAX_TILES'):
self.assertIn(marker, layout, marker)
drawer = editor_sources.component('TileInspector')
for marker in ('["full", "Full page"]', 'Goes to page', 'retargetPageTile(tile, Number(v))'):
for marker in ('["single", "wide", "full"]', 't("editor.tile.goes_to.label")', 'retargetPageTile(tile, Number(v))'):
self.assertIn(marker, drawer, marker)
self.assertEqual((editor_sources.text('tile.size.full'), editor_sources.text('tile.goes_to.label')), ('Full page', 'Goes to page'))
self.assertIn(':class="{ wide, full, bare, placeholder: placeholder || !live, chosen }"', editor_sources.component('TileCard'))
self.assertIn('["screen", "Screen"]', editor_sources.component('Library'))
self.assertIn('"timer", "screen",', editor_sources.component('Library'))
self.assertEqual(editor_sources.text('library.filters.screen'), 'Screen')
if __name__ == '__main__':
+2 -1
View File
@@ -37,7 +37,8 @@ class Startup(unittest.TestCase):
empty = editor_sources.component('EmptyState')
choose = re.search(r'<section v-if="state.inventory.screens.length" id="choose" class="empty">(.*?)</section>', empty, re.S)
self.assertTrue(choose, 'the card asks for a screen while there are screens')
self.assertIn('<h2>Choose a screen</h2>', choose[1])
self.assertIn('<h2>{{ t("editor.empty.choose.title") }}</h2>', choose[1])
self.assertEqual(editor_sources.text('empty.choose.title'), 'Choose a screen')
self.assertNotIn('<button', choose[1], 'the list beside it is the choice')
self.assertIn('id="empty" class="empty"', empty, 'the install card when there are no screens yet')
self.assertIn('id="start"', empty)
+4 -2
View File
@@ -22,6 +22,8 @@ FIRMWARE = (ROOT / 'components/smart_display/header_bar.h').read_text()
FIRMWARE += json.dumps(json.loads((ROOT / 'screen_manager/translations/en.json').read_text(encoding='utf-8'))['screen']['time'])
TILES = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
EDITOR = (ROOT / 'web/src/model/topbar.ts').read_text()
# The mockup's words, in the editor's part of the translations since app 0.2.90.
EDITOR_WORDS = json.loads((ROOT / 'screen_manager/translations/en.json').read_text(encoding='utf-8'))['editor']['mockup']
PROFILES = ('guition-4848s040.yaml', 'home-like-2432s028.yaml')
def state(value, last_changed='2026-09-13T14:00:00+00:00', **attributes):
@@ -207,7 +209,7 @@ class ParityTests(unittest.TestCase):
for phrase in ('Just now', ' min ago', ' hour ago', ' hours ago', 'Yesterday', ' days ago', '1 week ago', ' weeks ago',
'1 month ago', ' months ago', ' year ago', ' years ago', 'In ', 'Tomorrow'):
self.assertIn(phrase, FIRMWARE)
self.assertIn(phrase, EDITOR)
self.assertIn(phrase, json.dumps(EDITOR_WORDS['time']))
for threshold in ('3600', '86400', '172800', '604800', '2592000', '31536000'):
self.assertIn(threshold, FIRMWARE)
self.assertIn(threshold, EDITOR)
@@ -215,7 +217,7 @@ class ParityTests(unittest.TestCase):
self.assertIn('Math.max(2, Math.floor((cap * 4 + 5) / 10)), item: Math.max(6, Math.floor((cap * 125 + 50) / 100)), name: Math.max(8, Math.floor((cap * 16 + 5) / 10))', EDITOR)
self.assertIn('width * 35 / 100', FIRMWARE)
self.assertIn('(metrics.width * 35) / 100', EDITOR)
self.assertEqual(header_bar.MONTHS, tuple(re.search(r'const MONTHS = \[(.*?)\];', EDITOR).group(1).replace('"', '').replace(' ', '').split(',')))
self.assertEqual(header_bar.MONTHS, tuple(EDITOR_WORDS['date']['months_short']))
def test_profiles_draw_the_bar_from_the_runtime(self):
for name in PROFILES:
+9 -6
View File
@@ -567,16 +567,15 @@ class Editor(unittest.TestCase):
self.assertIn(':class="{ unknown: values[row.key] === null || values[row.key] === undefined }"', self.tab)
def test_the_rows_are_the_settings_of_the_screen_page(self):
keys = re.findall(r'\{ key: "(\w+)", label: "[^"]+", kind: "(\w+)"', self.script)
self.assertEqual([key for key, _ in keys], [key for key in SETTING_RULES if key != 'show_clock'][:0] or [key for key, _ in keys])
import editor_sources
keys = re.findall(r'\{ key: "(\w+)", kind: "(\w+)"', self.script)
# 12 or 24 hours is Settings -> Language & region's, for every screen at once (app 0.2.90).
self.assertEqual({key for key, _ in keys}, set(SETTING_RULES) - {'show_clock', 'clock_24h'})
# Labels and steps as on the screen: the same rows, the same -/+ steps, the same duration ladder.
# The page's labels are keys into the translations since app 0.2.90; English is the reference.
# The labels are keys into the translations since app 0.2.90; English is the reference.
english = json.loads((ROOT / 'screen_manager/translations/en.json').read_text(encoding='utf-8'))['screen']['settings']
page_labels = {english[key] for key in re.findall(r'(?:number|toggle|duration|moment|choice)\(screen_text::txt::settings_(\w+)', SCREEN_PAGE)}
groups = self.script[self.script.index('const SETTING_GROUPS = ['):self.script.index('export type SettingRow')]
script_labels = set(re.findall(r'label: "([^"]+)"', groups))
script_labels = {editor_sources.text(f'screen_settings.rows.{key}') for key, _ in keys}
self.assertEqual(page_labels, script_labels)
for key, step in (('brightness', 5), ('standby_brightness', 5), ('night_brightness', 5)):
self.assertRegex(self.script, rf'key: "{key}", [^}}]*step: {step}')
@@ -589,7 +588,11 @@ class Editor(unittest.TestCase):
self.assertIn('api(`screens/${encodeURIComponent(screen)}/settings`', self.script)
self.assertIn('const { settings: _settings, ...tiles } = state.layout;', self.script)
self.assertIn('await send(`screens/${encodeURIComponent(state.selected)}`, "PUT", tiles);', self.script)
self.assertIn("setSetting('clock_24h', v === '24', 150)", self.script, 'the top bar clock format is the same setting')
# The top bar's clock follows the one clock of Settings → Language & region (app 0.2.90).
import editor_sources
self.assertIn('state.inventory.language?.clock_effective !== "12"', self.script)
self.assertNotIn("setSetting('clock_24h'", self.script)
self.assertIn('<a href="#settings">', editor_sources.component('TopbarInspector'))
class Firmware(unittest.TestCase):
+3 -1
View File
@@ -226,7 +226,9 @@ class Editor(unittest.TestCase):
self.PICKER = editor_sources.component('ActionPicker')
def test_perform_action_is_a_tap_choice_with_home_assistants_list(self):
self.assertIn('list.push(["action", "Perform action"]);', self.SCRIPT)
import editor_sources
self.assertIn('keys.push("action");', self.SCRIPT)
self.assertEqual(editor_sources.text('tile.tap.action'), 'Perform action')
self.assertIn('getJson(`entity-actions?entity=${encodeURIComponent(entity)}`)', self.SCRIPT)
self.assertIn('<ActionPicker v-if="domain !== \'screen\' && !goesTo && tap === \'action\'" :tile="tile" />', self.SCRIPT)
# Fields follow Home Assistant's selectors; an empty field is left out of the data.
+5 -2
View File
@@ -6,6 +6,7 @@ the firmware asks theme.h for roles, so a new look is a refill of a few styles p
way: no colour written anywhere else, every paint defined and filled, and the look applied at boot and after
every settings change. tests/test_theme.cpp checks the values themselves.
"""
import json
import re
import sys
import unittest
@@ -154,8 +155,10 @@ class Setting(unittest.TestCase):
self.assertIn('toggle(screen_text::txt::settings_dark_mode, []() -> int32_t { return dark_mode; },', page)
self.assertIn('else if (key == "dark_mode") reported = dark_mode = flag(value);', page)
editor = (ROOT / 'web/src/store.ts').read_text()
self.assertIn('{ key: "dark_mode", label: "Dark mode", kind: "toggle" },', editor)
brightness = editor.split('{ title: "Brightness"', 1)[1].split('] },', 1)[0]
self.assertIn('{ key: "dark_mode", kind: "toggle" },', editor)
texts = json.loads((ROOT / 'screen_manager/translations/en.json').read_text(encoding='utf-8'))['editor']
self.assertEqual(texts['screen_settings']['rows']['dark_mode'], 'Dark mode')
brightness = editor.split('{ group: "brightness"', 1)[1].split('] },', 1)[0]
self.assertLess(brightness.index('"brightness"'), brightness.index('"dark_mode"'), 'right under Brightness, as on the screen')
self.assertLess(brightness.index('"dark_mode"'), brightness.index('"standby_enabled"'))