Ziehen zum Aktualisieren, Nachtmodus-Zeilen, Parkplatznamen (2026.8.30.10)

Ziehen zum Aktualisieren funktionierte auf keinem Geraet, in keiner der
beiden Anwendungen - und der Grund entwertet zugleich, wie es bisher geprueft
wurde. Beide trieben die Geste ueber pointermove und riefen dort
preventDefault(). Das verhindert laut Spezifikation kein Scrollen; darueber
entscheidet allein touch-action. Auf echtem Touch uebernimmt der Browser die
senkrechte Geste, schickt pointercancel und stellt pointermove ein - der Zug
wird zurueckgesetzt, lange bevor er die Schwelle erreicht. Eine Maus kennt
diese Uebernahme nicht, synthetische PointerEvents ebenso wenig: genau
deshalb lief die Geste 2026-08-17 im Test sauber durch und am Geraet nie.

Jetzt laeuft sie auf Touch-Ereignissen, touchmove zwingend mit passive:false
(sonst ist preventDefault() wirkungslos); der Zeigerweg bleibt fuer Maus und
Stift und ignoriert pointerType "touch". Mit echten TouchEvents geprueft,
inklusive defaultPrevented - dem einzigen Signal, das belegt, dass das native
Ueberscrollen wirklich unterbunden ist.

Der zuletzt nicht nachstellbare Befund war nachtmodus-spezifisch: --tile ist
dort ein durchscheinender Schleier, und die Wischzeilen malten ihn ein
zweites Mal auf die Kachel, auf der sie ohnehin liegen. Am Tag sind beide
deckend weiss, deshalb war nichts zu sehen. Das Panel nutzt an dieser Stelle
--tile-deckend; dieses Token fehlte im Design-System und ist jetzt da.

