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
+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]
}