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.
This commit is contained in:
Paul Nothaft
2026-08-11 10:55:10 +02:00
parent 2fe3576682
commit 5c0c2d1898
14 changed files with 1616 additions and 12 deletions
+2
View File
@@ -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"
+27 -11
View File
@@ -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<Route>({ 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 (
<div className="ads-root dm-wurzel" data-theme="nacht">
<div className="dm-scroll" style={{ flex: 1, padding: 22 }}>
<span className="ads-eyebrow">DataMetric360</span>
<Tile>
<div style={{ fontWeight: 300, fontSize: 20 }}>Gerüst steht</div>
</Tile>
</div>
<div className="ads-root dm-wurzel" data-theme={theme}>
<Shell route={route} geheZu={geheZu} tabBeschriftung={tabBeschriftung}>
<Platzhalter route={route} geheZu={geheZu} />
</Shell>
</div>
)
}
+73
View File
@@ -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<typeof bereichVon>[0], geheZu = vi.fn()) {
render(
<Shell route={{ name: routeName }} geheZu={geheZu} tabBeschriftung>
<p>Inhalt</p>
</Shell>,
)
return geheZu
}
describe("Shell", () => {
beforeEach(() => {
bildschirmbreiteSetzen(390)
})
it("zeigt auf schmalen Bildschirmen die Tab-Leiste unten", () => {
const { container } = render(
<Shell route={{ name: "home" }} geheZu={vi.fn()} tabBeschriftung>
<p>Inhalt</p>
</Shell>,
)
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(
<Shell route={{ name: "home" }} geheZu={vi.fn()} tabBeschriftung>
<p>Inhalt</p>
</Shell>,
)
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")
})
})
+144
View File
@@ -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: <SymbolUebersicht /> },
{ key: "audi", label: "Mein Audi", icon: <SymbolAudi /> },
{ key: "trips", label: "Fahrten", icon: <SymbolFahrten /> },
{ key: "stat", label: "Statistik", icon: <SymbolStatistik /> },
{ key: "fuel", label: "Tanken", icon: <SymbolTanken /> },
]
export function Shell({ route, geheZu, tabBeschriftung, hinweis, children }: ShellProps) {
const breit = useBreiterBildschirm()
const aktiverBereich = bereichVon(route.name)
const zurueckZiel = ZURUECK[route.name]
const kopf = (
<header className="dm-kopf">
{zurueckZiel && !breit ? (
<button
type="button"
className="dm-kopf__zurueck"
aria-label="Zurück"
onClick={() => geheZu(zurueckZiel)}
>
<SymbolZurueck groesse={22} />
</button>
) : null}
<div className="dm-kopf__titel">
<span className="ads-eyebrow">DataMetric360</span>
<div className="ads-title">{TITEL[route.name]}</div>
</div>
<button
type="button"
className="dm-kopf__aktion"
aria-label="Einstellungen"
aria-current={route.name === "einst" ? "page" : undefined}
onClick={() => geheZu("einst")}
>
<SymbolZahnrad groesse={22} />
</button>
</header>
)
if (breit) {
return (
<>
<nav className="dm-seitenleiste" aria-label="Hauptbereiche">
<span className="ads-eyebrow dm-seitenleiste__marke">DataMetric360</span>
{BEREICHE.map((bereich) => (
<button
key={bereich.key}
type="button"
className={
bereich.key === aktiverBereich
? "dm-seitenleiste__punkt dm-seitenleiste__punkt--an"
: "dm-seitenleiste__punkt"
}
aria-current={bereich.key === aktiverBereich ? "page" : undefined}
onClick={() => geheZu(bereich.key)}
>
{bereich.icon}
<span>{bereich.label}</span>
</button>
))}
<div className="dm-seitenleiste__fuss">
<button
type="button"
className={
route.name === "einst"
? "dm-seitenleiste__punkt dm-seitenleiste__punkt--an"
: "dm-seitenleiste__punkt"
}
onClick={() => geheZu("einst")}
>
<SymbolZahnrad />
<span>Einstellungen</span>
</button>
</div>
</nav>
<div className="dm-spalte">
{hinweis}
{!istHauptbereich(route.name) && zurueckZiel ? (
<div className="dm-brotkrume">
<button type="button" onClick={() => geheZu(zurueckZiel)}>
<SymbolZurueck groesse={16} />
<span>{TITEL[zurueckZiel]}</span>
</button>
</div>
) : null}
<main className="dm-scroll dm-inhalt dm-inhalt--breit">{children}</main>
</div>
</>
)
}
return (
<div className="dm-spalte">
{hinweis}
{kopf}
<main className="dm-scroll dm-inhalt">{children}</main>
<TabBar
items={BEREICHE}
activeKey={aktiverBereich}
onChange={(schluessel) => geheZu(schluessel as Hauptbereich)}
iconOnly={!tabBeschriftung}
className="dm-tableiste"
/>
</div>
)
}
@@ -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<boolean>(() => {
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
}
+112
View File
@@ -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<Record<SeitenName, SeitenName>> = {
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<SeitenName, string> = {
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"
}
+50
View File
@@ -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<Record<SeitenName, SeitenName[]>> = {
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 (
<>
<Tile>
<div style={{ fontWeight: 300, fontSize: 18 }}>{TITEL[route.name]}</div>
<p style={{ color: "var(--fg2)", fontSize: 14, fontWeight: 300, margin: "8px 0 0" }}>
Diese Seite wird noch gebaut.
</p>
</Tile>
{weiter.length > 0 && (
<div className="dm-raster">
{weiter.map((ziel) => (
<Tile key={ziel} variant="button" chevron onClick={() => geheZu(ziel)}>
<span className="ads-eyebrow">weiter</span>
<div style={{ fontWeight: 300, fontSize: 16, marginTop: 4 }}>{TITEL[ziel]}</div>
</Tile>
))}
</div>
)}
</>
)
}
+186
View File
@@ -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);
}
+163
View File
@@ -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 (
<svg
width={groesse}
height={groesse}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{kinder}
</svg>
)
}
export function SymbolUebersicht({ groesse }: SymbolProps) {
return (
<Rahmen
groesse={groesse}
kinder={
<g>
<path d="M4 17a9 9 0 0 1 18 0" />
<path d="M13 17l5.5-5" />
<circle cx="13" cy="17" r="1.3" />
</g>
}
/>
)
}
export function SymbolAudi({ groesse }: SymbolProps) {
return (
<Rahmen
groesse={groesse}
kinder={<polygon points="21.6,5 16.5,19 2.4,19 7.5,5" />}
/>
)
}
export function SymbolFahrten({ groesse }: SymbolProps) {
return (
<Rahmen
groesse={groesse}
kinder={
<g>
<circle cx="6" cy="6" r="2.2" />
<circle cx="18" cy="18" r="2.2" />
<path d="M8 7.5c4 1.5 4.5 3 3 5s-1 3.5 4.5 5" />
</g>
}
/>
)
}
export function SymbolStatistik({ groesse }: SymbolProps) {
return <Rahmen groesse={groesse} kinder={<path d="M4 20V9.5M10 20V4.5M16 20v-8M22 20H2" />} />
}
export function SymbolTanken({ groesse }: SymbolProps) {
return (
<Rahmen
groesse={groesse}
kinder={
<g>
<path d="M4.5 20V6.2c0-.9.8-1.7 1.7-1.7h5.6c1 0 1.7.8 1.7 1.7V20" />
<path d="M3 20h12.5" />
<path d="M4.5 11h10.5" />
<path d="M17 20v-8.5a2 2 0 0 0-2-2" />
<path d="M17 11.5V7.8l-2-2.3" />
</g>
}
/>
)
}
export function SymbolZurueck({ groesse }: SymbolProps) {
return <Rahmen groesse={groesse} kinder={<path d="M15 5l-7 7 7 7" />} />
}
export function SymbolZahnrad({ groesse }: SymbolProps) {
return (
<Rahmen
groesse={groesse}
kinder={
<g>
<circle cx="12" cy="12" r="3" />
<path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" />
</g>
}
/>
)
}
export function SymbolSonne({ groesse }: SymbolProps) {
return (
<Rahmen
groesse={groesse}
kinder={
<g>
<circle cx="12" cy="12" r="4.5" />
<path d="M12 2v2.4M12 19.6V22M2 12h2.4M19.6 12H22M4.7 4.7l1.7 1.7M17.6 17.6l1.7 1.7M19.3 4.7l-1.7 1.7M6.4 17.6l-1.7 1.7" />
</g>
}
/>
)
}
export function SymbolMond({ groesse }: SymbolProps) {
return (
<Rahmen
groesse={groesse}
kinder={<path d="M19.5 14.4A8.4 8.4 0 1 1 9.6 4.5a6.7 6.7 0 0 0 9.9 9.9z" />}
/>
)
}
export function SymbolMehr({ groesse }: SymbolProps) {
return (
<Rahmen
groesse={groesse}
kinder={
<g>
<circle cx="5" cy="12" r="1.4" />
<circle cx="12" cy="12" r="1.4" />
<circle cx="19" cy="12" r="1.4" />
</g>
}
/>
)
}
export function SymbolWarnung({ groesse }: SymbolProps) {
return (
<Rahmen
groesse={groesse}
kinder={
<g>
<path d="M12 4.5 2.5 20h19z" />
<path d="M12 10v4.5" />
<circle cx="12" cy="17.3" r="0.9" fill="currentColor" stroke="none" />
</g>
}
/>
)
}
+31
View File
@@ -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
+23
View File
@@ -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")
})
})
+81
View File
@@ -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<Theme>(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<boolean>(() => {
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]
}
+12
View File
@@ -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"],
},
})
+686 -1
View File
@@ -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",