Files
audi-app/companion-app/src/theme.ts
T
tobias e9c16325cc 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 <noreply@anthropic.com>
2026-09-05 02:24:08 +02:00

177 lines
6.9 KiB
TypeScript

/**
* Ansichtseinstellungen: Tag/Nacht, Fahrbahn unter dem Fahrzeug, Beschriftung
* der Menüleiste. Übernommen aus dem alten Panel (A6 des Design-Audits):
* gespeicherte Wahl schlägt Systemeinstellung, Standard für das Thema ist
* „nacht".
*
* WARUM HIER EIN GETEILTER SPEICHER STEHT UND NICHT DREIMAL useState
* ------------------------------------------------------------------
* Bis 2026-08-31 legte jeder dieser Haken sein eigenes `useState` an. Wer ihn
* zweimal aufrief, bekam zwei voneinander unabhängige Kopien desselben Werts:
*
* useTheme() → App.tsx (setzt data-theme), Einstellungen.tsx (der Schalter),
* Typenschild.tsx (zweimal, für die Farbe des Schilds)
* useTabBeschriftung() → Einstellungen.tsx (der Schalter), App.tsx (die Leiste)
*
* Der Schalter in den Einstellungen änderte damit nur seine eigene Kopie und
* den Speicher der Ablage — die Kopie in App.tsx blieb stehen, `data-theme`
* wechselte nie, und die Umschaltung wirkte erst nach einem Neustart der App.
* Genau so gemeldet: „Tag/Nacht Umschaltung wirkungslos". Die übrigen
* Schalter hatten denselben Fehler, nur ist er dort weniger auffällig.
*
* `useSyncExternalStore` (in React eingebaut) löst das an der Wurzel: es gibt
* je Einstellung genau einen Wert, jeder Aufrufer sieht denselben, und ein
* Setzen benachrichtigt alle. Das ist der React-eigene Weg für einen Zustand,
* der außerhalb des Komponentenbaums lebt — kein Kontext-Anbieter nötig, und
* die Aufrufstellen bleiben unverändert.
*
* Alle Zugriffe auf die Ablage sind abgesichert: im Privatmodus mancher
* Browser wirft schon das Lesen.
*/
import { useCallback, useEffect, useSyncExternalStore } from "react"
export type Theme = "nacht" | "tag"
function ablageLesen(schluessel: string): string | null {
try {
return localStorage.getItem(schluessel)
} catch {
return null
}
}
function ablageSchreiben(schluessel: string, wert: string): void {
try {
localStorage.setItem(schluessel, wert)
} catch {
// Privatmodus — die Wahl gilt dann nur für diese Sitzung.
}
}
/**
* Ein Wert, den sich alle Aufrufer teilen.
*
* `wert` wird beim ersten Zugriff aus der Ablage gelesen und danach im
* Speicher gehalten — `useSyncExternalStore` verlangt, dass zwei Aufrufe von
* `stand()` ohne zwischenzeitliche Änderung dasselbe liefern. Bei jedem Mal
* neu aus der Ablage zu lesen genügt dem zwar auch, aber nur zufällig; ein
* gehaltener Wert macht die Zusage explizit.
*/
function einstellung<T>(
schluessel: string,
ausAblage: (roh: string | null) => T,
inAblage: (wert: T) => string,
) {
let wert = ausAblage(ablageLesen(schluessel))
const horcher = new Set<() => void>()
return {
stand: () => wert,
/** Ob überhaupt etwas in der Ablage steht - also eine bewusste Wahl. */
gewaehlt: () => ablageLesen(schluessel) !== null,
/* Setzt den gehaltenen Wert, OHNE ihn zu speichern. Für Werte, die dem
System folgen, solange niemand selbst gewählt hat: eine Systemumstellung
ist keine Entscheidung des Nutzers und darf deshalb keine ablegen. */
ausSystem: (neu: T) => {
if (neu === wert) return
wert = neu
for (const bei of horcher) bei()
},
horchen: (bei: () => void) => {
horcher.add(bei)
return () => horcher.delete(bei)
},
setzen: (neu: T) => {
if (neu === wert) return
wert = neu
ablageSchreiben(schluessel, inAblage(neu))
for (const bei of horcher) bei()
},
}
}
const THEMA = einstellung<Theme>(
"dm360.theme",
(roh) => {
if (roh === "nacht" || roh === "tag") return roh
// Ohne gespeicherte Wahl entscheidet das System; ohne beides: nacht.
const hell =
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-color-scheme: light)").matches
return hell ? "tag" : "nacht"
},
(wert) => wert,
)
/* Systemumstellung im laufenden Betrieb nachziehen.
Der Startwert oben liest `prefers-color-scheme` genau einmal, beim Laden des
Moduls. Stellt das Gerät danach um - etwa durch die automatische Umschaltung
bei Sonnenuntergang -, folgte das Stylesheet sofort (es hängt selbst an der
Medienabfrage), der React-Zustand aber nie. Alles, was sein Bild aus
`useTheme()` wählt statt aus CSS, blieb damit auf der alten Fassung stehen:
die Vier Ringe und das Typenschild sind `<img>` mit eingebettetem SVG und
je einer hellen und einer dunklen Fassung (siehe VierRinge in
Typenschild.tsx). Auf dunklem Grund stand dann die dunkle Fassung - schwarz
auf schwarz, vom Eigentümer am 05.09.2026 gemeldet. Ein Neuladen räumte es
auf, weshalb es sprunghaft wirkte.
Nur solange niemand selbst gewählt hat: eine gespeicherte Wahl in
"Einstellungen" bleibt unangetastet. */
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
const dunkel = window.matchMedia("(prefers-color-scheme: dark)")
const nachziehen = () => {
if (THEMA.gewaehlt()) return
THEMA.ausSystem(dunkel.matches ? "nacht" : "tag")
}
if (typeof dunkel.addEventListener === "function") dunkel.addEventListener("change", nachziehen)
else if (typeof dunkel.addListener === "function") dunkel.addListener(nachziehen)
}
const TABBESCHRIFTUNG = einstellung<boolean>(
"dm360.tabbeschriftung",
(roh) => roh !== "aus",
(an) => (an ? "an" : "aus"),
)
/** Nur für Tests: setzt den gehaltenen Wert auf den Stand der Ablage zurück. */
export function themeStartwert(): Theme {
return THEMA.stand()
}
export function useTheme(): [Theme, (theme: Theme) => void] {
const theme = useSyncExternalStore(THEMA.horchen, THEMA.stand, THEMA.stand)
const setzen = useCallback((neu: Theme) => THEMA.setzen(neu), [])
// Farbe der Systemleisten mitziehen, damit auf dem Handy kein heller
// Balken über einer dunklen App steht. Werte des Panels (--canvas in
// audi-dashboard-ios.css); hier standen einmal #161b23/#ffffff aus dem
// Basis-Stylesheet, das im Panel nie greift — die Systemleiste hatte damit
// einen anderen Ton als die App darunter.
useEffect(() => {
const marke = document.querySelector('meta[name="theme-color"]')
if (marke) marke.setAttribute("content", theme === "nacht" ? "#0C1014" : "#F2F2F7")
}, [theme])
return [theme, setzen]
}
/* useBoden() ist am 2026-08-31 entfallen: der Bodenschatten unter dem
Fahrzeug ist weg (Ansage des Eigentuemers), damit hatte der Schalter
nichts mehr zu schalten. Der abgelegte Wert unter "dm360.boden" bleibt
liegen und stoert nicht - er wird schlicht nicht mehr gelesen. */
/** Sichtbarkeit der Tab-Beschriftungen, ebenfalls dauerhaft gespeichert. */
export function useTabBeschriftung(): [boolean, (an: boolean) => void] {
const an = useSyncExternalStore(
TABBESCHRIFTUNG.horchen,
TABBESCHRIFTUNG.stand,
TABBESCHRIFTUNG.stand,
)
const setzen = useCallback((neu: boolean) => TABBESCHRIFTUNG.setzen(neu), [])
return [an, setzen]
}