Paritaetsrunden 2 und 3, Designpruefung umgesetzt (2026.8.30.8)

Drei zusammenhaengende Runden, alle live bei 375x812 gegen audi_ha_test
geprueft und in beiden Codebasen angewandt.

Paritaetsrunde 2: der gemeinsame Rahmen war das eigentliche Problem -
Seitenrand, Kachelabstaende, Kopfleiste, Tableiste und vier Bausteine des
Design-Systems trugen noch den Stand vor der iOS-Entscheidung. Dazu sieben
Bildschirme neu aufgebaut. Zwei Panel-Fehler dabei mitbehoben: teaser()
zeigte unter "Letzte Fahrt" den aeltesten Datensatz, und "Daten bearbeiten"
war ein toter Knopf.

Paritaetsrunde 3: das Panel hat nie Audi Type gerendert. @font-face in einem
Shadow Root wird ignoriert - Schriftschnitte registriert der Browser pro
Dokument, nie pro Shadow Tree. Die drei Schnitte liegen jetzt in
audi-dashboard-schriften.css und werden ins Dokument gehaengt. Damit erledigt
sich eine ganze Reihe von "die Schrift sieht anders aus"-Eindruecken: die
beiden Anwendungen zeigten tatsaechlich verschiedene Schriften.
Ausserdem "Daten bearbeiten" im Panel gebaut und portiert, das Zeilenmenue
entfernt und die letzten neun Bildschirme angeglichen.

Designpruefung: die fuenf Punkte der Reihenfolge. Beim vierten hat das Messen
den Befund veraendert - gezaehlt waren die deklarierten Groessen, wirksam war
laengst eine saubere Sieben-Schritt-Skala mit 27 Ausreissern; die sind jetzt
auf den naechsten Schritt gezogen, keiner verschiebt sich um mehr als 1px.

