Fix what a full test round of today's releases found: a ticking single clock, a switch that works in standby, no taps from a swipe (0.2.78)

A test and code-analysis round of 0.2.72 to 0.2.76 (suites at every tag, sanitizers, ESP32 and GitHub builds, host
harnesses on both boards, the editor in a browser, best practice checked against ESPHome, LVGL, Home Assistant and
Vue) found these, fixed here with Max's choices for the product questions:

- Firmware 0.2.65: the second hand of a single analog clock moves again; on a page that is one full on/off tile the
  waking push also switches it; on the Guition a flick is never a tap and a finger that slides off a tile doesn't tap
  it; Previous/Next react to a firm press; the same Go to page tile may sit on several pages; a layout the screen has
  no memory for is refused instead of crashing; 64-bit redraw mask for 48 tiles; Montserrat 14 no longer built.
- App: Save refuses a layout too long for one message or too big for eight pages; an offline screen keeps its
  firmware version from the device registry; HA refusals reach the editor; 128 KB requests; changelog out of the
  live stream; cached hashed assets; tini as init and a clean stop; changelog parsing without asterisks or crashes.
- Editor: clicking the open screen keeps unsaved edits, edits during a save stay unsaved, a Page row and phone
  dragging to other pages, repeated page tiles, limits from the server, HA's error messages, a type-checked build.