Ausserdem: steht das Auto auf einem benannten Platz, zeigen beide dessen
Namen statt der naechsten Hausnummer - Nominatim liefert ihn mit, die
Pruefung auf die Art der Flaeche verhindert, dass irgendein benanntes
Gebaeude die Anschrift verdraengt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 23:22:52 +02:00
parent a08922e9a0
commit 0fd81df700
9 changed files with 224 additions and 45 deletions
+52 -1
View File
@@ -1,7 +1,7 @@
# AGENTS.md — Project state, review findings, open items, and working rules
**Last updated: 2026-08-30** (owner findings batch - vehicle pin, tile chevrons, select arrows,
SmartDeal dialog, address cache and pull-to-refresh; manifest `2026.8.30.9`, see section AO.
SmartDeal dialog, address cache and pull-to-refresh; manifest `2026.8.30.10`, see section AO.
Before that: design-audit follow-through - the five priority items applied to
both codebases, plus the location preview's wrong pin; manifest `2026.8.30.8`, see section AN.
Before that: third parity round — the panel had never actually rendered Audi
@@ -5879,6 +5879,57 @@ vs 1.1 right, measured via `getBBox`) and is now nudged onto centre and scaled t
outweighing its outline neighbours; Teilen uses the real `share-s` from the CI set; and the sheet's
close button only exists while the sheet is raised - half-covered it looked operable and did nothing.
**The "could not be reproduced" finding was a night-mode-only defect (2026.8.30.10).** The owner
added the missing detail: night mode. In night mode `--tile` is deliberately translucent - a white
veil over the dark page - and `.ads-swipe-content` painted the same token a second time on top of
the tile it already sits on. Two veils add up, so every row in Fahrten and Tanken was visibly lighter
than the tile around it, while the year and month headers (not swipe rows) stayed correct. In day
mode both values are opaque white, which is why painting white on white showed nothing and the first
pass found the two sides identical. The panel does not have this: its iOS overlay sets
`.swipe-wrap .swipe-content{background:var(--tile-deckend)}`, an opaque token that `design-system`
never had. Added `--tile-deckend` (#171B21 / #FFFFFF, the panel's own values) and switched
`SwipeRow.css` to it. **Rule worth keeping: any surface painted on top of another surface needs the
opaque token, never `--tile`** - the bug is invisible in day mode by construction.
**Pull-to-refresh never worked on a real device, in either app - and the reason invalidates how it
was verified before.** Both implementations drove the gesture from `pointermove` and called
`preventDefault()` there. Per the Pointer Events spec that does **not** prevent scrolling: scrolling
is decided by `touch-action` alone. On a real touch screen the browser claims the vertical gesture,
fires `pointercancel` and stops sending `pointermove`; the handler's own cancel path then resets the
drag long before the 56px threshold. A mouse never triggers that takeover, and neither do synthetic
`PointerEvent`s - which is precisely why the 2026-08-17 session drove the whole gesture end to end
with dispatched pointer events, saw it work, and concluded the JS was fine. It was fine; it was
attached to the wrong event family. The `overscroll-behavior-y: contain` added then addresses scroll
*chaining* to the host page, a different problem, and was left in place.
Both now run the gesture on touch events - `touchstart` passive, `touchmove` with
**`{ passive: false }`** (without that flag `preventDefault()` is ignored and the browser scrolls
anyway), `touchend`/`touchcancel` - while the pointer path stays for mouse and stylus and now
ignores `pointerType === "touch"` so the two cannot fight over the same gesture. Panel: `ptrStarten()`
/ `ptrBewegen()` / `ptrBeenden()` extracted so both paths share one implementation. App: a manual
listener in a `useEffect`, because React registers `touchmove` passively and `preventDefault()` in an
`onTouchMove` prop would be silently ignored. Verified in both with genuine `TouchEvent`s - which the
old code could not have satisfied: threshold reached, label switching to "Loslassen zum
Aktualisieren", the refresh running, and `defaultPrevented === true` on the move, the one signal that
proves the native overscroll is actually suppressed.
One trap avoided while doing it: the app's early `return` for the wide layout sat in front of the new
`useEffect`, which would have made the hook run on some renders and not others. Moved behind it.
**Named places instead of a street address.** On the owner's question - a car park says more than the
nearest house number. Nominatim already returns `name` plus the type of the matched feature; both
apps now prefer that name when the type is one of parking / parking_space / parking_entrance /
bicycle_parking / motorcycle_parking / rest_area / services / fuel / charging_station, with the town
still appended. The type check is what keeps any incidentally named building from displacing the
address. And to answer the other half plainly: when nothing resolves at all, both show the
coordinates - that was already the designed fallback (`adresse ?? koordinaten`), and it is also what
a throttled lookup produces, which is exactly why the two cases were indistinguishable before the
cache landed.
**Header padding on the app was cut to the safe area on the owner's instruction** - see the note at
the end of section AN; the panel's 56px sit below Home Assistant's own header, the native shell has
nothing above them but the status bar.
**One finding could not be reproduced:** "Fahrten/Tankfüllung: Auflistung/grauer Hintergrund ist
falsch". Panel and app are identical there - same backgrounds (transparent rows on a white tile),
same paddings, same indents, same text. Reported back rather than guessed at.
+65 -18
View File
@@ -53,12 +53,8 @@ function useZiehenAktualisieren(
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
if (start.current === null || !beiAktualisieren) return
start.current = null
if (zug >= PTR_SCHWELLE) {
setzeLaedt(true)
@@ -71,26 +67,77 @@ function useZiehenAktualisieren(
}
}
const starten = (y: number) => {
if (laedt) return
if ((inhalt.current?.scrollTop ?? 0) > 0) return
start.current = y
setzeZug(0)
}
/* Gibt true zurück, wenn die Geste uns gehört — der Aufrufer muss dann
preventDefault() rufen. */
const bewegen = (y: number): boolean => {
if (start.current === null) return false
const dy = y - 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))
return true
}
start.current = null
setzeZug(0)
return false
}
/* Auf dem Finger läuft die Geste über Touch-Ereignisse, nicht über Pointer.
Grund: preventDefault() auf einem Pointer-Ereignis verhindert laut
Spezifikation kein Scrollen — darüber entscheidet allein touch-action. Auf
einem echten Gerät übernimmt der Browser die senkrechte Geste deshalb
sofort, schickt pointercancel und stellt pointermove ein; der Zug wurde
zurückgesetzt, bevor er die Schwelle erreichte. Am Rechner fällt das nie
auf, eine Maus kennt keine Scroll-Übernahme. Der Listener muss von Hand
und mit passive:false hängen — React registriert touchmove passiv, dort
wäre preventDefault() wirkungslos. */
useEffect(() => {
const el = inhalt.current
// Der Aussteiger sitzt IM Effekt, nicht davor: ein vorgezogenes return
// wuerde diesen Hook je nach Bildschirmbreite mal aufrufen und mal nicht
// und damit die Hook-Reihenfolge brechen.
if (!el || !beiAktualisieren) return undefined
const beiStart = (e: TouchEvent) => {
if (e.touches.length === 1) starten(e.touches[0]!.clientY)
}
const beiBewegung = (e: TouchEvent) => {
if (e.touches.length !== 1) return
if (bewegen(e.touches[0]!.clientY)) e.preventDefault()
}
el.addEventListener("touchstart", beiStart, { passive: true })
el.addEventListener("touchmove", beiBewegung, { passive: false })
el.addEventListener("touchend", beenden)
el.addEventListener("touchcancel", beenden)
return () => {
el.removeEventListener("touchstart", beiStart)
el.removeEventListener("touchmove", beiBewegung)
el.removeEventListener("touchend", beenden)
el.removeEventListener("touchcancel", beenden)
}
})
if (!beiAktualisieren) {
return { zug: 0, laedt: false, handler: {} as Record<string, never> }
}
return {
zug,
laedt,
handler: {
// Nur Maus/Stift — der Finger läuft über die Touch-Ereignisse oben.
onPointerDown: (e: ReactPointerEvent) => {
if (!e.isPrimary || laedt) return
if ((inhalt.current?.scrollTop ?? 0) > 0) return
start.current = e.clientY
setzeZug(0)
if (!e.isPrimary || e.pointerType === "touch") return
starten(e.clientY)
},
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)
}
if (!e.isPrimary || e.pointerType === "touch") return
bewegen(e.clientY)
},
onPointerUp: beenden,
onPointerCancel: beenden,
+27 -1
View File
@@ -23,6 +23,20 @@ export interface Koordinate {
const CACHE_SCHLUESSEL = "dm360.geocache"
/* Arten, bei denen der Eigenname aussagekräftiger ist als die Anschrift
(Feld type der Nominatim-Antwort). */
const PLATZ_ARTEN = new Set([
"parking",
"parking_space",
"parking_entrance",
"bicycle_parking",
"motorcycle_parking",
"rest_area",
"services",
"fuel",
"charging_station",
])
function cacheLesen(): Record<string, Koordinate | string | null> {
try {
const roh = localStorage.getItem(CACHE_SCHLUESSEL)
@@ -75,13 +89,25 @@ export async function koordinatenAufloesen(lat: number, lon: number): Promise<st
{ headers: { "Accept-Language": "de" } },
)
const daten = (await antwort.json()) as {
name?: string
type?: string
address?: Record<string, string>
display_name?: string
}
const a = daten.address ?? {}
const strasse = [a["road"], a["house_number"]].filter(Boolean).join(" ")
const ort = [a["postcode"], a["city"] ?? a["town"] ?? a["village"]].filter(Boolean).join(" ")
const adresse = [strasse, ort].filter(Boolean).join(", ") || daten.display_name || null
/* Steht das Auto auf einem benannten Platz — Parkplatz, Parkhaus,
Rastplatz, Ladesäule —, sagt dessen Name mehr als die nächstgelegene
Hausnummer: "Parkhaus Westentor" statt "Rathausstraße 4". Nominatim
liefert ihn im Feld name, zusammen mit der Art der getroffenen Fläche;
ohne die Prüfung auf diese Art würde auch jedes beliebige Gebäude, das
zufällig einen Namen trägt, die Anschrift verdrängen. Der Ort bleibt
angehängt, damit erkennbar ist, wo der Platz liegt. */
const platz = PLATZ_ARTEN.has(daten.type ?? "") ? (daten.name ?? "").trim() : ""
const adresse = platz
? [platz, ort].filter(Boolean).join(", ")
: [strasse, ort].filter(Boolean).join(", ") || daten.display_name || null
// Nur echte Treffer merken: eine gedrosselte Antwort ist kein "hier gibt
// es keine Adresse", die soll beim nächsten Mal erneut versucht werden.
if (adresse) adresseMerken(schluessel, adresse)
@@ -1 +1 @@
{"version":"2026.8.30.9","sha256":"63b67b2116bdc2970ebcb9c02dc8c2d1ec1f0156fd70c59f88db101307054474","bytes":256695,"gebaut":"2026-08-30T21:09:06Z"}
{"version":"2026.8.30.10","sha256":"0a9cdbbccf4e91b07152699e0331bf75ff62908832880e6c2408981b76347710","bytes":257008,"gebaut":"2026-08-30T21:20:20Z"}
@@ -786,6 +786,10 @@ function standortErfassenFallsNoetig() {
dokumentierte Leaflet-über-CDN-Vorgehen (siehe AGENTS.md). Cache-Schlüssel
auf 3 Nachkommastellen (~110 m) gerundet, damit ein erneutes Öffnen an
derselben Stelle keinen zweiten Abruf auslöst. */
/* Arten, bei denen der Eigenname aussagekraeftiger ist als die Anschrift. */
const PLATZ_ARTEN = new Set(["parking", "parking_space", "parking_entrance",
"bicycle_parking", "motorcycle_parking", "rest_area", "services", "fuel",
"charging_station"]);
async function standortAdresseAufloesen(lat, lon) {
const key = lat.toFixed(3) + "," + lon.toFixed(3);
if (STANDORT_ADRESSE_KEY === key) return STANDORT_ADRESSE;
@@ -808,7 +812,16 @@ async function standortAdresseAufloesen(lat, lon) {
const daten = await res.json();
const a = daten.address || {};
const strasse = [a.road, a.house_number].filter(Boolean).join(" ");
STANDORT_ADRESSE = [strasse, [a.postcode, a.city || a.town || a.village].filter(Boolean).join(" ")].filter(Boolean).join(", ") || daten.display_name || "";
const ort = [a.postcode, a.city || a.town || a.village].filter(Boolean).join(" ");
/* Steht das Auto auf einem benannten Platz - Parkplatz, Parkhaus,
Rastplatz -, sagt dessen Name mehr als die naechstgelegene Hausnummer.
Nominatim liefert ihn im Feld name; die Pruefung auf die Art der
Flaeche verhindert, dass jedes zufaellig benannte Gebaeude die
Anschrift verdraengt. */
const platz = PLATZ_ARTEN.has(daten.type) ? (daten.name || "").trim() : "";
STANDORT_ADRESSE = platz
? [platz, ort].filter(Boolean).join(", ")
: [strasse, ort].filter(Boolean).join(", ") || daten.display_name || "";
} catch (e) {
// Den Schluessel bewusst NICHT setzen: sonst gilt ein einzelner
// fehlgeschlagener Abruf (Rate-Limit, kurzer Netzausfall) fuer diese Zelle
@@ -4803,32 +4816,16 @@ function ereignisseVerdrahten() {
ROOT.addEventListener("pointerdown", (e) => {
// Ziehen zum Aktualisieren (nur Übersicht, nur wenn ganz oben gescrollt) -
// unabhängig von der Wischgeste unten, da hier keine .swipe-wrap nötig ist.
if (e.isPrimary && route.name === "home" && !ptrLaedt) {
const ansicht = $("#view");
if (ansicht && ansicht.scrollTop <= 0) { ptrY0 = e.clientY; ptrDrag = 0; }
}
// Nur Maus/Stift. Der Finger laeuft ueber die Touch-Ereignisse weiter
// unten, weil nur dort preventDefault() das native Ueberscrollen wirklich
// verhindert - siehe ptrBewegen().
if (e.isPrimary && e.pointerType !== "touch") ptrStarten(e.clientY);
const wrap = e.target.closest(".swipe-wrap");
if (!wrap || !e.isPrimary) return;
swipeAktivWrap = wrap; swipeStartX = e.clientX; swipeStartY = e.clientY; swipeGeste = null; swipeDX = 0;
});
ROOT.addEventListener("pointermove", (e) => {
if (ptrY0 !== null && e.isPrimary) {
const ansicht = $("#view"), dy = e.clientY - ptrY0;
if (dy > 0 && ansicht && ansicht.scrollTop <= 0) {
e.preventDefault();
ptrDrag = Math.min(90, dy * 0.5); // Widerstand, wie bei iOS
const ptr = $("#ptr");
if (ptr) {
ptr.style.transition = "none";
ptr.style.height = ptrDrag + "px";
ptr.querySelector("span").textContent = ptrDrag >= 56 ? "Loslassen zum Aktualisieren" : "Ziehen zum Aktualisieren";
}
} else {
ptrY0 = null; ptrDrag = 0;
const ptr = $("#ptr");
if (ptr) { ptr.style.transition = ""; ptr.style.height = "0"; }
}
}
if (e.pointerType !== "touch") ptrBewegen(e.clientY);
if (!swipeAktivWrap || !e.isPrimary) return;
const dx = e.clientX - swipeStartX, dy = e.clientY - swipeStartY;
if (swipeGeste === null) {
@@ -4863,6 +4860,39 @@ function ereignisseVerdrahten() {
// auf den gerade erst aufgedeckten Löschen-Knopf) fälschlich verschluckt.
if (swipeWarGezogen) setTimeout(() => { swipeWarGezogen = false; }, 0);
}
function ptrStarten(y) {
if (route.name !== "home" || ptrLaedt) return;
const ansicht = $("#view");
if (ansicht && ansicht.scrollTop <= 0) { ptrY0 = y; ptrDrag = 0; }
}
/* Gibt true zurueck, wenn die Geste uns gehoert - der Aufrufer muss dann
preventDefault() rufen. Warum das ueber Touch-Ereignisse laufen muss:
preventDefault() auf einem Pointer-Ereignis verhindert laut Spezifikation
KEIN Scrollen; darueber entscheidet allein touch-action. Auf einem echten
Touch-Geraet uebernimmt der Browser die senkrechte Geste deshalb sofort,
schickt pointercancel und stellt pointermove ein - die Geste wurde
zurueckgesetzt, bevor sie die 56px erreichen konnte. Am Rechner faellt das
nie auf, weil eine Maus keine Scroll-Uebernahme kennt; auch synthetische
PointerEvents durchlaufen sie nicht. Auf touchmove dagegen wirkt
preventDefault() und haelt das native Ueberscrollen an. */
function ptrBewegen(y) {
if (ptrY0 === null) return false;
const ansicht = $("#view"), dy = y - ptrY0;
if (dy > 0 && ansicht && ansicht.scrollTop <= 0) {
ptrDrag = Math.min(90, dy * 0.5); // Widerstand, wie bei iOS
const ptr = $("#ptr");
if (ptr) {
ptr.style.transition = "none";
ptr.style.height = ptrDrag + "px";
ptr.querySelector("span").textContent = ptrDrag >= 56 ? "Loslassen zum Aktualisieren" : "Ziehen zum Aktualisieren";
}
return true;
}
ptrY0 = null; ptrDrag = 0;
const ptr = $("#ptr");
if (ptr) { ptr.style.transition = ""; ptr.style.height = "0"; }
return false;
}
function ptrBeenden() {
if (ptrY0 === null) return;
ptrY0 = null;
@@ -4886,6 +4916,19 @@ function ereignisseVerdrahten() {
ROOT.addEventListener("pointerup", (e) => { swipeBeenden(); ptrBeenden(); });
ROOT.addEventListener("pointercancel", (e) => { swipeBeenden(); ptrBeenden(); });
// Ziehen zum Aktualisieren auf dem Finger: eigener Weg ueber Touch-Ereignisse
// (Begruendung in ptrBewegen()). passive:false ist zwingend - ohne das ist
// preventDefault() auf touchmove wirkungslos und der Browser scrollt doch.
ROOT.addEventListener("touchstart", (e) => {
if (e.touches.length === 1) ptrStarten(e.touches[0].clientY);
}, { passive: true });
ROOT.addEventListener("touchmove", (e) => {
if (e.touches.length !== 1) return;
if (ptrBewegen(e.touches[0].clientY)) e.preventDefault();
}, { passive: false });
ROOT.addEventListener("touchend", () => ptrBeenden());
ROOT.addEventListener("touchcancel", () => ptrBeenden());
// Batteriespannungs-Verlauf: Pinch-Zoom (2 Finger) / Ziehen zum Verschieben
// (1 Finger) / Mausrad-Zoom, per pointerId in bvZeiger nachverfolgt statt
// über die swipe*-Variablen oben, die für horizontales Wischen in Listen
@@ -1,7 +1,7 @@
{
"domain": "audi_dashboard",
"name": "Audi Dashboard",
"version": "2026.8.30.9",
"version": "2026.8.30.10",
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
"codeowners": ["@paul"],
@@ -37,7 +37,11 @@
.ads-swipe-content {
position: relative;
background: var(--tile);
/* Deckend, nicht --tile: die Zeile liegt auf einer Kachel, die im
Nachtmodus selbst nur ein durchscheinender Schleier ist - zweimal
gemalt wird der Streifen sichtbar heller als die Kachel um ihn herum
(.swipe-wrap .swipe-content in audi-dashboard-ios.css). */
background: var(--tile-deckend);
touch-action: pan-y;
transition: transform .18s;
}
+8
View File
@@ -43,6 +43,13 @@
/* Graue Füllfläche für Bedienelemente (Seg-Spur, Eingabe-/Auswahl-Boxen) -
live am Panel gemessen (--ios-fill, audi-dashboard-ios.css). */
--ios-fill: rgba(120, 120, 128, .24);
/* Deckende Kachelflaeche. Im Nachtmodus ist --tile bewusst durchscheinend
(weisser Schleier ueber der dunklen Seite) - wird sie ein zweites Mal
auf eine Kachel gemalt, addieren sich die Schleier und der Bereich wird
sichtbar heller. Genau das passierte den Wischzeilen in Fahrten und
Tanken. Ueberall dort, wo eine Flaeche auf einer anderen liegt, gehoert
dieser deckende Wert hin (--tile-deckend im Panel). */
--tile-deckend: #171B21;
--font-stack: Helvetica, Arial, sans-serif;
}
@@ -69,6 +76,7 @@
--bad: #FF3B30;
--shade: rgba(0, 0, 0, .04);
--ios-fill: rgba(120, 120, 128, .16);
--tile-deckend: #FFFFFF;
}
/*