diff --git a/AGENTS.md b/AGENTS.md index 30e420b..fcbcdfa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,10 @@ and screens for lookups and tests. If that file is missing you are not on his ma - Keep base hardware and UI in `home-like-2432s028.yaml`; personal data belongs in the gitignored local profiles. +- The editor is `web/` (Vue 3 + Vite, TypeScript; app 0.2.74+). `screen_manager/app/static` is its build + output: change `web/src`, run `cd web && npm run build`, and commit both. Keep every URL the page asks for + relative (`api/...`), so it works behind Home Assistant's ingress path. Tests read the source through + `tests/editor_sources.py`; docs/RELEASING.md "Local development" has the dev-server recipe. - Colours live in one table: `components/smart_display/theme.h` (firmware 0.2.54+). Every role has a light and a dark value, board profiles name paints (`styles: paint_card`) instead of writing a colour, and firmware code asks for a role (`theme::color(theme::INK)`). Never write a hex colour in a profile or diff --git a/README.md b/README.md index aed4415..9e2eca1 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ the bell, and a Guition shows who is there with the doorbell camera's picture. A action Home Assistant has for a tile, and Dark mode suits a screen beside the bed. **How.** ESP Screen Manager is an app inside Home Assistant. It flashes a new screen over USB, updates -it over Wi-Fi and sends it your tiles. Changing a screen is pick, drag and **Save & send to screen**: +it over Wi-Fi and sends it your tiles. Changing a screen is pick, drag and **Save & send**: no reflash, no YAML to write, no blueprint, MQTT or token. Brightness, night hours and Dark mode can also be changed on the screen or by an automation, and your own YAML for one screen survives every update. @@ -153,7 +153,7 @@ For Home Assistant OS with Apps/Add-ons on **aarch64 or amd64**: 2. Install **ESP Screen Manager**, start the app, and open **ESP Screens**. ESPHome Device Builder is optional: the ESPHome CLI is already in this app. 3. Connect the screen with a USB data cable to the **Home Assistant machine** - and choose **Settings → New screen**: CYD or Guition, a name, the USB port, and + and choose **New screen** in the sidebar: CYD or Guition, a name, the USB port, and **Install**. If Wi-Fi is missing from the ESPHome `secrets.yaml`, the window asks for it once and ESP Screens only adds the missing lines. The profile with unique API and OTA keys goes into the ESPHome folder; the build @@ -169,7 +169,7 @@ For Home Assistant OS with Apps/Add-ons on **aarch64 or amd64**: shows after installation (copy button). Grant the device permission to perform Home Assistant actions. 5. Select the screen in ESP Screens, choose your tiles, and click - **Save & send to screen**. Then test the physical controls. + **Save & send**. Then test the physical controls.

New screen in ESP Screens: choose the board, give it a name, pick the USB port and install @@ -177,7 +177,7 @@ For Home Assistant OS with Apps/Add-ons on **aarch64 or amd64**:

New screen (step 3) and choosing your tiles (step 5).

-You can install new firmware later from **Settings → Firmware & USB → Wi-Fi / OTA**. +You can install new firmware later from **Firmware & USB → Wi-Fi / OTA** in the sidebar. For an existing screen, always use the existing profile; creating a new installation profile generates new keys. diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 1ef0f6b..75053ad 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -68,7 +68,7 @@ the sidebar panel; here, open Home Assistant yourself. ## Using it -From here it works like the app: **Settings → New screen** installs a screen, you pair it +From here it works like the app: **New screen** installs a screen, you pair it under **Settings → Devices & services** in Home Assistant, and then choose the tiles. See the [installation guide](EASY_SETUP.md). diff --git a/docs/EASY_SETUP.md b/docs/EASY_SETUP.md index aef8110..1eebcdb 100644 --- a/docs/EASY_SETUP.md +++ b/docs/EASY_SETUP.md @@ -4,7 +4,7 @@ A new screen, from USB to everyday use With **ESP Screen Manager**, you choose your tiles in Home Assistant. You search by name, device, room, or entity ID, arrange them in the order you want, and click -**Save & send to screen**. After that, the status stays automatically up to date. +**Save & send**. After that, the status stays automatically up to date. You don't need to **reflash** for different tiles. This works with Home Assistant OS on a 64-bit Raspberry Pi or an amd64 machine, @@ -77,7 +77,7 @@ within reach at all. Then put the firmware on the screen from your own computer: The file holds your Wi-Fi password and the screen's keys: keep it to yourself. Pairing works as in chapter 3, and every later update goes over Wi-Fi, so the cable is only needed once. -For an existing profile, the same file is under **Settings → Firmware & USB**: choose the +For an existing profile, the same file is under **Firmware & USB** (in the sidebar): choose the profile and **Download · flash from your own computer**, then **Build & download**. **CYD:** calibration appears on first boot. Calmly tap the visible crosshair @@ -146,7 +146,7 @@ Adding without a chosen slot fills the first free slot. Fixed slots and empty slots work on the screen from firmware 0.2.26 on; older firmware shifts the tiles up to the first free slot, and the editor notes that below the preview. The preview shows the layout, not live sensor values. -Click **Save & send to screen** to send your changes. +Click **Save & send** to send your changes. - Light, switch, input_boolean, and fan: tap to turn on/off. - Long press a light: brightness, rainbow color, and white temperature, as far as @@ -189,7 +189,7 @@ as soon as the screen comes back. The app must keep running for current tile dat | --- | --- | --- | | Different entities, names, or order | Save in ESP Screens | Wi-Fi, keys, calibration | | New management page/app version | App store → ESP Screen Manager → Update | All layouts in `/data/screens.json` | -| New screen feature/card | The **Update** button on the screen, or **Update automatically every night** under Settings (manually: Settings → Firmware & USB → Wi-Fi / OTA) | Own YAML, keys, and CYD calibration; the app resends tiles | +| New screen feature/card | The **Update** button on the screen, or **Update automatically every night** under Settings (manually: Firmware & USB → Wi-Fi / OTA) | Own YAML, keys, and CYD calibration; the app resends tiles | The device's own YAML references the firmware packages on `main`. On a new build, ESPHome fetches the latest published package and component code. So you don't @@ -264,7 +264,7 @@ HA mechanisms used: [Ingress](https://developers.home-assistant.io/docs/apps/pre Update ESP Screen Manager to 0.1.2 and install the new firmware once via your **existing** ESPHome device → Install → Wirelessly. Keep your own YAML with Wi-Fi and keys. Then open the screen in ESP Screen Manager and expand -**Screen settings**. After adjusting, click **Save & send to screen**. +**Screen settings**. After adjusting, click **Save & send**. After this, changes to these settings don't need a new firmware flash. | Setting | Options | Default | diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 235fc9c..6b46eff 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -20,6 +20,9 @@ 1. Update the source profiles and shared components. Run `python3 tools/generate_packages.py`; never edit `packages/*.yaml` directly. + The editor is the Vue app in `web/`: after a change under `web/src`, run + `cd web && npm ci && npm run build` and commit `screen_manager/app/static` with it + (that folder is the build output; never edit it by hand). 2. Run all Python tests with aiohttp installed, all `tests/*.cpp`, the generator with `--check`, and compile both Easy Setup profiles. Do that sequentially: profiles with the same `DEVICE_NAME` share one build folder, and a @@ -53,6 +56,15 @@ python3 -m venv .venv-portal .venv-portal/bin/python -m unittest discover -s tests ``` +For the editor (`web/`, Vue 3 + Vite + TypeScript): `cd web && npm ci`, then `npm run dev` serves +http://localhost:5173 with hot reload and proxies `/api` to a server on 127.0.0.1:8099 (SCREEN_DEV or a demo +home). `npm run check` type-checks, `npm run build` writes the page into `screen_manager/app/static` +(clearing `assets/` first). Vite names every file after a hash of its content, which is what keeps a browser +from combining an old script with a new page; `server.py` serves `index.html` as built and `/assets/`. +Everything the page asks for is a relative URL (`api/...`, `./assets/...`), so it works under Home +Assistant's ingress path as well as on a bare localhost. The Python tests read the source through +`tests/editor_sources.py`. + A temporary development server supports `SCREEN_DEV=1`, `HA_API` (ending in `/api`), `HA_TOKEN_FILE`, and `SCREEN_DATA`. It only binds on localhost. Never put a token in source code, URLs, or Git. Production uses Supervisor and only accepts @@ -171,7 +183,7 @@ An old editor that omits `icon` keeps the stored choice. `tile_icons.py` is the single source list. `tools/generate_icons.py` writes the glyphs as a YAML anchor into the three MDI fonts of both board profiles and builds -`static/tile-icons.woff` for the editor (with left bearing equal to xMin, otherwise +`web/src/assets/tile-icons.woff` for the editor (with left bearing equal to xMin, otherwise icons sit off-center in the browser); run `generate_packages.py` afterward. The `MDI_GLYPH_*` substitutions are retired: `TILEn_ICON` in manual profiles must come from the set. No changed preferences or keys. diff --git a/screen_manager/CHANGELOG.md b/screen_manager/CHANGELOG.md index 7fd4ed3..c40a8e4 100644 --- a/screen_manager/CHANGELOG.md +++ b/screen_manager/CHANGELOG.md @@ -1,3 +1,15 @@ +## 0.2.73 (firmware 0.2.61) + +A new editor, built as a Vue app, with the same firmware. + +- **One workspace instead of one long page.** Your screens sit in a sidebar on the left, the screen you chose fills the middle, and the entity library sits on the right. The big title, the separate Settings view and the sticky save bar are gone: **Save & send** appears in the head the moment something changed, and says *Saved · sent to screen* when it is done. +- **Tile settings in a drawer.** Tap a tile and its settings slide in from the right; the mockup stays visible, so a wider tile, another background or a direct control shows up on the page while you choose. Tap another tile and the drawer follows. Esc or a tap beside the pages closes it. +- **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. +- **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. + ## 0.2.72 (firmware 0.2.61) Next, Next, Next: the page buttons keep up with your finger. diff --git a/screen_manager/DOCS.md b/screen_manager/DOCS.md index 25fcdf2..6e6759f 100644 --- a/screen_manager/DOCS.md +++ b/screen_manager/DOCS.md @@ -2,7 +2,7 @@ See [the full installation guide](https://github.com/MaxGramser/homeassistant_espscreen/blob/main/docs/EASY_SETUP.md). -Open **Settings → New screen** to install a screen: connect it via USB to the +Open **New screen** in the sidebar to install a screen: connect it via USB to the Home Assistant machine, choose the board, name and USB port, then click **Install**. The profile with unique keys goes into the ESPHome folder of the HA configuration, any missing Wi-Fi goes into `secrets.yaml` (existing secrets are left untouched), and the @@ -10,26 +10,26 @@ build and flash run in the same window; it then shows the API key for pairing. Is the Home Assistant machine out of reach of the screen, such as a server or a virtual machine? Choose **Download** under **Install via**: ESP Screens builds the firmware and you put it on the screen from your own computer with [ESPHome Web](https://web.esphome.io) in -Chrome or Edge. Pairing itself happens in Home Assistant under **Settings → Devices & services** (a button in the window and on the *not yet in Home Assistant* card under My screens). Each screen has its own profile. **Settings → Firmware & USB** is +Chrome or Edge. Pairing itself happens in Home Assistant under **Settings → Devices & services** (a button in the window and on the *not yet in Home Assistant* card under My screens). Each screen has its own profile. **Firmware & USB** in the sidebar is for existing profiles: check, build or reinstall via USB, the IP address (OTA) or Download. Existing ESPHome profiles in the HA config folder are found automatically. -**Settings** (top right) also has the nightly firmware updates, the **Alerts** cheatsheet +**Settings** in the sidebar has the nightly firmware updates, the **Alerts** cheatsheet (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. -At the top of the editor you set the **Top bar** for each screen: the name on the left, up +Tap the bar at the top of any page in the mockup to set the **Top bar** of a screen: the name on the left, up to six items on the right (time, analog clock, date, or an entity with an icon, such as temperature, a door, the alarm, or "last changed"). Drag to reorder, tap to configure; firmware 0.2.32 or newer renders them. After pairing via the HA ESPHome integration, choose the tiles in ESP Screens. -**Tile settings** has click behavior, larger values and mini-sliders. -**Screen settings** has brightness and standby. **Inspector** helps with +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. +**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. -Each screen also has **Override YAML** for a persistent, hardware-specific +The ··· menu of a screen also has **Override YAML** for a persistent, hardware-specific `.local.yaml` file. It is loaded after the shared package and does not replace the managed name, credentials, Wi-Fi, OTA or package connection. diff --git a/screen_manager/app/server.py b/screen_manager/app/server.py index 6ffd61b..8b893be 100644 --- a/screen_manager/app/server.py +++ b/screen_manager/app/server.py @@ -1,7 +1,6 @@ """HA Ingress app. HA writes: text.set_value on discovered inboxes (or the screen_message action on firmware 0.2.33+), each screen's alert actions when an alert event for every screen fires, and one persistent notification when a nightly update stops.""" import asyncio import contextlib -import hashlib import json import logging import os @@ -1495,12 +1494,13 @@ def create_app(manager, development=False): app = web.Application(middlewares=[guard], client_max_size=16*1024) static = Path(__file__).parent / 'static' - # The page asks for its script and styles with a stamp of their content, so a browser that keeps old copies - # anyway (Safari did after an update) never runs an old script against a new page. - stamp = hashlib.sha1(b''.join((static / name).read_bytes() for name in ('app.js', 'style.css'))).hexdigest()[:12] - page = (static / 'index.html').read_text().replace('"static/app.js"', f'"static/app.js?v={stamp}"').replace('"static/style.css"', f'"static/style.css?v={stamp}"') + # The page is the Vite build of web/ (npm run build writes it here): index.html names its script and styles + # by a hash of their content, so a browser that keeps old copies anyway (Safari did after an update) never + # runs an old script against a new page. + page = (static / 'index.html').read_text() async def index(request): - return web.Response(text=page, content_type='text/html') + # SCREEN_DEV reads the file every time, so a fresh `npm run build` shows up without a restart. + return web.Response(text=(static / 'index.html').read_text() if development else page, content_type='text/html') def light_payload(screens=None): """Screens and update status: everything that changes while the page is open.""" if screens is None: @@ -1674,7 +1674,7 @@ def create_app(manager, development=False): app.router.add_get('/api/events', events) app.router.add_put('/api/screens/{inbox}', save) app.router.add_put('/api/screens/{inbox}/settings', change_settings) - app.router.add_static('/static/', static) + app.router.add_static('/assets/', static / 'assets') return app async def main(): diff --git a/screen_manager/app/static/app.js b/screen_manager/app/static/app.js deleted file mode 100644 index 9f9a9d9..0000000 --- a/screen_manager/app/static/app.js +++ /dev/null @@ -1,3131 +0,0 @@ -"use strict"; -const $ = (s) => document.querySelector(s); -let inventory = { screens: [], entities: [] }, - selected = null, - layout = null, - dirty = false, - filter = "", - busy = false, - selectedTile = null; -const node = (tag, text, cls) => { - const n = document.createElement(tag); - if (text !== undefined) n.textContent = text; - if (cls) n.className = cls; - return n; -}; -function toast(message, action) { - const t = $("#toast"); - t.replaceChildren(node("span", message)); - if (action) { - const b = node("button", action.label); - b.type = "button"; - b.onclick = () => { action.run(); t.hidden = true; }; - t.append(b); - } - t.hidden = false; - clearTimeout(toast.timer); - toast.timer = setTimeout(() => (t.hidden = true), action ? 8000 : 5000); -} -async function api(path, options = {}) { - const response = await fetch(`api/${path}`, { - ...options, - headers: { - "Content-Type": "application/json", - "X-Screen-CSRF": inventory.csrf || "", - ...options.headers, - }, - }); - if (!response.ok) { - let message = "That didn't work. Refresh the page and try again."; - try { - message = (await response.json()).error || message; - } catch {} - throw new Error(message); - } - return response; -} -function markDirty() { - dirty = true; - $("#dirty").textContent = "Unsaved changes"; - $("#save-detail").textContent = "Click save to update your screen."; -} -function select(id) { - if ( - id !== selected && - dirty && - !confirm( - "You have unsaved changes. Open a different screen anyway?", - ) - ) - return; - if (id !== selected) { - flushSettings(); - settingEdits = {}; - } - selected = id; - selectedTile = null; - if ($("#tile-sheet").open) $("#tile-sheet").close(); - const screen = inventory.screens.find((s) => s.id === id); - if (!screen) return; - layout = structuredClone(screen.layout); - normalize(); - insertAt = -1; - dirty = false; - $("#title").value = layout.title; - $("#screen-name").textContent = screen.name; - $("#open-override").disabled = !screen.update?.profile; - $("#editor").hidden = false; - $("#empty").hidden = true; - $("#choose").hidden = true; - $("#dirty").textContent = "All saved"; - $("#save-detail").textContent = "Changes apply without reflashing."; - renderScreens(); - renderTopbar(); - renderTiles(); - renderResults(); - renderSettings(); - loadTopbarPreview(0); - loadCapabilities(layout.tiles.map((t) => t.entity)); -} -// What Home Assistant says each entity can do (app 0.2.67): On / off, a small slider, direct controls and the -// graph and forecast displays follow its own action list, so a device that can do more gets more. Unknown (Home -// Assistant not answering) is null, and the sheet then offers what it always did. A choice a tile already has stays -// visible with a warning instead of disappearing. -const capabilities = {}, askedCapabilities = new Set(); -const TOGGLE_BEFORE = ["light", "switch", "input_boolean", "fan", "media_player", "climate"]; -const SLIDER_DOMAINS = ["light", "fan", "cover", "number", "input_number", "media_player"]; -async function loadCapabilities(entities) { - const wanted = [...new Set(entities)].filter((id) => !askedCapabilities.has(id) && !id.startsWith("screen.")); - if (!wanted.length) return; - wanted.forEach((id) => askedCapabilities.add(id)); - try { - for (let i = 0; i < wanted.length; i += 40) { - const query = wanted.slice(i, i + 40).map((id) => `entity=${encodeURIComponent(id)}`).join("&"); - Object.assign(capabilities, (await (await api(`capabilities?${query}`)).json()).capabilities || {}); - } - } catch { - wanted.forEach((id) => askedCapabilities.delete(id)); - return; - } - if (sheetIndex >= 0 && wanted.includes(layout?.tiles[sheetIndex]?.entity)) renderTileSheet(); -} -// A hint under a field sits beside its label, not in it: a click on a label presses the first choice inside. -function withHint(wrap, text, warn = false) { - const group = node("div", undefined, "sheet-hinted"); - group.append(wrap, node("small", text, warn ? "field-hint warn" : "field-hint")); - return group; -} -// ----- Screen settings: the same groups and rows as the settings page on the screen itself ----- -// Every change applies at once, like on the screen; no Save needed. A screen with firmware 0.2.49+ owns its -// settings and ESP Screens changes them through its entities in Home Assistant, so an automation, the page on -// the screen and this panel always show the same value. Older firmware gets them with its layout. -const SETTING_GROUPS = [ - { title: "Brightness", icon: "F0599", rows: [ - { key: "brightness", label: "Brightness", kind: "number", min: 5, max: 100, step: 5, unit: "%" }, - { key: "dark_mode", label: "Dark mode", kind: "toggle" }, - { key: "standby_enabled", label: "Auto standby", kind: "toggle" }, - { key: "standby_seconds", label: "Standby after", kind: "duration", min: 60, max: 86400, needs: "standby_enabled" }, - { key: "standby_brightness", label: "Standby brightness", kind: "number", min: 0, max: 100, step: 5, unit: "%", needs: "standby_enabled", cap: "brightness" }, - ] }, - { title: "Night", icon: "F0594", rows: [ - { key: "night_enabled", label: "Night mode", kind: "toggle" }, - { key: "night_start", label: "Starts", kind: "moment", needs: "night_enabled" }, - { key: "night_end", label: "Ends", kind: "moment", needs: "night_enabled" }, - { key: "night_brightness", label: "Night brightness", kind: "number", min: 0, max: 100, step: 5, unit: "%", needs: "night_enabled", cap: "brightness" }, - ] }, - { title: "Screen", icon: "F0379", rows: [ - { key: "clock_24h", label: "Clock", kind: "choice", options: [[false, "12 hour"], [true, "24 hour"]] }, - { key: "auto_home", label: "Back to page 1", kind: "toggle" }, - { key: "auto_home_seconds", label: "After", kind: "duration", min: 30, max: 3600, needs: "auto_home" }, - { key: "home_on_standby", label: "Also on standby", kind: "toggle" }, - { key: "swipe_pages", label: "Swipe between pages", kind: "toggle" }, - { key: "rotation", label: "Rotation", kind: "choice", options: [[0, "0°"], [90, "90°"], [180, "180°"], [270, "270°"]] }, - ] }, -]; -const SETTING_ROWS = Object.fromEntries(SETTING_GROUPS.flatMap((group) => group.rows.map((row) => [row.key, row]))); -// Changes made here that the screen has not reported back yet: {key: {value, at}}. They win over what -// Home Assistant still shows for a few seconds, so a value never flicks back while it travels. -let settingEdits = {}, settingQueue = {}, settingTarget = null, settingTimer = null, settingFlight = null, settingPanel = { key: "", rows: {} }; -const SETTING_EDIT_MS = 4000; -const settingsView = () => inventory.screens.find((s) => s.id === selected)?.settings; -function settingValues() { - const view = settingsView(), values = { ...(view?.values || {}) }; - for (const [key, edit] of Object.entries(settingEdits)) values[key] = edit.value; - return values; -} -// The same steps as settings_screen.h: seconds low down, quarters of an hour up top; times by the quarter, -// whole hours while held. -const ladderStep = (seconds) => (seconds < 300 ? 30 : seconds < 900 ? 60 : seconds < 3600 ? 300 : seconds < 7200 ? 900 : 1800); -function steppedSetting(row, value, direction, held, values) { - if (row.kind === "moment") { - let next = held && value % 60 ? Math.floor(value / 60) * 60 + (direction > 0 ? 60 : 0) : value + direction * (held ? 60 : 15); - next %= 1440; - return next < 0 ? next + 1440 : next; - } - const step = row.kind === "duration" ? ladderStep(direction < 0 ? value - 1 : value) : row.step; - const max = row.cap ? Math.min(row.max, values[row.cap]) : row.max; - return Math.min(max, Math.max(row.min, value + direction * step)); -} -function durationText(seconds) { - if (seconds < 60) return `${seconds} sec`; - if (seconds < 3600) return `${Math.floor(seconds / 60)} min`; - const hours = Math.floor(seconds / 3600), minutes = Math.floor((seconds % 3600) / 60); - return minutes ? `${hours} h ${String(minutes).padStart(2, "0")}` : `${hours} h`; -} -function momentText(minutes, clock24) { - const hour = Math.floor(minutes / 60), minute = String(minutes % 60).padStart(2, "0"); - return clock24 ? `${String(hour).padStart(2, "0")}:${minute}` : `${hour % 12 || 12}:${minute} ${hour < 12 ? "AM" : "PM"}`; -} -function settingText(row, values) { - const value = values[row.key]; - // Home Assistant has no value while the screen is offline or the entity is off. - if (value === null || value === undefined) return "—"; - if (row.kind === "number") return `${value}${row.unit || ""}`; - if (row.kind === "duration") return durationText(value); - if (row.kind === "moment") return momentText(value, values.clock_24h !== false); - return ""; -} -// A key held down steps again and again, faster after a moment, like the -/+ keys on the screen. -function holdable(button, step) { - let timer = null, repeats = 0, held = false; - const stop = () => { clearTimeout(timer); timer = null; }; - button.addEventListener("pointerdown", (event) => { - if (button.disabled || event.button !== 0) return; - held = false; - repeats = 0; - stop(); - timer = setTimeout(function repeat() { - held = true; - repeats += 1; - step(repeats > 5); - timer = setTimeout(repeat, 180); - }, 450); - }); - for (const type of ["pointerup", "pointercancel", "pointerleave"]) button.addEventListener(type, stop); - button.addEventListener("click", () => { - if (held) { held = false; return; } - step(false); - }); -} -function settingControl(row) { - const refs = { row }; - const line = node("div", undefined, `setting-row setting-${row.kind}`); - line.dataset.setting = row.key; - const label = node("span", row.label, "setting-label"); - label.id = `setting-label-${row.key}`; - const control = node("div", undefined, "setting-control"); - if (row.kind === "toggle") { - const toggle = node("button", undefined, "switch"); - toggle.type = "button"; - toggle.id = `setting-${row.key}`; - toggle.setAttribute("role", "switch"); - toggle.setAttribute("aria-labelledby", label.id); - toggle.onclick = () => setSetting(row.key, !settingValues()[row.key], 150); - // The whole row answers a click, as on the screen. - line.onclick = (event) => { if (event.target === line || event.target === label) toggle.click(); }; - control.append(toggle); - refs.toggle = toggle; - } else if (row.kind === "choice") { - const group = node("div", undefined, "segmented setting-choices"); - group.setAttribute("role", "group"); - group.setAttribute("aria-labelledby", label.id); - refs.chips = row.options.map(([value, text]) => { - const chip = node("button", text); - chip.type = "button"; - chip.onclick = () => setSetting(row.key, value, 150); - group.append(chip); - return [value, chip]; - }); - control.append(group); - } else { - const minus = node("button", undefined, "step"); - const plus = node("button", undefined, "step"); - minus.type = plus.type = "button"; - minus.append(node("span", glyph("F0374"), "mdi")); - plus.append(node("span", glyph("F0415"), "mdi")); - minus.setAttribute("aria-label", `${row.label} lower`); - plus.setAttribute("aria-label", `${row.label} higher`); - const value = node("output", undefined, "setting-value"); - value.setAttribute("aria-labelledby", label.id); - value.id = `setting-${row.key}`; - for (const [button, direction] of [[minus, -1], [plus, 1]]) { - holdable(button, (held) => { - const values = settingValues(); - const next = steppedSetting(row, values[row.key], direction, held, values); - if (next !== values[row.key]) setSetting(row.key, next, 600); - }); - } - control.append(minus, value, plus); - Object.assign(refs, { minus, plus, value }); - } - line.append(label, control); - refs.line = line; - return refs; -} -function renderSettings() { - const container = $("#settings-groups"); - const screen = inventory.screens.find((s) => s.id === selected), view = screen?.settings; - if (!view) { - container.replaceChildren(); - settingPanel = { key: "", rows: {} }; - $("#settings-status").textContent = ""; - return; - } - // Rebuild only when the rows change (another screen, board or firmware); otherwise update in place, so a - // key held down keeps its grip while the values come back. - const shape = `${screen.id}|${view.keys.join(",")}`; - if (settingPanel.key !== shape) { - const rows = {}; - container.replaceChildren(...SETTING_GROUPS.map((group) => { - const shown = group.rows.filter((row) => view.keys.includes(row.key)); - const card = node("section", undefined, "settings-card"); - card.hidden = !shown.length; - const head = node("h4"); - head.append(node("span", glyph(group.icon), "mdi"), node("span", group.title)); - card.append(head); - for (const row of shown) { - rows[row.key] = settingControl(row); - card.append(rows[row.key].line); - } - return card; - })); - settingPanel = { key: shape, rows }; - } - const values = settingValues(); - const offline = view.owner === "screen" && !screen.online; - for (const [key, refs] of Object.entries(settingPanel.rows)) { - const row = refs.row, needs = row.needs ? values[row.needs] : true; - const unavailable = offline || view.unavailable.includes(key); - refs.line.classList.toggle("inactive", !needs || unavailable); - refs.line.title = unavailable && !offline ? "This entity is off in Home Assistant, or the screen is restarting." : ""; - if (refs.toggle) { - refs.toggle.setAttribute("aria-checked", String(Boolean(values[key]))); - refs.toggle.classList.toggle("unknown", values[key] === null || values[key] === undefined); - refs.toggle.disabled = unavailable; - } else if (refs.chips) { - for (const [value, chip] of refs.chips) { - chip.setAttribute("aria-pressed", String(values[key] === value)); - chip.disabled = unavailable; - } - } else { - refs.value.textContent = settingText(row, values); - const lower = steppedSetting(row, values[key], -1, false, values), higher = steppedSetting(row, values[key], 1, false, values); - refs.minus.disabled = unavailable || !needs || (row.kind !== "moment" && lower === values[key]); - refs.plus.disabled = unavailable || !needs || (row.kind !== "moment" && higher === values[key]); - } - } - const pending = Object.keys(settingQueue).length || settingFlight; - $("#settings-status").textContent = offline - ? "This screen is offline. Its settings can change once it's back." - : pending - ? "Saving…" - : view.owner === "screen" - ? "Changes apply on the screen at once, and show up here when they change there." - : "Changes apply at once. Firmware 0.2.49 lets the screen keep them itself."; - $("#general-settings").classList.toggle("offline", offline); -} -function setSetting(key, value, delay) { - // One screen's changes at a time: the ones for the screen shown before go out first. - if (settingTarget && settingTarget !== selected && Object.keys(settingQueue).length) { - flushSettings(); - toast("Still saving the other screen's settings. Try again in a moment."); - return; - } - settingTarget = selected; - const values = settingValues(); - settingEdits[key] = { value, at: Date.now() }; - settingQueue[key] = value; - // A lower brightness pulls both dim levels down with it, as on the screen. - if (key === "brightness") - for (const dim of ["standby_brightness", "night_brightness"]) - if (values[dim] > value) settingEdits[dim] = { value, at: Date.now() }; - renderSettings(); - if (key === "clock_24h") { renderTopbar(); renderBars(); if ($("#topbar-sheet").open) renderTopbarLive(); } - clearTimeout(settingTimer); - settingTimer = setTimeout(flushSettings, delay); -} -async function flushSettings(unloading = false) { - clearTimeout(settingTimer); - if (settingFlight || !Object.keys(settingQueue).length || !settingTarget) return; - const screen = settingTarget, changes = settingQueue; - settingQueue = {}; - const request = api(`screens/${encodeURIComponent(screen)}/settings`, { - method: "PUT", - body: JSON.stringify({ settings: changes }), - keepalive: unloading, - }); - settingFlight = request; - renderSettings(); - try { - const view = await (await request).json(); - const current = inventory.screens.find((s) => s.id === screen); - if (current) current.settings = view; - } catch (e) { - toast(e.message); - // What did not arrive is not kept: the panel shows the screen's own values again. - if (screen === selected) for (const key of Object.keys(changes)) delete settingEdits[key]; - if (screen === selected && changes.brightness !== undefined) for (const dim of ["standby_brightness", "night_brightness"]) delete settingEdits[dim]; - } finally { - settingFlight = null; - if (Object.keys(settingQueue).length) settingTimer = setTimeout(flushSettings, 150); - else settingTarget = null; - if (screen === selected) { settleSettings(); renderSettings(); } - // A value the screen refused or clamped comes back without a live update: look again once edits expire. - setTimeout(() => { if (screen === selected) { settleSettings(); renderSettings(); } }, SETTING_EDIT_MS + 100); - } -} -// Values Home Assistant reports take over again once they match a change made here, or after a few seconds -// (the screen refused or clamped it). -function settleSettings() { - const view = settingsView(); - for (const [key, edit] of Object.entries(settingEdits)) { - if (settingQueue[key] !== undefined || settingFlight) continue; - if ((view && view.values[key] === edit.value) || Date.now() - edit.at > SETTING_EDIT_MS) delete settingEdits[key]; - } -} -const updating = new Set(); -const PHASES = { - install: "Building and installing…", - verify: "Waiting for the screen to come back…", - settle: "Checking that it stays stable…", -}; -async function startUpdate(screen, host) { - updating.add(screen.id); - renderScreens(); - try { - await api(`screens/${encodeURIComponent(screen.id)}/update`, { - method: "POST", - body: JSON.stringify(host ? { host } : {}), - }); - await refresh(); - } catch (e) { - updating.delete(screen.id); - renderScreens(); - toast(e.message); - } -} -function renderUpdate(screen) { - const u = screen.update || {}; - const row = node("div", undefined, "screen-update"); - const running = u.state === "running" || updating.has(screen.id); - if (running) { - row.append( - node("span", undefined, "spin"), - node("small", PHASES[u.phase] || "Starting update…"), - ); - } else if (u.state === "queued") { - row.append(node("small", "Queued for the update")); - } else if (u.available && screen.online) { - row.append(node("span", `Update ${u.target}`, "badge update")); - const button = node("button", "Update", "mini"); - button.type = "button"; - if (u.host && u.profile) { - button.onclick = (e) => { - e.stopPropagation(); - startUpdate(screen); - }; - } else if (!u.profile) { - button.disabled = true; - button.title = "No ESPHome profile found with this device name."; - } else { - button.onclick = (e) => { - e.stopPropagation(); - const form = node("form", undefined, "screen-host"); - const input = node("input"); - input.placeholder = "IP address, e.g. 192.168.1.50"; - input.required = true; - input.pattern = "[A-Za-z0-9][A-Za-z0-9.\\-]*"; - const go = node("button", "Start", "mini"); - const cancel = node("button", "✕", "quiet"); - cancel.type = "button"; - cancel.setAttribute("aria-label", "Cancel"); - cancel.onclick = () => { - form.remove(); - renderScreens(); - }; - form.append(input, go, cancel); - form.onsubmit = (ev) => { - ev.preventDefault(); - form.remove(); - startUpdate(screen, input.value.trim()); - }; - row.replaceChildren( - node("small", "The IP address, once; new firmware reports it itself."), - form, - ); - input.focus(); - }; - } - row.append(button); - } else if (u.result && Date.now() / 1000 - u.result.time < 86400) { - row.append( - node( - "small", - u.result.message, - u.result.state === "failed" ? "failed" : "", - ), - ); - } else return null; - return row; -} -function openIntegrations() { - // Pairing happens in Home Assistant itself. This page lives in HA's ingress iframe, - // so send the top window to Devices & services (same origin); elsewhere open a tab. - const path = "/config/integrations/dashboard"; - try { - window.top.location.assign(path); - } catch { - window.open(path, "_blank"); - } -} -async function copyText(text, element, what = "API key") { - try { - if (!navigator.clipboard || !window.isSecureContext) throw new Error(); - await navigator.clipboard.writeText(text); - toast(`${what} copied.`); - } catch { - if (element) { - const range = document.createRange(); - range.selectNodeContents(element); - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); - } - toast(document.execCommand("copy") ? `${what} copied.` : `${what} is selected. Copy with Ctrl+C or Command+C.`); - } -} -// Profiles that Home Assistant does not list yet: the freshly flashed screen is not lost, it -// still has to be added under Devices & services, outside this page. -function renderPending() { - const box = $("#pending"); - box.replaceChildren(); - for (const p of inventory.pending || []) { - const card = node("div", undefined, "pending"); - card.append( - node("strong", p.friendly), - node("small", p.installed - ? "Installed, but not yet in Home Assistant. That happens outside ESP Screens: 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 - ? "Firmware downloaded, not yet in Home Assistant. Put it on the screen with ESPHome Web, then 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." - : `Not yet in Home Assistant. Already flashed? Add the ESPHome device under Settings → Devices & services, then allow the Home Assistant actions under Configure. Not flashed yet? Settings → Firmware & USB → ${p.file}.`), - ); - const actions = node("div", undefined, "pending-actions"); - const go = node("button", "Open Devices & services", "mini"); - go.type = "button"; - go.onclick = openIntegrations; - actions.append(go); - if (p.api_key) { - const copy = node("button", "Copy API key", "mini quiet"); - copy.type = "button"; - copy.onclick = () => copyText(p.api_key); - actions.append(copy); - } - card.append(actions); - box.append(card); - } -} -function renderScreens() { - renderPending(); - // ESP Screens opens without a chosen screen (app 0.2.65+): the owner picks one. Until then the right side asks - // for that, or offers the install while there are no screens yet. - if (!selected) { - $("#choose").hidden = !inventory.screens.length; - $("#empty").hidden = !!inventory.screens.length; - } - // Keep an open address form alive across the periodic refresh. - if ($("#screens .screen-host")) { - renderUpdates(); - return; - } - $("#screens").replaceChildren(); - for (const screen of inventory.screens) { - if (screen.update?.state === "running") updating.delete(screen.id); - const item = node( - "div", - undefined, - `screen ${screen.id === selected ? "selected" : ""}`, - ); - const b = node("button", undefined, "screen-main"); - b.type = "button"; - const meta = node("small"); - meta.append( - node("span", screen.online ? "●" : "○", `dot ${screen.online ? "online" : ""}`), - ` ${screen.online ? "Online" : "Offline"}${screen.area ? " · " + screen.area : ""} · firmware ${screen.firmware || "unknown"}`, - ); - b.append(node("strong", screen.name), meta); - b.onclick = () => select(screen.id); - item.append(b); - const override = node("button", "Override YAML", "mini quiet"); - override.type = "button"; - override.disabled = !screen.update?.profile; - override.title = screen.update?.profile ? "Hardware-specific YAML for this screen" : "No ESPHome profile found"; - override.onclick = (event) => { - event.stopPropagation(); - openOverride(screen.update?.profile, screen.name); - }; - const overrideRow = node("div", undefined, "screen-override"); - overrideRow.append(override); - item.append(overrideRow); - const update = renderUpdate(screen); - if (update) item.append(update); - $("#screens").append(item); - } - const screen = inventory.screens.find((s) => s.id === selected); - if (screen) { - $("#delivery").textContent = screen.online - ? `${screen.delivery} · ${screen.status}` - : "Offline · changes are saved"; - $("#delivery").classList.toggle("online", screen.online); - } - renderUpdates(); -} - -// ----- Per-screen local YAML overrides ----- -const OVERRIDE_EXAMPLE = `# Hardware-specific changes for this screen. -# This file is kept when the shared firmware package updates. -# Do not add esphome:, api:, ota:, wifi: or packages: here. - -# Example: a CYD with the ST7789V display controller. -# "!extend" changes the display the shared package already defines; -# a bare id would add a second, incomplete display and fail the build. -display: - - id: !extend my_display - model: ST7789V -`; -const overrideEditor = $("#override-editor"), overrideGutter = $("#override-gutter"); -let overrideProfile = null; -function updateOverrideEditor() { - if (!overrideEditor || !overrideGutter) return; - const lines = overrideEditor.value.split("\n").length; - overrideGutter.textContent = Array.from({ length: lines }, (_, i) => i + 1).join("\n"); - $("#override-count").textContent = `${overrideEditor.value.length} characters · ${lines} line${lines === 1 ? "" : "s"}`; -} -function setOverrideStatus(message, kind = "") { - const status = $("#override-status"); - status.textContent = message; - status.className = `override-status ${kind}`; -} -function setOverrideContent(content) { - overrideEditor.value = content || ""; - updateOverrideEditor(); - overrideEditor.scrollTop = 0; - overrideGutter.scrollTop = 0; -} -async function openOverride(profile, friendly = "") { - if (!profile) { - toast("No ESPHome profile was found for this screen."); - return; - } - overrideProfile = profile; - $("#override-title").textContent = `Override YAML${friendly ? ` · ${friendly}` : ""}`; - setOverrideStatus("Loading…"); - $("#override-dialog").showModal(); - try { - const data = await (await api(`firmware/profiles/${encodeURIComponent(profile)}/override`)).json(); - $("#override-file").textContent = data.override_file; - $("#override-state").textContent = data.attached ? "Active" : "Ready to attach"; - $("#override-state").className = `badge${data.attached ? " online" : ""}`; - setOverrideContent(data.exists && data.content !== "{}\n" ? data.content : ""); - setOverrideStatus(data.attached ? "Changes here apply on the next build." : "Save once to attach this file to the profile."); - } catch (error) { - setOverrideStatus(error.message, "error"); - } -} -function overridePayload() { - return { content: overrideEditor.value }; -} -async function saveOverride(runCheck = false) { - if (!overrideProfile) return; - const save = $("#override-save"), check = $("#override-check"); - save.disabled = true; check.disabled = true; - setOverrideStatus("Checking and saving…"); - try { - const response = await api(`firmware/profiles/${encodeURIComponent(overrideProfile)}/override`, { - method: "PUT", body: JSON.stringify(overridePayload()), - }); - const data = await response.json(); - $("#override-file").textContent = data.override_file; - $("#override-state").textContent = "Active"; - $("#override-state").className = "badge online"; - if (!runCheck) { - setOverrideStatus("Saved. The override is kept during firmware updates.", "ok"); - toast("Override YAML saved."); - return; - } - setOverrideStatus("Saved. ESPHome is checking the complete profile…"); - const job = await (await api("firmware/jobs", { - method: "POST", body: JSON.stringify({ file: overrideProfile, action: "validate" }), - })).json(); - pollOverrideCheck(job); - } catch (error) { - setOverrideStatus(error.message, "error"); - } finally { - save.disabled = false; check.disabled = false; - } -} -function pollOverrideCheck(job) { - const started = Date.now(), profile = overrideProfile; - const poll = async () => { - if (overrideProfile !== profile) return; // the dialog closed or shows another screen - try { - const data = await (await api("firmware")).json(); - const current = data.job; - if (current && current.file === profile && current.state === "running") { - setOverrideStatus(`ESPHome is checking the profile… ${current.stage || ""}`.trim()); - } else if (current && current.file === profile && current.state === "success") { - setOverrideStatus("The complete profile is valid. Safe to build and install.", "ok"); - return; - } else if (current && current.file === profile && current.state === "failed") { - const error = (data.logs || []).filter((line) => /error|failed/i.test(line)).pop(); - setOverrideStatus(error || "ESPHome rejected the complete profile. See Firmware & USB for the full log.", "error"); - return; - } - if (Date.now() - started < 7200000) setTimeout(poll, 1200); - } catch (error) { - setOverrideStatus(error.message, "error"); - } - }; - poll(); -} -$("#open-override").onclick = () => { - const screen = inventory.screens.find((entry) => entry.id === selected); - openOverride(screen?.update?.profile, screen?.name); -}; -$("#close-override").onclick = () => $("#override-dialog").close(); -$("#override-dialog").addEventListener("close", () => { overrideProfile = null; }); -$("#override-save").onclick = () => saveOverride(false); -$("#override-check").onclick = () => saveOverride(true); -$("#override-example").onclick = () => { - if (!overrideEditor.value.trim() || confirm("Replace the current text with the example?")) { - setOverrideContent(OVERRIDE_EXAMPLE); - setOverrideStatus("Example loaded. Save it when you are ready."); - overrideEditor.focus(); - } -}; -$("#override-empty").onclick = () => { - if (!overrideEditor.value.trim() || confirm("Clear the local override?")) { - setOverrideContent(""); - setOverrideStatus("The override will be empty after you save."); - overrideEditor.focus(); - } -}; -overrideEditor.addEventListener("input", updateOverrideEditor); -overrideEditor.addEventListener("scroll", () => { overrideGutter.scrollTop = overrideEditor.scrollTop; }); -overrideEditor.addEventListener("keydown", (event) => { - if (event.key === "Tab") { - event.preventDefault(); - const start = overrideEditor.selectionStart, end = overrideEditor.selectionEnd; - overrideEditor.setRangeText(" ", start, end, "end"); - updateOverrideEditor(); - } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") { - event.preventDefault(); - saveOverride(false); - } -}); -updateOverrideEditor(); -function renderUpdates() { - const u = inventory.updates; - $("#updates").hidden = !u; - if (!u) return; - const outdated = inventory.screens.filter((s) => s.update?.available).length; - $("#updates-hint").textContent = u.busy - ? `Updating to firmware ${u.target}…` - : !inventory.screens.length - ? `No screen paired yet. New screens get firmware ${u.target}.` - : outdated - ? `Firmware ${u.target} is available for ${outdated} screen${outdated === 1 ? "" : "s"}.` - : `All screens have firmware ${u.target}.`; - $("#update-all").hidden = !u.pending || !!u.busy || u.pending < 2; - $("#update-all").textContent = `Update all ${u.pending} screens`; - if (document.activeElement !== $("#auto-update")) - $("#auto-update").checked = u.auto; -} -$("#update-all").onclick = async () => { - try { - await api("updates/run", { method: "POST" }); - await refresh(); - } catch (e) { - toast(e.message); - } -}; -// ----- Settings: a view of its own next to My screens; #settings keeps it open across a reload ----- -function showView() { - const settings = location.hash === "#settings"; - $("#home-view").hidden = settings; - $("#settings-view").hidden = !settings; - $("#open-settings-label").textContent = settings ? "My screens" : "Settings"; - $("#open-settings-icon").textContent = settings ? "▦" : "⚙"; - $("#open-settings-hint").textContent = settings ? "Tiles and top bar" : "New screen, updates, alerts, Claude"; - $("#open-settings").setAttribute("aria-expanded", String(settings)); - if (settings) { - renderUpdates(); - renderClaude(); - } - window.scrollTo(0, 0); -} -$("#open-settings").onclick = () => { - location.hash = location.hash === "#settings" ? "" : "#settings"; -}; -$("#close-settings").onclick = () => { - location.hash = ""; -}; -window.addEventListener("hashchange", showView); -showView(); -function renderClaude() { - const skill = inventory.claude_skill, button = $("#claude-install"), status = $("#claude-status"); - button.disabled = !skill; - if (!skill) { - status.textContent = "Loading…"; - return; - } - $("#claude-path").textContent = skill.path; - button.textContent = !skill.installed ? "Install for Claude Code" : skill.current ? "Install again" : "Update the skill"; - button.classList.toggle("again", skill.installed && skill.current); - status.textContent = !skill.installed ? "Not installed" : skill.current ? "● Installed" : "Installed · a newer version is ready"; - status.className = !skill.installed ? "hint" : skill.current ? "badge online" : "badge update"; -} -$("#claude-install").onclick = async () => { - const button = $("#claude-install"); - button.disabled = true; - try { - inventory.claude_skill = await (await api("claude-skill", { method: "POST" })).json(); - toast(inventory.claude_skill.restart - ? "Skill installed. Restart Claude Code once so it finds the new skills folder." - : "Skill installed. Claude Code picks it up right away."); - } catch (e) { - toast(e.message); - } finally { - renderClaude(); - } -}; -$("#auto-update").onchange = async () => { - try { - await api("updates", { - method: "PUT", - body: JSON.stringify({ auto: $("#auto-update").checked }), - }); - toast( - $("#auto-update").checked - ? "Screens will now update automatically at night." - : "Automatic updates are off.", - ); - } catch (e) { - $("#auto-update").checked = !$("#auto-update").checked; - toast(e.message); - } -}; -const domains = { - light: ["Light", "☀", "#ad7600", "#fff3d3"], - climate: ["Climate", "❄", "#c86620", "#ffebdc"], - vacuum: ["Vacuum", "◉", "#008577", "#def3ed"], - fan: ["Fan", "✣", "#008aab", "#def5fa"], - cover: ["Cover", "▤", "#8053af", "#eee5f8"], - media_player: ["Media", "▶", "#007cad", "#def2fc"], - sensor: ["Sensor", "⌁", "#3476b1", "#e5effa"], - binary_sensor: ["Status", "◈", "#ad7600", "#fff3d3"], - switch: ["Switch", "⏻", "#ad7600", "#fff3d3"], - input_boolean: ["Switch", "⏻", "#ad7600", "#fff3d3"], - scene: ["Scene", "✦", "#8053af", "#eee5f8"], - script: ["Script", "▷", "#8053af", "#eee5f8"], - weather: ["Weather", "☁", "#007cad", "#def2fc"], - number: ["Value", "±", "#008577", "#def3ed"], - input_number: ["Value", "±", "#008577", "#def3ed"], - select: ["Select", "≡", "#5862af", "#eaecfa"], - input_select: ["Select", "≡", "#5862af", "#eaecfa"], - button: ["Action", "↗", "#5862af", "#eaecfa"], - input_button: ["Action", "↗", "#5862af", "#eaecfa"], - screen: ["Clock", "◷", "#25282c", "#e9ecf1"], - sun: ["Sun", "☼", "#c86620", "#ffebdc"], - timer: ["Timer", "⏱", "#008577", "#def3ed"], - person: ["Person", "☺", "#2f7d32", "#e1f2e2"], - camera: ["Camera", "◧", "#3d4a57", "#e6ebf0"], - image: ["Image", "◧", "#3d4a57", "#e6ebf0"], -}; -const displayNames = { standard: "standard", watch: "large value", forecast: "weather forecast", graph: "graph", digital: "digital clock", analog: "analog clock", sunpath: "sun path" }; -// Same rule as the add-on: only a wide card in the standard layout shows direct controls; -// without a choice the domain's first control set applies. -function effectiveControls(tile) { - const domain = tile.entity.split(".")[0], catalogue = inventory.controls?.[domain], o = tile.options || {}; - if (!catalogue || o.size !== "wide" || (o.display || "standard") !== "standard" || o.inline === "slider") return null; - const choice = o.controls ?? catalogue.default; - return choice === "none" ? null : choice; -} -function controlsLabel(tile) { - const key = effectiveControls(tile); - if (!key) return "none"; - return inventory.controls?.[tile.entity.split(".")[0]]?.choices.find((c) => c.key === key)?.label.toLocaleLowerCase() || key; -} -// Miniature of the control set on the mockup card: the same shapes the screen draws. -function controlsPreview(tile) { - const key = effectiveControls(tile), domain = tile.entity.split(".")[0], cp = inventory.icons?.controls || {}; - if (!key) return null; - const box = node("span", undefined, "preview-controls"); - const buttons = (...names) => { for (const name of names) box.append(node("span", cp[name] ? glyph(cp[name]) : "", "preview-key mdi")); }; - const stepper = (text) => { const pill = node("span", undefined, "preview-stepper"); pill.append(node("span", cp.minus ? glyph(cp.minus) : "−", "mdi"), node("b", text), node("span", cp.plus ? glyph(cp.plus) : "+", "mdi")); box.append(pill); }; - const slider = () => box.append(node("span", "", "preview-range")); - if (key === "toggle") box.append(node("span", "", "preview-toggle")); - else if (key === "setpoint") stepper("20°"); - else if (key === "stepper") domain.endsWith("select") ? buttons("chevron-left", "chevron-right") : stepper("50"); - else if (key === "mode") buttons("power", "fire", "snowflake"); - else if (key === "volume") { slider(); buttons("volume-high"); } - else if (key === "playback") buttons("skip-previous", "play", "skip-next"); - else if (key === "buttons" && domain === "cover") buttons("arrow-expand-horizontal", "stop", "arrow-collapse-horizontal"); - else if (key === "buttons" && domain === "vacuum") buttons("play", "stop", "home-map-marker"); - else if (key === "buttons" && domain === "timer") buttons("play", "close"); - else if (key === "run") box.append(node("b", { scene: "Activate", script: "Run" }[domain] || "Press", "preview-run")); - else slider(); - return box; -} -// New tiles start with the card that shows the entity best. -function defaultOptions(id) { - const domain = id.split(".")[0]; - if (domain === "sun") return { options: { display: "sunpath", size: "wide" } }; - if (domain === "weather") return { options: { display: "forecast", size: "wide" } }; - if (domain === "screen") return { options: { display: "digital", size: "wide" } }; - return {}; -} -// ---- Grid positions ---- -// Two columns, three rows per page, at most eight pages. A tile's `slot` is its absolute -// cell (page * 6 + row * 2 + column); a wide tile starts in the left column and also covers -// the cell to its right. Empty cells are allowed and stay exactly where they are. -const SLOTS_PER_PAGE = 6, MAX_PAGES = 8, MAX_SLOTS = MAX_PAGES * SLOTS_PER_PAGE; -const isWide = (tile) => tile.options?.size === "wide"; -const cellsOf = (slot, wide) => (wide ? [slot, slot + 1] : [slot]); -const rowStart = (slot) => slot - (slot % 2); -const pageOf = (slot) => Math.floor(slot / SLOTS_PER_PAGE); -const liveEntries = () => layout.tiles.map((tile) => ({ tile, slot: tile.slot })); -// In-order packing: the rule before positions existed, and what firmware below 0.2.26 still draws. -function packSlots(tiles) { - let position = 0; - return tiles.map((tile) => { - const wide = isWide(tile); - if (wide && position % 2 === 1) position++; - const slot = position; - position += wide ? 2 : 1; - return slot; - }); -} -function hasGaps(tiles) { - const packed = packSlots(tiles); - return tiles.some((tile, i) => tile.slot !== packed[i]); -} -// Every tile gets a position (older layouts pack in order), the list stays in reading -// order and the open settings sheet keeps following its tile. -function normalize() { - if (layout.tiles.some((t) => !Number.isInteger(t.slot))) { const packed = packSlots(layout.tiles); layout.tiles.forEach((t, i) => (t.slot = packed[i])); } - layout.tiles.sort((a, b) => a.slot - b.slot); - if (sheetIndex >= 0) sheetIndex = layout.tiles.findIndex((t) => t.entity === selectedTile); -} -function occupied(entries) { - const taken = new Set(); - for (const { tile, slot } of entries) for (const cell of cellsOf(slot, isWide(tile))) taken.add(cell); - return taken; -} -const fits = (taken, slot, wide) => Number.isInteger(slot) && slot >= 0 && slot + (wide ? 1 : 0) < MAX_SLOTS && !(wide && slot % 2) && cellsOf(slot, wide).every((c) => !taken.has(c)); -function firstFree(taken, wide) { - for (let slot = 0; slot < MAX_SLOTS; slot++) if (fits(taken, slot, wide)) return slot; - return -1; -} -// The free position closest to `origin`; on a tie the later one, so a nudged tile moves down, not up. -function nearestFree(taken, wide, origin) { - let best = -1; - for (let slot = 0; slot < MAX_SLOTS; slot++) if (fits(taken, slot, wide) && (best < 0 || Math.abs(slot - origin) <= Math.abs(best - origin))) best = slot; - return best; -} -// The arrangement after putting `moving` (a tile on the grid, or a new one) at `target`: -// it lands exactly there; tiles in its way take the cells it left (a swap) or else the -// nearest free cell; everything else stays put. Null when the target is off the grid. -function arrange(tiles, moving, target) { - const wide = isWide(moving); - if (wide) target = rowStart(target); - if (!fits(new Set(), target, wide)) return null; - const footprint = cellsOf(target, wide); - const vacated = tiles.includes(moving) ? cellsOf(moving.slot, wide) : []; - const result = [{ tile: moving, slot: target }], displaced = []; - for (const tile of tiles) { - if (tile === moving) continue; - if (cellsOf(tile.slot, isWide(tile)).some((c) => footprint.includes(c))) displaced.push(tile); - else result.push({ tile, slot: tile.slot }); - } - for (const tile of displaced) { - const w = isWide(tile), taken = occupied(result); - let slot = vacated.map((c) => (w ? rowStart(c) : c)).find((c) => fits(taken, c, w)); - if (slot === undefined) slot = nearestFree(taken, w, tile.slot); - if (slot < 0) return null; - result.push({ tile, slot }); - } - return result.sort((a, b) => a.slot - b.slot); -} -// Apply an arrangement; a new tile joins the layout. True when anything changed. -function commit(result) { - const before = layout.tiles.map((t) => `${t.entity}@${t.slot}`).join(); - for (const { tile, slot } of result) { tile.slot = slot; if (!layout.tiles.includes(tile)) layout.tiles.push(tile); } - normalize(); - const changed = layout.tiles.map((t) => `${t.entity}@${t.slot}`).join() !== before; - if (changed) { markDirty(); renderTiles(); renderResults(); } - return changed; -} -function placeTile(tile, target) { - loadCapabilities([tile.entity]); - const result = arrange(layout.tiles, tile, target); - return result ? commit(result) : false; -} -// Pages the tiles need, or more when the user keeps empty pages on purpose (`layout.pages`). -function pageCount(entries) { - const last = Math.max(0, ...entries.map(({ tile, slot }) => slot + (isWide(tile) ? 2 : 1))); - return Math.min(MAX_PAGES, Math.max(1, Math.ceil(last / SLOTS_PER_PAGE), layout.pages || 1)); -} -function addPage() { - layout.pages = Math.min(MAX_PAGES, pageCount(liveEntries()) + 1); - markDirty(); - renderTiles(); - document.querySelector("#layout-preview .screen-preview:last-child")?.scrollIntoView({ behavior: "smooth", block: "nearest" }); -} -// An empty page goes; the pages after it move up. -function removePage(page) { - for (const tile of layout.tiles) if (pageOf(tile.slot) > page) tile.slot -= SLOTS_PER_PAGE; - layout.pages = Math.max(1, pageCount(liveEntries()) - 1); - markDirty(); - renderTiles(); -} -function focusSlot(slot) { - document.querySelector(`#layout-preview [data-slot="${slot}"]`)?.focus(); -} -function supportsFirmware(major, minor, patch) { - const version = inventory.screens.find((s) => s.id === selected)?.firmware || ""; - const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version); - if (!match) return false; - const [a, b, c] = match.slice(1).map(Number); - return a > major || (a === major && (b > minor || (b === minor && c >= patch))); -} -function domainBadge(id) { - const [title, symbol, color, background] = domains[id.split(".")[0]] || ["Entity", "◇", "#637184", "#edf0f4"]; - const badge = node("span", symbol, "domain-icon"); - badge.title = title; - badge.setAttribute("aria-label", title); - badge.style.color = color; - badge.style.background = background; - return badge; -} -// The screen draws Material Design Icons; static/tile-icons.woff holds the same glyphs. -const glyph = (cp) => String.fromCodePoint(parseInt(cp, 16)); -let iconIndex = { source: null, byName: {} }; -function iconNamed(name) { - if (iconIndex.source !== inventory.icons) - iconIndex = { source: inventory.icons, byName: Object.fromEntries((inventory.icons?.groups || []).flatMap((g) => g.icons.map((i) => [i.name, i]))) }; - return iconIndex.byName[name]; -} -// What the firmware draws without a choice: Home Assistant's own icon, else the domain icon. -function automaticIcon(id) { - const icons = inventory.icons, entity = inventory.entities.find((e) => e.id === id), domain = id.split(".")[0]; - if (icons.builtin?.[id]) return icons.builtin[id]; - if (entity?.icon) return entity.icon; - if (domain === "weather") return icons.weather[entity?.state] || icons.weather.partlycloudy; - if (domain === "sun") return icons.sun[entity?.state] || icons.sun.below_horizon; - return icons.defaults[domain] || icons.fallback; -} -function tileBadge(tile) { - const badge = domainBadge(tile.entity); - if (!inventory.icons) return badge; - badge.textContent = glyph(iconNamed(tile.options?.icon)?.cp || automaticIcon(tile.entity)); - badge.classList.add("mdi"); - return badge; -} -function tileLimit() { - const version=inventory.screens.find(s=>s.id===selected)?.firmware || ""; - const match=/^(\d+)\.(\d+)\.(\d+)$/.exec(version); - if(!match)return 10; - return Number(match[1])>0 || Number(match[2])>2 || Number(match[2])===2 && Number(match[3])>=7 ? 20 : 10; -} -function entityName(id) { - return inventory.entities.find((e) => e.id === id)?.name || inventory.builtin?.find((e) => e.id === id)?.name || id; -} -let insertAt = -1; // empty cell chosen as the place for the next tile from the picker -function renderPreview(preview) { - const root = $("#layout-preview"); - root.replaceChildren(); - // While dragging, the mockup already shows the arrangement after the drop. - const entries = preview?.result || liveEntries(); - const moving = preview?.moving || null; - const bySlot = new Map(entries.map((e) => [e.slot, e])); - const covered = new Set(entries.filter((e) => isWide(e.tile)).map((e) => e.slot + 1)); - const pages = pageCount(entries); - // While dragging, one more page waits below the last one. - const shown = drag.active && pages < MAX_PAGES ? pages + 1 : pages; - for (let page = 0; page < shown; page++) { - const frame = node("section", undefined, "screen-preview"); - const heading = node("div", undefined, "preview-heading"); - heading.append(node("small", `Page ${page + 1}`)); - if (page >= pages) { - frame.classList.add("new-page"); - heading.replaceChildren(node("small", `Page ${page + 1} · drag here for a new page`)); - } else if (pages > 1 && !entries.some((e) => pageOf(e.slot) === page)) { - heading.append(node("small", "empty", "page-note")); - const drop = node("button", "Remove page", "mini"); - drop.type = "button"; - drop.title = "The pages after this one shift up one slot"; - drop.onclick = () => removePage(page); - heading.append(drop); - } - frame.append(heading); - if (page < pages) frame.append(topbarBar()); - const grid = node("div", undefined, "preview-grid"); - for (let cell = 0; cell < SLOTS_PER_PAGE; cell++) { - const slot = page * SLOTS_PER_PAGE + cell; - if (covered.has(slot)) continue; - const entry = bySlot.get(slot); - grid.append(entry ? tileCard(entry.tile, slot, entry.tile === moving) : emptyCell(slot)); - } - frame.append(grid); - root.append(frame); - } - fitTopbars(); -} -// A card on the mockup. A placeholder is the tile being dragged, drawn where it will land. -function tileCard(tile, slot, placeholder) { - const index = layout.tiles.indexOf(tile); - const card = node("div", undefined, "preview-tile"); - card.dataset.slot = slot; - const name = tile.name || entityName(tile.entity); - const background = inventory.backgrounds?.[tile.options?.background]?.color; - if (background) card.style.backgroundColor = background; - // "None": no card on the screen; the mockup keeps a dashed outline. - if (tile.options?.background === "none") card.classList.add("bare"); - if (isWide(tile)) card.classList.add("wide"); - card.append(tileBadge(tile), node("strong", name)); - if (tile.options?.inline === "slider") card.append(node("span", "", "preview-slider")); - const controls = controlsPreview(tile); - if (controls) card.append(controls); - const display = tile.options?.display; - if (display && display !== "standard") card.append(node("small", displayNames[display] || display)); - if (placeholder || index < 0) { card.classList.add("placeholder"); return card; } - card.tabIndex = 0; - card.setAttribute("role", "button"); - card.setAttribute("aria-label", `${name}, slot ${(slot % SLOTS_PER_PAGE) + 1} on page ${pageOf(slot) + 1}. Enter: configure, arrow keys: move`); - card.classList.toggle("chosen", selectedTile === tile.entity); - enableDrag(card, { kind: "tile", tile }); - card.onclick = () => openTileSheet(index); - card.onkeydown = (e) => { - if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openTileSheet(index); return; } - const step = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -2, ArrowDown: 2 }[e.key]; - if (!step) return; - e.preventDefault(); - // A wide card owns its row: left and right mean the row above and below. - if (placeTile(tile, tile.slot + (isWide(tile) ? Math.sign(step) * 2 : step))) focusSlot(tile.slot); - }; - const remove = node("button", "✕", "preview-remove"); - remove.type = "button"; - remove.title = "Remove tile"; - remove.setAttribute("aria-label", `Remove ${name}`); - remove.onclick = (e) => { e.stopPropagation(); removeTile(index); }; - card.append(remove); - return card; -} -// An empty cell: a drop target, and a click marks it as the place for the next tile. -function emptyCell(slot) { - const cell = node("button", undefined, "preview-cell"); - cell.type = "button"; - cell.dataset.slot = slot; - const marked = insertAt === slot; - cell.classList.toggle("insert-here", marked); - cell.append(node("span", "+"), node("small", marked ? "Next tile goes here" : "Empty")); - cell.title = "Empty slot. Click to add a tile here, or drag one over."; - cell.setAttribute("aria-label", `Empty slot ${(slot % SLOTS_PER_PAGE) + 1} on page ${pageOf(slot) + 1}: add the next tile here`); - cell.onclick = () => { - insertAt = marked ? -1 : slot; - renderPreview(); - if (insertAt >= 0) { $("#search").focus(); $("#search").scrollIntoView({ behavior: "smooth", block: "center" }); } - }; - return cell; -} -function renderTiles() { - normalize(); - // Pages follow the tiles; only a page the user added on purpose can stay empty. - layout.pages = pageCount(liveEntries()); - renderPreview(); - $("#add-page").disabled = layout.pages >= MAX_PAGES; - $("#count").textContent = `${layout.tiles.length} / ${tileLimit()}${tileLimit()===10?" · update firmware for 20":""}`; - $("#no-tiles").hidden = layout.tiles.length > 0; - // Firmware below 0.2.26 ignores positions and packs the tiles in order, without gaps. - const hint = $("#positions-hint"); - hint.hidden = !hasGaps(layout.tiles) || supportsFirmware(0, 2, 26); - hint.textContent = `Empty slots and fixed positions work from firmware 0.2.26. This screen (firmware ${inventory.screens.find((s) => s.id === selected)?.firmware || "unknown"}) shifts the tiles up to the first free slot until that update.`; - if (sheetIndex >= 0) renderTileSheet(); -} -function removeTile(index) { - const [tile] = layout.tiles.splice(index, 1); - if (!tile) return; - if (sheetIndex >= 0) closeTileSheet(); - markDirty(); - renderTiles(); - renderResults(); - toast(`${tile.name || entityName(tile.entity)} removed`, { - label: "Undo", - run: () => placeTile(tile, tile.slot), - }); -} -// Tile settings open in a sheet above the mockup; every change applies live, -// so the card behind it shows the result while you pick. -let sheetIndex = -1, iconPickerOpen = false; -function openTileSheet(index) { - loadCapabilities([layout.tiles[index].entity]); - if (index !== sheetIndex) { iconPickerOpen = false; actionPickerOpen = false; actionSearch = ""; } - sheetIndex = index; - selectedTile = layout.tiles[index].entity; - renderTileSheet(); - renderPreview(); - const sheet = $("#tile-sheet"); - if (!sheet.open) sheet.showModal(); -} -function closeTileSheet() { - sheetIndex = -1; - selectedTile = null; - const sheet = $("#tile-sheet"); - if (sheet.open) sheet.close(); - renderPreview(); -} -$("#tile-sheet").addEventListener("close", () => { if (sheetIndex >= 0) { sheetIndex = -1; selectedTile = null; renderPreview(); } }); -function field(title, control) { - const label = node("label", undefined, "sheet-field"); - label.append(node("span", title), control); - return label; -} -function segmented(choices, value, onChange) { - const group = node("div", undefined, "segmented"); - for (const [key, text] of choices) { - const b = node("button", text); - b.type = "button"; - b.setAttribute("aria-pressed", String(String(key) === String(value))); - b.onclick = () => { for (const other of group.children) other.setAttribute("aria-pressed", String(other === b)); onChange(key); }; - group.append(b); - } - return group; -} -// A choice applies live like the palette: only pressed states, the summary and the -// badges update, so search text and scroll position survive. -function iconField(tile, onChange) { - const fromHA = Boolean(inventory.entities.find((e) => e.id === tile.entity)?.icon); - return iconPicker({ - selected: tile.options?.icon || "auto", - automatic: automaticIcon(tile.entity), - autoLabel: `Automatic (${fromHA ? "from Home Assistant" : "default"})`, - onPick: (name) => { tile.options = { ...tile.options, icon: name }; markDirty(); renderPreview(); onChange(); }, - note: supportsFirmware(0, 2, 18) ? "" : "The screen shows a chosen icon from firmware 0.2.18.", - }); -} -// The icon choice for tiles and top bar items: automatic, optionally none, or one from the set. -function iconPicker({ selected, automatic, autoLabel, allowNone = false, onPick, note = "" }) { - const wrap = node("div", undefined, "sheet-field"); - wrap.append(node("span", "Icon")); - let picked = selected; - const summary = node("button", undefined, "icon-current"); - summary.type = "button"; - summary.setAttribute("aria-expanded", String(iconPickerOpen)); - const current = node("span", undefined, "mdi"), text = node("span"), action = node("small", iconPickerOpen ? "Close" : "Change"); - summary.append(current, text, action); - const describe = () => { - const chosen = iconNamed(picked); - current.textContent = picked === "none" ? "" : glyph(chosen?.cp || automatic); - text.textContent = picked === "none" ? "No icon" : chosen?.label || autoLabel; - }; - describe(); - const panel = node("div", undefined, "icon-picker"); - panel.hidden = !iconPickerOpen; - summary.onclick = () => { - iconPickerOpen = panel.hidden; - panel.hidden = !iconPickerOpen; - summary.setAttribute("aria-expanded", String(iconPickerOpen)); - action.textContent = iconPickerOpen ? "Close" : "Change"; - }; - const choice = (name, cp, label, cls = "") => { - const b = node("button", undefined, `icon-choice ${cls}`); - b.type = "button"; - b.dataset.icon = name; - b.dataset.search = label.toLocaleLowerCase(); - b.title = label; - b.setAttribute("aria-label", `Icon: ${label}`); - b.setAttribute("aria-pressed", String(picked === name)); - b.append(node("span", cp ? glyph(cp) : "", "mdi")); - b.onclick = () => { - picked = name; - for (const other of panel.querySelectorAll(".icon-choice")) other.setAttribute("aria-pressed", String(other === b)); - describe(); - onPick(name); - }; - return b; - }; - const search = node("input"); - search.type = "search"; - search.placeholder = "Search, for example lamp, music, or door"; - search.setAttribute("aria-label", "Search for an icon"); - const auto = choice("auto", automatic, autoLabel, "icon-auto"); - auto.append(node("span", autoLabel)); - const none = allowNone ? choice("none", "", "No icon", "icon-auto icon-none") : null; - none?.append(node("span", "No icon, text only")); - const list = node("div", undefined, "icon-list"), empty = node("p", "No icon found.", "hint"); - empty.hidden = true; - const sections = inventory.icons.groups.map((group) => { - const section = node("section"), grid = node("div", undefined, "icon-grid"); - for (const icon of group.icons) { - const b = choice(icon.name, icon.cp, icon.label); - b.dataset.search += ` ${icon.name.replaceAll("-", " ")} ${group.label.toLocaleLowerCase()}`; - grid.append(b); - } - section.append(node("small", group.label), grid); - list.append(section); - return section; - }); - search.oninput = () => { - const query = search.value.trim().toLocaleLowerCase(); - let found = 0; - for (const section of sections) { - let visible = 0; - for (const b of section.querySelectorAll(".icon-choice")) { - b.hidden = !b.dataset.search.includes(query); - if (!b.hidden) visible++; - } - section.hidden = !visible; - found += visible; - } - empty.hidden = found > 0; - }; - panel.append(search, auto, ...(none ? [none] : []), list, empty); - wrap.append(summary, panel); - if (note) wrap.append(node("small", note, "icon-hint")); - return wrap; -} -// ----- Perform action (app 0.2.67): the actions Home Assistant offers for a tile's entity, under its own names ----- -const entityActions = {}, askedActions = new Set(); -let actionPickerOpen = false, actionSearch = ""; -async function loadEntityActions(entity) { - if (askedActions.has(entity)) return; - askedActions.add(entity); - try { - entityActions[entity] = (await (await api(`entity-actions?entity=${encodeURIComponent(entity)}`)).json()).actions; - } catch { - askedActions.delete(entity); - return; - } - if (sheetIndex >= 0 && layout?.tiles[sheetIndex]?.entity === entity) renderTileSheet(); -} -// One field of an action, in the kind of value Home Assistant asks for (its selector). An empty field is left out. -function actionField(field, value, onChange) { - const selector = field.selector || {}, kind = Object.keys(selector)[0] || "text", config = selector[kind] || {}; - const wrap = node("div", undefined, "sheet-field action-field"); - wrap.append(node("span", field.required ? field.name : `${field.name} (optional)`)); - const example = field.example === undefined ? "" : typeof field.example === "string" ? field.example : JSON.stringify(field.example); - // A select's own options, or the values this entity has where Home Assistant asks for one of them (a source, an effect). - const choices = Array.isArray(field.options) ? field.options.map((o) => [o, o]) - : kind === "select" && Array.isArray(config.options) && !config.multiple - ? config.options.map((o) => (o && typeof o === "object" ? [String(o.value), String(o.label ?? o.value)] : [String(o), String(o)])) - : null; - if (kind === "boolean") { - wrap.append(segmented([["", "Not set"], ["true", "On"], ["false", "Off"]], value === undefined ? "" : String(value), (v) => onChange(v === "" ? undefined : v === "true"))); - } else if (choices && choices.length <= 4) { - wrap.append(segmented([["", "Not set"], ...choices], value === undefined ? "" : String(value), (v) => onChange(v === "" ? undefined : v))); - } else if (choices) { - const list = node("select"); - list.setAttribute("aria-label", field.name); - for (const [key, text] of [["", "Not set"], ...choices]) { - const option = node("option", text); - option.value = key; - option.selected = String(value ?? "") === key; - list.append(option); - } - list.onchange = () => onChange(list.value === "" ? undefined : list.value); - wrap.append(list); - } else if (kind === "color_rgb") { - const row = node("div", undefined, "action-number"), input = node("input"), clear = node("button", "Not set", "quiet"); - input.type = "color"; - input.setAttribute("aria-label", field.name); - const hex = (rgb) => "#" + rgb.map((c) => Math.max(0, Math.min(255, Number(c) || 0)).toString(16).padStart(2, "0")).join(""); - input.value = Array.isArray(value) && value.length === 3 ? hex(value) : "#ffffff"; - input.oninput = () => onChange([1, 3, 5].map((i) => parseInt(input.value.slice(i, i + 2), 16))); - clear.type = "button"; - clear.onclick = () => onChange(undefined); - row.append(input, clear); - wrap.append(row); - } else if (kind === "number" || kind === "color_temp") { - const row = node("div", undefined, "action-number"), input = node("input"); - input.type = "number"; - input.setAttribute("aria-label", field.name); - for (const key of ["min", "max", "step"]) if (typeof config[key] === "number") input[key] = config[key]; - if (config.step === "any") input.step = "any"; - input.value = value ?? ""; - input.placeholder = example; - input.oninput = () => onChange(input.value === "" || !Number.isFinite(Number(input.value)) ? undefined : Number(input.value)); - row.append(input); - const unit = config.unit_of_measurement || (kind === "color_temp" ? config.unit || "" : ""); - if (unit) row.append(node("span", unit)); - wrap.append(row); - } else { - // Text as it is; anything else Home Assistant asks for (a list, an object, a time) as JSON, or as text when it isn't. - const input = node("input"); - input.type = "text"; - input.setAttribute("aria-label", field.name); - input.value = value === undefined ? "" : typeof value === "string" ? value : JSON.stringify(value); - input.placeholder = example; - input.oninput = () => { - const text = input.value.trim(); - if (!text) return onChange(undefined); - if (kind === "text") return onChange(input.value); - try { onChange(JSON.parse(text)); } catch { onChange(input.value); } - }; - wrap.append(input); - } - if (field.description) wrap.append(node("small", field.description, "field-hint")); - return wrap; -} -function actionPicker(tile) { - const wrap = node("div", undefined, "sheet-field"); - wrap.append(node("span", "Action")); - const list = entityActions[tile.entity], chosen = tile.options?.action; - if (list === undefined) { - wrap.append(node("small", "Asking Home Assistant which actions this entity has…", "field-hint")); - loadEntityActions(tile.entity); - return wrap; - } - if (list === null) { - wrap.append(node("small", "Home Assistant isn't answering right now. Try again in a moment.", "field-hint warn")); - return wrap; - } - const entry = list.find((a) => a.action === chosen?.action); - const open = actionPickerOpen || !chosen; - const summary = node("button", undefined, "icon-current action-current"); - summary.type = "button"; - summary.setAttribute("aria-expanded", String(open)); - const text = node("span"); - text.append(node("strong", entry ? entry.name : chosen ? chosen.action : "Choose an action")); - if (chosen) text.append(node("small", chosen.action, "action-id")); - summary.append(text, node("small", open ? "Close" : "Change")); - summary.onclick = () => { actionPickerOpen = !open; renderTileSheet(); }; - const panel = node("div", undefined, "icon-picker"); - panel.hidden = !open; - const search = node("input"), rows = node("div", undefined, "action-list"), empty = node("p", "No action found.", "hint"); - search.type = "search"; - search.placeholder = "Search, for example toggle, position, or play"; - search.setAttribute("aria-label", "Search for an action"); - search.value = actionSearch; - for (const action of list) { - const b = node("button", undefined, "action-choice"); - b.type = "button"; - b.dataset.search = `${action.name} ${action.action} ${action.description}`.toLocaleLowerCase(); - b.setAttribute("aria-pressed", String(action.action === chosen?.action)); - b.append(node("strong", action.name), node("small", action.description ? `${action.action} · ${action.description}` : action.action)); - b.onclick = () => { - const same = action.action === chosen?.action; - tile.options = { ...tile.options, action: same ? chosen : { action: action.action } }; - actionPickerOpen = false; - markDirty(); - renderTileSheet(); - }; - rows.append(b); - } - const filter = () => { - const query = search.value.trim().toLocaleLowerCase(); - let found = 0; - for (const b of rows.children) { b.hidden = !b.dataset.search.includes(query); if (!b.hidden) found++; } - empty.hidden = found > 0; - }; - search.oninput = () => { actionSearch = search.value; filter(); }; - filter(); - panel.append(search, rows, empty); - wrap.append(summary, panel); - if (chosen && !entry) - wrap.append(node("small", "Home Assistant doesn't offer this action for this entity any more, so a tap does nothing. Choose another one.", "field-hint warn")); - if (entry && !open) { - const missing = node("small", "", "field-hint warn"); - const check = () => { - const data = tile.options.action?.data || {}; - const names = entry.fields.filter((f) => f.required && data[f.key] === undefined).map((f) => f.name); - missing.textContent = names.length ? `Home Assistant needs ${names.join(", ")}.` : ""; - missing.hidden = !names.length; - }; - for (const field of entry.fields) - wrap.append(actionField(field, chosen.data?.[field.key], (value) => { - const data = { ...(tile.options.action?.data || {}) }; - if (value === undefined) delete data[field.key]; - else data[field.key] = value; - tile.options = { ...tile.options, action: { action: chosen.action, ...(Object.keys(data).length ? { data } : {}) } }; - markDirty(); - check(); - })); - check(); - wrap.append(missing); - } - if (!supportsFirmware(0, 2, 58)) - wrap.append(node("small", "The screen performs an action from firmware 0.2.58: press Update on the screen. Until then a tap works as Automatic.", "field-hint")); - return wrap; -} -function renderTileSheet() { - const sheet = $("#tile-sheet"), tile = layout.tiles[sheetIndex]; - if (!tile) return; - sheet.replaceChildren(); - const domain = tile.entity.split(".")[0], name = entityName(tile.entity); - const head = node("div", undefined, "sheet-head"), titles = node("div"); - titles.append(node("strong", name), node("small", tile.entity)); - const close = node("button", "✕", "quiet sheet-close"); - close.type = "button"; - close.setAttribute("aria-label", "Close"); - close.onclick = closeTileSheet; - let badge = tileBadge(tile); - head.append(badge, titles, close); - const body = node("div", undefined, "sheet-body"); - const nameInput = node("input"); - nameInput.value = tile.name; - nameInput.placeholder = name; - nameInput.maxLength = 60; - nameInput.oninput = () => { tile.name = nameInput.value; markDirty(); renderPreview(); }; - body.append(field("Name on the screen", nameInput)); - // The clock, forecast and sun path cards draw no tile icon. - if (inventory.icons && domain !== "screen" && !["forecast", "sunpath"].includes(tile.options?.display)) - body.append(iconField(tile, () => { const next = tileBadge(tile); badge.replaceWith(next); badge = next; })); - const caps = capabilities[tile.entity]; - const current = (key, fallback) => tile.options?.[key] ?? fallback; - const display = current("display", domain === "screen" ? "digital" : "standard"); - const displays = domain === "screen" - ? [["digital", "Digital clock"], ["analog", "Analog clock"]] - : [["standard", "Name and status"], ["watch", "Large value"]]; - if (domain === "weather" && (!caps || caps.displays.includes("forecast") || display === "forecast")) displays.push(["forecast", "Weather forecast"]); - if (domain === "sensor" && (!caps || caps.displays.includes("graph") || display === "graph")) displays.push(["graph", "Graph"]); - if (domain === "sun") displays.push(["sunpath", "Sun path"]); - const set = (key, value) => { - const wasWide = isWide(tile); - tile.options = { ...tile.options, [key]: value }; - // Direct controls need the standard layout without a mini slider, and vice versa. - if (key === "display" && value === "watch") { tile.options.inline = "none"; if (inventory.controls?.[domain]) tile.options.controls = "none"; } - if (key === "display" && ["forecast", "sunpath"].includes(value)) tile.options.size = "wide"; - if (key === "inline" && value === "slider") { tile.options.display = "standard"; if (inventory.controls?.[domain]) tile.options.controls = "none"; } - if (key === "controls" && value !== "none") { tile.options.display = "standard"; tile.options.inline = "none"; } - // A card that becomes wide gets the first direct control Home Assistant offers when the usual one isn't there. - const catalogue = inventory.controls?.[domain]; - if (key === "size" && value === "wide" && caps && catalogue && !("controls" in tile.options) && !caps.controls.includes(catalogue.default)) - tile.options.controls = catalogue.choices.find((c) => c.key !== "none" && caps.controls.includes(c.key))?.key || "none"; - markDirty(); - // A card that becomes double-wide keeps its row when the cell beside it is free, else it - // takes the nearest free row (below first); every other tile stays where it is. - if (isWide(tile) && !wasWide) { - const taken = occupied(liveEntries().filter((e) => e.tile !== tile)), own = rowStart(tile.slot); - const slot = fits(taken, own, true) ? own : nearestFree(taken, true, own); - if (slot >= 0) tile.slot = slot; - } - renderTiles(); - }; - const displayField = field("Display", segmented(displays, display, (v) => set("display", v))); - if (caps && display === "graph" && !caps.displays.includes("graph")) - body.append(withHint(displayField, "Home Assistant has no numbers for this entity, so the graph stays empty. Choose another display.", true)); - else if (caps && display === "forecast" && !caps.displays.includes("forecast")) - body.append(withHint(displayField, "This weather service has no daily forecast in Home Assistant. Choose another display.", true)); - else body.append(displayField); - body.append(field("Width", segmented([["single", "Normal"], ["wide", "Double-width"]], current("size", "single"), (v) => set("size", v)))); - const catalogue = inventory.controls?.[domain]; - if (catalogue && current("size", "single") === "wide") { - const chosen = current("controls", catalogue.default); - const choices = catalogue.choices.filter((c) => !caps || c.key === "none" || c.key === chosen || caps.controls.includes(c.key)); - const wrap = field("Direct control on the tile", segmented(choices.map((c) => [c.key, c.label]), chosen, (v) => set("controls", v))); - if (caps && chosen !== "none" && !caps.controls.includes(chosen)) - body.append(withHint(wrap, "Home Assistant doesn't offer this control for this entity, so it stays empty on the screen. Choose another one.", true)); - else body.append(withHint(wrap, supportsFirmware(0, 2, 19) - ? "On the right of the double-width tile, like the rows in Home Assistant. Tapping the name works as configured below." - : "The screen shows direct control from firmware 0.2.19; until then the tile stays as it was.")); - } - if (domain !== "screen") { - const tap = current("tap", "auto"); - const taps = [["auto", "Automatic"], ["detail", "Open control"], ["none", "View only"]]; - // On / off where Home Assistant can toggle the entity, such as a cover; a speaker without on and off gets none. - if ((caps ? caps.toggle : TOGGLE_BEFORE.includes(domain)) || tap === "toggle") taps.push(["toggle", "On / off"]); - taps.push(["action", "Perform action"]); - const wrap = field("On tap", segmented(taps, tap, (v) => set("tap", v))); - if (tap === "toggle" && caps && !caps.toggle) - body.append(withHint(wrap, "Home Assistant can't turn this on and off, so a tap does nothing. Choose another option.", true)); - else if (tap === "toggle" && !TOGGLE_BEFORE.includes(domain) && !supportsFirmware(0, 2, 58)) - body.append(withHint(wrap, "The screen switches this from firmware 0.2.58: press Update on the screen. Until then a tap opens its card.")); - else if (tap === "toggle") body.append(withHint(wrap, "Hold the tile to open its card.")); - else body.append(wrap); - if (tap === "action") body.append(actionPicker(tile)); - } - const inline = current("inline", "none"); - if (SLIDER_DOMAINS.includes(domain) && (!caps || caps.inline || inline === "slider")) { - const wrap = field("Small slider on the tile", segmented([["none", "No"], ["slider", "Yes, control directly"]], inline, (v) => set("inline", v))); - if (inline === "slider" && caps && !caps.inline) - body.append(withHint(wrap, "Home Assistant has nothing a slider can change for this entity. Choose No.", true)); - else body.append(wrap); - } - if (domain === "sensor") - body.append(field("History", segmented([[1, "1 hour"], [6, "6 hours"], [24, "24 hours"]], current("history_hours", 24), (v) => set("history_hours", Number(v))))); - const palette = node("div", undefined, "sheet-field"); - palette.append(node("span", "Pastel background")); - const swatches = node("div", undefined, "palette-swatches"); - for (const [key, choice] of Object.entries(inventory.backgrounds || {})) { - const button = node("button", undefined, "palette-choice"); - button.type = "button"; - button.setAttribute("aria-label", `Background: ${choice.label}`); - button.setAttribute("aria-pressed", String((tile.options?.background || "auto") === key)); - const sample = node("span", undefined, "palette-sample"); - if (choice.color) sample.style.backgroundColor = choice.color; - else sample.classList.add(key === "none" ? "palette-none" : "palette-auto"); - button.append(sample, node("span", choice.label)); - button.onclick = () => { - tile.options = { ...tile.options, background: key }; - for (const other of swatches.children) other.setAttribute("aria-pressed", String(other === button)); - markDirty(); - renderPreview(); - }; - swatches.append(button); - } - palette.append(swatches); - body.append(palette); - const foot = node("div", undefined, "sheet-foot"); - const remove = node("button", "Remove", "quiet danger"); - remove.type = "button"; - remove.onclick = () => removeTile(sheetIndex); - foot.append(remove); - if (domain !== "screen") { - const inspectButton = node("button", "Inspect", "quiet"); - inspectButton.type = "button"; - inspectButton.onclick = () => { const entity = tile.entity; closeTileSheet(); inspect(entity); }; - foot.append(inspectButton); - } - const done = node("button", "Done"); - done.type = "button"; - done.onclick = closeTileSheet; - foot.append(done); - sheet.append(head, body, foot); -} -// ---- Top bar ---- -// The name on the left; on the right up to six items: the time, an analog clock, the date, or an -// entity's state or last change. The add-on formats entity text (POST header-preview) exactly as the -// screen gets it; the screen and this mockup tick clocks and "5 min ago" themselves. -const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; -const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; -const BUILTIN_ICONS = { clock: "clock-outline", analog: "clock-outline", date: "calendar" }; -let topbarPreviews = new Map(), topbarTimer = 0, topbarSheetIndex = null, topbarOverflow = new Set(), topbarAdded = null; -const itemKey = (item) => JSON.stringify(item); -// Without a stored top bar the screen shows what it always did: the clock of show_clock. -const topbarItems = () => layout.header?.items ?? ((layout.settings?.show_clock ?? true) ? [{ type: "clock" }] : []); -const topbarMax = () => inventory.header?.max_items || 6; -function setTopbarItems(items) { - layout.header = { items }; - markDirty(); - renderTopbar(); - renderBars(); - loadTopbarPreview(); -} -// A caption over a group of buttons; unlike a label it never forwards a click to the first one. -function group(title, content) { - const wrap = node("div", undefined, "sheet-field"); - wrap.append(node("span", title), content); - return wrap; -} -// Only the bars of the mockup pages, so tiles, focus and a running drag stay untouched. -function renderBars() { - const bars = document.querySelectorAll("#layout-preview .preview-bar-wrap"); - if (!bars.length) return renderPreview(); - for (const bar of bars) bar.replaceWith(topbarBar()); - fitTopbars(); -} -// Entity text as the screen will show it, for the items not previewed yet. -function loadTopbarPreview(delay = 150) { - clearTimeout(topbarTimer); - topbarTimer = setTimeout(async () => { - const items = topbarItems(); - if (!items.some((item) => item.type === "entity")) return; - try { - const data = await (await api("header-preview", { method: "POST", body: JSON.stringify({ header: { items } }) })).json(); - items.forEach((item, i) => topbarPreviews.set(itemKey(item), data.items[i])); - if (!layout) return; - renderTopbar(); - renderBars(); - if (topbarSheetIndex !== null && $("#topbar-sheet").open) renderTopbarLive(); - } catch { - // Keep the last preview; the next edit or refresh tries again. - } - }, delay); -} -function clockText(now = new Date()) { - let hours = now.getHours(); - // The screen formats with %I:%M when the 24-hour clock is off. - if (settingValues().clock_24h === false) hours = hours % 12 || 12; - return `${String(hours).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`; -} -const dateText = (now = new Date()) => `${WEEKDAYS[now.getDay()]} ${now.getDate()} ${MONTHS[now.getMonth()]}`; -// Same wording and thresholds as header_bar::ago_text() in the firmware. -function agoText(then, now = Math.floor(Date.now() / 1000)) { - const seconds = now - then, span = Math.abs(seconds), per = (unit) => Math.floor(span / unit); - if (seconds < 0) { - if (span < 3600) return `In ${Math.max(1, per(60))} min`; - if (span < 86400) return per(3600) === 1 ? "In 1 hour" : `In ${per(3600)} hours`; - if (span < 172800) return "Tomorrow"; - return `In ${per(86400)} days`; - } - if (span < 60) return "Just now"; - if (span < 3600) return `${per(60)} min ago`; - if (span < 86400) return per(3600) === 1 ? "1 hour ago" : `${per(3600)} hours ago`; - if (span < 172800) return "Yesterday"; - if (span < 604800) return `${per(86400)} days ago`; - if (span < 2592000) return per(604800) === 1 ? "1 week ago" : `${per(604800)} weeks ago`; - if (span < 31536000) return per(2592000) === 1 ? "1 month ago" : `${per(2592000)} months ago`; - return per(31536000) === 1 ? "1 year ago" : `${per(31536000)} years ago`; -} -function topbarLabel(item) { - if (item.type === "entity") return entityName(item.entity); - return inventory.header?.builtin.find((b) => b.type === item.type)?.label || item.type; -} -// What the item shows right now: { icon, text, color, shown }. Entities wait for the add-on's preview. -function topbarView(item) { - if (item.type === "clock") return { text: clockText(), shown: true }; - if (item.type === "date") return { text: dateText(), shown: true }; - if (item.type === "analog") return { analog: true, shown: true }; - const p = topbarPreviews.get(itemKey(item)); - if (!p) return { icon: item.icon === "none" ? null : iconNamed(item.icon)?.cp || automaticIcon(item.entity), text: "…", shown: true, loading: true }; - return { icon: p.i || null, text: p.k === "ago" ? agoText(p.e) : p.t, color: p.c ? `#${p.c}` : null, shown: p.shown }; -} -// ---- The bar as the screen draws it ---- -// One rule set with header_bar.h in the firmware, in screen pixels of the board: every value (the -// time too) in the same 400 Roboto on the name's baseline; icons and the dial centred on the -// height of the digits; the gaps measured between what you see (glyph ink), not between boxes, so -// an icon with side bearings sits exactly as close to its value as one without. -const BAR_METRICS = { - guition: { width: 448, top: 36, name: 27, text: 21, icon: 26 }, - cyd: { width: 298, top: 24, name: 18, text: 14, icon: 18 }, -}; -// Screens without the Guition type sensor are CYDs, as the rotation setting assumes too. -const barMetrics = () => BAR_METRICS[inventory.screens.find((s) => s.id === selected)?.board === "guition" ? "guition" : "cyd"]; -const measure = document.createElement("canvas").getContext("2d"); -const inkCache = new Map(); -// Ink box of a string relative to its origin on the baseline: left/right, and top (negative, up)/bottom. -function inkOf(text, font) { - const key = `${font}|${text}`; - if (!inkCache.has(key)) { - measure.font = font; - const m = measure.measureText(text); - inkCache.set(key, { left: -m.actualBoundingBoxLeft, right: m.actualBoundingBoxRight, top: -m.actualBoundingBoxAscent, bottom: m.actualBoundingBoxDescent, advance: m.width }); - } - return inkCache.get(key); -} -const barFonts = (m) => ({ name: `500 ${m.name}px "Bar Roboto"`, text: `400 ${m.text}px "Bar Roboto"`, icon: `${m.icon}px "Tile Icons"` }); -// The fonts load on first use; measurements before that are wrong, so draw again once they are in. -Promise.all([document.fonts.load('500 27px "Bar Roboto"', "Studio 0"), document.fonts.load('400 21px "Bar Roboto"', "Away 0"), document.fonts.load('26px "Tile Icons"', String.fromCodePoint(0xf0150))]) - .then(() => { inkCache.clear(); if (layout) { renderTopbar(); renderBars(); } }) - .catch(() => {}); -// Same integer arithmetic as header_bar::gaps() in the firmware, from the digit height in pixels. -function barGaps(cap) { - return { icon: 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)) }; -} -// The parts per item with their ink widths, the placement, and which items fall off. -function barLayout(items = topbarItems(), metrics = barMetrics(), nameText = $("#title").value || "Home") { - const fonts = barFonts(metrics); - // The firmware reads the digit height as a whole number of pixels (the glyph box of "0"). - const zero = inkOf("0", fonts.text), cap = Math.round(zero.bottom - zero.top), gaps = barGaps(cap); - const dialInk = inkOf(String.fromCodePoint(0xf0150), fonts.icon), dial = Math.round(dialInk.bottom - dialInk.top); - const parts = items.map((item, index) => { - const view = topbarView(item); - const part = { index, item, view, shown: view.shown, width: 0 }; - if (view.analog) { part.dial = dial; part.width = dial; return part; } - if (view.icon) { part.icon = { glyph: glyph(view.icon), ink: inkOf(glyph(view.icon), fonts.icon) }; part.width += part.icon.ink.right - part.icon.ink.left; } - if (view.text) { - part.text = { value: view.text, ink: inkOf(view.text, fonts.text) }; - part.width += (part.icon ? gaps.icon : 0) + part.text.ink.right - part.text.ink.left; - } - return part; - }); - const shown = parts.filter((p) => p.shown); - const natural = inkOf(nameText, fonts.name).advance; - const minName = Math.min(natural, Math.floor((metrics.width * 35) / 100)); - const total = (list) => list.reduce((sum, p) => sum + p.width, 0) + Math.max(0, list.length - 1) * gaps.item; - let first = 0; - while (first < shown.length && total(shown.slice(first)) + gaps.name + minName > metrics.width) first++; - const placed = shown.slice(first), dropped = new Set(shown.slice(0, first).map((p) => p.index)); - let x = metrics.width - total(placed); - for (const p of placed) { p.x = x; x += p.width + gaps.item; } - const nameRoom = placed.length ? placed[0].x - gaps.name : metrics.width; - return { metrics, fonts, cap, zero, gaps, parts, placed, dropped, nameText, natural, nameRoom }; -} -// LVGL's LV_LABEL_LONG_DOT: the longest start that fits with "..." after it. -function dotted(text, font, room) { - if (inkOf(text, font).advance <= room) return text; - const chars = [...text]; - while (chars.length && inkOf(chars.join("") + "...", font).advance > room) chars.pop(); - return chars.join("") + "..."; -} -// `font` goes through CSSOM: the add-on's CSP blocks style attributes. -const svgNode = (tag, attrs = {}) => { - const el = document.createElementNS("http://www.w3.org/2000/svg", tag); - for (const [k, v] of Object.entries(attrs)) { - if (k === "font") el.style.font = v; - else el.setAttribute(k, v); - } - return el; -}; -// Draws placed parts into an SVG group; `baseline` is the text baseline in screen pixels. -function drawParts(group, lay, parts, baseline) { - const capMiddle = baseline + (lay.zero.top + lay.zero.bottom) / 2; - for (const p of parts) { - const color = p.view.color || "#46525e"; - if (p.dial) { - const d = p.dial, stroke = Math.max(1, Math.round(d / 10)), hand = Math.max(1, Math.round(d * 0.075)); - const cx = p.x + d / 2, now = new Date(); - group.append(svgNode("circle", { cx, cy: capMiddle, r: d / 2 - stroke / 2, fill: "none", stroke: color, "stroke-width": stroke })); - for (const [angle, length] of [[(now.getHours() % 12 + now.getMinutes() / 60) * 30, d * 0.24], [now.getMinutes() * 6, d * 0.34]]) { - const rad = (angle * Math.PI) / 180; - group.append(svgNode("line", { x1: cx, y1: capMiddle, x2: cx + length * Math.sin(rad), y2: capMiddle - length * Math.cos(rad), stroke: color, "stroke-width": hand, "stroke-linecap": "round" })); - } - continue; - } - let x = p.x; - if (p.icon) { - const ink = p.icon.ink, icon = svgNode("text", { x: x - ink.left, y: capMiddle - (ink.top + ink.bottom) / 2, fill: color, font: lay.fonts.icon }); - icon.textContent = p.icon.glyph; - group.append(icon); - x += ink.right - ink.left + (p.text ? lay.gaps.icon : 0); - } - if (p.text) { - const text = svgNode("text", { x: x - p.text.ink.left, y: baseline, fill: "#46525e", font: lay.fonts.text }); - text.textContent = p.text.value; - group.append(text); - } - } -} -// The bar at the top of a mockup page, scaled with the page: name left, items right. -function topbarBar() { - const lay = barLayout(), m = lay.metrics; - const height = m.top + Math.round(m.name * 0.45); - const svg = svgNode("svg", { viewBox: `0 0 ${m.width} ${height}`, class: "preview-bar", role: "img" }); - const name = svgNode("text", { x: 0, y: m.top, fill: "#1b1b1b", font: lay.fonts.name }); - name.textContent = dotted(lay.nameText, lay.fonts.name, Math.min(lay.natural, lay.nameRoom)); - svg.append(name); - const group = svgNode("g"); - drawParts(group, lay, lay.placed, m.top); - svg.append(group); - svg.setAttribute("aria-label", `Top bar: ${lay.nameText}`); - const wrap = node("div", undefined, "preview-bar-wrap"); - wrap.title = "Edit top bar"; - wrap.append(svg); - wrap.onclick = () => { $("#topbar").scrollIntoView({ behavior: "smooth", block: "center" }); $("#topbar").classList.add("flash"); setTimeout(() => $("#topbar").classList.remove("flash"), 900); }; - return wrap; -} -// Which items the screen leaves out for lack of room; the chips mark them. -function fitTopbars() { - const dropped = barLayout().dropped; - const before = [...topbarOverflow].join(); - topbarOverflow = dropped; - if ([...dropped].join() !== before) renderTopbar(); -} -// One item on its own, drawn exactly as in the bar (the sheet's "how it looks on the screen"). -function itemSample(item) { - const lay = barLayout([item]), m = lay.metrics, part = lay.parts[0]; - part.x = 0; - const top = Math.round(m.text * 1.05), height = Math.round(m.text * 1.4); - const svg = svgNode("svg", { viewBox: `-2 0 ${Math.max(1, part.width) + 4} ${height}`, class: "topbar-sample", role: "img" }); - svg.style.width = `${(Math.max(1, part.width) + 4) * (26 / m.icon)}px`; - const group = svgNode("g"); - drawParts(group, lay, [part], top); - svg.append(group); - return svg; -} -function renderTopbar() { - if (!layout) return; - const items = topbarItems(), chips = $("#topbar-chips"); - chips.replaceChildren(); - items.forEach((item, index) => chips.append(topbarChip(item, index))); - const add = node("button", "+ Add", "topbar-add"); - add.type = "button"; - add.disabled = items.length >= topbarMax(); - add.title = add.disabled ? `Maximum ${topbarMax()} items` : "Add the time, date, analog clock, or an entity"; - add.onclick = () => openTopbarSheet(-1); - chips.append(add); - $("#topbar-count").textContent = `${items.length} / ${topbarMax()}`; - const needed = inventory.header?.min_firmware || "0.2.32"; - const [major, minor, patch] = needed.split(".").map(Number); - const hint = $("#topbar-hint"); - hint.textContent = supportsFirmware(major, minor, patch) - ? topbarOverflow.size ? "Not everything fits next to the name: the screen drops the dashed items. Remove one or choose a shorter name." : "Drag to reorder; tap an item to configure it." - : `Sensors, the date, and the analog clock appear from firmware ${needed}; until that update, this screen shows the name and, if the time is in the bar, the clock.`; - hint.classList.toggle("warn", topbarOverflow.size > 0 && supportsFirmware(major, minor, patch)); -} -function topbarChip(item, index) { - const view = topbarView(item); - const chip = node("div", undefined, "topbar-chip"); - chip.setAttribute("role", "listitem"); - chip.tabIndex = 0; - chip.dataset.index = index; - chip.classList.toggle("is-hidden", !view.shown); - chip.classList.toggle("is-overflow", topbarOverflow.has(index)); - chip.classList.toggle("just-added", topbarAdded?.key === itemKey(item) && Date.now() - topbarAdded.time < 1200); - chip.classList.toggle("dragging-chip", chipDrag.active && chipDrag.index === index); - const icon = node("span", "", "mdi chip-icon"); - const cp = view.analog || item.type !== "entity" ? iconNamed(BUILTIN_ICONS[item.type])?.cp : view.icon; - if (cp) icon.textContent = glyph(cp); - if (view.color) icon.style.color = view.color; - const texts = node("span", undefined, "chip-text"); - const detail = !view.shown ? "Hidden: not active right now" : topbarOverflow.has(index) ? "Doesn't fit next to the name" : view.analog ? "Dial" : view.text; - texts.append(node("strong", topbarLabel(item)), node("small", detail)); - const remove = node("button", "✕", "chip-remove"); - remove.type = "button"; - remove.setAttribute("aria-label", `Remove ${topbarLabel(item)} from the top bar`); - remove.onclick = (e) => { e.stopPropagation(); removeTopbarItem(index); }; - chip.append(icon, texts, remove); - chip.setAttribute("aria-label", `${topbarLabel(item)}, slot ${index + 1}. Enter: configure, arrows: move`); - chip.onclick = () => openTopbarSheet(index); - chip.onkeydown = (e) => { - if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openTopbarSheet(index); return; } - const step = { ArrowLeft: -1, ArrowRight: 1 }[e.key]; - if (step && moveTopbarItem(index, index + step)) $(`#topbar-chips [data-index="${index + step}"]`)?.focus(); - }; - enableChipDrag(chip, index); - return chip; -} -function moveTopbarItem(from, to) { - const items = [...topbarItems()]; - if (to < 0 || to >= items.length || from === to) return false; - items.splice(to, 0, ...items.splice(from, 1)); - setTopbarItems(items); - return true; -} -function removeTopbarItem(index) { - const items = [...topbarItems()]; - const [item] = items.splice(index, 1); - if (!item) return; - if ($("#topbar-sheet").open) $("#topbar-sheet").close(); - setTopbarItems(items); - toast(`${topbarLabel(item)} removed from the top bar`, { - label: "Undo", - run: () => { const back = [...topbarItems()]; back.splice(Math.min(index, back.length), 0, item); setTopbarItems(back); }, - }); -} -function addTopbarItem(item) { - const items = topbarItems(); - if (items.length >= topbarMax()) return toast(`The top bar has room for ${topbarMax()} items.`); - if (items.some((other) => itemKey(other) === itemKey(item))) return toast("This is already in the top bar."); - // The new chip lights up briefly so the eye finds it. - topbarAdded = { key: itemKey(item), time: Date.now() }; - setTopbarItems([...items, item]); - $("#topbar-sheet").close(); -} -// Pointer drag between chips, mouse and touch (touch after a short hold, so the page still scrolls). -// The order updates while dragging, the screen mockup follows, and a finished drag is not a click. -const chipDrag = { index: -1, start: null, timer: 0, active: false, pointerId: null, moved: false }; -function enableChipDrag(chip, index) { - chip.addEventListener("pointerdown", (e) => { - if (e.button !== 0 || e.target.closest(".chip-remove")) return; - if (e.pointerType !== "touch") e.preventDefault(); - Object.assign(chipDrag, { index, start: { x: e.clientX, y: e.clientY }, pointerId: e.pointerId, active: false, moved: false }); - clearTimeout(chipDrag.timer); - if (e.pointerType === "touch") chipDrag.timer = setTimeout(() => beginChipDrag(), 260); - document.addEventListener("pointermove", moveChipDrag); - document.addEventListener("pointerup", endChipDrag); - document.addEventListener("pointercancel", endChipDrag); - }); -} -function beginChipDrag() { - chipDrag.active = true; - document.body.classList.add("dragging"); - document.addEventListener("touchmove", blockChipScroll, { passive: false }); - $(`#topbar-chips [data-index="${chipDrag.index}"]`)?.classList.add("dragging-chip"); -} -function blockChipScroll(e) { if (chipDrag.active) e.preventDefault(); } -function moveChipDrag(e) { - if (e.pointerId !== chipDrag.pointerId || !chipDrag.start) return; - if (!chipDrag.active) { - const distance = Math.hypot(e.clientX - chipDrag.start.x, e.clientY - chipDrag.start.y); - if (e.pointerType === "touch") { if (distance > 10) { clearTimeout(chipDrag.timer); chipDrag.start = null; } return; } - if (distance < 6) return; - beginChipDrag(); - } - // The target is the chip under the pointer's row whose middle the pointer passed. - const chips = [...document.querySelectorAll("#topbar-chips .topbar-chip")]; - let target = chipDrag.index; - chips.forEach((chip, i) => { - const r = chip.getBoundingClientRect(); - if (e.clientY >= r.top - 6 && e.clientY <= r.bottom + 6) { - if (i < chipDrag.index && e.clientX < r.left + r.width / 2) target = Math.min(target, i); - if (i > chipDrag.index && e.clientX > r.left + r.width / 2) target = Math.max(target, i); - } - }); - if (target !== chipDrag.index) { - const items = [...topbarItems()]; - items.splice(target, 0, ...items.splice(chipDrag.index, 1)); - layout.header = { items }; - chipDrag.index = target; - chipDrag.moved = true; - renderTopbar(); - renderBars(); - } -} -function endChipDrag(e) { - if (e.pointerId !== chipDrag.pointerId) return; - clearTimeout(chipDrag.timer); - document.removeEventListener("pointermove", moveChipDrag); - document.removeEventListener("pointerup", endChipDrag); - document.removeEventListener("pointercancel", endChipDrag); - document.removeEventListener("touchmove", blockChipScroll); - document.body.classList.remove("dragging"); - if (chipDrag.active) { - drag.suppressUntil = Date.now() + 400; - if (chipDrag.moved) markDirty(); - renderTopbar(); - } - Object.assign(chipDrag, { index: -1, start: null, active: false, pointerId: null, moved: false }); -} -// One sheet for adding (index -1) and for one item's settings. -function openTopbarSheet(index) { - if (index !== topbarSheetIndex) iconPickerOpen = false; - topbarSheetIndex = index; - renderTopbarSheet(); - if (!$("#topbar-sheet").open) $("#topbar-sheet").showModal(); -} -$("#topbar-sheet").addEventListener("close", () => { topbarSheetIndex = null; }); -function sheetHead(badge, title, subtitle) { - const head = node("div", undefined, "sheet-head"), titles = node("div"); - titles.append(node("strong", title), node("small", subtitle)); - const close = node("button", "✕", "quiet sheet-close"); - close.type = "button"; - close.setAttribute("aria-label", "Close"); - close.onclick = () => $("#topbar-sheet").close(); - head.append(badge, titles, close); - return head; -} -function optionButton(cp, title, detail, onClick, disabled = false) { - const b = node("button", undefined, "topbar-option"); - b.type = "button"; - b.disabled = disabled; - const texts = node("span"); - texts.append(node("strong", title), node("small", detail)); - b.append(node("span", cp ? glyph(cp) : "", "mdi"), texts); - b.onclick = onClick; - return b; -} -function renderTopbarSheet() { - const sheet = $("#topbar-sheet"); - sheet.replaceChildren(); - if (topbarSheetIndex === -1) return renderTopbarAdd(sheet); - const item = topbarItems()[topbarSheetIndex]; - if (!item) return sheet.close(); - const badge = node("span", "", "domain-icon mdi"); - const body = node("div", undefined, "sheet-body"); - if (item.type === "entity") { - const view = topbarView(item); - badge.textContent = view.icon ? glyph(view.icon) : ""; - sheet.append(sheetHead(badge, entityName(item.entity), item.entity)); - const live = node("div", undefined, "topbar-live"); - live.id = "topbar-live"; - body.append(live); - // Choices apply live; an icon pick keeps the open picker (search, scroll) and only refreshes the samples. - const update = (patch) => { - const items = [...topbarItems()]; - items[topbarSheetIndex] = { ...items[topbarSheetIndex], ...patch }; - setTopbarItems(items); - renderTopbarLive(); - const next = topbarView(items[topbarSheetIndex]); - badge.textContent = next.icon ? glyph(next.icon) : ""; - }; - const content = segmented(inventory.header.contents.map((c) => [c.key, c.label]), item.content, (v) => update({ content: v })); - const contentField = group("What to show", content); - contentField.append(node("small", "Last changed keeps counting on the screen itself: “Just now”, “5 min ago”, “Yesterday”.", "field-hint")); - body.append(contentField); - body.append(iconPicker({ - selected: item.icon, - automatic: topbarPreviews.get(itemKey(item))?.auto_icon || automaticIcon(item.entity), - autoLabel: "Automatic (like Home Assistant)", - allowNone: true, - onPick: (name) => update({ icon: name }), - })); - const shows = segmented(inventory.header.shows.map((s) => [s.key, s.label]), item.show, (v) => update({ show: v })); - const showField = group("Show", shows); - showField.append(node("small", "Only when active hides the item as long as it's off, closed, away, or 0. Handy for an open door, a running washing machine, or who's home.", "field-hint")); - body.append(showField); - } else { - badge.textContent = glyph(iconNamed(BUILTIN_ICONS[item.type])?.cp || ""); - sheet.append(sheetHead(badge, topbarLabel(item), "From the screen itself, also works without Home Assistant")); - const live = node("div", undefined, "topbar-live"); - live.id = "topbar-live"; - body.append(live); - if (item.type !== "date") { - // The screen's own clock setting: it applies at once, like the Clock row under Screen settings. - const format = segmented([["24", "24 hour"], ["12", "12 hour"]], settingValues().clock_24h !== false ? "24" : "12", (v) => { - setSetting("clock_24h", v === "24", 150); - }); - const formatField = group("Format", format); - formatField.append(node("small", "Applies to every clock on this screen, including the clock tiles.", "field-hint")); - body.append(formatField); - } - } - const foot = node("div", undefined, "sheet-foot"); - const remove = node("button", "Remove", "quiet danger"); - remove.type = "button"; - remove.onclick = () => removeTopbarItem(topbarSheetIndex); - const left = node("button", "← Move left", "quiet"), right = node("button", "Move right →", "quiet"); - left.type = right.type = "button"; - left.disabled = topbarSheetIndex === 0; - right.disabled = topbarSheetIndex >= topbarItems().length - 1; - left.onclick = () => { if (moveTopbarItem(topbarSheetIndex, topbarSheetIndex - 1)) { topbarSheetIndex--; renderTopbarSheet(); } }; - right.onclick = () => { if (moveTopbarItem(topbarSheetIndex, topbarSheetIndex + 1)) { topbarSheetIndex++; renderTopbarSheet(); } }; - const done = node("button", "Done"); - done.type = "button"; - done.onclick = () => sheet.close(); - foot.append(remove, left, right, done); - sheet.append(body, foot); - renderTopbarLive(); -} -// "How it looks": the item as the bar draws it, updated while choosing. -function renderTopbarLive() { - const live = $("#topbar-live"), item = topbarItems()[topbarSheetIndex]; - if (!live || !item) return; - const view = topbarView(item); - const note = !view.shown ? "Hidden right now: not active" : topbarOverflow.has(topbarSheetIndex) ? "Doesn't currently fit next to the name" : "How it looks on the screen"; - live.replaceChildren(node("small", note), itemSample(item)); -} -function renderTopbarAdd(sheet) { - const badge = node("span", "+", "domain-icon"); - sheet.append(sheetHead(badge, "Add to the top bar", `${topbarItems().length} of ${topbarMax()} slots used`)); - const body = node("div", undefined, "sheet-body"); - const taken = new Set(topbarItems().map(itemKey)); - const own = node("div", undefined, "topbar-options"); - const samples = { clock: clockText(), analog: "Small dial with the time", date: dateText() }; - for (const builtin of inventory.header?.builtin || []) { - const item = { type: builtin.type }; - own.append(optionButton(iconNamed(BUILTIN_ICONS[builtin.type])?.cp, builtin.label, taken.has(itemKey(item)) ? "Already added" : samples[builtin.type], () => addTopbarItem(item), taken.has(itemKey(item)))); - } - body.append(group("From the screen itself", own)); - const suggested = inventory.header?.suggestions?.[selected] || []; - if (suggested.length) { - const list = node("div", undefined, "topbar-options"); - for (const s of suggested) { - const exists = taken.has(itemKey(s.item)); - list.append(optionButton(s.icon || automaticIcon(s.item.entity), s.label, exists ? "Already added" : [s.name, s.area].filter(Boolean).join(" · "), () => addTopbarItem(s.item), exists)); - } - body.append(group("Suggestions from Home Assistant", list)); - } - const search = node("input"); - search.type = "search"; - search.placeholder = "Search by name, room, or entity, e.g. temperature or door"; - const results = node("div", undefined, "topbar-results"); - const renderMatches = () => { - const query = search.value.trim().toLocaleLowerCase(); - const matches = inventory.entities.filter((e) => `${e.name} ${e.id} ${e.area} ${e.device}`.toLocaleLowerCase().includes(query)); - results.replaceChildren(...matches.slice(0, 40).map((e) => { - const item = { type: "entity", entity: e.id, content: "state", icon: "auto", show: "always" }; - return optionButton(automaticIcon(e.id), e.name, [e.area, e.id].filter(Boolean).join(" · "), () => addTopbarItem(item), taken.has(itemKey(item))); - })); - if (!matches.length) results.append(node("p", "No entities found.", "hint")); - else if (matches.length > 40) results.append(node("p", `${matches.length} results. Keep typing to narrow it down.`, "hint")); - }; - search.oninput = renderMatches; - search.setAttribute("aria-label", "Search for an entity for the top bar"); - const searchField = group("An entity", search); - searchField.append(results); - body.append(searchField); - renderMatches(); - const foot = node("div", undefined, "sheet-foot"); - const cancel = node("button", "Cancel", "quiet"); - cancel.type = "button"; - cancel.onclick = () => sheet.close(); - foot.append(cancel); - sheet.append(body, foot); -} -// A click in the picker: the marked empty cell, else the first free cell. -function addTile(id) { - if (layout.tiles.some((t) => t.entity === id) || layout.tiles.length >= tileLimit()) return; - const tile = { entity: id, name: "", ...defaultOptions(id) }; - const slot = insertAt >= 0 ? insertAt : firstFree(occupied(liveEntries()), isWide(tile)); - insertAt = -1; - selectedTile = id; - if (slot >= 0) placeTile(tile, slot); -} -// Pointer-based drag & drop, mouse and touch, from the picker into the mockup and between -// cells. Touch starts after a short hold so the page still scrolls. While dragging, the -// mockup already shows where everything ends up; the drop confirms exactly that, and a -// drop off the grid changes nothing. A finished drag never doubles as a click. -const drag = { active: false, source: null, element: null, ghost: null, timer: 0, start: null, offset: null, pointerId: null, suppressUntil: 0, last: null, scroller: 0, moving: null, target: null, preview: null }; -function enableDrag(element, source) { - element.addEventListener("pointerdown", (e) => { - if (e.button !== 0 || element.disabled || e.target.closest(".preview-remove")) return; - // No text selection while the mouse drags; touch keeps its default so the page can scroll. - if (e.pointerType !== "touch") e.preventDefault(); - Object.assign(drag, { source, element, start: { x: e.clientX, y: e.clientY }, pointerId: e.pointerId, active: false }); - // A fast flick may leave the card before its first move event: keep the pointer until the drag begins. - try { element.setPointerCapture(e.pointerId); } catch {} - clearTimeout(drag.timer); - if (e.pointerType === "touch") drag.timer = setTimeout(() => beginDrag(e), 260); - }); - element.addEventListener("pointermove", (e) => { - if (drag.active || !drag.start || drag.element !== element) return; - const distance = Math.hypot(e.clientX - drag.start.x, e.clientY - drag.start.y); - if (e.pointerType === "touch") { if (distance > 10) { clearTimeout(drag.timer); drag.start = null; } return; } - if (distance >= 6) beginDrag(e); - }); - const cancel = () => { if (drag.element === element && !drag.active) { clearTimeout(drag.timer); drag.start = null; } }; - element.addEventListener("pointerup", cancel); - element.addEventListener("pointercancel", cancel); -} -function beginDrag(e) { - if (drag.active || !drag.start) return; - drag.active = true; - drag.moving = drag.source.kind === "tile" ? drag.source.tile : { entity: drag.source.id, name: "", ...defaultOptions(drag.source.id) }; - drag.target = null; - drag.preview = null; - getSelection()?.removeAllRanges(); - const rect = drag.element.getBoundingClientRect(); - const ghost = drag.element.cloneNode(true); - ghost.classList.add("drag-ghost"); - ghost.style.width = `${rect.width}px`; - drag.offset = { x: e.clientX - rect.left, y: e.clientY - rect.top }; - document.body.append(ghost); - drag.ghost = ghost; - document.body.classList.add("dragging"); - // The mockup re-renders while hovering, so the pointer is followed on the document, not the card. - document.addEventListener("pointermove", moveDrag); - document.addEventListener("pointerup", finishDrag); - document.addEventListener("pointercancel", finishDrag); - try { drag.element.releasePointerCapture(drag.pointerId); } catch {} - try { document.documentElement.setPointerCapture(drag.pointerId); } catch {} - document.addEventListener("touchmove", blockScroll, { passive: false }); - // Near the viewport edges the page scrolls along, so the mockup can be reached on small screens. - drag.scroller = setInterval(() => { - if (!drag.last) return; - const step = drag.last.y < 70 ? -12 : drag.last.y > innerHeight - 70 ? 12 : 0; - if (step) { window.scrollBy(0, step); setTarget(slotAt(drag.last.x, drag.last.y)); } - }, 16); - setTarget(-1); - moveDrag(e); -} -function blockScroll(e) { if (drag.active) e.preventDefault(); } -function finishDrag(e) { if (e.pointerId === drag.pointerId) endDrag(e.type === "pointerup"); } -function moveDrag(e) { - if (!drag.ghost || e.pointerId !== drag.pointerId) return; - drag.last = { x: e.clientX, y: e.clientY }; - drag.ghost.style.transform = `translate(${e.clientX - drag.offset.x}px, ${e.clientY - drag.offset.y}px)`; - setTarget(slotAt(e.clientX, e.clientY)); -} -// The cell under the pointer: the nearest card or empty cell (the gaps between them count -// too); on a wide card the left or right half decides. -1 away from the mockup. -function slotAt(x, y) { - let best = null, nearest = Infinity; - for (const cell of document.querySelectorAll("#layout-preview [data-slot]")) { - const r = cell.getBoundingClientRect(); - const distance = Math.hypot(Math.max(r.left - x, 0, x - r.right), Math.max(r.top - y, 0, y - r.bottom)); - if (distance < nearest) { nearest = distance; best = { cell, r }; } - } - if (!best || nearest > 16) return -1; - let slot = Number(best.cell.dataset.slot); - if (best.cell.classList.contains("wide") && x > (best.r.left + best.r.right) / 2) slot += 1; - return slot; -} -function setTarget(slot) { - if (drag.target === slot) return; - drag.target = slot; - const result = slot >= 0 ? arrange(layout.tiles, drag.moving, slot) : null; - drag.preview = result; - // Off the grid: a tile from the grid shows where it came from; a new one shows nowhere yet. - renderPreview({ result: result || liveEntries(), moving: drag.moving }); -} -function endDrag(drop) { - const { preview } = drag; - document.removeEventListener("pointermove", moveDrag); - document.removeEventListener("pointerup", finishDrag); - document.removeEventListener("pointercancel", finishDrag); - document.removeEventListener("touchmove", blockScroll); - try { document.documentElement.releasePointerCapture(drag.pointerId); } catch {} - drag.ghost?.remove(); - Object.assign(drag, { ghost: null, active: false, suppressUntil: Date.now() + 400, last: null, start: null, moving: null, target: null, preview: null }); - clearInterval(drag.scroller); - document.body.classList.remove("dragging"); - if (!(drop && preview && commit(preview))) renderPreview(); -} -window.addEventListener("click", (e) => { if (Date.now() < drag.suppressUntil) { e.stopPropagation(); e.preventDefault(); } }, true); -// The picker offers what a tile can show: the inventory also lists locks, trackers and counters for the top bar (tile: false). -function pickable(entity) { - return entity.tile !== false; -} -function renderResults() { - if (!layout) return; - const query = $("#search").value.toLocaleLowerCase(); - const chosen = new Set(layout.tiles.map((t) => t.entity)); - // Camera images need a Guition (app 0.2.66); the add-on refuses them for other screens too. - const guition = inventory.screens.find((s) => s.id === selected)?.board === "guition"; - const matches = [...(inventory.builtin || []), ...inventory.entities].filter( - (e) => - pickable(e) && - (guition || !["camera", "image"].includes(e.id.split(".")[0])) && - (!filter || e.id.startsWith(filter + ".") || - ({switch:"input_boolean", number:"input_number", select:"input_select", weather:"sun", button:"input_button"}[filter] === e.id.split(".")[0])) && - `${e.name} ${e.id} ${e.device} ${e.area}` - .toLocaleLowerCase() - .includes(query), - ); - $("#results").replaceChildren(); - for (const entity of matches.slice(0, 80)) { - const b = node("button", undefined, "result"), - description = node("span"); - description.append( - node("strong", entity.name), - node("small", domains[entity.id.split(".")[0]]?.[0] || entity.id.split(".")[0], "domain-label"), - node("small", [entity.area, entity.device].filter(Boolean).join(" · ")), - node("small", entity.id), - ); - b.append( - domainBadge(entity.id), - description, - node("span", chosen.has(entity.id) ? "✓" : "+", "plus"), - ); - b.disabled = chosen.has(entity.id) || layout.tiles.length >= tileLimit(); - b.onclick = () => addTile(entity.id); - if (!b.disabled) enableDrag(b, { kind: "entity", id: entity.id }); - $("#results").append(b); - } - if (!matches.length) - $("#results").append( - node("p", "No entities found. Try a different name.", "hint"), - ); - if (matches.length > 80) - $("#results").append( - node( - "p", - `${matches.length} results. Keep typing to narrow it down.`, - "hint", - ), - ); -} -async function refresh(full = true) { - try { - const data = await (await api(full ? "inventory" : "inventory?light=1")).json(); - // The live stream can open a screen before the first full inventory arrives (an add-on busy building - // firmware answers it first); that screen was drawn without entity names and with an empty entity list. - const firstCatalogue = full && !inventory.entities?.length; - // A light poll carries only screens and update status; keep the catalogues we have. - inventory = full ? data : { ...inventory, ...data }; - $("#connection").textContent = inventory.connected - ? "● Home Assistant connected" - : "Reconnecting to Home Assistant…"; - $("#connection").classList.toggle("online", inventory.connected); - renderScreens(); - if (full) renderClaude(); - if (selected) { settleSettings(); renderSettings(); } - if (firstCatalogue && selected && layout && !drag.active && !chipDrag.active) { renderTopbar(); renderTiles(); renderResults(); } - if ($("#alerts-dialog").open) renderAlertScreens(); - } catch { - $("#connection").textContent = - "Management page unreachable · retrying…"; - } -} -$("#save").onclick = async () => { - if (busy) return; - busy = true; - $("#save").disabled = true; - try { - layout.title = $("#title").value; - // Screen settings apply on their own (flushSettings); the stored ones stay as they are. - const { settings: _settings, ...tiles } = layout; - await api(`screens/${encodeURIComponent(selected)}`, { - method: "PUT", - body: JSON.stringify(tiles), - }); - dirty = false; - $("#dirty").textContent = "Saved"; - $("#save-detail").textContent = - "Sync follows automatically, even after reconnecting."; - toast("Saved. Your screen is being updated."); - await refresh(); - } catch (e) { - toast(e.message); - } finally { - ((busy = false), (selectedTile = null)); - $("#save").disabled = false; - } -}; -$("#title").oninput = () => { markDirty(); renderPreview(); }; -$("#add-page").onclick = addPage; -$("#search").oninput = renderResults; -$("#refresh").onclick = refresh; -for (const [value, label] of [ - ["", "All"], - ["light", "Lights"], - ["climate", "Climate"], - ["switch", "Switches"], - ["binary_sensor", "Status"], - ["button", "Actions"], - ["script", "Scripts"], - ["fan", "Fans"], - ["cover", "Covers"], - ["scene", "Scenes"], - ["vacuum", "Vacuum"], - ["sensor", "Sensors"], - ["media_player", "Media"], - ["weather", "Weather"], - ["number", "Values"], - ["select", "Selects"], - ["person", "People"], - ["timer", "Timers"], - ["screen", "Clock"], -]) { - const b = node("button", label, value === "" ? "active" : ""); - if (value) b.prepend(domainBadge(value + ".")); - b.onclick = () => { - filter = value; - $("#filters") - .querySelectorAll("button") - .forEach((n) => n.classList.remove("active")); - b.classList.add("active"); - renderResults(); - }; - $("#filters").append(b); -} -// ----- New screen: profile, Wi-Fi, and the first flash in one window ----- -const installer = { - poll: null, view: "setup", file: null, friendly: "", board: "cyd", target: "", - apiKey: null, nodeEdited: false, ports: null, jobState: null, picked: false, action: null, -}; -// Download: ESP Screens builds, the owner flashes the file from their own computer. ESPHome Web is ESPHome's own -// browser flasher; this address opens it with its hint for a downloaded project (as ESPHome Device Builder does). -const ESPHOME_WEB = "https://web.esphome.io/?dashboard_install"; -const DOWNLOAD_TARGET = "Download · flash from your own computer"; -function firmwareFile(file) { - return { href: `api/firmware/profiles/${encodeURIComponent(file)}/download`, name: file.replace(/\.yaml$/, "") + ".factory.bin" }; -} -// ESPHome's node-name rule: lowercase ASCII, digits and dashes, starting with a letter. -function slug(text) { - const clean = text.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "") - .replace(/[^a-z0-9]+/g, "-").replace(/^[^a-z]+/, "").slice(0, 30).replace(/-+$/, ""); - return clean || "screen"; -} -function portLabel(port) { - const id = port.replace(/^\/dev\/serial\/by-id\/usb-/, "").replace(/-if\d+(-port\d+)?$/, "").replace(/_/g, " "); - return `USB · ${id === port ? port.replace(/^\/dev\//, "") : id}`; -} -function syncNode() { - const form = $("#install-form"); - if (!installer.nodeEdited) form.elements.name.value = slug(form.elements.friendly_name.value); - $("#node-preview").textContent = form.elements.name.value || "…"; -} -function renderTargets(ports) { - const select = $("#install-target"); - const key = ports.join("\n"); - if (installer.ports === key) return; - installer.ports = key; - const current = select.value; - const option = (value, text) => { const o = node("option", text); o.value = value; return o; }; - // USB on the Home Assistant machine is always listed first, also before a board is plugged in, so nobody - // concludes it isn't possible; "usb" stands for that port until one shows up. - const usb = ports.length ? ports.map((p) => option(p, portLabel(p))) : [option("usb", "USB · no board found on the Home Assistant machine yet")]; - select.replaceChildren(...usb, option("download", DOWNLOAD_TARGET), option("", "Later · save profile only")); - // Download or Later stays once the owner chose it; a USB choice follows the board. - const kept = current === "download" || current === "" ? installer.picked : ports.includes(current); - select.value = kept ? current : ports[0] || "usb"; - renderTargetHint(); -} -function renderTargetHint() { - const select = $("#install-target"); - const ports = [...select.options].filter((o) => o.value.startsWith("/")).length; - $("#target-hint").textContent = select.value === "usb" - ? "Connect the screen with a USB data cable to the Home Assistant machine; the list refreshes on its own. Or choose Download to use your own computer." - : select.value === "download" - ? "Download the firmware and put it on the screen from your own computer, in Chrome or Edge. After that, updates go over Wi-Fi." - : !select.value - ? "The profile goes into the ESPHome folder. You can install later via Settings → Firmware & USB, or from ESPHome Device Builder." - : ports > 1 - ? "More than one board connected: choose this screen's port." - : "Once over USB; after that, everything is wireless."; - $("#install-go").textContent = select.value === "download" ? "Build & download" : select.value ? "Install" : "Save profile"; -} -function renderWifi(wifi) { - const fields = $("#wifi-fields"); - const ask = wifi?.state === "new" || wifi?.state === "missing"; - fields.hidden = !ask; - fields.disabled = !ask; - if (ask) { - const missing = wifi.missing || []; - $("#wifi-ssid-label").hidden = !missing.includes("wifi_ssid"); - $("#wifi-password-label").hidden = !missing.includes("wifi_password"); - $("#wifi-status").textContent = wifi.state === "new" - ? "Fill in once: ESP Screens saves this in ESPHome secrets.yaml, later screens use it automatically." - : "Your ESPHome secrets.yaml is still missing Wi-Fi details. ESP Screens only fills in the missing lines."; - } - return wifi?.state === "ready" - ? "Wi-Fi comes from your ESPHome secrets.yaml." - : wifi?.state === "invalid" - ? "secrets.yaml in the ESPHome folder isn't valid YAML. Fix the file first; it won't be overwritten." - : ""; -} -async function installerRefresh() { - let data; - try { - data = await (await api("firmware")).json(); - } catch (e) { - $("#install-note").textContent = e.message; - return; - } - const job = data.job; - const ours = job && installer.file && job.file === installer.file; - if (ours) installer.jobState = job.state; - if (installer.view === "setup") { - if (ours && job.state === "running") return showProgress(job, data.logs); - renderTargets(data.ports || []); - const wifiNote = renderWifi(data.wifi); - const busy = job?.state === "running"; - const flashing = !!$("#install-target").value; - $("#install-note").textContent = busy - ? `A build or installation is already running (${job.file}). Wait for it to finish.` - : !data.available && flashing - ? "The ESPHome CLI is missing from this installation; only saving the profile is possible." - : wifiNote; - // "usb" without a port: the button waits for the board, the hint under the list says where to plug it in. - $("#install-go").disabled = data.wifi?.state === "invalid" || $("#install-target").value === "usb" || - (flashing && (busy || !data.available)); - } else if (installer.view === "progress" && ours) { - renderProgress(job, data.logs); - } -} -function showProgress(job, logs) { - installer.view = "progress"; - installer.jobState = job.state; - $("#install-setup").hidden = true; - $("#install-progress").hidden = false; - $("#install-log-wrap").hidden = false; - renderProgress(job, logs); -} -function outcome(ok) { - $("#progress-spin").hidden = true; - $("#progress-mark").hidden = false; - $("#progress-mark").textContent = ok ? "✓" : "✕"; - $("#progress-mark").className = `outcome ${ok ? "ok" : "bad"}`; -} -function renderProgress(job, logs) { - const running = job.state === "running"; - const ok = job.state === "success"; - const download = job.action === "download"; - installer.action = job.action; - if (running) { - $("#progress-spin").hidden = false; - $("#progress-mark").hidden = true; - } else outcome(ok); - $("#install-title").textContent = running ? "One moment…" : ok ? (download ? "Ready to download." : "Done.") : "That didn't work."; - $("#progress-title").textContent = running - ? job.stage === "upload" ? `Writing firmware to ${installer.friendly}…` : "Building firmware…" - : ok - ? download ? `Firmware for ${installer.friendly} is ready` : `Firmware is on ${installer.friendly}` - : download ? "Build failed" : "Install failed"; - $("#progress-detail").textContent = running - ? job.stage === "upload" - ? "Don't disconnect the USB cable yet." - : `A first build takes a few minutes on a Raspberry Pi. You can close this window: the ${download ? "build" : "installation"} keeps running and you'll find it again under New screen.` - : ok - ? download - ? "Put it on the screen from your own computer, then pair the screen with Home Assistant." - : `The screen boots up and connects to your Wi-Fi.${installer.board === "cyd" ? " The CYD first asks for a touch calibration: tap the crosshairs." : ""} Pair it with Home Assistant now:` - : logs.filter((l) => /error/i.test(l)).pop() || logs.filter((l) => /failed/i.test(l)).pop() || "See the log below."; - const pre = $("#install-log"); - const stick = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 8; - pre.textContent = logs.join("\n"); - if (stick) pre.scrollTop = pre.scrollHeight; - if (!running && !ok) $("#install-log-wrap").open = true; - $("#install-download").hidden = !(ok && download); - if (ok && download) renderDownload(); - $("#install-result").hidden = !ok; - if (ok) renderSteps(); - $("#install-retry").hidden = running || ok; - $("#install-close").textContent = ok ? "Done" : "Close"; - $("#install-close").classList.toggle("quiet", !ok); -} -function showSaved() { - installer.view = "done"; - $("#install-setup").hidden = true; - $("#install-progress").hidden = false; - outcome(true); - $("#install-title").textContent = "Profile saved."; - $("#progress-title").textContent = `${installer.file} is in the ESPHome folder`; - $("#progress-detail").textContent = - "Install it later from Settings → Firmware & USB: choose this profile, then the USB port of the Home Assistant machine, or Download to put the firmware on the screen from your own computer. Save the API key for pairing:"; - $("#install-download").hidden = true; - $("#install-result").hidden = false; - renderSteps(); - $("#install-log-wrap").hidden = true; - $("#install-retry").hidden = true; - $("#install-close").textContent = "Done"; - $("#install-close").classList.remove("quiet"); -} -// ESPHome Web's words: Connect, then Install and "Select the factory binary you want to install on your device". -function renderDownload() { - const file = firmwareFile(installer.file); - const link = $("#download-firmware"); - link.href = file.href; - link.download = file.name; - link.textContent = `Download ${file.name}`; - const web = node("a", "ESPHome Web"); - web.href = ESPHOME_WEB; - web.target = "_blank"; - web.rel = "noopener"; - const steps = [ - [["Plug the screen into your computer"], " with a USB data cable."], - [["Open ", web], " in Chrome or Edge, click Connect and choose the screen's USB port."], - [["Click Install"], ` and select ${file.name}. The screen restarts and joins your Wi-Fi.${installer.board === "cyd" ? " The CYD first asks for a touch calibration: tap the crosshairs." : ""}`], - ]; - $("#download-steps").replaceChildren(...steps.map(([bold, text]) => { - const li = node("li"); - const b = node("b"); - b.append(...bold); - li.append(b, text); - return li; - })); -} -function renderSteps() { - $("#api-key").textContent = installer.apiKey || ""; - const steps = [ - ["Go to Home Assistant → Settings → Devices & services.", ` This happens outside ESP Screens. Home Assistant discovers ${installer.friendly} as an ESPHome device; click Add. Not discovered? Add ESPHome manually with the screen's IP address. `], - ["Paste the API key", " above when Home Assistant asks for an encryption key."], - ["Allow HA actions:", " ESPHome integration → Configure → “Allow the device to perform Home Assistant actions”. Without this, the screen sees everything but controls nothing."], - ["Choose your tiles.", " Back in ESP Screens, the screen appears in the list on the left within about thirty seconds; until then it's shown there as “not yet in Home Assistant”."], - ]; - $("#install-steps").replaceChildren(...steps.map(([b, t], i) => { - const li = node("li"); - li.append(node("b", b), t); - if (i === 0) { - const go = node("button", "Open Devices & services", "mini"); - go.type = "button"; - go.onclick = openIntegrations; - li.append(go); - } - return li; - })); -} -function resetInstaller() { - Object.assign(installer, { view: "setup", file: null, apiKey: null, nodeEdited: false, ports: null, jobState: null, target: "", picked: false, action: null }); - const form = $("#install-form"); - form.reset(); - $("#node-label").hidden = true; - $("#install-setup").hidden = false; - $("#install-progress").hidden = true; - $("#install-download").hidden = true; - $("#install-result").hidden = true; - $("#install-log-wrap").open = false; - $("#install-log").textContent = ""; - $("#install-title").textContent = "Connect and install."; - $("#install-status").textContent = ""; - $("#install-note").textContent = ""; - $("#install-target").replaceChildren(); - $("#target-hint").textContent = ""; - $("#install-close").classList.add("quiet"); - syncNode(); -} -function openInstaller() { - if (installer.view !== "progress") resetInstaller(); - $("#installer").showModal(); - installerRefresh(); - clearInterval(installer.poll); - installer.poll = setInterval(installerRefresh, 3000); -} -$("#new-screen").onclick = $("#start").onclick = openInstaller; -$("#close-install").onclick = $("#install-close").onclick = () => $("#installer").close(); -$("#installer").addEventListener("close", () => { - clearInterval(installer.poll); - // A finished job is shown once; the next open starts a fresh form. - if (installer.view === "progress" && installer.jobState !== "running") installer.view = "done"; -}); -$("#install-target").onchange = () => { installer.picked = true; renderTargetHint(); installerRefresh(); }; -$("#install-form").elements.friendly_name.oninput = syncNode; -$("#install-form").elements.name.oninput = () => { installer.nodeEdited = true; syncNode(); }; -$("#edit-node").onclick = () => { - installer.nodeEdited = true; - $("#node-label").hidden = false; - $("#install-form").elements.name.focus(); -}; -$("#install-form").onsubmit = async (e) => { - e.preventDefault(); - const form = e.target; - syncNode(); - if (!form.reportValidity()) return; - const data = Object.fromEntries(new FormData(form)); - $("#install-go").disabled = true; - $("#install-status").textContent = ""; - try { - const result = await (await api("firmware/profiles", { method: "POST", body: JSON.stringify(data) })).json(); - Object.assign(installer, { file: result.file, apiKey: result.api_key, friendly: data.friendly_name.trim(), board: data.board, target: data.target }); - form.elements.wifi_password.value = ""; - if (result.job) showProgress(result.job, []); - else showSaved(); - } catch (err) { - $("#install-status").textContent = err.message; - } finally { - $("#install-go").disabled = false; - } -}; -$("#install-retry").onclick = async () => { - try { - const download = installer.action === "download"; - if (!download) { - const { ports } = await (await api("firmware")).json(); - // The board may have been replugged; a single visible port is unambiguous. - if (!ports.includes(installer.target) && ports.length === 1) installer.target = ports[0]; - } - const job = await (await api("firmware/jobs", { - method: "POST", - body: JSON.stringify(download - ? { file: installer.file, action: "download" } - : { file: installer.file, action: "install", target: installer.target }), - })).json(); - showProgress(job, []); - } catch (err) { - toast(err.message); - } -}; -$("#copy-key").onclick = () => copyText(installer.apiKey || "", $("#api-key")); -// A change still waiting for its short pause goes out when the page closes. -window.addEventListener("pagehide", () => flushSettings(true)); -window.addEventListener("beforeunload", (e) => { - if (dirty) { - e.preventDefault(); - e.returnValue = ""; - } -}); -refresh(); -// Poll only while the tab is visible; a hidden tab would otherwise keep the add-on busy. -// Live updates arrive over server-sent events; polling is the fallback while the stream is down, -// plus a full catalogue refresh every 5 minutes. -let pollTimer, lastFull = Date.now(), live = false, stream; -function applyLive(data) { - inventory = { ...inventory, ...data }; - $("#connection").textContent = inventory.connected ? "● Home Assistant connected" : "Reconnecting to Home Assistant…"; - $("#connection").classList.toggle("online", inventory.connected); - renderScreens(); - if (selected) { settleSettings(); renderSettings(); } -} -function listen() { - if (stream || typeof EventSource === "undefined") return; - stream = new EventSource("api/events"); - stream.onopen = () => { live = true; poll(); }; - stream.onmessage = (e) => { if (!document.hidden) applyLive(JSON.parse(e.data)); }; - stream.onerror = () => { live = false; poll(); }; -} -function poll() { - clearTimeout(pollTimer); - const wait = live ? 60000 : inventory.updates?.busy ? 3000 : 10000; - pollTimer = setTimeout(async () => { - if (!document.hidden) { - const full = Date.now() - lastFull >= 300000; - if (full) lastFull = Date.now(); - if (full || !live) await refresh(full); - } - poll(); - }, wait); -} -listen(); -poll(); -// The mockup's clocks tick and entity values in the top bar follow Home Assistant while the page is open. -setInterval(() => { - if (!layout || document.hidden || drag.active || chipDrag.active) return; - loadTopbarPreview(0); - renderTopbar(); - renderBars(); -}, 30000); -document.addEventListener("visibilitychange", async () => { - if (document.hidden) return; - lastFull = Date.now(); - await refresh(); - poll(); -}); - -// Shared firmware workspace; always select a concrete profile and upload target. -let firmwarePoll, firmwareData; -async function firmwareRefresh(initial = false) { - try { - const data = await (await api("firmware")).json(); - firmwareData = data; - if (initial) { - $("#firmware-file").replaceChildren( - ...data.profiles.map((p) => { - const o = node("option", p.file); - o.value = p.file; - return o; - }), - ); - } - renderFirmwarePorts(data.ports); - const running = data.job?.state === "running"; - // Build & install also depends on the target: renderFirmwareTarget below. - for (const id of ["validate", "build"]) - $("#firmware-" + id).disabled = - running || !data.available || !data.profiles.length; - $("#firmware-status").textContent = !data.available - ? "The ESPHome CLI is missing. Update the app to 0.2.0." - : data.job - ? `${data.job.file} · ${data.job.action} · ${data.job.state}` - : "Choose the intended profile, then a USB port, an IP address, or Download."; - $("#firmware-log").textContent = data.logs.join("\n"); - renderFirmwareTarget(); - } catch (e) { - toast(e.message); - } -} -// Wi-Fi / OTA, then USB on the Home Assistant machine (listed before a board is plugged in too, as in New screen), -// then Download. Rebuilt only when the ports change, so a choice survives the refresh. -function renderFirmwarePorts(ports) { - const select = $("#firmware-port"); - const key = ports.join("\n"); - if (select.dataset.ports === key) return; - select.dataset.ports = key; - const current = select.value; - const option = (value, text) => { const o = node("option", text); o.value = value; return o; }; - const usb = ports.length ? ports.map((p) => option(p, p)) : [option("usb", "USB · no board found on the Home Assistant machine yet")]; - select.replaceChildren(option("ota", "Wi-Fi / OTA"), ...usb, option("download", DOWNLOAD_TARGET)); - const usbChosen = current === "usb" || current.startsWith("/"); - select.value = ports.includes(current) || current === "ota" || current === "download" ? current : usbChosen ? ports[0] || "usb" : "ota"; -} -// The chosen target decides the host field and the main button; with Download, the file of the profile's last -// build appears as soon as there is one. -function renderFirmwareTarget() { - const target = $("#firmware-port").value; - const file = $("#firmware-file").value; - const data = firmwareData; - $("#firmware-host-label").hidden = target !== "ota"; - $("#firmware-install").textContent = target === "download" ? "Build & download" : "Build & install"; - $("#firmware-install").disabled = !data || data.job?.state === "running" || !data.available || !data.profiles.length || target === "usb"; - const ready = target === "download" && !!file && !!data?.downloads?.includes(file); - $("#firmware-download").hidden = !ready; - if (ready) { - const image = firmwareFile(file); - const link = $("#firmware-download-link"); - link.href = image.href; - link.download = image.name; - link.textContent = `Download ${image.name}`; - } -} -$("#open-firmware").onclick = () => { - $("#firmware-dialog").showModal(); - firmwareRefresh(true); - clearInterval(firmwarePoll); - firmwarePoll = setInterval(() => firmwareRefresh(), 3000); -}; -$("#close-firmware").onclick = () => { - $("#firmware-dialog").close(); - clearInterval(firmwarePoll); -}; -$("#firmware-dialog").addEventListener("close", () => - clearInterval(firmwarePoll), -); -$("#firmware-port").onchange = renderFirmwareTarget; -$("#firmware-file").onchange = () => { - $("#firmware-host").placeholder = $("#firmware-file").value.replace( - /\.yaml$/, - ".local", - ); - renderFirmwareTarget(); -}; -for (const action of ["validate", "build", "install"]) - $("#firmware-" + action).onclick = async () => { - const target = $("#firmware-port").value; - try { - await api("firmware/jobs", { - method: "POST", - body: JSON.stringify({ - file: $("#firmware-file").value, - action: - action === "validate" - ? "validate" - : action === "build" - ? "build" - : target === "download" - ? "download" - : "install", - target: - target === "ota" - ? $("#firmware-host").value.trim() - : target === "download" - ? "" - : target, - }), - }); - await firmwareRefresh(); - } catch (e) { - toast(e.message); - } - }; -function openSection(id) { - const section = $(id); - if (section.tagName === "DETAILS") section.open = true; - section.scrollIntoView({ behavior: "smooth", block: "start" }); -} -async function inspect(entity) { - if (!selected) return; - openSection("#inspector-section"); - $("#inspection-summary").textContent = "Fetching current HA status..."; - try { - const data = await ( - await api(`screens/${encodeURIComponent(selected)}/inspect`) - ).json(); - const tiles = entity - ? data.tiles.filter((t) => t.entity === entity) - : data.tiles; - $("#inspection-summary").replaceChildren(); - for (const tile of tiles) { - const card = node("article", undefined, "inspection-tile"); - card.append( - node("strong", tile.entity), - node("p", `Status: ${tile.word || tile.state}`), - ); - const options = - layout.tiles.find((t) => t.entity === tile.entity)?.options || {}; - card.append( - node( - "small", - `Small slider: ${options.inline === "slider" ? "yes" : "no"} · Display: ${displayNames[options.display || "standard"] || options.display} · Width: ${options.size === "wide" ? "double" : "normal"} · Control: ${controlsLabel({ entity: tile.entity, options })} · Background: ${inventory.backgrounds?.[options.background || "auto"]?.label || "Default"}`, - ), - ); - $("#inspection-summary").append(card); - } - $("#inspection").textContent = JSON.stringify( - entity ? tiles[0] : data, - null, - 2, - ); - } catch (e) { - $("#inspection-summary").textContent = e.message; - toast(e.message); - } -} -$("#inspect").onclick = () => inspect(); - -// ----- Alerts: the cheatsheet for esphome._show_alert, built from the inventory ----- -const versionAtLeast = (version, minimum) => { - const parse = (v) => (/^(\d+)\.(\d+)\.(\d+)$/.exec(v || "") || []).slice(1).map(Number); - const [a, b] = [parse(version), parse(minimum)]; - if (a.length !== 3 || b.length !== 3) return false; - for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i]; - return true; -}; -const yamlString = (text) => `"${String(text).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; -function copyChip(text, what) { - const b = node("button", "Copy", "mini"); - b.type = "button"; - b.onclick = () => copyText(text, undefined, what); - return b; -} -function alertExampleYaml(action) { - const fields = inventory.alerts?.fields || []; - const lines = fields.map((f) => ` ${f.name}: ${f.type === "string" ? (/^[a-z][a-z0-9-]*$/.test(f.example) ? f.example : yamlString(f.example)) : f.example === true ? "true" : f.example === false ? "false" : f.example}`); - return `action: ${action || "esphome._show_alert"}\ndata:\n${lines.join("\n")}`; -} -function alertWaitYaml(action) { - return [ - `# Doorbell: show the alert and wait until someone presses the button.`, - `actions:`, - ` - action: ${action || "esphome._show_alert"}`, - ` data:`, - ` title: "Someone is at the door"`, - ` subtitle: "Door 3, back"`, - ` icon: doorbell`, - ` color: orange`, - ` button_text: "Coming"`, - ` timeout: 0`, - ` flash: true`, - ` - wait_for_trigger:`, - ` - trigger: event`, - ` event_type: ${inventory.alerts?.event || "esphome.screen_alert"}`, - ` event_data:`, - ` action: ok`, - ` timeout: "00:05:00"`, - ` - if:`, - ` - condition: template`, - ` value_template: "{{ wait.trigger is not none }}"`, - ` then:`, - ` - action: notify.notify`, - ` data:`, - ` message: "Someone is coming to the door."`, - ].join("\n"); -} -// One event for every screen (app 0.2.45): an action for "Edit in YAML" of the Event action. -function alertAllYaml() { - const fields = inventory.alerts?.fields || []; - const lines = fields.map((f) => ` ${f.name}: ${f.type === "string" ? (/^[a-z][a-z0-9-]*$/.test(f.example) ? f.example : yamlString(f.example)) : String(f.example)}`); - const camera = inventory.alerts?.camera; - if (camera) lines.push(` # ${camera.name}: ${camera.example} # a Guition shows its picture on the card`); - return `event: ${inventory.alerts?.broadcast?.show || "esp_screens_show_alert"}\nevent_data:\n${lines.join("\n")}`; -} -function renderAlertBroadcast() { - const broadcast = inventory.alerts?.broadcast; - if (broadcast) { - $("#alerts-all-event").textContent = broadcast.show; - $("#alerts-all-dismiss").textContent = broadcast.dismiss; - } - $("#alerts-all-example").textContent = alertAllYaml(); -} -function renderAlertScreens() { - const alerts = inventory.alerts, list = $("#alerts-screen-list"); - if (!alerts) return; - $("#alerts-min-firmware").textContent = alerts.min_firmware; - list.replaceChildren(); - if (!inventory.screens.length) { - list.append(node("p", "No screen paired yet. Install one via New screen; the action appears once Home Assistant sees the screen.", "hint")); - } - for (const screen of inventory.screens) { - const row = node("div", undefined, "alert-screen"); - const head = node("div", undefined, "alert-screen-head"); - head.append(node("strong", screen.name)); - const ready = versionAtLeast(screen.firmware, alerts.min_firmware) && screen.alert_action; - const badge = node("span", ready ? `● firmware ${screen.firmware}` : screen.alert_action ? `Update needed · firmware ${screen.firmware || "unknown"}` : "Device name unknown · update the screen", `badge ${ready ? "online" : "update"}`); - head.append(badge); - row.append(head); - for (const [label, action] of [["Show alert", screen.alert_action], ["Dismiss alert", screen.dismiss_action]]) { - const line = node("div", undefined, "copy-line"); - line.append(node("span", label, "copy-label"), node("code", action || "esphome._show_alert")); - if (action) line.append(copyChip(action, "Action name")); - row.append(line); - } - list.append(row); - } - const select = $("#alerts-example-screen"), current = select.value; - select.replaceChildren(); - for (const screen of inventory.screens.filter((s) => s.alert_action)) { - const option = node("option", screen.name); - option.value = screen.alert_action; - select.append(option); - } - if (!select.options.length) { - const option = node("option", "a screen (not paired yet)"); - option.value = ""; - select.append(option); - } - if ([...select.options].some((o) => o.value === current)) select.value = current; - renderAlertExamples(); -} -function renderAlertExamples() { - const action = $("#alerts-example-screen").value; - $("#alerts-example").textContent = alertExampleYaml(action); - $("#alerts-wait-example").textContent = alertWaitYaml(action); -} -function alertIconCard(icon, label) { - const b = node("button", undefined, "alert-icon"); - b.type = "button"; - b.title = `Copy ${icon.name}`; - b.append(node("span", glyph(icon.cp), "mdi"), node("code", icon.name)); - if (label) b.append(node("small", label)); - b.onclick = () => copyText(icon.name, undefined, "Icon name"); - return b; -} -function renderAlertIcons() { - const alerts = inventory.alerts, icons = inventory.icons; - if (!alerts || !icons) return; - const suggested = $("#alerts-suggested"); - suggested.replaceChildren(); - for (const icon of alerts.suggested_icons) { - const chip = node("button", undefined, "chip"); - chip.type = "button"; - chip.append(node("span", glyph(icon.cp), "mdi"), node("code", icon.name)); - chip.onclick = () => copyText(icon.name, undefined, "Icon name"); - suggested.append(chip); - } - const query = $("#alerts-icon-search").value.trim().toLowerCase(); - const groups = $("#alerts-icon-groups"); - groups.replaceChildren(); - const all = [...icons.groups, { label: "Also available: control and weather icons", icons: alerts.extra_icons }]; - let shown = 0; - for (const group of all) { - const matches = group.icons.filter((i) => !query || i.name.includes(query) || (i.label || "").toLowerCase().includes(query)); - if (!matches.length) continue; - shown += matches.length; - const block = node("div", undefined, "alert-icon-group"); - block.append(node("p", `${group.label} · ${matches.length}`, "alerts-subhead")); - const grid = node("div", undefined, "alert-icon-grid"); - for (const icon of matches) grid.append(alertIconCard(icon, icon.label)); - block.append(grid); - groups.append(block); - } - if (!shown) groups.append(node("p", `No icon for "${query}". Unknown names show the warning triangle (${alerts.fallback_icon}).`, "hint")); -} -function renderAlertColors() { - const alerts = inventory.alerts, swatches = $("#alerts-swatches"); - if (!alerts) return; - swatches.replaceChildren(); - const white = node("button", undefined, "swatch"); - white.type = "button"; - const blank = node("i"); - blank.style.background = "#FFFFFF"; - white.append(blank, node("span", undefined)); - white.lastChild.append(node("code", "empty"), node("small", "White (default)")); - white.onclick = () => copyText("", undefined, "Empty color"); - swatches.append(white); - for (const colour of alerts.colors) { - const b = node("button", undefined, "swatch"); - b.type = "button"; - const dot = node("i"); - dot.style.background = colour.color; - const text = node("span"); - text.append(node("code", colour.name), node("small", `${colour.label} · ${colour.color}`)); - b.append(dot, text); - b.onclick = () => copyText(colour.name, undefined, "Color name"); - swatches.append(b); - } -} -function renderAlertTables() { - const alerts = inventory.alerts; - if (!alerts) return; - const types = { string: "text", int: "number", bool: "on / off" }; - const table = $("#alerts-field-table"); - table.replaceChildren(); - const head = node("tr"); - for (const title of ["Field", "Type", "What it does", "Example", "Limit"]) head.append(node("th", title)); - table.append(head); - for (const field of alerts.fields) { - const row = node("tr"); - const name = node("td"); - name.append(node("code", field.name), node("small", field.label)); - const example = node("td"); - example.append(node("code", typeof field.example === "string" ? field.example : String(field.example))); - const limit = alerts.limits.cyd[field.name]; - const cells = [name, node("td", types[field.type] || field.type), node("td", field.help), example, - node("td", limit ? `CYD ${limit} · Guition ${alerts.limits.guition[field.name]} bytes` : field.type === "int" ? "0 to 86400 s" : "—")]; - cells.forEach((cell, index) => cell.dataset.label = ["Field", "Type", "What it does", "Example", "Limit"][index]); - row.append(...cells); - table.append(row); - } - // The camera image (app 0.2.66): a field of the event for every screen, not of a screen's own action. - if (alerts.camera) { - const row = node("tr"), name = node("td"), example = node("td"); - name.append(node("code", alerts.camera.name), node("small", alerts.camera.label)); - example.append(node("code", alerts.camera.example)); - const cells = [name, node("td", "entity"), node("td", alerts.camera.help), example, node("td", "Guition, firmware 0.2.57+")]; - cells.forEach((cell, index) => cell.dataset.label = ["Field", "Type", "What it does", "Example", "Limit"][index]); - row.append(...cells); - table.append(row); - } - const endings = $("#alerts-ending-table"); - endings.replaceChildren(); - const endHead = node("tr"); - for (const title of ["action", "When"]) endHead.append(node("th", title)); - endings.append(endHead); - for (const ending of alerts.endings) { - const row = node("tr"), cell = node("td"), when = node("td", ending.label); - cell.append(node("code", ending.action)); - cell.dataset.label = "action"; - when.dataset.label = "When"; - row.append(cell, when); - endings.append(row); - } - $("#alerts-event-name").textContent = alerts.event; - const doorbell = alerts.suggested_icons.find((i) => i.name === "doorbell"); - if (doorbell) $("#alert-mock-icon").textContent = glyph(doorbell.cp); - const orange = alerts.colors.find((c) => c.name === "orange"); - if (orange) $("#alert-mock").querySelector(".alert-mock-card").style.background = orange.color; -} -function renderAlerts() { - if (!inventory.alerts) { - toast("The cheatsheet is still loading; try again in a moment."); - return false; - } - renderAlertTables(); - renderAlertScreens(); - renderAlertBroadcast(); - renderAlertIcons(); - renderAlertColors(); - return true; -} -$("#open-alerts").onclick = () => { - if (renderAlerts()) $("#alerts-dialog").showModal(); -}; -$("#close-alerts").onclick = () => $("#alerts-dialog").close(); -$("#alerts-example-screen").onchange = renderAlertExamples; -$("#alerts-example-copy").onclick = () => copyText($("#alerts-example").textContent, $("#alerts-example"), "YAML"); -$("#alerts-wait-copy").onclick = () => copyText($("#alerts-wait-example").textContent, $("#alerts-wait-example"), "YAML"); -$("#alerts-all-copy").onclick = () => copyText($("#alerts-all-example").textContent, $("#alerts-all-example"), "YAML"); -$("#alerts-icon-search").oninput = renderAlertIcons; -for (const b of document.querySelectorAll("#alerts-dialog [data-jump]")) { - b.onclick = () => document.getElementById(b.dataset.jump)?.scrollIntoView({ behavior: "smooth", block: "start" }); -} diff --git a/screen_manager/app/static/bar-roboto-400.woff b/screen_manager/app/static/assets/bar-roboto-400-H0q_-5Ia.woff similarity index 100% rename from screen_manager/app/static/bar-roboto-400.woff rename to screen_manager/app/static/assets/bar-roboto-400-H0q_-5Ia.woff diff --git a/screen_manager/app/static/bar-roboto-500.woff b/screen_manager/app/static/assets/bar-roboto-500-Bn7ZL9yA.woff similarity index 100% rename from screen_manager/app/static/bar-roboto-500.woff rename to screen_manager/app/static/assets/bar-roboto-500-Bn7ZL9yA.woff diff --git a/screen_manager/app/static/assets/index-D7h6hKdu.css b/screen_manager/app/static/assets/index-D7h6hKdu.css new file mode 100644 index 0000000..66dea48 --- /dev/null +++ b/screen_manager/app/static/assets/index-D7h6hKdu.css @@ -0,0 +1 @@ +:root{color-scheme:light dark;--ground: #f5f6f8;--surface: #ffffff;--surface-2: #f9fafb;--line: #e4e7ec;--line-strong: #d3d8e0;--ink: #16181d;--ink-2: #565d6b;--muted: #7a8290;--accent: #2f63f0;--accent-ink: #ffffff;--accent-soft: #edf2ff;--good: #1e9a5f;--good-soft: #e6f6ee;--warn: #b56d00;--warn-soft: #fff3df;--danger: #c6372e;--danger-soft: #fdecea;--seg: #eef0f3;--shadow: 0 12px 40px rgba(20, 24, 40, .12), 0 1px 2px rgba(20, 24, 40, .06);--code-bg: #f1f3f6;--ui-font: -apple-system, BlinkMacSystemFont, system-ui, "SF Pro Text", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace}@media(prefers-color-scheme:dark){:root{--ground: #0e1014;--surface: #161a21;--surface-2: #1b2029;--line: #262c37;--line-strong: #333b49;--ink: #e9ecf1;--ink-2: #b4bbc7;--muted: #8a93a3;--accent: #6d93ff;--accent-ink: #0b1633;--accent-soft: #1b2540;--good: #3fbf7f;--good-soft: #14302a;--warn: #e0a03a;--warn-soft: #302610;--danger: #f07167;--danger-soft: #3a1b19;--seg: #222834;--shadow: 0 16px 48px rgba(0, 0, 0, .55), 0 1px 2px rgba(0, 0, 0, .4);--code-bg: #12151b}}@font-face{font-family:Tile Icons;src:url(./tile-icons-BawP5y9D.woff) format("woff");font-display:block}@font-face{font-family:Bar Roboto;font-weight:400;src:url(./bar-roboto-400-H0q_-5Ia.woff) format("woff");font-display:block}@font-face{font-family:Bar Roboto;font-weight:500;src:url(./bar-roboto-500-Bn7ZL9yA.woff) format("woff");font-display:block}*{box-sizing:border-box}html,body,#app{height:100%}body{margin:0;background:var(--ground);color:var(--ink);font:13px/1.45 var(--ui-font);-webkit-font-smoothing:antialiased;font-variant-numeric:tabular-nums}button,input,select,textarea{font:inherit;color:inherit}button{background:none;border:0;padding:0;cursor:pointer;border-radius:8px}button:disabled{opacity:.45;cursor:default}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,[tabindex]:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:2px}input,select,textarea{background:var(--surface);border:1px solid var(--line-strong);border-radius:8px;padding:7px 10px;width:100%;color:var(--ink)}input::placeholder,textarea::placeholder{color:var(--muted)}select{appearance:auto}h1,h2,h3,h4{margin:0;font-weight:600;letter-spacing:-.01em}h1{font-size:20px;letter-spacing:-.02em}h2{font-size:15px}h3,h4{font-size:13px}p{margin:0}small,.hint{display:block;color:var(--muted);font-size:11.5px;font-weight:400;line-height:1.4}.hint.warn,small.warn{color:var(--warn)}code{font-family:var(--mono);font-size:.92em;background:var(--code-bg);padding:1px 6px;border-radius:5px;overflow-wrap:anywhere}pre{margin:0;padding:12px 14px;background:var(--code-bg);border:1px solid var(--line);border-radius:10px;overflow:auto;font:12px/1.5 var(--mono);color:var(--ink);white-space:pre-wrap;overflow-wrap:anywhere}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}.mdi{font-family:Tile Icons;font-style:normal;font-weight:400;line-height:1}[hidden]{display:none!important}.app{display:grid;grid-template-columns:232px minmax(0,1fr);height:100%;min-height:100vh}.app.dragging{user-select:none;-webkit-user-select:none;cursor:grabbing}.main{display:flex;flex-direction:column;min-width:0;min-height:0}.side{background:var(--surface);border-right:1px solid var(--line);display:flex;flex-direction:column;padding:14px 10px;gap:4px;min-height:0;overflow:auto;position:sticky;top:0;height:100vh}.brand{display:flex;align-items:center;gap:9px;padding:4px 8px 6px;font-weight:600;font-size:13px}.brand .mark{width:22px;height:22px;border-radius:6px;background:var(--ink);color:var(--surface);display:grid;place-items:center;font-size:14px;line-height:1}.conn{display:block;padding:0 8px 8px;font-size:11px;color:var(--muted)}.conn:before{content:"●";margin-right:6px;color:var(--warn)}.conn.online:before{color:var(--good)}.side .label{font-size:10.5px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);padding:10px 8px 4px}.nav-item{display:flex;align-items:center;gap:9px;width:100%;padding:7px 8px;border-radius:8px;text-align:left;color:var(--ink-2)}.nav-item:hover{background:var(--surface-2);color:var(--ink)}.nav-item[aria-current=true]{background:var(--seg);color:var(--ink);font-weight:500}.nav-item .dot{width:7px;height:7px;border-radius:50%;background:var(--good);flex:none}.nav-item .dot.off{background:var(--line-strong)}.nav-item .txt{display:grid;min-width:0;flex:1}.nav-item .txt small{font-size:11px;color:var(--muted);font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.nav-item .mdi{font-size:16px;width:18px;text-align:center;color:var(--muted)}.nav-item .plus{width:18px;text-align:center;font-size:14px}.nav-item.ghost{color:var(--muted)}.nav-item.ghost:hover{color:var(--accent);background:var(--accent-soft)}.pill{font-size:10.5px;font-weight:600;padding:2px 7px;border-radius:999px;background:var(--warn-soft);color:var(--warn);flex:none}.side .spacer{flex:1}.screen-item{display:grid;gap:4px}.screen-update{display:grid;gap:6px;padding:2px 8px 8px 24px}.screen-update.available{grid-template-columns:1fr auto;align-items:center}.screen-update.available:has(.screen-host){grid-template-columns:1fr}.screen-update small.failed{color:var(--danger)}.screen-host{display:flex;gap:6px;align-items:center}.screen-host input{padding:5px 8px;font-size:12px}.pending{margin:4px 0;padding:10px;border:1px dashed var(--line-strong);border-radius:10px;display:grid;gap:6px}.pending strong{font-size:12.5px}.pending-actions{display:flex;flex-wrap:wrap;gap:6px}.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:6px 12px;border-radius:8px;font-weight:600;font-size:12.5px;white-space:nowrap}.btn.primary{background:var(--accent);color:var(--accent-ink)}.btn.primary:hover:not(:disabled){filter:brightness(1.06)}.btn.quiet{color:var(--ink-2);border:1px solid var(--line-strong);background:var(--surface)}.btn.quiet:hover:not(:disabled){background:var(--surface-2);color:var(--ink)}.btn.danger{color:var(--danger)}.btn.danger:hover:not(:disabled){background:var(--danger-soft)}.btn.mini{padding:4px 9px;font-size:11.5px}.btn.link{color:var(--accent);padding:0;font-weight:600}.icon-btn{width:30px;height:30px;display:grid;place-items:center;border-radius:8px;color:var(--ink-2);font-size:15px;flex:none}.icon-btn:hover{background:var(--seg);color:var(--ink)}.chip{display:inline-flex;align-items:center;gap:6px;padding:3px 9px;border-radius:999px;font-size:11.5px;font-weight:500;color:var(--ink-2);background:var(--surface-2);border:1px solid var(--line);white-space:nowrap}.chip .dot{width:6px;height:6px;border-radius:50%;background:var(--good);flex:none}.chip.off .dot{background:var(--line-strong)}.chip.dirty{color:var(--warn);background:var(--warn-soft);border-color:transparent}.chip.sent,.chip.good{color:var(--good);background:var(--good-soft);border-color:transparent}.chip.update{color:var(--warn);background:var(--warn-soft);border-color:transparent}.seg{display:inline-flex;padding:2px;background:var(--seg);border-radius:8px;gap:2px}.seg button{padding:4px 11px;border-radius:6px;font-size:12px;font-weight:500;color:var(--ink-2);white-space:nowrap}.seg button[aria-pressed=true]{background:var(--surface);color:var(--ink);box-shadow:0 1px 2px #14182824}.seg.wrap{flex-wrap:wrap}.seg button:hover:not(:disabled):not([aria-pressed=true]){color:var(--ink)}.spin{display:inline-block;width:14px;height:14px;border:2px solid var(--line-strong);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;flex:none}.spin.small{width:11px;height:11px;border-width:1.5px}@keyframes spin{to{transform:rotate(360deg)}}.switch{width:34px;height:20px;border-radius:10px;background:var(--line-strong);position:relative;flex:none;padding:0}.switch:after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 2px #0003;transition:left .12s}.switch[aria-checked=true]{background:var(--accent)}.switch[aria-checked=true]:after{left:16px}.switch.unknown:after{left:9px;opacity:.6}@media(prefers-reduced-motion:reduce){.switch:after,.drawer,.spin{transition:none;animation:none}}.main-head{display:flex;align-items:center;gap:12px;padding:0 18px;min-height:52px;border-bottom:1px solid var(--line);background:var(--surface);position:sticky;top:0;z-index:4}.main-head h1{font-size:15px}.main-head .eyebrow{font-size:10.5px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}.head-right{margin-left:auto;display:flex;align-items:center;gap:8px;position:relative}.menu{position:absolute;top:38px;right:0;min-width:210px;background:var(--surface);border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);padding:6px;display:grid;z-index:6}.menu button{text-align:left;padding:7px 10px;border-radius:7px;color:var(--ink);display:flex;justify-content:space-between;gap:12px;width:100%}.menu button small{color:var(--muted);display:inline}.menu button:hover:not(:disabled){background:var(--surface-2)}.body{position:relative;display:grid;grid-template-columns:minmax(0,1fr) 300px;flex:1;min-height:0;overflow:hidden}.canvas{overflow:auto;padding:22px 22px 26px;min-width:0}.canvas-head{display:flex;align-items:baseline;gap:12px;margin-bottom:14px;color:var(--muted);font-size:12px;flex-wrap:wrap}.canvas-head b{color:var(--ink);font-weight:600;font-size:13px}.canvas-head .warn{color:var(--warn)}.pages{display:flex;gap:18px;align-items:flex-start}.page{flex:0 0 292px;display:grid;gap:8px}.page-label{display:flex;justify-content:space-between;align-items:center;font-size:11.5px;color:var(--muted);padding:0 4px;min-height:22px}.page-label .btn.mini{color:var(--muted)}.page-label .btn.mini:hover{color:var(--danger);background:var(--danger-soft)}.page.ghost .device{display:grid;place-items:center;border:1.5px dashed var(--line-strong);background:transparent;color:var(--muted);font-weight:500;cursor:pointer}.page.ghost .device:hover,.page.ghost .device:focus-visible{color:var(--accent);border-color:var(--accent)}.page.ghost .device.drop{color:var(--accent);border-color:var(--accent);background:var(--accent-soft)}.page.ghost.disabled .device{opacity:.5;cursor:default}.device{background:#e7e7e7;border-radius:18px;padding:10px;display:grid;grid-template-rows:auto minmax(0,1fr);gap:8px;color:#1b1b1b;aspect-ratio:1 / 1}.device.cyd{aspect-ratio:4 / 3}.bar-wrap{cursor:pointer;border-radius:8px;padding:0 2px}.bar-wrap:hover{background:#dedede}.bar-wrap.selected{outline:2px solid var(--accent);outline-offset:1px}.bar-wrap svg{display:block;width:100%;height:auto}.tiles{display:grid;grid-template-columns:1fr 1fr;grid-auto-rows:minmax(0,1fr);gap:8px;min-height:0}.tile{position:relative;background:#fff;border:1px solid #dcdcdc;border-radius:12px;padding:8px 9px;display:flex;flex-direction:column;justify-content:space-between;gap:4px;min-height:0;cursor:pointer;text-align:left;overflow:hidden;color:#1b1b1b;touch-action:pan-y}.tile:hover{border-color:#b9c4d3}.tile.chosen{outline:2px solid var(--accent);outline-offset:1px}.tile.wide{grid-column:span 2;flex-direction:row;align-items:center;gap:8px}.tile.bare{background:transparent;border-style:dashed;border-color:#c3c9d0}.tile.placeholder{opacity:.55;border:2px dashed var(--accent);background:var(--accent-soft);cursor:default}.tile .ic{font-size:18px;line-height:1;color:#1b1b1b;flex:none}.tile .lead{display:grid;gap:2px;min-width:0;flex:1;align-content:end}.tile.wide .lead{display:flex;align-items:center;gap:8px;align-content:initial}.tile.wide .lead .tx{display:grid;min-width:0;gap:1px}.tile .nm{font-size:11.5px;font-weight:600;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tile .st{font-size:10.5px;color:#5a5f66;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.device.cyd .tile{padding:6px 7px}.device.cyd .tile .nm{font-size:10.5px}.device.cyd .tile .ic{font-size:15px}.tile .mini-slider{height:10px;border-radius:5px;background:linear-gradient(to right,#ffbf38 60%,#fff1d3 60%);flex:none;width:100%}.tile.wide .mini-slider{width:70px}.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 .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}.tile .stp .mdi{font-size:12px}.tile .run{height:20px;padding:0 9px;border-radius:10px;background:#eceef1;font-size:11px;font-weight:600;line-height:20px}.tile .remove{position:absolute;top:5px;right:5px;width:22px;height:22px;border-radius:50%;background:#ffffffd9;color:#5a5f66;border:1px solid #dcdcdc;font-size:10px;line-height:1;display:grid;place-items:center;opacity:0;transition:opacity .12s}.tile:hover .remove,.tile:focus-within .remove{opacity:1}.tile .remove:hover{color:var(--danger);border-color:#e7b4b0;background:#fff}@media(hover:none){.tile .remove{opacity:1}}.tile .clockface{width:46px;height:46px;margin:auto}.tile.wide .clockface{margin:0;flex:none}.cell{min-height:0;border:1px dashed #cfd4da;border-radius:12px;background:transparent;color:#a3abb5;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1px;font-weight:500;cursor:pointer;padding:6px}.cell span{font-size:18px;line-height:1}.cell small{font-size:10.5px;color:inherit}.cell:hover,.cell:focus-visible{border-color:#9eb6cf;color:#4b5563;background:#f6f7f9}.cell.insert-here{border:2px dashed var(--accent);color:var(--accent);background:var(--accent-soft)}.drag-ghost{position:fixed;left:0;top:0;pointer-events:none;z-index:50;opacity:.92;box-shadow:var(--shadow);border-radius:12px}.library{border-left:1px solid var(--line);background:var(--surface);display:flex;flex-direction:column;min-height:0}.lib-head{padding:14px 14px 10px;display:grid;gap:10px;border-bottom:1px solid var(--line)}.lib-title{display:flex;align-items:baseline;justify-content:space-between;font-weight:600;gap:8px}.lib-title small{color:var(--muted);font-weight:400;font-size:11.5px}.filters{display:flex;gap:6px;overflow-x:auto;scrollbar-width:none;padding-bottom:2px}.filters::-webkit-scrollbar{display:none}.filters button{flex:none;display:inline-flex;align-items:center;gap:5px;padding:4px 10px;border-radius:999px;font-size:11.5px;font-weight:500;color:var(--ink-2);background:var(--surface-2);border:1px solid var(--line)}.filters button[aria-pressed=true]{background:var(--ink);color:var(--surface);border-color:var(--ink)}.filters .domain-icon{width:16px;height:16px;font-size:11px;border-radius:50%;display:grid;place-items:center}.lib-list{overflow:auto;padding:8px;display:grid;gap:2px;align-content:start;flex:1}.ent{display:flex;align-items:center;gap:10px;padding:8px;border-radius:9px;width:100%;text-align:left;cursor:grab;touch-action:pan-y}.ent:hover:not(:disabled){background:var(--surface-2)}.ent:disabled{opacity:.55;cursor:default}.ent .av{width:30px;height:30px;border-radius:9px;display:grid;place-items:center;font-size:17px;flex:none}.ent .tx{display:grid;min-width:0;flex:1}.ent .tx b{font-weight:500;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ent .tx small{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ent .add{width:24px;height:24px;border-radius:50%;display:grid;place-items:center;color:var(--accent);background:var(--accent-soft);font-size:15px;font-weight:600;flex:none}.ent .add.done{color:var(--good);background:var(--good-soft);font-size:12px}.lib-foot{padding:10px 14px;border-top:1px solid var(--line);font-size:11.5px;color:var(--muted)}.domain-icon{display:inline-grid;place-items:center;border-radius:50%;font-weight:500}.drawer{position:absolute;top:0;right:0;bottom:0;width:360px;max-width:100%;background:var(--surface);border-left:1px solid var(--line);box-shadow:var(--shadow);transform:translate(102%);transition:transform .18s cubic-bezier(.2,.7,.2,1);display:flex;flex-direction:column;min-height:0;z-index:5}.drawer.open{transform:none}.dr-head{display:flex;align-items:center;gap:10px;padding:12px 12px 12px 16px;border-bottom:1px solid var(--line)}.dr-head .av{width:34px;height:34px;border-radius:10px;display:grid;place-items:center;font-size:20px;flex:none}.dr-head .tx{flex:1;min-width:0;display:grid}.dr-head .tx b{font-weight:600;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dr-head .tx small{color:var(--muted);font-size:11.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dr-head .tx small.mono{font-family:var(--mono)}.dr-body{overflow:auto;padding:14px 16px 18px;display:grid;gap:16px;align-content:start;flex:1}.f{display:grid;gap:7px}.f>.f-label{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}.f input,.f select{font-size:13px}.row{display:flex;align-items:center;gap:10px;padding:7px 10px 7px 8px;border:1px solid var(--line-strong);border-radius:9px;background:var(--surface);width:100%;text-align:left}.row .av{width:30px;height:30px;border-radius:8px;background:var(--surface-2);display:grid;place-items:center;font-size:18px;flex:none}.row .tx{flex:1;min-width:0;display:grid}.row .tx b{font-weight:500}.row .tx small{font-size:11px}.row .link{color:var(--accent);font-weight:600;font-size:12px;flex:none}.sw{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}.sw button{display:grid;justify-items:center;gap:4px;padding:6px 4px 5px;border-radius:9px;border:1.5px solid transparent;font-size:10.5px;color:var(--ink-2)}.sw button i{width:30px;height:22px;border-radius:6px;border:1px solid rgba(0,0,0,.08);display:block}.sw button i.auto{background:linear-gradient(135deg,#fff 50%,#e5e7eb 50%)}.sw button i.none{background:linear-gradient(135deg,transparent 46%,#8b95a1 46%,#8b95a1 54%,transparent 54%),#e7e7e7;border-style:dashed}.sw button[aria-pressed=true]{border-color:var(--accent);background:var(--accent-soft);color:var(--ink)}.picker{display:grid;gap:10px;padding:10px;border:1px solid var(--line);border-radius:10px;background:var(--surface-2)}.picker input{background:var(--surface)}.icon-list{display:grid;gap:12px;max-height:300px;overflow:auto;padding-right:2px}.icon-list small{margin-bottom:6px}.icon-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(40px,1fr));gap:6px}.icon-choice{display:grid;place-items:center;height:40px;background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:9px}.icon-choice .mdi{font-size:22px}.icon-choice[aria-pressed=true]{background:var(--accent-soft);color:var(--accent);border-color:var(--accent)}.icon-choice.icon-auto{display:flex;justify-content:flex-start;gap:10px;padding:0 12px;font-size:12.5px;font-weight:500;height:38px}.action-list{display:grid;gap:6px;max-height:300px;overflow:auto;padding-right:2px}.action-choice{display:grid;gap:2px;padding:8px 12px;background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:9px;font-size:12.5px;text-align:left}.action-choice strong{font-weight:600}.action-choice[aria-pressed=true]{background:var(--accent-soft);border-color:var(--accent)}.action-number{display:flex;align-items:center;gap:8px}.action-number input{flex:1;min-width:0}.items{display:grid;gap:6px}.item{display:flex;align-items:center;gap:8px;padding:7px 8px;border:1px solid var(--line-strong);border-radius:9px;background:var(--surface);width:100%;text-align:left;cursor:pointer;touch-action:pan-y}.item:hover{border-color:var(--accent)}.item.selected{border-color:var(--accent);background:var(--accent-soft)}.item.is-hidden{border-style:dashed}.item.is-hidden .av,.item.is-hidden b{opacity:.55}.item.is-overflow{border-style:dashed;border-color:var(--warn)}.item.is-overflow small{color:var(--warn)}.item.dragging-chip{border:2px dashed var(--accent);background:var(--accent-soft);cursor:grabbing}.item.just-added{animation:chip-added 1.2s ease-out}@keyframes chip-added{0%{background:var(--accent-soft)}to{background:var(--surface)}}.item .grip{color:var(--line-strong);font-size:13px;cursor:grab;letter-spacing:-2px;flex:none}.item .av{width:26px;height:26px;border-radius:7px;background:var(--surface-2);display:grid;place-items:center;font-size:15px;flex:none}.item .tx{flex:1;min-width:0;display:grid}.item .tx b{font-weight:500;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.item .tx small{color:var(--muted);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.item .x{color:var(--muted);width:22px;height:22px;display:grid;place-items:center;border-radius:6px;flex:none;font-size:11px}.item .x:hover{color:var(--danger);background:var(--danger-soft)}.ghost-btn{border:1.5px dashed var(--line-strong);border-radius:9px;padding:8px;color:var(--accent);font-weight:600;width:100%;background:transparent}.ghost-btn:hover:not(:disabled){border-color:var(--accent);background:var(--accent-soft)}.dr-foot{margin-top:auto;display:flex;align-items:center;gap:8px;padding:10px 12px;border-top:1px solid var(--line);background:var(--surface-2);flex-wrap:wrap}.dr-foot .spacer{flex:1}.live{display:grid;gap:6px;padding:10px;border:1px solid var(--line);border-radius:10px;background:#e7e7e7;color:#1b1b1b;justify-items:start}.live small{color:#5a5f66}.live svg{display:block;height:28px}.option{display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface);width:100%;text-align:left}.option:hover:not(:disabled){border-color:var(--accent)}.option .mdi{font-size:20px;width:26px;text-align:center;color:var(--ink-2);flex:none}.option .tx{display:grid;min-width:0}.option .tx strong{font-weight:500;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.option .tx small{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.options{display:grid;gap:6px}.inspection-tile{display:grid;gap:3px;padding:8px 10px;border:1px solid var(--line);border-radius:9px;margin-bottom:6px}.inspection-tile strong{font-size:12.5px}.settings{padding:22px;overflow:auto;display:grid;gap:14px;align-content:start;grid-column:1 / -1}.settings .status{font-size:12px;color:var(--muted)}.set-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:14px}.set-card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:6px 14px 8px}.set-card h4{display:flex;align-items:center;gap:8px;padding:10px 0 8px}.set-card h4 .mdi{color:var(--muted);font-size:16px}.srow{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 0;border-top:1px solid var(--line)}.srow>.s-label{color:var(--ink-2)}.srow.inactive .s-label,.srow.inactive .s-control{opacity:.45}.settings.offline .set-grid{opacity:.6}.step{display:inline-flex;align-items:center;gap:2px}.step button{width:26px;height:26px;border-radius:6px;color:var(--ink-2);background:var(--seg);display:grid;place-items:center}.step button .mdi{font-size:15px}.step button:hover:not(:disabled){color:var(--ink);background:var(--line-strong)}.step output{min-width:56px;text-align:center;font-weight:500}.panel{padding:22px 26px 40px;overflow:auto;display:grid;gap:18px;align-content:start;max-width:960px;width:100%}.panel.wide{max-width:1120px}.panel-head{display:flex;align-items:flex-start;gap:12px}.panel-head .tx{display:grid;gap:2px;flex:1;min-width:0}.panel-head h1{font-size:20px}.panel-head p{color:var(--muted);font-size:13px}.panel p,.panel li{color:var(--ink-2);font-size:13px}.card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px 18px;display:grid;gap:12px;align-content:start}.card h2{font-size:14px}.card-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:14px}.field{display:grid;gap:6px}.field>.f-label,fieldset legend{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}fieldset{border:0;padding:0;margin:0;display:grid;gap:8px;min-width:0}fieldset:disabled{opacity:.6}.boards{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px}.board{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--line-strong);border-radius:10px;cursor:pointer}.board:has(input:checked){border-color:var(--accent);background:var(--accent-soft)}.board input{width:auto;margin:0;accent-color:var(--accent)}.board b{display:block;font-weight:600}.actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.status-line{font-size:12.5px;color:var(--ink-2)}.status-line.ok{color:var(--good)}.status-line.error{color:var(--danger)}.progress-head{display:flex;align-items:center;gap:10px;font-weight:600;font-size:14px}.outcome{width:22px;height:22px;border-radius:50%;display:grid;place-items:center;color:#fff;font-size:12px;font-weight:700}.outcome.ok{background:var(--good)}.outcome.bad{background:var(--danger)}.steps{margin:0;padding-left:20px;display:grid;gap:8px}.steps li b{color:var(--ink)}.key-box{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface-2);flex-wrap:wrap}.key-box code{flex:1;min-width:0;background:none;padding:0;font-size:12px}.log{max-height:320px}details.log-wrap summary{cursor:pointer;font-weight:500;color:var(--ink-2);font-size:12.5px}details.log-wrap pre{margin-top:8px}.check{display:flex;align-items:flex-start;gap:10px;cursor:pointer}.check input{width:18px;height:18px;margin:1px 0 0;accent-color:var(--accent)}.check span{display:grid;gap:2px;font-weight:500}.node-line{display:flex;align-items:center;gap:6px;flex-wrap:wrap}.tools{display:flex;gap:10px;flex-wrap:wrap}.tool{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface);text-align:left;min-width:220px}.tool:hover{border-color:var(--accent)}.tool .tool-icon{width:30px;height:30px;border-radius:8px;background:var(--seg);display:grid;place-items:center;font-size:15px;color:var(--ink-2);flex:none}.tool .tx{display:grid}.tool .tx b{font-weight:600}.yaml-editor{display:grid;grid-template-columns:44px minmax(0,1fr);border:1px solid var(--line-strong);border-radius:10px;overflow:hidden;background:var(--code-bg);min-height:280px;max-height:60vh}.yaml-gutter{padding:12px 8px;text-align:right;color:var(--muted);font:12px/1.5 var(--mono);white-space:pre;user-select:none;overflow:hidden;border-right:1px solid var(--line)}.yaml-editor textarea{border:0;border-radius:0;background:transparent;resize:none;font:12px/1.5 var(--mono);padding:12px;white-space:pre;overflow:auto;min-height:280px;tab-size:2}.file-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.file-row code{font-size:12px}.notice{display:flex;gap:12px;padding:12px 14px;border:1px solid var(--line);border-radius:10px;background:var(--surface-2)}.notice .mdi{font-size:20px;color:var(--ink-2)}.notice strong{display:block;font-weight:600;margin-bottom:2px}.alerts section{display:grid;gap:10px}.alerts h2{font-size:15px}.alerts-nav{display:flex;flex-wrap:wrap;gap:6px}.alerts-nav button{background:var(--accent-soft);color:var(--accent);padding:5px 11px;font-size:12px;border-radius:20px;font-weight:500}.alert-hero{display:grid;grid-template-columns:minmax(0,300px) 1fr;gap:20px;align-items:center}.alert-mock{background:#101820;border-radius:16px;padding:22px 18px;display:grid;place-items:center}.alert-mock-card{position:relative;width:100%;background:#ffe1c6;border:1px solid #0001;border-radius:14px;padding:12px 12px 48px 52px;color:#1b1b1b;display:grid;gap:2px}.alert-mock-icon{position:absolute;left:12px;top:12px;font-size:30px}.alert-mock-card strong{font-size:15px;line-height:1.2}.alert-mock-card>span{font-size:12px;color:#3a3a3c}.alert-mock-card .fake{position:absolute;right:10px;bottom:10px;padding:6px 16px;border-radius:999px;background:#1b1b1b;color:#fff;font-size:13px}.alert-screen{border:1px solid var(--line);border-radius:10px;padding:10px 12px;display:grid;gap:8px;background:var(--surface)}.alert-screen-head{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}.copy-line{display:flex;align-items:flex-start;gap:8px;flex-wrap:wrap}.copy-line .copy-label{flex:none;width:96px;font-size:12px;color:var(--muted);padding-top:2px}.copy-line>code{flex:1;min-width:0;padding:4px 8px}.copy-line pre{flex:1;min-width:0}.table-scroll{overflow-x:auto}table{width:100%;border-collapse:collapse;font-size:12.5px;min-width:560px}th,td{text-align:left;vertical-align:top;padding:8px 12px 8px 0;border-bottom:1px solid var(--line)}th{font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);font-weight:600}td code{white-space:nowrap}.chips{display:flex;flex-wrap:wrap;gap:6px}.chips .chip .mdi{font-size:16px}.chips .chip code{background:none;padding:0}.alert-icon-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:8px}.alert-icon{display:grid;justify-items:center;align-content:start;gap:4px;min-height:86px;padding:10px 6px 8px;background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:10px;font-weight:500}.alert-icon:hover{border-color:var(--accent)}.alert-icon .mdi{font-size:26px}.alert-icon code{background:none;padding:0;font-size:11px;color:var(--accent);text-align:center;white-space:normal}.alert-icon small{font-size:11px;text-align:center}.swatches{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px}.swatch{display:flex;align-items:center;gap:10px;padding:7px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--ink);font-size:12px;text-align:left;font-weight:500}.swatch:hover{border-color:var(--accent)}.swatch i{width:32px;height:32px;border-radius:8px;border:1px solid #0001;flex:none;display:block}.swatch span{display:grid;gap:2px;min-width:0}.swatch code{background:none;padding:0}.alerts-list{padding-left:18px;margin:0;display:grid;gap:8px}.alerts-list b{color:var(--ink)}.subhead{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-top:6px}.empty{margin:auto;max-width:420px;text-align:center;display:grid;gap:10px;justify-items:center;padding:60px 20px}.empty-icon{width:48px;height:48px;border-radius:14px;background:var(--seg);color:var(--muted);display:grid;place-items:center;font-size:26px}.empty h2{font-size:18px}.empty p{color:var(--muted)}.toast{position:fixed;left:50%;bottom:20px;transform:translate(-50%);display:flex;align-items:center;gap:14px;padding:10px 16px;background:var(--ink);color:var(--surface);border-radius:10px;box-shadow:var(--shadow);font-size:13px;z-index:60;max-width:min(92vw,560px)}.toast button{color:#9fc4ff;font-weight:650}@media(max-width:960px){.app{grid-template-columns:1fr;min-height:100vh}.side{position:static;height:auto;flex-direction:row;flex-wrap:wrap;align-items:center;border-right:0;border-bottom:1px solid var(--line);padding:10px;gap:4px 6px}.side .label,.side .spacer,.conn{display:none}.side #screens,.screen-item{display:contents}.screen-update{display:none}.side #pending{flex-basis:100%}.nav-item{width:auto}.more{display:contents}.main-head{flex-wrap:wrap;padding:10px 14px;gap:8px;position:static}.head-right{margin-left:0;width:100%}.body{grid-template-columns:1fr;overflow:visible}.canvas{padding:16px;overflow:visible}.pages{overflow-x:auto;padding-bottom:8px}.library{border-left:0;border-top:1px solid var(--line)}.lib-list{flex:none;max-height:360px}.drawer{position:fixed;top:0;width:100%}.panel{padding:16px}.alert-hero{grid-template-columns:1fr}} diff --git a/screen_manager/app/static/assets/index-DOjlT4hj.js b/screen_manager/app/static/assets/index-DOjlT4hj.js new file mode 100644 index 0000000..7d5bc61 --- /dev/null +++ b/screen_manager/app/static/assets/index-DOjlT4hj.js @@ -0,0 +1,27 @@ +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const o of document.querySelectorAll('link[rel="modulepreload"]'))s(o);new MutationObserver(o=>{for(const l of o)if(l.type==="childList")for(const r of l.addedNodes)r.tagName==="LINK"&&r.rel==="modulepreload"&&s(r)}).observe(document,{childList:!0,subtree:!0});function n(o){const l={};return o.integrity&&(l.integrity=o.integrity),o.referrerPolicy&&(l.referrerPolicy=o.referrerPolicy),o.crossOrigin==="use-credentials"?l.credentials="include":o.crossOrigin==="anonymous"?l.credentials="omit":l.credentials="same-origin",l}function s(o){if(o.ep)return;o.ep=!0;const l=n(o);fetch(o.href,l)}})();function So(e){const t=Object.create(null);for(const n of e.split(","))t[n]=1;return n=>n in t}const be={},en=[],mt=()=>{},Yi=()=>!1,As=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),Es=e=>e.startsWith("onUpdate:"),Oe=Object.assign,Co=(e,t)=>{const n=e.indexOf(t);n>-1&&e.splice(n,1)},ga=Object.prototype.hasOwnProperty,ve=(e,t)=>ga.call(e,t),ee=Array.isArray,Vt=e=>Gn(e)==="[object Map]",sn=e=>Gn(e)==="[object Set]",ei=e=>Gn(e)==="[object Date]",ne=e=>typeof e=="function",xe=e=>typeof e=="string",gt=e=>typeof e=="symbol",ge=e=>e!==null&&typeof e=="object",zi=e=>(ge(e)||ne(e))&&ne(e.then)&&ne(e.catch),Gi=Object.prototype.toString,Gn=e=>Gi.call(e),ya=e=>Gn(e).slice(8,-1),Ji=e=>Gn(e)==="[object Object]",To=e=>xe(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,En=So(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),Ps=e=>{const t=Object.create(null);return(n=>t[n]||(t[n]=e(n)))},ba=/-\w/g,Be=Ps(e=>e.replace(ba,t=>t.slice(1).toUpperCase())),wa=/\B([A-Z])/g,Yt=Ps(e=>e.replace(wa,"-$1").toLowerCase()),Os=Ps(e=>e.charAt(0).toUpperCase()+e.slice(1)),Us=Ps(e=>e?`on${Os(e)}`:""),ht=(e,t)=>!Object.is(e,t),rs=(e,...t)=>{for(let n=0;n{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:s,value:n})},Is=e=>{const t=parseFloat(e);return isNaN(t)?e:t};let ti;const Ms=()=>ti||(ti=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function Ne(e){if(ee(e)){const t={};for(let n=0;nn.startsWith("/*")?"":n).split(_a).forEach(n=>{if(n){const s=n.split(ka);s.length>1&&(t[s[0].trim()]=s[1].trim())}}),t}function re(e){let t="";if(xe(e))t=e;else if(ee(e))for(let n=0;nyt(n,t))}const Zi=e=>!!(e&&e.__v_isRef===!0),y=e=>xe(e)?e:e==null?"":ee(e)||ge(e)&&(e.toString===Gi||!ne(e.toString))?Zi(e)?y(e.value):JSON.stringify(e,el,2):String(e),el=(e,t)=>Zi(t)?el(e,t.value):Vt(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((n,[s,o],l)=>(n[Vs(s,l)+" =>"]=o,n),{})}:sn(t)?{[`Set(${t.size})`]:[...t.values()].map(n=>Vs(n))}:gt(t)?Vs(t):ge(t)&&!ee(t)&&!Ji(t)?String(t):t,Vs=(e,t="")=>{var n;return gt(e)?`Symbol(${(n=e.description)!=null?n:t})`:e};let Pe;class Pa{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this._warnOnRun=!0,this.__v_skip=!0,!t&&Pe&&(Pe.active?(this.parent=Pe,this.index=(Pe.scopes||(Pe.scopes=[])).push(this)-1):(this._active=!1,this._warnOnRun=!1))}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,n;if(this.scopes){const s=this.scopes.slice();for(t=0,n=s.length;t0&&--this._on===0){if(Pe===this)Pe=this.prevScope;else{let t=Pe;for(;t;){if(t.prevScope===this){t.prevScope=this.prevScope;break}t=t.prevScope}}this.prevScope=void 0}}stop(t){if(this._active){this._active=!1;let n,s;for(n=0,s=this.effects.length;n0)return;if(On){let t=On;for(On=void 0;t;){const n=t.next;t.next=void 0,t.flags&=-9,t=n}}let e;for(;Pn;){let t=Pn;for(Pn=void 0;t;){const n=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(s){e||(e=s)}t=n}}if(e)throw e}function ol(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function il(e){let t,n=e.depsTail,s=n;for(;s;){const o=s.prevDep;s.version===-1?(s===n&&(n=o),Po(s),Ia(s)):t=s,s.dep.activeLink=s.prevActiveLink,s.prevActiveLink=void 0,s=o}e.deps=t,e.depsTail=n}function io(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(ll(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function ll(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===Rn)||(e.globalVersion=Rn,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!io(e))))return;e.flags|=2;const t=e.dep,n=we,s=st;we=e,st=!0;try{ol(e);const o=e.fn(e._value);(t.version===0||ht(o,e._value))&&(e.flags|=128,e._value=o,t.version++)}catch(o){throw t.version++,o}finally{we=n,st=s,il(e),e.flags&=-3}}function Po(e,t=!1){const{dep:n,prevSub:s,nextSub:o}=e;if(s&&(s.nextSub=o,e.prevSub=void 0),o&&(o.prevSub=s,e.nextSub=void 0),n.subs===e&&(n.subs=s,!s&&n.computed)){n.computed.flags&=-5;for(let l=n.computed.deps;l;l=l.nextDep)Po(l,!0)}!t&&!--n.sc&&n.map&&n.map.delete(n.key)}function Ia(e){const{prevDep:t,nextDep:n}=e;t&&(t.nextDep=n,e.prevDep=void 0),n&&(n.prevDep=t,e.nextDep=void 0)}let st=!0;const al=[];function Pt(){al.push(st),st=!1}function Ot(){const e=al.pop();st=e===void 0?!0:e}function oi(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const n=we;we=void 0;try{t()}finally{we=n}}}let Rn=0;class Ma{constructor(t,n){this.sub=t,this.dep=n,this.version=n.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class Oo{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(t){if(!we||!st||we===this.computed)return;let n=this.activeLink;if(n===void 0||n.sub!==we)n=this.activeLink=new Ma(we,this),we.deps?(n.prevDep=we.depsTail,we.depsTail.nextDep=n,we.depsTail=n):we.deps=we.depsTail=n,rl(n);else if(n.version===-1&&(n.version=this.version,n.nextDep)){const s=n.nextDep;s.prevDep=n.prevDep,n.prevDep&&(n.prevDep.nextDep=s),n.prevDep=we.depsTail,n.nextDep=void 0,we.depsTail.nextDep=n,we.depsTail=n,we.deps===n&&(we.deps=s)}return n}trigger(t){this.version++,Rn++,this.notify(t)}notify(t){Ao();try{for(let n=this.subs;n;n=n.prevSub)n.sub.notify()&&n.sub.dep.notify()}finally{Eo()}}}function rl(e){if(e.dep.sc++,e.sub.flags&4){const t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let s=t.deps;s;s=s.nextDep)rl(s)}const n=e.dep.subs;n!==e&&(e.prevSub=n,n&&(n.nextSub=e)),e.dep.subs=e}}const lo=new WeakMap,tn=Symbol(""),ao=Symbol(""),Nn=Symbol("");function Ie(e,t,n){if(st&&we){let s=lo.get(e);s||lo.set(e,s=new Map);let o=s.get(n);o||(s.set(n,o=new Oo),o.map=s,o.key=n),o.track()}}function Tt(e,t,n,s,o,l){const r=lo.get(e);if(!r){Rn++;return}const u=a=>{a&&a.trigger()};if(Ao(),t==="clear")r.forEach(u);else{const a=ee(e),c=a&&To(n);if(a&&n==="length"){const f=Number(s);r.forEach((b,A)=>{(A==="length"||A===Nn||!gt(A)&&A>=f)&&u(b)})}else switch((n!==void 0||r.has(void 0))&&u(r.get(n)),c&&u(r.get(Nn)),t){case"add":a?c&&u(r.get("length")):(u(r.get(tn)),Vt(e)&&u(r.get(ao)));break;case"delete":a||(u(r.get(tn)),Vt(e)&&u(r.get(ao)));break;case"set":Vt(e)&&u(r.get(tn));break}}Eo()}function rn(e){const t=he(e);return t===e||(Ie(t,"iterate",Nn),Xe(e))?t:bt(e)?Wt(e)?t.map(n=>Kt(Ze(n))):t.map(Kt):t.map(Ze)}function Ds(e){return Ie(e=he(e),"iterate",Nn),e}function ft(e,t){return bt(e)?Kt(Wt(e)?Ze(t):t):Ze(t)}const Da={__proto__:null,[Symbol.iterator](){return Ks(this,Symbol.iterator,e=>ft(this,e))},concat(...e){return rn(this).concat(...e.map(t=>ee(t)?rn(t):t))},entries(){return Ks(this,"entries",e=>(e[1]=ft(this,e[1]),e))},every(e,t){return kt(this,"every",e,t,void 0,arguments)},filter(e,t){return kt(this,"filter",e,t,n=>n.map(s=>ft(this,s)),arguments)},find(e,t){return kt(this,"find",e,t,n=>ft(this,n),arguments)},findIndex(e,t){return kt(this,"findIndex",e,t,void 0,arguments)},findLast(e,t){return kt(this,"findLast",e,t,n=>ft(this,n),arguments)},findLastIndex(e,t){return kt(this,"findLastIndex",e,t,void 0,arguments)},forEach(e,t){return kt(this,"forEach",e,t,void 0,arguments)},includes(...e){return qs(this,"includes",e)},indexOf(...e){return qs(this,"indexOf",e)},join(e){return rn(this).join(e)},lastIndexOf(...e){return qs(this,"lastIndexOf",e)},map(e,t){return kt(this,"map",e,t,void 0,arguments)},pop(){return kn(this,"pop")},push(...e){return kn(this,"push",e)},reduce(e,...t){return ii(this,"reduce",e,t)},reduceRight(e,...t){return ii(this,"reduceRight",e,t)},shift(){return kn(this,"shift")},some(e,t){return kt(this,"some",e,t,void 0,arguments)},splice(...e){return kn(this,"splice",e)},toReversed(){return rn(this).toReversed()},toSorted(e){return rn(this).toSorted(e)},toSpliced(...e){return rn(this).toSpliced(...e)},unshift(...e){return kn(this,"unshift",e)},values(){return Ks(this,"values",e=>ft(this,e))}};function Ks(e,t,n){const s=Ds(e),o=s[t]();return s!==e&&!Xe(e)&&(o._next=o.next,o.next=()=>{const l=o._next();return l.done||(l.value=n(l.value)),l}),o}const La=Array.prototype;function kt(e,t,n,s,o,l){const r=Ds(e),u=r!==e&&!Xe(e),a=r[t];if(a!==La[t]){const b=a.apply(e,l);return u?Ze(b):b}let c=n;r!==e&&(u?c=function(b,A){return n.call(this,ft(e,b),A,e)}:n.length>2&&(c=function(b,A){return n.call(this,b,A,e)}));const f=a.call(r,c,s);return u&&o?o(f):f}function ii(e,t,n,s){const o=Ds(e),l=o!==e&&!Xe(e);let r=n,u=!1;o!==e&&(l?(u=s.length===0,r=function(c,f,b){return u&&(u=!1,c=ft(e,c)),n.call(this,c,ft(e,f),b,e)}):n.length>3&&(r=function(c,f,b){return n.call(this,c,f,b,e)}));const a=o[t](r,...s);return u?ft(e,a):a}function qs(e,t,n){const s=he(e);Ie(s,"iterate",Nn);const o=s[t](...n);return(o===-1||o===!1)&&Do(n[0])?(n[0]=he(n[0]),s[t](...n)):o}function kn(e,t,n=[]){Pt(),Ao();const s=he(e)[t].apply(e,n);return Eo(),Ot(),s}const Fa=So("__proto__,__v_isRef,__isVue"),ul=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!=="arguments"&&e!=="caller").map(e=>Symbol[e]).filter(gt));function Ha(e){gt(e)||(e=String(e));const t=he(this);return Ie(t,"has",e),t.hasOwnProperty(e)}class cl{constructor(t=!1,n=!1){this._isReadonly=t,this._isShallow=n}get(t,n,s){if(n==="__v_skip")return t.__v_skip;const o=this._isReadonly,l=this._isShallow;if(n==="__v_isReactive")return!o;if(n==="__v_isReadonly")return o;if(n==="__v_isShallow")return l;if(n==="__v_raw")return s===(o?l?Ya:hl:l?pl:fl).get(t)||Object.getPrototypeOf(t)===Object.getPrototypeOf(s)?t:void 0;const r=ee(t);if(!o){let a;if(r&&(a=Da[n]))return a;if(n==="hasOwnProperty")return Ha}const u=Reflect.get(t,n,Le(t)?t:s);if((gt(n)?ul.has(n):Fa(n))||(o||Ie(t,"get",n),l))return u;if(Le(u)){const a=r&&To(n)?u:u.value;return o&&ge(a)?uo(a):a}return ge(u)?o?uo(u):hn(u):u}}class dl extends cl{constructor(t=!1){super(!1,t)}set(t,n,s,o){let l=t[n];const r=ee(t)&&To(n);if(!this._isShallow){const c=bt(l);if(!Xe(s)&&!bt(s)&&(l=he(l),s=he(s)),!r&&Le(l)&&!Le(s))return c||(l.value=s),!0}const u=r?Number(n)e,ss=e=>Reflect.getPrototypeOf(e);function Ua(e,t,n){return function(...s){const o=this.__v_raw,l=he(o),r=Vt(l),u=e==="entries"||e===Symbol.iterator&&r,a=e==="keys"&&r,c=o[e](...s),f=n?ro:t?Kt:Ze;return!t&&Ie(l,"iterate",a?ao:tn),Oe(Object.create(c),{next(){const{value:b,done:A}=c.next();return A?{value:b,done:A}:{value:u?[f(b[0]),f(b[1])]:f(b),done:A}}})}}function os(e){return function(...t){return e==="delete"?!1:e==="clear"?void 0:this}}function Va(e,t){const n={get(o){const l=this.__v_raw,r=he(l),u=he(o);e||(ht(o,u)&&Ie(r,"get",o),Ie(r,"get",u));const{has:a}=ss(r),c=t?ro:e?Kt:Ze;if(a.call(r,o))return c(l.get(o));if(a.call(r,u))return c(l.get(u));l!==r&&l.get(o)},get size(){const o=this.__v_raw;return!e&&Ie(he(o),"iterate",tn),o.size},has(o){const l=this.__v_raw,r=he(l),u=he(o);return e||(ht(o,u)&&Ie(r,"has",o),Ie(r,"has",u)),o===u?l.has(o):l.has(o)||l.has(u)},forEach(o,l){const r=this,u=r.__v_raw,a=he(u),c=t?ro:e?Kt:Ze;return!e&&Ie(a,"iterate",tn),u.forEach((f,b)=>o.call(l,c(f),c(b),r))}};return Oe(n,e?{add:os("add"),set:os("set"),delete:os("delete"),clear:os("clear")}:{add(o){const l=he(this),r=ss(l),u=he(o),a=!t&&!Xe(o)&&!bt(o)?u:o;return r.has.call(l,a)||ht(o,a)&&r.has.call(l,o)||ht(u,a)&&r.has.call(l,u)||(l.add(a),Tt(l,"add",a,a)),this},set(o,l){!t&&!Xe(l)&&!bt(l)&&(l=he(l));const r=he(this),{has:u,get:a}=ss(r);let c=u.call(r,o);c||(o=he(o),c=u.call(r,o));const f=a.call(r,o);return r.set(o,l),c?ht(l,f)&&Tt(r,"set",o,l):Tt(r,"add",o,l),this},delete(o){const l=he(this),{has:r,get:u}=ss(l);let a=r.call(l,o);a||(o=he(o),a=r.call(l,o)),u&&u.call(l,o);const c=l.delete(o);return a&&Tt(l,"delete",o,void 0),c},clear(){const o=he(this),l=o.size!==0,r=o.clear();return l&&Tt(o,"clear",void 0,void 0),r}}),["keys","values","entries",Symbol.iterator].forEach(o=>{n[o]=Ua(o,e,t)}),n}function Io(e,t){const n=Va(e,t);return(s,o,l)=>o==="__v_isReactive"?!e:o==="__v_isReadonly"?e:o==="__v_raw"?s:Reflect.get(ve(n,o)&&o in s?n:s,o,l)}const Wa={get:Io(!1,!1)},Ka={get:Io(!1,!0)},qa={get:Io(!0,!1)};const fl=new WeakMap,pl=new WeakMap,hl=new WeakMap,Ya=new WeakMap;function za(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function hn(e){return bt(e)?e:Mo(e,!1,Na,Wa,fl)}function Ga(e){return Mo(e,!1,Ba,Ka,pl)}function uo(e){return Mo(e,!0,ja,qa,hl)}function Mo(e,t,n,s,o){if(!ge(e)||e.__v_raw&&!(t&&e.__v_isReactive)||e.__v_skip||!Object.isExtensible(e))return e;const l=o.get(e);if(l)return l;const r=za(ya(e));if(r===0)return e;const u=new Proxy(e,r===2?s:n);return o.set(e,u),u}function Wt(e){return bt(e)?Wt(e.__v_raw):!!(e&&e.__v_isReactive)}function bt(e){return!!(e&&e.__v_isReadonly)}function Xe(e){return!!(e&&e.__v_isShallow)}function Do(e){return e?!!e.__v_raw:!1}function he(e){const t=e&&e.__v_raw;return t?he(t):e}function Ja(e){return!ve(e,"__v_skip")&&Object.isExtensible(e)&&Xi(e,"__v_skip",!0),e}const Ze=e=>ge(e)?hn(e):e,Kt=e=>ge(e)?uo(e):e;function Le(e){return e?e.__v_isRef===!0:!1}function fe(e){return Xa(e,!1)}function Xa(e,t){return Le(e)?e:new Qa(e,t)}class Qa{constructor(t,n){this.dep=new Oo,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=n?t:he(t),this._value=n?t:Ze(t),this.__v_isShallow=n}get value(){return this.dep.track(),this._value}set value(t){const n=this._rawValue,s=this.__v_isShallow||Xe(t)||bt(t);t=s?t:he(t),ht(t,n)&&(this._rawValue=t,this._value=s?t:Ze(t),this.dep.trigger())}}function v(e){return Le(e)?e.value:e}const Za={get:(e,t,n)=>t==="__v_raw"?e:v(Reflect.get(e,t,n)),set:(e,t,n,s)=>{const o=e[t];return Le(o)&&!Le(n)?(o.value=n,!0):Reflect.set(e,t,n,s)}};function vl(e){return Wt(e)?e:new Proxy(e,Za)}class er{constructor(t,n,s){this.fn=t,this.setter=n,this._value=void 0,this.dep=new Oo(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=Rn-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!n,this.isSSR=s}notify(){if(this.flags|=16,!(this.flags&8)&&we!==this)return sl(this,!0),!0}get value(){const t=this.dep.track();return ll(this),t&&(t.version=this.dep.version),this._value}set value(t){this.setter&&this.setter(t)}}function tr(e,t,n=!1){let s,o;return ne(e)?s=e:(s=e.get,o=e.set),new er(s,o,n)}const is={},hs=new WeakMap;let Qt;function nr(e,t=!1,n=Qt){if(n){let s=hs.get(n);s||hs.set(n,s=[]),s.push(e)}}function sr(e,t,n=be){const{immediate:s,deep:o,once:l,scheduler:r,augmentJob:u,call:a}=n,c=m=>o?m:Xe(m)||o===!1||o===0?At(m,1):At(m);let f,b,A,D,I=!1,R=!1;if(Le(e)?(b=()=>e.value,I=Xe(e)):Wt(e)?(b=()=>c(e),I=!0):ee(e)?(R=!0,I=e.some(m=>Wt(m)||Xe(m)),b=()=>e.map(m=>{if(Le(m))return m.value;if(Wt(m))return c(m);if(ne(m))return a?a(m,2):m()})):ne(e)?t?b=a?()=>a(e,2):e:b=()=>{if(A){Pt();try{A()}finally{Ot()}}const m=Qt;Qt=f;try{return a?a(e,3,[D]):e(D)}finally{Qt=m}}:b=mt,t&&o){const m=b,H=o===!0?1/0:o;b=()=>At(m(),H)}const T=Oa(),$=()=>{f.stop(),T&&T.active&&Co(T.effects,f)};if(l&&t){const m=t;t=(...H)=>{const C=m(...H);return $(),C}}let x=R?new Array(e.length).fill(is):is;const w=m=>{if(!(!(f.flags&1)||!f.dirty&&!m))if(t){const H=f.run();if(m||o||I||(R?H.some((C,L)=>ht(C,x[L])):ht(H,x))){A&&A();const C=Qt;Qt=f;try{const L=[H,x===is?void 0:R&&x[0]===is?[]:x,D];x=H,a?a(t,3,L):t(...L)}finally{Qt=C}}}else f.run()};return u&&u(w),f=new tl(b),f.scheduler=r?()=>r(w,!1):w,D=m=>nr(m,!1,f),A=f.onStop=()=>{const m=hs.get(f);if(m){if(a)a(m,4);else for(const H of m)H();hs.delete(f)}},t?s?w(!0):x=f.run():r?r(w.bind(null,!0),!0):f.run(),$.pause=f.pause.bind(f),$.resume=f.resume.bind(f),$.stop=$,$}function At(e,t=1/0,n){if(t<=0||!ge(e)||e.__v_skip||(n=n||new Map,(n.get(e)||0)>=t))return e;if(n.set(e,t),t--,Le(e))At(e.value,t,n);else if(ee(e))for(let s=0;s{At(s,t,n)});else if(Ji(e)){for(const s in e)At(e[s],t,n);for(const s of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,s)&&At(e[s],t,n)}return e}function Jn(e,t,n,s){try{return s?e(...s):e()}catch(o){Ls(o,t,n)}}function ot(e,t,n,s){if(ne(e)){const o=Jn(e,t,n,s);return o&&zi(o)&&o.catch(l=>{Ls(l,t,n)}),o}if(ee(e)){const o=[];for(let l=0;l>>1,o=Re[s],l=jn(o);l=jn(n)?Re.push(e):Re.splice(ir(t),0,e),e.flags|=1,gl()}}function gl(){vs||(vs=ml.then(bl))}function lr(e){if(!ee(e))Nt&&e.id===-1?Nt.splice(un+1,0,e):e.flags&1||(cn.push(e),e.flags|=1);else for(let t=0;tjn(n)-jn(s));if(cn.length=0,Nt){for(let n=0;ne.id==null?e.flags&2?-1:1/0:e.id;function bl(e){try{for(ct=0;ct{s._d&&yi(-1);const l=ms(t),r=nn.length;let u;try{u=e(...o)}finally{for(let a=nn.length;a>r;a--)Kl();ms(l),s._d&&yi(1)}return u};return s._n=!0,s._c=!0,s._d=!0,s}function Te(e,t){if(qe===null)return e;const n=js(qe),s=e.dirs||(e.dirs=[]);for(let o=0;o1)return n&&ne(t)?t.call(s&&s.proxy):t}}const ur=Symbol.for("v-scx"),cr=()=>us(ur);function In(e,t,n){return _l(e,t,n)}function _l(e,t,n=be){const{immediate:s,deep:o,flush:l,once:r}=n,u=Oe({},n),a=t&&s||!t&&l!=="post";let c;if(Vn){if(l==="sync"){const D=cr();c=D.__watcherHandles||(D.__watcherHandles=[])}else if(!a){const D=()=>{};return D.stop=mt,D.resume=mt,D.pause=mt,D}}const f=Me;u.call=(D,I,R)=>ot(D,f,I,R);let b=!1;l==="post"?u.scheduler=D=>{Ue(D,f&&f.suspense)}:l!=="sync"&&(b=!0,u.scheduler=(D,I)=>{I?D():Fo(D)}),u.augmentJob=D=>{t&&(D.flags|=4),b&&(D.flags|=2,f&&(D.id=f.uid,D.i=f))};const A=sr(e,t,u);return Vn&&(c?c.push(A):a&&A()),A}function dr(e,t,n){const s=this.proxy,o=xe(e)?e.includes(".")?kl(s,e):()=>s[e]:e.bind(s,s);let l;ne(t)?l=t:(l=t.handler,n=t);const r=Qn(this),u=_l(o,l.bind(s),n);return r(),u}function kl(e,t){const n=t.split(".");return()=>{let s=e;for(let o=0;oe.__isTeleport,Ys=Symbol("_leaveCb");function pr(e){let t=e[0];if(e.length>1){for(const n of e)if(n.type!==It){t=n;break}}return t}function $l(e){if(!Ro(e))return Fs(e.type)&&e.children?pr(e.children):e;if(e.component)return e.component.subTree;const{shapeFlag:t,children:n}=e;if(n){if(t&16)return n[0];if(t&32&&ne(n.default))return n.default()}}function Ho(e,t){if(e.shapeFlag&6&&e.component){e.transition=t;const n=e.component.subTree;Ho(Fs(n.type)&&$l(n)||n,t)}else e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function $e(e,t){return ne(e)?Oe({name:e.name},t,{setup:e}):e}function xl(e){e.ids=[e.ids[0]+e.ids[2]+++"-",0,0]}function ai(e,t){let n;return!!((n=Object.getOwnPropertyDescriptor(e,t))&&!n.configurable)}const gs=new WeakMap;function Mn(e,t,n,s,o=!1){if(ee(e)){e.forEach((R,T)=>Mn(R,t&&(ee(t)?t[T]:t),n,s,o));return}if(Dn(s)&&!o){s.shapeFlag&512&&s.type.__asyncResolved&&s.component.subTree.component&&Mn(e,t,n,s.component.subTree);return}const l=s.shapeFlag&4?js(s.component):s.el,r=o?null:l,{i:u,r:a}=e,c=t&&t.r,f=u.refs===be?u.refs={}:u.refs,b=u.setupState,A=he(b),D=b===be?Yi:R=>ai(f,R)?!1:ve(A,R),I=(R,T)=>!(T&&ai(f,T));if(c!=null&&c!==a){if(ri(t),xe(c))f[c]=null,D(c)&&(b[c]=null);else if(Le(c)){const R=t;I(c,R.k)&&(c.value=null),R.k&&(f[R.k]=null)}}if(ne(a))Jn(a,u,12,[r,f]);else{const R=xe(a),T=Le(a);if(R||T){const $=()=>{if(e.f){const x=R?D(a)?b[a]:f[a]:I()||!e.k?a.value:f[e.k];if(o)ee(x)&&Co(x,l);else if(ee(x))x.includes(l)||x.push(l);else if(R)f[a]=[l],D(a)&&(b[a]=f[a]);else{const w=[l];I(a,e.k)&&(a.value=w),e.k&&(f[e.k]=w)}}else R?(f[a]=r,D(a)&&(b[a]=r)):T&&(I(a,e.k)&&(a.value=r),e.k&&(f[e.k]=r))};if(r){const x=()=>{$(),gs.delete(e)};x.id=-1,gs.set(e,x),Ue(x,n)}else ri(e),$()}}}function ri(e){const t=gs.get(e);t&&(t.flags|=8,gs.delete(e))}Ms().requestIdleCallback;Ms().cancelIdleCallback;const Dn=e=>!!e.type.__asyncLoader,Ro=e=>e.type.__isKeepAlive;function hr(e,t){Sl(e,"a",t)}function vr(e,t){Sl(e,"da",t)}function Sl(e,t,n=Me){const s=e.__wdc||(e.__wdc=()=>{let o=n;for(;o;){if(o.isDeactivated)return;o=o.parent}return e()});if(Hs(t,s,n),n){let o=n.parent;for(;o&&o.parent;)Ro(o.parent.vnode)&&mr(s,t,n,o),o=o.parent}}function mr(e,t,n,s){const o=Hs(t,e,s,!0);Cl(()=>{Co(s[t],o)},n)}function Hs(e,t,n=Me,s=!1){if(n){const o=n[e]||(n[e]=[]),l=t.__weh||(t.__weh=(...r)=>{Pt();const u=Qn(n),a=ot(t,n,e,r);return u(),Ot(),a});return s?o.unshift(l):o.push(l),l}}const Lt=e=>(t,n=Me)=>{(!Vn||e==="sp")&&Hs(e,(...s)=>t(...s),n)},gr=Lt("bm"),bn=Lt("m"),yr=Lt("bu"),br=Lt("u"),Xn=Lt("bum"),Cl=Lt("um"),wr=Lt("sp"),_r=Lt("rtg"),kr=Lt("rtc");function $r(e,t=Me){Hs("ec",e,t)}const xr="components",Tl=Symbol.for("v-ndc");function Sr(e){return xe(e)?Cr(xr,e,!1)||e:e||Tl}function Cr(e,t,n=!0,s=!1){const o=qe||Me;if(o){const l=o.type;{const u=cu(l,!1);if(u&&(u===t||u===Be(t)||u===Os(Be(t))))return l}const r=ui(o[e]||l[e],t)||ui(o.appContext[e],t);return!r&&s?l:r}}function ui(e,t){return e&&(e[t]||e[Be(t)]||e[Os(Be(t))])}function le(e,t,n,s){let o;const l=n,r=ee(e);if(r||xe(e)){const u=r&&Wt(e);let a=!1,c=!1;u&&(a=!Xe(e),c=bt(e),e=Ds(e)),o=new Array(e.length);for(let f=0,b=e.length;ft(u,a,void 0,l));else{const u=Object.keys(e);o=new Array(u.length);for(let a=0,c=u.length;ae?Gl(e)?js(e):co(e.parent):null,Ln=Oe(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>co(e.parent),$root:e=>co(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>El(e),$forceUpdate:e=>e.f||(e.f=()=>{Fo(e.update)}),$nextTick:e=>e.n||(e.n=Lo.bind(e.proxy)),$watch:e=>dr.bind(e)}),zs=(e,t)=>e!==be&&!e.__isScriptSetup&&ve(e,t),Tr={get({_:e},t){if(t==="__v_skip")return!0;const{ctx:n,setupState:s,data:o,props:l,accessCache:r,type:u,appContext:a}=e;if(t[0]!=="$"){const A=r[t];if(A!==void 0)switch(A){case 1:return s[t];case 2:return o[t];case 4:return n[t];case 3:return l[t]}else{if(zs(s,t))return r[t]=1,s[t];if(o!==be&&ve(o,t))return r[t]=2,o[t];if(ve(l,t))return r[t]=3,l[t];if(n!==be&&ve(n,t))return r[t]=4,n[t];fo&&(r[t]=0)}}const c=Ln[t];let f,b;if(c)return t==="$attrs"&&Ie(e.attrs,"get",""),c(e);if((f=u.__cssModules)&&(f=f[t]))return f;if(n!==be&&ve(n,t))return r[t]=4,n[t];if(b=a.config.globalProperties,ve(b,t))return b[t]},set({_:e},t,n){const{data:s,setupState:o,ctx:l}=e;return zs(o,t)?(o[t]=n,!0):s!==be&&ve(s,t)?(s[t]=n,!0):ve(e.props,t)||t[0]==="$"&&t.slice(1)in e?!1:(l[t]=n,!0)},has({_:{data:e,setupState:t,accessCache:n,ctx:s,appContext:o,props:l,type:r}},u){let a;return!!(n[u]||e!==be&&u[0]!=="$"&&ve(e,u)||zs(t,u)||ve(l,u)||ve(s,u)||ve(Ln,u)||ve(o.config.globalProperties,u)||(a=r.__cssModules)&&a[u])},defineProperty(e,t,n){return n.get!=null?e._.accessCache[t]=0:ve(n,"value")&&this.set(e,t,n.value,null),Reflect.defineProperty(e,t,n)}};function ci(e){return ee(e)?e.reduce((t,n)=>(t[n]=null,t),{}):e}let fo=!0;function Ar(e){const t=El(e),n=e.proxy,s=e.ctx;fo=!1,t.beforeCreate&&di(t.beforeCreate,e,"bc");const{data:o,computed:l,methods:r,watch:u,provide:a,inject:c,created:f,beforeMount:b,mounted:A,beforeUpdate:D,updated:I,activated:R,deactivated:T,beforeDestroy:$,beforeUnmount:x,destroyed:w,unmounted:m,render:H,renderTracked:C,renderTriggered:L,errorCaptured:pe,serverPrefetch:Ae,expose:J,inheritAttrs:q,components:ae,directives:z,filters:B}=t;if(c&&Er(c,s,null),r)for(const ie in r){const ue=r[ie];ne(ue)&&(s[ie]=ue.bind(n))}if(o){const ie=o.call(n,n);ge(ie)&&(e.data=hn(ie))}if(fo=!0,l)for(const ie in l){const ue=l[ie],ze=ne(ue)?ue.bind(n,n):ne(ue.get)?ue.get.bind(n,n):mt,an=!ne(ue)&&ne(ue.set)?ue.set.bind(n):mt,_t=k({get:ze,set:an});Object.defineProperty(s,ie,{enumerable:!0,configurable:!0,get:()=>_t.value,set:We=>_t.value=We})}if(u)for(const ie in u)Al(u[ie],s,n,ie);if(a){const ie=ne(a)?a.call(n):a;Reflect.ownKeys(ie).forEach(ue=>{rr(ue,ie[ue])})}f&&di(f,e,"c");function oe(ie,ue){ee(ue)?ue.forEach(ze=>ie(ze.bind(n))):ue&&ie(ue.bind(n))}if(oe(gr,b),oe(bn,A),oe(yr,D),oe(br,I),oe(hr,R),oe(vr,T),oe($r,pe),oe(kr,C),oe(_r,L),oe(Xn,x),oe(Cl,m),oe(wr,Ae),ee(J))if(J.length){const ie=e.exposed||(e.exposed={});J.forEach(ue=>{Object.defineProperty(ie,ue,{get:()=>n[ue],set:ze=>n[ue]=ze,enumerable:!0})})}else e.exposed||(e.exposed={});H&&e.render===mt&&(e.render=H),q!=null&&(e.inheritAttrs=q),ae&&(e.components=ae),z&&(e.directives=z),Ae&&xl(e)}function Er(e,t,n=mt){ee(e)&&(e=po(e));for(const s in e){const o=e[s];let l;ge(o)?"default"in o?l=us(o.from||s,o.default,!0):l=us(o.from||s):l=us(o),Le(l)?Object.defineProperty(t,s,{enumerable:!0,configurable:!0,get:()=>l.value,set:r=>l.value=r}):t[s]=l}}function di(e,t,n){ot(ee(e)?e.map(s=>s.bind(t.proxy)):e.bind(t.proxy),t,n)}function Al(e,t,n,s){let o=s.includes(".")?kl(n,s):()=>n[s];if(xe(e)){const l=t[e];ne(l)&&In(o,l)}else if(ne(e))In(o,e.bind(n));else if(ge(e))if(ee(e))e.forEach(l=>Al(l,t,n,s));else{const l=ne(e.handler)?e.handler.bind(n):t[e.handler];ne(l)&&In(o,l,e)}}function El(e){const t=e.type,{mixins:n,extends:s}=t,{mixins:o,optionsCache:l,config:{optionMergeStrategies:r}}=e.appContext,u=l.get(t);let a;return u?a=u:!o.length&&!n&&!s?a=t:(a={},o.length&&o.forEach(c=>ys(a,c,r,!0)),ys(a,t,r)),ge(t)&&l.set(t,a),a}function ys(e,t,n,s=!1){const{mixins:o,extends:l}=t;l&&ys(e,l,n,!0),o&&o.forEach(r=>ys(e,r,n,!0));for(const r in t)if(!(s&&r==="expose")){const u=Pr[r]||n&&n[r];e[r]=u?u(e[r],t[r]):t[r]}return e}const Pr={data:fi,props:pi,emits:pi,methods:Sn,computed:Sn,beforeCreate:Fe,created:Fe,beforeMount:Fe,mounted:Fe,beforeUpdate:Fe,updated:Fe,beforeDestroy:Fe,beforeUnmount:Fe,destroyed:Fe,unmounted:Fe,activated:Fe,deactivated:Fe,errorCaptured:Fe,serverPrefetch:Fe,components:Sn,directives:Sn,watch:Ir,provide:fi,inject:Or};function fi(e,t){return t?e?function(){return Oe(ne(e)?e.call(this,this):e,ne(t)?t.call(this,this):t)}:t:e}function Or(e,t){return Sn(po(e),po(t))}function po(e){if(ee(e)){const t={};for(let n=0;nt==="modelValue"||t==="model-value"?e.modelModifiers:e[`${t}Modifiers`]||e[`${Be(t)}Modifiers`]||e[`${Yt(t)}Modifiers`];function Fr(e,t,...n){if(e.isUnmounted)return;const s=e.vnode.props||be;let o=n;const l=t.startsWith("update:"),r=l&&Lr(s,t.slice(7));r&&(r.trim&&(o=n.map(f=>xe(f)?f.trim():f)),r.number&&(o=o.map(Is)));let u,a=s[u=Us(t)]||s[u=Us(Be(t))];!a&&l&&(a=s[u=Us(Yt(t))]),a&&ot(a,e,6,o);const c=s[u+"Once"];if(c){if(!e.emitted)e.emitted={};else if(e.emitted[u])return;e.emitted[u]=!0,ot(c,e,6,o)}}const Hr=new WeakMap;function Ol(e,t,n=!1){const s=n?Hr:t.emitsCache,o=s.get(e);if(o!==void 0)return o;const l=e.emits;let r={},u=!1;if(!ne(e)){const a=c=>{const f=Ol(c,t,!0);f&&(u=!0,Oe(r,f))};!n&&t.mixins.length&&t.mixins.forEach(a),e.extends&&a(e.extends),e.mixins&&e.mixins.forEach(a)}return!l&&!u?(ge(e)&&s.set(e,null),null):(ee(l)?l.forEach(a=>r[a]=null):Oe(r,l),ge(e)&&s.set(e,r),r)}function Rs(e,t){return!e||!As(t)?!1:(t=t.slice(2),t=t==="Once"?t:t.replace(/Once$/,""),ve(e,t[0].toLowerCase()+t.slice(1))||ve(e,Yt(t))||ve(e,t))}function hi(e){const{type:t,vnode:n,proxy:s,withProxy:o,propsOptions:[l],slots:r,attrs:u,emit:a,render:c,renderCache:f,props:b,data:A,setupState:D,ctx:I,inheritAttrs:R}=e,T=ms(e);let $,x;try{if(n.shapeFlag&4){const m=o||s,H=m;$=pt(c.call(H,m,f,b,D,A,I)),x=u}else{const m=t;$=pt(m.length>1?m(b,{attrs:u,slots:r,emit:a}):m(b,null)),x=t.props?u:Rr(u)}}catch(m){nn.length=0,Ls(m,e,1),$=_e(It)}let w=$;if(x&&R!==!1){const m=Object.keys(x),{shapeFlag:H}=w;m.length&&H&7&&(l&&m.some(Es)&&(x=Nr(x,l)),w=vn(w,x,!1,!0))}if(n.dirs&&(w=vn(w,null,!1,!0),w.dirs=w.dirs?w.dirs.concat(n.dirs):n.dirs),n.transition){const m=Fs(w.type)&&$l(w)||w;Ho(m,n.transition)}return $=w,ms(T),$}const Rr=e=>{let t;for(const n in e)(n==="class"||n==="style"||As(n))&&((t||(t={}))[n]=e[n]);return t},Nr=(e,t)=>{const n={};for(const s in e)(!Es(s)||!(s.slice(9)in t))&&(n[s]=e[s]);return n};function jr(e,t,n){const{props:s,children:o,component:l}=e,{props:r,children:u,patchFlag:a}=t,c=l.emitsOptions;if(t.dirs||t.transition)return!0;if(n&&a>=0){if(a&1024)return!0;if(a&16)return s?vi(s,r,c):!!r;if(a&8){const f=t.dynamicProps;for(let b=0;bObject.create(Ml),Ll=e=>Object.getPrototypeOf(e)===Ml;function Ur(e,t,n,s=!1){const o={},l=Dl();e.propsDefaults=Object.create(null),Fl(e,t,o,l);for(const r in e.propsOptions[0])r in o||(o[r]=void 0);n?e.props=s?o:Ga(o):e.type.props?e.props=o:e.props=l,e.attrs=l}function Vr(e,t,n,s){const{props:o,attrs:l,vnode:{patchFlag:r}}=e,u=he(o),[a]=e.propsOptions;let c=!1;if((s||r>0)&&!(r&16)){if(r&8){const f=e.vnode.dynamicProps;for(let b=0;b{a=!0;const[A,D]=Hl(b,t,!0);Oe(r,A),D&&u.push(...D)};!n&&t.mixins.length&&t.mixins.forEach(f),e.extends&&f(e.extends),e.mixins&&e.mixins.forEach(f)}if(!l&&!a)return ge(e)&&s.set(e,en),en;if(ee(l))for(let f=0;fe==="_"||e==="_ctx"||e==="$stable",jo=e=>ee(e)?e.map(pt):[pt(e)],Kr=(e,t,n)=>{if(t._n)return t;const s=ar((...o)=>jo(t(...o)),n);return s._c=!1,s},Rl=(e,t,n)=>{const s=e._ctx;for(const o in e){if(No(o))continue;const l=e[o];if(ne(l))t[o]=Kr(o,l,s);else if(l!=null){const r=jo(l);t[o]=()=>r}}},Nl=(e,t)=>{const n=jo(t);e.slots.default=()=>n},jl=(e,t,n)=>{for(const s in t)(n||!No(s))&&(e[s]=t[s])},qr=(e,t,n)=>{const s=e.slots=Dl();if(e.vnode.shapeFlag&32){const o=t._;o?(jl(s,t,n),n&&Xi(s,"_",o,!0)):Rl(t,s)}else t&&Nl(e,t)},Yr=(e,t,n)=>{const{vnode:s,slots:o}=e;let l=!0,r=be;if(s.shapeFlag&32){const u=t._;u?n&&u===1?l=!1:jl(o,t,n):(l=!t.$stable,Rl(t,o)),r=t}else t&&(Nl(e,t),r={default:1});if(l)for(const u in o)!No(u)&&r[u]==null&&delete o[u]},Ue=Qr;function zr(e){return Gr(e)}function Gr(e,t){const n=Ms();n.__VUE__=!0;const{insert:s,remove:o,patchProp:l,createElement:r,createText:u,createComment:a,setText:c,setElementText:f,parentNode:b,nextSibling:A,setScopeId:D=mt,insertStaticContent:I}=e,R=(p,_,S,F=null,E=null,O=null,U=void 0,j=null,N=!!_.dynamicChildren)=>{if(p===_)return;p&&!$n(p,_)&&(F=se(p),We(p,E,O,!0),p=null),_.patchFlag===-2&&(N=!1,_.dynamicChildren=null),_.dynamicChildren&&p&&p.dynamicChildren&&p.dynamicChildren.hasOnce&&(_.dynamicChildren===en&&(_.dynamicChildren=[]),_.dynamicChildren.hasOnce=!0);const{type:P,ref:X,shapeFlag:K}=_;switch(P){case Ns:T(p,_,S,F);break;case It:$(p,_,S,F);break;case cs:p==null&&x(_,S,F,U);break;case W:ae(p,_,S,F,E,O,U,j,N);break;default:K&1?H(p,_,S,F,E,O,U,j,N):K&6?z(p,_,S,F,E,O,U,j,N):(K&64||K&128)&&P.process(p,_,S,F,E,O,U,j,N,Gt)}X!=null&&E?Mn(X,p&&p.ref,O,_||p,!_):X==null&&p&&p.ref!=null&&Mn(p.ref,null,O,p,!0)},T=(p,_,S,F)=>{if(p==null)s(_.el=u(_.children),S,F);else{const E=_.el=p.el;_.children!==p.children&&c(E,_.children)}},$=(p,_,S,F)=>{p==null?s(_.el=a(_.children||""),S,F):_.el=p.el},x=(p,_,S,F)=>{[p.el,p.anchor]=I(p.children,_,S,F,p.el,p.anchor)},w=({el:p,anchor:_},S,F)=>{let E;for(;p&&p!==_;)E=A(p),s(p,S,F),p=E;s(_,S,F)},m=({el:p,anchor:_})=>{let S;for(;p&&p!==_;)S=A(p),o(p),p=S;o(_)},H=(p,_,S,F,E,O,U,j,N)=>{if(_.type==="svg"?U="svg":_.type==="math"&&(U="mathml"),p==null)C(_,S,F,E,O,U,j,N);else{const P=p.el&&p.el._isVueCE?p.el:null;try{P&&P._beginPatch(),Ae(p,_,E,O,U,j,N)}finally{P&&P._endPatch()}}},C=(p,_,S,F,E,O,U,j)=>{let N,P;const{props:X,shapeFlag:K,transition:G,dirs:te}=p;if(N=p.el=r(p.type,O,X&&X.is,X),K&8?f(N,p.children):K&16&&pe(p.children,N,null,F,E,Gs(p,O),U,j),te&&Jt(p,null,F,"created"),L(N,p,p.scopeId,U,F),X){for(const ye in X)ye!=="value"&&!En(ye)&&l(N,ye,null,X[ye],O,F);"value"in X&&l(N,"value",null,X.value,O),(P=X.onVnodeBeforeMount)&&ut(P,F,p)}te&&Jt(p,null,F,"beforeMount");const de=Jr(E,G);de&&G.beforeEnter(N),s(N,_,S),((P=X&&X.onVnodeMounted)||de||te)&&Ue(()=>{P&&ut(P,F,p),de&&G.enter(N),te&&Jt(p,null,F,"mounted")},E)},L=(p,_,S,F,E)=>{if(S&&D(p,S),F)for(let O=0;O{for(let P=N;P{const j=_.el=p.el;let{patchFlag:N,dynamicChildren:P,dirs:X}=_;N|=p.patchFlag&16;const K=p.props||be,G=_.props||be;let te;if(S&&Xt(S,!1),(te=G.onVnodeBeforeUpdate)&&ut(te,S,_,p),X&&Jt(_,p,S,"beforeUpdate"),S&&Xt(S,!0),P&&(!p.dynamicChildren||p.dynamicChildren.length!==P.length)&&(N=0,U=!1,P=null),(K.innerHTML&&G.innerHTML==null||K.textContent&&G.textContent==null)&&f(j,""),P?J(p.dynamicChildren,P,j,S,F,Gs(_,E),O):U||ue(p,_,j,null,S,F,Gs(_,E),O,!1),N>0){if(N&16)q(j,K,G,S,E);else if(N&2&&K.class!==G.class&&l(j,"class",null,G.class,E),N&4&&l(j,"style",K.style,G.style,E),N&8){const de=_.dynamicProps;for(let ye=0;ye{te&&ut(te,S,_,p),X&&Jt(_,p,S,"updated")},F)},J=(p,_,S,F,E,O,U)=>{for(let j=0;j<_.length;j++){const N=p[j],P=_[j],X=N.el&&(N.type===W||!$n(N,P)||N.shapeFlag&198)?b(N.el):S;R(N,P,X,null,F,E,O,U,!0)}},q=(p,_,S,F,E)=>{if(_!==S){if(_!==be)for(const O in _)!En(O)&&!(O in S)&&l(p,O,_[O],null,E,F);for(const O in S){if(En(O))continue;const U=S[O],j=_[O];U!==j&&O!=="value"&&l(p,O,j,U,E,F)}"value"in S&&l(p,"value",_.value,S.value,E)}},ae=(p,_,S,F,E,O,U,j,N)=>{const P=_.el=p?p.el:u(""),X=_.anchor=p?p.anchor:u("");let{patchFlag:K,dynamicChildren:G,slotScopeIds:te}=_;te&&(j=j?j.concat(te):te),p==null?(s(P,S,F),s(X,S,F),pe(_.children||[],S,X,E,O,U,j,N)):K>0&&K&64&&G&&p.dynamicChildren&&p.dynamicChildren.length===G.length?(J(p.dynamicChildren,G,S,E,O,U,j),(_.key!=null||E&&_===E.subTree)&&Bl(p,_,!0)):ue(p,_,S,X,E,O,U,j,N)},z=(p,_,S,F,E,O,U,j,N)=>{_.slotScopeIds=j,p==null?_.shapeFlag&512?E.ctx.activate(_,S,F,U,N):B(_,S,F,E,O,U,N):Y(p,_,N)},B=(p,_,S,F,E,O,U)=>{const j=p.component=ou(p,F,E);if(Ro(p)&&(j.ctx.renderer=Gt),lu(j,!1,U),j.asyncDep){if(E&&E.registerDep(j,oe,U),!p.el){const N=j.subTree=_e(It);$(null,N,_,S),p.placeholder=N.el}}else oe(j,p,_,S,E,O,U)},Y=(p,_,S)=>{const F=_.component=p.component;if(jr(p,_,S))if(F.asyncDep&&!F.asyncResolved){_.el=p.el,ie(F,_,S);return}else F.next=_,F.update();else _.el=p.el,F.vnode=_},oe=(p,_,S,F,E,O,U)=>{const j=()=>{if(p.isMounted){let{next:K,bu:G,u:te,parent:de,vnode:ye}=p;{const at=Ul(p);if(at){K&&(K.el=ye.el,ie(p,K,U)),at.asyncDep.then(()=>{Ue(()=>{p.isUnmounted||P()},E)});return}}let me=K,Ce;Xt(p,!1),K?(K.el=ye.el,ie(p,K,U)):K=ye,G&&rs(G),(Ce=K.props&&K.props.onVnodeBeforeUpdate)&&ut(Ce,de,K,ye),Xt(p,!0);const Ee=hi(p),lt=p.subTree;p.subTree=Ee,R(lt,Ee,b(lt.el),se(lt),p,E,O),K.el=Ee.el,me===null&&Br(p,Ee.el),te&&Ue(te,E),(Ce=K.props&&K.props.onVnodeUpdated)&&Ue(()=>ut(Ce,de,K,ye),E)}else{let K;const{el:G,props:te}=_,{bm:de,m:ye,parent:me,root:Ce,type:Ee}=p,lt=Dn(_);Xt(p,!1),de&&rs(de),!lt&&(K=te&&te.onVnodeBeforeMount)&&ut(K,me,_),Xt(p,!0);{Ce.ce&&Ce.ce._hasShadowRoot()&&Ce.ce._injectChildStyle(Ee,p.parent?p.parent.type:void 0);const at=p.subTree=hi(p);R(null,at,S,F,p,E,O),_.el=at.el}if(ye&&Ue(ye,E),!lt&&(K=te&&te.onVnodeMounted)){const at=_;Ue(()=>ut(K,me,at),E)}(_.shapeFlag&256||me&&Dn(me.vnode)&&me.vnode.shapeFlag&256)&&p.a&&Ue(p.a,E),p.isMounted=!0,_=S=F=null}};p.scope.on();const N=p.effect=new tl(j);p.scope.off();const P=p.update=N.run.bind(N),X=p.job=N.runIfDirty.bind(N);X.i=p,X.id=p.uid,N.scheduler=()=>Fo(X),Xt(p,!0),P()},ie=(p,_,S)=>{_.component=p;const F=p.vnode.props;p.vnode=_,p.next=null,Vr(p,_.props,F,S),Yr(p,_.children,S),Pt(),li(p),Ot()},ue=(p,_,S,F,E,O,U,j,N=!1)=>{const P=p&&p.children,X=p?p.shapeFlag:0,K=_.children,{patchFlag:G,shapeFlag:te}=_;if(G>0){if(G&128){an(P,K,S,F,E,O,U,j,N);return}else if(G&256){ze(P,K,S,F,E,O,U,j,N);return}}te&8?(X&16&&M(P,E,O),K!==P&&f(S,K)):X&16?te&16?an(P,K,S,F,E,O,U,j,N):M(P,E,O,!0):(X&8&&f(S,""),te&16&&pe(K,S,F,E,O,U,j,N))},ze=(p,_,S,F,E,O,U,j,N)=>{p=p||en,_=_||en;const P=p.length,X=_.length,K=Math.min(P,X);let G;for(G=0;GX?M(p,E,O,!0,!1,K):pe(_,S,F,E,O,U,j,N,K)},an=(p,_,S,F,E,O,U,j,N)=>{let P=0;const X=_.length;let K=p.length-1,G=X-1;for(;P<=K&&P<=G;){const te=p[P],de=_[P]=N?St(_[P]):pt(_[P]);if($n(te,de))R(te,de,S,null,E,O,U,j,N);else break;P++}for(;P<=K&&P<=G;){const te=p[K],de=_[G]=N?St(_[G]):pt(_[G]);if($n(te,de))R(te,de,S,null,E,O,U,j,N);else break;K--,G--}if(P>K){if(P<=G){const te=G+1,de=teG)for(;P<=K;)We(p[P],E,O,!0),P++;else{const te=P,de=P,ye=new Map;for(P=de;P<=G;P++){const Ke=_[P]=N?St(_[P]):pt(_[P]);Ke.key!=null&&ye.set(Ke.key,P)}let me,Ce=0;const Ee=G-de+1;let lt=!1,at=0;const _n=new Array(Ee);for(P=0;P=Ee){We(Ke,E,O,!0);continue}let rt;if(Ke.key!=null)rt=ye.get(Ke.key);else for(me=de;me<=G;me++)if(_n[me-de]===0&&$n(Ke,_[me])){rt=me;break}rt===void 0?We(Ke,E,O,!0):(_n[rt-de]=P+1,rt>=at?at=rt:lt=!0,R(Ke,_[rt],S,null,E,O,U,j,N),Ce++)}const Xo=lt?Xr(_n):en;for(me=Xo.length-1,P=Ee-1;P>=0;P--){const Ke=de+P,rt=_[Ke],Qo=_[Ke+1],Zo=Ke+1{const{el:O,type:U,transition:j,children:N,shapeFlag:P}=p;if(P&6){_t(p.component.subTree,_,S,F);return}if(P&128){p.suspense.move(_,S,F);return}if(P&64){U.move(p,_,S,Gt);return}if(U===W){s(O,_,S);for(let K=0;Kj.enter(O),E));else{const{leave:K,delayLeave:G,afterLeave:te}=j,de=()=>{p.ctx.isUnmounted?o(O):s(O,_,S)},ye=()=>{const me=O._isLeaving||!!O[Ys];O._isLeaving&&O[Ys](!0),j.persisted&&!me?de():K(O,()=>{de(),te&&te()})};G?G(O,de,ye):ye()}else s(O,_,S)},We=(p,_,S,F=!1,E=!1)=>{const{type:O,props:U,ref:j,children:N,dynamicChildren:P,shapeFlag:X,patchFlag:K,dirs:G,cacheIndex:te,memo:de}=p;if((K===-2||P&&P.hasOnce)&&(E=!1),j!=null&&(Pt(),Mn(j,null,S,p,!0),Ot()),te!=null&&(!p.ctx||p.ctx===_)&&(_.renderCache[te]=void 0),X&256){_.ctx.deactivate(p);return}const ye=X&1&&G,me=!Dn(p);let Ce;if(me&&(Ce=U&&U.onVnodeBeforeUnmount)&&ut(Ce,_,p),X&6)ce(p.component,S,F);else{if(X&128){p.suspense.unmount(S,F);return}ye&&Jt(p,null,_,"beforeUnmount"),X&64?p.type.remove(p,_,S,Gt,F):P&&!P.hasOnce&&(O!==W||K>0&&K&64)?M(P,_,S,!1,!0):(O===W&&K&384||!E&&X&16)&&M(N,_,S),F&&ts(p)}const Ee=de!=null&&te==null;(me&&(Ce=U&&U.onVnodeUnmounted)||ye||Ee)&&Ue(()=>{Ce&&ut(Ce,_,p),ye&&Jt(p,null,_,"unmounted"),Ee&&(p.el=null)},S)},ts=p=>{const{type:_,el:S,anchor:F,transition:E}=p;if(_===W){ns(S,F);return}if(_===cs){m(p),E&&!E.persisted&&E.afterLeave&&E.afterLeave();return}const O=()=>{o(S),E&&!E.persisted&&E.afterLeave&&E.afterLeave()};if(p.shapeFlag&1&&E&&!E.persisted){const{leave:U,delayLeave:j}=E,N=()=>U(S,O);j?j(p.el,O,N):N()}else O()},ns=(p,_)=>{let S;for(;p!==_;)S=A(p),o(p),p=S;o(_)},ce=(p,_,S)=>{const{bum:F,scope:E,job:O,subTree:U,um:j,m:N,a:P}=p;gi(N),gi(P),F&&rs(F),E.stop(),O?(O.flags|=8,We(U,p,_,S)):p.vnode.el&&U&&(U.transition=p.vnode.transition,We(U,p,_,S)),j&&Ue(j,_),Ue(()=>{p.isUnmounted=!0},_)},M=(p,_,S,F=!1,E=!1,O=0)=>{for(let U=O;U{if(p.shapeFlag&6)return se(p.component.subTree);if(p.shapeFlag&128)return p.suspense.next();const _=A(p.anchor||p.el),S=_&&_[fr];return S?A(S):_};let Ge=!1;const wn=(p,_,S)=>{let F;p==null?_._vnode&&(We(_._vnode,null,null,!0),F=_._vnode.component):R(_._vnode||null,p,_,null,null,null,S),_._vnode=p,Ge||(Ge=!0,li(F),yl(),Ge=!1)},Gt={p:R,um:We,m:_t,r:ts,mt:B,mc:pe,pc:ue,pbc:J,n:se,o:e};return{render:wn,hydrate:void 0,createApp:Dr(wn)}}function Gs({type:e,props:t},n){return n==="svg"&&e==="foreignObject"||n==="mathml"&&e==="annotation-xml"&&t&&t.encoding&&t.encoding.includes("html")?void 0:n}function Xt({effect:e,job:t},n){n?(e.flags|=32,t.flags|=4):(e.flags&=-33,t.flags&=-5)}function Jr(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function Bl(e,t,n=!1){const s=e.children,o=t.children;if(ee(s)&&ee(o))for(let l=0;l>1,e[n[u]]0&&(t[s]=n[l-1]),n[l]=s)}}for(l=n.length,r=n[l-1];l-- >0;)n[l]=r,r=t[r];return n}function Ul(e){const t=e.subTree.component;if(t)return t.asyncDep&&!t.asyncResolved?t:Ul(t)}function gi(e){if(e)for(let t=0;te.__isSuspense;function Qr(e,t){t&&t.pendingBranch?ee(e)?t.effects.push(...e):t.effects.push(e):lr(e)}const W=Symbol.for("v-fgt"),Ns=Symbol.for("v-txt"),It=Symbol.for("v-cmt"),cs=Symbol.for("v-stc"),nn=[];let Ye=null;function h(e=!1){nn.push(Ye=e?null:[])}function Kl(){nn.pop(),Ye=nn[nn.length-1]||null}let Bn=1;function yi(e,t=!1){Bn+=e,e<0&&Ye&&t&&(Ye.hasOnce=!0)}function ql(e){return e.dynamicChildren=Bn>0?Ye||en:null,Kl(),Bn>0&&Ye&&Ye.push(e),e}function g(e,t,n,s,o,l){return ql(i(e,t,n,s,o,l,!0))}function Ve(e,t,n,s,o){return ql(_e(e,t,n,s,o,!0))}function Yl(e){return e?e.__v_isVNode===!0:!1}function $n(e,t){return e.type===t.type&&e.key===t.key}const zl=({key:e})=>e??null,ds=({ref:e,ref_key:t,ref_for:n})=>(typeof e=="number"&&(e=""+e),e!=null?xe(e)||Le(e)||ne(e)?{i:qe,r:e,k:t,f:!!n}:e:null);function i(e,t=null,n=null,s=0,o=null,l=e===W?0:1,r=!1,u=!1){const a={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&zl(t),ref:t&&ds(t),scopeId:wl,slotScopeIds:null,children:n,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:l,patchFlag:s,dynamicProps:o,dynamicChildren:null,appContext:null,ctx:qe};return u?(bs(a,n),l&128&&e.normalize(a)):n&&(a.shapeFlag|=xe(n)?8:16),Bn>0&&!r&&Ye&&(a.patchFlag>0||l&6)&&a.patchFlag!==32&&Ye.push(a),a}const _e=Zr;function Zr(e,t=null,n=null,s=0,o=null,l=!1){if((!e||e===Tl)&&(e=It),Yl(e)){const u=vn(e,t,!0);return n&&bs(u,n),Bn>0&&!l&&Ye&&(u.shapeFlag&6?Ye[Ye.indexOf(e)]=u:Ye.push(u)),u.patchFlag=-2,u}if(du(e)&&(e=e.__vccOpts),t){t=eu(t);let{class:u,style:a}=t;u&&!xe(u)&&(t.class=re(u)),ge(a)&&(Do(a)&&!ee(a)&&(a=Oe({},a)),t.style=Ne(a))}const r=xe(e)?1:Wl(e)?128:Fs(e)?64:ge(e)?4:ne(e)?2:0;return i(e,t,n,s,o,r,l,!0)}function eu(e){return e?Do(e)||Ll(e)?Oe({},e):e:null}function vn(e,t,n=!1,s=!1){const{props:o,ref:l,patchFlag:r,children:u,transition:a}=e,c=t?tu(o||{},t):o,f={__v_isVNode:!0,__v_skip:!0,type:e.type,props:c,key:c&&zl(c),ref:t&&t.ref?n&&l?ee(l)?l.concat(ds(t)):[l,ds(t)]:ds(t):l,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:u,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==W?r===-1?16:r|16:r,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:a,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&vn(e.ssContent),ssFallback:e.ssFallback&&vn(e.ssFallback),placeholder:e.placeholder,el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce,cacheIndex:e.cacheIndex};return a&&s&&Ho(f,a.clone(f)),f}function Q(e=" ",t=0){return _e(Ns,null,e,t)}function fs(e,t){const n=_e(cs,null,e);return n.staticCount=t,n}function V(e="",t=!1){return t?(h(),Ve(It,null,e)):_e(It,null,e)}function pt(e){return e==null||typeof e=="boolean"?_e(It):ee(e)?_e(W,null,e.slice()):Yl(e)?St(e):_e(Ns,null,String(e))}function St(e){return e.el===null&&e.patchFlag!==-1||e.memo?e:vn(e)}function bs(e,t){let n=0;const{shapeFlag:s}=e;if(t==null)t=null;else if(ee(t))n=16;else if(typeof t=="object")if(s&65){const o=t.default;o&&(o._c&&(o._d=!1),bs(e,o()),o._c&&(o._d=!0));return}else{n=32;const o=t._;!o&&!Ll(t)?t._ctx=qe:o===3&&qe&&(qe.slots._===1?t._=1:(t._=2,e.patchFlag|=1024))}else if(ne(t)){if(s&65){bs(e,{default:t});return}t={default:t,_ctx:qe},n=32}else t=String(t),s&64?(n=16,t=[Q(t)]):n=8;e.children=t,e.shapeFlag|=n}function tu(...e){const t={};for(let n=0;nMe||qe;let ws,Un;{const e=Ms(),t=(n,s)=>{let o;return(o=e[n])||(o=e[n]=[]),o.push(s),l=>{o.length>1?o.forEach(r=>r(l)):o[0](l)}};ws=t("__VUE_INSTANCE_SETTERS__",n=>Me=n),Un=t("__VUE_SSR_SETTERS__",n=>Vn=n)}const Qn=e=>{const t=Me;return ws(e),e.scope.on(),()=>{e.scope.off(),ws(t)}},bi=()=>{Me&&Me.scope.off(),ws(null)};function Gl(e){return e.vnode.shapeFlag&4}let Vn=!1;function lu(e,t=!1,n=!1){t&&Un(t);const{props:s,children:o}=e.vnode,l=Gl(e);Ur(e,s,l,t),qr(e,o,n||t);const r=l?au(e,t):void 0;return t&&Un(!1),r}function au(e,t){const n=e.type;e.accessCache=Object.create(null),e.proxy=new Proxy(e.ctx,Tr);const{setup:s}=n;if(s){Pt();const o=e.setupContext=s.length>1?uu(e):null,l=Qn(e),r=Jn(s,e,0,[e.props,o]),u=zi(r);if(Ot(),l(),(u||e.sp)&&!Dn(e)&&xl(e),u){if(r.then(bi,bi),t)return r.then(a=>{Un(!0);try{wi(e,a,t)}finally{Un(!1)}}).catch(a=>{Ls(a,e,0)});e.asyncDep=r}else wi(e,r)}else Jl(e)}function wi(e,t,n){ne(t)?e.type.__ssrInlineRender?e.ssrRender=t:e.render=t:ge(t)&&(e.setupState=vl(t)),Jl(e)}function Jl(e,t,n){const s=e.type;e.render||(e.render=s.render||mt);{const o=Qn(e);Pt();try{Ar(e)}finally{Ot(),o()}}}const ru={get(e,t){return Ie(e,"get",""),e[t]}};function uu(e){const t=n=>{e.exposed=n||{}};return{attrs:new Proxy(e.attrs,ru),slots:e.slots,emit:e.emit,expose:t}}function js(e){return e.exposed?e.exposeProxy||(e.exposeProxy=new Proxy(vl(Ja(e.exposed)),{get(t,n){if(n in t)return t[n];if(n in Ln)return Ln[n](e)},has(t,n){return n in t||n in Ln}})):e.proxy}function cu(e,t=!0){return ne(e)?e.displayName||e.name:e.name||t&&e.__name}function du(e){return ne(e)&&"__vccOpts"in e}const k=(e,t)=>tr(e,t,Vn),fu="3.5.43";let vo;const _i=typeof window<"u"&&window.trustedTypes;if(_i)try{vo=_i.createPolicy("vue",{createHTML:e=>e})}catch{}const Xl=vo?e=>vo.createHTML(e):e=>e,pu="http://www.w3.org/2000/svg",hu="http://www.w3.org/1998/Math/MathML",$t=typeof document<"u"?document:null,ki=$t&&$t.createElement("template"),vu={insert:(e,t,n)=>{t.insertBefore(e,n||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,n,s)=>{const o=t==="svg"?$t.createElementNS(pu,e):t==="mathml"?$t.createElementNS(hu,e):n?$t.createElement(e,{is:n}):$t.createElement(e);return e==="select"&&s&&s.multiple!=null&&o.setAttribute("multiple",s.multiple),o},createText:e=>$t.createTextNode(e),createComment:e=>$t.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>$t.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,n,s,o,l){const r=n?n.previousSibling:t.lastChild;if(o&&(o===l||o.nextSibling))for(;t.insertBefore(o.cloneNode(!0),n),!(o===l||!(o=o.nextSibling)););else{ki.innerHTML=Xl(s==="svg"?`${e}`:s==="mathml"?`${e}`:e);const u=ki.content;if(s==="svg"||s==="mathml"){const a=u.firstChild;for(;a.firstChild;)u.appendChild(a.firstChild);u.removeChild(a)}t.insertBefore(u,n)}return[r?r.nextSibling:t.firstChild,n?n.previousSibling:t.lastChild]}},mu=Symbol("_vtc");function gu(e,t,n){const s=e[mu];s&&(t=(t?[t,...s]:[...s]).join(" ")),t==null?e.removeAttribute("class"):n?e.setAttribute("class",t):e.className=t}const $i=Symbol("_vod"),yu=Symbol("_vsh"),bu=Symbol(""),wu=/(?:^|;)\s*display\s*:/;function _u(e,t,n){const s=e.style,o=xe(n);let l=!1;if(n&&!o){if(t)if(xe(t))for(const r of t.split(";")){const u=r.slice(0,r.indexOf(":")).trim();n[u]==null&&Cn(s,u,"")}else for(const r in t)n[r]==null&&Cn(s,r,"");for(const r in n){r==="display"&&(l=!0);const u=n[r];u!=null?$u(e,r,!xe(t)&&t?t[r]:void 0,u)||Cn(s,r,u):Cn(s,r,"")}}else if(o){if(t!==n){const r=s[bu];r&&(n+=";"+r),s.cssText=n,l=wu.test(n)}}else t&&e.removeAttribute("style");$i in e&&(e[$i]=l?s.display:"",e[yu]&&(s.display="none"))}const ls=/\s*!important$/;function Cn(e,t,n){if(ee(n))n.forEach(s=>Cn(e,t,s));else if(n==null&&(n=""),t.startsWith("--"))ls.test(n)?e.setProperty(t,n.replace(ls,""),"important"):e.setProperty(t,n);else{const s=ku(e,t);ls.test(n)?e.setProperty(Yt(s),n.replace(ls,""),"important"):e[s]=n}}const xi=["Webkit","Moz","ms"],Js={};function ku(e,t){const n=Js[t];if(n)return n;let s=Be(t);if(s!=="filter"&&s in e)return Js[t]=s;s=Os(s);for(let o=0;oXs||(Eu.then(()=>Xs=0),Xs=Date.now());function Ou(e,t){const n=s=>{if(!s._vts)s._vts=Date.now();else if(s._vts<=n.attached)return;const o=n.value;if(ee(o)){const l=s.stopImmediatePropagation;s.stopImmediatePropagation=()=>{l.call(s),s._stopped=!0};const r=o.slice(),u=[s];for(let a=0;ae.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,Iu=(e,t,n,s,o,l)=>{const r=o==="svg";t==="class"?gu(e,s,r):t==="style"?_u(e,n,s):As(t)?Es(t)||Su(e,t,n,s,l):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):Mu(e,t,s,r))?(Ti(e,t,s),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&Ci(e,t,s,r,l,t!=="value")):e._isVueCE&&(Du(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!xe(s)))?Ti(e,Be(t),s,l,t):(t==="true-value"?e._trueValue=s:t==="false-value"&&(e._falseValue=s),Ci(e,t,s,r))};function Mu(e,t,n,s){if(s)return!!(t==="innerHTML"||t==="textContent"||t in e&&Ei(t)&&ne(n));if(t==="spellcheck"||t==="draggable"||t==="translate"||t==="autocorrect"||t==="sandbox"&&e.tagName==="IFRAME"||t==="form"||t==="list"&&e.tagName==="INPUT"||t==="type"&&e.tagName==="TEXTAREA")return!1;if(t==="width"||t==="height"){const o=e.tagName;if(o==="IMG"||o==="VIDEO"||o==="CANVAS"||o==="SOURCE")return!1}return Ei(t)&&xe(n)?!1:t in e}function Du(e,t){const n=e._def.props;if(!n)return!1;const s=Be(t);return Array.isArray(n)?n.some(o=>Be(o)===s):Object.keys(n).some(o=>Be(o)===s)}const mn=e=>{const t=e.props["onUpdate:modelValue"]||!1;return ee(t)?n=>rs(t,n):t};function Lu(e){e.target.composing=!0}function Pi(e){const t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event("input")))}const vt=Symbol("_assign"),as=Symbol("_initialValue");function Qs(e,t,n){return t&&(e=e.trim()),n&&(e=Is(e)),e}const Je={created(e,{modifiers:{lazy:t,trim:n,number:s}},o){e.parentNode&&(e.type==="text"?e[as]=e.defaultValue.replace(/[\r\n]/g,""):e.type==="textarea"&&(e[as]=e.defaultValue.replace(/\r\n?/g,` +`))),e[vt]=mn(o);const l=s||o.props&&o.props.type==="number";jt(e,t?"change":"input",r=>{r.target.composing||e[vt](Qs(e.value,n,l))}),(n||l)&&jt(e,"change",()=>{e.value=Qs(e.value,n,l)}),t||(jt(e,"compositionstart",Lu),jt(e,"compositionend",Pi),jt(e,"change",Pi))},mounted(e,{value:t,modifiers:{trim:n,number:s}}){const o=t??"",l=e[as];delete e[as],l!==void 0&&(e.type==="text"||e.type==="textarea")&&e.value!==l?e[vt](Qs(e.value,n,s)):e.value=o},beforeUpdate(e,{value:t,oldValue:n,modifiers:{lazy:s,trim:o,number:l}},r){if(e[vt]=mn(r),e.composing)return;const u=(l||e.type==="number")&&!/^0\d/.test(e.value)?Is(e.value):e.value,a=t??"";if(u===a)return;const c=e.getRootNode();(c instanceof Document||c instanceof ShadowRoot)&&c.activeElement===e&&e.type!=="range"&&(s&&t===n||o&&e.value.trim()===a)||(e.value=a)}},Oi={created(e,{value:t},n){e.checked=yt(t,n.props.value),e[vt]=mn(n),jt(e,"change",()=>{e[vt](Wn(e))})},beforeUpdate(e,{value:t,oldValue:n},s){e[vt]=mn(s),t!==n&&(e.checked=yt(t,s.props.value))}},_s={deep:!0,created(e,{value:t,modifiers:{number:n}},s){e._modelValue=t,jt(e,"change",()=>{const o=Array.prototype.filter.call(e.options,a=>a.selected).map(a=>n?Is(Wn(a)):Wn(a)),l=e.multiple,r=l?sn(e._modelValue)?new Set(o):o:o[0],u=e._pendingValue=[l,l?ee(r)?o.slice():o:r];try{e[vt](r)}finally{Lo(()=>{e._pendingValue===u&&(e._pendingValue=void 0)})}}),e[vt]=mn(s)},mounted(e,{value:t}){Ii(e,t)},beforeUpdate(e,{value:t},n){e._modelValue=t,e[vt]=mn(n)},updated(e,{value:t}){const n=e._pendingValue;e._pendingValue=void 0,(!n||n[0]!==e.multiple||!Fu(t,n[1],n[0]))&&Ii(e,t)}};function Fu(e,t,n){if(!n||ee(e))return yt(e,t);if(sn(e)){if(e.size!==t.length)return!1;for(const s of t)if(!e.has(s))return!1;return!0}return!1}function Ii(e,t){const n=e.multiple,s=ee(t);if(!(n&&!s&&!sn(t))){for(let o=0,l=e.options.length;oString(c)===String(u)):r.selected=Ea(t,u)>-1}else r.selected=t.has(u);else if(yt(Wn(r),t)){e.selectedIndex!==o&&(e.selectedIndex=o);return}}!n&&e.selectedIndex!==-1&&(e.selectedIndex=-1)}}function Wn(e){return"_value"in e?e._value:e.value}const Hu=["ctrl","shift","alt","meta"],Ru={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&e.button!==0,middle:e=>"button"in e&&e.button!==1,right:e=>"button"in e&&e.button!==2,exact:(e,t)=>Hu.some(n=>e[`${n}Key`]&&!t.includes(n))},Ft=(e,t)=>{if(!e)return e;const n=e._withMods||(e._withMods={}),s=t.join(".");return n[s]||(n[s]=((o,...l)=>{for(let r=0;r{const n=e._withKeys||(e._withKeys={}),s=t.join(".");return n[s]||(n[s]=(o=>{if(!("key"in o))return;const l=Yt(o.key);if(t.some(r=>r===l||Nu[r]===l))return e(o)}))},ju=Oe({patchProp:Iu},vu);let Mi;function Bu(){return Mi||(Mi=zr(ju))}const Uu=((...e)=>{const t=Bu().createApp(...e),{mount:n}=t;return t.mount=s=>{const o=Wu(s);if(!o)return;const l=t._component;!ne(l)&&!l.render&&!l.template&&(l.template=o.innerHTML),o.nodeType===1&&(o.textContent="");const r=n(o,!1,Vu(o));return o instanceof Element&&(o.removeAttribute("v-cloak"),o.setAttribute("data-v-app","")),r},t});function Vu(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function Wu(e){return xe(e)?document.querySelector(e):e}const Ku=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],qu=["Su","Mo","Tu","We","Th","Fr","Sa"],Zl={clock:"clock-outline",analog:"clock-outline",date:"calendar"},He=e=>JSON.stringify(e),ke=e=>String.fromCodePoint(parseInt(e,16));function Bo(e,t=new Date){let n=t.getHours();return e||(n=n%12||12),`${String(n).padStart(2,"0")}:${String(t.getMinutes()).padStart(2,"0")}`}const Uo=(e=new Date)=>`${qu[e.getDay()]} ${e.getDate()} ${Ku[e.getMonth()]}`;function Yu(e,t=Math.floor(Date.now()/1e3)){const n=t-e,s=Math.abs(n),o=l=>Math.floor(s/l);return n<0?s<3600?`In ${Math.max(1,o(60))} min`:s<86400?o(3600)===1?"In 1 hour":`In ${o(3600)} hours`:s<172800?"Tomorrow":`In ${o(86400)} days`:s<60?"Just now":s<3600?`${o(60)} min ago`:s<86400?o(3600)===1?"1 hour ago":`${o(3600)} hours ago`:s<172800?"Yesterday":s<604800?`${o(86400)} days ago`:s<2592e3?o(604800)===1?"1 week ago":`${o(604800)} weeks ago`:s<31536e3?o(2592e3)===1?"1 month ago":`${o(2592e3)} months ago`:o(31536e3)===1?"1 year ago":`${o(31536e3)} years ago`}const zu={guition:{width:448,top:36,name:27,text:21,icon:26},cyd:{width:298,top:24,name:18,text:14,icon:18}},Di=document.createElement("canvas").getContext("2d"),mo=new Map,Gu=()=>mo.clear();function Zt(e,t){const n=`${t}|${e}`;let s=mo.get(n);if(!s){Di.font=t;const o=Di.measureText(e);s={left:-o.actualBoundingBoxLeft,right:o.actualBoundingBoxRight,top:-o.actualBoundingBoxAscent,bottom:o.actualBoundingBoxDescent,advance:o.width},mo.set(n,s)}return s}const Ju=e=>({name:`500 ${e.name}px "Bar Roboto"`,text:`400 ${e.text}px "Bar Roboto"`,icon:`${e.icon}px "Tile Icons"`});function Xu(e){return{icon:Math.max(2,Math.floor((e*4+5)/10)),item:Math.max(6,Math.floor((e*125+50)/100)),name:Math.max(8,Math.floor((e*16+5)/10))}}function ea(e,t,n,s){const o=Ju(t),l=Zt("0",o.text),r=Math.round(l.bottom-l.top),u=Xu(r),a=Zt(String.fromCodePoint(983376),o.icon),c=Math.round(a.bottom-a.top),f=e.map((m,H)=>{const C=s(m),L={index:H,item:m,view:C,shown:C.shown,width:0};return C.analog?(L.dial=c,L.width=c,L):(C.icon&&(L.icon={glyph:ke(C.icon),ink:Zt(ke(C.icon),o.icon)},L.width+=L.icon.ink.right-L.icon.ink.left),C.text&&(L.text={value:C.text,ink:Zt(C.text,o.text)},L.width+=(L.icon?u.icon:0)+L.text.ink.right-L.text.ink.left),L)}),b=f.filter(m=>m.shown),A=Zt(n,o.name).advance,D=Math.min(A,Math.floor(t.width*35/100)),I=m=>m.reduce((H,C)=>H+C.width,0)+Math.max(0,m.length-1)*u.item;let R=0;for(;Rt.width;)R++;const T=b.slice(R),$=new Set(b.slice(0,R).map(m=>m.index));let x=t.width-I(T);for(const m of T)m.x=x,x+=m.width+u.item;const w=T.length?T[0].x-u.name:t.width;return{metrics:t,fonts:o,cap:r,zero:l,gaps:u,parts:f,placed:T,dropped:$,nameText:n,natural:A,nameRoom:w}}function Qu(e,t,n){if(Zt(e,t).advance<=n)return e;const s=[...e];for(;s.length&&Zt(s.join("")+"...",t).advance>n;)s.pop();return s.join("")+"..."}function Zu(e){"fonts"in document&&Promise.all([document.fonts.load('500 27px "Bar Roboto"',"Studio 0"),document.fonts.load('400 21px "Bar Roboto"',"Away 0"),document.fonts.load('26px "Tile Icons"',String.fromCodePoint(983376))]).then(()=>{Gu(),e()}).catch(()=>{})}let ta="";function ec(e){ta=e||""}async function Vo(e,t={}){const n=await fetch(`api/${e}`,{...t,headers:{"Content-Type":"application/json","X-Screen-CSRF":ta,...t.headers}});if(!n.ok){let s="That didn't work. Refresh the page and try again.";try{s=(await n.json()).error||s}catch{}throw new Error(s)}return n}const Mt=async e=>(await Vo(e)).json(),it=async(e,t,n,s={})=>{const l=await(await Vo(e,{method:t,body:n===void 0?void 0:JSON.stringify(n),...s})).text();return l?JSON.parse(l):null},Et=6,gn=8,Wo=gn*Et,Qe=e=>e.options?.size==="wide",Fn=(e,t)=>t?[e,e+1]:[e],go=e=>e-e%2,ks=e=>Math.floor(e/Et),wt=e=>e.tiles.map(t=>({tile:t,slot:t.slot}));function na(e){let t=0;return e.map(n=>{const s=Qe(n);s&&t%2===1&&t++;const o=t;return t+=s?2:1,o})}function tc(e){const t=na(e);return e.some((n,s)=>n.slot!==t[s])}function Ko(e){if(e.tiles.some(t=>!Number.isInteger(t.slot))){const t=na(e.tiles);e.tiles.forEach((n,s)=>n.slot=t[s])}e.tiles.sort((t,n)=>t.slot-n.slot)}function qo(e){const t=new Set;for(const{tile:n,slot:s}of e)for(const o of Fn(s,Qe(n)))t.add(o);return t}const Kn=(e,t,n)=>Number.isInteger(t)&&t>=0&&t+(n?1:0)!e.has(s));function nc(e,t){for(let n=0;no.includes(c))?u.push(a):r.push({tile:a,slot:a.slot}));for(const a of u){const c=Qe(a),f=qo(r);let b=l.map(A=>c?go(A):A).find(A=>Kn(f,A,c));if(b===void 0&&(b=sa(f,c,a.slot)),b<0)return null;r.push({tile:a,slot:b})}return r.sort((a,c)=>a.slot-c.slot)}function zt(e,t=1){const n=Math.max(0,...e.map(({tile:s,slot:o})=>o+(Qe(s)?2:1)));return Math.min(gn,Math.max(1,Math.ceil(n/Et),t||1))}function sc(e){const t=e.split(".")[0];return t==="sun"?{options:{display:"sunpath",size:"wide"}}:t==="weather"?{options:{display:"forecast",size:"wide"}}:t==="screen"?{options:{display:"digital",size:"wide"}}:{}}const ia=e=>({entity:e,name:"",slot:-1,...sc(e)});function la(e,t){const n=e.entity.split(".")[0],s=t.controls?.[n],o=e.options||{};if(!s||o.size!=="wide"||(o.display||"standard")!=="standard"||o.inline==="slider")return null;const l=o.controls??s.default;return l==="none"?null:l}function oc(e,t){const n=la(e,t);return n?t.controls?.[e.entity.split(".")[0]]?.choices.find(s=>s.key===n)?.label.toLocaleLowerCase()||n:"none"}const yo=e=>(/^(\d+)\.(\d+)\.(\d+)$/.exec(e||"")||[]).slice(1).map(Number);function bo(e,t){const[n,s]=[yo(e),yo(t)];if(n.length!==3||s.length!==3)return!1;for(let o=0;o<3;o++)if(n[o]!==s[o])return n[o]>s[o];return!0}const ic=(e,t,n,s)=>bo(e,`${t}.${n}.${s}`);function lc(e){const t=yo(e);return t.length!==3?10:t[0]>0||t[1]>2||t[1]===2&&t[2]>=7?20:10}const aa={standard:"standard",watch:"large value",forecast:"weather forecast",graph:"graph",digital:"digital clock",analog:"analog clock",sunpath:"sun path"},Li=["light","switch","input_boolean","fan","media_player","climate"],ac=["light","fan","cover","number","input_number","media_player"],rc={light:["Light","☀","#ad7600","#fff3d3"],climate:["Climate","❄","#c86620","#ffebdc"],vacuum:["Vacuum","◉","#008577","#def3ed"],fan:["Fan","✣","#008aab","#def5fa"],cover:["Cover","▤","#8053af","#eee5f8"],media_player:["Media","▶","#007cad","#def2fc"],sensor:["Sensor","⌁","#3476b1","#e5effa"],binary_sensor:["Status","◈","#ad7600","#fff3d3"],switch:["Switch","⏻","#ad7600","#fff3d3"],input_boolean:["Switch","⏻","#ad7600","#fff3d3"],scene:["Scene","✦","#8053af","#eee5f8"],script:["Script","▷","#8053af","#eee5f8"],weather:["Weather","☁","#007cad","#def2fc"],number:["Value","±","#008577","#def3ed"],input_number:["Value","±","#008577","#def3ed"],select:["Select","≡","#5862af","#eaecfa"],input_select:["Select","≡","#5862af","#eaecfa"],button:["Action","↗","#5862af","#eaecfa"],input_button:["Action","↗","#5862af","#eaecfa"],screen:["Clock","◷","#25282c","#e9ecf1"],sun:["Sun","☼","#c86620","#ffebdc"],timer:["Timer","⏱","#008577","#def3ed"],person:["Person","☺","#2f7d32","#e1f2e2"],camera:["Camera","◧","#3d4a57","#e6ebf0"],image:["Image","◧","#3d4a57","#e6ebf0"]},xt=e=>rc[e.split(".")[0]]||["Entity","◇","#637184","#edf0f4"],d=hn({inventory:{screens:[],entities:[]},connected:!1,reachable:!0,selected:null,layout:null,dirty:!1,busy:!1,saved:0,tab:"layout",selectedTile:null,inspector:null,iconPickerOpen:!1,actionPickerOpen:!1,actionSearch:"",insertAt:-1,filter:"",search:"",capabilities:{},entityActions:{},topbarPreviews:{},topbarAdded:null,topbarOverflow:[],settingEdits:{},settingPending:!1,updating:[],toast:null,now:Date.now(),fontsVersion:0,route:location.hash,overrideProfile:null,overrideFriendly:"",drag:{active:!1,moving:null,preview:null},menuOpen:!1}),qt=k(()=>d.inventory.screens.find(e=>e.id===d.selected)),ra=k(()=>qt.value?.firmware||""),fn=(e,t,n)=>ic(ra.value,e,t,n),Hn=k(()=>lc(ra.value)),Yo=k(()=>qt.value?.board==="guition"),ua=k(()=>zu[Yo.value?"guition":"cyd"]),Zs=k(()=>d.selectedTile&&d.layout?d.layout.tiles.find(e=>e.entity===d.selectedTile):void 0);let Fi=0;function Se(e,t){d.toast={message:e,action:t},clearTimeout(Fi),Fi=window.setTimeout(()=>d.toast=null,t?8e3:5e3)}function uc(){d.toast=null}async function dt(e,t,n="API key"){try{if(!navigator.clipboard||!window.isSecureContext)throw new Error;await navigator.clipboard.writeText(e),Se(`${n} copied.`)}catch{if(t){const s=document.createRange();s.selectNodeContents(t);const o=window.getSelection();o?.removeAllRanges(),o?.addRange(s)}Se(document.execCommand("copy")?`${n} copied.`:`${n} is selected. Copy with Ctrl+C or Command+C.`)}}function $s(){const e="/config/integrations/dashboard";try{window.top.location.assign(e)}catch{window.open(e,"_blank")}}const cc=["","#settings","#new-screen","#firmware","#alerts","#override"],tt=k(()=>cc.includes(d.route)?d.route:"");function De(e){if(location.hash===e){d.route=e;return}location.hash=e}window.addEventListener("hashchange",()=>{d.route=location.hash,window.scrollTo(0,0)});function Zn(e){return d.inventory.entities.find(t=>t.id===e)?.name||d.inventory.builtin?.find(t=>t.id===e)?.name||e}let eo={source:null,byName:{}};function es(e){return eo.source!==d.inventory.icons&&(eo={source:d.inventory.icons,byName:Object.fromEntries((d.inventory.icons?.groups||[]).flatMap(t=>t.icons.map(n=>[n.name,n])))}),e?eo.byName[e]:void 0}function on(e){const t=d.inventory.icons;if(!t)return"F0335";const n=d.inventory.entities.find(o=>o.id===e),s=e.split(".")[0];return t.builtin?.[e]?t.builtin[e]:n?.icon?n.icon:s==="weather"?t.weather[n?.state||""]||t.weather.partlycloudy:s==="sun"?t.sun[n?.state||""]||t.sun.below_horizon:t.defaults[s]||t.fallback}const wo=e=>es(e.options?.icon)?.cp||on(e.entity),to=new Set;async function zo(e){const t=[...new Set(e)].filter(n=>!to.has(n)&&!n.startsWith("screen."));if(t.length){t.forEach(n=>to.add(n));try{for(let n=0;n`entity=${encodeURIComponent(o)}`).join("&");Object.assign(d.capabilities,(await Mt(`capabilities?${s}`)).capabilities||{})}}catch{t.forEach(n=>to.delete(n))}}}const no=new Set;async function dc(e){if(!no.has(e)){no.add(e);try{d.entityActions[e]=(await Mt(`entity-actions?entity=${encodeURIComponent(e)}`)).actions}catch{no.delete(e)}}}function et(){d.dirty=!0,d.saved=0}function fc(e){if(e!==d.selected&&d.dirty&&!confirm("You have unsaved changes. Open a different screen anyway?"))return;e!==d.selected&&(Yn(),d.settingEdits={}),d.selected=e,d.selectedTile=null,d.inspector=null,d.tab="layout",d.menuOpen=!1;const t=d.inventory.screens.find(n=>n.id===e);if(!t){d.layout=null;return}d.layout=JSON.parse(JSON.stringify(t.layout)),Ko(d.layout),d.layout.pages=zt(wt(d.layout),d.layout.pages),d.insertAt=-1,d.dirty=!1,d.saved=0,Go(0),zo(d.layout.tiles.map(n=>n.entity)),De("")}function pc(e){const t=d.layout,n=t.tiles.map(o=>`${o.entity}@${o.slot}`).join();for(const{tile:o,slot:l}of e)o.slot=l,t.tiles.includes(o)||t.tiles.push(o);Ko(t),t.pages=zt(wt(t),t.pages);const s=t.tiles.map(o=>`${o.entity}@${o.slot}`).join()!==n;return s&&et(),s}function Bs(e,t){if(!d.layout)return!1;zo([e.entity]);const n=oa(d.layout.tiles,e,t);return n?pc(n):!1}function hc(e){const t=d.layout;if(!t||t.tiles.some(o=>o.entity===e)||t.tiles.length>=Hn.value)return;const n=ia(e),s=d.insertAt>=0?d.insertAt:nc(qo(wt(t)),Qe(n));d.insertAt=-1,s>=0&&Bs(n,s)&&_o(e)}function ca(e){const t=d.layout;if(!t)return;const n=t.tiles.indexOf(e);n<0||(t.tiles.splice(n,1),d.selectedTile===e.entity&&je(),et(),t.pages=zt(wt(t),t.pages),Se(`${e.name||Zn(e.entity)} removed`,{label:"Undo",run:()=>Bs(e,e.slot)}))}function Hi(){const e=d.layout;e&&(e.pages=Math.min(gn,zt(wt(e),e.pages)+1),et())}function vc(e){const t=d.layout;if(t){for(const n of t.tiles)ks(n.slot)>e&&(n.slot-=Et);t.pages=Math.max(1,zt(wt(t),t.pages)-1),et()}}function mc(){const e=d.layout;if(!e)return 1;const t=d.drag.preview||wt(e),n=zt(t,e.pages);return d.drag.active&&nu.key!=="none"&&o.controls.includes(u.key))?.key||"none"),et(),d.layout&&Qe(e)&&!l){const u=qo(wt(d.layout).filter(f=>f.tile!==e)),a=go(e.slot),c=Kn(u,a,!0)?a:sa(u,!0,a);c>=0&&(e.slot=c),Ko(d.layout),d.layout.pages=zt(wt(d.layout),d.layout.pages)}}function _o(e){d.selectedTile!==e&&(d.iconPickerOpen=!1,d.actionPickerOpen=!1,d.actionSearch=""),d.selectedTile=e,d.inspector={kind:"tile"},zo([e])}function Ct(e){d.inspector?.kind==="bar"&&d.inspector.index===e||(d.iconPickerOpen=!1),d.selectedTile=null,d.inspector={kind:"bar",index:e}}function Ri(){d.selectedTile=null,d.inspector={kind:"bar-add"}}function je(){d.inspector=null,d.selectedTile=null}async function gc(){if(!(d.busy||!d.layout||!d.selected)){d.busy=!0;try{const{settings:e,...t}=d.layout;await it(`screens/${encodeURIComponent(d.selected)}`,"PUT",t),d.dirty=!1,d.saved=Date.now(),Se("Saved. Your screen is being updated."),await ln()}catch(e){Se(e.message)}finally{d.busy=!1}}}const Dt=()=>d.layout?.header?.items??(d.layout?.settings?.show_clock??!0?[{type:"clock"}]:[]),Bt=()=>d.inventory.header?.max_items||6;function qn(e){d.layout&&(d.layout.header={items:e},et(),Go())}let Ni=0;function Go(e=150){clearTimeout(Ni),Ni=window.setTimeout(async()=>{const t=Dt();if(t.some(n=>n.type==="entity"))try{const n=await it("header-preview","POST",{header:{items:t}});t.forEach((s,o)=>d.topbarPreviews[He(s)]=n.items[o])}catch{}},e)}function Tn(e){return e.type==="entity"?Zn(e.entity):d.inventory.header?.builtin.find(t=>t.type===e.type)?.label||e.type}function Rt(e){const t=new Date(d.now);if(e.type==="clock")return{text:Bo(yn().clock_24h!==!1,t),shown:!0};if(e.type==="date")return{text:Uo(t),shown:!0};if(e.type==="analog")return{analog:!0,shown:!0};const n=d.topbarPreviews[He(e)];return n?{icon:n.i||null,text:n.k==="ago"?Yu(n.e,Math.floor(d.now/1e3)):n.t,color:n.c?`#${n.c}`:null,shown:n.shown}:{icon:e.icon==="none"?null:es(e.icon)?.cp||on(e.entity),text:"…",shown:!0,loading:!0}}function so(e,t){const n=[...Dt()];return t<0||t>=n.length||e===t?!1:(n.splice(t,0,...n.splice(e,1)),qn(n),!0)}function ji(e){const t=[...Dt()],[n]=t.splice(e,1);n&&(d.inspector?.kind==="bar"&&je(),qn(t),Se(`${Tn(n)} removed from the top bar`,{label:"Undo",run:()=>{const s=[...Dt()];s.splice(Math.min(e,s.length),0,n),qn(s)}}))}function oo(e){const t=Dt();if(t.length>=Bt())return Se(`The top bar has room for ${Bt()} items.`);if(t.some(n=>He(n)===He(e)))return Se("This is already in the top bar.");d.topbarAdded={key:He(e),time:Date.now()},qn([...t,e]),Ct(t.length)}const yc=[{title:"Brightness",icon:"F0599",rows:[{key:"brightness",label:"Brightness",kind:"number",min:5,max:100,step:5,unit:"%"},{key:"dark_mode",label:"Dark mode",kind:"toggle"},{key:"standby_enabled",label:"Auto standby",kind:"toggle"},{key:"standby_seconds",label:"Standby after",kind:"duration",min:60,max:86400,needs:"standby_enabled"},{key:"standby_brightness",label:"Standby brightness",kind:"number",min:0,max:100,step:5,unit:"%",needs:"standby_enabled",cap:"brightness"}]},{title:"Night",icon:"F0594",rows:[{key:"night_enabled",label:"Night mode",kind:"toggle"},{key:"night_start",label:"Starts",kind:"moment",needs:"night_enabled"},{key:"night_end",label:"Ends",kind:"moment",needs:"night_enabled"},{key:"night_brightness",label:"Night brightness",kind:"number",min:0,max:100,step:5,unit:"%",needs:"night_enabled",cap:"brightness"}]},{title:"Screen",icon:"F0379",rows:[{key:"clock_24h",label:"Clock",kind:"choice",options:[[!1,"12 hour"],[!0,"24 hour"]]},{key:"auto_home",label:"Back to page 1",kind:"toggle"},{key:"auto_home_seconds",label:"After",kind:"duration",min:30,max:3600,needs:"auto_home"},{key:"home_on_standby",label:"Also on standby",kind:"toggle"},{key:"swipe_pages",label:"Swipe between pages",kind:"toggle"},{key:"rotation",label:"Rotation",kind:"choice",options:[[0,"0°"],[90,"90°"],[180,"180°"],[270,"270°"]]}]}],da=4e3;let Ut={},pn=null,xs=0,ps=null;const Jo=()=>qt.value?.settings;function yn(){const e=Jo(),t={...e?.values||{}};for(const[n,s]of Object.entries(d.settingEdits))t[n]=s.value;return t}const bc=e=>e<300?30:e<900?60:e<3600?300:e<7200?900:1800;function Bi(e,t,n,s,o){if(e.kind==="moment"){let u=s&&t%60?Math.floor(t/60)*60+(n>0?60:0):t+n*(s?60:15);return u%=1440,u<0?u+1440:u}const l=e.kind==="duration"?bc(n<0?t-1:t):e.step,r=e.cap?Math.min(e.max,o[e.cap]):e.max;return Math.min(r,Math.max(e.min,t+n*l))}function wc(e){if(e<60)return`${e} sec`;if(e<3600)return`${Math.floor(e/60)} min`;const t=Math.floor(e/3600),n=Math.floor(e%3600/60);return n?`${t} h ${String(n).padStart(2,"0")}`:`${t} h`}function _c(e,t){const n=Math.floor(e/60),s=String(e%60).padStart(2,"0");return t?`${String(n).padStart(2,"0")}:${s}`:`${n%12||12}:${s} ${n<12?"AM":"PM"}`}function kc(e,t){const n=t[e.key];return n==null?"—":e.kind==="number"?`${n}${e.unit||""}`:e.kind==="duration"?wc(n):e.kind==="moment"?_c(n,t.clock_24h!==!1):""}function An(e,t,n){if(pn&&pn!==d.selected&&Object.keys(Ut).length){Yn(),Se("Still saving the other screen's settings. Try again in a moment.");return}pn=d.selected;const s=yn();if(d.settingEdits[e]={value:t,at:Date.now()},Ut[e]=t,e==="brightness")for(const o of["standby_brightness","night_brightness"])s[o]>t&&(d.settingEdits[o]={value:t,at:Date.now()});d.settingPending=!0,clearTimeout(xs),xs=window.setTimeout(()=>Yn(),n)}async function Yn(e=!1){if(clearTimeout(xs),ps||!Object.keys(Ut).length||!pn)return;const t=pn,n=Ut;Ut={};const s=Vo(`screens/${encodeURIComponent(t)}/settings`,{method:"PUT",body:JSON.stringify({settings:n}),keepalive:e});ps=s;try{const o=await(await s).json(),l=d.inventory.screens.find(r=>r.id===t);l&&(l.settings=o)}catch(o){if(Se(o.message),t===d.selected)for(const l of Object.keys(n))delete d.settingEdits[l];if(t===d.selected&&n.brightness!==void 0)for(const l of["standby_brightness","night_brightness"])delete d.settingEdits[l]}finally{ps=null,Object.keys(Ut).length?xs=window.setTimeout(()=>Yn(),150):pn=null,d.settingPending=!!Object.keys(Ut).length,t===d.selected&&Ss(),setTimeout(()=>{t===d.selected&&Ss()},da+100)}}function Ss(){const e=Jo();for(const[t,n]of Object.entries(d.settingEdits))Ut[t]!==void 0||ps||(e&&e.values[t]===n.value||Date.now()-n.at>da)&&delete d.settingEdits[t]}const $c={install:"Building and installing…",verify:"Waiting for the screen to come back…",settle:"Checking that it stays stable…"};async function Ui(e,t){d.updating.push(e.id);try{await it(`screens/${encodeURIComponent(e.id)}/update`,"POST",t?{host:t}:{}),await ln()}catch(n){d.updating=d.updating.filter(s=>s!==e.id),Se(n.message)}}async function Vi(){try{await it("updates/run","POST"),await ln()}catch(e){Se(e.message)}}async function xc(e){try{await it("updates","PUT",{auto:e}),d.inventory.updates&&(d.inventory.updates.auto=e),Se(e?"Screens will now update automatically at night.":"Automatic updates are off.")}catch(t){Se(t.message)}}async function Sc(){try{d.inventory.claude_skill=await it("claude-skill","POST"),Se(d.inventory.claude_skill?.restart?"Skill installed. Restart Claude Code once so it finds the new skills folder.":"Skill installed. Claude Code picks it up right away.")}catch(e){Se(e.message)}}async function ln(e=!0){try{const t=await Mt(e?"inventory":"inventory?light=1");d.inventory=e?t:{...d.inventory,...t},t.csrf&&ec(t.csrf),d.connected=!!d.inventory.connected,d.reachable=!0;for(const n of d.inventory.screens)n.update?.state==="running"&&(d.updating=d.updating.filter(s=>s!==n.id));d.selected&&Ss()}catch{d.reachable=!1}}function Cc(e){d.inventory={...d.inventory,...e},d.connected=!!d.inventory.connected;for(const t of d.inventory.screens)t.update?.state==="running"&&(d.updating=d.updating.filter(n=>n!==t.id));d.selected&&Ss()}let Wi=0,ko=Date.now(),Cs=!1,xn=null;function Tc(){xn||typeof EventSource>"u"||(xn=new EventSource("api/events"),xn.onopen=()=>{Cs=!0,zn()},xn.onmessage=e=>{document.hidden||Cc(JSON.parse(e.data))},xn.onerror=()=>{Cs=!1,zn()})}function zn(){clearTimeout(Wi);const e=Cs?6e4:d.inventory.updates?.busy?3e3:1e4;Wi=window.setTimeout(async()=>{if(!document.hidden){const t=Date.now()-ko>=3e5;t&&(ko=Date.now()),(t||!Cs)&&await ln(t)}zn()},e)}let Ki=!1;function Ac(){Ki||(Ki=!0,ln(),Tc(),zn(),Zu(()=>d.fontsVersion++),setInterval(()=>{!d.layout||document.hidden||d.drag.active||(d.now=Date.now(),Go(0))},3e4),document.addEventListener("visibilitychange",async()=>{document.hidden||(ko=Date.now(),d.now=Date.now(),await ln(),zn())}),window.addEventListener("pagehide",()=>Yn(!0)),window.addEventListener("beforeunload",e=>{d.dirty&&(e.preventDefault(),e.returnValue="")}))}const Ec={class:"side"},Pc={id:"screens"},Oc=["aria-current","onClick"],Ic={class:"txt"},Mc={key:0,class:"pill"},Dc={key:1,class:"spin small"},Lc=["onSubmit"],Fc=["disabled","title","onClick"],Hc={id:"pending"},Rc={class:"pending-actions"},Nc=["onClick"],jc=["aria-current"],Bc={class:"more"},Uc=["aria-current"],Vc={class:"mdi"},Wc=["aria-current"],Kc={class:"mdi"},qc=["aria-current"],Yc={class:"mdi"},zc=$e({__name:"Sidebar",setup(e){const t=fe(null),n=fe("");function s(u){const a=u.update||{};return a.state==="running"||d.updating.includes(u.id)?{kind:"running",text:$c[a.phase||""]||"Starting update…"}:a.state==="queued"?{kind:"queued",text:"Queued for the update"}:a.available&&u.online?{kind:"available",text:`Update ${a.target}`}:a.result&&Date.now()/1e3-a.result.time<86400?{kind:a.result.state==="failed"?"failed":"done",text:a.result.message}:null}function o(u){const a=u.update||{};a.host&&a.profile?Ui(u):a.profile&&(t.value=u.id,n.value="")}function l(u){const a=n.value.trim();a&&(t.value=null,Ui(u,a))}const r=u=>u.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.":u.downloaded?"Firmware downloaded, not yet in Home Assistant. Put it on the screen with ESPHome Web, then add the discovered ESPHome device under Settings → Devices & services, paste the API key there, and allow the Home Assistant actions under Configure.":`Not yet in Home Assistant. Already flashed? Add the ESPHome device under Settings → Devices & services, then allow the Home Assistant actions under Configure. Not flashed yet? Firmware & USB → ${u.file}.`;return(u,a)=>(h(),g("aside",Ec,[a[16]||(a[16]=i("div",{class:"brand"},[i("span",{class:"mark"},"▦"),i("span",null,"ESP Screens")],-1)),i("span",{id:"connection",class:re(["conn",{online:v(d).connected}]),role:"status"},y(v(d).reachable?v(d).connected?"Home Assistant connected":"Reconnecting to Home Assistant…":"Management page unreachable · retrying…"),3),a[17]||(a[17]=i("div",{class:"label"},"Screens",-1)),i("div",Pc,[(h(!0),g(W,null,le(v(d).inventory.screens,c=>(h(),g("div",{key:c.id,class:re(["screen-item",{selected:c.id===v(d).selected&&v(tt)===""}])},[i("button",{type:"button",class:"nav-item","aria-current":c.id===v(d).selected&&v(tt)===""?"true":"false",onClick:f=>v(fc)(c.id)},[i("span",{class:re(["dot",{off:!c.online}])},null,2),i("span",Ic,[i("span",null,y(c.name),1),i("small",null,y(c.online?"Online":"Offline")+y(c.area?" · "+c.area:"")+" · "+y(c.firmware||"unknown"),1)]),s(c)?.kind==="available"?(h(),g("span",Mc,"Update")):s(c)?.kind==="running"?(h(),g("span",Dc)):V("",!0)],8,Oc),s(c)?(h(),g("div",{key:0,class:re(["screen-update",s(c).kind])},[s(c).kind==="available"?(h(),g(W,{key:0},[t.value===c.id?(h(),g(W,{key:0},[a[9]||(a[9]=i("small",null,"The IP address, once; new firmware reports it itself.",-1)),i("form",{class:"screen-host",onSubmit:Ft(f=>l(c),["prevent"])},[Te(i("input",{"onUpdate:modelValue":a[0]||(a[0]=f=>n.value=f),placeholder:"IP address, e.g. 192.168.1.50",required:"",pattern:"[A-Za-z0-9][A-Za-z0-9.\\-]*","aria-label":"IP address",autofocus:""},null,512),[[Je,n.value]]),a[8]||(a[8]=i("button",{type:"submit",class:"btn mini primary"},"Start",-1)),i("button",{type:"button",class:"icon-btn","aria-label":"Cancel",onClick:a[1]||(a[1]=f=>t.value=null)},"✕")],40,Lc)],64)):(h(),g(W,{key:1},[i("small",null,y(s(c).text),1),i("button",{type:"button",class:"btn mini primary",disabled:!c.update?.profile,title:c.update?.profile?"":"No ESPHome profile found with this device name.",onClick:f=>o(c)},"Update",8,Fc)],64))],64)):(h(),g("small",{key:1,class:re({failed:s(c).kind==="failed"})},y(s(c).text),3))],2)):V("",!0)],2))),128))]),i("div",Hc,[(h(!0),g(W,null,le(v(d).inventory.pending||[],c=>(h(),g("div",{key:c.file,class:"pending"},[i("strong",null,y(c.friendly),1),i("small",null,y(r(c)),1),i("div",Rc,[i("button",{type:"button",class:"btn mini quiet",onClick:a[2]||(a[2]=(...f)=>v($s)&&v($s)(...f))},"Open Devices & services"),c.api_key?(h(),g("button",{key:0,type:"button",class:"btn mini quiet",onClick:f=>v(dt)(c.api_key)},"Copy API key",8,Nc)):V("",!0)])]))),128))]),i("button",{id:"new-screen",type:"button",class:"nav-item ghost","aria-current":v(tt)==="#new-screen"?"true":"false",onClick:a[3]||(a[3]=c=>v(De)("#new-screen"))},[...a[10]||(a[10]=[i("span",{class:"plus"},"+",-1),i("span",{class:"txt"},"New screen",-1)])],8,jc),a[18]||(a[18]=i("div",{class:"spacer"},null,-1)),i("div",Bc,[a[15]||(a[15]=i("div",{class:"label"},"More",-1)),i("button",{id:"open-firmware",type:"button",class:"nav-item","aria-current":v(tt)==="#firmware"?"true":"false",onClick:a[4]||(a[4]=c=>v(De)("#firmware"))},[i("span",Vc,y(v(ke)("F0241")),1),a[11]||(a[11]=i("span",{class:"txt"},"Firmware & USB",-1))],8,Uc),i("button",{id:"open-alerts",type:"button",class:"nav-item","aria-current":v(tt)==="#alerts"?"true":"false",onClick:a[5]||(a[5]=c=>v(De)("#alerts"))},[i("span",Kc,y(v(ke)("F0594")),1),a[12]||(a[12]=i("span",{class:"txt"},"Alerts",-1))],8,Wc),i("button",{id:"open-settings",type:"button",class:"nav-item","aria-current":v(tt)==="#settings"?"true":"false",onClick:a[6]||(a[6]=c=>v(De)("#settings"))},[i("span",Yc,y(v(ke)("F0493")),1),a[13]||(a[13]=i("span",{class:"txt"},"Settings",-1))],8,qc),i("button",{id:"refresh",type:"button",class:"nav-item ghost",onClick:a[7]||(a[7]=c=>v(ln)())},[...a[14]||(a[14]=[i("span",{class:"plus"},"↻",-1),i("span",{class:"txt"},"Refresh",-1)])])])]))}}),Gc={key:0,id:"toast",class:"toast",role:"status"},Jc=$e({__name:"Toast",setup(e){return(t,n)=>v(d).toast?(h(),g("div",Gc,[i("span",null,y(v(d).toast.message),1),v(d).toast.action?(h(),g("button",{key:0,type:"button",onClick:n[0]||(n[0]=s=>{v(d).toast.action.run(),v(uc)()})},y(v(d).toast.action.label),1)):V("",!0)])):V("",!0)}}),Z={source:null,element:null,ghost:null,timer:0,start:null,offset:{x:0,y:0},pointerId:null,suppressUntil:0,last:null,scroller:0,target:null},fa={mounted(e,t){e.__dragSource=t.value,e.addEventListener("pointerdown",s=>{const o=e.__dragSource;if(!(s.button!==0||e.disabled||s.target.closest(".remove"))){s.pointerType!=="touch"&&s.preventDefault(),Object.assign(Z,{source:o,element:e,start:{x:s.clientX,y:s.clientY},pointerId:s.pointerId});try{e.setPointerCapture(s.pointerId)}catch{}clearTimeout(Z.timer),s.pointerType==="touch"&&(Z.timer=window.setTimeout(()=>qi(s),260))}}),e.addEventListener("pointermove",s=>{if(d.drag.active||!Z.start||Z.element!==e)return;const o=Math.hypot(s.clientX-Z.start.x,s.clientY-Z.start.y);if(s.pointerType==="touch"){o>10&&(clearTimeout(Z.timer),Z.start=null);return}o>=6&&qi(s)});const n=()=>{Z.element===e&&!d.drag.active&&(clearTimeout(Z.timer),Z.start=null)};e.addEventListener("pointerup",n),e.addEventListener("pointercancel",n)},updated(e,t){e.__dragSource=t.value}};function qi(e){if(d.drag.active||!Z.start||!Z.source||!Z.element)return;d.drag.active=!0,d.drag.moving=Z.source.kind==="tile"?Z.source.tile:ia(Z.source.id),d.drag.preview=null,Z.target=null,getSelection()?.removeAllRanges();const t=Z.element.getBoundingClientRect(),n=Z.element.cloneNode(!0);n.classList.add("drag-ghost"),n.style.width=`${t.width}px`,n.style.height=`${t.height}px`,Z.offset={x:e.clientX-t.left,y:e.clientY-t.top},document.body.append(n),Z.ghost=n,document.addEventListener("pointermove",$o),document.addEventListener("pointerup",Ts),document.addEventListener("pointercancel",Ts);try{Z.element.releasePointerCapture(Z.pointerId)}catch{}try{document.documentElement.setPointerCapture(Z.pointerId)}catch{}document.addEventListener("touchmove",pa,{passive:!1}),Z.scroller=window.setInterval(()=>{if(!Z.last)return;const s=document.querySelector(".canvas");if(!s)return;const o=s.getBoundingClientRect(),l=Z.last.xo.right-60?12:0,r=Z.last.yo.bottom-60?12:0;(l||r)&&(s.scrollBy(l,r),xo(ha(Z.last.x,Z.last.y)))},16),xo(-1),$o(e)}function pa(e){d.drag.active&&e.preventDefault()}function Ts(e){e.pointerId===Z.pointerId&&Xc(e.type==="pointerup")}function $o(e){!Z.ghost||e.pointerId!==Z.pointerId||(Z.last={x:e.clientX,y:e.clientY},Z.ghost.style.transform=`translate(${e.clientX-Z.offset.x}px, ${e.clientY-Z.offset.y}px)`,xo(ha(e.clientX,e.clientY)))}function ha(e,t){let n=null,s=1/0;for(const l of document.querySelectorAll(".pages [data-slot]")){const r=l.getBoundingClientRect(),u=Math.hypot(Math.max(r.left-e,0,e-r.right),Math.max(r.top-t,0,t-r.bottom));u16)return-1;let o=Number(n.cell.dataset.slot);return n.cell.classList.contains("wide")&&e>(n.r.left+n.r.right)/2&&(o+=1),o}function xo(e){Z.target===e||!d.layout||!d.drag.moving||(Z.target=e,d.drag.preview=e>=0?oa(d.layout.tiles,d.drag.moving,e):null)}function Xc(e){const t=d.drag.preview,n=d.drag.moving;document.removeEventListener("pointermove",$o),document.removeEventListener("pointerup",Ts),document.removeEventListener("pointercancel",Ts),document.removeEventListener("touchmove",pa);try{document.documentElement.releasePointerCapture(Z.pointerId)}catch{}if(Z.ghost?.remove(),Object.assign(Z,{ghost:null,suppressUntil:Date.now()+400,last:null,start:null,target:null,element:null,source:null}),clearInterval(Z.scroller),d.drag.active=!1,d.drag.preview=null,d.drag.moving=null,e&&t&&n&&d.layout){const s=d.layout.tiles.map(o=>`${o.entity}@${o.slot}`).join();for(const{tile:o,slot:l}of t)o.slot=l,d.layout.tiles.includes(o)||d.layout.tiles.push(o);d.layout.tiles.sort((o,l)=>o.slot-l.slot),d.layout.tiles.map(o=>`${o.entity}@${o.slot}`).join()!==s&&et(),s.includes(`${n.entity}@`)||Bs(n,n.slot)}}window.addEventListener("click",e=>{Date.now()t.tile.name||Zn(t.tile.entity)),s=k(()=>Qe(t.tile)),o=k(()=>d.inventory.backgrounds?.[t.tile.options?.background||""]?.color),l=k(()=>t.tile.options?.background==="none"),r=k(()=>t.tile.options?.display||"standard"),u=k(()=>r.value!=="standard"?aa[r.value]||r.value:""),a=k(()=>la(t.tile,d.inventory)),c=k(()=>t.tile.entity.split(".")[0]),f=k(()=>d.inventory.icons?.controls||{}),b=w=>f.value[w]?ke(f.value[w]):"",A=k(()=>d.selectedTile===t.tile.entity&&d.inspector?.kind==="tile"),D=k(()=>!t.placeholder&&d.layout?.tiles.includes(t.tile)),I=k(()=>`${n.value}, slot ${t.slot%Et+1} on page ${ks(t.slot)+1}. Enter: configure, arrow keys: move`),R=k(()=>new Date(d.now)),T=k(()=>(R.value.getHours()%12+R.value.getMinutes()/60)*30),$=k(()=>R.value.getMinutes()*6);async function x(w){if(w.key==="Enter"||w.key===" "){w.preventDefault(),_o(t.tile.entity);return}const m={ArrowLeft:-1,ArrowRight:1,ArrowUp:-2,ArrowDown:2}[w.key];m&&(w.preventDefault(),Bs(t.tile,t.tile.slot+(s.value?Math.sign(m)*2:m))&&(await Lo(),document.querySelector(`.pages [data-slot="${t.tile.slot}"]`)?.focus()))}return(w,m)=>Te((h(),g("div",{class:re(["tile",{wide:s.value,bare:l.value,placeholder:e.placeholder||!D.value,chosen:A.value}]),"data-slot":e.slot,style:Ne(o.value&&!l.value?{backgroundColor:o.value}:void 0),tabindex:D.value?0:-1,role:D.value?"button":void 0,"aria-label":D.value?I.value:void 0,onClick:m[1]||(m[1]=H=>D.value&&v(_o)(e.tile.entity)),onKeydown:m[2]||(m[2]=H=>D.value&&x(H))},[r.value==="analog"?(h(),g(W,{key:0},[(h(),g("svg",Zc,[m[3]||(m[3]=i("circle",{cx:"30",cy:"30",r:"27",fill:"#fff",stroke:"#c9ccd1"},null,-1)),(h(),g(W,null,le([0,90,180,270],H=>i("line",{key:H,x1:"30",y1:"5",x2:"30",y2:"9",stroke:"#1b1b1b","stroke-width":"1.5",transform:`rotate(${H} 30 30)`},null,8,ed)),64)),i("line",{x1:"30",y1:"30",x2:"30",y2:"16",stroke:"#1b1b1b","stroke-width":"2.4","stroke-linecap":"round",transform:`rotate(${T.value} 30 30)`},null,8,td),i("line",{x1:"30",y1:"30",x2:"30",y2:"11",stroke:"#1b1b1b","stroke-width":"1.6","stroke-linecap":"round",transform:`rotate(${$.value} 30 30)`},null,8,nd),m[4]||(m[4]=i("circle",{cx:"30",cy:"30",r:"1.8",fill:"#1b1b1b"},null,-1))])),s.value?(h(),g("span",sd,[i("span",od,[i("span",id,y(n.value),1),i("span",ld,y(u.value),1)])])):V("",!0)],64)):s.value?(h(),g(W,{key:1},[i("span",ad,[i("span",rd,y(v(ke)(v(wo)(e.tile))),1),i("span",ud,[i("span",cd,y(n.value),1),u.value?(h(),g("span",dd,y(u.value),1)):V("",!0)])]),e.tile.options?.inline==="slider"?(h(),g("span",fd)):V("",!0),a.value?(h(),g("span",pd,[a.value==="toggle"?(h(),g("span",hd)):a.value==="setpoint"?(h(),g("span",vd,[i("span",md,y(b("minus")||"−"),1),m[5]||(m[5]=i("b",null,"20°",-1)),i("span",gd,y(b("plus")||"+"),1)])):a.value==="stepper"&&c.value.endsWith("select")?(h(),g(W,{key:2},[i("span",yd,y(b("chevron-left")),1),i("span",bd,y(b("chevron-right")),1)],64)):a.value==="stepper"?(h(),g("span",wd,[i("span",_d,y(b("minus")||"−"),1),m[6]||(m[6]=i("b",null,"50",-1)),i("span",kd,y(b("plus")||"+"),1)])):a.value==="mode"?(h(),g(W,{key:4},[i("span",$d,y(b("power")),1),i("span",xd,y(b("fire")),1),i("span",Sd,y(b("snowflake")),1)],64)):a.value==="volume"?(h(),g(W,{key:5},[m[7]||(m[7]=i("span",{class:"range"},null,-1)),i("span",Cd,y(b("volume-high")),1)],64)):a.value==="playback"?(h(),g(W,{key:6},[i("span",Td,y(b("skip-previous")),1),i("span",Ad,y(b("play")),1),i("span",Ed,y(b("skip-next")),1)],64)):a.value==="buttons"&&c.value==="cover"?(h(),g(W,{key:7},[i("span",Pd,y(b("arrow-expand-horizontal")),1),i("span",Od,y(b("stop")),1),i("span",Id,y(b("arrow-collapse-horizontal")),1)],64)):a.value==="buttons"&&c.value==="vacuum"?(h(),g(W,{key:8},[i("span",Md,y(b("play")),1),i("span",Dd,y(b("stop")),1),i("span",Ld,y(b("home-map-marker")),1)],64)):a.value==="buttons"&&c.value==="timer"?(h(),g(W,{key:9},[i("span",Fd,y(b("play")),1),i("span",Hd,y(b("close")),1)],64)):a.value==="run"?(h(),g("span",Rd,y({scene:"Activate",script:"Run"}[c.value]||"Press"),1)):(h(),g("span",Nd))])):V("",!0)],64)):(h(),g(W,{key:2},[i("span",jd,y(v(ke)(v(wo)(e.tile))),1),i("span",Bd,[i("span",Ud,y(n.value),1),u.value?(h(),g("span",Vd,y(u.value),1)):V("",!0),e.tile.options?.inline==="slider"?(h(),g("span",Wd)):V("",!0)])],64)),D.value?(h(),g("button",{key:3,type:"button",class:"remove",title:"Remove tile","aria-label":`Remove ${n.value}`,onClick:m[0]||(m[0]=Ft(H=>v(ca)(e.tile),["stop"]))},"✕",8,Kd)):V("",!0)],46,Qc)),[[v(fa),{kind:"tile",tile:e.tile}]])}}),Yd=["viewBox","aria-label"],zd=["y"],Gd=["cx","cy","r","stroke","stroke-width"],Jd=["x1","y1","x2","y2","stroke","stroke-width"],Xd=["x","y","fill"],Qd=["x","y"],va=$e({__name:"TopbarSvg",props:{items:{},nameText:{},single:{type:Boolean}},setup(e,{expose:t}){const n=e,s=k(()=>{d.fontsVersion,d.now,d.topbarPreviews;const T=n.items||Dt();return ea(T,ua.value,n.nameText??(d.layout?.title||"Home"),Rt)}),o=k(()=>s.value.metrics),l=k(()=>n.single?Math.round(o.value.text*1.4):o.value.top+Math.round(o.value.name*.45)),r=k(()=>n.single?Math.round(o.value.text*1.05):o.value.top),u=k(()=>{if(!n.single)return s.value.placed;const T=s.value.parts[0];return T&&(T.x=0),T?[T]:[]}),a=k(()=>n.single?`-2 0 ${Math.max(1,u.value[0]?.width||1)+4} ${l.value}`:`0 0 ${o.value.width} ${l.value}`),c=k(()=>r.value+(s.value.zero.top+s.value.zero.bottom)/2),f=k(()=>Qu(s.value.nameText,s.value.fonts.name,Math.min(s.value.natural,s.value.nameRoom))),b=k(()=>new Date(d.now));function A(T){const $=b.value;return[[($.getHours()%12+$.getMinutes()/60)*30,T*.24],[$.getMinutes()*6,T*.34]].map(([x,w])=>{const m=x*Math.PI/180;return{x2:w*Math.sin(m),y2:-w*Math.cos(m)}})}const D=T=>T.x-T.icon.ink.left,I=T=>(T.icon?T.x+(T.icon.ink.right-T.icon.ink.left)+s.value.gaps.icon:T.x)-T.text.ink.left,R=k(()=>`${(Math.max(1,u.value[0]?.width||1)+4)*(26/o.value.icon)}px`);return t({lay:s}),(T,$)=>(h(),g("svg",{viewBox:a.value,role:"img","aria-label":e.single?"How it looks on the screen":`Top bar: ${s.value.nameText}`,style:Ne(e.single?{width:R.value}:void 0)},[e.single?V("",!0):(h(),g("text",{key:0,x:"0",y:o.value.top,fill:"#1b1b1b",style:Ne({font:s.value.fonts.name})},y(f.value),13,zd)),i("g",null,[(h(!0),g(W,null,le(u.value,x=>(h(),g(W,{key:x.index},[x.dial?(h(),g(W,{key:0},[i("circle",{cx:x.x+x.dial/2,cy:c.value,r:x.dial/2-Math.max(1,Math.round(x.dial/10))/2,fill:"none",stroke:x.view.color||"#46525e","stroke-width":Math.max(1,Math.round(x.dial/10))},null,8,Gd),(h(!0),g(W,null,le(A(x.dial),(w,m)=>(h(),g("line",{key:m,x1:x.x+x.dial/2,y1:c.value,x2:x.x+x.dial/2+w.x2,y2:c.value+w.y2,stroke:x.view.color||"#46525e","stroke-width":Math.max(1,Math.round(x.dial*.075)),"stroke-linecap":"round"},null,8,Jd))),128))],64)):(h(),g(W,{key:1},[x.icon?(h(),g("text",{key:0,x:D(x),y:c.value-(x.icon.ink.top+x.icon.ink.bottom)/2,fill:x.view.color||"#46525e",style:Ne({font:s.value.fonts.icon})},y(x.icon.glyph),13,Xd)):V("",!0),x.text?(h(),g("text",{key:1,x:I(x),y:r.value,fill:"#46525e",style:Ne({font:s.value.fonts.text})},y(x.text.value),13,Qd)):V("",!0)],64))],64))),128))])],12,Yd))}}),Zd={class:"page"},ef={class:"page-label"},tf={key:1},nf={class:"tiles"},sf=["data-slot","aria-label","onClick"],of=$e({__name:"DevicePage",props:{page:{},entries:{},pages:{},moving:{}},setup(e){const t=e,n=k(()=>new Map(t.entries.map(c=>[c.slot,c]))),s=k(()=>new Set(t.entries.filter(c=>Qe(c.tile)).map(c=>c.slot+1))),o=k(()=>Array.from({length:Et},(c,f)=>t.page*Et+f).filter(c=>!s.value.has(c))),l=k(()=>t.pages>1&&!t.entries.some(c=>ks(c.slot)===t.page)),r=k(()=>d.inspector?.kind==="bar"||d.inspector?.kind==="bar-add"),u=k(()=>t.entries.filter(c=>ks(c.slot)===t.page).reduce((c,f)=>c+(Qe(f.tile)?2:1),0));function a(c){const f=d.insertAt===c;d.insertAt=f?-1:c,d.insertAt>=0&&document.querySelector("#search")?.focus()}return(c,f)=>(h(),g("div",Zd,[i("div",ef,[i("span",null,"Page "+y(e.page+1),1),l.value?(h(),g("button",{key:0,type:"button",class:"btn mini",title:"The pages after this one shift up one slot",onClick:f[0]||(f[0]=b=>v(vc)(e.page))},"Remove page")):(h(),g("span",tf,y(u.value)+" / "+y(v(Et)),1))]),i("div",{class:re(["device",{cyd:!v(Yo)}])},[i("div",{class:re(["bar-wrap",{selected:r.value}]),title:"Edit top bar",role:"button",tabindex:"0",onClick:f[1]||(f[1]=b=>v(Ct)(0)),onKeydown:f[2]||(f[2]=Ql(Ft(b=>v(Ct)(0),["prevent"]),["enter"]))},[_e(va)],34),i("div",nf,[(h(!0),g(W,null,le(o.value,b=>(h(),g(W,{key:b},[n.value.get(b)?(h(),Ve(qd,{key:0,tile:n.value.get(b).tile,slot:b,placeholder:n.value.get(b).tile===e.moving},null,8,["tile","slot","placeholder"])):(h(),g("button",{key:1,type:"button",class:re(["cell",{"insert-here":v(d).insertAt===b}]),"data-slot":b,title:"Empty slot. Click to add a tile here, or drag one over.","aria-label":`Empty slot ${b%v(Et)+1} on page ${e.page+1}: add the next tile here`,onClick:A=>a(b)},[f[3]||(f[3]=i("span",null,"+",-1)),i("small",null,y(v(d).insertAt===b?"Next tile goes here":"Empty"),1)],10,sf))],64))),128))])],2)]))}}),lf={class:"library",id:"library"},af={class:"lib-head"},rf={class:"lib-title"},uf={class:"filters",id:"filters"},cf=["aria-pressed","onClick"],df={class:"lib-list",id:"results","aria-live":"polite"},ff=["title","disabled","onClick"],pf={class:"tx"},hf={key:0,class:"hint"},vf={key:1,class:"hint"},mf={class:"lib-foot"},gf=$e({__name:"Library",setup(e){const t=[["","All"],["light","Lights"],["climate","Climate"],["switch","Switches"],["binary_sensor","Status"],["button","Actions"],["script","Scripts"],["fan","Fans"],["cover","Covers"],["scene","Scenes"],["vacuum","Vacuum"],["sensor","Sensors"],["media_player","Media"],["weather","Weather"],["number","Values"],["select","Selects"],["person","People"],["timer","Timers"],["screen","Clock"]],n={switch:"input_boolean",number:"input_number",select:"input_select",weather:"sun",button:"input_button"},s=k(()=>new Set(d.layout?.tiles.map(u=>u.entity)||[])),o=k(()=>{const u=d.search.toLocaleLowerCase(),a=d.filter;return[...d.inventory.builtin||[],...d.inventory.entities].filter(c=>c.tile!==!1&&(Yo.value||!["camera","image"].includes(c.id.split(".")[0]))&&(!a||c.id.startsWith(a+".")||n[a]===c.id.split(".")[0])&&`${c.name} ${c.id} ${c.device||""} ${c.area||""}`.toLocaleLowerCase().includes(u))}),l=k(()=>(d.layout?.tiles.length||0)>=Hn.value),r=k(()=>d.inventory.entities.length);return(u,a)=>(h(),g("aside",lf,[i("div",af,[i("div",rf,[a[1]||(a[1]=Q("Library ",-1)),i("small",null,y(r.value)+" entities",1)]),Te(i("input",{id:"search","onUpdate:modelValue":a[0]||(a[0]=c=>v(d).search=c),type:"search",placeholder:"Search by name, device, or room…",autocomplete:"off","aria-label":"Add an entity"},null,512),[[Je,v(d).search]]),i("div",uf,[(h(),g(W,null,le(t,([c,f])=>i("button",{key:c,type:"button","aria-pressed":v(d).filter===c?"true":"false",onClick:b=>v(d).filter=c},[c?(h(),g("span",{key:0,class:"domain-icon",style:Ne({color:v(xt)(c+".")[2],background:v(xt)(c+".")[3]}),"aria-hidden":"true"},y(v(xt)(c+".")[1]),5)):V("",!0),Q(y(f),1)],8,cf)),64))])]),i("div",df,[(h(!0),g(W,null,le(o.value.slice(0,80),c=>Te((h(),g("button",{key:c.id,type:"button",class:"ent",title:c.id,disabled:s.value.has(c.id)||l.value,onClick:f=>v(hc)(c.id)},[i("span",{class:"av mdi",style:Ne({color:v(xt)(c.id)[2],background:v(xt)(c.id)[3]})},y(v(d).inventory.icons?v(ke)(v(on)(c.id)):v(xt)(c.id)[1]),5),i("span",pf,[i("b",null,y(c.name),1),i("small",null,y([v(xt)(c.id)[0],c.area,c.device].filter(Boolean).join(" · ")),1)]),i("span",{class:re(["add",{done:s.value.has(c.id)}])},y(s.value.has(c.id)?"✓":"+"),3)],8,ff)),[[v(fa),{kind:"entity",id:c.id}]])),128)),o.value.length?o.value.length>80?(h(),g("p",vf,y(o.value.length)+" results. Keep typing to narrow it down.",1)):V("",!0):(h(),g("p",hf,"No entities found. Try a different name."))]),i("div",mf,y(l.value?`This screen holds ${v(Hn)} tiles${v(Hn)===10?"; update its firmware for 20":""}.`:"Drag onto a page, or tap + to add to the first free slot."),1)]))}}),yf={class:"canvas-head"},bf={id:"count"},wf={key:0,id:"no-tiles"},_f={key:1},kf={key:2,id:"positions-hint",class:"warn"},$f={class:"pages",id:"layout-preview","aria-label":"Screen layout"},xf={class:"page-label"},Sf=["tabindex"],Cf=$e({__name:"LayoutView",setup(e){const t=k(()=>d.layout),n=k(()=>d.drag.preview||wt(t.value)),s=k(()=>zt(n.value,t.value.pages)),o=k(()=>mc()),l=k(()=>s.valuetc(t.value.tiles)&&!fn(0,2,26)?`Empty slots and fixed positions work from firmware 0.2.26. This screen (firmware ${qt.value?.firmware||"unknown"}) shifts the tiles up to the first free slot until that update.`:"");function u(a){a.target.closest(".device, .page-label, .canvas-head")||d.inspector&&je()}return(a,c)=>(h(),g(W,null,[i("div",{class:"canvas",id:"canvas",onClick:u},[i("div",yf,[i("b",bf,y(s.value)+" "+y(s.value===1?"page":"pages")+" · "+y(t.value.tiles.length)+" / "+y(v(Hn))+" tiles",1),t.value.tiles.length?(h(),g("span",_f,"Tap the top bar or a tile to change it. Drag to move; drop on the next page for a new one.")):(h(),g("span",wf,"Add your first light, scene, or device from the library.")),r.value?(h(),g("span",kf,y(r.value),1)):V("",!0)]),i("div",$f,[(h(!0),g(W,null,le(o.value,f=>(h(),Ve(of,{key:f,page:f-1,entries:n.value,pages:s.value,moving:v(d).drag.moving},null,8,["page","entries","pages","moving"]))),128)),i("div",{class:re(["page ghost",{disabled:!l.value}])},[i("div",xf,[i("span",null,"Page "+y(o.value+1),1)]),i("div",{class:re(["device",{cyd:v(qt)?.board!=="guition"}]),id:"add-page",role:"button",tabindex:l.value?0:-1,onClick:c[0]||(c[0]=f=>l.value&&v(Hi)()),onKeydown:c[1]||(c[1]=Ql(Ft(f=>l.value&&v(Hi)(),["prevent"]),["enter"]))},y(l.value?"+ Add page":`${v(gn)} pages is the most`),43,Sf)],2)])]),_e(gf)],64))}}),Tf={class:"status",id:"settings-status",role:"status"},Af={key:0,class:"set-grid",id:"settings-groups"},Ef={class:"mdi"},Pf=["data-setting","title","onClick"],Of=["id"],If={class:"s-control"},Mf=["id","aria-checked","aria-labelledby","disabled","onClick"],Df=["aria-labelledby"],Lf=["aria-pressed","disabled","onClick"],Ff={key:2,class:"step"},Hf=["aria-label","disabled","onPointerdown","onClick"],Rf={class:"mdi"},Nf=["id","aria-labelledby"],jf=["aria-label","disabled","onPointerdown","onClick"],Bf={class:"mdi"},Uf=$e({__name:"SettingsTab",setup(e){const t=k(()=>Jo()),n=k(()=>yn()),s=k(()=>t.value?.owner==="screen"&&!qt.value?.online),o=k(()=>yc.map(I=>({...I,rows:I.rows.filter(R=>t.value?.keys.includes(R.key))})).filter(I=>I.rows.length)),l=I=>s.value||!!t.value?.unavailable.includes(I.key),r=I=>I.needs?!!n.value[I.needs]:!0,u=k(()=>s.value?"This screen is offline. Its settings can change once it's back.":d.settingPending?"Saving…":t.value?.owner==="screen"?"Changes apply on the screen at once, and show up here when they change there.":"Changes apply at once. Firmware 0.2.49 lets the screen keep them itself."),a=(I,R)=>{const T=n.value[I.key];return l(I)||!r(I)||T===null||T===void 0?!0:I.kind!=="moment"&&Bi(I,T,R,!1,n.value)===T},c=new WeakMap;function f(I,R,T){const $=yn(),x=Bi(I,$[I.key],R,T,$);x!==$[I.key]&&An(I.key,x,600)}function b(I,R,T){const $=I.currentTarget;if($.disabled||I.button!==0)return;const x={timer:0,held:!1};c.set($,x);let w=0;x.timer=window.setTimeout(function m(){x.held=!0,w+=1,f(R,T,w>5),x.timer=window.setTimeout(m,180)},450)}function A(I){const R=c.get(I.currentTarget);R&&clearTimeout(R.timer)}function D(I,R,T){const $=c.get(I.currentTarget);if($?.held){$.held=!1;return}f(R,T,!1)}return(I,R)=>(h(),g("div",{class:re(["settings",{offline:s.value}]),id:"general-settings"},[i("span",Tf,y(u.value),1),t.value?(h(),g("div",Af,[(h(!0),g(W,null,le(o.value,T=>(h(),g("section",{key:T.title,class:"set-card"},[i("h4",null,[i("span",Ef,y(v(ke)(T.icon)),1),Q(y(T.title),1)]),(h(!0),g(W,null,le(T.rows,$=>(h(),g("div",{key:$.key,class:re(["srow",[`setting-${$.kind}`,{inactive:!r($)||l($)}]]),"data-setting":$.key,title:l($)&&!s.value?"This entity is off in Home Assistant, or the screen is restarting.":"",onClick:x=>$.kind==="toggle"&&x.target.closest(".srow")===x.currentTarget&&!x.target.closest("button")&&!l($)&&v(An)($.key,!n.value[$.key],150)},[i("span",{class:"s-label",id:`setting-label-${$.key}`},y($.label),9,Of),i("div",If,[$.kind==="toggle"?(h(),g("button",{key:0,type:"button",class:re(["switch",{unknown:n.value[$.key]===null||n.value[$.key]===void 0}]),role:"switch",id:`setting-${$.key}`,"aria-checked":n.value[$.key]?"true":"false","aria-labelledby":`setting-label-${$.key}`,disabled:l($),onClick:Ft(x=>v(An)($.key,!n.value[$.key],150),["stop"])},null,10,Mf)):$.kind==="choice"?(h(),g("div",{key:1,class:"seg",role:"group","aria-labelledby":`setting-label-${$.key}`},[(h(!0),g(W,null,le($.options,([x,w])=>(h(),g("button",{key:String(x),type:"button","aria-pressed":n.value[$.key]===x?"true":"false",disabled:l($),onClick:m=>v(An)($.key,x,150)},y(w),9,Lf))),128))],8,Df)):(h(),g("div",Ff,[i("button",{type:"button","aria-label":`${$.label} lower`,disabled:a($,-1),onPointerdown:x=>b(x,$,-1),onPointerup:A,onPointercancel:A,onPointerleave:A,onClick:x=>D(x,$,-1)},[i("span",Rf,y(v(ke)("F0374")),1)],40,Hf),i("output",{id:`setting-${$.key}`,"aria-labelledby":`setting-label-${$.key}`},y(v(kc)($,n.value)),9,Nf),i("button",{type:"button","aria-label":`${$.label} higher`,disabled:a($,1),onPointerdown:x=>b(x,$,1),onPointerup:A,onPointercancel:A,onPointerleave:A,onClick:x=>D(x,$,1)},[i("span",Bf,y(v(ke)("F0415")),1)],40,jf)]))])],10,Pf))),128))]))),128))])):V("",!0)],2))}}),Vf={class:"seg wrap",role:"group"},Wf=["disabled","aria-pressed","onClick"],nt=$e({__name:"Segmented",props:{choices:{},value:{},disabled:{type:Boolean}},emits:["pick"],setup(e,{emit:t}){const n=t;return(s,o)=>(h(),g("div",Vf,[(h(!0),g(W,null,le(e.choices,([l,r])=>(h(),g("button",{key:String(l),type:"button",disabled:e.disabled,"aria-pressed":String(l)===String(e.value)?"true":"false",onClick:u=>n("pick",l)},y(r),9,Wf))),128))]))}}),Kf={class:"f"},qf={key:0},Yf={key:1,class:"warn"},zf=["aria-expanded"],Gf={class:"tx"},Jf={key:0,class:"mono"},Xf={class:"link"},Qf={key:0,class:"picker"},Zf={class:"action-list"},ep=["aria-pressed","onClick"],tp={key:0,class:"hint"},np={key:1,class:"warn"},sp={class:"f-label"},op=["aria-label","value","onChange"],ip=["value"],lp={key:3,class:"action-number"},ap=["aria-label","value","onInput"],rp=["onClick"],up={key:4,class:"action-number"},cp=["aria-label","min","max","step","value","placeholder","onInput"],dp={key:0},fp=["aria-label","value","placeholder","onInput"],pp={key:6},hp={key:0,class:"warn"},vp={key:3},mp=$e({__name:"ActionPicker",props:{tile:{}},setup(e){const t=e,n=k(()=>d.entityActions[t.tile.entity]),s=k(()=>t.tile.options?.action),o=k(()=>n.value?.find(m=>m.action===s.value?.action)),l=k(()=>d.actionPickerOpen||!s.value),r=k(()=>{const m=d.actionSearch.trim().toLocaleLowerCase();return(n.value||[]).filter(H=>`${H.name} ${H.action} ${H.description}`.toLocaleLowerCase().includes(m))});n.value===void 0&&dc(t.tile.entity);function u(m){const H=m.action===s.value?.action;t.tile.options={...t.tile.options,action:H?s.value:{action:m.action}},d.actionPickerOpen=!1,et()}function a(m,H){const C={...t.tile.options?.action?.data||{}};H===void 0?delete C[m]:C[m]=H,t.tile.options={...t.tile.options,action:{action:s.value.action,...Object.keys(C).length?{data:C}:{}}},et()}const c=k(()=>{if(!o.value)return[];const m=t.tile.options?.action?.data||{};return o.value.fields.filter(H=>H.required&&m[H.key]===void 0).map(H=>H.name)}),f=m=>Object.keys(m.selector||{})[0]||"text",b=m=>(m.selector||{})[f(m)]||{},A=m=>m.example===void 0?"":typeof m.example=="string"?m.example:JSON.stringify(m.example);function D(m){const H=b(m),C=f(m);return Array.isArray(m.options)?m.options.map(L=>[L,L]):C==="select"&&Array.isArray(H.options)&&!H.multiple?H.options.map(L=>L&&typeof L=="object"?[String(L.value),String(L.label??L.value)]:[String(L),String(L)]):null}const I=m=>"#"+m.map(H=>Math.max(0,Math.min(255,Number(H)||0)).toString(16).padStart(2,"0")).join(""),R=m=>Array.isArray(m)&&m.length===3?I(m):"#ffffff",T=m=>m===void 0?"":typeof m=="string"?m:JSON.stringify(m);function $(m,H){const C=H.trim();if(!C)return a(m.key,void 0);if(f(m)==="text")return a(m.key,H);try{a(m.key,JSON.parse(C))}catch{a(m.key,H)}}function x(m,H){a(m.key,H===""||!Number.isFinite(Number(H))?void 0:Number(H))}const w=m=>b(m).unit_of_measurement||f(m)==="color_temp"&&b(m).unit||"";return(m,H)=>(h(),g("div",Kf,[H[3]||(H[3]=i("span",{class:"f-label"},"Action",-1)),n.value===void 0?(h(),g("small",qf,"Asking Home Assistant which actions this entity has…")):n.value===null?(h(),g("small",Yf,"Home Assistant isn't answering right now. Try again in a moment.")):(h(),g(W,{key:2},[i("button",{type:"button",class:"row","aria-expanded":l.value?"true":"false",onClick:H[0]||(H[0]=C=>v(d).actionPickerOpen=!l.value)},[i("span",Gf,[i("b",null,y(o.value?o.value.name:s.value?s.value.action:"Choose an action"),1),s.value?(h(),g("small",Jf,y(s.value.action),1)):V("",!0)]),i("span",Xf,y(l.value?"Close":"Change"),1)],8,zf),l.value?(h(),g("div",Qf,[Te(i("input",{"onUpdate:modelValue":H[1]||(H[1]=C=>v(d).actionSearch=C),type:"search",placeholder:"Search, for example toggle, position, or play","aria-label":"Search for an action"},null,512),[[Je,v(d).actionSearch]]),i("div",Zf,[(h(!0),g(W,null,le(r.value,C=>(h(),g("button",{key:C.action,type:"button",class:"action-choice","aria-pressed":C.action===s.value?.action?"true":"false",onClick:L=>u(C)},[i("strong",null,y(C.name),1),i("small",null,y(C.description?`${C.action} · ${C.description}`:C.action),1)],8,ep))),128)),r.value.length?V("",!0):(h(),g("p",tp,"No action found."))])])):V("",!0),s.value&&!o.value?(h(),g("small",np,"Home Assistant doesn't offer this action for this entity any more, so a tap does nothing. Choose another one.")):V("",!0),o.value&&!l.value?(h(),g(W,{key:2},[(h(!0),g(W,null,le(o.value.fields,C=>(h(),g("div",{key:C.key,class:"f"},[i("span",sp,y(C.required?C.name:`${C.name} (optional)`),1),f(C)==="boolean"?(h(),Ve(nt,{key:0,choices:[["","Not set"],["true","On"],["false","Off"]],value:s.value?.data?.[C.key]===void 0?"":String(s.value?.data?.[C.key]),onPick:L=>a(C.key,L===""?void 0:L==="true")},null,8,["value","onPick"])):D(C)&&D(C).length<=4?(h(),Ve(nt,{key:1,choices:[["","Not set"],...D(C)],value:s.value?.data?.[C.key]===void 0?"":String(s.value?.data?.[C.key]),onPick:L=>a(C.key,L===""?void 0:L)},null,8,["choices","value","onPick"])):D(C)?(h(),g("select",{key:2,"aria-label":C.name,value:String(s.value?.data?.[C.key]??""),onChange:L=>a(C.key,L.target.value===""?void 0:L.target.value)},[H[2]||(H[2]=i("option",{value:""},"Not set",-1)),(h(!0),g(W,null,le(D(C),([L,pe])=>(h(),g("option",{key:L,value:L},y(pe),9,ip))),128))],40,op)):f(C)==="color_rgb"?(h(),g("div",lp,[i("input",{type:"color","aria-label":C.name,value:R(s.value?.data?.[C.key]),onInput:L=>a(C.key,[1,3,5].map(pe=>parseInt(L.target.value.slice(pe,pe+2),16)))},null,40,ap),i("button",{type:"button",class:"btn quiet mini",onClick:L=>a(C.key,void 0)},"Not set",8,rp)])):f(C)==="number"||f(C)==="color_temp"?(h(),g("div",up,[i("input",{type:"number","aria-label":C.name,min:b(C).min,max:b(C).max,step:b(C).step==="any"?"any":b(C).step,value:s.value?.data?.[C.key]??"",placeholder:A(C),onInput:L=>x(C,L.target.value)},null,40,cp),w(C)?(h(),g("span",dp,y(w(C)),1)):V("",!0)])):(h(),g("input",{key:5,type:"text","aria-label":C.name,value:T(s.value?.data?.[C.key]),placeholder:A(C),onInput:L=>$(C,L.target.value)},null,40,fp)),C.description?(h(),g("small",pp,y(C.description),1)):V("",!0)]))),128)),c.value.length?(h(),g("small",hp,"Home Assistant needs "+y(c.value.join(", "))+".",1)):V("",!0)],64)):V("",!0),v(fn)(0,2,58)?V("",!0):(h(),g("small",vp,"The screen performs an action from firmware 0.2.58: press Update on the screen. Until then a tap works as Automatic."))],64))]))}}),gp={class:"f"},yp=["aria-expanded"],bp={class:"av mdi"},wp={class:"tx"},_p={class:"link"},kp={key:0,class:"picker"},$p=["aria-pressed","title"],xp={class:"mdi"},Sp=["aria-pressed"],Cp={class:"icon-list"},Tp={class:"icon-grid"},Ap=["title","aria-label","aria-pressed","onClick"],Ep={class:"mdi"},Pp={key:0,class:"hint"},Op={key:1},ma=$e({__name:"IconPicker",props:{selected:{},automatic:{},autoLabel:{},allowNone:{type:Boolean},note:{}},emits:["pick"],setup(e,{emit:t}){const n=e,s=t,o=fe(""),l=k(()=>es(n.selected)),r=k(()=>n.selected==="none"?"":ke(l.value?.cp||n.automatic)),u=k(()=>n.selected==="none"?"No icon":l.value?.label||n.autoLabel),a=k(()=>{const c=o.value.trim().toLocaleLowerCase();return(d.inventory.icons?.groups||[]).map(f=>({label:f.label,icons:f.icons.filter(b=>!c||`${b.label} ${b.name.replaceAll("-"," ")} ${f.label}`.toLocaleLowerCase().includes(c))})).filter(f=>f.icons.length)});return(c,f)=>(h(),g("div",gp,[f[5]||(f[5]=i("span",{class:"f-label"},"Icon",-1)),i("button",{type:"button",class:"row","aria-expanded":v(d).iconPickerOpen?"true":"false",onClick:f[0]||(f[0]=b=>v(d).iconPickerOpen=!v(d).iconPickerOpen)},[i("span",bp,y(r.value),1),i("span",wp,[i("b",null,y(u.value),1)]),i("span",_p,y(v(d).iconPickerOpen?"Close":"Change"),1)],8,yp),v(d).iconPickerOpen?(h(),g("div",kp,[Te(i("input",{"onUpdate:modelValue":f[1]||(f[1]=b=>o.value=b),type:"search",placeholder:"Search, for example lamp, music, or door","aria-label":"Search for an icon"},null,512),[[Je,o.value]]),i("button",{type:"button",class:"icon-choice icon-auto","aria-pressed":e.selected==="auto"?"true":"false",title:e.autoLabel,onClick:f[2]||(f[2]=b=>s("pick","auto"))},[i("span",xp,y(v(ke)(e.automatic)),1),i("span",null,y(e.autoLabel),1)],8,$p),e.allowNone?(h(),g("button",{key:0,type:"button",class:"icon-choice icon-auto","aria-pressed":e.selected==="none"?"true":"false",title:"No icon",onClick:f[3]||(f[3]=b=>s("pick","none"))},[...f[4]||(f[4]=[i("span",{class:"mdi"},null,-1),i("span",null,"No icon, text only",-1)])],8,Sp)):V("",!0),i("div",Cp,[(h(!0),g(W,null,le(a.value,b=>(h(),g("section",{key:b.label},[i("small",null,y(b.label),1),i("div",Tp,[(h(!0),g(W,null,le(b.icons,A=>(h(),g("button",{key:A.name,type:"button",class:"icon-choice",title:A.label,"aria-label":`Icon: ${A.label}`,"aria-pressed":e.selected===A.name?"true":"false",onClick:D=>s("pick",A.name)},[i("span",Ep,y(v(ke)(A.cp)),1)],8,Ap))),128))])]))),128)),a.value.length?V("",!0):(h(),g("p",Pp,"No icon found."))])])):V("",!0),e.note?(h(),g("small",Op,y(e.note),1)):V("",!0)]))}}),Ip={class:"dr-head"},Mp={class:"tx"},Dp={class:"mono"},Lp={class:"dr-body"},Fp={class:"f"},Hp=["value","placeholder"],Rp={class:"f"},Np={key:0,class:"warn"},jp={class:"f"},Bp={key:1,class:"f"},Up={key:2,class:"f"},Vp={key:4,class:"f"},Wp={key:0,class:"warn"},Kp={key:5,class:"f"},qp={class:"f"},Yp={class:"sw"},zp=["aria-label","aria-pressed","onClick"],Gp={class:"dr-foot"},Jp=$e({__name:"TileInspector",props:{tile:{}},setup(e){const t=e,n=k(()=>t.tile.entity.split(".")[0]),s=k(()=>Zn(t.tile.entity)),o=k(()=>d.capabilities[t.tile.entity]),l=(J,q)=>t.tile.options?.[J]??q,r=k(()=>l("display",n.value==="screen"?"digital":"standard")),u=k(()=>{const J=n.value==="screen"?[["digital","Digital clock"],["analog","Analog clock"]]:[["standard","Name and status"],["watch","Large value"]],q=o.value;return n.value==="weather"&&(!q||q.displays.includes("forecast")||r.value==="forecast")&&J.push(["forecast","Weather forecast"]),n.value==="sensor"&&(!q||q.displays.includes("graph")||r.value==="graph")&&J.push(["graph","Graph"]),n.value==="sun"&&J.push(["sunpath","Sun path"]),J}),a=k(()=>{const J=o.value;return J&&r.value==="graph"&&!J.displays.includes("graph")?"Home Assistant has no numbers for this entity, so the graph stays empty. Choose another display.":J&&r.value==="forecast"&&!J.displays.includes("forecast")?"This weather service has no daily forecast in Home Assistant. Choose another display.":""}),c=k(()=>l("size","single")),f=k(()=>d.inventory.controls?.[n.value]),b=k(()=>l("controls",f.value?.default)),A=k(()=>{const J=o.value;return(f.value?.choices||[]).filter(q=>!J||q.key==="none"||q.key===b.value||J.controls.includes(q.key)).map(q=>[q.key,q.label])}),D=k(()=>{const J=o.value;return J&&b.value!=="none"&&!J.controls.includes(b.value)?{text:"Home Assistant doesn't offer this control for this entity, so it stays empty on the screen. Choose another one.",warn:!0}:{text:fn(0,2,19)?"On the right of the double-width tile, like the rows in Home Assistant. Tapping the name works as set below.":"The screen shows direct control from firmware 0.2.19; until then the tile stays as it was.",warn:!1}}),I=k(()=>l("tap","auto")),R=k(()=>{const J=[["auto","Automatic"],["detail","Open control"],["none","View only"]];return((o.value?o.value.toggle:Li.includes(n.value))||I.value==="toggle")&&J.push(["toggle","On / off"]),J.push(["action","Perform action"]),J}),T=k(()=>I.value==="toggle"&&o.value&&!o.value.toggle?{text:"Home Assistant can't turn this on and off, so a tap does nothing. Choose another option.",warn:!0}:I.value==="toggle"&&!Li.includes(n.value)&&!fn(0,2,58)?{text:"The screen switches this from firmware 0.2.58: press Update on the screen. Until then a tap opens its card.",warn:!1}:I.value==="toggle"?{text:"Hold the tile to open its card.",warn:!1}:null),$=k(()=>l("inline","none")),x=k(()=>ac.includes(n.value)&&(!o.value||o.value.inline||$.value==="slider")),w=k(()=>$.value==="slider"&&o.value&&!o.value.inline),m=k(()=>l("history_hours",24)),H=k(()=>Object.entries(d.inventory.backgrounds||{})),C=k(()=>!!d.inventory.entities.find(J=>J.id===t.tile.entity)?.icon),L=k(()=>!!d.inventory.icons&&n.value!=="screen"&&!["forecast","sunpath"].includes(r.value));function pe(J){t.tile.name=J,et()}function Ae(){d.inspector={kind:"inspect",entity:t.tile.entity}}return(J,q)=>(h(),g(W,null,[i("div",Ip,[i("span",{class:"av mdi",style:Ne({color:v(xt)(e.tile.entity)[2],background:v(xt)(e.tile.entity)[3]})},y(v(ke)(v(wo)(e.tile))),5),i("span",Mp,[i("b",null,y(e.tile.name||s.value),1),i("small",Dp,y(e.tile.entity),1)]),i("button",{type:"button",class:"icon-btn","aria-label":"Close",onClick:q[0]||(q[0]=(...ae)=>v(je)&&v(je)(...ae))},"✕")]),i("div",Lp,[i("div",Fp,[q[10]||(q[10]=i("label",{class:"f-label",for:"tile-name"},"Name on the screen",-1)),i("input",{id:"tile-name",value:e.tile.name,placeholder:s.value,maxlength:"60",onInput:q[1]||(q[1]=ae=>pe(ae.target.value))},null,40,Hp)]),L.value?(h(),Ve(ma,{key:0,selected:e.tile.options?.icon||"auto",automatic:v(on)(e.tile.entity),"auto-label":`Automatic (${C.value?"from Home Assistant":"default"})`,note:v(fn)(0,2,18)?"":"The screen shows a chosen icon from firmware 0.2.18.",onPick:q[2]||(q[2]=ae=>v(Ht)(e.tile,"icon",ae))},null,8,["selected","automatic","auto-label","note"])):V("",!0),i("div",Rp,[q[11]||(q[11]=i("span",{class:"f-label"},"Display",-1)),_e(nt,{choices:u.value,value:r.value,onPick:q[3]||(q[3]=ae=>v(Ht)(e.tile,"display",ae))},null,8,["choices","value"]),a.value?(h(),g("small",Np,y(a.value),1)):V("",!0)]),i("div",jp,[q[12]||(q[12]=i("span",{class:"f-label"},"Width",-1)),_e(nt,{choices:[["single","Normal"],["wide","Double-width"]],value:c.value,onPick:q[4]||(q[4]=ae=>v(Ht)(e.tile,"size",ae))},null,8,["value"])]),f.value&&c.value==="wide"?(h(),g("div",Bp,[q[13]||(q[13]=i("span",{class:"f-label"},"Direct control on the tile",-1)),_e(nt,{choices:A.value,value:b.value,onPick:q[5]||(q[5]=ae=>v(Ht)(e.tile,"controls",ae))},null,8,["choices","value"]),i("small",{class:re({warn:D.value.warn})},y(D.value.text),3)])):V("",!0),n.value!=="screen"?(h(),g("div",Up,[q[14]||(q[14]=i("span",{class:"f-label"},"On tap",-1)),_e(nt,{choices:R.value,value:I.value,onPick:q[6]||(q[6]=ae=>v(Ht)(e.tile,"tap",ae))},null,8,["choices","value"]),T.value?(h(),g("small",{key:0,class:re({warn:T.value.warn})},y(T.value.text),3)):V("",!0)])):V("",!0),n.value!=="screen"&&I.value==="action"?(h(),Ve(mp,{key:3,tile:e.tile},null,8,["tile"])):V("",!0),x.value?(h(),g("div",Vp,[q[15]||(q[15]=i("span",{class:"f-label"},"Small slider on the tile",-1)),_e(nt,{choices:[["none","No"],["slider","Yes, control directly"]],value:$.value,onPick:q[7]||(q[7]=ae=>v(Ht)(e.tile,"inline",ae))},null,8,["value"]),w.value?(h(),g("small",Wp,"Home Assistant has nothing a slider can change for this entity. Choose No.")):V("",!0)])):V("",!0),n.value==="sensor"?(h(),g("div",Kp,[q[16]||(q[16]=i("span",{class:"f-label"},"History",-1)),_e(nt,{choices:[[1,"1 hour"],[6,"6 hours"],[24,"24 hours"]],value:m.value,onPick:q[8]||(q[8]=ae=>v(Ht)(e.tile,"history_hours",Number(ae)))},null,8,["value"])])):V("",!0),i("div",qp,[q[17]||(q[17]=i("span",{class:"f-label"},"Pastel background",-1)),i("div",Yp,[(h(!0),g(W,null,le(H.value,([ae,z])=>(h(),g("button",{key:ae,type:"button","aria-label":`Background: ${z.label}`,"aria-pressed":(e.tile.options?.background||"auto")===ae?"true":"false",onClick:B=>v(Ht)(e.tile,"background",ae)},[i("i",{class:re(z.color?"":ae==="none"?"none":"auto"),style:Ne(z.color?{background:z.color}:void 0)},null,6),Q(y(z.label),1)],8,zp))),128))])])]),i("div",Gp,[i("button",{type:"button",class:"btn danger",onClick:q[9]||(q[9]=ae=>v(ca)(e.tile))},"Remove"),q[18]||(q[18]=i("span",{class:"spacer"},null,-1)),n.value!=="screen"?(h(),g("button",{key:0,type:"button",class:"btn quiet",onClick:Ae},"Read current data")):V("",!0)])],64))}}),Xp={class:"dr-head"},Qp={class:"av mdi",style:{background:"var(--seg)",color:"var(--ink-2)"}},Zp={class:"tx"},eh={class:"dr-body"},th={class:"f"},nh=["value"],sh={class:"f"},oh={class:"f-label",id:"topbar-caption"},ih={style:{"text-transform":"none","letter-spacing":"0","font-weight":"500"}},lh={class:"items",id:"topbar-chips",role:"list","aria-labelledby":"topbar-caption"},ah=["data-index","aria-label","onPointerdown","onClick","onKeydown"],rh={class:"tx"},uh=["aria-label","onClick"],ch=["disabled","title"],dh={class:"live",id:"topbar-live"},fh={class:"f"},ph={class:"f"},hh={key:1,class:"f"},vh={key:2},mh={key:0,class:"dr-foot"},gh=["disabled"],yh=["disabled"],bh=$e({__name:"TopbarInspector",props:{index:{}},setup(e){const t=e,n=k(()=>Dt()),s=k(()=>n.value[t.index]),o=k(()=>(d.fontsVersion,d.now,d.topbarPreviews,ea(n.value,ua.value,d.layout?.title||"Home",Rt))),l=k(()=>o.value.dropped),r=k(()=>d.inventory.header?.min_firmware||"0.2.32"),u=k(()=>{const[z,B,Y]=r.value.split(".").map(Number);return fn(z,B,Y)}),a=k(()=>u.value?l.value.size?"Not everything fits next to the name: the screen drops the dashed items. Remove one or choose a shorter name.":"Drag to reorder; tap an item to configure it.":`Sensors, the date, and the analog clock appear from firmware ${r.value}; until that update, this screen shows the name and, if the time is in the bar, the clock.`),c=(z,B)=>{const Y=Rt(z);return Y.shown?l.value.has(B)?"Doesn't fit next to the name":Y.analog?"Dial":Y.text:"Hidden: not active right now"},f=z=>{const B=Rt(z);return B.analog||z.type!=="entity"?es(Zl[z.type])?.cp:B.icon},b=z=>d.topbarAdded?.key===He(z)&&Date.now()-d.topbarAdded.time<1200;function A(z){d.layout&&(d.layout.title=z,et())}function D(z){const B=[...n.value];B[t.index]={...B[t.index],...z},qn(B)}const I=k(()=>s.value?Rt(s.value).shown?l.value.has(t.index)?"Doesn't currently fit next to the name":"How it looks on the screen":"Hidden right now: not active":""),R=k(()=>yn().clock_24h!==!1),T=k(()=>({clock:Bo(R.value,new Date(d.now)),analog:"Small dial with the time",date:Uo(new Date(d.now))})),$=fe({index:-1,active:!1,moved:!1});let x=null,w=0,m=null,H=0;function C(z,B){z.button!==0||z.target.closest(".x")||(z.pointerType!=="touch"&&z.preventDefault(),$.value={index:B,active:!1,moved:!1},x={x:z.clientX,y:z.clientY},m=z.pointerId,clearTimeout(w),z.pointerType==="touch"&&(w=window.setTimeout(L,260)),document.addEventListener("pointermove",Ae),document.addEventListener("pointerup",J),document.addEventListener("pointercancel",J))}function L(){$.value.active=!0,document.addEventListener("touchmove",pe,{passive:!1})}function pe(z){$.value.active&&z.preventDefault()}function Ae(z){if(z.pointerId!==m||!x)return;if(!$.value.active){const oe=Math.hypot(z.clientX-x.x,z.clientY-x.y);if(z.pointerType==="touch"){oe>10&&(clearTimeout(w),x=null);return}if(oe<6)return;L()}const B=[...document.querySelectorAll(".items .item[data-index]")];let Y=$.value.index;if(B.forEach((oe,ie)=>{const ue=oe.getBoundingClientRect();ie<$.value.index&&z.clientY$.value.index&&z.clientY>ue.top+ue.height/2&&(Y=Math.max(Y,ie))}),Y!==$.value.index&&d.layout){const oe=[...n.value];oe.splice(Y,0,...oe.splice($.value.index,1)),d.layout.header={items:oe};const ie=t.index===$.value.index;$.value.index=Y,$.value.moved=!0,ie&&Ct(Y)}}function J(z){z.pointerId===m&&(clearTimeout(w),document.removeEventListener("pointermove",Ae),document.removeEventListener("pointerup",J),document.removeEventListener("pointercancel",J),document.removeEventListener("touchmove",pe),$.value.active&&(H=Date.now()+400,$.value.moved&&et()),$.value={index:-1,active:!1,moved:!1},x=null,m=null)}function q(z){Date.now()(h(),g(W,null,[i("div",Xp,[i("span",Qp,y(s.value?v(ke)(f(s.value)||"F0150"):v(ke)("F0150")),1),i("span",Zp,[i("b",null,y(s.value?s.value.type==="entity"?v(Zn)(s.value.entity):v(Tn)(s.value):"Top bar"),1),i("small",{class:re({mono:s.value?.type==="entity"})},y(s.value?s.value.type==="entity"?s.value.entity:"From the screen itself, also works without Home Assistant":`${n.value.length} of ${v(Bt)()} items`),3)]),i("button",{type:"button",class:"icon-btn","aria-label":"Close",onClick:B[0]||(B[0]=(...Y)=>v(je)&&v(je)(...Y))},"✕")]),i("div",eh,[i("div",th,[B[10]||(B[10]=i("label",{class:"f-label",for:"title"},"Left: name",-1)),i("input",{id:"title",value:v(d).layout?.title,maxlength:"60",placeholder:"For example: Living room",onInput:B[1]||(B[1]=Y=>A(Y.target.value))},null,40,nh)]),i("div",sh,[i("span",oh,[B[11]||(B[11]=Q("Right: time, sensors, or a clock ",-1)),i("span",ih," · "+y(n.value.length)+" / "+y(v(Bt)()),1)]),i("div",lh,[(h(!0),g(W,null,le(n.value,(Y,oe)=>(h(),g("div",{key:v(He)(Y)+oe,class:re(["item",{selected:oe===e.index,"is-hidden":!v(Rt)(Y).shown,"is-overflow":l.value.has(oe),"just-added":b(Y),"dragging-chip":$.value.active&&$.value.index===oe}]),role:"listitem",tabindex:"0","data-index":oe,"aria-label":`${v(Tn)(Y)}, slot ${oe+1}. Enter: configure, arrows: move`,onPointerdown:ie=>C(ie,oe),onClick:ie=>q(oe),onKeydown:ie=>ae(ie,oe)},[B[12]||(B[12]=i("span",{class:"grip","aria-hidden":"true"},"⋮⋮",-1)),i("span",{class:"av mdi",style:Ne(v(Rt)(Y).color?{color:v(Rt)(Y).color}:void 0)},y(f(Y)?v(ke)(f(Y)):""),5),i("span",rh,[i("b",null,y(v(Tn)(Y)),1),i("small",null,y(c(Y,oe)),1)]),i("button",{type:"button",class:"x","aria-label":`Remove ${v(Tn)(Y)} from the top bar`,onClick:Ft(ie=>v(ji)(oe),["stop"])},"✕",8,uh)],42,ah))),128)),i("button",{type:"button",class:"ghost-btn",id:"topbar-add",disabled:n.value.length>=v(Bt)(),title:n.value.length>=v(Bt)()?`Maximum ${v(Bt)()} items`:"Add the time, date, analog clock, or an entity",onClick:B[2]||(B[2]=(...Y)=>v(Ri)&&v(Ri)(...Y))},"+ Add time, date, or an entity",8,ch)]),i("small",{id:"topbar-hint",class:re({warn:l.value.size>0&&u.value})},y(a.value),3)]),s.value?(h(),g(W,{key:0},[i("div",dh,[i("small",null,y(I.value),1),_e(va,{items:[s.value],single:""},null,8,["items"])]),s.value.type==="entity"?(h(),g(W,{key:0},[i("div",fh,[B[13]||(B[13]=i("span",{class:"f-label"},"What to show",-1)),_e(nt,{choices:(v(d).inventory.header?.contents||[]).map(Y=>[Y.key,Y.label]),value:s.value.content,onPick:B[3]||(B[3]=Y=>D({content:Y}))},null,8,["choices","value"]),B[14]||(B[14]=i("small",null,"Last changed keeps counting on the screen itself: “Just now”, “5 min ago”, “Yesterday”.",-1))]),_e(ma,{selected:s.value.icon||"auto",automatic:v(d).topbarPreviews[v(He)(s.value)]?.auto_icon||v(on)(s.value.entity),"auto-label":"Automatic (like Home Assistant)","allow-none":"",onPick:B[4]||(B[4]=Y=>D({icon:Y}))},null,8,["selected","automatic"]),i("div",ph,[B[15]||(B[15]=i("span",{class:"f-label"},"Show",-1)),_e(nt,{choices:(v(d).inventory.header?.shows||[]).map(Y=>[Y.key,Y.label]),value:s.value.show,onPick:B[5]||(B[5]=Y=>D({show:Y}))},null,8,["choices","value"]),B[16]||(B[16]=i("small",null,"Only when active hides the item as long as it's off, closed, away, or 0. Handy for an open door, a running washing machine, or who's home.",-1))])],64)):s.value.type!=="date"?(h(),g("div",hh,[B[17]||(B[17]=i("span",{class:"f-label"},"Format",-1)),_e(nt,{choices:[["24","24 hour"],["12","12 hour"]],value:R.value?"24":"12",onPick:B[6]||(B[6]=Y=>v(An)("clock_24h",Y==="24",150))},null,8,["value"]),B[18]||(B[18]=i("small",null,"The screen's own clock setting: it applies at once to every clock on this screen, including the clock tiles.",-1))])):(h(),g("small",vh,y(T.value.date)+" today. The date follows the screen's own clock.",1))],64)):V("",!0)]),s.value?(h(),g("div",mh,[i("button",{type:"button",class:"btn danger",onClick:B[7]||(B[7]=Y=>v(ji)(e.index))},"Remove"),B[19]||(B[19]=i("span",{class:"spacer"},null,-1)),i("button",{type:"button",class:"btn quiet",disabled:e.index===0,onClick:B[8]||(B[8]=Y=>v(so)(e.index,e.index-1)&&v(Ct)(e.index-1))},"↑ Up",8,gh),i("button",{type:"button",class:"btn quiet",disabled:e.index>=n.value.length-1,onClick:B[9]||(B[9]=Y=>v(so)(e.index,e.index+1)&&v(Ct)(e.index+1))},"↓ Down",8,yh)])):V("",!0)],64))}}),wh={class:"dr-head"},_h={class:"tx"},kh={class:"dr-body"},$h={class:"f"},xh={class:"options"},Sh=["disabled","onClick"],Ch={class:"mdi"},Th={class:"tx"},Ah={key:0,class:"f"},Eh={class:"options"},Ph=["disabled","onClick"],Oh={class:"mdi"},Ih={class:"tx"},Mh={class:"f"},Dh={class:"options"},Lh=["disabled","onClick"],Fh={class:"mdi"},Hh={class:"tx"},Rh={key:0,class:"hint"},Nh={key:1,class:"hint"},jh={class:"dr-foot"},Bh=$e({__name:"TopbarAdd",setup(e){const t=fe(""),n=k(()=>new Set(Dt().map(He))),s=k(()=>yn().clock_24h!==!1),o=k(()=>({clock:Bo(s.value,new Date(d.now)),analog:"Small dial with the time",date:Uo(new Date(d.now))})),l=k(()=>d.inventory.header?.suggestions?.[d.selected||""]||[]),r=k(()=>{const a=t.value.trim().toLocaleLowerCase();return d.inventory.entities.filter(c=>`${c.name} ${c.id} ${c.area||""} ${c.device||""}`.toLocaleLowerCase().includes(a))}),u=a=>({type:"entity",entity:a,content:"state",icon:"auto",show:"always"});return(a,c)=>(h(),g(W,null,[i("div",wh,[c[4]||(c[4]=i("span",{class:"av",style:{background:"var(--seg)",color:"var(--ink-2)","font-size":"18px"}},"+",-1)),i("span",_h,[c[3]||(c[3]=i("b",null,"Add to the top bar",-1)),i("small",null,y(v(Dt)().length)+" of "+y(v(Bt)())+" slots used",1)]),i("button",{type:"button",class:"icon-btn","aria-label":"Close",onClick:c[0]||(c[0]=(...f)=>v(je)&&v(je)(...f))},"✕")]),i("div",kh,[i("div",$h,[c[5]||(c[5]=i("span",{class:"f-label"},"From the screen itself",-1)),i("div",xh,[(h(!0),g(W,null,le(v(d).inventory.header?.builtin||[],f=>(h(),g("button",{key:f.type,type:"button",class:"option",disabled:n.value.has(v(He)({type:f.type})),onClick:b=>v(oo)({type:f.type})},[i("span",Ch,y(v(ke)(v(es)(v(Zl)[f.type])?.cp||"F0150")),1),i("span",Th,[i("strong",null,y(f.label),1),i("small",null,y(n.value.has(v(He)({type:f.type}))?"Already added":o.value[f.type]),1)])],8,Sh))),128))])]),l.value.length?(h(),g("div",Ah,[c[6]||(c[6]=i("span",{class:"f-label"},"Suggestions from Home Assistant",-1)),i("div",Eh,[(h(!0),g(W,null,le(l.value,f=>(h(),g("button",{key:v(He)(f.item),type:"button",class:"option",disabled:n.value.has(v(He)(f.item)),onClick:b=>v(oo)(f.item)},[i("span",Oh,y(v(ke)(f.icon||v(on)(f.item.entity))),1),i("span",Ih,[i("strong",null,y(f.label),1),i("small",null,y(n.value.has(v(He)(f.item))?"Already added":[f.name,f.area].filter(Boolean).join(" · ")),1)])],8,Ph))),128))])])):V("",!0),i("div",Mh,[c[7]||(c[7]=i("label",{class:"f-label",for:"topbar-search"},"An entity",-1)),Te(i("input",{id:"topbar-search","onUpdate:modelValue":c[1]||(c[1]=f=>t.value=f),type:"search",placeholder:"Search by name, room, or entity, e.g. temperature or door","aria-label":"Search for an entity for the top bar"},null,512),[[Je,t.value]]),i("div",Dh,[(h(!0),g(W,null,le(r.value.slice(0,40),f=>(h(),g("button",{key:f.id,type:"button",class:"option",disabled:n.value.has(v(He)(u(f.id))),onClick:b=>v(oo)(u(f.id))},[i("span",Fh,y(v(ke)(v(on)(f.id))),1),i("span",Hh,[i("strong",null,y(f.name),1),i("small",null,y([f.area,f.id].filter(Boolean).join(" · ")),1)])],8,Lh))),128)),r.value.length?r.value.length>40?(h(),g("p",Nh,y(r.value.length)+" results. Keep typing to narrow it down.",1)):V("",!0):(h(),g("p",Rh,"No entities found."))])])]),i("div",jh,[c[8]||(c[8]=i("span",{class:"spacer"},null,-1)),i("button",{type:"button",class:"btn quiet",onClick:c[2]||(c[2]=(...f)=>v(je)&&v(je)(...f))},"Cancel")])],64))}}),Uh={class:"dr-head"},Vh={class:"tx"},Wh={class:"dr-body",id:"inspector-section"},Kh={id:"inspection-summary"},qh={key:0,class:"hint warn"},Yh={key:1,class:"hint"},zh={id:"inspection"},Gh=$e({__name:"InspectPanel",props:{entity:{}},setup(e){const t=e,n=fe(null),s=fe("Reading…"),o=fe("");async function l(){if(d.selected){n.value=null,o.value="",s.value="Fetching current HA status...";try{const u=await Mt(`screens/${encodeURIComponent(d.selected)}/inspect`),a=t.entity?u.tiles.filter(c=>c.entity===t.entity):u.tiles;n.value=a,s.value=JSON.stringify(t.entity?a[0]:u,null,2)}catch(u){o.value=u.message,s.value="",Se(u.message)}}}const r=u=>{const a=d.layout?.tiles.find(c=>c.entity===u)?.options||{};return`Small slider: ${a.inline==="slider"?"yes":"no"} · Display: ${aa[a.display||"standard"]||a.display} · Width: ${a.size==="wide"?"double":"normal"} · Control: ${oc({entity:u,options:a},d.inventory)} · Background: ${d.inventory.backgrounds?.[a.background||"auto"]?.label||"Default"}`};return bn(l),In(()=>t.entity,l),(u,a)=>(h(),g(W,null,[i("div",Uh,[a[2]||(a[2]=i("span",{class:"av",style:{background:"var(--seg)",color:"var(--ink-2)","font-size":"16px"}},"⌕",-1)),i("span",Vh,[a[1]||(a[1]=i("b",null,"Current data",-1)),i("small",{class:re({mono:e.entity})},y(e.entity||"Connection, status, and supported properties"),3)]),i("button",{type:"button",class:"icon-btn","aria-label":"Close",onClick:a[0]||(a[0]=(...c)=>v(je)&&v(je)(...c))},"✕")]),i("div",Wh,[i("div",Kh,[o.value?(h(),g("p",qh,y(o.value),1)):n.value?V("",!0):(h(),g("p",Yh,"Fetching current HA status...")),(h(!0),g(W,null,le(n.value||[],c=>(h(),g("article",{key:c.entity,class:"inspection-tile"},[i("strong",null,y(c.entity),1),i("span",null,"Status: "+y(c.word||c.state),1),i("small",null,y(r(c.entity)),1)]))),128))]),i("pre",zh,y(s.value),1)]),i("div",{class:"dr-foot"},[i("button",{type:"button",class:"btn quiet",onClick:l},"Read again"),a[3]||(a[3]=i("span",{class:"spacer"},null,-1))])],64))}}),Jh=["aria-hidden"],Xh=$e({__name:"Drawer",setup(e){const t=k(()=>!!(d.inspector&&(d.inspector.kind!=="tile"||Zs.value)));return(n,s)=>(h(),g("aside",{class:re(["drawer",{open:t.value}]),id:"tile-sheet","aria-hidden":t.value?"false":"true",onClick:s[0]||(s[0]=Ft(()=>{},["stop"]))},[v(d).inspector?(h(),g(W,{key:0},[v(d).inspector.kind==="tile"&&v(Zs)?(h(),Ve(Jp,{key:0,tile:v(Zs)},null,8,["tile"])):v(d).inspector.kind==="bar"?(h(),Ve(bh,{key:1,index:v(d).inspector.index},null,8,["index"])):v(d).inspector.kind==="bar-add"?(h(),Ve(Bh,{key:2})):v(d).inspector.kind==="inspect"?(h(),Ve(Gh,{key:3,entity:v(d).inspector.entity},null,8,["entity"])):V("",!0)],64)):V("",!0)],10,Jh))}}),Qh={class:"main-head"},Zh={class:"eyebrow",id:"screen-name"},ev=["title"],tv={class:"head-right"},nv={class:"seg",role:"tablist"},sv=["aria-pressed"],ov=["aria-pressed"],iv={key:1,id:"dirty",class:"chip dirty"},lv=["disabled"],av={key:0,class:"spin small"},rv=["aria-expanded"],uv={key:3,class:"menu",role:"menu"},cv=["disabled","title"],dv={class:"body",id:"body"},fv=$e({__name:"ScreenView",setup(e){const t=k(()=>qt.value),n=k(()=>t.value.online?`${t.value.delivery} · ${t.value.status}`:"Offline · changes are saved"),s=k(()=>t.value.update?.available&&t.value.online&&t.value.update?.profile);function o(){d.menuOpen=!1,d.overrideProfile=t.value.update?.profile||null,d.overrideFriendly=t.value.name,De("#override")}function l(){d.menuOpen=!1,d.selectedTile=null,d.inspector={kind:"inspect"}}function r(a){a.key==="Escape"&&(d.menuOpen?d.menuOpen=!1:d.inspector&&!a.target?.closest?.(".picker")&&je())}function u(a){d.menuOpen&&!a.target.closest(".head-right .menu, #more")&&(d.menuOpen=!1)}return bn(()=>{document.addEventListener("keydown",r),document.addEventListener("click",u)}),Xn(()=>{document.removeEventListener("keydown",r),document.removeEventListener("click",u)}),(a,c)=>(h(),g(W,null,[i("header",Qh,[i("div",null,[i("span",Zh,y(t.value.name),1),i("h1",null,y(v(d).layout?.title||"Home"),1)]),i("span",{id:"delivery",class:re(["chip",{off:!t.value.online}]),title:n.value},[c[6]||(c[6]=i("span",{class:"dot"},null,-1)),Q(y(n.value),1)],10,ev),i("div",tv,[i("div",nv,[i("button",{type:"button",id:"tab-layout",role:"tab","aria-pressed":v(d).tab==="layout"?"true":"false",onClick:c[0]||(c[0]=f=>v(d).tab="layout")},"Layout",8,sv),i("button",{type:"button",id:"tab-settings",role:"tab","aria-pressed":v(d).tab==="settings"?"true":"false",onClick:c[1]||(c[1]=f=>{v(d).tab="settings",v(je)()})},"Screen settings",8,ov)]),v(d).dirty?(h(),g("span",iv,"Unsaved changes")):(h(),g("span",{key:0,id:"dirty",class:re(["chip",{sent:v(d).saved}])},y(v(d).saved?"Saved · sent to screen":"All saved"),3)),v(d).dirty?(h(),g("button",{key:2,id:"save",type:"button",class:"btn primary",disabled:v(d).busy,onClick:c[2]||(c[2]=f=>v(gc)())},[v(d).busy?(h(),g("span",av)):V("",!0),Q(y(v(d).busy?"Saving…":"Save & send"),1)],8,lv)):V("",!0),i("button",{id:"more",type:"button",class:"icon-btn","aria-label":"More","aria-haspopup":"menu","aria-expanded":v(d).menuOpen?"true":"false",onClick:c[3]||(c[3]=Ft(f=>v(d).menuOpen=!v(d).menuOpen,["stop"]))},"···",8,rv),v(d).menuOpen?(h(),g("div",uv,[i("button",{type:"button",role:"menuitem",id:"inspect",onClick:l},[...c[7]||(c[7]=[Q("Read current data ",-1),i("small",null,"from Home Assistant",-1)])]),i("button",{type:"button",role:"menuitem",id:"open-override",disabled:!t.value.update?.profile,title:t.value.update?.profile?"":"No ESPHome profile found for this screen",onClick:o},[...c[8]||(c[8]=[Q("Override YAML ",-1),i("small",null,"advanced",-1)])],8,cv),i("button",{type:"button",role:"menuitem",onClick:c[4]||(c[4]=f=>{v(d).menuOpen=!1,v(De)("#firmware")})},"Firmware & USB"),s.value?(h(),g("button",{key:0,type:"button",role:"menuitem",onClick:c[5]||(c[5]=f=>{v(d).menuOpen=!1,a.$emit("update")})},[c[9]||(c[9]=Q("Update firmware ",-1)),i("small",null,y(t.value.update?.target),1)])):V("",!0)])):V("",!0)])]),i("div",dv,[v(d).tab==="layout"?(h(),Ve(Cf,{key:0})):(h(),Ve(Uf,{key:1})),_e(Xh)])],64))}}),pv={key:0,id:"choose",class:"empty"},hv={key:1,id:"empty",class:"empty"},vv=$e({__name:"EmptyState",setup(e){return(t,n)=>v(d).inventory.screens.length?(h(),g("section",pv,[...n[1]||(n[1]=[i("span",{class:"empty-icon"},"▦",-1),i("h2",null,"Choose a screen",-1),i("p",null,"Pick one of your screens on the left to change its tiles, top bar and settings.",-1)])])):(h(),g("section",hv,[n[2]||(n[2]=i("span",{class:"empty-icon"},"▦",-1)),n[3]||(n[3]=i("h2",null,"Start with a screen",-1)),n[4]||(n[4]=i("p",null,"Connect a CYD or Guition via USB to your Home Assistant machine. Profile, firmware, and the first flash happen in one go.",-1)),i("button",{id:"start",type:"button",class:"btn primary",onClick:n[0]||(n[0]=s=>v(De)("#new-screen"))},"Install new screen")]))}}),mv={class:"panel",id:"settings-view"},gv={class:"panel-head"},yv={class:"card-grid"},bv={class:"card"},wv={class:"tools"},_v={key:0,class:"card updates",id:"updates"},kv={id:"updates-hint"},$v={class:"check"},xv=["checked"],Sv={class:"card"},Cv={class:"tools"},Tv={class:"card",id:"claude"},Av={class:"actions"},Ev=["disabled"],Pv={id:"claude-path"},Ov=$e({__name:"AppSettingsView",setup(e){const t=k(()=>d.inventory.updates),n=k(()=>d.inventory.screens.filter(u=>u.update?.available).length),s=k(()=>t.value?t.value.busy?`Updating to firmware ${t.value.target}…`:d.inventory.screens.length?n.value?`Firmware ${t.value.target} is available for ${n.value} screen${n.value===1?"":"s"}.`:`All screens have firmware ${t.value.target}.`:`No screen paired yet. New screens get firmware ${t.value.target}.`:""),o=k(()=>d.inventory.claude_skill),l=fe(!1);async function r(){l.value=!0;try{await Sc()}finally{l.value=!1}}return(u,a)=>(h(),g("div",mv,[i("div",gv,[a[6]||(a[6]=i("div",{class:"tx"},[i("span",{class:"eyebrow"},"Settings"),i("h1",{id:"settings-title"},"Everything around your screens.")],-1)),i("button",{type:"button",class:"btn quiet",id:"close-settings",onClick:a[0]||(a[0]=c=>v(De)(""))},"← My screens")]),i("div",yv,[i("section",bv,[a[9]||(a[9]=i("h2",null,"Screens",-1)),a[10]||(a[10]=i("p",null,"Add a screen, or build and reinstall the firmware of one you already have.",-1)),i("div",wv,[i("button",{type:"button",class:"tool",onClick:a[1]||(a[1]=c=>v(De)("#new-screen"))},[...a[7]||(a[7]=[i("span",{class:"tool-icon"},"+",-1),i("span",{class:"tx"},[i("b",null,"New screen"),i("small",null,"Connect and install")],-1)])]),i("button",{type:"button",class:"tool",onClick:a[2]||(a[2]=c=>v(De)("#firmware"))},[...a[8]||(a[8]=[i("span",{class:"tool-icon"},"⇪",-1),i("span",{class:"tx"},[i("b",null,"Firmware & USB"),i("small",null,"Build and install via USB or Wi-Fi")],-1)])])])]),t.value?(h(),g("section",_v,[a[12]||(a[12]=i("h2",null,"Firmware updates",-1)),i("p",kv,y(s.value),1),t.value.pending&&!t.value.busy&&t.value.pending>=2?(h(),g("button",{key:0,id:"update-all",type:"button",class:"btn primary",onClick:a[3]||(a[3]=(...c)=>v(Vi)&&v(Vi)(...c))},"Update all "+y(t.value.pending)+" screens",1)):V("",!0),i("label",$v,[i("input",{type:"checkbox",id:"auto-update",checked:!!t.value.auto,onChange:a[4]||(a[4]=c=>v(xc)(c.target.checked))},null,40,xv),a[11]||(a[11]=i("span",null,[Q("Update automatically every night"),i("small",null,"Between 03:00 and 06:00, one screen at a time. Stops on an error and reports it in Home Assistant.")],-1))])])):V("",!0),i("section",Sv,[a[14]||(a[14]=i("h2",null,"Alerts",-1)),a[15]||(a[15]=i("p",null,"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.",-1)),i("div",Cv,[i("button",{type:"button",class:"tool",onClick:a[5]||(a[5]=c=>v(De)("#alerts"))},[...a[13]||(a[13]=[i("span",{class:"tool-icon"},"!",-1),i("span",{class:"tx"},[i("b",null,"Alerts"),i("small",null,"Full-screen notifications, from an automation")],-1)])])])]),i("section",Tv,[a[18]||(a[18]=i("h2",null,"Claude",-1)),a[19]||(a[19]=i("p",null,'Teach Claude about ESP Screens, then ask: "Show an alert on all my screens when the mailbox is full." Claude knows the alert events, every field, color, and icon.',-1)),i("div",Av,[i("button",{type:"button",id:"claude-install",class:re(["btn",o.value?.installed&&o.value.current?"quiet":"primary"]),disabled:!o.value||l.value,onClick:r},y(!o.value||!o.value.installed?"Install for Claude Code":o.value.current?"Install again":"Update the skill"),11,Ev),i("span",{id:"claude-status",class:re(["chip",o.value&&o.value.installed?o.value.current?"good":"update":""]),role:"status"},y(o.value?o.value.installed?o.value.current?"● Installed":"Installed · a newer version is ready":"Not installed":"Loading…"),3)]),i("small",null,[a[16]||(a[16]=Q("For Claude Code in Home Assistant. Writes one file to ",-1)),i("code",Pv,y(o.value?.path||"/homeassistant/.claude/skills/esp-screens"),1),a[17]||(a[17]=Q("; installing again replaces it.",-1))]),a[20]||(a[20]=i("div",{class:"actions"},[i("a",{class:"btn quiet",id:"claude-download",href:"api/claude-skill.zip",download:"esp-screens.zip"},"Download for claude.ai"),i("small",null,"Upload the zip in Claude under Customize → Skills.")],-1))])])]))}}),Iv={class:"panel",id:"installer"},Mv={class:"panel-head"},Dv={class:"tx"},Lv={id:"install-title"},Fv={key:0},Hv={class:"boards"},Rv={class:"board"},Nv={class:"board"},jv={class:"field"},Bv={class:"node-line"},Uv={id:"node-preview"},Vv={key:0,class:"field",id:"node-label"},Wv={key:1,id:"wifi-fields",class:"wifi"},Kv={class:"hint",id:"wifi-status"},qv={key:0,class:"field",id:"wifi-ssid-label"},Yv={key:1,class:"field",id:"wifi-password-label"},zv={class:"field"},Gv={key:0,value:"usb"},Jv=["value"],Xv={id:"target-hint"},Qv={key:2,class:"hint",id:"install-note"},Zv={class:"actions"},em=["disabled"],tm={id:"install-status",class:"status-line error",role:"status"},nm={key:1,id:"install-progress",class:"card"},sm={class:"progress-head"},om={key:0,class:"spin",id:"progress-spin"},im={id:"progress-title"},lm={id:"progress-detail"},am={key:0,id:"install-download",class:"card",style:{background:"var(--surface-2)"}},rm=["href","download"],um={class:"steps",id:"download-steps"},cm={key:1,id:"install-result",class:"field"},dm={class:"key-box"},fm={class:"steps",id:"install-steps"},pm=["open"],hm={id:"install-log",class:"log"},vm={class:"actions"},mm="https://web.esphome.io/?dashboard_install",gm="Download · flash from your own computer",ym=$e({__name:"InstallerView",setup(e){const t=hn({board:"cyd",friendly_name:"",name:"",wifi_ssid:"",wifi_password:"",target:""}),n=hn({view:"setup",file:null,friendly:"",board:"cyd",target:"",apiKey:null,nodeEdited:!1,jobState:null,picked:!1,action:null}),s=fe(!1),o=fe(null),l=fe(null),r=fe([]),u=fe(""),a=fe(""),c=fe(!1),f=fe(!1),b=fe(null);let A=0;function D(ce){return ce.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g,"").replace(/[^a-z0-9]+/g,"-").replace(/^[^a-z]+/,"").slice(0,30).replace(/-+$/,"")||"screen"}function I(ce){const M=ce.replace(/^\/dev\/serial\/by-id\/usb-/,"").replace(/-if\d+(-port\d+)?$/,"").replace(/_/g," ");return`USB · ${M===ce?ce.replace(/^\/dev\//,""):M}`}In(()=>t.friendly_name,()=>{n.nodeEdited||(t.name=D(t.friendly_name))});const R=k(()=>t.name||"…"),T=k(()=>o.value?.ports||[]),$=k(()=>o.value?.wifi),x=k(()=>$.value?.state==="new"||$.value?.state==="missing"),w=k(()=>$.value?.missing||[]),m=k(()=>$.value?.state==="new"?"Fill in once: ESP Screens saves this in ESPHome secrets.yaml, later screens use it automatically.":"Your ESPHome secrets.yaml is still missing Wi-Fi details. ESP Screens only fills in the missing lines."),H=k(()=>$.value?.state==="ready"?"Wi-Fi comes from your ESPHome secrets.yaml.":$.value?.state==="invalid"?"secrets.yaml in the ESPHome folder isn't valid YAML. Fix the file first; it won't be overwritten.":""),C=k(()=>t.target==="usb"?"Connect the screen with a USB data cable to the Home Assistant machine; the list refreshes on its own. Or choose Download to use your own computer.":t.target==="download"?"Download the firmware and put it on the screen from your own computer, in Chrome or Edge. After that, updates go over Wi-Fi.":t.target?T.value.length>1?"More than one board connected: choose this screen's port.":"Once over USB; after that, everything is wireless.":"The profile goes into the ESPHome folder. You can install later via Firmware & USB, or from ESPHome Device Builder."),L=k(()=>t.target==="download"?"Build & download":t.target?"Install":"Save profile"),pe=k(()=>o.value?.job?.state==="running"&&!(n.file&&o.value.job.file===n.file)),Ae=k(()=>c.value||$.value?.state==="invalid"||t.target==="usb"||!!t.target&&(pe.value||!o.value?.available));function J(){const ce=t.target;(ce==="download"||ce===""?n.picked:T.value.includes(ce))||(t.target=T.value[0]||"usb")}async function q(){let ce;try{ce=await Mt("firmware")}catch(Ge){u.value=Ge.message;return}o.value=ce;const M=ce.job,se=M&&n.file&&M.file===n.file;if(se&&(n.jobState=M.state),n.view==="setup"){if(se&&M.state==="running"){ae(M,ce.logs||[]);return}J(),u.value=pe.value?`A build or installation is already running (${M.file}). Wait for it to finish.`:!ce.available&&t.target?"The ESPHome CLI is missing from this installation; only saving the profile is possible.":H.value}else n.view==="progress"&&se&&(l.value=M,r.value=ce.logs||[],M.state!=="running"&&M.state!=="success"&&(f.value=!0))}function ae(ce,M){n.view="progress",n.jobState=ce.state,n.action=ce.action,l.value=ce,r.value=M}const z=k(()=>l.value?.state==="running"),B=k(()=>n.view==="done"||l.value?.state==="success"),Y=k(()=>n.action==="download"),oe=k(()=>n.view==="done"?"Profile saved.":z.value?"One moment…":B.value?Y.value?"Ready to download.":"Done.":"That didn't work."),ie=k(()=>n.view==="done"?`${n.file} is in the ESPHome folder`:z.value?l.value?.stage==="upload"?`Writing firmware to ${n.friendly}…`:"Building firmware…":B.value?Y.value?`Firmware for ${n.friendly} is ready`:`Firmware is on ${n.friendly}`:Y.value?"Build failed":"Install failed"),ue=k(()=>n.view==="done"?"Install it later from Firmware & USB: choose this profile, then the USB port of the Home Assistant machine, or Download to put the firmware on the screen from your own computer. Save the API key for pairing:":z.value?l.value?.stage==="upload"?"Don't disconnect the USB cable yet.":`A first build takes a few minutes on a Raspberry Pi. You can leave this page: the ${Y.value?"build":"installation"} keeps running and you'll find it again under New screen.`:B.value?Y.value?"Put it on the screen from your own computer, then pair the screen with Home Assistant.":`The screen boots up and connects to your Wi-Fi.${n.board==="cyd"?" The CYD first asks for a touch calibration: tap the crosshairs.":""} Pair it with Home Assistant now:`:r.value.filter(ce=>/error/i.test(ce)).pop()||r.value.filter(ce=>/failed/i.test(ce)).pop()||"See the log below."),ze=k(()=>({href:`api/firmware/profiles/${encodeURIComponent(n.file||"")}/download`,name:(n.file||"").replace(/\.yaml$/,"")+".factory.bin"})),an=k(()=>[["Go to Home Assistant → Settings → Devices & services.",` This happens outside ESP Screens. Home Assistant discovers ${n.friendly} as an ESPHome device; click Add. Not discovered? Add ESPHome manually with the screen's IP address. `],["Paste the API key"," above when Home Assistant asks for an encryption key."],["Allow HA actions:"," ESPHome integration → Configure → “Allow the device to perform Home Assistant actions”. Without this, the screen sees everything but controls nothing."],["Choose your tiles."," Back in ESP Screens, the screen appears in the list on the left within about thirty seconds; until then it's shown there as “not yet in Home Assistant”."]]);async function _t(ce){const M=ce.target;if(n.nodeEdited||(t.name=D(t.friendly_name)),!!M.reportValidity()){c.value=!0,a.value="";try{const se={board:t.board,friendly_name:t.friendly_name,name:t.name,target:t.target};x.value&&(w.value.includes("wifi_ssid")&&(se.wifi_ssid=t.wifi_ssid),w.value.includes("wifi_password")&&(se.wifi_password=t.wifi_password));const Ge=await it("firmware/profiles","POST",se);Object.assign(n,{file:Ge.file,apiKey:Ge.api_key,friendly:t.friendly_name.trim(),board:t.board,target:t.target}),t.wifi_password="",Ge.job?ae(Ge.job,[]):n.view="done"}catch(se){a.value=se.message}finally{c.value=!1}}}async function We(){try{if(!Y.value){const{ports:M}=await Mt("firmware");!M.includes(n.target)&&M.length===1&&(n.target=M[0])}const ce=await it("firmware/jobs","POST",Y.value?{file:n.file,action:"download"}:{file:n.file,action:"install",target:n.target});ae(ce,[])}catch(ce){Se(ce.message)}}function ts(){Object.assign(n,{view:"setup",file:null,apiKey:null,nodeEdited:!1,jobState:null,target:"",picked:!1,action:null}),Object.assign(t,{board:"cyd",friendly_name:"",name:"",wifi_ssid:"",wifi_password:"",target:""}),s.value=!1,l.value=null,r.value=[],a.value="",u.value="",f.value=!1,q()}function ns(){n.view==="progress"&&n.jobState!=="running"&&(n.view="done"),De("")}return bn(()=>{q(),A=window.setInterval(q,3e3)}),Xn(()=>clearInterval(A)),(ce,M)=>(h(),g("div",Iv,[i("div",Mv,[i("div",Dv,[M[13]||(M[13]=i("span",{class:"eyebrow"},"New screen",-1)),i("h1",Lv,y(n.view==="setup"?"Connect and install.":oe.value),1),n.view==="setup"?(h(),g("p",Fv,"ESP Screens creates the screen's own profile with unique keys and builds the firmware. Connect the screen with a USB data cable to the machine running Home Assistant, or download the firmware and put it on the screen from your own computer.")):V("",!0)]),i("button",{type:"button",class:"btn quiet",id:"close-install","aria-label":"Close",onClick:ns},"← Back")]),n.view==="setup"?(h(),g("form",{key:0,id:"install-form",class:"card",onSubmit:Ft(_t,["prevent"])},[i("fieldset",null,[M[16]||(M[16]=i("legend",null,"Which screen do you have?",-1)),i("div",Hv,[i("label",Rv,[Te(i("input",{type:"radio",name:"board",value:"cyd","onUpdate:modelValue":M[0]||(M[0]=se=>t.board=se)},null,512),[[Oi,t.board]]),M[14]||(M[14]=i("span",null,[i("b",null,"CYD · 2.8 inch"),i("small",null,"ESP32-2432S028 · 320 × 240")],-1))]),i("label",Nv,[Te(i("input",{type:"radio",name:"board",value:"guition","onUpdate:modelValue":M[1]||(M[1]=se=>t.board=se)},null,512),[[Oi,t.board]]),M[15]||(M[15]=i("span",null,[i("b",null,"Guition · 4 inch"),i("small",null,"ESP32-S3-4848S040 · 480 × 480 · GT911")],-1))])])]),i("div",jv,[M[18]||(M[18]=i("label",{class:"f-label",for:"friendly_name"},"Name",-1)),Te(i("input",{id:"friendly_name",name:"friendly_name","onUpdate:modelValue":M[2]||(M[2]=se=>t.friendly_name=se),required:"",maxlength:"60",placeholder:"Living room screen",autocomplete:"off"},null,512),[[Je,t.friendly_name]]),i("small",Bv,[M[17]||(M[17]=Q("Device name ",-1)),i("code",Uv,y(R.value),1),i("button",{type:"button",class:"btn link mini",id:"edit-node",onClick:M[3]||(M[3]=se=>{n.nodeEdited=!0,s.value=!0})},"customize")]),M[19]||(M[19]=i("small",null,"Every screen gets its own profile with its own keys. Four screens? Go through this four times with a different name.",-1))]),s.value?(h(),g("div",Vv,[M[20]||(M[20]=i("label",{class:"f-label",for:"node-name"},"Device name",-1)),Te(i("input",{id:"node-name",name:"name","onUpdate:modelValue":M[4]||(M[4]=se=>t.name=se),pattern:"[a-z][a-z0-9\\-]{0,29}",maxlength:"30",autocomplete:"off",onInput:M[5]||(M[5]=se=>n.nodeEdited=!0)},null,544),[[Je,t.name]]),M[21]||(M[21]=i("small",null,"Lowercase letters, digits, and dashes. A different name for each screen.",-1))])):V("",!0),x.value?(h(),g("fieldset",Wv,[M[24]||(M[24]=i("legend",null,"Wi-Fi",-1)),i("p",Kv,y(m.value),1),w.value.includes("wifi_ssid")?(h(),g("div",qv,[M[22]||(M[22]=i("label",{class:"f-label",for:"wifi_ssid"},"Wi-Fi name (2.4 GHz)",-1)),Te(i("input",{id:"wifi_ssid",name:"wifi_ssid","onUpdate:modelValue":M[6]||(M[6]=se=>t.wifi_ssid=se),autocomplete:"off"},null,512),[[Je,t.wifi_ssid]])])):V("",!0),w.value.includes("wifi_password")?(h(),g("div",Yv,[M[23]||(M[23]=i("label",{class:"f-label",for:"wifi_password"},"Wi-Fi password",-1)),Te(i("input",{id:"wifi_password",name:"wifi_password",type:"password","onUpdate:modelValue":M[7]||(M[7]=se=>t.wifi_password=se),autocomplete:"new-password"},null,512),[[Je,t.wifi_password]])])):V("",!0)])):V("",!0),i("div",zv,[M[26]||(M[26]=i("label",{class:"f-label",for:"install-target"},"Install via",-1)),Te(i("select",{id:"install-target",name:"target","onUpdate:modelValue":M[8]||(M[8]=se=>t.target=se),onChange:M[9]||(M[9]=se=>{n.picked=!0,q()})},[T.value.length?V("",!0):(h(),g("option",Gv,"USB · no board found on the Home Assistant machine yet")),(h(!0),g(W,null,le(T.value,se=>(h(),g("option",{key:se,value:se},y(I(se)),9,Jv))),128)),i("option",{value:"download"},y(gm)),M[25]||(M[25]=i("option",{value:""},"Later · save profile only",-1))],544),[[_s,t.target]]),i("small",Xv,y(C.value),1)]),u.value?(h(),g("p",Qv,y(u.value),1)):V("",!0),i("div",Zv,[i("button",{type:"submit",class:"btn primary",id:"install-go",disabled:Ae.value},y(L.value),9,em),i("span",tm,y(a.value),1)])],32)):(h(),g("div",nm,[i("div",sm,[z.value?(h(),g("span",om)):(h(),g("span",{key:1,class:re(["outcome",B.value?"ok":"bad"]),id:"progress-mark"},y(B.value?"✓":"✕"),3)),i("strong",im,y(ie.value),1)]),i("p",lm,y(ue.value),1),B.value&&Y.value&&n.view!=="done"?(h(),g("div",am,[i("a",{class:"btn primary",id:"download-firmware",href:ze.value.href,download:ze.value.name},"Download "+y(ze.value.name),9,rm),i("ol",um,[M[30]||(M[30]=i("li",null,[i("b",null,"Plug the screen into your computer"),Q(" with a USB data cable.")],-1)),i("li",null,[i("b",null,[M[27]||(M[27]=Q("Open ",-1)),i("a",{href:mm,target:"_blank",rel:"noopener"},"ESPHome Web")]),M[28]||(M[28]=Q(" in Chrome or Edge, click Connect and choose the screen's USB port.",-1))]),i("li",null,[M[29]||(M[29]=i("b",null,"Click Install",-1)),Q(" and select "+y(ze.value.name)+". The screen restarts and joins your Wi-Fi."+y(n.board==="cyd"?" The CYD first asks for a touch calibration: tap the crosshairs.":""),1)])]),M[31]||(M[31]=i("small",null,"The file holds your Wi-Fi password and the screen's keys: keep it to yourself.",-1))])):V("",!0),B.value?(h(),g("div",cm,[i("div",dm,[M[32]||(M[32]=i("span",null,"API key",-1)),i("code",{id:"api-key",ref_key:"keyBox",ref:b},y(n.apiKey||""),513),i("button",{type:"button",class:"btn quiet mini",id:"copy-key",onClick:M[10]||(M[10]=se=>v(dt)(n.apiKey||"",b.value))},"Copy")]),i("ol",fm,[(h(!0),g(W,null,le(an.value,([se,Ge],wn)=>(h(),g("li",{key:wn},[i("b",null,y(se),1),Q(y(Ge),1),wn===0?(h(),g("button",{key:0,type:"button",class:"btn quiet mini",onClick:M[11]||(M[11]=(...Gt)=>v($s)&&v($s)(...Gt))},"Open Devices & services")):V("",!0)]))),128))])])):V("",!0),n.view!=="done"?(h(),g("details",{key:2,id:"install-log-wrap",class:"log-wrap",open:f.value,onToggle:M[12]||(M[12]=se=>f.value=se.target.open)},[M[33]||(M[33]=i("summary",null,"ESPHome log",-1)),i("pre",hm,y(r.value.join(` +`)),1)],40,pm)):V("",!0),i("div",vm,[!z.value&&!B.value?(h(),g("button",{key:0,type:"button",class:"btn primary",id:"install-retry",onClick:We},"Retry")):V("",!0),i("button",{type:"button",class:"btn quiet",id:"install-close",onClick:ts},y(B.value?"Install another screen":"Start over"),1),i("button",{type:"button",class:re(["btn",B.value?"primary":"quiet"]),onClick:ns},y(B.value?"Done":"Close"),3)])]))]))}}),bm={class:"panel",id:"firmware-dialog"},wm={class:"panel-head"},_m={class:"card"},km={class:"card-grid"},$m={class:"field"},xm=["value"],Sm={class:"field"},Cm={key:0,value:"usb"},Tm=["value"],Am={key:0,class:"field",id:"firmware-host-label"},Em=["placeholder"],Pm={class:"actions"},Om=["disabled"],Im=["disabled"],Mm=["disabled"],Dm={key:0,class:"spin"},Lm={id:"firmware-status",class:"status-line",role:"status"},Fm={key:0,id:"firmware-download",class:"card",style:{background:"var(--surface-2)"}},Hm=["href","download"],Rm={id:"firmware-log",class:"log"},Nm="https://web.esphome.io/?dashboard_install",jm="Download · flash from your own computer",Bm=$e({__name:"FirmwareView",setup(e){const t=fe(null),n=fe(""),s=fe("ota"),o=fe("");let l=0;async function r(R=!1){try{const T=await Mt("firmware");t.value=T,(R||!n.value)&&(n.value=T.profiles[0]?.file||"");const $=T.ports||[];s.value.startsWith("/")&&!$.includes(s.value)&&(s.value=$[0]||"usb"),s.value==="usb"&&$.length&&(s.value=$[0])}catch(T){Se(T.message)}}const u=k(()=>t.value?.job?.state==="running"),a=k(()=>!t.value||u.value||!t.value.available||!t.value.profiles?.length),c=k(()=>a.value||s.value==="usb"),f=k(()=>t.value?.ports||[]),b=k(()=>t.value?t.value.available?t.value.job?`${t.value.job.file} · ${t.value.job.action} · ${t.value.job.state}`:"Choose the intended profile, then a USB port, an IP address, or Download.":"The ESPHome CLI is missing. Update the app to 0.2.0.":"Loading…"),A=k(()=>s.value==="download"&&!!n.value&&!!t.value?.downloads?.includes(n.value)),D=k(()=>({href:`api/firmware/profiles/${encodeURIComponent(n.value)}/download`,name:n.value.replace(/\.yaml$/,"")+".factory.bin"}));async function I(R){try{await it("firmware/jobs","POST",{file:n.value,action:R==="validate"?"validate":R==="build"?"build":s.value==="download"?"download":"install",target:s.value==="ota"?o.value.trim():s.value==="download"?"":s.value}),await r()}catch(T){Se(T.message)}}return bn(()=>{r(!0),l=window.setInterval(()=>r(),3e3)}),Xn(()=>clearInterval(l)),(R,T)=>(h(),g("div",bm,[i("div",wm,[T[7]||(T[7]=fs('
Firmware & USB

Build and install screen firmware

The built-in ESPHome CLI uses your own profile (YAML with keys) and secrets. Choose the screen's profile, then a USB port (screen connected to the Home Assistant machine), Wi-Fi / OTA with the hostname, or Download to put the firmware on the screen from your own computer. Check only validates, Build only compiles, Build & install puts the firmware on the screen. Your tiles stay as they are in ESP Screens. The first build takes a few minutes.

',1)),i("button",{type:"button",class:"btn quiet",id:"close-firmware",onClick:T[0]||(T[0]=$=>v(De)(""))},"← Back")]),i("div",_m,[i("div",km,[i("div",$m,[T[8]||(T[8]=i("label",{class:"f-label",for:"firmware-file"},"ESPHome profile",-1)),Te(i("select",{id:"firmware-file","onUpdate:modelValue":T[1]||(T[1]=$=>n.value=$)},[(h(!0),g(W,null,le(t.value?.profiles||[],$=>(h(),g("option",{key:$.file,value:$.file},y($.file),9,xm))),128))],512),[[_s,n.value]])]),i("div",Sm,[T[10]||(T[10]=i("label",{class:"f-label",for:"firmware-port"},"Install to",-1)),Te(i("select",{id:"firmware-port","onUpdate:modelValue":T[2]||(T[2]=$=>s.value=$)},[T[9]||(T[9]=i("option",{value:"ota"},"Wi-Fi / OTA",-1)),f.value.length?V("",!0):(h(),g("option",Cm,"USB · no board found on the Home Assistant machine yet")),(h(!0),g(W,null,le(f.value,$=>(h(),g("option",{key:$,value:$},y($),9,Tm))),128)),i("option",{value:"download"},y(jm))],512),[[_s,s.value]])]),s.value==="ota"?(h(),g("div",Am,[T[11]||(T[11]=i("label",{class:"f-label",for:"firmware-host"},"IP address or hostname",-1)),Te(i("input",{id:"firmware-host","onUpdate:modelValue":T[3]||(T[3]=$=>o.value=$),placeholder:n.value?n.value.replace(/\.yaml$/,".local"):"screen-livingroom.local"},null,8,Em),[[Je,o.value]])])):V("",!0)]),i("div",Pm,[i("button",{type:"button",class:"btn quiet",id:"firmware-validate",disabled:a.value,onClick:T[4]||(T[4]=$=>I("validate"))},"Check",8,Om),i("button",{type:"button",class:"btn quiet",id:"firmware-build",disabled:a.value,onClick:T[5]||(T[5]=$=>I("build"))},"Build only",8,Im),i("button",{type:"button",class:"btn primary",id:"firmware-install",disabled:c.value,onClick:T[6]||(T[6]=$=>I("install"))},y(s.value==="download"?"Build & download":"Build & install"),9,Mm),u.value?(h(),g("span",Dm)):V("",!0)]),i("p",Lm,y(b.value),1),A.value?(h(),g("div",Fm,[i("a",{class:"btn primary",id:"firmware-download-link",href:D.value.href,download:D.value.name},"Download "+y(D.value.name),9,Hm),i("small",null,[T[12]||(T[12]=Q("Put it on the screen with ",-1)),i("a",{href:Nm,target:"_blank",rel:"noopener"},"ESPHome Web"),T[13]||(T[13]=Q(" in Chrome or Edge: Connect, choose the screen's USB port, Install, and select this file. It holds your Wi-Fi password and the screen's keys: keep it to yourself.",-1))])])):V("",!0)]),i("pre",Rm,y((t.value?.logs||[]).join(` +`)),1)]))}}),Um={class:"panel wide alerts",id:"alerts-dialog"},Vm={class:"panel-head"},Wm={key:0,class:"hint"},Km={class:"alert-hero"},qm={class:"alert-mock",id:"alert-mock","aria-hidden":"true"},Ym={class:"mdi alert-mock-icon",id:"alert-mock-icon"},zm={class:"alerts-nav","aria-label":"Sections"},Gm=["data-jump","onClick"],Jm={id:"alerts-screens",class:"card"},Xm={id:"alerts-min-firmware"},Qm={id:"alerts-screen-list",class:"options"},Zm={key:0,class:"hint"},eg={class:"alert-screen-head"},tg={class:"copy-label"},ng=["onClick"],sg={id:"alerts-howto",class:"card"},og={class:"field"},ig=["value"],lg={key:0,value:""},ag={class:"copy-line"},rg={id:"alerts-example"},ug={id:"alerts-all",class:"card"},cg={id:"alerts-all-event"},dg={id:"alerts-all-dismiss"},fg={class:"copy-line"},pg={id:"alerts-all-example"},hg={id:"alerts-fields",class:"card"},vg={class:"table-scroll"},mg={id:"alerts-field-table"},gg={key:0},yg={id:"alerts-icons",class:"card"},bg={class:"chips",id:"alerts-suggested"},wg=["onClick"],_g={class:"mdi"},kg={class:"field"},$g={id:"alerts-icon-groups"},xg={class:"subhead"},Sg={class:"alert-icon-grid"},Cg=["title","onClick"],Tg={class:"mdi"},Ag={key:0},Eg={key:0,class:"hint"},Pg={id:"alerts-colors",class:"card"},Og={class:"swatches",id:"alerts-swatches"},Ig=["onClick"],Mg={id:"alerts-events",class:"card"},Dg={id:"alerts-event-name"},Lg={class:"table-scroll"},Fg={id:"alerts-ending-table"},Hg={class:"copy-line"},Rg={id:"alerts-wait-example"},Ng={id:"alerts-tips",class:"card"},jg={class:"alerts-list"},Bg=$e({__name:"AlertsView",setup(e){const t=k(()=>d.inventory.alerts),n=k(()=>d.inventory.icons),s=fe(""),o=fe(""),l=x=>`"${String(x).replace(/\\/g,"\\\\").replace(/"/g,'\\"')}"`,r=x=>x.type==="string"?/^[a-z][a-z0-9-]*$/.test(x.example)?x.example:l(x.example):x.example===!0?"true":x.example===!1?"false":String(x.example),u=k(()=>d.inventory.screens.filter(x=>x.alert_action)),a=k(()=>s.value||u.value[0]?.alert_action||""),c=k(()=>{const x=(t.value?.fields||[]).map(w=>` ${w.name}: ${r(w)}`);return`action: ${a.value||"esphome._show_alert"} +data: +${x.join(` +`)}`}),f=k(()=>["# Doorbell: show the alert and wait until someone presses the button.","actions:",` - action: ${a.value||"esphome._show_alert"}`," data:",' title: "Someone is at the door"',' subtitle: "Door 3, back"'," icon: doorbell"," color: orange",' button_text: "Coming"'," timeout: 0"," flash: true"," - wait_for_trigger:"," - trigger: event",` event_type: ${t.value?.event||"esphome.screen_alert"}`," event_data:"," action: ok",' timeout: "00:05:00"'," - if:"," - condition: template",' value_template: "{{ wait.trigger is not none }}"'," then:"," - action: notify.notify"," data:",' message: "Someone is coming to the door."'].join(` +`)),b=k(()=>{const x=(t.value?.fields||[]).map(m=>` ${m.name}: ${r(m)}`),w=t.value?.camera;return w&&x.push(` # ${w.name}: ${w.example} # a Guition shows its picture on the card`),`event: ${t.value?.broadcast?.show||"esp_screens_show_alert"} +event_data: +${x.join(` +`)}`}),A=k(()=>{if(!t.value||!n.value)return[];const x=o.value.trim().toLowerCase();return[...n.value.groups,{label:"Also available: control and weather icons",icons:t.value.extra_icons}].map(m=>({label:m.label,icons:m.icons.filter(H=>!x||H.name.includes(x)||(H.label||"").toLowerCase().includes(x))})).filter(m=>m.icons.length)}),D=k(()=>t.value?.suggested_icons?.find(x=>x.name==="doorbell")),I=k(()=>t.value?.colors?.find(x=>x.name==="orange")),R={string:"text",int:"number",bool:"on / off"};function T(x){document.getElementById(x)?.scrollIntoView({behavior:"smooth",block:"start"})}const $=[["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"]];return(x,w)=>(h(),g("div",Um,[i("div",Vm,[w[7]||(w[7]=i("div",{class:"tx"},[i("span",{class:"eyebrow"},"Alerts · cheatsheet"),i("h1",null,"A full-screen notification, from an automation."),i("p",null,[Q("Every screen has the "),i("code",null,"show_alert"),Q(" action. It places a card over everything, wakes the screen, and keeps the backlight at normal brightness until someone taps the button or the timeout runs out. Home Assistant itself shows no explanation or dropdowns for ESPHome actions; everything you can fill in is listed here.")])],-1)),i("button",{type:"button",class:"btn quiet",id:"close-alerts",onClick:w[0]||(w[0]=m=>v(De)(""))},"← Back")]),t.value?(h(),g(W,{key:1},[i("div",Km,[i("div",qm,[i("div",{class:"alert-mock-card",style:Ne(I.value?{background:I.value.color}:void 0)},[i("span",Ym,y(D.value?v(ke)(D.value.cp):""),1),w[8]||(w[8]=i("strong",{id:"alert-mock-title"},"Someone is at the door",-1)),w[9]||(w[9]=i("span",{id:"alert-mock-subtitle"},"Door 3, back",-1)),w[10]||(w[10]=i("span",{class:"fake",id:"alert-mock-button"},"Coming",-1))],4)]),i("nav",zm,[(h(),g(W,null,le($,([m,H])=>i("button",{key:m,type:"button","data-jump":m,onClick:C=>T(m)},y(H),9,Gm)),64))])]),i("section",Jm,[w[13]||(w[13]=i("h2",null,"Your screens",-1)),i("p",null,[w[11]||(w[11]=Q("Every screen has its own action, with the device name in it. From firmware ",-1)),i("code",Xm,y(t.value.min_firmware),1),w[12]||(w[12]=Q("; update an older screen with its Update button under Screens.",-1))]),i("div",Qm,[v(d).inventory.screens.length?V("",!0):(h(),g("p",Zm,"No screen paired yet. Install one via New screen; the action appears once Home Assistant sees the screen.")),(h(!0),g(W,null,le(v(d).inventory.screens,m=>(h(),g("div",{key:m.id,class:"alert-screen"},[i("div",eg,[i("strong",null,y(m.name),1),i("span",{class:re(["chip",v(bo)(m.firmware,t.value.min_firmware)&&m.alert_action?"good":"update"])},y(v(bo)(m.firmware,t.value.min_firmware)&&m.alert_action?`● firmware ${m.firmware}`:m.alert_action?`Update needed · firmware ${m.firmware||"unknown"}`:"Device name unknown · update the screen"),3)]),(h(!0),g(W,null,le([["Show alert",m.alert_action],["Dismiss alert",m.dismiss_action]],([H,C])=>(h(),g("div",{key:H,class:"copy-line"},[i("span",tg,y(H),1),i("code",null,y(C||"esphome._show_alert"),1),C?(h(),g("button",{key:0,type:"button",class:"btn quiet mini",onClick:L=>v(dt)(C,void 0,"Action name")},"Copy",8,ng)):V("",!0)]))),128))]))),128))])]),i("section",sg,[w[15]||(w[15]=fs('

In an automation

  1. Add action and search for ESPHome. Choose your screen's action: Home Assistant calls it "Performs the action show_alert of the node device name".
  2. Fill in the fields. Home Assistant asks for all seven; leave what you don't use empty ("", 0, off).
  3. Prefer YAML? Choose Edit in YAML on the action and paste the example below.
',2)),i("div",og,[w[14]||(w[14]=i("label",{class:"f-label",for:"alerts-example-screen"},"Example for",-1)),Te(i("select",{id:"alerts-example-screen","onUpdate:modelValue":w[1]||(w[1]=m=>s.value=m),style:{"max-width":"360px"}},[(h(!0),g(W,null,le(u.value,m=>(h(),g("option",{key:m.id,value:m.alert_action},y(m.name),9,ig))),128)),u.value.length?V("",!0):(h(),g("option",lg,"a screen (not paired yet)"))],512),[[_s,s.value]])]),i("div",ag,[i("pre",rg,y(c.value),1),i("button",{type:"button",class:"btn quiet mini",id:"alerts-example-copy",onClick:w[2]||(w[2]=m=>v(dt)(c.value,void 0,"YAML"))},"Copy YAML")])]),i("section",ug,[w[23]||(w[23]=i("h2",null,"All screens at once",-1)),i("p",null,[w[16]||(w[16]=Q("Fire the event ",-1)),i("code",cg,y(t.value.broadcast?.show||"esp_screens_show_alert"),1),w[17]||(w[17]=Q(" with the same fields, and ESP Screens passes the alert on to every screen that is online, including screens you add later. ",-1)),i("code",dg,y(t.value.broadcast?.dismiss||"esp_screens_dismiss_alert"),1),w[18]||(w[18]=Q(" clears it everywhere. In an automation, add the ",-1)),w[19]||(w[19]=i("b",null,"Event",-1)),w[20]||(w[20]=Q(" action or paste the example under ",-1)),w[21]||(w[21]=i("b",null,"Edit in YAML",-1)),w[22]||(w[22]=Q(". Leave out the fields you don't use. ESP Screens has to be running for this.",-1))]),i("div",fg,[i("pre",pg,y(b.value),1),i("button",{type:"button",class:"btn quiet mini",id:"alerts-all-copy",onClick:w[3]||(w[3]=m=>v(dt)(b.value,void 0,"YAML"))},"Copy YAML")])]),i("section",hg,[w[27]||(w[27]=i("h2",null,"The fields",-1)),w[28]||(w[28]=i("p",null,"Text is free-form; the limit is in bytes, and an accented letter counts as two. What doesn't fit gets an ellipsis (title) or is dropped (subtitle).",-1)),i("div",vg,[i("table",mg,[w[26]||(w[26]=i("tr",null,[i("th",null,"Field"),i("th",null,"Type"),i("th",null,"What it does"),i("th",null,"Example"),i("th",null,"Limit")],-1)),(h(!0),g(W,null,le(t.value.fields,m=>(h(),g("tr",{key:m.name},[i("td",null,[i("code",null,y(m.name),1),i("small",null,y(m.label),1)]),i("td",null,y(R[m.type]||m.type),1),i("td",null,y(m.help),1),i("td",null,[i("code",null,y(typeof m.example=="string"?m.example:String(m.example)),1)]),i("td",null,y(t.value.limits.cyd[m.name]?`CYD ${t.value.limits.cyd[m.name]} · Guition ${t.value.limits.guition[m.name]} bytes`:m.type==="int"?"0 to 86400 s":"—"),1)]))),128)),t.value.camera?(h(),g("tr",gg,[i("td",null,[i("code",null,y(t.value.camera.name),1),i("small",null,y(t.value.camera.label),1)]),w[24]||(w[24]=i("td",null,"entity",-1)),i("td",null,y(t.value.camera.help),1),i("td",null,[i("code",null,y(t.value.camera.example),1)]),w[25]||(w[25]=i("td",null,"Guition, firmware 0.2.57+",-1))])):V("",!0)])])]),i("section",yg,[w[30]||(w[30]=i("h2",null,"Icons",-1)),w[31]||(w[31]=i("p",null,[Q("Fill in the name in "),i("code",null,"icon"),Q(", for example "),i("code",null,"doorbell"),Q(". "),i("code",null,"mdi:doorbell"),Q(" and the hex codepoint ("),i("code",null,"F12E6"),Q(") also work. Only these icons are included in the firmware; any other name shows the warning triangle. Tap an icon to copy its name.")],-1)),w[32]||(w[32]=i("p",{class:"subhead"},"Handy for notifications",-1)),i("div",bg,[(h(!0),g(W,null,le(t.value.suggested_icons,m=>(h(),g("button",{key:m.name,type:"button",class:"chip",onClick:H=>v(dt)(m.name,void 0,"Icon name")},[i("span",_g,y(v(ke)(m.cp)),1),i("code",null,y(m.name),1)],8,wg))),128))]),i("div",kg,[w[29]||(w[29]=i("label",{class:"f-label",for:"alerts-icon-search"},"Search for an icon",-1)),Te(i("input",{id:"alerts-icon-search","onUpdate:modelValue":w[4]||(w[4]=m=>o.value=m),type:"search",placeholder:"Search by name or description…",autocomplete:"off"},null,512),[[Je,o.value]])]),i("div",$g,[(h(!0),g(W,null,le(A.value,m=>(h(),g("div",{key:m.label,class:"field"},[i("p",xg,y(m.label)+" · "+y(m.icons.length),1),i("div",Sg,[(h(!0),g(W,null,le(m.icons,H=>(h(),g("button",{key:H.name,type:"button",class:"alert-icon",title:`Copy ${H.name}`,onClick:C=>v(dt)(H.name,void 0,"Icon name")},[i("span",Tg,y(v(ke)(H.cp)),1),i("code",null,y(H.name),1),H.label?(h(),g("small",Ag,y(H.label),1)):V("",!0)],8,Cg))),128))])]))),128)),A.value.length?V("",!0):(h(),g("p",Eg,'No icon for "'+y(o.value)+'". Unknown names show the warning triangle ('+y(t.value.fallback_icon)+").",1))])]),i("section",Pg,[w[34]||(w[34]=i("h2",null,"Colors",-1)),w[35]||(w[35]=i("p",null,[Q("Fill in the name in "),i("code",null,"color"),Q(". These are the pastel shades of the tiles, with dark text. Empty or unknown gives the white card. Tap to copy.")],-1)),i("div",Og,[i("button",{type:"button",class:"swatch",onClick:w[5]||(w[5]=m=>v(dt)("",void 0,"Empty color"))},[...w[33]||(w[33]=[i("i",{style:{background:"#ffffff"}},null,-1),i("span",null,[i("code",null,"empty"),i("small",null,"White (default)")],-1)])]),(h(!0),g(W,null,le(t.value.colors,m=>(h(),g("button",{key:m.name,type:"button",class:"swatch",onClick:H=>v(dt)(m.name,void 0,"Color name")},[i("i",{style:Ne({background:m.color})},null,4),i("span",null,[i("code",null,y(m.name),1),i("small",null,y(m.label)+" · "+y(m.color),1)])],8,Ig))),128))])]),w[56]||(w[56]=fs('

Behavior

  • Wakes the screen. An alert immediately wakes a dimmed or dark screen and appears above every page, every open light card, and the standby layer.
  • On until the button. As long as the card is showing, the backlight stays at normal brightness. Standby and night hours wait. After the button, the regular standby time starts again.
  • Timeout. timeout: 0 waits for the button, however long that takes. With timeout: 60 the card disappears after a minute, but the button closes it sooner.
  • Blinking. flash: true blinks the backlight four times when the alert arrives (about a second) and leaves the screen on afterward.
  • Replacing. A new alert replaces the current one; there's no stack. The replaced alert reports itself as replaced.
  • Closing remotely. dismiss_alert clears the card, for example if the door is already open. Without an alert, that action does nothing.
  • Tapping beside the card does nothing; only the button closes it. That way a wake-up tap never accidentally dismisses the alert.
',1)),i("section",Mg,[w[47]||(w[47]=i("h2",null,"Events",-1)),i("p",null,[w[36]||(w[36]=Q("Every end fires the event ",-1)),i("code",Dg,y(t.value.event),1),w[37]||(w[37]=Q(" with ",-1)),w[38]||(w[38]=i("code",null,"action",-1)),w[39]||(w[39]=Q(", ",-1)),w[40]||(w[40]=i("code",null,"title",-1)),w[41]||(w[41]=Q(", ",-1)),w[42]||(w[42]=i("code",null,"screen",-1)),w[43]||(w[43]=Q(" (the device name) and the ",-1)),w[44]||(w[44]=i("code",null,"device_id",-1)),w[45]||(w[45]=Q(" that Home Assistant adds. Visible under Settings → Tools → Events.",-1))]),i("div",Lg,[i("table",Fg,[w[46]||(w[46]=i("tr",null,[i("th",null,"action"),i("th",null,"When")],-1)),(h(!0),g(W,null,le(t.value.endings,m=>(h(),g("tr",{key:m.action},[i("td",null,[i("code",null,y(m.action),1)]),i("td",null,y(m.label),1)]))),128))])]),w[48]||(w[48]=i("p",{class:"subhead"},"Waiting until someone presses the button",-1)),i("div",Hg,[i("pre",Rg,y(f.value),1),i("button",{type:"button",class:"btn quiet mini",id:"alerts-wait-copy",onClick:w[6]||(w[6]=m=>v(dt)(f.value,void 0,"YAML"))},"Copy YAML")])]),i("section",Ng,[w[55]||(w[55]=i("h2",null,"Tips",-1)),i("ul",jg,[w[53]||(w[53]=fs("
  • Use a color per kind of notification: orange for the door, red for smoke or water, green for "done". The text always stays dark and readable.
  • Keep the title short; a screen shows about twenty characters on one line and ends a longer title with an ellipsis. Put details in the subtitle.
  • A custom button text turns the notification into a question: "Coming", "Seen", "Turn off". The event then tells you who responded.
  • For an alert that resolves itself (the door opens, the laundry gets taken out) close it in the same automation with dismiss_alert.
  • ",4)),i("li",null,[w[49]||(w[49]=Q("Every screen? Fire ",-1)),i("code",null,y(t.value.broadcast?.show||"esp_screens_show_alert"),1),w[50]||(w[50]=Q(" once (see All screens at once). The screens still work independently and each reports with its own ",-1)),w[51]||(w[51]=i("code",null,"screen",-1)),w[52]||(w[52]=Q(".",-1))]),w[54]||(w[54]=i("li",null,[Q("Rather "),i("b",null,"ask Claude"),Q(" for the automation? Install the ESP Screens skill under Settings → Claude.")],-1))])])],64)):(h(),g("p",Wm,"The cheatsheet is still loading; one moment."))]))}}),Ug={class:"panel",id:"override-dialog"},Vg={class:"panel-head"},Wg={class:"tx"},Kg={id:"override-title"},qg={class:"notice"},Yg={class:"mdi"},zg={class:"file-row"},Gg={id:"override-file"},Jg={class:"yaml-editor",id:"yaml-editor-wrap"},Xg={class:"actions"},Qg={id:"override-count",class:"hint"},Zg={class:"actions"},ey=["disabled"],ty=["disabled"],ny=`# Hardware-specific changes for this screen. +# This file is kept when the shared firmware package updates. +# Do not add esphome:, api:, ota:, wifi: or packages: here. + +# Example: a CYD with the ST7789V display controller. +# "!extend" changes the display the shared package already defines; +# a bare id would add a second, incomplete display and fail the build. +display: + - id: !extend my_display + model: ST7789V +`,sy=$e({__name:"OverrideView",setup(e){const t=k(()=>d.overrideProfile),n=fe(""),s=fe(""),o=fe(!1),l=fe("Loading…"),r=fe(""),u=fe(!1),a=fe(null),c=fe(null),f=k(()=>n.value.split(` +`).length),b=k(()=>Array.from({length:f.value},(C,L)=>L+1).join(` +`)),A=k(()=>`${n.value.length} characters · ${f.value} line${f.value===1?"":"s"}`);let D=!0;function I(C,L=""){l.value=C,r.value=L}async function R(){if(!t.value){I("No ESPHome profile was found for this screen.","error");return}I("Loading…");try{const C=await Mt(`firmware/profiles/${encodeURIComponent(t.value)}/override`);s.value=C.override_file,o.value=!!C.attached,n.value=C.exists&&C.content!==`{} +`?C.content:"",I(C.attached?"Changes here apply on the next build.":"Save once to attach this file to the profile.")}catch(C){I(C.message,"error")}}async function T(C=!1){if(!(!t.value||u.value)){u.value=!0,I("Checking and saving…");try{const L=await it(`firmware/profiles/${encodeURIComponent(t.value)}/override`,"PUT",{content:n.value});if(s.value=L.override_file,o.value=!0,!C){I("Saved. The override is kept during firmware updates.","ok"),Se("Override YAML saved.");return}I("Saved. ESPHome is checking the complete profile…"),await it("firmware/jobs","POST",{file:t.value,action:"validate"}),$()}catch(L){I(L.message,"error")}finally{u.value=!1}}}function $(){const C=Date.now(),L=t.value,pe=async()=>{if(!(!D||t.value!==L))try{const Ae=await Mt("firmware"),J=Ae.job;if(J&&J.file===L&&J.state==="running")I(`ESPHome is checking the profile… ${J.stage||""}`.trim());else if(J&&J.file===L&&J.state==="success"){I("The complete profile is valid. Safe to build and install.","ok");return}else if(J&&J.file===L&&J.state==="failed"){const q=(Ae.logs||[]).filter(ae=>/error|failed/i.test(ae)).pop();I(q||"ESPHome rejected the complete profile. See Firmware & USB for the full log.","error");return}Date.now()-C<72e5&&setTimeout(pe,1200)}catch(Ae){I(Ae.message,"error")}};pe()}function x(){(!n.value.trim()||confirm("Replace the current text with the example?"))&&(n.value=ny,I("Example loaded. Save it when you are ready."),a.value?.focus())}function w(){(!n.value.trim()||confirm("Clear the local override?"))&&(n.value="",I("The override will be empty after you save."),a.value?.focus())}function m(C){const L=a.value;if(C.key==="Tab"){C.preventDefault();const pe=L.selectionStart,Ae=L.selectionEnd;L.setRangeText(" ",pe,Ae,"end"),n.value=L.value}else(C.ctrlKey||C.metaKey)&&C.key.toLowerCase()==="s"&&(C.preventDefault(),T(!1))}function H(){c.value&&a.value&&(c.value.scrollTop=a.value.scrollTop)}return bn(R),Xn(()=>{D=!1}),(C,L)=>(h(),g("div",Ug,[i("div",Vg,[i("div",Wg,[L[4]||(L[4]=i("span",{class:"eyebrow"},"Advanced · local override",-1)),i("h1",Kg,"Override YAML"+y(v(d).overrideFriendly?` · ${v(d).overrideFriendly}`:""),1),L[5]||(L[5]=i("p",null,"This small file is yours. It is loaded after the shared screen package and stays in place when ESP Screen Manager updates. Change hardware-specific details here, such as a display model.",-1))]),i("button",{type:"button",class:"btn quiet",id:"close-override",onClick:L[0]||(L[0]=pe=>v(De)(""))},"← Back")]),i("div",qg,[i("span",Yg,y(v(ke)("F0493")),1),L[6]||(L[6]=i("div",null,[i("strong",null,"What is safe to change?"),i("span",null,"Display, touchscreen, substitutions, pins and other device details. Name, Wi-Fi, API, OTA and package links remain managed.")],-1))]),i("div",zg,[i("code",Gg,y(s.value||t.value||""),1),i("span",{id:"override-state",class:re(["chip",{good:o.value}])},y(o.value?"Active":"Ready to attach"),3)]),i("div",Jg,[i("div",{ref_key:"gutter",ref:c,class:"yaml-gutter",id:"override-gutter","aria-hidden":"true"},y(b.value),513),Te(i("textarea",{ref_key:"editor",ref:a,id:"override-editor","onUpdate:modelValue":L[1]||(L[1]=pe=>n.value=pe),spellcheck:"false",autocapitalize:"off",autocomplete:"off",autocorrect:"off","aria-label":"Local override YAML",placeholder:`# Example +display: + - id: !extend my_display + model: ST7789V`,onKeydown:m,onScroll:H},null,544),[[Je,n.value]])]),i("div",Xg,[i("button",{type:"button",class:"btn quiet mini",id:"override-example",onClick:x},"Use example"),i("button",{type:"button",class:"btn quiet mini",id:"override-empty",onClick:w},"Clear"),i("span",Qg,y(A.value),1)]),i("p",{id:"override-status",class:re(["status-line",r.value]),role:"status"},y(l.value),3),i("div",Zg,[i("button",{type:"button",class:"btn quiet",id:"override-check",disabled:u.value||!t.value,onClick:L[2]||(L[2]=pe=>T(!0))},"Save & check",8,ey),i("button",{type:"button",class:"btn primary",id:"override-save",disabled:u.value||!t.value,onClick:L[3]||(L[3]=pe=>T(!1))},"Save override",8,ty)])]))}}),oy={class:"main"},iy=$e({__name:"App",setup(e){const t=k(()=>tt.value==="#settings"?Ov:tt.value==="#new-screen"?ym:tt.value==="#firmware"?Bm:tt.value==="#alerts"?Bg:tt.value==="#override"?sy:qt.value&&d.layout?fv:vv);return(n,s)=>(h(),g("div",{class:re(["app",{dragging:v(d).drag.active}])},[_e(zc),i("main",oy,[(h(),Ve(Sr(t.value)))]),_e(Jc)],2))}});Uu(iy).mount("#app");Ac(); diff --git a/screen_manager/app/static/tile-icons.woff b/screen_manager/app/static/assets/tile-icons-BawP5y9D.woff similarity index 100% rename from screen_manager/app/static/tile-icons.woff rename to screen_manager/app/static/assets/tile-icons-BawP5y9D.woff diff --git a/screen_manager/app/static/index.html b/screen_manager/app/static/index.html index 9de9e8d..03b82be 100644 --- a/screen_manager/app/static/index.html +++ b/screen_manager/app/static/index.html @@ -3,609 +3,12 @@ + ESP Screens - - + + -
    -
    - ▦ -
    - ESP ScreensTiles for your CYD and Guition, without reflashing -
    -
    - -
    -
    -
    -
    -
    -

    HOME ASSISTANT

    -

    Manage your remote screens here.

    -
    - Connecting… -
    -
    - - - - - -
    -
    - -
    - -
    -
    -
    -

    NEW SCREEN

    -

    Connect and install.

    -
    - -
    -
    -

    - ESP Screens creates the screen's own profile with unique keys and - builds the firmware. Connect the screen with a USB data cable to the - machine running Home Assistant, or download the firmware and put it - on the screen from your own computer. -

    -
    - Which screen do you have? - -
    - - - - -

    -
    - - -
    -
    - -
    -
    - -
    -
    -

    FIRMWARE & USB

    -

    Build and install screen firmware

    -
    - -
    -

    - The built-in ESPHome CLI uses your own profile (YAML with keys) - and secrets. Choose the screen's profile, then a USB port (screen - connected to the Home Assistant machine), Wi-Fi / OTA with the hostname, - or Download to put the firmware on the screen from your own computer. - Check only validates, Build only compiles, - Build & install puts the firmware on the screen. Your tiles - stay as they are in ESP Screens. The first build takes a few minutes. -

    - - - -
    - -
    -

    - -
    
    -    
    - -
    -
    -

    ADVANCED · LOCAL OVERRIDE

    -

    Override YAML

    -
    - -
    -

    - This small file is yours. It is loaded after the shared screen package and stays in place when - ESP Screen Manager updates. Change hardware-specific details here, such as a display model. -

    -
    - -
    - What is safe to change? - Display, touchscreen, substitutions, pins and other device details. Name, Wi-Fi, API, OTA and package links remain managed. -
    -
    -
    - - -
    -
    - - -
    -
    - - - -
    -

    -
    - - -
    -
    - - - -
    -
    -

    ALERTS · CHEATSHEET

    -

    A full-screen notification, from an automation.

    -
    - -
    -

    - Every screen has the show_alert action. It places a card over everything, - wakes the screen, and keeps the backlight at normal brightness until someone taps - the button or the timeout runs out. Home Assistant itself shows no explanation or - dropdowns for ESPHome actions; everything you can fill in is listed here. -

    -
    - - -
    -
    -

    Your screens

    -

    - Every screen has its own action, with the device name in it. From firmware - 0.2.31; update an older screen with its - Update button under My screens. -

    -
    -
    -
    -

    In an automation

    -
      -
    1. Add action and search for ESPHome. Choose your screen's action: - Home Assistant calls it "Performs the action show_alert of the node - device name".
    2. -
    3. Fill in the fields. Home Assistant asks for all seven; leave what you don't - use empty ("", 0, off).
    4. -
    5. Prefer YAML? Choose Edit in YAML on the action and paste the example - below.
    6. -
    -
    - -
    -
    
    -            
    -          
    -
    -
    -
    -

    All screens at once

    -

    - Fire the event esp_screens_show_alert with the same - fields, and ESP Screens passes the alert on to every screen that is online, including - screens you add later. esp_screens_dismiss_alert - clears it everywhere. In an automation, add the Event action or paste the example - under Edit in YAML. Leave out the fields you don't use. ESP Screens has to be - running for this. -

    -
    -
    
    -          
    -        
    -
    -
    -

    The fields

    -

    - Text is free-form; the limit is in bytes, and an accented letter counts as two. - What doesn't fit gets an ellipsis (title) or is dropped (subtitle). -

    -
    -
    -
    -

    Icons

    -

    - Fill in the name in icon, for example doorbell. - mdi:doorbell and the hex codepoint (F12E6) also work. - Only these icons are included in the firmware; any other name shows the - warning triangle. Tap an icon to copy its name. -

    -

    Handy for notifications

    -
    - -
    -
    -
    -

    Colors

    -

    - Fill in the name in color. These are the pastel shades of the tiles, - with dark text. Empty or unknown gives the white card. Tap to copy. -

    -
    -
    -
    -

    Behavior

    -
      -
    • Wakes the screen. An alert immediately wakes a dimmed or dark screen and - appears above every page, every open light card, and the standby layer.
    • -
    • On until the button. As long as the card is showing, the backlight stays at - normal brightness. Standby and night hours wait. After the button, the regular - standby time starts again.
    • -
    • Timeout. timeout: 0 waits for the button, however long that - takes. With timeout: 60 the card disappears after a minute, but the - button closes it sooner.
    • -
    • Blinking. flash: true blinks the backlight four times - when the alert arrives (about a second) and leaves the screen on afterward.
    • -
    • Replacing. A new alert replaces the current one; there's no stack. - The replaced alert reports itself as replaced.
    • -
    • Closing remotely. dismiss_alert clears the card, - for example if the door is already open. Without an alert, that action does nothing.
    • -
    • Tapping beside the card does nothing; only the button closes it. That way a - wake-up tap never accidentally dismisses the alert.
    • -
    -
    -
    -

    Events

    -

    - Every end fires the event esphome.screen_alert - with action, title, screen (the device name) - and the device_id that Home Assistant adds. Visible under - Settings → Tools → Events. -

    -
    -

    Waiting until someone presses the button

    -
    -
    
    -          
    -        
    -
    -
    -

    Tips

    -
      -
    • Use a color per kind of notification: orange for the door, red for - smoke or water, green for "done". The text always stays dark and readable.
    • -
    • Keep the title short; a screen shows about twenty characters on one line and - ends a longer title with an ellipsis. Put details in the subtitle.
    • -
    • A custom button text turns the notification into a question: "Coming", "Seen", - "Turn off". The event then tells you who responded.
    • -
    • For an alert that resolves itself (the door opens, the laundry gets taken out) - close it in the same automation with dismiss_alert.
    • -
    • Every screen? Fire esp_screens_show_alert once (see All screens at - once). The screens still work independently and each reports with its own - screen.
    • -
    • Rather ask Claude for the automation? Install the ESP Screens skill under - Settings → Claude.
    • -
    -
    -
    - +
    diff --git a/screen_manager/app/static/style.css b/screen_manager/app/static/style.css deleted file mode 100644 index d774e40..0000000 --- a/screen_manager/app/static/style.css +++ /dev/null @@ -1,1541 +0,0 @@ -:root { - color-scheme: light; - --bg: #f5f6f8; - --ink: #202c3c; - --muted: #697689; - --line: #e1e6ed; - --accent: #245cde; - --soft: #edf3ff; - font: - 15px/1.5 system-ui, - -apple-system, - sans-serif; -} -* { - box-sizing: border-box; -} -body { - margin: 0; - background: var(--bg); - color: var(--ink); -} -button, -input { - font: inherit; -} -button { - border: 0; - border-radius: 11px; - padding: 11px 18px; - background: var(--accent); - color: white; - font-weight: 600; - cursor: pointer; -} -button:hover { - filter: brightness(0.95); -} -button:disabled { - opacity: 0.5; - cursor: default; -} -button:focus-visible, -input:focus-visible, -summary:focus-visible { - outline: 3px solid #83b5ff; - outline-offset: 3px; -} -input { - border: 1px solid #cdd5df; - border-radius: 10px; - padding: 11px 12px; - width: 100%; - background: white; - color: var(--ink); -} -label { - display: block; - font-weight: 600; -} -label input { - margin-top: 7px; -} -small, -.hint { - display: block; - color: var(--muted); - font-size: 13px; - font-weight: 400; -} -header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 21px 4vw; - background: white; - border-bottom: 1px solid var(--line); - gap: 20px; -} -.brand { - display: flex; - align-items: center; - gap: 12px; -} -.brand strong { - font-size: 19px; -} -.mark { - width: 44px; - height: 44px; - background: var(--ink); - color: white; - border-radius: 12px; - display: grid; - place-items: center; - font-size: 30px; -} -main { - max-width: 1440px; - margin: auto; - padding: 38px 4vw; -} -.intro { - display: flex; - justify-content: space-between; - align-items: center; - gap: 20px; - margin-bottom: 30px; -} -h1 { - font-size: clamp(25px, 3vw, 36px); - letter-spacing: -1.1px; - margin: 4px 0 8px; -} -h2 { - font-size: 21px; - letter-spacing: -0.4px; - margin: 0 0 14px; -} -h3 { - font-size: 16px; - margin: 0; -} -p { - margin: 8px 0 16px; -} -.intro p:not(.eyebrow) { - color: var(--muted); -} -.eyebrow { - font-size: 11px; - font-weight: 750; - letter-spacing: 1.6px; - color: var(--muted); - margin: 0 0 7px; -} -.badge { - display: inline-block; - border: 1px solid var(--line); - border-radius: 30px; - padding: 6px 11px; - font-size: 12px; - white-space: normal; - background: white; - color: var(--muted); -} -.badge.online { - background: #e9f6ef; - color: #21704b; - border-color: #d6eede; -} -.workspace { - display: grid; - grid-template-columns: 210px minmax(0, 1fr); - gap: 28px; -} -aside { - padding-top: 10px; -} -aside h2 { - font-size: 14px; -} -#screens { - display: grid; - gap: 8px; - margin-bottom: 18px; -} -.screen { - border: 1px solid transparent; - border-radius: 14px; - background: #e9ecf1; -} -.screen:hover:not(.selected) { - background: #e2e6ec; -} -.screen.selected { - background: white; - border-color: var(--line); - box-shadow: 0 3px 10px #202c3c05; -} -.screen-main { - display: block; - width: 100%; - text-align: left; - background: transparent; - color: var(--ink); - border: 0; - padding: 14px; -} -.screen-main small { - margin-top: 3px; -} -.screen-main .dot.online { - color: #2e9d62; -} -.screen-override { - padding: 0 14px 12px; -} -.screen-override button { - width: 100%; -} -.screen-update { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 8px; - padding: 0 14px 12px; -} -.screen-update small.failed { - color: #b23b3b; -} -.badge.update { - background: #fff4e0; - color: #8a5a10; - border-color: #f4e3c2; - padding: 4px 9px; -} -.mini { - padding: 5px 11px; - font-size: 12px; - border-radius: 20px; -} -.screen-host { - display: flex; - gap: 6px; - width: 100%; -} -.screen-host input { - flex: 1; - min-width: 0; - padding: 6px 9px; - font-size: 12px; -} -.spin { - width: 13px; - height: 13px; - flex: none; - border: 2px solid var(--line); - border-top-color: var(--ink); - border-radius: 50%; - animation: spin 0.8s linear infinite; -} -@keyframes spin { - to { - transform: rotate(360deg); - } -} -/* Settings: a view of its own next to My screens (app 0.2.45). */ -.settings-groups { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 18px; -} -@media (max-width: 800px) { - .settings-groups { - grid-template-columns: 1fr; - } -} -.settings-group { - display: grid; - gap: 12px; - align-content: start; - background: white; - border: 1px solid var(--line); - border-radius: 18px; - padding: 20px 22px; -} -.settings-group h2 { - margin: 0; -} -.settings-group > .hint, -.settings-group > p { - margin: 0; -} -.settings-group code { - background: var(--soft); - padding: 1px 6px; - border-radius: 6px; - font-size: 12px; - overflow-wrap: anywhere; -} -.settings-tools { - display: flex; - flex-wrap: wrap; - gap: 10px; -} -.updates #update-all { - justify-self: start; -} -.claude-skill { - display: flex; - align-items: center; - gap: 12px; - flex-wrap: wrap; -} -a.button { - display: inline-block; - border-radius: 11px; - padding: 11px 18px; - font-weight: 600; - text-decoration: none; -} -.again { - background: white; - color: var(--ink); - border: 1px solid var(--line); -} -.again:hover { - border-color: #b9cbec; - filter: none; -} -.check { - display: flex; - gap: 9px; - align-items: flex-start; - cursor: pointer; -} -.check input { - width: 16px; - height: 16px; - flex: none; - margin: 2px 0 0; - padding: 0; - accent-color: var(--accent, #2e5bff); -} -.check small { - display: block; - margin-top: 2px; - color: var(--muted); -} -.quiet { - background: transparent; - color: var(--muted); - padding: 7px; -} -.toolbar { - display: flex; - gap: 10px; - flex-wrap: wrap; -} -.tool { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 14px 8px 9px; - border: 1px solid var(--line); - border-radius: 14px; - background: white; - color: var(--ink); - text-align: left; -} -.tool:hover { - border-color: #b9cbec; - box-shadow: 0 3px 10px #202c3c0c; -} -.tool-icon { - width: 34px; - height: 34px; - border-radius: 10px; - background: var(--soft); - color: var(--accent); - display: grid; - place-items: center; - font-size: 19px; - font-weight: 700; - flex: none; -} -.tool-primary .tool-icon { - background: var(--accent); - color: white; -} -.tool-text b { - display: block; - font-size: 13px; -} -.tool-text small { - color: var(--muted); - font-size: 11px; -} -.preview-tile, -.result { - touch-action: pan-y; - cursor: grab; -} -.drag-ghost { - position: fixed; - left: 0; - top: 0; - z-index: 1000; - pointer-events: none; - opacity: 0.92; - box-shadow: 0 14px 32px #202c3c33; - background: white; - margin: 0; -} -body.dragging { - cursor: grabbing; - user-select: none; - -webkit-user-select: none; -} -/* Dragging a card must never start a text selection; the search box stays editable. */ -.tile-editor, -.picker #results { - user-select: none; - -webkit-user-select: none; -} -#editor, -.empty { - background: white; - border: 1px solid var(--line); - border-radius: 18px; - padding: 27px; - min-width: 0; - box-shadow: 0 5px 20px #202c3c03; -} -.editor-head { - display: flex; - justify-content: space-between; - gap: 12px; - align-items: start; - margin-bottom: 15px; -} -.editor-head-actions { - display: flex; - align-items: center; - justify-content: end; - gap: 8px; - flex-wrap: wrap; -} -.editor-head .badge { - max-width: 240px; -} -.columns { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 25px; - margin-top: 30px; -} -.section-title { - display: flex; - justify-content: space-between; -} -.section-title span { - font-size: 12px; - color: var(--muted); -} -#tiles { - padding: 0; - list-style: none; - margin: 18px 0; -} -.tile { - border: 1px solid var(--line); - border-radius: 12px; - padding: 12px; - margin-bottom: 10px; - background: #fcfcfd; - display: grid; - grid-template-columns: 22px minmax(0, 1fr) auto; - gap: 8px; - align-items: center; -} -.tile.dragging { - opacity: 0.35; -} -.grip { - color: #9aa7b8; - cursor: grab; - font-size: 19px; -} -.tile input { - font-size: 14px; - padding: 5px 6px; - margin: 0; - background: transparent; - border-color: transparent; -} -.tile input:focus { - border-color: #aac4fa; - background: white; -} -.tile small { - font-size: 11px; - overflow-wrap: anywhere; - padding-left: 6px; -} -.tile-actions { - display: flex; - flex-direction: column; -} -.tile-actions button { - padding: 1px 6px; - font-size: 12px; - line-height: 1.6; - background: transparent; - color: var(--muted); -} -.page-break { - font-size: 11px; - color: var(--muted); - text-transform: uppercase; - letter-spacing: 1px; - margin: 18px 0 9px; -} -.picker { - background: #f7f9fc; - padding: 18px; - border-radius: 13px; - align-self: start; -} -.picker label { - font-size: 14px; -} -.filters { - display: flex; - gap: 5px; - flex-wrap: wrap; - margin: 13px 0; -} -.filters button { - font-size: 11px; - padding: 5px 9px; - background: white; - color: var(--muted); - border: 1px solid var(--line); - border-radius: 20px; -} -.filters button.active { - background: var(--soft); - color: var(--accent); - border-color: #c9dafa; -} -#results { - max-height: 450px; - overflow: auto; -} -.result { - width: 100%; - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - text-align: left; - background: transparent; - color: var(--ink); - padding: 11px 5px; - border-bottom: 1px solid var(--line); - border-radius: 0; - font-size: 13px; - font-weight: 500; -} -.result small { - font-size: 11px; - overflow-wrap: anywhere; -} -.result .plus { - color: var(--accent); - font-size: 22px; -} -.savebar { - position: sticky; - bottom: 0; - z-index: 6; - margin: 25px -27px -27px; - padding: 16px 27px; - background: #ffffffee; - backdrop-filter: blur(6px); - border-top: 1px solid var(--line); - border-radius: 0 0 18px 18px; - display: flex; - justify-content: space-between; - align-items: center; - gap: 15px; -} -.savebar strong { - font-size: 13px; -} -.empty { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - min-height: 400px; - text-align: center; -} -.empty p { - max-width: 400px; - color: var(--muted); -} -.empty-icon { - font-size: 60px; - color: #9cafd0; -} -dialog { - border: 1px solid var(--line); - border-radius: 20px; - max-width: 620px; - width: calc(100% - 28px); - padding: 28px; - color: var(--ink); - max-height: 90vh; -} -dialog::backdrop { - background: #17223280; - backdrop-filter: blur(3px); -} -.dialog-head { - display: flex; - align-items: start; - justify-content: space-between; - gap: 15px; -} -dialog p { - color: var(--muted); - font-size: 14px; -} -fieldset { - border: 0; - padding: 0; - margin: 23px 0; - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; -} -legend { - font-weight: 600; - margin-bottom: 9px; -} -.board { - border: 1px solid var(--line); - border-radius: 12px; - padding: 13px; - display: flex; - align-items: center; - gap: 9px; - font-size: 13px; -} -.board:has(input:checked) { - background: var(--soft); - border-color: #9bb9fa; -} -.board input { - width: auto; - margin: 0; -} -.board small { - font-size: 11px; -} -dialog label { - margin-bottom: 15px; - font-size: 14px; -} -.steps { - padding-left: 20px; - font-size: 13px; - color: var(--muted); -} -.steps li { - padding-left: 4px; - margin-bottom: 12px; -} -.steps b { - color: var(--ink); -} -#toast { - position: fixed; - bottom: 24px; - left: 50%; - transform: translateX(-50%); - display: flex; - align-items: center; - gap: 16px; - padding: 14px 22px; - background: var(--ink); - color: white; - border-radius: 13px; - box-shadow: 0 5px 30px #0003; - max-width: 90vw; - z-index: 10; -} -[hidden] { - display: none !important; -} -@media (max-width: 1000px) { - .workspace { - grid-template-columns: 160px minmax(0, 1fr); - gap: 18px; - } - .columns { - grid-template-columns: 1fr; - } - .editor-head { - display: block; - } - .editor-head-actions { - justify-content: start; - margin-top: 10px; - } - .editor-head .badge { - margin-bottom: 10px; - } -} -@media (max-width: 620px) { - main { - padding: 25px 16px; - } - .tool-text small { - display: none; - } - .savebar { - margin: 25px -18px -18px; - padding: 14px 18px; - } - .workspace { - display: block; - } - aside { - margin-bottom: 18px; - } - #screens { - display: flex; - overflow-x: auto; - } - .screen { - min-width: 160px; - } - aside > .hint { - display: none; - } - .intro { - display: block; - } - .intro > .badge { - margin-top: 8px; - } - #editor { - padding: 18px; - } - /* The screens sit right above it on a phone: a short prompt, not a tall empty card. */ - #choose { - min-height: 0; - padding: 18px; - } - #choose .empty-icon { - display: none; - } - .savebar { - align-items: stretch; - flex-direction: column; - } - header { - padding: 15px; - } - .brand small { - display: none; - } - header button { - font-size: 12px; - } - fieldset { - grid-template-columns: 1fr; - } -} - -textarea { - font: - 12px/1.5 ui-monospace, - monospace; - width: 100%; - resize: vertical; - border: 1px solid var(--line); - border-radius: 10px; - padding: 12px; - margin-top: 8px; - background: #f7f9fc; - color: var(--ink); -} -.yaml-actions { - display: flex; - gap: 10px; - margin-bottom: 20px; -} - -.screen-settings { - margin: 28px 0 20px; - border-top: 1px solid var(--line); - padding-top: 20px; -} -.screen-settings summary { - cursor: pointer; - font-size: 18px; - font-weight: 700; -} -/* Screen settings: the groups and rows of the settings page on the screen itself (app 0.2.57). */ -.settings-title { - align-items: baseline; - flex-wrap: wrap; - gap: 4px 16px; -} -.settings-title h3 { - margin: 0; - font-size: 18px; -} -/* Columns, not a grid: the cards fill them from the top, so a short group leaves no hole beside a long one. */ -.settings-cards { - column-width: 290px; - column-gap: 14px; - margin: 16px 0 4px; -} -.settings-card { - break-inside: avoid; - margin-bottom: 14px; - border: 1px solid var(--line); - border-radius: 16px; - background: white; - padding: 14px 16px 8px; -} -.settings-card h4 { - display: flex; - align-items: center; - gap: 8px; - margin: 0 0 6px; - font-size: 15px; -} -.settings-card h4 .mdi { - font-size: 20px; - color: var(--muted); -} -.setting-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - min-height: 48px; - padding: 4px 0; - font-size: 14px; -} -.setting-toggle { - cursor: pointer; -} -.setting-label { - min-width: 0; - overflow-wrap: anywhere; -} -.setting-control { - display: flex; - align-items: center; - gap: 6px; - flex: none; -} -.setting-row.inactive .setting-label, -.setting-row.inactive .setting-control { - opacity: 0.45; -} -#general-settings.offline .settings-cards { - opacity: 0.6; -} -.switch { - position: relative; - width: 46px; - height: 28px; - padding: 0; - border-radius: 14px; - background: #c9d0d9; - transition: background 0.15s; -} -.switch::after { - content: ""; - position: absolute; - top: 3px; - left: 3px; - width: 22px; - height: 22px; - border-radius: 50%; - background: white; - box-shadow: 0 1px 2px #17223233; - transition: transform 0.15s; -} -.switch[aria-checked="true"] { - background: var(--accent); -} -.switch[aria-checked="true"]::after { - transform: translateX(18px); -} -/* Neither on nor off: Home Assistant has no value (the screen is offline, or the entity is off). */ -.switch.unknown::after { - transform: translateX(9px); -} -.step { - display: grid; - place-items: center; - width: 34px; - height: 34px; - padding: 0; - border-radius: 50%; - background: #eef2f7; - color: var(--ink); - touch-action: manipulation; - user-select: none; -} -.step .mdi { - font-size: 18px; -} -.step:hover:not(:disabled) { - filter: none; - background: #e1e8f1; -} -.step:active:not(:disabled) { - background: var(--soft); - color: var(--accent); -} -.setting-value { - min-width: 72px; - text-align: center; - font-weight: 600; - font-variant-numeric: tabular-nums; - white-space: nowrap; -} -.setting-choices.segmented { - flex-wrap: nowrap; - gap: 4px; -} -.setting-choices.segmented button { - padding: 6px 10px; -} -/* An end of a range greys its key; a row that waits on its switch fades as a whole instead. */ -.setting-row .step:disabled { - opacity: 0.35; -} -.setting-row.inactive button:disabled { - opacity: 1; -} -@media (prefers-reduced-motion: reduce) { - .switch, - .switch::after { - transition: none; - } -} - -.tile-options { - margin-top: 10px; - font-size: 13px; -} -.tile-options label { - display: block; - margin: 8px 0; -} -select { - width: 100%; - padding: 10px; - border: 1px solid #d8dfe5; - border-radius: 9px; - background: white; - color: #152331; - font: inherit; -} -pre { - white-space: pre-wrap; - overflow-wrap: anywhere; - max-height: 340px; - overflow: auto; - background: #f0f3f6; - border-radius: 12px; - padding: 16px; - font-size: 12px; -} -#firmware-dialog { - max-width: 780px; - width: 90vw; -} -.override-dialog { - max-width: 820px; - width: 92vw; - padding: 26px; -} -.override-intro { - max-width: 700px; - margin-bottom: 14px; -} -.override-notice { - display: flex; - align-items: flex-start; - gap: 11px; - padding: 12px 14px; - border: 1px solid #d8e8ff; - border-radius: 13px; - background: #f4f8ff; - color: #34506e; - font-size: 13px; -} -.override-notice strong, -.override-notice span { - display: block; -} -.override-notice strong { - color: #233c5a; - margin-bottom: 2px; -} -.override-icon { - display: grid; - place-items: center; - width: 25px; - height: 25px; - flex: 0 0 25px; - border-radius: 8px; - background: #dceaff; - color: #286cc2; - font-weight: 750; -} -.override-file-row { - display: flex; - align-items: center; - gap: 10px; - margin: 18px 0 8px; -} -.override-file { - font: 600 13px ui-monospace, SFMono-Regular, Menlo, monospace; - color: var(--ink); - overflow-wrap: anywhere; -} -.yaml-editor { - display: grid; - grid-template-columns: 43px minmax(0, 1fr); - min-height: 340px; - border: 1px solid #bac9dc; - border-radius: 14px; - overflow: hidden; - background: #142131; - box-shadow: 0 8px 24px #1d2d4014; -} -.yaml-gutter { - padding: 15px 10px 15px 0; - overflow: hidden; - background: #1b2b3e; - color: #71849b; - font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; - text-align: right; - user-select: none; - white-space: pre; -} -#override-editor { - display: block; - width: 100%; - min-height: 340px; - resize: vertical; - border: 0; - border-left: 1px solid #2e4158; - border-radius: 0; - outline: 0; - padding: 15px; - background: #142131; - color: #eaf2fc; - caret-color: #8ec5ff; - font: 13px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; - tab-size: 2; - white-space: pre; -} -#override-editor::selection { - background: #3978bd80; -} -.override-tools { - display: flex; - align-items: center; - gap: 8px; - margin-top: 9px; -} -.override-tools .hint { - margin-left: auto; -} -.override-status { - min-height: 20px; - margin: 12px 0 0; - font-size: 13px; -} -.override-status.ok { - color: #21704b; -} -.override-status.error { - color: #a53f3f; -} -.override-actions { - justify-content: flex-end; - margin-top: 8px; -} -.tile-actions { - align-self: start; -} -.tile .tile-options input, -.tile .tile-options select { - cursor: auto; -} - -.tile.selected-tile { - border-color: #009fe3; - background: #f0f9ff; -} -.tile-options summary { - cursor: pointer; - color: #007fb6; - font-weight: 650; - padding: 10px 0; -} -.tile-options > button { - margin-top: 12px; -} -.inspection-tile { - padding: 14px; - margin: 10px 0; - border: 1px solid #d8e3ed; - border-radius: 12px; - overflow-wrap: anywhere; -} -#general-settings, -#inspector-section, -#tile-section { - scroll-margin-top: 16px; -} - -/* The picker shares the screen's two-column, six-tile page structure. */ -.domain-icon { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; flex: 0 0 36px; border-radius: 50%; font-size: 22px; font-weight: 500; } -.tile { grid-template-columns: 36px minmax(0, 1fr) auto; } -.result { border: 1px solid var(--line); border-radius: 14px; background: white; margin-bottom: 8px; padding: 12px; } -.result > span:nth-child(2) { flex: 1; min-width: 0; } -.result strong { display: block; } -.result .domain-label { display: inline-block; margin: 3px 0; color: var(--muted); } -.filters button { display: inline-flex; align-items: center; gap: 5px; } -.filters .domain-icon { width: 20px; height: 20px; flex-basis: 20px; font-size: 15px; } -.screen-preview { background: #e7e7e7; border: 1px solid #d9dde2; border-radius: 22px; padding: 14px; margin-top: 14px; } -.preview-heading { display: flex; align-items: center; gap: 8px; color: #49515a; margin: 0 2px 12px; } -.preview-heading .page-note { color: var(--muted); } -.preview-heading .mini { margin-left: auto; padding: 3px 10px; border-radius: 8px; font-size: 12px; font-weight: 600; background: white; color: var(--ink); border: 1px solid var(--line); } -.preview-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; } -.preview-tile { background: white; color: #25282c; border: 1px solid #ddd; border-radius: 15px; padding: 12px; min-height: 90px; text-align: left; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } -.preview-tile strong { flex: 1; min-width: 0; font-size: 13px; overflow-wrap: anywhere; } -.preview-tile .domain-icon { width: 30px; height: 30px; flex-basis: 30px; font-size: 20px; } -.preview-tile.chosen { outline: 2px solid var(--accent); outline-offset: 1px; } -.preview-tile.wide { grid-column: span 2; } -/* Empty cells are real places on the screen: a drop target, and a click marks where the next tile goes. */ -.preview-cell { min-height: 90px; padding: 8px; border: 1px dashed #cfd4da; border-radius: 15px; background: transparent; color: #a3abb5; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; font-weight: 500; cursor: pointer; } -.preview-cell span { font-size: 20px; line-height: 1; } -.preview-cell small { font-size: 11px; } -.preview-cell:hover, .preview-cell:focus-visible { border-color: #9eb6cf; color: #4b5563; background: #fbfcfd; } -.preview-cell.insert-here { border: 2px dashed var(--accent); color: var(--accent); background: var(--soft); } -/* The dragged tile, drawn where it will land; the drop confirms exactly this. */ -.preview-tile.placeholder { opacity: 0.6; border: 2px dashed var(--accent); background: var(--soft); box-shadow: none; cursor: default; } -.preview-tile.placeholder .preview-remove { display: none; } -.screen-preview.new-page { background: transparent; border: 2px dashed #cfd4da; } -.add-page { margin-top: 12px; width: 100%; border: 1px dashed #cfd4da; background: transparent; color: var(--muted); } -.add-page:hover:not(:disabled) { border-color: #9eb6cf; color: var(--ink); background: #fbfcfd; } -.screen-preview.new-page .preview-cell { border-color: #e3e7ec; } -.preview-slider { height: 13px; flex-basis: 100%; border-radius: 10px; background: linear-gradient(to right, #ffbf38 60%, #fff1d3 60%); } -/* Miniature direct controls on a wide mockup card, in the shapes the screen draws. */ -.preview-controls { display: flex; align-items: center; gap: 4px; flex: none; margin-right: 18px; color: #1b1b1b; } -.preview-key { display: grid; place-items: center; width: 28px; height: 22px; border-radius: 7px; background: #eceef1; font-size: 14px; } -.preview-stepper { display: flex; align-items: center; gap: 4px; height: 22px; padding: 0 7px; border-radius: 11px; background: #eceef1; font-size: 12px; } -.preview-stepper b { font-size: 12px; font-weight: 600; } -.preview-range { width: 60px; height: 20px; border-radius: 10px; background: linear-gradient(to right, #2196f3 35%, #d3e8fb 35%); } -.preview-toggle { position: relative; width: 34px; height: 20px; border-radius: 10px; background: #2196f3; } -.preview-toggle::after { content: ""; position: absolute; top: 3px; right: 3px; width: 14px; height: 14px; border-radius: 50%; background: white; } -.preview-run { height: 22px; padding: 0 10px; border-radius: 11px; background: #eceef1; font-size: 12px; font-weight: 600; line-height: 22px; } -.field-hint { font-weight: 400; } -.sheet-hinted { display: grid; gap: 7px; font-size: 12px; color: var(--muted); } -.field-hint.warn { color: #9a6200; } -.preview-tile small { flex-basis: 100%; } -.result:hover:not(:disabled), .preview-tile:hover { border-color: #9eb6cf; box-shadow: 0 2px 7px #253f5010; } - -#tiles > .tile:not(.selected-tile), #tiles > .page-break { display: none; } -#tiles > .selected-tile { background: #f8fafc; border-color: #aac4df; } -.picker { position: sticky; top: 16px; } -@media (max-width: 800px) { .picker { position: static; } } - -.tile-palette { display: block; min-width: 0; margin: 12px 0; padding: 12px; border: 1px solid #d8dfe5; border-radius: 12px; } -.tile-palette legend { font-weight: 650; padding: 0 5px; } -.palette-swatches { display: grid; grid-template-columns: repeat(auto-fit, minmax(66px, 1fr)); gap: 8px; margin-bottom: 10px; } -.palette-choice { display: flex; flex-direction: column; align-items: center; gap: 5px; padding: 6px; color: #1b1b1b; background: white; border: 2px solid transparent; border-radius: 10px; font-size: 12px; } -.palette-choice[aria-pressed="true"] { border-color: #1674ae; background: #edf6ff; } -.palette-choice:focus-visible { outline: 3px solid #1674ae; outline-offset: 2px; } -.palette-sample { width: 36px; height: 28px; border-radius: 7px; border: 1px solid #0002; } -.palette-auto { background: linear-gradient(135deg, #fff 50%, #e5e7eb 50%); } -.palette-none { background: linear-gradient(135deg, transparent 46%, #8b95a1 46%, #8b95a1 54%, transparent 54%), #e7e7e7; border-style: dashed; } -.preview-tile.bare { background: transparent; border: 1px dashed #c3c9d0; } -.tile-palette small { color: #52616e; } - -/* Tile settings sheet and in-preview removal. */ -#toast button { background: transparent; color: #9fc4ff; padding: 0; font-weight: 650; } -.preview-tile { position: relative; } -.preview-remove { position: absolute; top: 6px; right: 6px; width: 24px; height: 24px; padding: 0; border-radius: 50%; background: #ffffffd9; color: var(--muted); border: 1px solid var(--line); font-size: 11px; line-height: 1; display: grid; place-items: center; opacity: 0; transition: opacity 0.15s; } -.preview-tile:hover .preview-remove, .preview-tile:focus-within .preview-remove { opacity: 1; } -.preview-remove:hover { color: #b3261e; border-color: #e7b4b0; background: white; } -@media (hover: none) { .preview-remove { opacity: 1; } } -.tile-sheet { padding: 0; width: min(560px, calc(100% - 28px)); overflow: hidden; } -.sheet-head { display: flex; align-items: center; gap: 12px; padding: 16px 20px; border-bottom: 1px solid var(--line); } -.sheet-head > div { flex: 1; min-width: 0; } -.sheet-head strong { display: block; font-size: 16px; } -.sheet-head small { color: var(--muted); overflow-wrap: anywhere; } -.sheet-close { font-size: 15px; } -.sheet-body { padding: 16px 20px; display: grid; gap: 14px; max-height: 58vh; overflow: auto; } -.sheet-field { display: grid; gap: 7px; font-size: 12px; font-weight: 650; color: var(--muted); } -.sheet-field input { font-weight: 400; font-size: 14px; } -.segmented { display: flex; flex-wrap: wrap; gap: 6px; } -.segmented button { background: #f3f5f8; color: var(--ink); border: 1px solid transparent; border-radius: 10px; padding: 8px 12px; font-size: 13px; font-weight: 500; } -.segmented button[aria-pressed="true"] { background: var(--soft); color: var(--accent); border-color: #c9dafa; font-weight: 650; } -.sheet-foot { display: flex; gap: 10px; align-items: center; padding: 14px 20px; border-top: 1px solid var(--line); background: #fafbfc; } -.sheet-foot .danger { color: #b3261e; margin-right: auto; } -@media (max-width: 620px) { - .tile-sheet { width: 100%; max-width: 100%; margin: auto 0 0; border-radius: 20px 20px 0 0; max-height: 90vh; } - .sheet-body { max-height: 52vh; } -} - -/* Tile icons: the same Material Design Icons subset the screen's fonts carry. */ -@font-face { font-family: "Tile Icons"; src: url("tile-icons.woff") format("woff"); font-display: block; } -.mdi { font-family: "Tile Icons"; font-style: normal; font-weight: normal; line-height: 1; } -.domain-icon.mdi { font-size: 22px; } -.preview-tile .domain-icon.mdi { font-size: 19px; } -.icon-current { display: flex; align-items: center; gap: 10px; width: 100%; padding: 7px 12px 7px 7px; background: white; color: var(--ink); border: 1px solid #cdd5df; border-radius: 12px; font-size: 14px; font-weight: 500; text-align: left; } -.icon-current .mdi { display: grid; place-items: center; width: 36px; height: 36px; flex: none; border-radius: 9px; background: #f3f5f8; font-size: 24px; } -.icon-current > span:nth-child(2) { flex: 1; min-width: 0; } -.icon-current small { color: var(--accent); font-weight: 600; } -.icon-picker { display: grid; gap: 10px; padding: 10px; border: 1px solid var(--line); border-radius: 12px; background: #fafbfc; } -.icon-picker input { font-size: 14px; font-weight: 400; } -.icon-list { display: grid; gap: 12px; max-height: 300px; overflow: auto; padding-right: 2px; } -.icon-list small { display: block; margin-bottom: 6px; } -.icon-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); gap: 6px; } -.icon-choice { display: grid; place-items: center; height: 44px; padding: 0; background: white; color: var(--ink); border: 1px solid var(--line); border-radius: 10px; } -.icon-choice .mdi { font-size: 25px; } -.icon-choice[aria-pressed="true"] { background: var(--soft); color: var(--accent); border-color: #9dbcf5; box-shadow: inset 0 0 0 1px #9dbcf5; } -.icon-choice.icon-auto { display: flex; justify-content: flex-start; gap: 10px; padding: 0 12px; font-size: 13px; font-weight: 500; } -.icon-choice.icon-auto .mdi { font-size: 22px; } -.icon-picker .hint { margin: 0; } -.icon-hint { font-weight: 400; } -/* Perform action: Home Assistant's actions for the entity, then the fields of the chosen one. */ -.action-current > span { display: grid; gap: 2px; flex: 1; min-width: 0; padding-left: 5px; } -.action-current .action-id { color: var(--muted); font-weight: 400; } -.action-list { display: grid; gap: 6px; max-height: 300px; overflow: auto; padding-right: 2px; } -.action-choice { display: grid; gap: 2px; padding: 8px 12px; background: white; color: var(--ink); border: 1px solid var(--line); border-radius: 10px; font-size: 13px; text-align: left; } -.action-choice strong { font-weight: 600; } -.action-choice small { color: var(--muted); font-weight: 400; } -.action-choice[aria-pressed="true"] { background: var(--soft); border-color: #9dbcf5; box-shadow: inset 0 0 0 1px #9dbcf5; } -.action-field { margin-top: 4px; } -.action-field input, .action-field select { font-size: 14px; font-weight: 400; } -.action-number { display: flex; align-items: center; gap: 8px; } -.action-number input { flex: 1; min-width: 0; } - -/* New screen: profile, Wi-Fi, and the first flash in one window. */ -.link { - background: none; - color: var(--accent); - padding: 0 0 0 8px; - font-weight: 600; - font-size: 12px; -} -.node-line code { - background: var(--soft); - padding: 1px 6px; - border-radius: 6px; - font-size: 12px; -} -#installer .wifi { - grid-template-columns: 1fr; - gap: 0; - margin: 0 0 16px; - padding: 12px 16px 2px; - border: 1px solid #f4e3c2; - background: #fff9ee; - border-radius: 12px; -} -#installer .wifi legend { - padding: 0 4px; - margin-bottom: 2px; -} -#installer .wifi .hint { - margin: 0 0 12px; -} -#installer select { - margin-top: 7px; - padding: 11px 12px; - border-radius: 10px; - border-color: #cdd5df; -} -.install-actions { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 12px; - margin-top: 18px; -} -#install-status { - color: #b23b3b; - font-size: 13px; -} -#install-note:empty { - display: none; -} -.progress-head { - display: flex; - align-items: center; - gap: 10px; - font-size: 17px; - margin-top: 6px; -} -.progress-head .spin { - width: 17px; - height: 17px; - border-width: 3px; -} -.outcome { - font-size: 22px; - line-height: 1; - font-weight: 700; -} -.outcome.ok { - color: #21704b; -} -.outcome.bad { - color: #b23b3b; -} -.key-box { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 12px; - border: 1px solid var(--line); - border-radius: 12px; - background: #f7f9fc; - margin: 4px 0 16px; -} -.key-box span { - flex: none; - font-size: 12px; - font-weight: 600; - color: var(--muted); -} -.key-box code { - flex: 1; - min-width: 0; - overflow-wrap: anywhere; - font-size: 12px; -} -/* Download: the built firmware and how to put it on the screen from your own computer. */ -.download-box { - padding: 14px; - border: 1px solid var(--line); - border-radius: 12px; - background: #f7f9fc; - margin: 4px 0 16px; -} -.download-box a.button { - background: var(--accent); - color: white; - overflow-wrap: anywhere; -} -.download-box .steps { - margin: 14px 0 6px; -} -.download-box .steps li { - margin-bottom: 6px; -} -.download-box a:not(.button) { - color: var(--accent); -} -.download-box > small { - margin-top: 10px; - line-height: 1.45; -} -#install-log-wrap { - margin-top: 12px; -} -#install-log-wrap summary { - cursor: pointer; - font-size: 13px; - font-weight: 600; - color: var(--muted); -} -#install-log { - max-height: 240px; - margin: 10px 0 0; -} - -/* Profiles that Home Assistant does not list yet: pairing happens outside this page. */ -#pending:empty { - display: none; -} -.pending { - border: 1px dashed #b9cbec; - border-radius: 14px; - padding: 12px 14px; - margin-bottom: 10px; - background: #f8fbff; -} -.pending strong { - display: block; - font-size: 14px; -} -.pending small { - margin: 3px 0 10px; - line-height: 1.4; -} -.pending-actions { - display: flex; - flex-wrap: wrap; - gap: 6px; -} -.pending-actions .quiet { - padding: 5px 9px; - font-size: 12px; -} -.steps .mini { - display: inline-block; - margin-top: 6px; -} - -/* Alerts: the cheatsheet for esphome._show_alert. */ -#alerts-dialog { max-width: 920px; width: 92vw; } -.alerts section { margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--line); } -.alerts h3 { font-size: 18px; letter-spacing: -0.3px; margin-bottom: 4px; } -.alerts section > p { margin-top: 4px; } -.alerts code { background: var(--soft); color: var(--ink); padding: 1px 6px; border-radius: 6px; font-size: 12px; overflow-wrap: anywhere; } -.alerts pre { max-height: none; margin: 0; flex: 1; min-width: 0; } -.alerts ol.steps { margin: 6px 0 18px; } -.alerts .steps li { margin-bottom: 8px; } -.alerts-list { padding-left: 20px; margin: 6px 0 0; font-size: 14px; color: var(--muted); } -.alerts-list li { margin-bottom: 9px; } -.alerts-list b { color: var(--ink); } -.alerts-subhead { font-size: 12px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; color: var(--muted) !important; margin: 18px 0 8px !important; } -.alert-hero { display: grid; grid-template-columns: minmax(0, 300px) 1fr; gap: 20px; align-items: center; margin: 6px 0 4px; } -.alert-mock { background: #101820; border-radius: 16px; padding: 22px 18px; display: grid; place-items: center; } -.alert-mock-card { position: relative; width: 100%; background: #FFE1C6; border: 1px solid #0001; border-radius: 14px; padding: 12px 12px 48px 52px; color: #1b1b1b; display: grid; gap: 2px; } -.alert-mock-icon { position: absolute; left: 12px; top: 12px; font-size: 30px; } -.alert-mock-card strong { font-size: 15px; line-height: 1.2; } -.alert-mock-card > span { font-size: 12px; color: #3a3a3c; } -.alert-mock-card button { position: absolute; right: 10px; bottom: 10px; padding: 6px 16px; border-radius: 999px; background: #1b1b1b; color: white; font-size: 13px; cursor: default; } -.alerts-nav { display: flex; flex-wrap: wrap; gap: 6px; align-content: start; } -.alerts-nav button { background: var(--soft); color: var(--accent); padding: 7px 12px; font-size: 12px; border-radius: 20px; } -.alerts-nav button:hover { filter: none; background: #dfe9ff; } -.alert-screen { border: 1px solid var(--line); border-radius: 14px; padding: 12px 14px; margin-bottom: 10px; display: grid; gap: 8px; background: white; } -.alert-screen-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; } -.alert-screen-head .badge { padding: 4px 9px; font-size: 12px; } -.copy-line { display: flex; align-items: flex-start; gap: 8px; flex-wrap: wrap; } -.copy-line .copy-label { flex: none; width: 96px; font-size: 12px; color: var(--muted); padding-top: 2px; } -.copy-line > code { flex: 1; min-width: 0; padding: 4px 8px; } -.copy-line .mini { flex: none; background: white; color: var(--ink); border: 1px solid var(--line); } -.alert-example { display: grid; gap: 10px; } -.alert-example label { margin: 0; } -.alert-example select { max-width: 360px; margin-top: 7px; } -.table-scroll { overflow-x: auto; } -.alerts table { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 560px; } -.alerts th, .alerts td { text-align: left; vertical-align: top; padding: 9px 12px 9px 0; border-bottom: 1px solid var(--line); } -.alerts th { font-size: 11px; letter-spacing: 1px; text-transform: uppercase; color: var(--muted); font-weight: 700; } -.alerts td small { margin-top: 3px; } -.alerts td:first-child code, .alerts td code { white-space: nowrap; } -.chips { display: flex; flex-wrap: wrap; gap: 6px; } -.chip { display: inline-flex; align-items: center; gap: 7px; padding: 5px 11px 5px 7px; background: white; border: 1px solid var(--line); border-radius: 20px; color: var(--ink); font-size: 12px; font-weight: 500; } -.chip .mdi { font-size: 18px; } -.chip code { background: none; padding: 0; color: var(--ink); } -.chip:hover { border-color: #b9cbec; filter: none; } -.alerts-search { margin: 18px 0 6px; } -.alerts-search input { font-weight: 400; } -.alert-icon-group { margin-top: 6px; } -.alert-icon-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); gap: 8px; } -.alert-icon { display: grid; justify-items: center; align-content: start; gap: 4px; min-height: 92px; padding: 10px 6px 8px; background: white; color: var(--ink); border: 1px solid var(--line); border-radius: 12px; font-weight: 500; } -.alert-icon:hover { border-color: #b9cbec; filter: none; box-shadow: 0 3px 10px #202c3c0c; } -.alert-icon .mdi { font-size: 28px; } -.alert-icon code { background: none; padding: 0; font-size: 11px; color: var(--accent); text-align: center; } -.alert-icon small { font-size: 11px; text-align: center; } -.swatches { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; } -.swatch { display: flex; align-items: center; gap: 10px; padding: 7px; border: 1px solid var(--line); border-radius: 12px; background: white; color: var(--ink); font-size: 12px; text-align: left; font-weight: 500; } -.swatch:hover { border-color: #b9cbec; filter: none; } -.swatch i { width: 34px; height: 34px; border-radius: 10px; border: 1px solid #0001; flex: none; } -.swatch span { display: grid; gap: 2px; min-width: 0; } -.swatch code { background: none; padding: 0; color: var(--ink); } -.swatch small { font-size: 11px; } -@media (max-width: 700px) { - .alert-hero { grid-template-columns: 1fr; } - .copy-line .copy-label { width: 100%; } - /* Tables stack per row on a phone; each cell shows its column name. */ - .alerts table { min-width: 0; } - .alerts table tr:first-child { display: none; } - .alerts table tr { display: grid; gap: 4px; padding: 10px 0; border-bottom: 1px solid var(--line); } - .alerts td { display: block; padding: 0; border: 0; } - .alerts td::before { content: attr(data-label); display: block; font-size: 11px; letter-spacing: 1px; text-transform: uppercase; color: var(--muted); font-weight: 700; margin-bottom: 2px; } - .alerts td:first-child::before { display: none; } - .alerts td:first-child code { font-size: 14px; } - .copy-line > pre { flex-basis: 100%; } -} - -/* Top bar: the name on the left, up to six items on the right. */ -.topbar-editor { margin-top: 4px; padding: 16px 18px 12px; border: 1px solid var(--line); border-radius: 16px; background: #f7f9fc; scroll-margin-top: 16px; transition: box-shadow 0.3s; } -.topbar-editor.flash { box-shadow: 0 0 0 3px #9dbcf5; } -.topbar-editor h3 { margin: 0; } -.topbar-fields { display: grid; grid-template-columns: minmax(180px, 2fr) 5fr; gap: 16px; margin-top: 10px; align-items: start; } -.topbar-name { font-size: 13px; } -.topbar-name input { margin-top: 6px; } -.topbar-caption { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; } -.topbar-chips { display: flex; flex-wrap: wrap; gap: 8px; min-height: 46px; } -.topbar-chip { position: relative; display: flex; align-items: center; gap: 8px; max-width: 260px; min-height: 46px; padding: 6px 30px 6px 8px; background: white; border: 1px solid #d5dce5; border-radius: 12px; cursor: grab; user-select: none; touch-action: pan-y; } -.topbar-chip:hover { border-color: #9eb6cf; box-shadow: 0 2px 7px #253f5010; } -.topbar-chip:focus-visible { outline: 3px solid #83b5ff; outline-offset: 2px; } -.topbar-chip .chip-icon { display: grid; place-items: center; width: 30px; height: 30px; flex: none; border-radius: 9px; background: #f0f2f5; color: #46525e; font-size: 19px; } -.chip-text { display: grid; min-width: 0; line-height: 1.25; } -.chip-text strong { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.chip-text small { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.chip-remove { position: absolute; top: 50%; right: 5px; transform: translateY(-50%); width: 22px; height: 22px; padding: 0; border-radius: 50%; background: transparent; color: var(--muted); font-size: 11px; line-height: 1; display: grid; place-items: center; opacity: 0; transition: opacity 0.15s; } -.topbar-chip:hover .chip-remove, .topbar-chip:focus-within .chip-remove { opacity: 1; } -.chip-remove:hover { color: #b3261e; background: #fdecea; } -@media (hover: none) { .chip-remove { opacity: 1; } } -.topbar-chip.is-hidden { background: #fbfcfd; border-style: dashed; } -.topbar-chip.is-hidden .chip-icon, .topbar-chip.is-hidden strong { opacity: 0.55; } -.topbar-chip.is-overflow { border-style: dashed; border-color: #e0b36a; background: repeating-linear-gradient(135deg, #fff 0 8px, #fff6e6 8px 16px); } -.topbar-chip.is-overflow small { color: #9a6200; } -.topbar-chip.dragging-chip { border: 2px dashed var(--accent); background: var(--soft); cursor: grabbing; } -.topbar-chip.just-added { animation: chip-added 1.2s ease-out; } -@keyframes chip-added { from { box-shadow: 0 0 0 4px #9dbcf5; } to { box-shadow: 0 0 0 0 #9dbcf500; } } -.topbar-add { min-height: 46px; padding: 8px 14px; border: 1px dashed #b9c4d0; border-radius: 12px; background: transparent; color: var(--accent); font-size: 13px; } -.topbar-add:hover:not(:disabled) { border-color: var(--accent); background: white; filter: none; } -#topbar-hint { margin: 10px 0 0; } -#topbar-hint.warn { color: #9a6200; } -@media (max-width: 700px) { .topbar-fields { grid-template-columns: 1fr; } .topbar-chip { max-width: 100%; } } - -/* The bar on a mockup page: an SVG in the board's own pixels (Roboto and the tile icons, the same - placement rules as the firmware), scaled with the page. */ -@font-face { font-family: "Bar Roboto"; src: url("bar-roboto-400.woff") format("woff"); font-weight: 400; font-display: block; } -@font-face { font-family: "Bar Roboto"; src: url("bar-roboto-500.woff") format("woff"); font-weight: 500; font-display: block; } -.preview-bar-wrap { margin: -4px 0 6px; border-radius: 8px; cursor: pointer; } -.preview-bar-wrap:hover { background: #dfe3e8; box-shadow: 0 0 0 5px #dfe3e8; } -.preview-bar { display: block; width: 100%; height: auto; overflow: visible; } - -/* Top bar sheet. */ -.topbar-options { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; } -.topbar-option { display: flex; align-items: center; gap: 10px; padding: 8px 10px; background: white; color: var(--ink); border: 1px solid var(--line); border-radius: 12px; text-align: left; font-weight: 500; } -.topbar-option:hover:not(:disabled) { border-color: #9eb6cf; filter: none; box-shadow: 0 2px 7px #253f5010; } -.topbar-option .mdi { display: grid; place-items: center; width: 34px; height: 34px; flex: none; border-radius: 9px; background: #f0f2f5; color: #46525e; font-size: 21px; } -.topbar-option > span:last-child { display: grid; min-width: 0; } -.topbar-option strong { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.topbar-option small { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.topbar-results { display: grid; gap: 6px; max-height: 260px; overflow: auto; margin-top: 8px; } -.topbar-results .topbar-option { width: 100%; } -.topbar-results .hint { margin: 4px 0; } -.topbar-live { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border-radius: 12px; background: #e7e7e7; } -.topbar-live small { color: #52606d; font-weight: 500; } -.topbar-sample { display: block; height: auto; max-width: 60%; overflow: visible; } -#topbar-sheet .sheet-head .domain-icon { background: #f0f2f5; color: #46525e; } -.icon-choice.icon-none .mdi { width: 22px; height: 22px; border: 1.5px dashed #b9c4d0; border-radius: 6px; } diff --git a/screen_manager/config.yaml b/screen_manager/config.yaml index 75d1390..f0c15ff 100644 --- a/screen_manager/config.yaml +++ b/screen_manager/config.yaml @@ -1,5 +1,5 @@ name: ESP Screen Manager -version: "0.2.72" +version: "0.2.73" slug: esp_screen_manager description: Choose tiles for your CYD and Guition touchscreen, without reflashing. url: https://github.com/MaxGramser/homeassistant_espscreen diff --git a/tests/editor_sources.py b/tests/editor_sources.py new file mode 100644 index 0000000..00aae25 --- /dev/null +++ b/tests/editor_sources.py @@ -0,0 +1,25 @@ +"""The editor's source, for the tests that read it. + +The page is a Vue app in web/ (app 0.2.74); `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). +""" +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +WEB = ROOT / 'web/src' +STATIC = ROOT / 'screen_manager/app/static' + +FILES = sorted(p for p in WEB.rglob('*') if p.suffix in ('.ts', '.vue')) +SCRIPT = '\n'.join(p.read_text() for p in FILES) +PAGE = SCRIPT +CSS = '\n'.join(p.read_text() for p in sorted(WEB.glob('styles/*.css'))) + + +def source(name): + """One file by name, e.g. 'store.ts' or 'components/Sidebar.vue'.""" + return (WEB / name).read_text() + + +def component(name): + return source(f'components/{name}.vue') diff --git a/tests/test_alert_broadcast.py b/tests/test_alert_broadcast.py index 1ebeea1..3e90beb 100644 --- a/tests/test_alert_broadcast.py +++ b/tests/test_alert_broadcast.py @@ -182,14 +182,13 @@ class Broadcast(unittest.IsolatedAsyncioTestCase): class Page(unittest.TestCase): def test_cheatsheet_explains_the_event(self): - html = (STATIC / 'index.html').read_text() - cheatsheet = html.split('id="alerts-dialog"', 1)[1] - for marker in ('id="alerts-all"', 'data-jump="alerts-all"', 'id="alerts-all-example"', 'id="alerts-all-copy"', + 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'): self.assertIn(marker, cheatsheet, marker) - script = (STATIC / 'app.js').read_text() - for marker in ('function alertAllYaml', 'broadcast?.show', 'renderAlertBroadcast();', '#alerts-all-copy").onclick'): - self.assertIn(marker, script, marker) + for marker in ('const allYaml = computed', 'broadcast?.show', 'copyText(allYaml, undefined, \'YAML\')'): + self.assertIn(marker, cheatsheet, marker) if __name__ == '__main__': diff --git a/tests/test_alerts_reference.py b/tests/test_alerts_reference.py index 7a6653b..46b5e05 100644 --- a/tests/test_alerts_reference.py +++ b/tests/test_alerts_reference.py @@ -55,13 +55,13 @@ class ReferenceTests(unittest.TestCase): self.assertLessEqual(parse_version(ALERT_MIN_FIRMWARE), parse_version(FIRMWARE_VERSION)) def test_page_carries_the_cheatsheet(self): - html = (STATIC / 'index.html').read_text() + import editor_sources + html = editor_sources.PAGE for marker in ('id="open-alerts"', 'id="alerts-dialog"', 'id="close-alerts"', 'id="alerts-screens"', 'id="alerts-icons"', 'id="alerts-colors"', 'id="alerts-fields"', 'id="alerts-example"'): self.assertIn(marker, html, marker) - script = (STATIC / 'app.js').read_text() - for marker in ('inventory.alerts', 'alert_action', 'dismiss_action', 'function renderAlerts', '#open-alerts'): - self.assertIn(marker, script, marker) - self.assertIn('#alerts-dialog', (STATIC / 'style.css').read_text()) + for marker in ('inventory.alerts', 'alert_action', 'dismiss_action', 'AlertsView', 'go(\'#alerts\')'): + self.assertIn(marker, editor_sources.SCRIPT, marker) + self.assertIn('.alert-icon-grid', editor_sources.CSS) HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None if HAS_AIOHTTP: diff --git a/tests/test_editor_startup.py b/tests/test_editor_startup.py index 6a0e256..c302135 100644 --- a/tests/test_editor_startup.py +++ b/tests/test_editor_startup.py @@ -1,26 +1,25 @@ """Opening ESP Screens. App 0.2.58: the page asks for the full inventory and opens the live stream at the same time. An add-on that is -building firmware answers the stream first, whose light payload has screens but no entities, so a screen opened -then had an empty catalogue: the entity list said "No entities found" and the tiles showed entity ids until a -filter chip was pressed. The first full inventory now draws those parts again. +building firmware answers the stream first, whose light payload has screens but no entities. The page keeps the +catalogue it has on a light poll and every name on the page follows the inventory, so the first full inventory +fills in what was missing. App 0.2.65: the page no longer opens the first screen by itself; the owner picks one. Until then a card asks for -that, or offers the install when there are no screens yet. A screen picked before the full inventory arrives -still gets its entity names from the redraw above. +that, or offers the install when there are no screens yet. + +App 0.2.74: the page is the Vue app in web/; these tests read its source (tests/editor_sources.py). """ import re +import sys import unittest from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] -SCRIPT = (ROOT / 'screen_manager/app/static/app.js').read_text() -PAGE = (ROOT / 'screen_manager/app/static/index.html').read_text() +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import editor_sources # noqa: E402 - -def function(name): - start = SCRIPT.index(f'function {name}(') - return SCRIPT[start:SCRIPT.index('\n}\n', start)] +SCRIPT = editor_sources.SCRIPT +STORE = editor_sources.source('store.ts') class Startup(unittest.TestCase): @@ -28,33 +27,28 @@ class Startup(unittest.TestCase): self.assertNotIn('inventory.screens[0]', SCRIPT) # The one way into a screen is its button in the list. self.assertEqual(re.findall(r'(?(.*?)', PAGE, re.S) - self.assertTrue(choose, 'hidden until the first inventory') + empty = editor_sources.component('EmptyState') + choose = re.search(r'
    (.*?)
    ', empty, re.S) + self.assertTrue(choose, 'the card asks for a screen while there are screens') self.assertIn('

    Choose a screen

    ', choose[1]) self.assertNotIn('