Show live values on the mockup, blink a screen to identify it, try an alert, copy and share layouts (0.2.74)

The tiles draw what Home Assistant reports (a new /api/states answers for the tiles on the page); the ···
menu gets Identify (the screen's own show_alert with flash), Copy layout from another screen, Export and
Import as JSON; the Alerts page opens with Try it (/api/alerts/test, one screen or all); the library filters
by room and hides what is placed, with lit icons for what is on; ⌘K searches screens, entities and actions;
the Update badge lists what's new from the changelog (copied into the image) and a running update shows a
progress bar with the ESPHome stage and the last log line.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-18 08:21:37 +02:00
parent ef8e94f6d1
commit 6148dfd211
27 changed files with 818 additions and 79 deletions
+2 -2
View File
@@ -71,12 +71,12 @@ also be changed on the screen or by an automation, and your own YAML for one scr
<p align="center">
<img src="docs/images/editor.png" width="98%" alt="ESP Screens in Home Assistant: your screens in the sidebar, the pages of the living room screen side by side with their top bar, and the entity library on the right">
</p>
<p align="center"><sub>ESP Screens, a page in Home Assistant: every screen on the left, its top bar and tiles on the right.</sub></p>
<p align="center"><sub>ESP Screens, a page in Home Assistant: your screens on the left, the pages of the chosen screen in the middle with the values Home Assistant reports right now, the library on the right.</sub></p>
<p align="center">
<img src="docs/images/editor-tiles.png" width="63%" alt="Choosing tiles: the pages of the screen side by side, next to the library with its search and filters">
<img src="docs/images/editor-tile-settings.png" width="33%" alt="Tile settings of the curtains in the drawer: double-width with open, stop and close on the tile, what a tap does, and the pastel background">
</p>
<p align="center"><sub>Search your home, drop a tile on the preview, tap it for its name, width, control and color, and for what a tap does: open its card, switch it, or run any action Home Assistant has for it, such as the curtains to 50 %. Save, and the screen has it.</sub></p>
<p align="center"><sub>Search your home, drop a tile on the preview, tap it for its name, width, control and color, and for what a tap does: open its card, switch it, or run any action Home Assistant has for it, such as the curtains to 50 %. Save, and the screen has it. ⌘K searches screens, entities and actions; Identify blinks a screen so you know which one it is.</sub></p>
<p align="center">
<img src="docs/images/editor-top-bar.png" width="31%" alt="Add to the top bar, in the drawer: the time, an analog clock, the date, suggestions from your own home and any entity">
<img src="docs/images/editor-settings.png" width="65%" alt="Settings in ESP Screens: New screen and Firmware & USB, the firmware updates, the Alerts cheatsheet, and the Claude skill">
Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

After

Width:  |  Height:  |  Size: 302 KiB

+6
View File
@@ -7,6 +7,12 @@ A new editor, built as a Vue app, with the same firmware.
- **Pages side by side.** The pages of a screen stand next to each other, at the screen's own shape (square for a Guition, 4:3 for a CYD), the way you swipe through them. Dragging from the library onto a page and between cells works as before, with mouse and touch; drop past the last page for a new one.
- **The top bar is part of the mockup.** Tap the bar on any page to name the screen and choose its items; the drawer shows every item as the screen draws it. Adding the time, the date, the dial or an entity happens in the same drawer.
- **Screen settings as a tab** next to Layout, with the same rows as the settings page on the screen. **Read current data** and **Override YAML** live in the ··· menu of the screen; New screen, Firmware & USB, Alerts and the app Settings are pages of their own, reached from the sidebar.
- **Live values on the mockup.** The tiles show what Home Assistant reports right now: the temperature with its unit, On or Off with a lit icon, the heating's current temperature and setpoint, the volume, a cover's position, the song that plays. Sliders and switches on the cards stand where they stand on the screen. The page asks every eight seconds while it is open.
- **Identify.** The ··· menu of a screen has Identify: the screen shows a short card and blinks its backlight, so with three screens you know which is which. Needs firmware 0.2.31 or newer, like every alert.
- **Try an alert.** The Alerts page opens with Try it: fill in the same seven fields an automation sends, choose one screen or all of them, and Send. The result says how many screens showed it and which fields were left empty.
- **Copy, export, import.** The ··· menu copies the layout of another screen onto this one, exports the layout as a JSON file (and to the clipboard), and imports one. An imported layout is trimmed to what the screen's firmware holds, and nothing is sent before you press Save & send.
- **A smarter library.** A room filter and Hide placed next to the domain chips; a lit icon for a light, switch or fan that is on, a grey one for an entity Home Assistant can't reach. ⌘K (Ctrl+K) opens a search over screens, entities and actions: open a screen, add an entity to the page you have open, save, identify, export.
- **Updates with content.** The Update badge has What's new: the changelog lines since that screen's firmware. A running update shows a progress bar with its stage (building, writing over Wi-Fi, waiting for the screen, checking it stays up) and the last line of the ESPHome log; Settings → Firmware updates shows the log's tail.
- **Dark mode follows your browser**, as Home Assistant's own Auto theme does.
- Same add-on API, same data, same firmware: nothing to update on the screens. The page is now built with Vue and Vite (`web/`); its files carry a hash of their content, so an update never runs an old script against a new page.
+2 -1
View File
@@ -15,7 +15,7 @@ for existing profiles: check, build or reinstall via USB, the IP address (OTA) o
Download. Existing ESPHome profiles in the HA config folder are
found automatically.
**Settings** in the sidebar has the nightly firmware updates, the **Alerts** cheatsheet
**Settings** in the sidebar has the nightly firmware updates (the Update badge of a screen lists what's new, a running update shows its progress), the **Alerts** cheatsheet with a form to try an alert
(an alert on one screen, or on every screen with the `esp_screens_show_alert` event)
and **Claude**: install the ESP Screens skill for Claude Code in Home Assistant, or
download it for claude.ai, and ask Claude for the alert automation.
@@ -27,6 +27,7 @@ to configure; firmware 0.2.32 or newer renders them.
After pairing via the HA ESPHome integration, choose the tiles in ESP Screens.
Tap a tile and its settings open in a drawer on the right, with the mockup still in view: click behavior, larger values, mini-sliders, a wider tile, backgrounds.
The mockup shows what Home Assistant reports right now: values, On or Off, positions, the song that plays. **Identify** in the ··· menu blinks a screen so you know which one it is; the same menu copies the layout of another screen, or exports and imports one as JSON. ⌘K searches screens, entities and actions.
**Screen settings**, the tab next to Layout, has brightness and standby. **Read current data** in the ··· menu of a screen helps with
missing attributes or an offline screen. New cards require firmware 0.2.0.
The ··· menu of a screen also has **Override YAML** for a persistent, hardware-specific
+2
View File
@@ -5,6 +5,8 @@ ARG BUILD_ARCH
LABEL io.hass.version="${BUILD_VERSION}" io.hass.type="app" io.hass.arch="${BUILD_ARCH}"
RUN pip install --no-cache-dir 'aiohttp>=3.12,<4' PyYAML
COPY app /app
# The Update badge shows what's new from the changelog (app 0.2.74).
COPY CHANGELOG.md /app/CHANGELOG.md
WORKDIR /app
ENTRYPOINT []
CMD ["python3", "/app/server.py"]
+41
View File
@@ -0,0 +1,41 @@
"""What's new for the Update badge (app 0.2.74): the CHANGELOG sections since a screen's firmware.
The Dockerfile copies CHANGELOG.md next to this file; a development checkout has it one folder up.
Each `## <app> (firmware <fw>)` section gives its bullet lines as plain text, newest first.
"""
import re
from pathlib import Path
HEADER = re.compile(r'^## (\d+\.\d+\.\d+) \(firmware (\d+\.\d+\.\d+)\)')
BOLD = re.compile(r'\*\*(.+?)\*\*')
CODE = re.compile(r'`([^`]*)`')
LINK = re.compile(r'\[([^\]]+)\]\([^)]+\)')
def load(path=None, limit=20):
"""[{app, firmware, lines}] for the latest sections; [] without a changelog."""
candidates = [Path(path)] if path else [Path(__file__).with_name('CHANGELOG.md'), Path(__file__).parent.parent / 'CHANGELOG.md']
for candidate in candidates:
if candidate.exists():
return parse(candidate.read_text(), limit)
return []
def parse(text, limit=20):
sections = []
for line in text.splitlines():
match = HEADER.match(line)
if match:
if len(sections) >= limit:
break
sections.append({'app': match.group(1), 'firmware': match.group(2), 'lines': []})
elif sections and line.startswith('- '):
sections[-1]['lines'].append(plain(line[2:]))
return sections
def plain(markdown):
text = BOLD.sub(r'\1', markdown)
text = CODE.sub(r'\1', text)
text = LINK.sub(r'\1', text)
return text.strip()
+45
View File
@@ -1623,6 +1623,48 @@ def create_app(manager, development=False):
manager.registry_index().get(t['entity']),getattr(manager.ha,'state_words',None)),
'attributes':state_message(i,t,manager.ha.states)['a'],
'options':t.get('options',{})} for i,t in enumerate(layout['tiles'])]})
async def states(request):
"""Live values for the editor's mockup (app 0.2.74): the state, Home Assistant's word and the attributes a
card shows, for the tiles on the page, saved or not. At most sixty entities per request."""
index = manager.registry_index()
result = {}
for eid in request.query.getall('entity', [])[:60]:
if not isinstance(eid, str) or eid not in manager.ha.states:
continue
state = manager.ha.states.get(eid, {})
entry = index.get(eid)
message = state_message(0, {'entity': eid, 'name': ''}, manager.ha.states,
precision=header_bar.precision_of(entry) if eid.startswith('sensor.') else None)
result[eid] = {'state': message['state'], 'a': message['a'],
'word': state_word(eid, state.get('state'), state.get('attributes'), entry, getattr(manager.ha, 'state_words', None))}
return web.json_response({'states': result})
def one_alert_target(inbox):
screen = manager.screen(inbox)
if screen is None:
raise ValueError('Unknown screen.')
ready, skipped = alert_targets([screen])
if not ready:
raise ValueError(f"{screen['name']} can't show an alert: {skipped[0][1] if skipped else 'not ready'}.")
return ready[0]
async def identify(request):
"""Identify (app 0.2.74): the screen shows a short card and blinks its backlight, so you know which one it is."""
screen = one_alert_target(request.match_info['inbox'])
data, _ = alert_data({'title': f"This is {screen['name']}", 'subtitle': 'Identify, from ESP Screens', 'icon': 'bell-ring',
'color': 'blue', 'button_text': 'OK', 'timeout': 8, 'flash': True})
await manager.ha.call(alert_service(screen['node']), data)
return web.json_response({'ok': True})
async def test_alert(request):
"""Alerts → Try it (app 0.2.74): one alert to one screen or to every screen, with the fields an automation sends."""
body = await request.json()
if not isinstance(body, dict):
raise ValueError('Invalid alert.')
data = body.get('data') if isinstance(body.get('data'), dict) else {}
if body.get('screen') == 'all':
return web.json_response(await manager.broadcast(BROADCAST_SHOW, data))
screen = one_alert_target(str(body.get('screen', '')))
service, unusable = alert_data(data)
await manager.ha.call(alert_service(screen['node']), service)
return web.json_response({'sent': 1, 'failed': 0, 'skipped': 0, 'unusable': unusable})
async def install_claude_skill(request):
"""Settings → Claude → Install: writes the skill into Home Assistant's configuration folder, only on request."""
return web.json_response(claude_skill.install(manager.skill_dir))
@@ -1667,6 +1709,9 @@ def create_app(manager, development=False):
app.router.add_get('/', index)
app.router.add_get('/api/inventory', inventory)
app.router.add_get('/api/capabilities', capabilities)
app.router.add_get('/api/states', states)
app.router.add_post('/api/screens/{inbox}/identify', identify)
app.router.add_post('/api/alerts/test', test_alert)
app.router.add_get('/api/entity-actions', entity_actions)
app.router.add_post('/api/header-preview', header_preview)
app.router.add_post('/api/claude-skill', install_claude_skill)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>ESP Screens</title>
<script type="module" crossorigin src="./assets/index-DOjlT4hj.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-D7h6hKdu.css">
<script type="module" crossorigin src="./assets/index-BuQ5a76G.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-PqOTdpJB.css">
</head>
<body>
<div id="app"></div>
+4 -1
View File
@@ -7,6 +7,7 @@ import os
from pathlib import Path
import re
import time
import changelog
from core import FIRMWARE_VERSION
LOG = logging.getLogger('screen_manager')
@@ -31,6 +32,8 @@ class Updater:
self.manager, self.path = manager, Path(path)
self.auto, self.hosts, self.results, self.last_round = False, {}, {}, None
self.task, self.current, self.queue, self.phase = None, None, [], None
# What's new since a screen's firmware, for the Update badge (app 0.2.74).
self.changelog = changelog.load()
if self.path.exists():
raw = json.loads(self.path.read_text())
if raw.get('version') != 1:
@@ -103,7 +106,7 @@ class Updater:
def summary(self, screens=None, profiles=None):
return {'auto': self.auto, 'target': FIRMWARE_VERSION, 'busy': self.current,
'pending': len(self.pending(screens, profiles)), 'last_round': self.last_round}
'pending': len(self.pending(screens, profiles)), 'last_round': self.last_round, 'changelog': self.changelog}
def pending(self, screens=None, profiles=None):
# Callers that already hold the inventory and profile list pass them in; the inventory
+149
View File
@@ -0,0 +1,149 @@
"""Live values on the mockup, Identify, the test alert and the changelog for the Update badge (app 0.2.74).
The editor asks /api/states for the tiles it shows and draws Home Assistant's values on the mockup; Identify and
Try it call a screen's own show_alert action, with the same field rules as an alert event; the update summary
carries the CHANGELOG sections so the badge can say what a screen gets.
"""
import importlib.util
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
sys.path.insert(0, str(ROOT / 'tests'))
import changelog # noqa: E402
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
SAMPLE = '''## 0.2.74 (firmware 0.2.62)
A new editor.
- **One workspace.** Sidebar, pages, [library](docs/X.md) with `code`.
- Second line.
## 0.2.71 (firmware 0.2.60)
- **Slider stays.** Text.
'''
class Changelog(unittest.TestCase):
def test_sections_carry_plain_bullet_lines(self):
sections = changelog.parse(SAMPLE)
self.assertEqual([(s['app'], s['firmware']) for s in sections], [('0.2.74', '0.2.62'), ('0.2.71', '0.2.60')])
self.assertEqual(sections[0]['lines'], ['One workspace. Sidebar, pages, library with code.', 'Second line.'])
self.assertEqual(changelog.parse(SAMPLE, limit=1)[0]['app'], '0.2.74')
def test_the_real_changelog_is_found_and_shipped(self):
sections = changelog.load()
self.assertTrue(sections and sections[0]['lines'], 'CHANGELOG.md one folder up from the app')
self.assertIn('COPY CHANGELOG.md /app/CHANGELOG.md', (ROOT / 'screen_manager/Dockerfile').read_text())
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
class Endpoints(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
import test_scaling
from aiohttp.test_utils import TestClient, TestServer
from server import Manager, create_app
self.ha = test_scaling.fake_ha(firmware='0.2.60', node='office-1')
self.ha.calls = []
async def call(action, data):
self.ha.calls.append((action, data))
self.ha.call = call
self.tmp = tempfile.TemporaryDirectory()
self.manager = Manager(self.ha, Path(self.tmp.name) / 'screens.json')
self.client = TestClient(TestServer(create_app(self.manager, True)))
await self.client.start_server()
inventory = await (await self.client.get('/api/inventory')).json()
self.csrf = inventory['csrf']
self.screen = inventory['screens'][0]
self.headers = {'X-Screen-CSRF': self.csrf}
async def asyncTearDown(self):
await self.client.close()
self.tmp.cleanup()
async def test_states_give_the_value_word_and_attributes_per_entity(self):
response = await self.client.get('/api/states?entity=light.a&entity=sensor.t&entity=light.nope&entity=screen.clock')
states = (await response.json())['states']
self.assertEqual(set(states), {'light.a', 'sensor.t'}, 'unknown entities and built-ins are left out')
self.assertEqual(states['light.a']['state'], 'on')
self.assertEqual(states['sensor.t']['state'], '21.5')
self.assertEqual(states['sensor.t']['a']['unit_of_measurement'], '°C')
self.assertIn('word', states['light.a'])
async def test_identify_blinks_the_screen_through_its_alert_action(self):
response = await self.client.post(f"/api/screens/{self.screen['id']}/identify", headers=self.headers)
self.assertEqual(response.status, 200, await response.text())
(action, data), = self.ha.calls
self.assertEqual(action, 'esphome.office_1_show_alert')
self.assertEqual((data['title'], data['flash'], data['timeout'], data['icon']), (f"This is {self.screen['name']}", True, 8, 'bell-ring'))
missing = await self.client.post('/api/screens/text.nope/identify', headers=self.headers)
self.assertEqual(missing.status, 400)
async def test_the_test_alert_reaches_one_screen_or_all_with_the_event_rules(self):
body = {'screen': self.screen['id'], 'data': {'title': 'Door', 'timeout': 'soon', 'flash': 'yes'}}
response = await self.client.post('/api/alerts/test', headers=self.headers, json=body)
result = await response.json()
self.assertEqual((response.status, result['sent'], result['unusable']), (200, 1, ['timeout']), result)
action, data = self.ha.calls[-1]
self.assertEqual(action, 'esphome.office_1_show_alert')
self.assertEqual((data['title'], data['timeout'], data['flash'], data['subtitle']), ('Door', 0, True, ''))
everyone = await self.client.post('/api/alerts/test', headers=self.headers, json={'screen': 'all', 'data': {'title': 'Hi'}})
self.assertEqual(everyone.status, 200, await everyone.text())
self.assertEqual((await everyone.json())['sent'], 1)
self.assertEqual(len(self.ha.calls), 2)
bad = await self.client.post('/api/alerts/test', headers=self.headers, json={'screen': 'text.nope'})
self.assertEqual(bad.status, 400)
async def test_the_update_summary_carries_the_changelog(self):
inventory = await (await self.client.get('/api/inventory?light=1')).json()
sections = inventory['updates']['changelog']
self.assertTrue(sections)
self.assertEqual(set(sections[0]), {'app', 'firmware', 'lines'})
class Editor(unittest.TestCase):
"""The page side of the same features, read from the Vue sources."""
def setUp(self):
import editor_sources
self.store = editor_sources.source('store.ts')
self.page = editor_sources.PAGE
def test_the_mockup_polls_live_values_and_draws_them(self):
self.assertIn('getJson(`states?${query}`)', self.store)
self.assertIn('if (!document.hidden && state.layout && state.tab === "layout" && route.value === "") loadStates();', self.store)
for marker in ('liveOf(props.tile.entity)', ':class="{ lit: isOn }"', ':style="sliderStyle"', "class=\"tog\" :class=\"{ off: !on }\""):
self.assertIn(marker, self.page, marker)
def test_identify_and_the_test_alert_have_their_buttons(self):
self.assertIn('send(`screens/${encodeURIComponent(screen.id)}/identify`, "POST")', self.store)
self.assertIn('send("alerts/test", "POST", { screen: target, data })', self.store)
self.assertIn('id="identify"', self.page)
self.assertIn('id="alerts-try"', self.page)
self.assertIn('id="try-send"', self.page)
def test_layouts_can_be_copied_exported_and_imported(self):
for name in ('export function copyLayoutFrom', 'export function exportLayout', 'export function importLayout'):
self.assertIn(name, self.store)
for marker in ('id="copy-layout"', 'id="export-layout"', 'id="import-layout"', 'accept="application/json,.json"'):
self.assertIn(marker, self.page, marker)
self.assertIn('.slice(0, tileLimit.value)', self.store, 'an imported layout never exceeds the firmware limit')
def test_the_library_filters_by_room_and_placement_and_the_palette_exists(self):
for marker in ('id="room"', 'id="hide-placed"', 'id="open-palette"', 'id="palette-input"', "e.key.toLowerCase() === \"k\""):
self.assertIn(marker, self.page, marker)
def test_updates_show_their_notes_and_progress(self):
self.assertIn('export function whatsNew', self.store)
self.assertIn('export function updateProgress', self.store)
for marker in ('class="whatsnew"', 'role="progressbar"', "go('#firmware')"):
self.assertIn(marker, self.page, marker)
if __name__ == '__main__':
unittest.main()
+3 -2
View File
@@ -25,9 +25,10 @@ STORE = editor_sources.source('store.ts')
class Startup(unittest.TestCase):
def test_no_screen_opens_by_itself(self):
self.assertNotIn('inventory.screens[0]', SCRIPT)
# The one way into a screen is its button in the list.
self.assertEqual(re.findall(r'(?<![\w.])(?<!function )select\(([^)]*)\)', SCRIPT), ['screen.id'])
# The ways into a screen are its button in the list and its row in the ⌘K search; both pass the chosen screen.
self.assertEqual(set(re.findall(r'(?<![\w.])(?<!function )select\(([^)]*)\)', SCRIPT)), {'screen.id'})
self.assertIn('@click="select(screen.id)"', editor_sources.component('Sidebar'))
self.assertIn('run: () => select(screen.id)', editor_sources.component('CommandPalette'))
for name in ('refresh', 'applyLive'):
body = STORE[STORE.index(f'function {name}('):]
self.assertNotRegex(body[:body.index('\n}\n')], r'(?<![\w.])select\(', name)
+9 -1
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed } from "vue";
import { computed, onBeforeUnmount, onMounted } from "vue";
import Sidebar from "./components/Sidebar.vue";
import Toast from "./components/Toast.vue";
import CommandPalette from "./components/CommandPalette.vue";
import ScreenView from "./components/ScreenView.vue";
import EmptyState from "./components/EmptyState.vue";
import AppSettingsView from "./components/AppSettingsView.vue";
@@ -19,6 +20,12 @@ const view = computed(() => {
if (route.value === "#override") return OverrideView;
return currentScreen.value && state.layout ? ScreenView : EmptyState;
});
// ⌘K (Ctrl+K) opens the search from anywhere.
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); state.palette = !state.palette; }
}
onMounted(() => document.addEventListener("keydown", onKey));
onBeforeUnmount(() => document.removeEventListener("keydown", onKey));
</script>
<template>
@@ -28,5 +35,6 @@ const view = computed(() => {
<component :is="view" />
</main>
<Toast />
<CommandPalette />
</div>
</template>
+48 -3
View File
@@ -1,11 +1,32 @@
<script setup lang="ts">
// Alerts: the cheatsheet for esphome.<node>_show_alert, built from the inventory.
import { computed, ref } from "vue";
import { computed, reactive, ref } from "vue";
import { versionAtLeast } from "../model/layout";
import { glyph } from "../model/topbar";
import { copyText, go, state } from "../store";
import { canAlert, copyText, go, sendTestAlert, state } from "../store";
const alerts = computed(() => state.inventory.alerts);
// Try it: the same seven fields an automation sends, to one screen or to all of them.
const tryForm = reactive({ screen: "all", title: "Someone is at the door", subtitle: "Door 3, back", icon: "doorbell", color: "orange", button_text: "Coming", timeout: 30, flash: true });
const trying = ref(false);
const tryResult = ref("");
const readyScreens = computed(() => state.inventory.screens.filter((s) => canAlert(s) && s.online));
async function tryAlert() {
trying.value = true;
tryResult.value = "";
try {
const { screen, ...data } = tryForm;
const result = await sendTestAlert(screen, data);
const where = screen === "all" ? `${result.sent} screen${result.sent === 1 ? "" : "s"}` : state.inventory.screens.find((s) => s.id === screen)?.name || "the screen";
tryResult.value = result.sent
? `Sent to ${where}.${result.skipped ? ` ${result.skipped} skipped (offline or old firmware).` : ""}${result.unusable?.length ? ` Left empty: ${result.unusable.join(", ")}.` : ""}`
: "No screen could show it: check that a screen is online with firmware " + (alerts.value?.min_firmware || "0.2.31") + " or newer.";
} catch (e: any) {
tryResult.value = e.message;
} finally {
trying.value = false;
}
}
const icons = computed(() => state.inventory.icons);
const exampleAction = ref("");
const iconQuery = ref("");
@@ -60,7 +81,7 @@ const doorbell = computed(() => alerts.value?.suggested_icons?.find((i: any) =>
const orange = computed(() => alerts.value?.colors?.find((c: any) => c.name === "orange"));
const types: Record<string, string> = { string: "text", int: "number", bool: "on / off" };
function jump(id: string) { document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" }); }
const sections = [["alerts-screens", "Your screens"], ["alerts-howto", "In an automation"], ["alerts-all", "All screens"], ["alerts-fields", "The fields"], ["alerts-icons", "Icons"], ["alerts-colors", "Colors"], ["alerts-behaviour", "Behavior"], ["alerts-events", "Events"], ["alerts-tips", "Tips"]];
const sections = [["alerts-try", "Try it"], ["alerts-screens", "Your screens"], ["alerts-howto", "In an automation"], ["alerts-all", "All screens"], ["alerts-fields", "The fields"], ["alerts-icons", "Icons"], ["alerts-colors", "Colors"], ["alerts-behaviour", "Behavior"], ["alerts-events", "Events"], ["alerts-tips", "Tips"]];
</script>
<template>
@@ -88,6 +109,30 @@ const sections = [["alerts-screens", "Your screens"], ["alerts-howto", "In an au
<button v-for="[id, label] in sections" :key="id" type="button" :data-jump="id" @click="jump(id)">{{ label }}</button>
</nav>
</div>
<section id="alerts-try" class="card">
<h2>Try it</h2>
<p>Send one alert now, with the same fields an automation sends. The screen shows the card and, with blinking on, flashes its backlight.</p>
<form class="try-grid" @submit.prevent="tryAlert">
<div class="field"><label class="f-label" for="try-screen">Screen</label>
<select id="try-screen" v-model="tryForm.screen">
<option value="all">All screens ({{ readyScreens.length }} ready)</option>
<option v-for="screen in state.inventory.screens" :key="screen.id" :value="screen.id" :disabled="!canAlert(screen) || !screen.online">{{ screen.name }}{{ canAlert(screen) && screen.online ? "" : " · not ready" }}</option>
</select></div>
<div class="field"><label class="f-label" for="try-title">Title</label><input id="try-title" v-model="tryForm.title" maxlength="64" /></div>
<div class="field"><label class="f-label" for="try-subtitle">Subtitle</label><input id="try-subtitle" v-model="tryForm.subtitle" maxlength="240" /></div>
<div class="field"><label class="f-label" for="try-icon">Icon</label><input id="try-icon" v-model="tryForm.icon" list="try-icons" placeholder="doorbell" />
<datalist id="try-icons"><option v-for="icon in alerts.suggested_icons" :key="icon.name" :value="icon.name">{{ icon.label || icon.name }}</option></datalist></div>
<div class="field"><label class="f-label" for="try-color">Color</label>
<select id="try-color" v-model="tryForm.color"><option value="">White (default)</option><option v-for="colour in alerts.colors" :key="colour.name" :value="colour.name">{{ colour.label }}</option></select></div>
<div class="field"><label class="f-label" for="try-button">Button text</label><input id="try-button" v-model="tryForm.button_text" maxlength="16" /></div>
<div class="field"><label class="f-label" for="try-timeout">Timeout (seconds, 0 waits for the button)</label><input id="try-timeout" v-model.number="tryForm.timeout" type="number" min="0" max="86400" /></div>
<label class="check field"><input type="checkbox" id="try-flash" v-model="tryForm.flash" /><span>Blink the backlight<small>Four times when the alert arrives.</small></span></label>
<div class="actions field wide">
<button type="submit" class="btn primary" id="try-send" :disabled="trying || !readyScreens.length">{{ trying ? "Sending…" : "Send alert" }}</button>
<span class="status-line" id="try-result" role="status">{{ tryResult }}</span>
</div>
</form>
</section>
<section id="alerts-screens" class="card">
<h2>Your screens</h2>
<p>Every screen has its own action, with the device name in it. From firmware <code id="alerts-min-firmware">{{ alerts.min_firmware }}</code>; update an older screen with its Update button under Screens.</p>
+21 -2
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// Everything around the screens: firmware updates, alerts, Claude.
import { computed, ref } from "vue";
import { go, installClaudeSkill, runUpdateAll, setAutoUpdate, state } from "../store";
import { anyUpdating, go, installClaudeSkill, runUpdateAll, setAutoUpdate, state, updateProgress } from "../store";
const u = computed(() => state.inventory.updates);
const outdated = computed(() => state.inventory.screens.filter((s) => s.update?.available).length);
@@ -12,6 +12,16 @@ const updatesHint = computed(() => !u.value ? "" : u.value.busy
: outdated.value
? `Firmware ${u.value.target} is available for ${outdated.value} screen${outdated.value === 1 ? "" : "s"}.`
: `All screens have firmware ${u.value.target}.`);
const running = computed(() => state.inventory.screens.find((s) => s.update?.state === "running" || state.updating.includes(s.id)));
const progress = computed(() => (running.value ? updateProgress(running.value) : null));
const logTail = computed(() => (state.firmwareJob?.logs || []).slice(-12).join("\n"));
// What the current firmware brings: the changelog sections that mention it.
const targetNotes = computed(() => {
const sections = (u.value as any)?.changelog as { app: string; firmware: string; lines: string[] }[] | undefined;
const target = u.value?.target;
if (!sections || !target) return [];
return sections.filter((s) => s.firmware === target).flatMap((s) => s.lines).slice(0, 10);
});
const skill = computed(() => state.inventory.claude_skill);
const installing = ref(false);
async function install() {
@@ -41,7 +51,16 @@ async function install() {
<section v-if="u" class="card updates" id="updates">
<h2>Firmware updates</h2>
<p id="updates-hint">{{ updatesHint }}</p>
<template v-if="running && progress">
<div class="progress" role="progressbar" :aria-valuenow="progress.percent" aria-valuemin="0" aria-valuemax="100"><i :style="{ width: progress.percent + '%' }"></i></div>
<div class="progress-text"><span>{{ running.name }} · {{ progress.percent }} %</span><span>{{ progress.text }}</span></div>
<pre v-if="logTail" class="log-lines">{{ logTail }}</pre>
</template>
<button v-if="u.pending && !u.busy && u.pending >= 2" id="update-all" type="button" class="btn primary" @click="runUpdateAll">Update all {{ u.pending }} screens</button>
<details v-if="targetNotes.length && !anyUpdating()" class="whatsnew">
<summary>What's new in firmware {{ u.target }}</summary>
<ul><li v-for="line in targetNotes" :key="line">{{ line }}</li></ul>
</details>
<label class="check">
<input type="checkbox" id="auto-update" :checked="Boolean(u.auto)" @change="setAutoUpdate(($event.target as HTMLInputElement).checked)" />
<span>Update automatically every night<small>Between 03:00 and 06:00, one screen at a time. Stops on an error and reports it in Home Assistant.</small></span>
@@ -49,7 +68,7 @@ async function install() {
</section>
<section class="card">
<h2>Alerts</h2>
<p>A card over the whole screen from an automation, on one screen or on all of them. The cheatsheet has the action names, fields, icons, and colors.</p>
<p>A card over the whole screen from an automation, on one screen or on all of them. The cheatsheet has the action names, fields, icons, and colors, and a form to try one.</p>
<div class="tools">
<button type="button" class="tool" @click="go('#alerts')"><span class="tool-icon">!</span><span class="tx"><b>Alerts</b><small>Full-screen notifications, from an automation</small></span></button>
</div>
+85
View File
@@ -0,0 +1,85 @@
<script setup lang="ts">
// ⌘K: screens, entities for the open screen, and the editor's actions, from one search field.
import { computed, nextTick, ref, watch } from "vue";
import { domainInfo } from "../model/layout";
import { glyph } from "../model/topbar";
import { addTile, automaticIcon, canAlert, currentScreen, exportLayout, go, identify, save, select, state, tileLimit } from "../store";
type Item = { group: string; label: string; detail?: string; icon?: string; glyphText?: string; key?: string; run: () => void };
const query = ref("");
const active = ref(0);
const input = ref<HTMLInputElement | null>(null);
const items = computed<Item[]>(() => {
const q = query.value.trim().toLocaleLowerCase();
const list: Item[] = [];
for (const screen of state.inventory.screens)
list.push({ group: "Screens", label: screen.name, detail: `${screen.online ? "Online" : "Offline"} · ${screen.firmware || "unknown"}`, glyphText: "▦", run: () => select(screen.id) });
const screen = currentScreen.value;
const actions: Item[] = [
{ group: "Actions", label: "New screen", detail: "Connect and install", glyphText: "+", run: () => go("#new-screen") },
{ group: "Actions", label: "Firmware & USB", icon: "F0241", run: () => go("#firmware") },
{ group: "Actions", label: "Alerts", detail: "Cheatsheet and a test alert", icon: "F0594", run: () => go("#alerts") },
{ group: "Actions", label: "Settings", detail: "Updates, Claude", icon: "F0493", run: () => go("#settings") },
];
if (screen && state.layout) {
actions.unshift(
{ group: "Actions", label: "Save & send", detail: state.dirty ? "Unsaved changes" : "Nothing to save", key: "⌘S", run: () => save() },
{ group: "Actions", label: "Layout", detail: screen.name, run: () => { go(""); state.tab = "layout"; } },
{ group: "Actions", label: "Screen settings", detail: screen.name, run: () => { go(""); state.tab = "settings"; } },
{ group: "Actions", label: "Identify this screen", detail: canAlert(screen) ? "Blinks the backlight and shows a card" : "Needs a newer firmware", run: () => { if (canAlert(screen)) identify(screen); } },
{ group: "Actions", label: "Export layout", detail: "Download and copy as JSON", run: exportLayout },
);
}
list.push(...actions);
if (screen && state.layout && q) {
const chosen = new Set(state.layout.tiles.map((t) => t.entity));
const full = state.layout.tiles.length >= tileLimit.value;
for (const e of state.inventory.entities) {
if (e.tile === false || chosen.has(e.id)) continue;
if (!`${e.name} ${e.id} ${e.area || ""} ${e.device || ""}`.toLocaleLowerCase().includes(q)) continue;
list.push({ group: "Add to this screen", label: e.name, detail: [domainInfo(e.id)[0], e.area].filter(Boolean).join(" · "), icon: state.inventory.icons ? automaticIcon(e.id) : undefined,
run: () => { if (!full) addTile(e.id); } });
if (list.length > 60) break;
}
}
return q ? list.filter((i) => `${i.label} ${i.detail || ""}`.toLocaleLowerCase().includes(q)) : list;
});
const grouped = computed(() => {
const out: { group: string; items: { item: Item; index: number }[] }[] = [];
items.value.forEach((item, index) => {
const g = out.find((o) => o.group === item.group) || (out.push({ group: item.group, items: [] }), out[out.length - 1]);
g.items.push({ item, index });
});
return out;
});
function close() { state.palette = false; }
function run(item: Item) { close(); item.run(); }
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") { e.preventDefault(); close(); }
else if (e.key === "ArrowDown") { e.preventDefault(); active.value = Math.min(items.value.length - 1, active.value + 1); }
else if (e.key === "ArrowUp") { e.preventDefault(); active.value = Math.max(0, active.value - 1); }
else if (e.key === "Enter") { e.preventDefault(); const item = items.value[active.value]; if (item) run(item); }
}
watch(query, () => (active.value = 0));
watch(() => state.palette, async (open) => { if (open) { query.value = ""; active.value = 0; await nextTick(); input.value?.focus(); } });
</script>
<template>
<div v-if="state.palette" class="palette-backdrop" @click="close">
<div class="palette" role="dialog" aria-label="Search" @click.stop @keydown="onKey">
<input ref="input" v-model="query" id="palette-input" placeholder="Search screens, entities, actions…" aria-label="Search" autocomplete="off" />
<div class="palette-list">
<template v-for="g in grouped" :key="g.group">
<div class="palette-group">{{ g.group }}</div>
<button v-for="{ item, index } in g.items" :key="index" type="button" class="palette-item" :class="{ active: index === active }" @mouseenter="active = index" @click="run(item)">
<span v-if="item.icon" class="mdi">{{ glyph(item.icon) }}</span>
<span v-else class="glyph">{{ item.glyphText || "›" }}</span>
<span class="tx"><span>{{ item.label }}</span><small v-if="item.detail">{{ item.detail }}</small></span>
<kbd v-if="item.key" class="hint-key">{{ item.key }}</kbd>
</button>
</template>
<p v-if="!items.length" class="palette-empty">Nothing matches. Type part of a screen, entity or action.</p>
</div>
</div>
</div>
</template>
+24 -6
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
// The entities a tile can show, with a search and one filter per domain. A click adds the entity to the
// marked empty cell or the first free one; a drag puts it exactly where it lands.
// The entities a tile can show, with a search, a filter per domain and per room, and a switch that hides what is
// already on the screen. A click adds the entity to the marked empty cell or the first free one; a drag puts it
// exactly where it lands.
import { computed } from "vue";
import { vDrag } from "../drag";
import { domainInfo } from "../model/layout";
@@ -14,17 +15,27 @@ const FILTERS: [string, string][] = [
];
const ALIAS: Record<string, string> = { switch: "input_boolean", number: "input_number", select: "input_select", weather: "sun", button: "input_button" };
const chosen = computed(() => new Set(state.layout?.tiles.map((t) => t.entity) || []));
const rooms = computed(() => [...new Set(state.inventory.entities.map((e) => e.area).filter((a): a is string => Boolean(a)))].sort((a, b) => a.localeCompare(b)));
const matches = computed(() => {
const query = state.search.toLocaleLowerCase(), filter = state.filter;
const query = state.search.toLocaleLowerCase(), filter = state.filter, room = state.room;
// The picker offers what a tile can show; camera images need a Guition (app 0.2.66).
return [...(state.inventory.builtin || []), ...state.inventory.entities].filter((e) =>
e.tile !== false &&
(isGuition.value || !["camera", "image"].includes(e.id.split(".")[0])) &&
(!filter || e.id.startsWith(filter + ".") || ALIAS[filter] === e.id.split(".")[0]) &&
(!room || e.area === room) &&
(!state.hidePlaced || !chosen.value.has(e.id)) &&
`${e.name} ${e.id} ${e.device || ""} ${e.area || ""}`.toLocaleLowerCase().includes(query));
});
const full = computed(() => (state.layout?.tiles.length || 0) >= tileLimit.value);
const count = computed(() => state.inventory.entities.length);
// The avatar shows the state at a glance: lit for on, grey for an entity Home Assistant can't reach.
const tone = (e: { id: string; state?: string }) => {
const domain = e.id.split(".")[0];
if (e.state === "unavailable" || e.state === "unknown") return "gone";
if (["light", "switch", "input_boolean", "fan"].includes(domain) && e.state === "on") return "on";
return "";
};
</script>
<template>
@@ -37,20 +48,27 @@ const count = computed(() => state.inventory.entities.length);
<span v-if="value" class="domain-icon" :style="{ color: domainInfo(value + '.')[2], background: domainInfo(value + '.')[3] }" aria-hidden="true">{{ domainInfo(value + ".")[1] }}</span>{{ label }}
</button>
</div>
<div class="lib-row">
<select id="room" v-model="state.room" aria-label="Room">
<option value="">All rooms</option>
<option v-for="room in rooms" :key="room" :value="room">{{ room }}</option>
</select>
<button type="button" class="chip-toggle" id="hide-placed" :aria-pressed="state.hidePlaced ? 'true' : 'false'" title="Hide what is already on this screen" @click="state.hidePlaced = !state.hidePlaced">Hide placed</button>
</div>
</div>
<div class="lib-list" id="results" aria-live="polite">
<button v-for="entity in matches.slice(0, 80)" :key="entity.id" type="button" class="ent" :title="entity.id"
:disabled="chosen.has(entity.id) || full" v-drag="{ kind: 'entity', id: entity.id }" @click="addTile(entity.id)">
<span class="av mdi" :style="{ color: domainInfo(entity.id)[2], background: domainInfo(entity.id)[3] }">{{ state.inventory.icons ? glyph(automaticIcon(entity.id)) : domainInfo(entity.id)[1] }}</span>
<span class="av mdi" :class="tone(entity)" :style="{ color: domainInfo(entity.id)[2], background: domainInfo(entity.id)[3] }">{{ state.inventory.icons ? glyph(automaticIcon(entity.id)) : domainInfo(entity.id)[1] }}</span>
<span class="tx">
<b>{{ entity.name }}</b>
<small>{{ [domainInfo(entity.id)[0], entity.area, entity.device].filter(Boolean).join(" · ") }}</small>
</span>
<span class="add" :class="{ done: chosen.has(entity.id) }">{{ chosen.has(entity.id) ? "✓" : "+" }}</span>
</button>
<p v-if="!matches.length" class="hint">No entities found. Try a different name.</p>
<p v-if="!matches.length" class="hint">{{ state.hidePlaced && !state.search && !state.filter && !state.room ? "Everything here is already on this screen." : "No entities found. Try a different name, room or filter." }}</p>
<p v-else-if="matches.length > 80" class="hint">{{ matches.length }} results. Keep typing to narrow it down.</p>
</div>
<div class="lib-foot">{{ full ? `This screen holds ${tileLimit} tiles${tileLimit === 10 ? "; update its firmware for 20" : ""}.` : "Drag onto a page, or tap + to add to the first free slot." }}</div>
<div class="lib-foot">{{ full ? `This screen holds ${tileLimit} tiles${tileLimit === 10 ? "; update its firmware for 20" : ""}.` : "Drag onto a page, or tap + to add to the first free slot. ⌘K searches everything." }}</div>
</aside>
</template>
+43 -11
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// One screen: the head with its status, the Layout and Settings tabs, and the drawer over the right side.
import { computed, onBeforeUnmount, onMounted } from "vue";
import { closeInspector, currentScreen, go, save, state } from "../store";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { canAlert, closeInspector, copyLayoutFrom, currentScreen, exportLayout, go, identify, importLayout, save, startUpdate, state } from "../store";
import LayoutView from "./LayoutView.vue";
import SettingsTab from "./SettingsTab.vue";
import Drawer from "./Drawer.vue";
@@ -10,26 +10,48 @@ const screen = computed(() => currentScreen.value!);
const statusText = computed(() => screen.value.online
? `${screen.value.delivery} · ${screen.value.status}`
: "Offline · changes are saved");
const updateReady = computed(() => screen.value.update?.available && screen.value.online && screen.value.update?.profile);
const updateReady = computed(() => screen.value.update?.available && screen.value.online && screen.value.update?.profile && screen.value.update?.host);
const others = computed(() => state.inventory.screens.filter((s) => s.id !== screen.value.id && s.layout?.tiles?.length));
const copyOpen = ref(false);
const fileInput = ref<HTMLInputElement | null>(null);
function closeMenu() { state.menuOpen = false; copyOpen.value = false; }
function openOverride() {
state.menuOpen = false;
closeMenu();
state.overrideProfile = screen.value.update?.profile || null;
state.overrideFriendly = screen.value.name;
go("#override");
}
function inspectAll() {
state.menuOpen = false;
closeMenu();
state.selectedTile = null;
state.inspector = { kind: "inspect" };
}
function copyFrom(id: string) {
closeMenu();
if (state.dirty && !confirm("Replace the unsaved layout with a copy of the other screen's?")) return;
copyLayoutFrom(id);
}
function pickFile() { closeMenu(); fileInput.value?.click(); }
async function onFile(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (!file) return;
if (state.dirty && !confirm("Replace the unsaved layout with the imported one?")) return;
importLayout(await file.text());
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
if (state.menuOpen) state.menuOpen = false;
if (state.menuOpen) closeMenu();
else if (state.palette) return;
else if (state.inspector && !(e.target as HTMLElement)?.closest?.(".picker")) closeInspector();
} else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") {
e.preventDefault();
if (state.dirty) save();
}
}
function onDocClick(e: MouseEvent) {
if (state.menuOpen && !(e.target as HTMLElement).closest(".head-right .menu, #more")) state.menuOpen = false;
if (state.menuOpen && !(e.target as HTMLElement).closest(".head-right .menu, #more")) closeMenu();
}
onMounted(() => { document.addEventListener("keydown", onKey); document.addEventListener("click", onDocClick); });
onBeforeUnmount(() => { document.removeEventListener("keydown", onKey); document.removeEventListener("click", onDocClick); });
@@ -49,16 +71,26 @@ onBeforeUnmount(() => { document.removeEventListener("keydown", onKey); document
</div>
<span v-if="!state.dirty" id="dirty" class="chip" :class="{ sent: state.saved }">{{ state.saved ? "Saved · sent to screen" : "All saved" }}</span>
<span v-else id="dirty" class="chip dirty">Unsaved changes</span>
<button v-if="state.dirty" id="save" type="button" class="btn primary" :disabled="state.busy" @click="save()">
<button v-if="state.dirty" id="save" type="button" class="btn primary" :disabled="state.busy" title="⌘S" @click="save()">
<span v-if="state.busy" class="spin small"></span>{{ state.busy ? "Saving…" : "Save & send" }}
</button>
<button id="more" type="button" class="icon-btn" aria-label="More" aria-haspopup="menu" :aria-expanded="state.menuOpen ? 'true' : 'false'" @click.stop="state.menuOpen = !state.menuOpen">···</button>
<button id="more" type="button" class="icon-btn" aria-label="More" aria-haspopup="menu" :aria-expanded="state.menuOpen ? 'true' : 'false'" @click.stop="state.menuOpen = !state.menuOpen; copyOpen = false">···</button>
<div v-if="state.menuOpen" class="menu" role="menu">
<button type="button" role="menuitem" id="identify" :disabled="!canAlert(screen) || !screen.online" :title="canAlert(screen) ? '' : 'Needs firmware 0.2.31 or newer'" @click="closeMenu(); identify(screen)">Identify <small>blink the screen</small></button>
<button type="button" role="menuitem" id="inspect" @click="inspectAll">Read current data <small>from Home Assistant</small></button>
<div class="sep"></div>
<button type="button" role="menuitem" id="copy-layout" :disabled="!others.length" :aria-expanded="copyOpen ? 'true' : 'false'" @click.stop="copyOpen = !copyOpen">Copy layout from… <small>{{ others.length ? `${others.length} screen${others.length === 1 ? "" : "s"}` : "no other screen" }}</small></button>
<div v-if="copyOpen" class="sub">
<button v-for="other in others" :key="other.id" type="button" role="menuitem" @click="copyFrom(other.id)">{{ other.name }} <small>{{ other.layout.tiles.length }} tiles</small></button>
</div>
<button type="button" role="menuitem" id="export-layout" @click="closeMenu(); exportLayout()">Export layout <small>JSON</small></button>
<button type="button" role="menuitem" id="import-layout" @click="pickFile">Import layout… <small>JSON</small></button>
<div class="sep"></div>
<button type="button" role="menuitem" id="open-override" :disabled="!screen.update?.profile" :title="screen.update?.profile ? '' : 'No ESPHome profile found for this screen'" @click="openOverride">Override YAML <small>advanced</small></button>
<button type="button" role="menuitem" @click="state.menuOpen = false; go('#firmware')">Firmware &amp; USB</button>
<button v-if="updateReady" type="button" role="menuitem" @click="state.menuOpen = false; $emit('update')">Update firmware <small>{{ screen.update?.target }}</small></button>
<button type="button" role="menuitem" @click="closeMenu(); go('#firmware')">Firmware &amp; USB</button>
<button v-if="updateReady" type="button" role="menuitem" @click="closeMenu(); startUpdate(screen)">Update firmware <small>{{ screen.update?.target }}</small></button>
</div>
<input ref="fileInput" type="file" accept="application/json,.json" hidden @change="onFile" />
</div>
</header>
<div class="body" id="body">
+16 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref } from "vue";
import { glyph } from "../model/topbar";
import { copyText, go, openIntegrations, PHASES, refresh, route, select, startUpdate, state } from "../store";
import { copyText, go, openIntegrations, PHASES, refresh, route, select, startUpdate, state, updateProgress, whatsNew } from "../store";
import type { Screen } from "../types";
const hostFor = ref<string | null>(null);
@@ -25,6 +25,10 @@ function startWithHost(screen: Screen) {
hostFor.value = null;
startUpdate(screen, address);
}
const lastLog = () => {
const lines = state.firmwareJob?.logs || [];
return lines.length ? lines[lines.length - 1] : "";
};
const pendingText = (p: { installed?: boolean; downloaded?: boolean; file: string }) => p.installed
? "Installed, but not yet in Home Assistant. Add the discovered ESPHome device under Settings → Devices & services, paste the API key there, and turn on “Allow the device to perform Home Assistant actions” under Configure."
: p.downloaded
@@ -38,6 +42,7 @@ const pendingText = (p: { installed?: boolean; downloaded?: boolean; file: strin
<span id="connection" class="conn" :class="{ online: state.connected }" role="status">
{{ !state.reachable ? "Management page unreachable · retrying…" : state.connected ? "Home Assistant connected" : "Reconnecting to Home Assistant…" }}
</span>
<button type="button" class="search-btn" id="open-palette" @click="state.palette = true">⌕ Search<kbd>⌘K</kbd></button>
<div class="label">Screens</div>
<div id="screens">
<div v-for="screen in state.inventory.screens" :key="screen.id" class="screen-item" :class="{ selected: screen.id === state.selected && route === '' }">
@@ -64,6 +69,16 @@ const pendingText = (p: { installed?: boolean; downloaded?: boolean; file: strin
<small>{{ updateState(screen)!.text }}</small>
<button type="button" class="btn mini primary" :disabled="!screen.update?.profile" :title="screen.update?.profile ? '' : 'No ESPHome profile found with this device name.'" @click="update(screen)">Update</button>
</template>
<details v-if="whatsNew(screen).length" class="whatsnew">
<summary>What's new</summary>
<ul><li v-for="line in whatsNew(screen).slice(0, 8)" :key="line">{{ line }}</li></ul>
</details>
</template>
<template v-else-if="updateState(screen)!.kind === 'running' && updateProgress(screen)">
<div class="progress" role="progressbar" :aria-valuenow="updateProgress(screen)!.percent" aria-valuemin="0" aria-valuemax="100"><i :style="{ width: updateProgress(screen)!.percent + '%' }"></i></div>
<div class="progress-text"><span>{{ updateProgress(screen)!.percent }} %</span><span :title="lastLog()">{{ updateProgress(screen)!.text }}</span></div>
<small v-if="lastLog()" :title="lastLog()" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis">{{ lastLog() }}</small>
<button type="button" class="btn link mini" style="justify-self: start" @click="go('#firmware')">Full log</button>
</template>
<small v-else :class="{ failed: updateState(screen)!.kind === 'failed' }">{{ updateState(screen)!.text }}</small>
</div>
+72 -14
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
// A card on the mockup. A placeholder is the tile being dragged, drawn where it will land.
// A card on the mockup, drawn with what Home Assistant reports right now. A placeholder is the tile being
// dragged, drawn where it will land.
import { computed, nextTick } from "vue";
import { vDrag } from "../drag";
import { displayNames, effectiveControls, isWide, pageOf, SLOTS_PER_PAGE } from "../model/layout";
import { glyph } from "../model/topbar";
import { entityName, openTile, placeTile, removeTile, state, tileIconCp } from "../store";
import { entityName, liveOf, openTile, placeTile, removeTile, state, tileIconCp } from "../store";
import type { Tile } from "../types";
const props = defineProps<{ tile: Tile; slot: number; placeholder?: boolean }>();
@@ -25,6 +26,54 @@ const now = computed(() => new Date(state.now));
const hourAngle = computed(() => (now.value.getHours() % 12 + now.value.getMinutes() / 60) * 30);
const minuteAngle = computed(() => now.value.getMinutes() * 6);
// ---- Live values ----
const current = computed(() => (domain.value === "screen" ? null : liveOf(props.tile.entity)));
const gone = computed(() => !current.value || ["unavailable", "unknown", ""].includes(current.value.state));
const on = computed(() => Boolean(current.value) && !gone.value && current.value!.state !== "off" && current.value!.state !== "closed" && current.value!.state !== "standby" && current.value!.state !== "idle" && current.value!.state !== "docked");
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, " ");
// Home Assistant's words for the weather states, for a Home Assistant that hands us none.
const WEATHER_WORDS: Record<string, string> = { "clear-night": "Clear, night", cloudy: "Cloudy", exceptional: "Exceptional", fog: "Fog", hail: "Hail", lightning: "Lightning", "lightning-rainy": "Lightning, rainy", partlycloudy: "Partly cloudy", pouring: "Pouring", rainy: "Rainy", snowy: "Snowy", "snowy-rainy": "Snowy, rainy", sunny: "Sunny", windy: "Windy", "windy-variant": "Windy" };
// 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 c.state === "unknown" ? "Unknown" : "Unavailable";
const a = c.a || {};
if (domain.value === "climate") return `${a.current_temperature !== undefined ? `${a.current_temperature}° · ` : ""}${c.word || capital(c.state)}`;
if (domain.value === "weather") return `${c.word || WEATHER_WORDS[c.state] || capital(c.state)}${a.temperature !== undefined ? ` · ${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}`;
if (domain.value === "sensor") return `${c.state}${unit.value ? ` ${unit.value}` : ""}`;
if (domain.value === "timer") return c.word || capital(c.state);
return c.word || capital(c.state);
});
const bigValue = computed(() => (current.value && !gone.value ? 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;
if (!c || gone.value) return 0;
const a = c.a || {};
if (domain.value === "light") return c.state === "on" ? (a.brightness !== undefined ? Math.round((a.brightness / 255) * 100) : 100) : 0;
if (domain.value === "fan") return c.state === "on" ? (a.percentage ?? 100) : 0;
if (domain.value === "cover") return a.current_position ?? (c.state === "open" ? 100 : 0);
if (domain.value === "media_player") return Math.round((a.volume_level ?? 0) * 100);
if (domain.value === "number" || domain.value === "input_number") {
const value = Number(c.state), min = Number(a.min ?? 0), max = Number(a.max ?? 100);
return Number.isFinite(value) && max > min ? Math.round(((value - min) / (max - min)) * 100) : 0;
}
return 0;
});
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(() => {
const t = current.value?.a?.temperature;
return t !== undefined && t !== null ? `${t}°` : "—";
});
async function onKey(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openTile(props.tile.entity); return; }
const step = ({ ArrowLeft: -1, ArrowRight: 1, ArrowUp: -2, ArrowDown: 2 } as Record<string, number>)[e.key];
@@ -53,33 +102,42 @@ async function onKey(e: KeyboardEvent) {
</svg>
<span v-if="wide" class="lead"><span class="tx"><span class="nm">{{ name }}</span><span class="st">{{ note }}</span></span></span>
</template>
<template v-else-if="display === 'digital' && domain === 'screen'">
<span class="ic mdi">{{ glyph(tileIconCp(tile)) }}</span>
<span class="lead"><span class="big">{{ String(now.getHours()).padStart(2, "0") }}:{{ String(now.getMinutes()).padStart(2, "0") }}</span><span class="nm">{{ name }}</span></span>
</template>
<template v-else-if="wide">
<span class="lead">
<span class="ic mdi">{{ glyph(tileIconCp(tile)) }}</span>
<span class="tx"><span class="nm">{{ name }}</span><span v-if="note" class="st">{{ note }}</span></span>
<span class="ic mdi" :class="{ lit: isOn }">{{ glyph(tileIconCp(tile)) }}</span>
<span class="tx">
<span class="nm">{{ name }}</span>
<span v-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>
</span>
<span v-if="tile.options?.inline === 'slider'" class="mini-slider"></span>
<span v-if="tile.options?.inline === 'slider'" class="mini-slider" :style="sliderStyle"></span>
<span v-if="controls" class="ctl">
<span v-if="controls === 'toggle'" class="tog"></span>
<span v-else-if="controls === 'setpoint'" class="stp"><span class="mdi">{{ key("minus") || "−" }}</span><b>20°</b><span class="mdi">{{ key("plus") || "+" }}</span></span>
<span v-if="controls === 'toggle'" class="tog" :class="{ off: !on }"></span>
<span v-else-if="controls === 'setpoint'" class="stp"><span class="mdi">{{ key("minus") || "−" }}</span><b>{{ setpoint }}</b><span class="mdi">{{ key("plus") || "+" }}</span></span>
<template v-else-if="controls === 'stepper' && domain.endsWith('select')"><span class="key mdi">{{ key("chevron-left") }}</span><span class="key mdi">{{ key("chevron-right") }}</span></template>
<span v-else-if="controls === 'stepper'" class="stp"><span class="mdi">{{ key("minus") || "−" }}</span><b>50</b><span class="mdi">{{ key("plus") || "+" }}</span></span>
<span v-else-if="controls === 'stepper'" class="stp"><span class="mdi">{{ key("minus") || "−" }}</span><b>{{ bigValue }}</b><span class="mdi">{{ key("plus") || "+" }}</span></span>
<template v-else-if="controls === 'mode'"><span class="key mdi">{{ key("power") }}</span><span class="key mdi">{{ key("fire") }}</span><span class="key mdi">{{ key("snowflake") }}</span></template>
<template v-else-if="controls === 'volume'"><span class="range"></span><span class="key mdi">{{ key("volume-high") }}</span></template>
<template v-else-if="controls === 'playback'"><span class="key mdi">{{ key("skip-previous") }}</span><span class="key mdi">{{ key("play") }}</span><span class="key mdi">{{ key("skip-next") }}</span></template>
<template v-else-if="controls === 'volume'"><span class="range" :style="volumeStyle"></span><span class="key mdi">{{ key("volume-high") }}</span></template>
<template v-else-if="controls === 'playback'"><span class="key mdi">{{ key("skip-previous") }}</span><span class="key mdi">{{ key(on ? "pause" : "play") || key("play") }}</span><span class="key mdi">{{ key("skip-next") }}</span></template>
<template v-else-if="controls === 'buttons' && domain === 'cover'"><span class="key mdi">{{ key("arrow-expand-horizontal") }}</span><span class="key mdi">{{ key("stop") }}</span><span class="key mdi">{{ key("arrow-collapse-horizontal") }}</span></template>
<template v-else-if="controls === 'buttons' && domain === 'vacuum'"><span class="key mdi">{{ key("play") }}</span><span class="key mdi">{{ key("stop") }}</span><span class="key mdi">{{ key("home-map-marker") }}</span></template>
<template v-else-if="controls === 'buttons' && domain === 'timer'"><span class="key mdi">{{ key("play") }}</span><span class="key mdi">{{ key("close") }}</span></template>
<span v-else-if="controls === 'run'" class="run">{{ ({ scene: "Activate", script: "Run" } as Record<string, string>)[domain] || "Press" }}</span>
<span v-else class="range"></span>
<span v-else class="range" :style="{ background: `linear-gradient(to right, #2196f3 ${fill}%, #d3e8fb ${fill}%)` }"></span>
</span>
</template>
<template v-else>
<span class="ic mdi">{{ glyph(tileIconCp(tile)) }}</span>
<span class="ic mdi" :class="{ lit: isOn }">{{ glyph(tileIconCp(tile)) }}</span>
<span class="lead">
<span v-if="display === 'watch'" class="big">{{ bigValue }}<small v-if="unit && !gone">{{ unit }}</small></span>
<span class="nm">{{ name }}</span>
<span v-if="note" class="st">{{ note }}</span>
<span v-if="tile.options?.inline === 'slider'" class="mini-slider"></span>
<span v-if="display !== 'watch' && status" class="st" :class="{ off: gone }">{{ status }}</span>
<span v-if="tile.options?.inline === 'slider'" class="mini-slider" :style="sliderStyle"></span>
</span>
</template>
<button v-if="live" type="button" class="remove" title="Remove tile" :aria-label="`Remove ${name}`" @click.stop="removeTile(tile)">✕</button>
+164 -5
View File
@@ -7,6 +7,7 @@ import {
rowStart, SLOTS_PER_PAGE, supportsFirmware as supportsVersion, tileLimit as limitFor,
} from "./model/layout";
import { agoText, BAR_METRICS, clockText, dateText, itemKey, type ItemView, whenBarFontsLoad } from "./model/topbar";
import { versionAtLeast } from "./model/layout";
import type { Capability, EntityAction, HeaderItem, Inventory, Layout, Screen, Tile } from "./types";
export type Inspector =
@@ -15,6 +16,8 @@ export type Inspector =
| { kind: "bar-add" }
| { kind: "inspect"; entity?: string };
export type DragState = { active: boolean; moving: Tile | null; preview: { tile: Tile; slot: number }[] | null };
// What Home Assistant reports for an entity right now: the state, its word and the attributes a card shows.
export type Live = { state: string; word?: string | null; a: Record<string, any> };
export const state = reactive({
inventory: { screens: [], entities: [] } as Inventory,
@@ -50,6 +53,11 @@ export const state = reactive({
overrideFriendly: "",
drag: { active: false, moving: null, preview: null } as DragState,
menuOpen: false,
liveStates: {} as Record<string, Live>,
room: "",
hidePlaced: false,
palette: false,
firmwareJob: null as null | { job: any; logs: string[] },
});
export const currentScreen = computed<Screen | undefined>(() => state.inventory.screens.find((s) => s.id === state.selected));
@@ -157,6 +165,31 @@ export async function loadEntityActions(entity: string) {
}
}
// ---- Live values on the mockup (app 0.2.74): what the screen shows right now ----
let statesFlight = false;
export async function loadStates() {
const entities = [...new Set((state.layout?.tiles || []).map((t) => t.entity).filter((id) => !id.startsWith("screen.")))];
if (!entities.length || statesFlight) return;
statesFlight = true;
try {
for (let i = 0; i < entities.length; i += 60) {
const query = entities.slice(i, i + 60).map((id) => `entity=${encodeURIComponent(id)}`).join("&");
Object.assign(state.liveStates, (await getJson(`states?${query}`)).states || {});
}
} catch {
// The next tick tries again; the mockup keeps the last values.
} finally {
statesFlight = false;
}
}
// The live value, else what the inventory knew when it was fetched, else nothing.
export function liveOf(entity: string): Live | null {
const live = state.liveStates[entity];
if (live) return live;
const known = state.inventory.entities.find((e) => e.id === entity);
return known?.state ? { state: known.state, word: null, a: {} } : null;
}
// ---- Selecting a screen and editing its layout ----
export function markDirty() {
state.dirty = true;
@@ -176,14 +209,16 @@ export function select(id: string | null) {
const screen = state.inventory.screens.find((s) => s.id === id);
if (!screen) { state.layout = null; return; }
// A plain copy: the inventory is reactive, and structuredClone refuses a proxy.
state.layout = JSON.parse(JSON.stringify(screen.layout));
normalize(state.layout);
state.layout.pages = pageCount(entriesOf(state.layout), state.layout.pages);
const layout: Layout = JSON.parse(JSON.stringify(screen.layout));
normalize(layout);
layout.pages = pageCount(entriesOf(layout), layout.pages);
state.layout = layout;
state.insertAt = -1;
state.dirty = false;
state.saved = 0;
loadTopbarPreview(0);
loadCapabilities(state.layout.tiles.map((t) => t.entity));
loadCapabilities(layout.tiles.map((t) => t.entity));
loadStates();
go("");
}
export const liveEntries = () => (state.layout ? entriesOf(state.layout) : []);
@@ -202,7 +237,9 @@ export function placeTile(tile: Tile, target: number) {
if (!state.layout) return false;
loadCapabilities([tile.entity]);
const result = arrange(state.layout.tiles, tile, target);
return result ? commit(result) : false;
const placed = result ? commit(result) : false;
if (placed && !state.liveStates[tile.entity]) loadStates();
return placed;
}
// A click in the picker: the marked empty cell, else the first free cell.
export function addTile(id: string) {
@@ -310,6 +347,120 @@ export async function save() {
}
}
// ---- Identify and the test alert (app 0.2.74): a screen's own show_alert action ----
export const canAlert = (screen: Screen | undefined) =>
Boolean(screen && screen.alert_action && versionAtLeast(screen.firmware, state.inventory.alerts?.min_firmware || "0.2.31"));
export async function identify(screen: Screen) {
try {
await send(`screens/${encodeURIComponent(screen.id)}/identify`, "POST");
toast(`${screen.name} blinks and shows a card for a few seconds.`);
} catch (e: any) {
toast(e.message);
}
}
export async function sendTestAlert(target: string, data: Record<string, unknown>) {
return (await send("alerts/test", "POST", { screen: target, data })) as { sent: number; failed: number; skipped: number; unusable?: string[] };
}
// ---- Copying and sharing a layout (app 0.2.74) ----
const LAYOUT_KEYS = ["title", "tiles", "header", "pages"] as const;
function adopt(source: Partial<Layout>, what: string) {
const layout = state.layout;
if (!layout) return;
const tiles = (Array.isArray(source.tiles) ? source.tiles : [])
.filter((t): t is Tile => Boolean(t && typeof t === "object" && typeof t.entity === "string" && t.entity.includes(".")))
.map((t) => ({ entity: t.entity, name: typeof t.name === "string" ? t.name : "", slot: Number.isInteger(t.slot) ? t.slot : -1,
...(t.options && typeof t.options === "object" ? { options: { ...t.options } } : {}) }) as Tile);
const seen = new Set<string>();
const unique = tiles.filter((t) => !seen.has(t.entity) && seen.add(t.entity));
const kept = unique.slice(0, tileLimit.value);
layout.tiles = kept;
if (source.header && Array.isArray(source.header.items)) layout.header = { items: source.header.items.map((i) => ({ ...i })) };
else delete layout.header;
layout.pages = Number.isInteger(source.pages) ? (source.pages as number) : 1;
normalize(layout);
layout.pages = pageCount(entriesOf(layout), layout.pages);
closeInspector();
markDirty();
loadCapabilities(kept.map((t) => t.entity));
loadStates();
loadTopbarPreview(0);
toast(kept.length < unique.length
? `${what}: ${kept.length} of ${unique.length} tiles fit this screen's firmware. Save & send when it looks right.`
: `${what}. Save & send when it looks right.`);
}
export function copyLayoutFrom(id: string) {
const other = state.inventory.screens.find((s) => s.id === id);
if (!other || !state.layout) return;
adopt(JSON.parse(JSON.stringify(other.layout)), `Layout of ${other.name} copied`);
}
export function layoutJson() {
const layout = state.layout;
if (!layout) return "";
const out: Record<string, unknown> = { esp_screens_layout: 1 };
for (const key of LAYOUT_KEYS) if (layout[key] !== undefined) out[key] = layout[key];
return JSON.stringify(out, null, 2);
}
export function exportLayout() {
const text = layoutJson();
if (!text) return;
const name = `${(currentScreen.value?.name || "screen").toLowerCase().replace(/[^a-z0-9]+/g, "-")}.layout.json`;
const url = URL.createObjectURL(new Blob([text], { type: "application/json" }));
const a = document.createElement("a");
a.href = url; a.download = name; a.click();
setTimeout(() => URL.revokeObjectURL(url), 5000);
copyText(text, undefined, "Layout JSON");
}
export function importLayout(text: string) {
let data: any;
try { data = JSON.parse(text); } catch { toast("That isn't JSON. Export a layout first, or paste one from another ESP Screens."); return; }
if (!data || typeof data !== "object" || !Array.isArray(data.tiles)) { toast("No tiles in this file. Expected a layout exported from ESP Screens."); return; }
adopt(data, "Layout imported");
}
// ---- Updates with content (app 0.2.74): what a screen gets, and how far its update is ----
export function whatsNew(screen: Screen): string[] {
const target = state.inventory.updates?.target;
const sections = (state.inventory.updates as any)?.changelog as { app: string; firmware: string; lines: string[] }[] | undefined;
if (!sections || !target) return [];
const since = screen.firmware;
const lines: string[] = [];
for (const section of sections) {
if (versionAtLeast(section.firmware, target) && section.firmware !== target) continue;
if (since && versionAtLeast(since, section.firmware)) continue;
for (const line of section.lines) if (!lines.includes(line)) lines.push(line);
}
return lines;
}
let firmwareFlight = false;
export async function loadFirmwareJob() {
if (firmwareFlight) return;
firmwareFlight = true;
try {
const data = await getJson("firmware");
state.firmwareJob = { job: data.job, logs: data.logs || [] };
} catch {
// Keep what we have.
} finally {
firmwareFlight = false;
}
}
export const anyUpdating = () => state.inventory.screens.some((s) => s.update?.state === "running") || state.updating.length > 0;
// Progress of a running update, from its phase and the ESPHome stage of the build.
export function updateProgress(screen: Screen): { percent: number; text: string } | null {
const u = screen.update || {};
if (!(u.state === "running" || state.updating.includes(screen.id))) return null;
const stage = state.firmwareJob?.job?.stage as string | undefined;
if (u.phase === "verify") return { percent: 78, text: PHASES.verify };
if (u.phase === "settle") return { percent: 92, text: PHASES.settle };
if (u.phase === "install" || !u.phase) {
if (stage === "upload") return { percent: 66, text: "Writing the firmware over Wi-Fi…" };
if (stage) return { percent: 40, text: "Building the firmware…" };
return { percent: 12, text: PHASES.install };
}
return { percent: 12, text: PHASES[u.phase] || "Starting update…" };
}
// ---- Top bar ----
// Without a stored top bar the screen shows what it always did: the clock of show_clock.
export const topbarItems = (): HeaderItem[] =>
@@ -608,6 +759,14 @@ export function boot() {
state.now = Date.now();
loadTopbarPreview(0);
}, 30000);
// The mockup follows Home Assistant while it is on screen; a running update reports its stage every few seconds.
setInterval(() => {
if (!document.hidden && state.layout && state.tab === "layout" && route.value === "") loadStates();
}, 8000);
setInterval(() => {
if (!document.hidden && anyUpdating()) loadFirmwareJob();
else if (state.firmwareJob && !anyUpdating()) state.firmwareJob = null;
}, 3000);
document.addEventListener("visibilitychange", async () => {
if (document.hidden) return;
lastFull = Date.now();
+51
View File
@@ -160,6 +160,12 @@ a:hover { text-decoration: underline; }
.tile .ctl { flex: none; display: flex; align-items: center; gap: 4px; color: #1b1b1b; }
.tile .tog { width: 30px; height: 17px; border-radius: 9px; background: #2196f3; position: relative; }
.tile .tog::after { content: ""; position: absolute; top: 2px; right: 2px; width: 13px; height: 13px; border-radius: 50%; background: #fff; }
.tile .tog.off { background: #c9ccd1; }
.tile .tog.off::after { right: auto; left: 2px; }
.tile .ic.lit { color: #d9930f; }
.tile .big { font-size: 19px; font-weight: 600; letter-spacing: -0.02em; line-height: 1.05; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tile .big small { display: inline; font-size: 10.5px; font-weight: 500; color: #5a5f66; margin-left: 2px; }
.device.cyd .tile .big { font-size: 16px; }
.tile .range { width: 56px; height: 14px; border-radius: 7px; background: linear-gradient(to right, #2196f3 35%, #d3e8fb 35%); }
.tile .key { height: 20px; min-width: 22px; padding: 0 5px; border-radius: 6px; background: #eceef1; display: grid; place-items: center; font-size: 12px; }
.tile .stp { display: flex; align-items: center; gap: 5px; height: 20px; padding: 0 7px; border-radius: 10px; background: #eceef1; font-size: 11px; font-weight: 600; }
@@ -423,3 +429,48 @@ td code { white-space: nowrap; }
.panel { padding: 16px; }
.alert-hero { grid-template-columns: 1fr; }
}
/* Updates with content: progress and what's new */
.progress { position: relative; height: 6px; border-radius: 3px; background: var(--seg); overflow: hidden; }
.progress i { position: absolute; inset: 0 auto 0 0; background: var(--accent); border-radius: 3px; transition: width 0.4s; }
.progress-text { display: flex; justify-content: space-between; gap: 8px; font-size: 11px; color: var(--muted); }
.progress-text span:last-child { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; text-align: right; }
.whatsnew summary { cursor: pointer; font-size: 11.5px; color: var(--accent); font-weight: 500; list-style: none; }
.whatsnew summary::-webkit-details-marker { display: none; }
.whatsnew ul { margin: 6px 0 0; padding-left: 14px; display: grid; gap: 4px; font-size: 11px; color: var(--ink-2); }
.whatsnew ul li { line-height: 1.35; }
.log-lines { max-height: 160px; font-size: 11px; }
/* Command palette */
.palette-backdrop { position: fixed; inset: 0; background: rgba(10, 12, 18, 0.28); z-index: 70; display: grid; align-items: start; justify-items: center; padding: 12vh 16px 16px; }
.palette { width: min(640px, 100%); background: var(--surface); border: 1px solid var(--line); border-radius: 14px; box-shadow: var(--shadow); overflow: hidden; display: flex; flex-direction: column; max-height: 70vh; }
.palette input { border: 0; border-radius: 0; border-bottom: 1px solid var(--line); padding: 14px 16px; font-size: 15px; background: transparent; }
.palette-list { overflow: auto; padding: 6px; display: grid; gap: 2px; }
.palette-group { font-size: 10.5px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); padding: 8px 10px 4px; }
.palette-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px; width: 100%; text-align: left; color: var(--ink); }
.palette-item .mdi, .palette-item .glyph { width: 22px; text-align: center; color: var(--muted); font-size: 16px; flex: none; }
.palette-item .tx { flex: 1; min-width: 0; display: grid; }
.palette-item .tx small { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.palette-item .hint-key { font-size: 10.5px; color: var(--muted); border: 1px solid var(--line); border-radius: 4px; padding: 1px 5px; }
.palette-item.active, .palette-item:hover { background: var(--accent-soft); }
.palette-empty { padding: 18px; color: var(--muted); font-size: 12.5px; text-align: center; }
.side .search-btn { display: flex; align-items: center; gap: 8px; width: 100%; margin: 4px 0 6px; padding: 7px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-2); color: var(--muted); font-size: 12px; text-align: left; }
.side .search-btn kbd { margin-left: auto; font: 10.5px var(--ui-font); border: 1px solid var(--line); border-radius: 4px; padding: 1px 5px; background: var(--surface); }
.side .search-btn:hover { color: var(--ink); border-color: var(--line-strong); }
/* Menu with a second level */
.menu .sub { display: grid; padding: 2px 0 4px 10px; }
.menu .sub button { font-size: 12.5px; padding: 6px 10px; }
.menu .sep { height: 1px; background: var(--line); margin: 4px 6px; }
/* Library extras */
.lib-row { display: flex; gap: 6px; align-items: center; }
.lib-row select { padding: 5px 8px; font-size: 12px; flex: 1; min-width: 0; }
.lib-row .chip-toggle { flex: none; padding: 5px 10px; border-radius: 999px; font-size: 11.5px; font-weight: 500; color: var(--ink-2); background: var(--surface-2); border: 1px solid var(--line); }
.lib-row .chip-toggle[aria-pressed="true"] { background: var(--ink); color: var(--surface); border-color: var(--ink); }
.ent .av.on { background: #fff3d6 !important; color: #b7791f !important; }
.ent .av.gone { background: var(--seg) !important; color: var(--muted) !important; }
/* Try it (alerts) */
.try-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; }
.try-grid .field.wide { grid-column: 1 / -1; }