Musterseite verworfen, Zahnrad-Regression behoben, Fotomenue als Aktionsblatt

Musterseite entfernt (companion-app/muster, vite.muster.config.ts,
/config/www/dmmuster). Ansage des Eigentuemers: sie fuehrt nur zu neuen
Fehlerquellen. Ausloeser war ein Befund, den ich als App-Fehler gemeldet hatte
- Ringe und Typenschild schwarz auf schwarz - und der nur dort bestand.

Behoben:

* Das Zahnrad im Autohaus-Kasten hatte seine Ecke verlassen (58/288 statt
  8/8). Ursache war mein HIG-Durchgang von gestern: .zahnrad kam in die
  Gruppe fuer die 44x44-Trefferflaeche und bekam damit position:relative,
  was das absolute aus dem Basisblatt ueberschrieb. Die Auflage war
  ueberfluessig - .zahnrad ist dort schon 44x44.
* Das Fotomenue hing mit position:absolute an der Kachel statt am
  Bildschirm und lag bei der hohen Raeder-Kachel ausserhalb des Sichtfelds;
  einen Abbrechen-Knopf hatte es nie. Das Panel benutzt jetzt dasselbe
  Aktionsblatt wie die App. .bildmenu ist samt Zustand, Markup, totem
  Handler und CSS entfernt.
* Der Blattkopf las sich als dritte Auswahl (linksbuendig, 15px, --fg).
  Jetzt zentriert, 13px, gedaempft - Apples Muster, in beiden Codebasen.
* "Anstehende Termine" stand 5px ueber "Oelwechsel" und wirkte als dessen
  Beischrift. Entfernt.
* Wartungsplan-Formular: Art nach oben, Datum 124px statt Browser-Vorgabe,
  Werkstatt ueber die volle Zeile (218 -> 306px), Kosten 116px. Zwei
  Erklaertexte entfernt.
* Thema folgt jetzt einer Systemumstellung im laufenden Betrieb. Vorher las
  es prefers-color-scheme genau einmal beim Laden. Drei Tests, die ohne die
  Behebung nachweislich fallen.

