From 5c0c2d18980e4e68cd73bb996c8073f63934e0b1 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 11 Aug 2026 10:55:10 +0200 Subject: [PATCH] Phase 5: App-Shell mit responsivem Layout, Navigation und Theme Zwei Layouts aus derselben Zusammensetzung: unter 1000px Tab-Leiste unten, darueber Seitenleiste links mit Brotkrume statt Zurueck-Pfeil. Navigation ist wie im alten Panel ein flacher Zustand mit fester ZURUECK-Tabelle statt eines Router-Pakets - die Verschachtelung ist hoechstens dreistufig. Theme und Tab-Beschriftung ueberleben einen Neustart (localStorage, abgesichert fuer den Privatmodus), Startwert folgt der Systemeinstellung. Symbole aus dem alten Panel uebernommen, damit App und Panel gleich aussehen. Neun Rendertests belegen die Abnahmekriterien: Layoutwechsel in beide Richtungen, Zurueck-Ziel ueber die Tabelle, Zahnrad statt Tab-Leiste fuer die Einstellungen, Theme-Persistenz. --- companion-app/package.json | 2 + companion-app/src/App.tsx | 38 +- companion-app/src/Shell.test.tsx | 73 ++ companion-app/src/Shell.tsx | 144 ++++ .../src/hooks/useBreiterBildschirm.ts | 26 + companion-app/src/navigation.ts | 112 +++ companion-app/src/screens/Platzhalter.tsx | 50 ++ companion-app/src/stile/rahmen.css | 186 +++++ companion-app/src/symbole.tsx | 163 +++++ companion-app/src/tests/aufbau.ts | 31 + companion-app/src/theme.test.ts | 23 + companion-app/src/theme.ts | 81 +++ companion-app/vitest.config.ts | 12 + package-lock.json | 687 +++++++++++++++++- 14 files changed, 1616 insertions(+), 12 deletions(-) create mode 100644 companion-app/src/Shell.test.tsx create mode 100644 companion-app/src/Shell.tsx create mode 100644 companion-app/src/hooks/useBreiterBildschirm.ts create mode 100644 companion-app/src/navigation.ts create mode 100644 companion-app/src/screens/Platzhalter.tsx create mode 100644 companion-app/src/stile/rahmen.css create mode 100644 companion-app/src/symbole.tsx create mode 100644 companion-app/src/tests/aufbau.ts create mode 100644 companion-app/src/theme.test.ts create mode 100644 companion-app/src/theme.ts create mode 100644 companion-app/vitest.config.ts diff --git a/companion-app/package.json b/companion-app/package.json index 2c9cba5..44f9f1d 100644 --- a/companion-app/package.json +++ b/companion-app/package.json @@ -20,11 +20,13 @@ "react-dom": "^18.3.1" }, "devDependencies": { + "@testing-library/react": "^16.3.2", "@types/leaflet": "^1.9.12", "@types/node": "^24.13.3", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^29.1.1", "typescript": "^5.7.2", "vite": "^6.0.7", "vitest": "^2.1.8" diff --git a/companion-app/src/App.tsx b/companion-app/src/App.tsx index 0c16a74..de95661 100644 --- a/companion-app/src/App.tsx +++ b/companion-app/src/App.tsx @@ -1,19 +1,35 @@ -import { Tile } from "@audi-dash/ui" +import { useCallback, useState } from "react" + +import { Shell } from "./Shell" +import type { Route, SeitenName } from "./navigation" +import { useTabBeschriftung, useTheme } from "./theme" +import { Platzhalter } from "./screens/Platzhalter" + +import "./stile/rahmen.css" /** - * Vorläufiger Einstieg — wird in Phase 5 durch die App-Shell ersetzt. - * Das äußerste Element trägt immer `ads-root` und `data-theme`, sonst greifen - * die Design-Tokens nicht (siehe design-system/.design-sync/conventions.md). + * Wurzel der App. Hält den Navigationszustand und das Theme; alles Weitere + * hängt darunter. Das äußerste Element trägt `ads-root` und `data-theme` — + * ohne diese Wurzelklasse greifen die Design-Tokens nicht + * (design-system/.design-sync/conventions.md). */ export function App() { + const [route, setzeRoute] = useState({ name: "home" }) + const [theme] = useTheme() + const [tabBeschriftung] = useTabBeschriftung() + + const geheZu = useCallback((name: SeitenName, id?: string) => { + setzeRoute(id === undefined ? { name } : { name, id }) + // Beim Seitenwechsel immer oben anfangen — sonst erbt die neue Seite die + // Scrollposition der alten. + document.querySelector(".dm-inhalt")?.scrollTo({ top: 0 }) + }, []) + return ( -
-
- DataMetric360 - -
Gerüst steht
-
-
+
+ + +
) } diff --git a/companion-app/src/Shell.test.tsx b/companion-app/src/Shell.test.tsx new file mode 100644 index 0000000..661f124 --- /dev/null +++ b/companion-app/src/Shell.test.tsx @@ -0,0 +1,73 @@ +/** + * Abnahmekriterien aus Phase 5 des Umsetzungsplans: Die App muss beide + * Layouts beherrschen und die Zurück-Navigation über die ZURUECK-Tabelle + * korrekt auflösen. + */ + +import { fireEvent, render, screen } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { Shell } from "./Shell" +import { bereichVon } from "./navigation" +import { bildschirmbreiteSetzen } from "./tests/aufbau" + +function rahmen(routeName: Parameters[0], geheZu = vi.fn()) { + render( + +

Inhalt

+
, + ) + return geheZu +} + +describe("Shell", () => { + beforeEach(() => { + bildschirmbreiteSetzen(390) + }) + + it("zeigt auf schmalen Bildschirmen die Tab-Leiste unten", () => { + const { container } = render( + +

Inhalt

+
, + ) + expect(container.querySelector(".ads-tabbar")).not.toBeNull() + expect(container.querySelector(".dm-seitenleiste")).toBeNull() + }) + + it("zeigt auf breiten Bildschirmen die Seitenleiste statt der Tab-Leiste", () => { + bildschirmbreiteSetzen(1280) + const { container } = render( + +

Inhalt

+
, + ) + expect(container.querySelector(".dm-seitenleiste")).not.toBeNull() + expect(container.querySelector(".ads-tabbar")).toBeNull() + }) + + it("führt der Zurück-Pfeil zur übergeordneten Seite", () => { + const geheZu = rahmen("beitrag") + fireEvent.click(screen.getByLabelText("Zurück")) + // beitrag -> vers laut ZURUECK-Tabelle + expect(geheZu).toHaveBeenCalledWith("vers") + }) + + it("blendet den Zurück-Pfeil auf Hauptbereichen aus", () => { + rahmen("trips") + expect(screen.queryByLabelText("Zurück")).toBeNull() + }) + + it("hebt den Hauptbereich hervor, zu dem eine Unterseite gehört", () => { + // schutz -> vertragsdetails -> vers -> audi + expect(bereichVon("schutz")).toBe("audi") + expect(bereichVon("fill")).toBe("fuel") + expect(bereichVon("trip")).toBe("trips") + }) + + it("erreicht die Einstellungen über das Zahnrad, nicht über die Tab-Leiste", () => { + const geheZu = rahmen("home") + fireEvent.click(screen.getByLabelText("Einstellungen")) + expect(geheZu).toHaveBeenCalledWith("einst") + }) +}) diff --git a/companion-app/src/Shell.tsx b/companion-app/src/Shell.tsx new file mode 100644 index 0000000..886c081 --- /dev/null +++ b/companion-app/src/Shell.tsx @@ -0,0 +1,144 @@ +/** + * Rahmen der App: Kopfzeile, Navigation, Inhaltsfläche. + * + * Zwei Layouts aus derselben Zusammensetzung — die entscheidende Anforderung + * aus dem Design-Auftrag: schmal (Handy) mit Tab-Leiste unten, breit (Tablet, + * eingebettet in Home Assistant) mit Seitenleiste links. Der Umschaltpunkt + * liegt bei 1000px. + */ + +import type { ReactNode } from "react" + +import { TabBar } from "@audi-dash/ui" + +import { useBreiterBildschirm } from "./hooks/useBreiterBildschirm" +import type { Hauptbereich, Route, SeitenName } from "./navigation" +import { TITEL, ZURUECK, bereichVon, istHauptbereich } from "./navigation" +import { + SymbolAudi, + SymbolFahrten, + SymbolStatistik, + SymbolTanken, + SymbolUebersicht, + SymbolZahnrad, + SymbolZurueck, +} from "./symbole" + +export interface ShellProps { + route: Route + geheZu: (name: SeitenName, id?: string) => void + tabBeschriftung: boolean + /** Wird über der Kopfzeile eingeblendet (Offline-Hinweis, Warteschlange). */ + hinweis?: ReactNode | undefined + /** Heißt `children`, weil JSX-Verschachtelung genau diesen Namen erwartet. */ + children: ReactNode +} + +const BEREICHE: { key: Hauptbereich; label: string; icon: ReactNode }[] = [ + { key: "home", label: "Übersicht", icon: }, + { key: "audi", label: "Mein Audi", icon: }, + { key: "trips", label: "Fahrten", icon: }, + { key: "stat", label: "Statistik", icon: }, + { key: "fuel", label: "Tanken", icon: }, +] + +export function Shell({ route, geheZu, tabBeschriftung, hinweis, children }: ShellProps) { + const breit = useBreiterBildschirm() + const aktiverBereich = bereichVon(route.name) + const zurueckZiel = ZURUECK[route.name] + + const kopf = ( +
+ {zurueckZiel && !breit ? ( + + ) : null} +
+ DataMetric360 +
{TITEL[route.name]}
+
+ +
+ ) + + if (breit) { + return ( + <> + +
+ {hinweis} + {!istHauptbereich(route.name) && zurueckZiel ? ( +
+ +
+ ) : null} +
{children}
+
+ + ) + } + + return ( +
+ {hinweis} + {kopf} +
{children}
+ geheZu(schluessel as Hauptbereich)} + iconOnly={!tabBeschriftung} + className="dm-tableiste" + /> +
+ ) +} diff --git a/companion-app/src/hooks/useBreiterBildschirm.ts b/companion-app/src/hooks/useBreiterBildschirm.ts new file mode 100644 index 0000000..271974a --- /dev/null +++ b/companion-app/src/hooks/useBreiterBildschirm.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from "react" + +/** + * Ab dieser Breite wechselt die App vom Handy- aufs Flächenlayout: Navigation + * wandert von unten nach links, Inhalte dürfen mehrspaltig werden. Der Wert + * stammt aus dem Design-Auftrag ("ab ca. 1000px"). + */ +export const BREITER_BILDSCHIRM = "(min-width: 1000px)" + +export function useBreiterBildschirm(): boolean { + const [breit, setzeBreit] = useState(() => { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false + return window.matchMedia(BREITER_BILDSCHIRM).matches + }) + + useEffect(() => { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") return + const abfrage = window.matchMedia(BREITER_BILDSCHIRM) + const beiAenderung = (ereignis: MediaQueryListEvent) => setzeBreit(ereignis.matches) + abfrage.addEventListener("change", beiAenderung) + setzeBreit(abfrage.matches) + return () => abfrage.removeEventListener("change", beiAenderung) + }, []) + + return breit +} diff --git a/companion-app/src/navigation.ts b/companion-app/src/navigation.ts new file mode 100644 index 0000000..5be5078 --- /dev/null +++ b/companion-app/src/navigation.ts @@ -0,0 +1,112 @@ +/** + * Flacher Navigationszustand — kein Router-Paket. + * + * Übernommen aus dem alten HA-Panel (`route` + `ZURUECK`): Die App hat keinen + * Verlaufsstapel, sondern genau eine aktuelle Seite und eine feste Tabelle, + * welche Seite der Zurück-Pfeil ansteuert. Das ist bewusst so geblieben — + * die Verschachtelung ist flach genug (höchstens drei Ebenen), und ein Router + * würde nur Gewicht ohne Nutzen bringen. + */ + +export const HAUPTBEREICHE = ["home", "audi", "trips", "stat", "fuel"] as const + +export type Hauptbereich = (typeof HAUPTBEREICHE)[number] + +export type SeitenName = + | Hauptbereich + // Unterseiten von "Mein Audi" + | "ident" + | "battverlauf" + | "service" + | "werkstatt" + | "sbuch" + | "reifen" + | "vers" + | "beitrag" + | "vertragsdetails" + | "schutz" + | "notruf" + | "steuer" + // Detailseiten der Listen + | "trip" + | "fill" + // Sonstige + | "sicherheit" + | "live" + | "einst" + +export interface Route { + name: SeitenName + /** Datensatz-Kennung bei Detailseiten (Fahrt, Tankvorgang, Servicebuch). */ + id?: string +} + +/** + * Ziel des Zurück-Pfeils je Unterseite. Seiten, die hier fehlen, sind + * Hauptbereiche und zeigen keinen Zurück-Pfeil. + */ +export const ZURUECK: Partial> = { + trip: "trips", + fill: "fuel", + service: "audi", + reifen: "audi", + ident: "audi", + battverlauf: "audi", + vers: "audi", + beitrag: "vers", + vertragsdetails: "vers", + schutz: "vertragsdetails", + steuer: "vers", + notruf: "vers", + werkstatt: "service", + sbuch: "service", + einst: "home", + sicherheit: "home", + live: "home", +} + +/** Überschrift je Seite. Leer bedeutet: die Seite bringt ihre eigene mit. */ +export const TITEL: Record = { + home: "Übersicht", + audi: "Mein Audi", + trips: "Fahrten", + stat: "Statistik", + fuel: "Tanken", + ident: "Fahrzeugdaten", + battverlauf: "Batterie", + service: "Service", + werkstatt: "Werkstatt", + sbuch: "Servicebuch", + reifen: "Reifen", + vers: "Versicherung & Steuer", + beitrag: "Beitrag", + vertragsdetails: "Vertragsdetails", + schutz: "Schutzbrief", + notruf: "Notruf", + steuer: "Kfz-Steuer", + trip: "Fahrt", + fill: "Tankvorgang", + sicherheit: "Fahrzeugstatus", + live: "Fahrt läuft", + einst: "Einstellungen", +} + +export function istHauptbereich(name: SeitenName): name is Hauptbereich { + return (HAUPTBEREICHE as readonly string[]).includes(name) +} + +/** + * Der Hauptbereich, zu dem eine Seite gehört — bestimmt, welcher Eintrag in + * Tab-Leiste bzw. Seitenleiste hervorgehoben wird. Folgt der ZURUECK-Kette + * nach oben. + */ +export function bereichVon(name: SeitenName): Hauptbereich { + let aktuell: SeitenName = name + for (let tiefe = 0; tiefe < 5; tiefe++) { + if (istHauptbereich(aktuell)) return aktuell + const eltern = ZURUECK[aktuell] + if (!eltern) return "home" + aktuell = eltern + } + return "home" +} diff --git a/companion-app/src/screens/Platzhalter.tsx b/companion-app/src/screens/Platzhalter.tsx new file mode 100644 index 0000000..9329746 --- /dev/null +++ b/companion-app/src/screens/Platzhalter.tsx @@ -0,0 +1,50 @@ +/** + * Vorläufiger Inhalt aller Seiten — wird in Phase 7 des Umsetzungsplans Seite + * für Seite durch die echten Ansichten ersetzt. Bis dahin belegt er die + * Navigation, damit sich der Rahmen bedienen lässt. + */ + +import { Tile } from "@audi-dash/ui" + +import type { Route, SeitenName } from "../navigation" +import { TITEL } from "../navigation" + +const UNTERSEITEN: Partial> = { + home: ["sicherheit", "einst"], + audi: ["ident", "battverlauf", "service", "reifen", "vers"], + service: ["werkstatt", "sbuch"], + vers: ["beitrag", "vertragsdetails", "steuer", "notruf"], + vertragsdetails: ["schutz"], + trips: ["trip"], + fuel: ["fill"], +} + +export function Platzhalter({ + route, + geheZu, +}: { + route: Route + geheZu: (name: SeitenName, id?: string) => void +}) { + const weiter = UNTERSEITEN[route.name] ?? [] + return ( + <> + +
{TITEL[route.name]}
+

+ Diese Seite wird noch gebaut. +

+
+ {weiter.length > 0 && ( +
+ {weiter.map((ziel) => ( + geheZu(ziel)}> + weiter +
{TITEL[ziel]}
+
+ ))} +
+ )} + + ) +} diff --git a/companion-app/src/stile/rahmen.css b/companion-app/src/stile/rahmen.css new file mode 100644 index 0000000..1e620d7 --- /dev/null +++ b/companion-app/src/stile/rahmen.css @@ -0,0 +1,186 @@ +/* + * Rahmen der App (Shell.tsx): Kopfzeile, Seitenleiste, Inhaltsfläche. + * Farben und Radien ausschließlich aus den Tokens von @audi-dash/ui. + */ + +.dm-spalte { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + height: 100%; +} + +/* ------------------------------------------------------------- Kopfzeile */ + +.dm-kopf { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: calc(env(safe-area-inset-top, 0px) + var(--sp-4)) var(--sp-5) var(--sp-3); + border-bottom: 1px solid var(--line); + background: var(--canvas); +} + +.dm-kopf__titel { + flex: 1; + min-width: 0; +} + +.dm-kopf__zurueck, +.dm-kopf__aktion { + flex: none; + width: 44px; + height: 44px; + display: grid; + place-items: center; + border: 0; + border-radius: var(--r-pill); + background: transparent; + color: var(--fg2); + cursor: pointer; +} + +.dm-kopf__zurueck:hover, +.dm-kopf__aktion:hover { + background: var(--shade); + color: var(--fg); +} + +.dm-kopf__aktion[aria-current="page"] { + color: var(--red); +} + +/* ---------------------------------------------------------- Inhaltsfläche */ + +.dm-inhalt { + flex: 1; + padding: var(--sp-4) var(--sp-5) calc(env(safe-area-inset-bottom, 0px) + var(--sp-5)); + display: flex; + flex-direction: column; + gap: var(--sp-3); +} + +.dm-inhalt--breit { + padding: var(--sp-5) 32px 32px; +} + +/* Mehrspaltige Anordnung auf großen Flächen: Kacheln nebeneinander statt + untereinander. Die Screens setzen die Klasse dort, wo es sinnvoll ist. */ +.dm-raster { + display: grid; + gap: var(--sp-3); + grid-template-columns: 1fr; +} + +@media (min-width: 1000px) { + .dm-raster { + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: start; + } + + .dm-raster--drei { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .dm-raster__breit { + grid-column: 1 / -1; + } +} + +/* Listen- und Detailansicht nebeneinander (Fahrten, Tanken auf großer Fläche) */ +.dm-zweispalter { + display: grid; + gap: var(--sp-4); + grid-template-columns: 1fr; + align-items: start; +} + +@media (min-width: 1000px) { + .dm-zweispalter { + grid-template-columns: minmax(0, 4fr) minmax(0, 5fr); + } +} + +/* ----------------------------------------------------------- Seitenleiste */ + +.dm-seitenleiste { + flex: 0 0 240px; + width: 240px; + height: 100%; + box-sizing: border-box; + border-right: 1px solid var(--line); + padding: 24px 14px 16px; + display: flex; + flex-direction: column; + gap: 2px; + background: var(--canvas); +} + +.dm-seitenleiste__marke { + padding: 0 10px var(--sp-4); +} + +.dm-seitenleiste__punkt { + display: flex; + align-items: center; + gap: var(--sp-3); + width: 100%; + padding: 11px 10px; + border: 0; + border-radius: var(--r-func); + background: transparent; + color: var(--fg2); + font-size: 14px; + font-weight: 300; + text-align: left; + cursor: pointer; +} + +.dm-seitenleiste__punkt:hover { + background: var(--shade); + color: var(--fg); +} + +.dm-seitenleiste__punkt--an { + background: var(--tile); + color: var(--fg); +} + +.dm-seitenleiste__fuss { + margin-top: auto; + padding-top: var(--sp-3); + border-top: 1px solid var(--line); +} + +/* Zurück-Weg auf großer Fläche: als Brotkrume über dem Inhalt statt als + Pfeil in der Kopfzeile — auf dem Handy nimmt der Pfeil den Platz ein. */ +.dm-brotkrume { + padding: var(--sp-4) 32px 0; +} + +.dm-brotkrume button { + display: inline-flex; + align-items: center; + gap: 6px; + border: 0; + background: transparent; + color: var(--fg2); + font-size: 13px; + font-weight: 300; + cursor: pointer; + padding: 6px 0; +} + +.dm-brotkrume button:hover { + color: var(--fg); +} + +/* ------------------------------------------------------------ Tab-Leiste */ + +.dm-tableiste { + flex: none; + padding-bottom: env(safe-area-inset-bottom, 0px); + border-top: 1px solid var(--line); + background: var(--canvas); +} diff --git a/companion-app/src/symbole.tsx b/companion-app/src/symbole.tsx new file mode 100644 index 0000000..c4d9263 --- /dev/null +++ b/companion-app/src/symbole.tsx @@ -0,0 +1,163 @@ +/** + * Symbole als React-Bausteine. Aus dem alten Panel übernommen (dort als + * SVG-Fragmente in Zeichenketten), damit App und Panel identisch aussehen. + * Bewusst keine Symbolbibliothek: es sind eine Handvoll, und jede Bibliothek + * wäre größer als der gesamte Rest. + */ + +import type { ReactElement } from "react" + +// Ausdrücklich `| undefined`, weil exactOptionalPropertyTypes eingeschaltet ist: +// ein durchgereichtes `groesse={undefined}` muss zulässig bleiben. +interface SymbolProps { + groesse?: number | undefined +} + +function Rahmen({ groesse = 24, kinder }: SymbolProps & { kinder: ReactElement }) { + return ( + + ) +} + +export function SymbolUebersicht({ groesse }: SymbolProps) { + return ( + + + + + + } + /> + ) +} + +export function SymbolAudi({ groesse }: SymbolProps) { + return ( + } + /> + ) +} + +export function SymbolFahrten({ groesse }: SymbolProps) { + return ( + + + + + + } + /> + ) +} + +export function SymbolStatistik({ groesse }: SymbolProps) { + return } /> +} + +export function SymbolTanken({ groesse }: SymbolProps) { + return ( + + + + + + + + } + /> + ) +} + +export function SymbolZurueck({ groesse }: SymbolProps) { + return } /> +} + +export function SymbolZahnrad({ groesse }: SymbolProps) { + return ( + + + + + } + /> + ) +} + +export function SymbolSonne({ groesse }: SymbolProps) { + return ( + + + + + } + /> + ) +} + +export function SymbolMond({ groesse }: SymbolProps) { + return ( + } + /> + ) +} + +export function SymbolMehr({ groesse }: SymbolProps) { + return ( + + + + + + } + /> + ) +} + +export function SymbolWarnung({ groesse }: SymbolProps) { + return ( + + + + + + } + /> + ) +} diff --git a/companion-app/src/tests/aufbau.ts b/companion-app/src/tests/aufbau.ts new file mode 100644 index 0000000..7f0b4e2 --- /dev/null +++ b/companion-app/src/tests/aufbau.ts @@ -0,0 +1,31 @@ +/** + * Testaufbau. jsdom kennt matchMedia nicht — die App fragt es aber ab, um + * zwischen Handy- und Flächenlayout zu wählen. `bildschirmbreiteSetzen()` + * stellt eine steuerbare Fassung bereit. + */ + +let aktuelleBreite = 390 + +export function bildschirmbreiteSetzen(breite: number): void { + aktuelleBreite = breite +} + +function passt(abfrage: string): boolean { + const min = /\(min-width:\s*(\d+)px\)/.exec(abfrage) + if (min?.[1]) return aktuelleBreite >= Number(min[1]) + const max = /\(max-width:\s*(\d+)px\)/.exec(abfrage) + if (max?.[1]) return aktuelleBreite <= Number(max[1]) + if (abfrage.includes("prefers-color-scheme: light")) return false + return false +} + +window.matchMedia = ((abfrage: string) => ({ + matches: passt(abfrage), + media: abfrage, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, +})) as unknown as typeof window.matchMedia diff --git a/companion-app/src/theme.test.ts b/companion-app/src/theme.test.ts new file mode 100644 index 0000000..5263d9b --- /dev/null +++ b/companion-app/src/theme.test.ts @@ -0,0 +1,23 @@ +import { beforeEach, describe, expect, it } from "vitest" + +import { themeStartwert } from "./theme" + +describe("Theme", () => { + beforeEach(() => { + localStorage.clear() + }) + + it("startet ohne gespeicherte Wahl im Nachtmodus", () => { + expect(themeStartwert()).toBe("nacht") + }) + + it("übernimmt eine gespeicherte Wahl", () => { + localStorage.setItem("dm360.theme", "tag") + expect(themeStartwert()).toBe("tag") + }) + + it("ignoriert unsinnige gespeicherte Werte", () => { + localStorage.setItem("dm360.theme", "irgendwas") + expect(themeStartwert()).toBe("nacht") + }) +}) diff --git a/companion-app/src/theme.ts b/companion-app/src/theme.ts new file mode 100644 index 0000000..69cf26b --- /dev/null +++ b/companion-app/src/theme.ts @@ -0,0 +1,81 @@ +/** + * Tag/Nacht-Umschaltung, übernommen aus dem alten Panel (A6 des Design-Audits): + * gespeicherte Wahl schlägt Systemeinstellung, Standard ist "nacht". + * Alle localStorage-Zugriffe sind abgesichert, weil sie im Privatmodus mancher + * Browser werfen. + */ + +import { useCallback, useEffect, useState } from "react" + +export type Theme = "nacht" | "tag" + +const SCHLUESSEL = "dm360.theme" + +function gespeichertLesen(): Theme | null { + try { + const wert = localStorage.getItem(SCHLUESSEL) + return wert === "nacht" || wert === "tag" ? wert : null + } catch { + return null + } +} + +function gespeichertSchreiben(theme: Theme): void { + try { + localStorage.setItem(SCHLUESSEL, theme) + } catch { + // Privatmodus — die Wahl gilt dann nur für diese Sitzung + } +} + +export function themeStartwert(): Theme { + const gespeichert = gespeichertLesen() + if (gespeichert) return gespeichert + const hell = + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-color-scheme: light)").matches + return hell ? "tag" : "nacht" +} + +export function useTheme(): [Theme, (theme: Theme) => void] { + const [theme, setzeTheme] = useState(themeStartwert) + + const setzen = useCallback((neu: Theme) => { + setzeTheme(neu) + gespeichertSchreiben(neu) + }, []) + + // Farbe der Systemleisten mitziehen, damit auf dem Handy kein heller + // Balken über einer dunklen App steht. + useEffect(() => { + const marke = document.querySelector('meta[name="theme-color"]') + if (marke) marke.setAttribute("content", theme === "nacht" ? "#161b23" : "#ffffff") + }, [theme]) + + return [theme, setzen] +} + +/** Sichtbarkeit der Tab-Beschriftungen, ebenfalls dauerhaft gespeichert. */ +const BESCHRIFTUNG_SCHLUESSEL = "dm360.tabbeschriftung" + +export function useTabBeschriftung(): [boolean, (an: boolean) => void] { + const [an, setzeAn] = useState(() => { + try { + return localStorage.getItem(BESCHRIFTUNG_SCHLUESSEL) !== "aus" + } catch { + return true + } + }) + + const setzen = useCallback((neu: boolean) => { + setzeAn(neu) + try { + localStorage.setItem(BESCHRIFTUNG_SCHLUESSEL, neu ? "an" : "aus") + } catch { + // Privatmodus + } + }, []) + + return [an, setzen] +} diff --git a/companion-app/vitest.config.ts b/companion-app/vitest.config.ts new file mode 100644 index 0000000..2fbdb68 --- /dev/null +++ b/companion-app/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config" +import react from "@vitejs/plugin-react" + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + globals: true, + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + setupFiles: ["./src/tests/aufbau.ts"], + }, +}) diff --git a/package-lock.json b/package-lock.json index 60be17d..ad26fd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,11 +22,13 @@ "react-dom": "^18.3.1" }, "devDependencies": { + "@testing-library/react": "^16.3.2", "@types/leaflet": "^1.9.12", "@types/node": "^24.13.3", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^29.1.1", "typescript": "^5.7.2", "vite": "^6.0.7", "vitest": "^2.1.8" @@ -48,6 +50,57 @@ "react-dom": ">=18" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@audi-dash/ui": { "resolved": "design-system", "link": true @@ -287,6 +340,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -335,6 +398,161 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -777,6 +995,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1201,6 +1437,62 @@ "win32" ] }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1294,7 +1586,6 @@ "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1444,6 +1735,29 @@ "node": ">=0.4.0" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1451,6 +1765,16 @@ "dev": true, "license": "MIT" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1474,6 +1798,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/browserslist": { "version": "4.28.8", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", @@ -1633,6 +1967,20 @@ "dev": true, "license": "MIT" }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1640,6 +1988,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/datametric360": { "resolved": "companion-app", "link": true @@ -1662,6 +2024,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -1672,6 +2041,23 @@ "node": ">=6" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.404", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.404.tgz", @@ -1679,6 +2065,19 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1814,6 +2213,26 @@ "node": ">=6.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -1830,6 +2249,58 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -1921,6 +2392,16 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1931,6 +2412,13 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -2002,6 +2490,19 @@ "node": ">=0.10.0" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2135,6 +2636,31 @@ } } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -2153,6 +2679,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -2161,6 +2688,13 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -2185,6 +2719,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -2241,6 +2785,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -2324,6 +2881,13 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -2408,6 +2972,52 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -2500,6 +3110,16 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -4209,6 +4829,54 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -4226,6 +4894,23 @@ "node": ">=8" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",