Open tile settings in a sheet above the mockup and remove tiles in place

App 0.2.14: clicking a card opens a settings sheet (bottom sheet on
mobile) with segmented choices that apply live to the mockup behind it,
so the page no longer scrolls to a list below. Each card gets a remove
button with an undo toast; the old per-tile list is gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-13 09:17:34 +02:00
parent 4af92e1b4c
commit 84d9aa8f3e
6 changed files with 205 additions and 198 deletions
+11
View File
@@ -53,3 +53,14 @@
CYD 10/10 (`count=7`, ~52,5 kB vrij). Beide handmatige profielen compileren.
De skeletonwissel en de CYD-kleuren zijn door de eigenaar visueel te
beoordelen; er is geen langdurige stabiliteitsproef uitgevoerd.
## App 0.2.14 (zelfde firmware)
Tegelinstellingen openen nu in een sheet boven de mockup (mobiel: onderaan)
met knoppen voor naam, weergave, breedte, tikgedrag, slider, geschiedenis en
kleur; elke keuze werkt live door in de mockup erachter. Het kruisje op een
kaart verwijdert direct, met **Ongedaan maken** in de melding; de tegellijst
onder de mockup is vervallen. In de browser met de gemockte inventory
gecontroleerd: sheet-layout, weergave "Grote waarde" direct zichtbaar in de
mockup, verwijderen en ongedaan maken herstellen de volgorde, het kruisje
start geen sleep. 55 Python-tests geslaagd; geen firmwarewijziging.
+6
View File
@@ -1,3 +1,9 @@
## 0.2.14 (firmware 0.2.15 blijft actueel)
- Tegelinstellingen openen in een paneel bóven de schermmockup (op mobiel een sheet onderaan): naam, weergave, breedte, tikgedrag, slider, geschiedenis en kleur als knoppen, direct zichtbaar in de mockup erachter. Geen springende pagina meer.
- Verwijderen kan direct in de mockup met het kruisje op een tegel; de melding onderaan heeft **Ongedaan maken**.
- De tegellijst onder de mockup is vervallen; ordenen doe je door te slepen.
## 0.2.13 (firmware 0.2.15)
- Editor: sleep tegels in de schermmockup om te ordenen en sleep entiteiten rechtstreeks uit de lijst naar een plek in de mockup — met muis én touch (even vasthouden). De opslaan-balk blijft altijd in beeld.
+153 -193
View File
@@ -13,11 +13,18 @@ const node = (tag, text, cls) => {
if (cls) n.className = cls;
return n;
};
function toast(message) {
$("#toast").textContent = message;
$("#toast").hidden = false;
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(() => ($("#toast").hidden = true), 5000);
toast.timer = setTimeout(() => (t.hidden = true), action ? 8000 : 5000);
}
async function api(path, options = {}) {
const response = await fetch(`api/${path}`, {
@@ -53,6 +60,7 @@ function select(id) {
return;
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);
@@ -284,7 +292,9 @@ function renderPreview() {
if (place.page !== page) return;
// Keep the grid honest: an empty right column before a wide tile is a real gap on the screen.
for (; filled < place.slot; filled++) grid.append(node("span", "", "preview-gap"));
const card = node("button", undefined, "preview-tile");
const card = node("div", undefined, "preview-tile");
card.tabIndex = 0;
card.setAttribute("role", "button");
const name = tile.name || entityName(tile.entity);
const background=inventory.backgrounds?.[tile.options?.background]?.color;
if(background)card.style.backgroundColor=background;
@@ -294,11 +304,14 @@ function renderPreview() {
enableDrag(card, { kind: "tile", index });
card.classList.toggle("chosen", selectedTile === tile.entity);
card.setAttribute("aria-label", `Tegel ${index + 1}: ${name}, instellen`);
card.onclick = () => {
selectedTile = tile.entity;
renderTiles();
document.querySelectorAll("#tiles > .tile")[index]?.scrollIntoView({behavior: "smooth", block: "center"});
};
card.onclick = () => openTileSheet(index);
card.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openTileSheet(index); } };
const remove = node("button", "✕", "preview-remove");
remove.type = "button";
remove.title = "Tegel verwijderen";
remove.setAttribute("aria-label", `${name} verwijderen`);
remove.onclick = (e) => { e.stopPropagation(); removeTile(index); };
card.append(remove);
if (tile.options?.inline === "slider") card.append(node("span", "", "preview-slider"));
const display = tile.options?.display;
if (display && display !== "standard") card.append(node("small", displayNames[display] || display));
@@ -318,193 +331,140 @@ function renderPreview() {
}
function renderTiles() {
renderPreview();
$("#tiles").replaceChildren();
$("#count").textContent = `${layout.tiles.length} / ${tileLimit()}${tileLimit()===10?" · update firmware voor 20":""}`;
$("#no-tiles").hidden = layout.tiles.length > 0;
const { placement } = packTiles(layout.tiles);
layout.tiles.forEach((tile, i) => {
if (i > 0 && placement[i].page !== placement[i - 1].page) $("#tiles").append(node("li", `Pagina ${placement[i].page + 1}`, "page-break"));
const li = node("li", undefined, "tile");
li.classList.toggle("selected-tile", selectedTile === tile.entity);
const content = node("div"),
input = node("input");
input.value = tile.name;
input.placeholder = entityName(tile.entity);
input.maxLength = 60;
input.setAttribute("aria-label", `Naam voor tegel ${i + 1}`);
input.oninput = () => {
tile.name = input.value;
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)} verwijderd`, {
label: "Ongedaan maken",
run: () => { layout.tiles.splice(Math.min(index, layout.tiles.length), 0, tile); markDirty(); renderTiles(); renderResults(); },
});
}
// 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;
function openTileSheet(index) {
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;
}
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", "Sluiten");
close.onclick = closeTileSheet;
head.append(domainBadge(tile.entity), 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("Naam op het scherm", nameInput));
const displays = domain === "screen"
? [["digital", "Digitale klok"], ["analog", "Analoge klok"]]
: [["standard", "Naam en status"], ["watch", "Grote waarde"]];
if (domain === "weather") displays.push(["forecast", "Weersvoorspelling"]);
if (domain === "sensor") displays.push(["graph", "Grafiek"]);
if (domain === "sun") displays.push(["sunpath", "Zonnebaan"]);
const current = (key, fallback) => tile.options?.[key] ?? fallback;
const set = (key, value) => {
tile.options = { ...tile.options, [key]: value };
if (key === "display" && value === "watch") tile.options.inline = "none";
if (key === "display" && ["forecast", "sunpath"].includes(value)) tile.options.size = "wide";
if (key === "inline" && value === "slider") tile.options.display = "standard";
markDirty();
renderTiles();
};
body.append(field("Weergave", segmented(displays, current("display", domain === "screen" ? "digital" : "standard"), (v) => set("display", v))));
body.append(field("Breedte", segmented([["single", "Normaal"], ["wide", "Dubbelbreed"]], current("size", "single"), (v) => set("size", v))));
if (domain !== "screen") {
const taps = [["auto", "Automatisch"], ["detail", "Bediening openen"], ["none", "Alleen bekijken"]];
if (["light", "switch", "input_boolean", "fan", "media_player"].includes(domain)) taps.push(["toggle", "Aan / uit"]);
body.append(field("Bij aantikken", segmented(taps, current("tap", "auto"), (v) => set("tap", v))));
}
if (["light", "fan", "cover", "number", "input_number", "media_player"].includes(domain))
body.append(field("Kleine slider op de tegel", segmented([["none", "Nee"], ["slider", "Ja, direct bedienen"]], current("inline", "none"), (v) => set("inline", v))));
if (domain === "sensor")
body.append(field("Geschiedenis", segmented([[1, "1 uur"], [6, "6 uur"], [24, "24 uur"]], current("history_hours", 24), (v) => set("history_hours", Number(v)))));
const palette = node("div", undefined, "sheet-field");
palette.append(node("span", "Pastel achtergrond"));
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", `Achtergrond: ${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("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();
};
content.append(input, node("small", tile.entity));
const actions = node("div", undefined, "tile-actions");
for (const [label, title, action, disabled] of [
["↑", "Omhoog", () => move(i, i - 1), i === 0],
["↓", "Omlaag", () => move(i, i + 1), i === layout.tiles.length - 1],
[
"✕",
"Verwijderen",
() => {
layout.tiles.splice(i, 1);
markDirty();
renderTiles();
renderResults();
},
false,
],
]) {
const b = node("button", label);
b.title = title;
b.setAttribute("aria-label", title);
b.disabled = disabled;
b.onclick = action;
actions.append(b);
}
const options = node("details", undefined, "tile-options");
options.append(node("summary", "Bediening & weergave instellen"));
options.open = selectedTile === tile.entity;
options.ontoggle = () => {
li.classList.toggle("selected-tile", options.open);
if (options.open) {
selectedTile = tile.entity;
renderPreview();
document.querySelectorAll(".tile-options").forEach((other) => {
if (other !== options) other.open = false;
});
}
};
li.onclick = (e) => {
if (!e.target.closest("input, select, button, summary, .tile-options"))
options.open = !options.open;
};
const palette = node("fieldset", undefined, "tile-palette");
palette.append(node("legend", "Pastel achtergrond"));
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", `Achtergrond: ${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("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,node("small", "Donkere tekst blijft leesbaar. Kies Standaard voor de normale kleuren. Vereist schermfirmware 0.2.10+."));
options.append(palette);
const controls = {};
const domain = tile.entity.split(".")[0];
const displays = domain === "screen"
? [["digital", "Digitale klok"], ["analog", "Analoge klok"]]
: [["standard", "Naam en status"], ["watch", "Grote waarde"]];
if (domain === "weather") displays.push(["forecast", "Weersvoorspelling (dubbelbreed)"]);
if (domain === "sensor") displays.push(["graph", "Grafiek van de geschiedenis"]);
if (domain === "sun") displays.push(["sunpath", "Zonnebaan (dubbelbreed)"]);
const fields = [
[
"tap",
"Bij aantikken",
[
["auto", "Automatisch"],
["detail", "Bediening openen"],
["none", "Alleen bekijken"],
],
],
["display", "Weergave", displays],
["size", "Breedte (firmware 0.2.14+)", [["single", "Normaal"], ["wide", "Dubbelbreed"]]],
];
if (domain === "screen") fields.shift();
if (
["light", "switch", "input_boolean", "fan", "media_player"].includes(
domain,
)
)
fields[0][2].push(["toggle", "Aan / uit"]);
if (
[
"light",
"fan",
"cover",
"number",
"input_number",
"media_player",
].includes(domain)
)
fields.push([
"inline",
"Kleine slider op deze tegel?",
[
["none", "Nee"],
["slider", "Ja, direct bedienen"],
],
]);
if (domain === "sensor")
fields.push([
"history_hours",
"Geschiedenis",
[
[1, "1 uur"],
[6, "6 uur"],
[24, "24 uur"],
],
]);
for (const [key, title, choices] of fields) {
const label = node("label", title),
input = node("select");
for (const [value, text] of choices) {
const option = node("option", text);
option.value = value;
input.append(option);
}
input.value =
tile.options?.[key] ??
(key === "tap"
? "auto"
: key === "display"
? (domain === "screen" ? "digital" : "standard")
: key === "history_hours"
? 24
: key === "size"
? "single"
: "none");
controls[key] = input;
input.onchange = () => {
tile.options = {
...tile.options,
[key]: key === "history_hours" ? Number(input.value) : input.value,
};
if (key === "display" && input.value === "watch")
tile.options.inline = "none";
if (key === "display" && ["forecast", "sunpath"].includes(input.value))
tile.options.size = "wide";
if (key === "inline" && input.value === "slider")
tile.options.display = "standard";
for (const [field, control] of Object.entries(controls)) {
if (tile.options[field] !== undefined)
control.value = tile.options[field];
}
markDirty();
renderPreview();
};
label.append(input);
options.append(label);
}
if (domain !== "screen") {
const inspectTile = node("button", "Inspecteer deze tegel", "quiet");
inspectTile.onclick = () => inspect(tile.entity);
options.append(inspectTile);
}
content.append(options);
li.append(domainBadge(tile.entity), content, actions);
$("#tiles").append(li);
});
swatches.append(button);
}
palette.append(swatches);
body.append(palette);
const foot = node("div", undefined, "sheet-foot");
const remove = node("button", "Verwijderen", "quiet danger");
remove.type = "button";
remove.onclick = () => removeTile(sheetIndex);
foot.append(remove);
if (domain !== "screen") {
const inspectButton = node("button", "Inspecteer", "quiet");
inspectButton.type = "button";
inspectButton.onclick = () => { const entity = tile.entity; closeTileSheet(); inspect(entity); };
foot.append(inspectButton);
}
const done = node("button", "Klaar");
done.type = "button";
done.onclick = closeTileSheet;
foot.append(done);
sheet.append(head, body, foot);
}
function addTile(id, at) {
if (layout.tiles.some((t) => t.entity === id) || layout.tiles.length >= tileLimit()) return;
@@ -520,7 +480,7 @@ function addTile(id, at) {
const drag = { active: false, source: null, element: null, ghost: null, target: null, timer: 0, start: null, offset: null, pointerId: null, suppressUntil: 0, last: null, scroller: 0 };
function enableDrag(element, source) {
element.addEventListener("pointerdown", (e) => {
if (e.button !== 0 || element.disabled) return;
if (e.button !== 0 || element.disabled || e.target.closest(".preview-remove")) return;
Object.assign(drag, { source, element, start: { x: e.clientX, y: e.clientY }, pointerId: e.pointerId, active: false });
clearTimeout(drag.timer);
if (e.pointerType === "touch") drag.timer = setTimeout(() => beginDrag(e), 260);
+5 -4
View File
@@ -112,12 +112,12 @@
<span id="count">0 / 10</span>
</div>
<p class="hint">
Klik op een tegel om bediening, weergave en breedte te kiezen.
Sleep om te ordenen. Zes vakjes per pagina; een dubbelbrede
tegel telt voor twee en begint altijd links.
Klik op een tegel voor naam, weergave, breedte en kleur; het
kruisje verwijdert. Sleep om te ordenen of sleep entiteiten uit
de lijst hierheen. Zes vakjes per pagina; een dubbelbrede tegel
telt voor twee en begint altijd links.
</p>
<div id="layout-preview" aria-label="Schermindeling"></div>
<ol id="tiles"></ol>
<p id="no-tiles" class="hint">
Voeg je eerste lamp, scène of apparaat toe via de kiezer.
</p>
@@ -351,6 +351,7 @@
<p id="firmware-status" role="status"></p>
<pre id="firmware-log"></pre>
</dialog>
<dialog id="tile-sheet" class="tile-sheet" aria-label="Tegel instellen"></dialog>
<div id="toast" role="status" hidden></div>
</body>
</html>
+29
View File
@@ -544,6 +544,9 @@ dialog label {
bottom: 24px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 16px;
padding: 14px 22px;
background: var(--ink);
color: white;
@@ -842,3 +845,29 @@ pre {
.palette-sample { width: 36px; height: 28px; border-radius: 7px; border: 1px solid #0002; }
.palette-auto { background: linear-gradient(135deg, #fff 50%, #e5e7eb 50%); }
.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; }
}
+1 -1
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager
version: "0.2.13"
version: "0.2.14"
slug: esp_screen_manager
description: Kies tegels voor je CYD en Guition touchscreen, zonder opnieuw flashen.
url: https://github.com/MaxGramser/homeassistant_espscreen