Geprueft: 295 App-Tests, Panel node --check, 0 Tracebacks, alle Aenderungen
live an der Testinstanz gemessen.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-05 02:24:08 +02:00
co-authored by Claude Opus 5
parent 863284e540
commit e9c16325cc
16 changed files with 2674 additions and 2467 deletions
@@ -2906,7 +2906,12 @@ let svAuswahl = 0;
function vService() {
const sv = CAR.service;
return `
<div class="tile"><span class="label">Anstehende Termine</span>
<!-- Ohne Kachelueberschrift: "Anstehende Termine" stand 13px grau und nur
5px ueber "Oelwechsel" (16px, --fg) und las sich damit als dessen
Beischrift statt als Titel (gemessen und gemeldet am 05.09.2026). Die
drei Zeilen benennen sich selbst, und die Seite heisst bereits
"Service" - eine Ueberschrift traegt hier nichts bei. -->
<div class="tile">
${termine().map((t) => {
const artIcon = ART_ICON[t.art];
const iconMarkup = artIcon ? ciSVG(artIcon, ART_ICON_GROESSE[t.art]) : "";
@@ -3295,25 +3300,27 @@ function vSbuch() {
<span class="label" style="margin-top:12px">${e.datum} · ${esc(e.werkstatt)}</span>
</div>
<div class="tile"><span class="label">Eintrag</span>
<div class="feld"><label>Datum</label><input value="${esc(e.datum)}" data-sb="${i}|datum"></div>
<div class="feld"><label>Kilometerstand</label>
<span class="mitEinheit"><input type="number" value="${e.km}" data-sb="${i}|km"><span>km</span></span></div>
<div class="feld"><label>Art</label>
<select data-sb="${i}|art">
${!artBekannt ? `<option value="${esc(e.art)}" selected>${esc(e.art)}</option>` : ""}
${SERVICE_ARTEN.map((a) => `<option ${a === e.art ? "selected" : ""}>${esc(a)}</option>`).join("")}
</select></div>
<div class="feld"><label>Werkstatt</label><input value="${esc(e.werkstatt)}" data-sb="${i}|werkstatt"></div>
<div class="feld"><label>Datum</label><input class="w-datum" value="${esc(e.datum)}" data-sb="${i}|datum"></div>
<div class="feld"><label>Kilometerstand</label>
<span class="mitEinheit"><input type="number" value="${e.km}" data-sb="${i}|km"><span>km</span></span></div>
<!-- Label oben, Feld ueber die ganze Zeile: ein echter Werkstattname
("Audi AG Kundendienst Center Ingolstadt") braucht 322px, neben einem
Label bleiben auf 375px aber nur 218 - gemessen am 05.09.2026. Dieselbe
Bauweise wie der Reifen-Kommentar. -->
<div class="feld feld--breit"><label>Werkstatt</label>
<input value="${esc(e.werkstatt)}" data-sb="${i}|werkstatt"></div>
<div class="feld" style="border-bottom:0"><label>Kosten</label>
<span class="mitEinheit"><input type="number" step="0.01" value="${e.kosten ?? ""}"
<span class="mitEinheit"><input class="w-betrag" type="number" step="0.01" value="${e.kosten ?? ""}"
data-sb="${i}|kosten" placeholder="noch offen"><span></span></span></div>
<span class="label" style="margin-top:14px">Kosten fließen in die Jahresübersicht ein</span>
</div>
<div class="tile"><span class="label">Durchgeführte Arbeiten</span>
<textarea class="notiz" data-sb="${i}|arbeiten" rows="7"
placeholder="Was wurde gemacht, welche Teile wurden getauscht, was ist aufgefallen">${esc(e.arbeiten || "")}</textarea>
<span class="label" style="margin-top:12px">Beim Verkauf ist diese Liste mehr wert als die Rechnung
sie zeigt, dass gepflegt wurde</span>
</div>
${istEntwurf ? `<button class="aktion" data-sbspeichern>Speichern</button>`
: `<button class="aktion loeschen" data-sbdel="${i}">Eintrag löschen</button>`}`;
@@ -3647,19 +3654,13 @@ function vEinst() {
${(() => {
const info = bildInfo(startIndex());
return `<div style="margin-top:12px">
<div data-bildklick="${esc(info.datei)}">
<div data-bildklick="${esc(info.datei)}" data-bildname="${esc(info.label)}">
${/* "klein" = das Bild bestimmt die Hoehe, statt im 16:9-Kasten
beschnitten zu werden (siehe .carfix.klein). Wer hier prueft,
ob das richtige Foto hochgeladen ist, muss es ganz sehen. */""
}${bildMitPlatzhalter(info.src, info.datei, info.label, "carfix klein", null, true)}
</div>
<input type="file" accept="image/*" hidden data-bildupload="${esc(info.datei)}">
${bildMenuOffen === info.datei ? `
<div class="bildmenu-catcher" data-bildmenu-schliessen></div>
<div class="bildmenu ansicht">
<button type="button" data-bildersetzen="${esc(info.datei)}">Foto ersetzen</button>
<button type="button" class="loeschen" data-bildloeschen="${esc(info.datei)}">Löschen</button>
</div>` : ""}
</div>`;
})()}
</div>
@@ -3879,7 +3880,26 @@ let debugOffen = false;
"Speichern" uebernimmt ihn, weil derselbe Wert den Schlaf-Timeout des
Dongles stellt. Siehe die Kachel in vEinst(). */
let PAUSENZEIT_ENTWURF = null;
let bildMenuOffen = null;
/* Das Blatt zu einem vorhandenen Foto: ersetzen oder loeschen. Loest
bildMenuOffen ab - der Zustand wird nicht mehr gebraucht, weil das Blatt
selbst der Zustand ist (siehe aktionsblatt()). */
function bildAktionsblatt(dateiname, name) {
aktionsblatt(name, [
{ text: "Foto ersetzen", tun: () => {
const input = ROOT.querySelector(`[data-bildupload="${dateiname}"]`);
if (input) input.click();
} },
{ text: "Foto löschen", destruktiv: true, tun: () => {
bestaetigen(LOESCH_TEXT.bild, LOESCH_HINWEIS.bild, "Foto löschen", () => {
(async () => {
try { await HASS.callService(DOMAIN, "bild_loeschen", { dateiname }); }
catch (err) { hinweis("Löschen fehlgeschlagen", err.message); return; }
bildVersion = Date.now(); render();
})();
});
} },
]);
}
let smartdealPopupOffen = false;
// "sommer"|"winter", welcher Reifensatz gerade sein km-Korrektur-Feld offen
// hat (Editier-Symbol neben der km-Anzeige) - null wenn keins offen ist.
@@ -4172,7 +4192,7 @@ function vReifen() {
${t.km != null ? de(t.km) : ""}<span class="unit">km</span>
</div>
</div>
<div data-bildklick="${radDatei}">
<div data-bildklick="${radDatei}" data-bildname="${esc(radLabel)}">
${bildMitPlatzhalter(bildAdresse(radDatei), radDatei, radLabel, "radbild")}
</div>
</div>
@@ -4189,12 +4209,6 @@ function vReifen() {
<button class="aktion" data-kmauf="${k}">Abbrechen</button>
</div>` : ""}
<input type="file" accept="image/*" hidden data-bildupload="${radDatei}">
${bildMenuOffen === radDatei ? `
<div class="bildmenu-catcher" data-bildmenu-schliessen></div>
<div class="bildmenu rad">
<button type="button" data-bildersetzen="${radDatei}">Foto ersetzen</button>
<button type="button" class="loeschen" data-bildloeschen="${radDatei}">Löschen</button>
</div>` : ""}
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:var(--sp-4)">
<button data-satz="${k === "sommer" ? "Sommer" : "Winter"}" class="montiert ${aktiv ? "on" : ""}">Montiert</button>
<button class="iconbtn archivbtn" data-reifenarchivieren="${k}" aria-label="Neue Räder anlegen">${ciSVG(CI.packageS, 18)}</button>
@@ -5178,6 +5192,34 @@ function themeGewaehlt() {
if (gespeichert === "nacht" || gespeichert === "tag") return gespeichert;
return window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches ? "tag" : "nacht";
}
/* Systemumstellung im laufenden Betrieb nachziehen - Gegenstueck zum
gleichnamigen Horcher in companion-app/src/theme.ts.
themeGewaehlt() liest die Systemeinstellung genau einmal, beim Einhaengen
des Elements. Stellte das Geraet danach um - etwa durch die automatische
Umschaltung bei Sonnenuntergang -, blieb das Panel auf dem alten Stand
stehen, bis jemand neu lud. Das Stylesheet haengt an data-theme, nicht an
der Medienabfrage, es blieb also stimmig - aber eben stimmig falsch.
Nur solange niemand selbst gewaehlt hat: eine gespeicherte Wahl bleibt. */
function themaSystemNachziehen() {
if (!window.matchMedia) return;
const dunkel = window.matchMedia("(prefers-color-scheme: dark)");
const nachziehen = () => {
let gespeichert = null;
try { gespeichert = localStorage.getItem(THEME_KEY); } catch (e) { /* Privatmodus */ }
if (gespeichert === "nacht" || gespeichert === "tag") return;
if (!HOST) return;
const neu = dunkel.matches ? "nacht" : "tag";
if (HOST.dataset.theme === neu) return;
HOST.dataset.theme = neu;
themeIcon();
tiles();
};
if (dunkel.addEventListener) dunkel.addEventListener("change", nachziehen);
else if (dunkel.addListener) dunkel.addListener(nachziehen);
}
function themeSetzen(modus) {
HOST.dataset.theme = modus;
try { localStorage.setItem(THEME_KEY, modus); } catch (e) { /* Privatmodus */ }
@@ -5197,6 +5239,22 @@ function hinweis(titel, text) {
sheet = { titel, text, knopf: null, aktion: null };
render();
}
/* Mehrere Wahlmoeglichkeiten in einem Blatt, plus abgesetztes Abbrechen -
Apples Aktionsblatt. Gegenstueck zu <ActionSheet> in der App
(design-system), das dort schon seit dem 31.08.2026 das schwebende
Bildmenue abgeloest hat.
Warum das Panel nachzieht: das alte .bildmenu war mit position:absolute an
die KACHEL geheftet, nicht an den Bildschirm. Bei einer hohen Kachel - den
Raedern - lag es dadurch unterhalb des Sichtfelds, und einen Weg hinaus gab
es auch nicht: wer das Rad nur versehentlich antippte, fand kein Abbrechen.
Beides am 05.09.2026 vom Eigentuemer gemeldet. Das Blatt haengt am Wurzel-
Element und ist deshalb immer zu sehen. */
function aktionsblatt(titel, aktionen) {
sheet = { titel, text: null, knopf: null, aktion: null, aktionen };
render();
}
/* ------------------------------------------------------- Beleg-Upload
Ein Weg für alle drei Quellen (Ziehen, Einfügen, Dateiauswahl). Kein
serviceRufen() hier: dessen catch loggt nur in die Konsole - siehe den
@@ -5501,6 +5559,15 @@ function vBelegPopup() {
function sheetMarkup() {
if (!sheet) return "";
const kopf = `<div class="sheet-kopf"><strong>${esc(sheet.titel)}</strong>${sheet.text ? `<span>${esc(sheet.text)}</span>` : ""}</div>`;
if (sheet.aktionen) {
const knoepfe = sheet.aktionen.map((a, i) =>
`<button data-sheet-aktion="${i}" class="${a.destruktiv ? "loeschen" : ""}">${esc(a.text)}</button>`).join("");
return `<div class="sheet-catcher" data-sheet-abbrechen></div>
<div class="sheet" role="dialog" aria-modal="true">
<div class="sheet-gruppe">${kopf}${knoepfe}</div>
<button class="sheet-abbrechen" data-sheet-abbrechen>Abbrechen</button>
</div>`;
}
const aktion = sheet.knopf
? `<button data-sheet-ok class="${sheet.destruktiv ? "loeschen" : ""}">${esc(sheet.knopf)}</button>`
: `<button data-sheet-abbrechen>Verstanden</button>`;
@@ -6281,6 +6348,13 @@ function ereignisseVerdrahten() {
ROOT.addEventListener("click", (e) => {
// B4: Das Action Sheet liegt über allem und fängt daher zuerst ab.
if (e.target.closest("[data-sheet-abbrechen]")) { sheetSchliessen(); return; }
const blattAktion = e.target.closest("[data-sheet-aktion]");
if (blattAktion) {
const gewaehlt = sheet && sheet.aktionen && sheet.aktionen[Number(blattAktion.dataset.sheetAktion)];
sheet = null; render();
if (gewaehlt && gewaehlt.tun) gewaehlt.tun();
return;
}
if (e.target.closest("[data-sheet-ok]")) {
const aktion = sheet && sheet.aktion;
sheet = null; render();
@@ -6370,18 +6444,6 @@ function ereignisseVerdrahten() {
bestaetigen(LOESCH_TEXT.sbuch, LOESCH_HINWEIS.sbuch, "Löschen", () => { eintragLoeschen("sbuch", id); go("service"); });
return;
}
const bildDel = e.target.closest("[data-bildloeschen]");
if (bildDel) {
const dateiname = bildDel.dataset.bildloeschen;
bestaetigen(LOESCH_TEXT.bild, LOESCH_HINWEIS.bild, "Foto löschen", () => {
(async () => {
try { await HASS.callService(DOMAIN, "bild_loeschen", { dateiname }); }
catch (err) { hinweis("Löschen fehlgeschlagen", err.message); return; }
bildMenuOffen = null; bildVersion = Date.now(); render();
})();
});
return;
}
// Fahrzeugbild (Radbild wie Fahrzeugbilder-Slots): vorhandenes Foto -> Popup
// mit Ersetzen/Löschen, fehlendes Foto (Platzhalter, bild-fehlt) -> Dateiauswahl
// direkt öffnen.
@@ -6394,18 +6456,10 @@ function ereignisseVerdrahten() {
const input = container.querySelector(`[data-bildupload="${dateiname}"]`);
if (input) input.click();
} else {
bildMenuOffen = dateiname; render();
bildAktionsblatt(dateiname, bildKlick.dataset.bildname || "Foto");
}
return;
}
if (e.target.closest("[data-bildmenu-schliessen]")) { bildMenuOffen = null; render(); return; }
const bildErsetzen = e.target.closest("[data-bildersetzen]");
if (bildErsetzen) {
const container = bildErsetzen.closest(".tile");
const input = container.querySelector(`[data-bildupload="${bildErsetzen.dataset.bildersetzen}"]`);
if (input) input.click();
return;
}
if (e.target.closest("[data-sdabbrechen]")) { smartdealPopupOffen = false; render(); return; }
if (e.target.closest("[data-sdbestaetigen]")) {
const datum = $("#sdPopupBis").value;
@@ -6887,7 +6941,7 @@ function ereignisseVerdrahten() {
const daten_base64 = await bildAlsBase64(klein);
await HASS.callService(DOMAIN, "bild_hochladen", { dateiname, daten_base64 });
} catch (err) { hinweis("Hochladen fehlgeschlagen", err.message); }
bildMenuOffen = null; bildVersion = Date.now(); render();
bildVersion = Date.now(); render();
})();
return;
}
@@ -7312,6 +7366,7 @@ class AudiDashboardPanel extends HTMLElement {
/* A6: Standard folgt der Systemeinstellung, eine manuelle Wahl überlebt den
Reload. Vorher stand die App nach jedem Neuladen wieder auf Nacht. */
HOST.dataset.theme = HOST.dataset.theme || themeGewaehlt();
themaSystemNachziehen();
schriftenLaden();