- tools/check.sh + GitHub Actions + a CYD flash budget; docs match 48 tiles and the new editor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-18 17:25:23 +02:00
parent c09e4e333f
commit 1a6308aba7
80 changed files with 2623 additions and 320 deletions
+4 -1
View File
@@ -3,9 +3,12 @@
"private": true,
"version": "0.0.0",
"type": "module",
"devEngines": {
"runtime": { "name": "node", "version": "^20.19.0 || >=22.12.0", "onFail": "error" }
},
"scripts": {
"dev": "vite",
"build": "rm -rf ../screen_manager/app/static/assets && vite build",
"build": "vue-tsc --noEmit && rm -rf ../screen_manager/app/static/assets && vite build",
"check": "vue-tsc --noEmit",
"preview": "vite preview",
"test": "vitest run",
+4 -1
View File
@@ -16,9 +16,12 @@ export async function api(path: string, options: RequestInit = {}): Promise<Resp
},
});
if (!response.ok) {
// Any answer with a JSON {"error": "..."} says what went wrong, such as Home Assistant refusing an action (400) or
// not answering in time (503, app 0.2.78); anything else, like an error page of the ingress proxy, gets this one.
let message = "That didn't work. Refresh the page and try again.";
try {
message = (await response.json()).error || message;
const error = (await response.json())?.error;
if (typeof error === "string" && error.trim()) message = error;
} catch {}
throw new Error(message);
}
+3 -3
View File
@@ -3,7 +3,7 @@
import { computed, reactive, ref } from "vue";
import { versionAtLeast } from "../model/layout";
import { glyph } from "../model/topbar";
import { canAlert, copyText, go, sendTestAlert, state } from "../store";
import { canAlert, copyText, firmwareVersion, go, sendTestAlert, state } from "../store";
const alerts = computed(() => state.inventory.alerts);
// Try it: the same seven fields an automation sends, to one screen or to all of them.
@@ -141,8 +141,8 @@ const sections = [["alerts-try", "Try it"], ["alerts-screens", "Your screens"],
<div v-for="screen in state.inventory.screens" :key="screen.id" class="alert-screen">
<div class="alert-screen-head">
<strong>{{ screen.name }}</strong>
<span class="chip" :class="versionAtLeast(screen.firmware, alerts.min_firmware) && screen.alert_action ? 'good' : 'update'">
{{ versionAtLeast(screen.firmware, alerts.min_firmware) && screen.alert_action ? `● firmware ${screen.firmware}` : screen.alert_action ? `Update needed · firmware ${screen.firmware || "unknown"}` : "Device name unknown · update the screen" }}
<span class="chip" :class="versionAtLeast(firmwareVersion(screen), alerts.min_firmware) && screen.alert_action ? 'good' : 'update'">
{{ versionAtLeast(firmwareVersion(screen), alerts.min_firmware) && screen.alert_action ? `● firmware ${screen.firmware}` : screen.alert_action ? `Update needed · firmware ${screen.firmware || "unknown"}` : "Device name unknown · update the screen" }}
</span>
</div>
<div v-for="[label, action] in [['Show alert', screen.alert_action], ['Dismiss alert', screen.dismiss_action]]" :key="label" class="copy-line">
+2 -3
View File
@@ -17,9 +17,8 @@ const progress = computed(() => (running.value ? updateProgress(running.value) :
const logTail = computed(() => (state.firmwareJob?.logs || []).slice(-12).join("\n"));
// What the current firmware brings: the changelog sections that mention it.
const targetNotes = computed(() => {
const sections = (u.value as any)?.changelog as { app: string; firmware: string; lines: string[] }[] | undefined;
const target = u.value?.target;
if (!sections || !target) return [];
const sections = state.inventory.changelog, target = u.value?.target;
if (!Array.isArray(sections) || !target) return [];
return sections.filter((s) => s.firmware === target).flatMap((s) => s.lines).slice(0, 10);
});
const skill = computed(() => state.inventory.claude_skill);
+1 -1
View File
@@ -43,7 +43,7 @@ watch(() => props.entity, load);
<div id="inspection-summary">
<p v-if="error" class="hint warn">{{ error }}</p>
<p v-else-if="!summary" class="hint">Fetching current HA status...</p>
<article v-for="tile in summary || []" :key="tile.entity" class="inspection-tile">
<article v-for="(tile, i) in summary || []" :key="i" class="inspection-tile">
<strong>{{ tile.entity }}</strong>
<span>Status: {{ tile.word || tile.state }}</span>
<small>{{ optionsText(tile.entity) }}</small>
+6 -4
View File
@@ -6,7 +6,7 @@ import { computed } from "vue";
import { vDrag } from "../drag";
import { domainInfo } from "../model/layout";
import { glyph } from "../model/topbar";
import { addTile, automaticIcon, isGuition, state, tileLimit } from "../store";
import { addTile, automaticIcon, isGuition, repeatable, state, tileLimit } from "../store";
const FILTERS: [string, string][] = [
["", "All"], ["light", "Lights"], ["climate", "Climate"], ["switch", "Switches"], ["binary_sensor", "Status"], ["button", "Actions"],
@@ -15,6 +15,8 @@ const FILTERS: [string, string][] = [
];
const ALIAS: Record<string, string> = { switch: "input_boolean", number: "input_number", select: "input_select", weather: "sun", button: "input_button" };
const chosen = computed(() => new Set(state.layout?.tiles.map((t) => t.entity) || []));
// On the screen and not to be added again; a page tile can be, when the firmware takes several (0.2.65).
const placed = (id: string) => chosen.value.has(id) && !repeatable(id);
const rooms = computed(() => [...new Set(state.inventory.entities.map((e) => e.area).filter((a): a is string => Boolean(a)))].sort((a, b) => a.localeCompare(b)));
const matches = computed(() => {
const query = state.search.toLocaleLowerCase(), filter = state.filter, room = state.room;
@@ -24,7 +26,7 @@ const matches = computed(() => {
(isGuition.value || !["camera", "image"].includes(e.id.split(".")[0])) &&
(!filter || e.id.startsWith(filter + ".") || ALIAS[filter] === e.id.split(".")[0]) &&
(!room || e.area === room) &&
(!state.hidePlaced || !chosen.value.has(e.id)) &&
(!state.hidePlaced || !placed(e.id)) &&
`${e.name} ${e.id} ${e.device || ""} ${e.area || ""}`.toLocaleLowerCase().includes(query));
});
const full = computed(() => (state.layout?.tiles.length || 0) >= tileLimit.value);
@@ -58,13 +60,13 @@ const tone = (e: { id: string; state?: string }) => {
</div>
<div class="lib-list" id="results" aria-live="polite">
<button v-for="entity in matches.slice(0, 80)" :key="entity.id" type="button" class="ent" :title="entity.id"
:disabled="chosen.has(entity.id) || full" v-drag="{ kind: 'entity', id: entity.id }" @click="addTile(entity.id)">
:disabled="placed(entity.id) || full" v-drag="{ kind: 'entity', id: entity.id }" @click="addTile(entity.id)">
<span class="av mdi" :class="tone(entity)" :style="{ color: domainInfo(entity.id)[2], background: domainInfo(entity.id)[3] }">{{ state.inventory.icons ? glyph(automaticIcon(entity.id)) : domainInfo(entity.id)[1] }}</span>
<span class="tx">
<b>{{ entity.name }}</b>
<small>{{ [domainInfo(entity.id)[0], entity.area, entity.device].filter(Boolean).join(" · ") }}</small>
</span>
<span class="add" :class="{ done: chosen.has(entity.id) }">{{ chosen.has(entity.id) ? "✓" : "+" }}</span>
<span class="add" :class="{ done: placed(entity.id) }">{{ placed(entity.id) ? "✓" : "+" }}</span>
</button>
<p v-if="!matches.length" class="hint">{{ state.hidePlaced && !state.search && !state.filter && !state.room ? "Everything here is already on this screen." : "No entities found. Try a different name, room or filter." }}</p>
<p v-else-if="matches.length > 80" class="hint">{{ matches.length }} results. Keep typing to narrow it down.</p>
+1
View File
@@ -52,6 +52,7 @@ const pendingText = (p: { installed?: boolean; downloaded?: boolean; file: strin
<span>{{ screen.name }}</span>
<small>{{ screen.online ? "Online" : "Offline" }}{{ screen.area ? " · " + screen.area : "" }} · {{ screen.firmware || "unknown" }}</small>
</span>
<span v-if="screen.id === state.selected && state.dirty" class="unsaved" role="img" aria-label="Unsaved changes" title="Unsaved changes"></span>
<span v-if="updateState(screen)?.kind === 'available'" class="pill">Update</span>
<span v-else-if="updateState(screen)?.kind === 'running'" class="spin small"></span>
</button>
+4 -4
View File
@@ -5,7 +5,7 @@ import { computed, nextTick } from "vue";
import { vDrag } from "../drag";
import { displayNames, effectiveControls, isFull, isWide, pageOf, pageTarget, SLOTS_PER_PAGE } from "../model/layout";
import { glyph } from "../model/topbar";
import { entityName, liveOf, openTile, placeTile, removeTile, state, tileIconCp } from "../store";
import { entityName, isSelected, liveOf, openTile, placeTile, removeTile, state, tileIconCp } from "../store";
import type { Tile } from "../types";
const props = defineProps<{ tile: Tile; slot: number; placeholder?: boolean }>();
@@ -21,7 +21,7 @@ const controls = computed(() => effectiveControls(props.tile, state.inventory));
const domain = computed(() => props.tile.entity.split(".")[0]);
const cp = computed(() => state.inventory.icons?.controls || {});
const key = (n: string) => (cp.value[n] ? glyph(cp.value[n]) : "");
const chosen = computed(() => state.selectedTile === props.tile.entity && state.inspector?.kind === "tile");
const chosen = computed(() => isSelected(props.tile) && state.inspector?.kind === "tile");
const live = computed(() => !props.placeholder && state.layout?.tiles.includes(props.tile));
const label = computed(() => `${name.value}, slot ${(props.slot % SLOTS_PER_PAGE) + 1} on page ${pageOf(props.slot) + 1}. Enter: configure, arrow keys: move`);
const now = computed(() => new Date(state.now));
@@ -77,7 +77,7 @@ const setpoint = computed(() => {
});
async function onKey(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openTile(props.tile.entity); return; }
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openTile(props.tile); return; }
const step = ({ ArrowLeft: -1, ArrowRight: 1, ArrowUp: -2, ArrowDown: 2 } as Record<string, number>)[e.key];
if (!step) return;
e.preventDefault();
@@ -93,7 +93,7 @@ async function onKey(e: KeyboardEvent) {
<div class="tile" :class="{ wide, full, bare, placeholder: placeholder || !live, chosen }" :data-slot="slot"
:style="background && !bare ? { backgroundColor: background } : undefined"
:tabindex="live ? 0 : -1" :role="live ? 'button' : undefined" :aria-label="live ? label : undefined"
v-drag="{ kind: 'tile', tile }" @click="live && openTile(tile.entity)" @keydown="live && onKey($event)">
v-drag="{ kind: 'tile', tile }" @click="live && openTile(tile)" @keydown="live && onKey($event)">
<template v-if="display === 'analog'">
<svg class="clockface" viewBox="0 0 60 60" aria-hidden="true">
<circle cx="30" cy="30" r="27" fill="#fff" stroke="#c9ccd1" />
+32 -6
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
// One tile's settings. Every change applies live, so the card on the mockup shows the result while you pick.
import { computed } from "vue";
import { domainInfo, MAX_PAGES, pageTarget, SLIDER_DOMAINS, TOGGLE_BEFORE } from "../model/layout";
import { computed, toRaw } from "vue";
import { domainInfo, entriesOf, MAX_PAGES, pageCount, pageOf, pageTarget, SLIDER_DOMAINS, TOGGLE_BEFORE } from "../model/layout";
import { glyph } from "../model/topbar";
import { automaticIcon, closeInspector, entityName, markDirty, removeTile, retargetPageTile, setTileOption, state, supports, tileIconCp } from "../store";
import { automaticIcon, closeInspector, entityName, fullPage, markDirty, moveTileToPage, removeTile, retargetPageTile, setTileOption, state, supports, tileIconCp } from "../store";
import type { Tile } from "../types";
import ActionPicker from "./ActionPicker.vue";
import IconPicker from "./IconPicker.vue";
@@ -14,9 +14,31 @@ const domain = computed(() => props.tile.entity.split(".")[0]);
const name = computed(() => entityName(props.tile.entity));
// A navigation tile (screen.page_<n>): the page it opens, its size, icon and colour; nothing else applies.
const goesTo = computed(() => pageTarget(props.tile.entity));
const pages = Array.from({ length: MAX_PAGES }, (_, i) => [i + 1, String(i + 1)] as [number, string]);
// Pages counted from 1. "Goes to page" offers the pages the screen has and the empty one after them, where a sub-page
// starts (app 0.2.78), and keeps a target beyond those so the choice stays visible.
const pageTotal = computed(() => (state.layout ? pageCount(entriesOf(state.layout), state.layout.pages) : 1));
const pageHere = computed(() => pageOf(props.tile.slot) + 1);
const emptyPage = (n: number) => !state.layout?.tiles.some((t) => pageOf(t.slot) === n - 1);
const pages = computed(() => {
const list = Array.from({ length: Math.min(MAX_PAGES, pageTotal.value + 1) }, (_, i) => i + 1);
if (goesTo.value > list.length) list.push(goesTo.value);
return list.map((n) => [n, emptyPage(n) ? `${n} (empty)` : String(n)] as [number, string]);
});
const goesToHint = computed(() => !fullPage.value
? { text: "Navigation tiles need firmware 0.2.62: press Update on the screen first.", warn: false }
: goesTo.value > pageTotal.value
? { text: `This screen has no page ${goesTo.value}, so a tap opens its last page. Choose another page.`, warn: true }
: { text: "A tap on the tile opens that page.", warn: false });
// Moving the tile without a drag (app 0.2.78): another page, or a new one after the last. A tile alone on the last page
// gets no "New page", which would only leave an empty page behind; with nowhere to go the row stays hidden.
const alone = computed(() => !state.layout?.tiles.some((t) => toRaw(t) !== toRaw(props.tile) && pageOf(t.slot) === pageHere.value - 1));
const onPage = computed(() => {
const list = Array.from({ length: pageTotal.value }, (_, i) => [i + 1, String(i + 1)] as [number, string]);
if (pageTotal.value < MAX_PAGES && !(alone.value && pageHere.value === pageTotal.value)) list.push([pageTotal.value + 1, "New page"]);
return list;
});
const sizes = computed<[string, string][]>(() => goesTo.value ? [["single", "Normal"], ["wide", "Double-width"]] : [["single", "Normal"], ["wide", "Double-width"], ["full", "Full page"]]);
const sizeHint = computed(() => goesTo.value ? "" : supports(0, 2, 62)
const sizeHint = computed(() => goesTo.value ? "" : fullPage.value
? "Full page: one big button that lights up while on; a slider, controls or a graph sit at the bottom of it."
: "A full-page tile needs firmware 0.2.62: press Update on the screen first.");
const caps = computed(() => state.capabilities[props.tile.entity]);
@@ -100,7 +122,7 @@ function inspect() {
<div v-if="goesTo" class="f">
<span class="f-label">Goes to page</span>
<Segmented :choices="pages" :value="goesTo" @pick="(v) => retargetPageTile(tile, Number(v))" />
<small>{{ supports(0, 2, 62) ? "A tap on the tile opens that page." : "Navigation tiles need firmware 0.2.62: press Update on the screen first." }}</small>
<small :class="{ warn: goesToHint.warn }">{{ goesToHint.text }}</small>
</div>
<div v-else class="f">
<span class="f-label">Display</span>
@@ -112,6 +134,10 @@ function inspect() {
<Segmented :choices="sizes" :value="size" @pick="(v) => setTileOption(tile, 'size', v)" />
<small v-if="sizeHint">{{ sizeHint }}</small>
</div>
<div v-if="onPage.length > 1" class="f">
<span class="f-label">Page</span>
<Segmented :choices="onPage" :value="pageHere" @pick="(v) => moveTileToPage(tile, Number(v) - 1)" />
</div>
<div v-if="catalogue && size !== 'single' && !goesTo" class="f">
<span class="f-label">Direct control on the tile</span>
<Segmented :choices="controlChoices" :value="controls" @pick="(v) => setTileOption(tile, 'controls', v)" />
+38 -8
View File
@@ -66,19 +66,47 @@ function beginDrag(e: PointerEvent) {
try { drag.element.releasePointerCapture(drag.pointerId!); } catch {}
try { document.documentElement.setPointerCapture(drag.pointerId!); } catch {}
document.addEventListener("touchmove", blockScroll, { passive: false });
// Near the edges of the canvas the pages scroll along, so every page can be reached.
// Near the edges the pages scroll along, so every page can be reached.
drag.scroller = window.setInterval(() => {
if (!drag.last) return;
const canvas = document.querySelector<HTMLElement>(".canvas");
if (!canvas) return;
const r = canvas.getBoundingClientRect();
const dx = drag.last.x < r.left + 60 ? -12 : drag.last.x > r.right - 60 ? 12 : 0;
const dy = drag.last.y < r.top + 60 ? -12 : drag.last.y > r.bottom - 60 ? 12 : 0;
if (dx || dy) { canvas.scrollBy(dx, dy); setTarget(slotAt(drag.last.x, drag.last.y)); }
const { x, y } = dragScrollers(document);
const dx = x ? edgeStep(drag.last.x, visibleSpan(x, "x")) : 0;
const dy = y ? edgeStep(drag.last.y, visibleSpan(y, "y")) : 0;
if (dx) x!.scrollBy(dx, 0);
if (dy) y!.scrollBy(0, dy);
if (dx || dy) setTarget(slotAt(drag.last.x, drag.last.y));
}, 16);
setTarget(-1);
moveDrag(e);
}
// True when the element scrolls along that axis: more content than room, and an overflow that lets it scroll.
export function scrollsAlong(element: Element | null, axis: "x" | "y") {
if (!element) return false;
const more = axis === "x" ? element.scrollWidth > element.clientWidth : element.scrollHeight > element.clientHeight;
const overflow = getComputedStyle(element)[axis === "x" ? "overflowX" : "overflowY"];
return more && (overflow === "auto" || overflow === "scroll");
}
// What a drag near an edge scrolls, per axis (app 0.2.78). A wide window scrolls the canvas both ways. At 960 px and
// narrower (a phone, also in the Home Assistant app) the canvas grows with its content: the row of pages scrolls
// sideways and the page itself up and down, so a tile can still reach page 2 and beyond.
export function dragScrollers(doc: Document): { x: Element | null; y: Element | null } {
const pages = doc.querySelector(".pages"), canvas = doc.querySelector(".canvas");
return {
x: scrollsAlong(pages, "x") ? pages : canvas,
y: scrollsAlong(canvas, "y") ? canvas : doc.scrollingElement,
};
}
// The part of a scroller on screen along one axis; the page itself is the whole window.
function visibleSpan(element: Element, axis: "x" | "y"): [number, number] {
const view = axis === "x" ? window.innerWidth : window.innerHeight;
if (element === element.ownerDocument.scrollingElement) return [0, view];
const r = element.getBoundingClientRect();
return axis === "x" ? [Math.max(r.left, 0), Math.min(r.right, view)] : [Math.max(r.top, 0), Math.min(r.bottom, view)];
}
// Within 60 px of either end the scroller moves 12 px per step towards that end.
export function edgeStep(position: number, [start, end]: [number, number]) {
return position < start + 60 ? -12 : position > end - 60 ? 12 : 0;
}
function blockScroll(e: TouchEvent) { if (state.drag.active) e.preventDefault(); }
function finishDrag(e: PointerEvent) { if (e.pointerId === drag.pointerId) endDrag(e.type === "pointerup"); }
function moveDrag(e: PointerEvent) {
@@ -122,11 +150,13 @@ function endDrag(drop: boolean) {
state.drag.moving = null;
if (drop && preview && moving && state.layout) {
const before = state.layout.tiles.map((t) => `${t.entity}@${t.slot}`).join();
// By the tile itself, not its entity: a screen can have several tiles that go to the same page (firmware 0.2.65).
const added = !state.layout.tiles.includes(moving);
for (const { tile, slot } of preview) { tile.slot = slot; if (!state.layout.tiles.includes(tile)) state.layout.tiles.push(tile); }
state.layout.tiles.sort((a, b) => a.slot - b.slot);
if (state.layout.tiles.map((t) => `${t.entity}@${t.slot}`).join() !== before) markDirty();
// Placing goes through the same rules as a click; a new tile also asks for its capabilities.
if (!before.includes(`${moving.entity}@`)) placeTile(moving, moving.slot);
if (added) placeTile(moving, moving.slot);
}
}
window.addEventListener("click", (e) => { if (Date.now() < drag.suppressUntil) { e.stopPropagation(); e.preventDefault(); } }, true);
+6 -5
View File
@@ -130,18 +130,19 @@ export function controlsLabel(tile: Tile, inventory: Inventory) {
return inventory.controls?.[tile.entity.split(".")[0]]?.choices.find((c) => c.key === key)?.label.toLocaleLowerCase() || key;
}
export const parseVersion = (v: string | undefined) => (/^(\d+)\.(\d+)\.(\d+)$/.exec(v || "") || []).slice(1).map(Number);
export function versionAtLeast(version: string | undefined, minimum: string) {
export const parseVersion = (v: string | undefined | null) => (/^(\d+)\.(\d+)\.(\d+)$/.exec(v || "") || []).slice(1).map(Number);
export function versionAtLeast(version: string | undefined | null, minimum: string) {
const [a, b] = [parseVersion(version), parseVersion(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;
}
export const supportsFirmware = (firmware: string | undefined, major: number, minor: number, patch: number) =>
export const supportsFirmware = (firmware: string | undefined | null, major: number, minor: number, patch: number) =>
versionAtLeast(firmware, `${major}.${minor}.${patch}`);
// Firmware 0.2.62 holds one tile per slot (48); 0.2.7 twenty; older firmware ten.
// Firmware 0.2.62 holds one tile per slot (48); 0.2.7 twenty; older firmware ten. The add-on tells the editor per
// screen (tile_limit, app 0.2.78); this rule stays for a screen entry without it.
export const MAX_TILES = MAX_SLOTS;
export function tileLimit(firmware: string | undefined) {
export function tileLimit(firmware: string | undefined | null) {
if (parseVersion(firmware).length !== 3) return 10;
return versionAtLeast(firmware, "0.2.62") ? MAX_TILES : versionAtLeast(firmware, "0.2.7") ? 20 : 10;
}
+80 -26
View File
@@ -1,6 +1,6 @@
// One reactive state for the whole editor. The Python API (server.py) is unchanged: this file is the
// former app.js state and its calls, with the DOM work moved into the components.
import { computed, reactive } from "vue";
import { computed, reactive, toRaw } from "vue";
import { api, getJson, send, setCsrf } from "./api";
import {
arrange, cellsOf, entriesOf, firstFree, fits, isFull, isWide, MAX_PAGES, nearestFree, newTile, normalize, occupied, pageCount, pageOf,
@@ -8,7 +8,7 @@ import {
} from "./model/layout";
import { agoText, BAR_METRICS, clockText, dateText, itemKey, type ItemView, whenBarFontsLoad } from "./model/topbar";
import { versionAtLeast } from "./model/layout";
import type { Capability, EntityAction, HeaderItem, Inventory, Layout, Screen, Tile } from "./types";
import type { Capability, ChangelogSection, EntityAction, HeaderItem, Inventory, Layout, Screen, Tile } from "./types";
export type Inspector =
| { kind: "tile" }
@@ -29,7 +29,8 @@ export const state = reactive({
busy: false,
saved: 0,
tab: "layout" as "layout" | "settings",
selectedTile: null as string | null,
// The tile itself, not its entity: several tiles can go to the same page (firmware 0.2.65).
selectedTile: null as Tile | null,
inspector: null as Inspector | null,
iconPickerOpen: false,
actionPickerOpen: false,
@@ -61,13 +62,35 @@ export const state = reactive({
});
export const currentScreen = computed<Screen | undefined>(() => state.inventory.screens.find((s) => s.id === state.selected));
export const firmwareOf = computed(() => currentScreen.value?.firmware || "");
// The firmware version a screen's features go by, as the add-on works it out (firmware_known, app 0.2.78; null when it
// can't tell). A screen entry without the field goes by the firmware text, as before.
export const firmwareVersion = (screen: Screen | undefined) =>
(screen && "firmware_known" in screen ? screen.firmware_known : screen?.firmware) || "";
export const firmwareOf = computed(() => firmwareVersion(currentScreen.value));
export const supports = (major: number, minor: number, patch: number) => supportsVersion(firmwareOf.value, major, minor, patch);
export const tileLimit = computed(() => limitFor(firmwareOf.value));
// What the screen holds and draws, as the add-on says (app 0.2.78), so a screen whose version Home Assistant can't
// report for a moment keeps its 48 tiles instead of dropping to ten, and a copied or imported layout isn't cut to ten.
export const tileLimit = computed(() => {
const limit = currentScreen.value?.tile_limit;
return typeof limit === "number" && Number.isInteger(limit) && limit > 0 ? limit : limitFor(firmwareOf.value);
});
export const fullPage = computed(() => {
const full = currentScreen.value?.full_page;
return typeof full === "boolean" ? full : supports(0, 2, 62);
});
// Several tiles that go to the same page, such as a way back to page 1 on every sub-page (firmware 0.2.65); every
// other entity stays once per screen.
export const pageTilesRepeat = computed(() => {
const repeat = currentScreen.value?.page_tiles_repeat;
return typeof repeat === "boolean" ? repeat : supports(0, 2, 65);
});
export const repeatable = (id: string) => pageTilesRepeat.value && pageTarget(id) > 0;
export const isGuition = computed(() => currentScreen.value?.board === "guition");
export const barMetrics = computed(() => BAR_METRICS[isGuition.value ? "guition" : "cyd"]);
export const currentTile = computed<Tile | undefined>(() =>
state.selectedTile && state.layout ? state.layout.tiles.find((t) => t.entity === state.selectedTile) : undefined);
state.selectedTile && state.layout?.tiles.includes(state.selectedTile) ? state.selectedTile : undefined);
// The reactive copy and the plain object are the same tile.
export const isSelected = (tile: Tile) => Boolean(state.selectedTile) && toRaw(state.selectedTile) === toRaw(tile);
// ---- Toasts ----
let toastTimer = 0;
@@ -191,11 +214,23 @@ export function liveOf(entity: string): Live | null {
}
// ---- Selecting a screen and editing its layout ----
// Every edit counts, so a save only clears the edits it sent (app 0.2.78).
let edits = 0;
export function markDirty() {
state.dirty = true;
state.saved = 0;
edits++;
}
export function select(id: string | null) {
// The open screen again, the way back from Firmware & USB, Alerts or Settings: its unsaved edits stay (app 0.2.78).
// Without edits it reads the stored layout again, which picks up what Claude or another tab changed meanwhile.
if (id === state.selected && state.layout && state.dirty) {
closeInspector();
state.tab = "layout";
state.menuOpen = false;
go("");
return;
}
if (id !== state.selected && state.dirty && !confirm("You have unsaved changes. Open a different screen anyway?")) return;
if (id !== state.selected) {
flushSettings();
@@ -244,11 +279,11 @@ export function placeTile(tile: Tile, target: number) {
// A click in the picker: the marked empty cell, else the first free cell.
export function addTile(id: string) {
const layout = state.layout;
if (!layout || layout.tiles.some((t) => t.entity === id) || layout.tiles.length >= tileLimit.value) return;
if (!layout || (!repeatable(id) && layout.tiles.some((t) => t.entity === id)) || layout.tiles.length >= tileLimit.value) return;
const tile = newTile(id);
const slot = state.insertAt >= 0 ? state.insertAt : firstFree(occupied(entriesOf(layout)), sizeOf(tile));
state.insertAt = -1;
if (slot >= 0 && placeTile(tile, slot)) openTile(id);
if (slot >= 0 && placeTile(tile, slot)) openTile(tile);
}
export function removeTile(tile: Tile) {
const layout = state.layout;
@@ -256,7 +291,7 @@ export function removeTile(tile: Tile) {
const index = layout.tiles.indexOf(tile);
if (index < 0) return;
layout.tiles.splice(index, 1);
if (state.selectedTile === tile.entity) closeInspector();
if (isSelected(tile)) closeInspector();
markDirty();
layout.pages = pageCount(entriesOf(layout), layout.pages);
toast(`${tile.name || entityName(tile.entity)} removed`, { label: "Undo", run: () => placeTile(tile, tile.slot) });
@@ -275,6 +310,17 @@ export function removePage(page: number) {
layout.pages = Math.max(1, pageCount(entriesOf(layout), layout.pages) - 1);
markDirty();
}
// Moving a tile without dragging it (app 0.2.78), for a finger on a phone and for anyone who can't drag: the first
// free cell of that page, else its first cell, where the tile in the way swaps places as it does for a drop or an
// arrow key. `page` counts from 0; the page after the last one starts a new page.
export function moveTileToPage(tile: Tile, page: number) {
const layout = state.layout;
if (!layout || !Number.isInteger(page) || page < 0 || page >= MAX_PAGES || page === pageOf(tile.slot)) return false;
const slot = firstFree(occupied(entriesOf(layout).filter((e) => e.tile !== tile)), sizeOf(tile), page * SLOTS_PER_PAGE);
const moved = placeTile(tile, slot >= 0 && pageOf(slot) === page ? slot : page * SLOTS_PER_PAGE);
if (!moved) toast(`There is no room for this tile on page ${page + 1}.`);
return moved;
}
export function pagesShown() {
const layout = state.layout;
if (!layout) return 1;
@@ -326,23 +372,24 @@ export function setTileOption(tile: Tile, key: string, value: unknown) {
normalize(layout);
layout.pages = pageCount(entriesOf(layout), layout.pages);
}
// A navigation tile goes to another page: its entity changes (screen.page_<n>), one tile per page it goes to.
// A navigation tile goes to another page: its entity changes (screen.page_<n>). One tile per page it goes to, unless
// the firmware takes several (0.2.65). The page after the last one becomes a new, empty page to fill (app 0.2.78).
export function retargetPageTile(tile: Tile, page: number) {
const entity = `screen.page_${page}`;
if (!state.layout || entity === tile.entity || !pageTarget(entity)) return false;
if (state.layout.tiles.some((t) => t.entity === entity)) { toast(`This screen already has a tile that goes to page ${page}.`); return false; }
const entity = `screen.page_${page}`, layout = state.layout;
if (!layout || entity === tile.entity || !pageTarget(entity)) return false;
if (!repeatable(entity) && layout.tiles.some((t) => t.entity === entity)) { toast(`This screen already has a tile that goes to page ${page}.`); return false; }
tile.entity = entity;
if (state.selectedTile) state.selectedTile = entity;
layout.pages = Math.max(pageCount(entriesOf(layout), layout.pages), page);
markDirty();
return true;
}
// ---- Inspector (the drawer) ----
export function openTile(entity: string) {
if (state.selectedTile !== entity) { state.iconPickerOpen = false; state.actionPickerOpen = false; state.actionSearch = ""; }
state.selectedTile = entity;
export function openTile(tile: Tile) {
if (!isSelected(tile)) { state.iconPickerOpen = false; state.actionPickerOpen = false; state.actionSearch = ""; }
state.selectedTile = tile;
state.inspector = { kind: "tile" };
loadCapabilities([entity]);
loadCapabilities([tile.entity]);
}
export function openBar(index: number) {
if (!(state.inspector?.kind === "bar" && state.inspector.index === index)) state.iconPickerOpen = false;
@@ -362,13 +409,19 @@ export function closeInspector() {
export async function save() {
if (state.busy || !state.layout || !state.selected) return;
state.busy = true;
// The layout as it leaves: a drag, the drawer or Add still work while the request is on its way (the add-on's check
// can take seconds after Copy or Import), and those changes aren't in it (app 0.2.78).
const sent = edits, screen = state.selected;
try {
// Screen settings apply on their own (flushSettings); the stored ones stay as they are.
const { settings: _settings, ...tiles } = state.layout;
await send(`screens/${encodeURIComponent(state.selected)}`, "PUT", tiles);
state.dirty = false;
state.saved = Date.now();
toast("Saved. Your screen is being updated.");
if (state.selected !== screen) toast(`Saved. ${state.inventory.screens.find((s) => s.id === screen)?.name || "The screen"} is being updated.`);
else if (edits === sent) {
state.dirty = false;
state.saved = Date.now();
toast("Saved. Your screen is being updated.");
} else toast("Saved. Your newest change isn't sent yet: press Save & send again.");
await refresh();
} catch (e: any) {
toast(e.message);
@@ -379,7 +432,7 @@ export async function save() {
// ---- Identify and the test alert (app 0.2.73): a screen's own show_alert action ----
export const canAlert = (screen: Screen | undefined) =>
Boolean(screen && screen.alert_action && versionAtLeast(screen.firmware, state.inventory.alerts?.min_firmware || "0.2.31"));
Boolean(screen && screen.alert_action && versionAtLeast(firmwareVersion(screen), state.inventory.alerts?.min_firmware || "0.2.31"));
export async function identify(screen: Screen) {
try {
await send(`screens/${encodeURIComponent(screen.id)}/identify`, "POST");
@@ -402,7 +455,7 @@ function adopt(source: Partial<Layout>, what: string) {
.map((t) => ({ entity: t.entity, name: typeof t.name === "string" ? t.name : "", slot: Number.isInteger(t.slot) ? t.slot : -1,
...(t.options && typeof t.options === "object" ? { options: { ...t.options } } : {}) }) as Tile);
const seen = new Set<string>();
const unique = tiles.filter((t) => !seen.has(t.entity) && seen.add(t.entity));
const unique = tiles.filter((t) => repeatable(t.entity) || (!seen.has(t.entity) && seen.add(t.entity)));
const kept = unique.slice(0, tileLimit.value);
layout.tiles = kept;
if (source.header && Array.isArray(source.header.items)) layout.header = { items: source.header.items.map((i) => ({ ...i })) };
@@ -449,11 +502,12 @@ export function importLayout(text: string) {
}
// ---- Updates with content (app 0.2.73): what a screen gets, and how far its update is ----
// The changelog comes with the full inventory only (app 0.2.78): the live payload goes out every few seconds.
export function whatsNew(screen: Screen): string[] {
const target = state.inventory.updates?.target;
const sections = (state.inventory.updates as any)?.changelog as { app: string; firmware: string; lines: string[] }[] | undefined;
if (!sections || !target) return [];
const since = screen.firmware;
const sections: ChangelogSection[] | undefined = state.inventory.changelog;
if (!Array.isArray(sections) || !target) return [];
const since = firmwareVersion(screen);
const lines: string[] = [];
for (const section of sections) {
if (versionAtLeast(section.firmware, target) && section.firmware !== target) continue;
+7 -2
View File
@@ -54,6 +54,8 @@ a:hover { text-decoration: underline; }
.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); }
/* The open screen has edits that aren't saved yet: the colour of the "Unsaved changes" chip. */
.nav-item .unsaved { width: 7px; height: 7px; border-radius: 50%; background: var(--warn); flex: none; }
.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); }
@@ -140,7 +142,9 @@ a:hover { text-decoration: underline; }
.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; }
/* manipulation, not pan-y (app 0.2.78): on a phone the row of pages scrolls sideways, also when the swipe starts on a
tile. The hold before a drag and the blocked touchmove during one still keep the page from scrolling. */
.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: manipulation; }
.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; }
@@ -201,7 +205,8 @@ a:hover { text-decoration: underline; }
.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; }
/* manipulation like .tile: a finger scrolls either way from here, a hold starts a drag (app 0.2.78). */
.ent { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 9px; width: 100%; text-align: left; cursor: grab; touch-action: manipulation; }
.ent:hover:not(:disabled) { background: var(--surface-2); }
.ent:disabled { opacity: 0.55; cursor: default; }
.ent .av { width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center; font-size: 17px; flex: none; }
+6
View File
@@ -29,7 +29,11 @@ export type Screen = {
id: string; name: string; online: boolean; area?: string; firmware?: string; board?: string;
layout: Layout; update?: UpdateInfo; settings?: SettingsView; delivery?: string; status?: string;
alert_action?: string; dismiss_action?: string;
// What the add-on reads from the firmware (app 0.2.78): its X.Y.Z (null when unknown), how many tiles it holds,
// whether it draws full-page tiles, and whether it takes several tiles that go to the same page.
firmware_known?: string | null; tile_limit?: number; full_page?: boolean; page_tiles_repeat?: boolean;
};
export type ChangelogSection = { app: string; firmware: string; lines: string[] };
export type Entity = { id: string; name: string; area?: string; device?: string; icon?: string; state?: string; tile?: boolean };
export type IconInfo = { name: string; cp: string; label: string };
export type Inventory = {
@@ -40,6 +44,8 @@ export type Inventory = {
builtin?: Entity[];
pending?: { friendly: string; file: string; installed?: boolean; downloaded?: boolean; api_key?: string }[];
updates?: { target: string; busy?: boolean; pending?: number; auto?: boolean };
// The CHANGELOG by release, newest first: only in the full inventory, not in the live payload (app 0.2.78).
changelog?: ChangelogSection[];
claude_skill?: { path: string; installed: boolean; current: boolean; restart?: boolean };
icons?: {
groups: { label: string; icons: IconInfo[] }[];
+35
View File
@@ -0,0 +1,35 @@
// The add-on's answers: what a failed request says to the person using the page.
import { afterEach, describe, expect, it, vi } from "vitest";
import { getJson, send } from "../src/api";
const answer = (status: number, body: string, type = "application/json") =>
vi.stubGlobal("fetch", vi.fn(async () => new Response(body, { status, headers: { "Content-Type": type } })));
afterEach(() => vi.unstubAllGlobals());
describe("api", () => {
it("shows the add-on's own sentence for any failed answer with a JSON error (app 0.2.78)", async () => {
answer(400, JSON.stringify({ error: "Home Assistant refused light.turn_on: the entity is unavailable." }));
await expect(send("screens/x", "PUT", {})).rejects.toThrow("Home Assistant refused light.turn_on: the entity is unavailable.");
answer(503, JSON.stringify({ error: "Home Assistant didn't answer in time. Try again in a moment." }));
await expect(getJson("capabilities?entity=light.a")).rejects.toThrow("Home Assistant didn't answer in time. Try again in a moment.");
answer(502, JSON.stringify({ error: "Bad gateway from Home Assistant." }));
await expect(getJson("states")).rejects.toThrow("Bad gateway from Home Assistant.");
});
it("keeps the general sentence for an answer that isn't JSON or has no error in it", async () => {
const general = "That didn't work. Refresh the page and try again.";
answer(502, "<html><body>502 Bad Gateway</body></html>", "text/html");
await expect(getJson("inventory")).rejects.toThrow(general);
answer(500, JSON.stringify({ detail: "nope" }));
await expect(getJson("inventory")).rejects.toThrow(general);
answer(500, JSON.stringify({ error: "" }));
await expect(getJson("inventory")).rejects.toThrow(general);
});
it("sends relative URLs with the CSRF header and reads an empty answer as null", async () => {
answer(200, "");
expect(await send("screens/x/identify", "POST")).toBeNull();
const call = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(call[0]).toBe("api/screens/x/identify");
expect(call[1].headers).toHaveProperty("X-Screen-CSRF");
});
});
+114 -4
View File
@@ -1,9 +1,12 @@
// The components that draw the state: a tile with live values, the library's filters, the ⌘K search.
import { mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import AppSettingsView from "../src/components/AppSettingsView.vue";
import CommandPalette from "../src/components/CommandPalette.vue";
import Library from "../src/components/Library.vue";
import Sidebar from "../src/components/Sidebar.vue";
import TileCard from "../src/components/TileCard.vue";
import TileInspector from "../src/components/TileInspector.vue";
import { state } from "../src/store";
import type { Inventory, Tile } from "../src/types";
@@ -17,7 +20,10 @@ function inventory(): Inventory {
{ id: "sensor.t", name: "Temperature", state: "21.5", area: "Living room" },
{ id: "cover.c", name: "Curtains", state: "open", area: "Living room" },
],
builtin: [{ id: "screen.clock", name: "Clock", device: "Built into the screen", area: "", state: "ok" }],
builtin: [
{ id: "screen.clock", name: "Clock", device: "Built into the screen", area: "", state: "ok" },
{ id: "screen.page_1", name: "Go to page 1", device: "Built into the screen", area: "", state: "ok" },
],
icons: { groups: [], weather: { partlycloudy: "F0595" }, sun: { below_horizon: "F0594" }, defaults: { light: "F0335", sensor: "F050F", cover: "F1846" }, fallback: "F0335", builtin: { "screen.clock": "F0150" }, controls: { minus: "F0374", plus: "F0415" } },
backgrounds: { auto: { label: "Default" }, none: { label: "None" } },
controls: { light: { default: "toggle", choices: [{ key: "toggle", label: "On/off" }, { key: "brightness", label: "Brightness" }, { key: "none", label: "None" }] } },
@@ -34,6 +40,7 @@ beforeEach(() => {
state.search = ""; state.filter = ""; state.room = ""; state.hidePlaced = false;
state.palette = false;
state.selectedTile = null; state.inspector = null;
state.dirty = false; state.tab = "layout";
});
function placed(tile: Tile) {
@@ -90,7 +97,7 @@ describe("TileCard", () => {
it("opens the tile's settings on a click", async () => {
const lamp = placed({ entity: "light.a", name: "", slot: 0 });
await lamp.trigger("click");
expect(state.selectedTile).toBe("light.a");
expect(state.selectedTile?.entity).toBe("light.a");
expect(state.inspector).toEqual({ kind: "tile" });
expect(lamp.classes()).toContain("chosen");
});
@@ -101,7 +108,7 @@ describe("Library", () => {
state.layout!.tiles.push({ entity: "light.a", name: "", slot: 0 });
const library = mount(Library);
const names = () => library.findAll(".ent .tx b").map((b) => b.text());
expect(names()).toEqual(["Clock", "Lamp A", "Lamp B", "Temperature", "Curtains"]);
expect(names()).toEqual(["Clock", "Go to page 1", "Lamp A", "Lamp B", "Temperature", "Curtains"]);
expect(library.find('.ent[title="light.a"]').attributes("disabled")).toBeDefined();
expect(library.find('.ent[title="light.a"] .av').classes()).toContain("on");
expect(library.find('.ent[title="light.b"] .av').classes()).toContain("gone");
@@ -110,7 +117,7 @@ describe("Library", () => {
expect(names()).toEqual(["Lamp B"]);
await library.find("#room").setValue("");
await library.find("#hide-placed").trigger("click");
expect(names()).toEqual(["Clock", "Lamp B", "Temperature", "Curtains"]);
expect(names()).toEqual(["Clock", "Go to page 1", "Lamp B", "Temperature", "Curtains"]);
state.filter = "light";
await library.vm.$nextTick();
expect(names()).toEqual(["Lamp B"]);
@@ -146,3 +153,106 @@ describe("full-page and navigation tiles on the mockup", () => {
expect(nav.find(".goto").text()).toBe("Page 3 ›");
});
});
describe("several tiles that go to the same page in the library (firmware 0.2.65)", () => {
it("keeps offering a placed navigation tile when the screen takes several, and adds another copy", async () => {
Object.assign(state.inventory.screens[0], { firmware: "0.2.65", page_tiles_repeat: true });
state.layout!.tiles.push({ entity: "screen.page_1", name: "", slot: 0 }, { entity: "light.a", name: "", slot: 1 });
const library = mount(Library);
const row = () => library.find('.ent[title="screen.page_1"]');
expect(row().attributes("disabled")).toBeUndefined();
expect(row().find(".add").text()).toBe("+");
expect(library.find('.ent[title="light.a"]').attributes("disabled")).toBeDefined();
await library.find("#hide-placed").trigger("click");
expect(row().exists()).toBe(true);
await row().trigger("click");
expect(state.layout!.tiles.filter((t) => t.entity === "screen.page_1")).toHaveLength(2);
});
it("marks it placed when the screen takes one per page", () => {
Object.assign(state.inventory.screens[0], { page_tiles_repeat: false });
state.layout!.tiles.push({ entity: "screen.page_1", name: "", slot: 0 });
const library = mount(Library);
expect(library.find('.ent[title="screen.page_1"]').attributes("disabled")).toBeDefined();
expect(library.find('.ent[title="screen.page_1"] .add').text()).toBe("✓");
});
});
describe("TileInspector: pages (app 0.2.78)", () => {
const row = (wrapper: ReturnType<typeof mount>, label: string) =>
wrapper.findAll(".f").find((f) => f.find(".f-label").exists() && f.find(".f-label").text() === label)!;
const choices = (wrapper: ReturnType<typeof mount>, label: string) => row(wrapper, label).findAll(".seg button").map((b) => b.text());
function open(tiles: any[], index: number) {
state.layout!.tiles.push(...tiles);
state.layout!.pages = 1;
const tile = state.layout!.tiles[index];
return { tile, drawer: mount(TileInspector, { props: { tile } }) };
}
it("offers the pages the screen has and the empty one after them as targets, and keeps one beyond those", async () => {
Object.assign(state.inventory.screens[0], { firmware: "0.2.63", full_page: true });
const { tile, drawer } = open([
{ entity: "light.a", name: "", slot: 0 }, { entity: "screen.page_2", name: "", slot: 1 }, { entity: "sensor.t", name: "", slot: 6 },
], 1);
expect(choices(drawer, "Goes to page")).toEqual(["1", "2", "3 (empty)"]);
expect(row(drawer, "Goes to page").find('[aria-pressed="true"]').text()).toBe("2");
tile.entity = "screen.page_7";
await drawer.vm.$nextTick();
expect(choices(drawer, "Goes to page")).toEqual(["1", "2", "3 (empty)", "7 (empty)"]);
expect(row(drawer, "Goes to page").find("small.warn").text()).toMatch(/no page 7/);
await row(drawer, "Goes to page").findAll(".seg button")[2].trigger("click");
expect(tile.entity).toBe("screen.page_3");
expect(state.layout!.pages).toBe(3);
expect(choices(drawer, "Goes to page")).toEqual(["1", "2", "3 (empty)", "4 (empty)"]);
expect(row(drawer, "Goes to page").find("small").classes()).not.toContain("warn");
});
it("moves the tile to another page or a new one with a tap", async () => {
const { tile, drawer } = open([{ entity: "light.a", name: "", slot: 0 }, { entity: "sensor.t", name: "", slot: 6 }], 0);
expect(choices(drawer, "Page")).toEqual(["1", "2", "New page"]);
expect(row(drawer, "Page").find('[aria-pressed="true"]').text()).toBe("1");
await row(drawer, "Page").findAll(".seg button")[1].trigger("click");
expect(tile.slot).toBe(7);
expect(state.dirty).toBe(true);
// Alone on the last page now: a new page would only leave this one empty.
const sensor = state.layout!.tiles.find((t) => t.entity === "sensor.t")!;
await row(drawer, "Page").findAll(".seg button")[0].trigger("click");
expect(tile.slot).toBe(0);
expect(sensor.slot).toBe(6);
const other = mount(TileInspector, { props: { tile: sensor } });
expect(choices(other, "Page")).toEqual(["1", "2"]);
await row(other, "Page").findAll(".seg button")[0].trigger("click");
expect(sensor.slot).toBe(1);
// One tile on one page: nowhere to go, so no row.
state.layout!.tiles = [];
const only = open([{ entity: "light.b", name: "", slot: 0 }], 0).drawer;
expect(row(only, "Page")).toBeUndefined();
});
});
describe("Sidebar", () => {
it("marks the open screen with unsaved edits and keeps them when it is chosen again", async () => {
state.dirty = true;
const layout = state.layout;
state.tab = "settings";
const sidebar = mount(Sidebar);
const item = sidebar.find("#screens .nav-item");
expect(item.find(".unsaved").exists()).toBe(true);
await item.trigger("click");
expect(state.layout).toBe(layout);
expect(state.dirty).toBe(true);
expect(state.tab).toBe("layout");
});
});
describe("AppSettingsView", () => {
it("shows what the current firmware brings from the changelog of the full inventory (app 0.2.78)", () => {
state.inventory.updates = { target: "0.2.65", pending: 0 };
state.inventory.changelog = [
{ app: "0.2.78", firmware: "0.2.65", lines: ["Several tiles go to the same page."] },
{ app: "0.2.76", firmware: "0.2.63", lines: ["Older."] },
];
const view = mount(AppSettingsView);
expect(view.find(".whatsnew summary").text()).toBe("What's new in firmware 0.2.65");
expect(view.findAll(".whatsnew li").map((li) => li.text())).toEqual(["Several tiles go to the same page."]);
delete state.inventory.changelog;
expect(mount(AppSettingsView).find(".whatsnew").exists()).toBe(false);
});
});
+53
View File
@@ -0,0 +1,53 @@
// What a drag near an edge scrolls (app 0.2.78): on a phone the row of pages sideways and the page itself up and
// down, on a wide window the canvas both ways.
import { beforeEach, describe, expect, it } from "vitest";
import { dragScrollers, edgeStep, scrollsAlong } from "../src/drag";
// jsdom has no layout: give an element the sizes a browser would measure, and the overflow longhands it computes.
function sized(element: HTMLElement, size: { scrollWidth?: number; clientWidth?: number; scrollHeight?: number; clientHeight?: number }) {
for (const [key, value] of Object.entries(size)) Object.defineProperty(element, key, { configurable: true, value });
return element;
}
let canvas: HTMLElement, pages: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '<div class="canvas"><div class="pages"></div></div>';
canvas = document.querySelector<HTMLElement>(".canvas")!;
pages = document.querySelector<HTMLElement>(".pages")!;
});
describe("the scroller of a drag", () => {
it("scrolls the canvas both ways on a wide window", () => {
// The canvas scrolls (overflow: auto); the row of pages is wider than it but doesn't scroll itself.
Object.assign(canvas.style, { overflowX: "auto", overflowY: "auto" });
sized(canvas, { scrollWidth: 1800, clientWidth: 900, scrollHeight: 1200, clientHeight: 700 });
sized(pages, { scrollWidth: 1800, clientWidth: 900 });
const { x, y } = dragScrollers(document);
expect(x).toBe(canvas);
expect(y).toBe(canvas);
});
it("scrolls the row of pages sideways and the page up and down on a phone", () => {
// At 960 px and narrower: the canvas is overflow: visible and the row of pages overflow-x: auto.
Object.assign(canvas.style, { overflowX: "visible", overflowY: "visible" });
pages.style.overflowX = "auto";
sized(canvas, { scrollWidth: 390, clientWidth: 390, scrollHeight: 900, clientHeight: 900 });
sized(pages, { scrollWidth: 1400, clientWidth: 358 });
const { x, y } = dragScrollers(document);
expect(x).toBe(pages);
expect(y).toBe(document.scrollingElement);
});
it("falls back to the canvas when the row of pages fits", () => {
pages.style.overflowX = "auto";
sized(pages, { scrollWidth: 358, clientWidth: 358 });
expect(dragScrollers(document).x).toBe(canvas);
expect(scrollsAlong(null, "x")).toBe(false);
});
});
describe("the step near an edge", () => {
it("moves 12 px towards an edge within 60 px of it, and not in between", () => {
expect(edgeStep(10, [0, 400])).toBe(-12);
expect(edgeStep(200, [0, 400])).toBe(0);
expect(edgeStep(390, [0, 400])).toBe(12);
expect(edgeStep(120, [100, 900])).toBe(-12);
});
});
+3
View File
@@ -103,6 +103,9 @@ describe("defaults, controls and versions", () => {
expect(versionAtLeast("1.0.0", "0.9.9")).toBe(true);
expect(versionAtLeast(undefined, "0.2.1")).toBe(false);
expect(versionAtLeast("unknown", "0.2.1")).toBe(false);
// Strict X.Y.Z: anything else is not a firmware release (the add-on works out firmware_known, app 0.2.78).
expect(versionAtLeast("0.2.63 (ESPHome 2026.6.2)", "0.2.1")).toBe(false);
expect(versionAtLeast(null, "0.2.1")).toBe(false);
expect(tileLimit("0.2.6")).toBe(10);
expect(tileLimit("0.2.7")).toBe(20);
expect(tileLimit("0.2.60")).toBe(20);
+224 -4
View File
@@ -1,8 +1,8 @@
// The store: selecting a screen, editing its layout, what's new, progress, copy and import.
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
addTile, canAlert, copyLayoutFrom, importLayout, layoutJson, liveOf, PHASES, removeTile, retargetPageTile, select, setTileOption, state, tileLimit,
topbarItems, topbarView, updateProgress, whatsNew,
addTile, canAlert, copyLayoutFrom, fullPage, importLayout, layoutJson, liveOf, moveTileToPage, pageTilesRepeat, PHASES, removeTile,
retargetPageTile, save, select, setTileOption, state, supports, tileLimit, topbarItems, topbarView, updateProgress, whatsNew,
} from "../src/store";
import type { Inventory, Screen } from "../src/types";
@@ -22,11 +22,13 @@ function inventory(): Inventory {
{ id: "light.a", name: "Lamp A", state: "on", area: "Living room" }, { id: "sensor.t", name: "Temperature", state: "21.5", area: "Living room" },
{ id: "switch.c", name: "Coffee", state: "off", area: "Kitchen" }, { id: "light.b", name: "Lamp B", state: "off", area: "Kitchen" },
],
updates: { target: "0.2.62", changelog: [
updates: { target: "0.2.62" },
// The full inventory carries the changelog next to the screens, not in the update summary (app 0.2.78).
changelog: [
{ app: "0.2.74", firmware: "0.2.62", lines: ["Full-page tiles.", "Live values."] },
{ app: "0.2.71", firmware: "0.2.60", lines: ["Slider stays put."] },
{ app: "0.2.40", firmware: "0.2.34", lines: ["English."] },
] } as any,
],
alerts: { min_firmware: "0.2.31" },
controls: { light: { default: "toggle", choices: [{ key: "toggle", label: "On/off" }, { key: "brightness", label: "Brightness" }, { key: "none", label: "None" }] } },
backgrounds: { auto: { label: "Default" }, orange: { label: "Orange", color: "#ffe1c6" } },
@@ -188,3 +190,221 @@ describe("full-page and navigation tiles", () => {
expect(retargetPageTile(nav, 9)).toBe(false);
});
});
describe("the open screen chosen again (app 0.2.78)", () => {
it("keeps unsaved edits and only brings the layout back into view", () => {
select("living");
addTile("light.b");
state.tab = "settings";
state.route = "#firmware";
select("living");
expect(state.layout!.tiles.map((t) => t.entity)).toEqual(["light.a", "sensor.t", "light.b"]);
expect(state.dirty).toBe(true);
expect(state.tab).toBe("layout");
expect(state.inspector).toBeNull();
expect(state.route).toBe("");
expect(window.confirm).not.toHaveBeenCalled();
});
it("reads the stored layout again when nothing is unsaved, so a change from Claude or another tab shows up", () => {
select("living");
state.inventory.screens[0].layout.tiles.push({ entity: "light.b", name: "", slot: 2 });
select("living");
expect(state.layout!.tiles.map((t) => t.entity)).toEqual(["light.a", "sensor.t", "light.b"]);
expect(state.dirty).toBe(false);
});
});
describe("saving while you keep editing (app 0.2.78)", () => {
// The PUT waits until the test answers it; every other request fails, which the store shrugs off.
function slowServer() {
const control = { answer: () => {} };
const fetchMock = vi.fn((_url: string, init?: RequestInit) => init?.method !== "PUT"
? Promise.reject(new Error("offline"))
: new Promise((resolve) => { control.answer = () => resolve({ ok: true, status: 200, text: async () => "{}", json: async () => ({}) }); }));
vi.stubGlobal("fetch", fetchMock);
return { control, fetchMock };
}
it("marks the layout saved when nothing changed while the request was on its way", async () => {
select("living");
addTile("light.b");
const { control } = slowServer();
const saving = save();
control.answer();
await saving;
expect(state.dirty).toBe(false);
expect(state.saved).toBeGreaterThan(0);
expect(state.toast?.message).toBe("Saved. Your screen is being updated.");
});
it("keeps a change made during the save unsaved and says so", async () => {
select("living");
addTile("light.b");
const { control, fetchMock } = slowServer();
const saving = save();
setTileOption(state.layout!.tiles[0], "background", "orange");
control.answer();
await saving;
expect(state.dirty).toBe(true);
expect(state.saved).toBe(0);
expect(state.toast?.message).toBe("Saved. Your newest change isn't sent yet: press Save & send again.");
// What went out is the layout from before that change.
const body = JSON.parse((fetchMock.mock.calls[0] as any[])[1].body);
expect(body.tiles.map((t: any) => t.entity)).toEqual(["light.a", "sensor.t", "light.b"]);
expect(body.tiles[0].options?.background).toBeUndefined();
});
it("leaves the unsaved flag of a screen opened meanwhile alone", async () => {
select("living");
addTile("light.b");
const { control } = slowServer();
const saving = save();
select("kitchen");
addTile("light.b");
control.answer();
await saving;
expect(state.selected).toBe("kitchen");
expect(state.dirty).toBe(true);
expect(state.toast?.message).toBe("Saved. Living room is being updated.");
});
});
describe("moving a tile to another page without dragging (app 0.2.78)", () => {
it("takes the first free cell of that page, or a new page after the last one", () => {
select("living");
const [lamp, sensor] = state.layout!.tiles;
state.layout!.pages = 2;
expect(moveTileToPage(sensor, 1)).toBe(true);
expect(sensor.slot).toBe(6);
expect(moveTileToPage(lamp, 1)).toBe(true);
expect(lamp.slot).toBe(7);
expect(moveTileToPage(lamp, 2)).toBe(true);
expect(lamp.slot).toBe(12);
expect(state.layout!.pages).toBe(3);
expect(state.dirty).toBe(true);
expect(moveTileToPage(lamp, 2)).toBe(false);
expect(moveTileToPage(lamp, 8)).toBe(false);
});
it("swaps with the first tile of a full page, as a drop there does", () => {
select("living");
state.layout!.tiles = [
...Array.from({ length: 6 }, (_, i) => ({ entity: `light.l${i}`, name: "", slot: i })),
{ entity: "light.b", name: "", slot: 6 },
];
const b = state.layout!.tiles[6];
expect(moveTileToPage(b, 0)).toBe(true);
expect(b.slot).toBe(0);
expect(state.layout!.tiles.find((t) => t.entity === "light.l0")!.slot).toBe(6);
});
it("moves a full-page tile to a page of its own and says so when a tile finds no room", () => {
select("living");
state.layout!.tiles = [
{ entity: "light.big", name: "", slot: 0, options: { size: "full" } },
{ entity: "light.w", name: "", slot: 6, options: { size: "wide" } },
...[8, 9, 10, 11].map((slot) => ({ entity: `light.p${slot}`, name: "", slot })),
...Array.from({ length: 36 }, (_, i) => ({ entity: `light.l${i}`, name: "", slot: 12 + i })),
];
const [big, wide] = state.layout!.tiles;
// Page 1 holds the full-page tile and every other page is taken: the wide tile would have to push it off.
expect(moveTileToPage(wide, 0)).toBe(false);
expect(wide.slot).toBe(6);
expect(state.toast?.message).toBe("There is no room for this tile on page 1.");
// The full-page tile and the tiles of page 2 change places.
expect(moveTileToPage(big, 1)).toBe(true);
expect(big.slot).toBe(6);
expect(state.layout!.tiles.filter((t) => t.slot < 6).map((t) => t.entity).sort()).toEqual(["light.p10", "light.p11", "light.p8", "light.p9", "light.w"]);
});
});
describe("what the add-on says about a screen's firmware (app 0.2.78)", () => {
it("takes the tile limit and the features from the screen entry", () => {
const living = state.inventory.screens[0];
Object.assign(living, { firmware: "unknown", firmware_known: null, tile_limit: 48, full_page: true, page_tiles_repeat: true });
select("living");
expect(tileLimit.value).toBe(48);
expect(fullPage.value).toBe(true);
expect(pageTilesRepeat.value).toBe(true);
// A version Home Assistant can't report right now no longer cuts a copied or imported layout to ten.
const many = Array.from({ length: 30 }, (_, i) => ({ entity: `light.l${i}`, name: "", slot: i }));
importLayout(JSON.stringify({ tiles: many }));
expect(state.layout!.tiles).toHaveLength(30);
expect(state.toast?.message).toBe("Layout imported. Save & send when it looks right.");
});
it("goes by firmware_known for the version and the notes", () => {
const living = state.inventory.screens[0];
Object.assign(living, { firmware: "unknown", firmware_known: "0.2.60" });
select("living");
expect(supports(0, 2, 60)).toBe(true);
expect(supports(0, 2, 61)).toBe(false);
expect(whatsNew(living)).toEqual(["Full-page tiles.", "Live values."]);
expect(canAlert(living)).toBe(true);
Object.assign(living, { firmware: "0.2.63", firmware_known: null });
expect(supports(0, 2, 31)).toBe(false);
expect(canAlert(living)).toBe(false);
});
it("keeps today's rule, with a strict X.Y.Z, for a screen entry without the fields", () => {
select("living");
expect(tileLimit.value).toBe(20);
expect(fullPage.value).toBe(false);
expect(pageTilesRepeat.value).toBe(false);
state.inventory.screens[0].firmware = "0.2.65";
expect(tileLimit.value).toBe(48);
expect(fullPage.value).toBe(true);
expect(pageTilesRepeat.value).toBe(true);
state.inventory.screens[0].firmware = "0.2.65 (ESPHome 2026.6.2)";
expect(tileLimit.value).toBe(10);
expect(fullPage.value).toBe(false);
state.inventory.screens[0].firmware = "unknown";
expect(tileLimit.value).toBe(10);
});
});
describe("the changelog from the full inventory (app 0.2.78)", () => {
it("reads the notes next to the screens and ignores an update summary that still has them", () => {
const living = state.inventory.screens[0];
state.inventory.updates = { target: "0.2.62", changelog: [{ app: "0.2.1", firmware: "0.2.62", lines: ["Old place."] }] } as any;
expect(whatsNew(living)).toEqual(["Full-page tiles.", "Live values."]);
});
it("shows nothing without a changelog", () => {
delete state.inventory.changelog;
expect(whatsNew(state.inventory.screens[1])).toEqual([]);
});
});
describe("several tiles that go to the same page (firmware 0.2.65)", () => {
it("adds, retargets and imports another copy when the screen takes them", () => {
Object.assign(state.inventory.screens[0], { firmware: "0.2.65", page_tiles_repeat: true });
select("living");
addTile("screen.page_1");
addTile("screen.page_1");
expect(state.layout!.tiles.filter((t) => t.entity === "screen.page_1")).toHaveLength(2);
addTile("screen.page_2");
const nav = state.layout!.tiles.find((t) => t.entity === "screen.page_2")!;
expect(retargetPageTile(nav, 1)).toBe(true);
expect(state.layout!.tiles.filter((t) => t.entity === "screen.page_1")).toHaveLength(3);
// Only a navigation tile repeats; any other entity still appears once.
addTile("light.a");
expect(state.layout!.tiles.filter((t) => t.entity === "light.a")).toHaveLength(1);
importLayout(JSON.stringify({ tiles: [
{ entity: "screen.page_1", slot: 0 }, { entity: "screen.page_1", slot: 6 }, { entity: "light.a", slot: 1 }, { entity: "light.a", slot: 2 },
] }));
expect(state.layout!.tiles.map((t) => t.entity)).toEqual(["screen.page_1", "light.a", "screen.page_1"]);
});
it("keeps one tile per page when the screen doesn't", () => {
Object.assign(state.inventory.screens[0], { firmware: "0.2.65", page_tiles_repeat: false });
select("living");
addTile("screen.page_1");
addTile("screen.page_1");
expect(state.layout!.tiles.filter((t) => t.entity === "screen.page_1")).toHaveLength(1);
addTile("screen.page_2");
expect(retargetPageTile(state.layout!.tiles.find((t) => t.entity === "screen.page_2")!, 1)).toBe(false);
expect(state.toast?.message).toMatch(/already has a tile that goes to page 1/);
});
it("starts the empty page after the last one when a tile goes there", () => {
select("living");
addTile("screen.page_1");
const nav = state.layout!.tiles.find((t) => t.entity === "screen.page_1")!;
expect(state.layout!.pages).toBe(1);
expect(retargetPageTile(nav, 1)).toBe(false);
expect(retargetPageTile(nav, 2)).toBe(true);
expect(state.layout!.pages).toBe(2);
expect(state.dirty).toBe(true);
});
});