Zuletzt: die Standortvorschau zeichnete die falsche Nadel (das Panel wechselt
den Icon-Satz ab 34px, die App nahm immer den grossen), und der Kopfabstand
der App ist auf den sicheren Bereich reduziert - die 56px des Panels liegen
dort unter der Kopfleiste von Home Assistant, in der nativen Huelle steht
darueber nichts.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-30 18:31:46 +02:00
co-authored by Claude Opus 5
parent 25c0d073c4
commit 4a58021670
64 changed files with 4129 additions and 1431 deletions
+129 -17
View File
@@ -7,15 +7,18 @@
* liegt bei 1000px.
*/
import type { ReactNode } from "react"
import { useEffect, useRef, useState } from "react"
import type { PointerEvent as ReactPointerEvent, ReactNode, RefObject } from "react"
import { TabBar } from "@audi-dash/ui"
import { standAlter } from "./format"
import { useBreiterBildschirm } from "./hooks/useBreiterBildschirm"
import { VierRinge } from "./screens/Typenschild"
import type { Hauptbereich, Route, SeitenName } from "./navigation"
import { TITEL, ZURUECK, bereichVon, istHauptbereich } from "./navigation"
import {
SymbolAktualisieren,
SymbolAudi,
SymbolFahrten,
SymbolStatistik,
@@ -31,10 +34,70 @@ export interface ShellProps {
tabBeschriftung: boolean
/** Wird über der Kopfzeile eingeblendet (Offline-Hinweis, Warteschlange). */
hinweis?: ReactNode | undefined
/** Zeitpunkt des letzten Datenstands — steht als „vor 3 Std." unter dem Titel. */
statusStand?: string | null | undefined
/** Neuabfrage beim Fahrzeug, ausgelöst durch Ziehen zum Aktualisieren. */
beiAktualisieren?: (() => Promise<void>) | undefined
/** Heißt `children`, weil JSX-Verschachtelung genau diesen Namen erwartet. */
children: ReactNode
}
/** Ziehen zum Aktualisieren, 1:1 nach der ptr-Logik des Panels. */
const PTR_SCHWELLE = 56
function useZiehenAktualisieren(
inhalt: RefObject<HTMLElement | null>,
beiAktualisieren: (() => Promise<void>) | undefined,
) {
const [zug, setzeZug] = useState(0)
const [laedt, setzeLaedt] = useState(false)
const start = useRef<number | null>(null)
if (!beiAktualisieren) {
return { zug: 0, laedt: false, handler: {} as Record<string, never> }
}
const beenden = () => {
if (start.current === null) return
start.current = null
if (zug >= PTR_SCHWELLE) {
setzeLaedt(true)
void beiAktualisieren().finally(() => {
setzeLaedt(false)
setzeZug(0)
})
} else {
setzeZug(0)
}
}
return {
zug,
laedt,
handler: {
onPointerDown: (e: ReactPointerEvent) => {
if (!e.isPrimary || laedt) return
if ((inhalt.current?.scrollTop ?? 0) > 0) return
start.current = e.clientY
setzeZug(0)
},
onPointerMove: (e: ReactPointerEvent) => {
if (start.current === null || !e.isPrimary) return
const dy = e.clientY - start.current
if (dy > 0 && (inhalt.current?.scrollTop ?? 0) <= 0) {
// Widerstand wie bei iOS: halbe Strecke, gedeckelt bei 90px.
setzeZug(Math.min(90, dy * 0.5))
} else {
start.current = null
setzeZug(0)
}
},
onPointerUp: beenden,
onPointerCancel: beenden,
},
}
}
const BEREICHE: { key: Hauptbereich; label: string; icon: ReactNode }[] = [
{ key: "home", label: "Übersicht", icon: <SymbolUebersicht /> },
{ key: "audi", label: "Mein Audi", icon: <SymbolAudi /> },
@@ -43,13 +106,33 @@ const BEREICHE: { key: Hauptbereich; label: string; icon: ReactNode }[] = [
{ key: "fuel", label: "Tanken", icon: <SymbolTanken /> },
]
export function Shell({ route, geheZu, tabBeschriftung, hinweis, children }: ShellProps) {
export function Shell({
route,
geheZu,
tabBeschriftung,
hinweis,
statusStand,
beiAktualisieren,
children,
}: ShellProps) {
const breit = useBreiterBildschirm()
const aktiverBereich = bereichVon(route.name)
const zurueckZiel = ZURUECK[route.name]
const unterseite = !istHauptbereich(route.name)
const inhaltRef = useRef<HTMLElement | null>(null)
const ptr = useZiehenAktualisieren(inhaltRef, breit ? undefined : beiAktualisieren)
// Der Text tickt mit, wie standAlterTicken() im Panel (Sekundentakt).
const [, neuZeichnen] = useState(0)
useEffect(() => {
if (route.name !== "home") return undefined
const uhr = window.setInterval(() => neuZeichnen((n) => n + 1), 1000)
return () => window.clearInterval(uhr)
}, [route.name])
const kopf = (
<header className="dm-kopf">
<header className={unterseite ? "dm-kopf dm-kopf--unterseite" : "dm-kopf"}>
{zurueckZiel && !breit ? (
<button
type="button"
@@ -60,27 +143,38 @@ export function Shell({ route, geheZu, tabBeschriftung, hinweis, children }: She
<SymbolZurueck groesse={22} />
</button>
) : null}
{/* Kein Eyebrow: das Panel blendet seinen (`.eyebrow{display:none}` in
audi-dashboard-ios.css) auf Telefonbreite vollständig aus - der feste
Text "DataMetric360" stand hier über jedem Bildschirm, im Panel steht
dort nichts. */}
<div className="dm-kopf__titel">
<span className="ads-eyebrow">DataMetric360</span>
<div className="ads-title">{TITEL[route.name]}</div>
{/* Datenstand nur auf der Übersicht, wie `#sync` im Panel. */}
{route.name === "home" && (
<div className="dm-stand">
<SymbolAktualisieren groesse={13} />
<span>{standAlter(statusStand)}</span>
</div>
)}
</div>
{/* `kopf` wird nur in der schmalen Spalte gerendert (breit hat seinen
eigenen Seitenleisten-Kopf, siehe unten) - dort sind die Audi-Ringe
oben rechts der einzige Zugang zu Einstellungen, das Zahnrad
entfällt, "damit oben rechts nur ein Element sitzt" (Design-Audit
2026-08-13, siehe .rings/.profilbtn in audi-dashboard-ios.css).
Companion-app zeigte hier bisher immer ein Zahnrad, unabhängig von
der Bildschirmbreite - echte Abweichung, nicht nur Kosmetik: es gab
keinen Rings-Zugang in Telefonbreite. */}
<button
type="button"
className="dm-kopf__aktion"
aria-label="Einstellungen"
aria-current={route.name === "einst" ? "page" : undefined}
onClick={() => geheZu("einst")}
>
<VierRinge hoehe={17} />
</button>
Nur auf den Tab-Ebenen: auf Unterseiten blendet das Panel die Ringe
aus (`#marke`.style.display in render()), dort steht links der
Zurück-Pfeil. */}
{!unterseite ? (
<button
type="button"
className="dm-kopf__aktion"
aria-label="Einstellungen"
onClick={() => geheZu("einst")}
>
<VierRinge hoehe={17} />
</button>
) : null}
</header>
)
@@ -143,7 +237,25 @@ export function Shell({ route, geheZu, tabBeschriftung, hinweis, children }: She
<div className="dm-spalte">
{hinweis}
{kopf}
<main className="dm-scroll dm-inhalt">{children}</main>
{/* Ziehen zum Aktualisieren wie im Panel (`.ptr`): das Band wächst
während der Geste aus der Höhe 0 heraus. */}
<div
className={ptr.laedt ? "dm-ptr dm-ptr--dreht" : "dm-ptr"}
style={{ height: ptr.laedt ? "56px" : `${ptr.zug}px` }}
aria-hidden={ptr.zug === 0 && !ptr.laedt}
>
<SymbolAktualisieren groesse={14} />
<span>
{ptr.laedt
? "Aktualisieren …"
: ptr.zug >= 56
? "Loslassen zum Aktualisieren"
: "Ziehen zum Aktualisieren"}
</span>
</div>
<main className="dm-scroll dm-inhalt" ref={inhaltRef} {...ptr.handler}>
{children}
</main>
<TabBar
items={BEREICHE}
activeKey={aktiverBereich}