Teilen-Blatt, HIG-Blaetter, geteilte Ansichtseinstellungen (2026.8.31.1)

Share-Erweiterung: ein PDF aus Mail landet ueber Teilen -> DataMetric360 als
Tankbeleg. Alles Native liegt versioniert unter companion-app/native/, weil
ios/ gitignored ist und npx cap add ios es sonst wieder frisst;
scripts/ios-teilen-einrichten.mjs haengt es bei jedem Bau ins Xcode-Projekt
und ist in ios-signieren.sh eingehaengt. Der pbxproj-Teil ist auf keinem Mac
erprobt - er bricht vor dem Schreiben ab, und docs/SHARE_EXTENSION.md
beschreibt dieselben Handgriffe fuer Xcode.

"In den Kalender uebernehmen" ging in der App nie: WKWebView ignoriert das
download-Attribut, der Klick lief ins Leere. Auf dem Geraet jetzt
@capacitor/filesystem plus @capacitor/share.

useTheme/useBoden legten je Aufrufer eigenen Zustand an - fuenf Kopien. Der
Schalter aenderte nur seine eigene, data-theme in App.tsx blieb stehen, die
Tag/Nacht-Umschaltung wirkte erst nach einem Neustart. Jetzt ein Speicher je
Einstellung ueber useSyncExternalStore. Zwei Regressionstests, gegen den
alten Stand als fehlschlagend nachgewiesen.

Alle Popups nach Apple HIG: neue Bausteine Sheet und ActionSheet, blickdicht
und mit abgedunkeltem Schleier, in beiden Codebasen. Popup portalierte nach
document.body - ausserhalb von [data-theme] - und war deshalb im Tagmodus
fast unsichtbar; Portal zielt jetzt auf .ads-root.

ImagePlaceholder merkte sich "fehlgeschlagen" ohne die Adresse: nach einem
leeren Galerieplatz zeigten auch die mit Foto nur noch den Platzhalter.
Vier Regressionstests, ebenfalls gegen den alten Stand geprueft.

Bildadressen tragen jetzt einen Cache-Brecher - HA liefert /local/ mit 31
Tagen Cache-Vorgabe aus, im Panel eingefuegte Radfotos erschienen in der App
deshalb nicht.

uebersichtsbild: Panel speichert den Namen, die App verglich gegen den
Dateinamen - der Vergleich traf nie zu. Kanonisch ist der Name.

Weiter: Navigationspfeil statt gleichschenkligem Dreieck auf der Streckenlinie
(die Kerbe unterscheidet Kopf und Ende), CI-Symbol tour-s als Fahrten-Icon,
Datumsfelder zeigen ihren Wert ohne erstes Antippen, feste Beispielnamen im
Setup, Kopf-Gegengewicht fuer mittige Titel, Standort-Blatt mit festem
Fussabstand, NSLocationWhenInUseUsageDescription, watchPosition fuer die
Live-Ortung, Art-Pille wieder als Knopf, Zwischenablage fuer Tankbelege.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-31 14:24:52 +02:00
parent 26f72930c3
commit b1411625aa
58 changed files with 2846 additions and 368 deletions
@@ -1 +1 @@
{"version":"2026.8.30.20","sha256":"28839ee2ba8d1d703bb688a0124a2930ffb45b0d92acfc05d9c9504b7141c144","bytes":257018,"gebaut":"2026-08-30T22:05:13Z"}
{"version":"2026.8.31.1","sha256":"bd990f600b5bf81ce66105af8d49a77d31a2a4e3b4e5070858c38e1e8d23fcc9","bytes":266016,"gebaut":"2026-08-31T12:23:41Z"}
@@ -573,6 +573,82 @@ function tiles() {
if (TILE) MAP.removeLayer(TILE);
TILE = window.L.tileLayer(url, { maxZoom: 19, attribution: ATTR }).addTo(MAP);
}
/* Fahrtrichtung auf der Streckenlinie.
Bis 2026-08-31 lagen auf der Linie kleine runde Punkte - je einer pro
GPS-Stuetzpunkt. Sie sagten nichts ueber die Richtung, und bei einer Fahrt
hin und zurueck auf derselben Strasse verschmolzen Hin- und Rueckweg zu
einem einzigen roten Band: aus der Karte war nicht mehr abzulesen, dass es
ueberhaupt zwei Wege waren.
Die Form ist der Navigationspfeil, kein gleichschenkliges Dreieck: das
Dreieck sieht von beiden Seiten gleich aus und laesst auf Abstand offen,
wo vorn ist - besonders dort, wo die Strecke die Richtung wechselt. Die
Kerbe im Fuss macht Kopf und Ende unterscheidbar (Nutzervorgabe mit
Vorlage, 2026-08-31).
Drei Entscheidungen, jede an Mustern nachgemessen (siehe unten):
1. Abstand nach STRECKE, nicht nach Stuetzpunkten. Die Punktdichte des
Rohtracks haengt an der Geschwindigkeit - im Stau entsteht ein Haufen,
auf der Autobahn eine Luecke. Pfeile daran haetten genau dort geklumpt,
wo am wenigsten passiert ist.
2. Mittig auf der Linie, ohne Versatz quer zur Fahrtrichtung. Ein solcher
Versatz war erprobt und trennt eine Hin-und-Rueck-Strecke sauber auf
zwei Spuren - aber nur, wenn Hin- und Rueckweg wirklich Punkt fuer Punkt
uebereinanderliegen. Das tun echte GPS-Aufzeichnungen nicht (Eigentum
des Fahrzeughalters, 2026-08-31). Fuer einen Fall, der in den Daten
nicht vorkommt, waere die schiefe Lage aller Dreiecke ein taeglicher
Preis ohne Gegenwert.
3. Weisser Rand. Das Dreieck liegt in derselben Farbe auf derselben Linie;
ohne die helle Kontur verschwimmt beides zu einer dicken Wurst. */
function spurMeter(a, b) {
const R = 6371000, r = Math.PI / 180;
const dLat = (b[0] - a[0]) * r, dLon = (b[1] - a[1]) * r;
const h = Math.sin(dLat / 2) ** 2 + Math.cos(a[0] * r) * Math.cos(b[0] * r) * Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
}
/* Winkel im Uhrzeigersinn ab Norden - genau das, was CSS rotate() erwartet.
Die Laengendifferenz wird mit cos(Breite) gestaucht, sonst zeigten die
Pfeile in unseren Breiten rund 30 Grad daneben. */
function spurWinkel(a, b) {
const dx = (b[1] - a[1]) * Math.cos(((a[0] + b[0]) / 2) * Math.PI / 180);
return Math.atan2(dx, b[0] - a[0]) * 180 / Math.PI;
}
function spurPfeile(karte, spur, farbe) {
if (!Array.isArray(spur) || spur.length < 2 || !window.L) return;
const laengen = [];
let gesamt = 0;
for (let i = 1; i < spur.length; i++) { const d = spurMeter(spur[i - 1], spur[i]); laengen.push(d); gesamt += d; }
if (!gesamt) return;
// Rund alle 350 m, aber nie weniger als 4 und nie mehr als 14: auf einer
// 200-km-Fahrt waeren 570 Pfeile eine zweite Linie, auf 300 m gar keiner.
const anzahl = Math.max(4, Math.min(14, Math.round(gesamt / 350)));
const schritt = gesamt / (anzahl + 1);
let ziel = schritt, gelaufen = 0;
for (let i = 1; i < spur.length && ziel < gesamt; i++) {
const d = laengen[i - 1];
while (ziel <= gelaufen + d && ziel < gesamt) {
const f = d ? (ziel - gelaufen) / d : 0;
const lat = spur[i - 1][0] + (spur[i][0] - spur[i - 1][0]) * f;
const lon = spur[i - 1][1] + (spur[i][1] - spur[i - 1][1]) * f;
const w = spurWinkel(spur[i - 1], spur[i]).toFixed(1);
const html = '<div style="transform:rotate(' + w + 'deg)">'
+ '<svg width="13" height="13" viewBox="0 0 10 10" style="overflow:visible">'
+ '<path d="M5 0.4 L8.8 9.6 L5 6.4 L1.2 9.6 Z" fill="none" stroke="#fff" stroke-width="1.8" stroke-linejoin="round" opacity=".95"/>'
+ '<path d="M5 0.4 L8.8 9.6 L5 6.4 L1.2 9.6 Z" fill="' + farbe + '"/></svg></div>';
window.L.marker([lat, lon], {
icon: window.L.divIcon({ className: "spur-pfeil", html, iconSize: [13, 13], iconAnchor: [6.5, 6.5] }),
interactive: false,
}).addTo(karte);
ziel += schritt;
}
gelaufen += d;
}
}
async function initMap() {
await leafletLaden();
const el = ROOT.getElementById("map");
@@ -591,7 +667,10 @@ async function initMap() {
? t.route
: [[t.start_lat, t.start_lon], [t.end_lat, t.end_lon]];
const line = window.L.polyline(track, { color: rot, weight: 2.5, opacity: 0.9, dashArray: Array.isArray(t.route) && t.route.length >= 2 ? null : "4 6" }).addTo(MAP);
track.slice(1, -1).forEach((pt) => window.L.circleMarker(pt, { radius: 2.5, color: rot, weight: 0, fillColor: rot, fillOpacity: 0.85 }).addTo(MAP));
// Nur auf einer echten Aufzeichnung: die gestrichelte Ersatzlinie
// zwischen Start und Ziel ist keine gefahrene Strecke, und ein Pfeil
// darauf behauptete eine Fahrtrichtung, die niemand gemessen hat.
if (Array.isArray(t.route) && t.route.length >= 2) spurPfeile(MAP, track, rot);
window.L.circleMarker([t.start_lat, t.start_lon], { radius: 6, color: "#fff", weight: 2, fillColor: "#000", fillOpacity: 1 }).addTo(MAP).bindPopup("Start · " + (t.start_address || ""));
window.L.circleMarker([t.end_lat, t.end_lon], { radius: 6, color: rot, weight: 2, fillColor: rot, fillOpacity: 1 }).addTo(MAP).bindPopup("Ziel · " + (t.end_address || ""));
MAP.fitBounds(line.getBounds(), { padding: [26, 26] });
@@ -637,6 +716,7 @@ async function initMap() {
let USER_POS = null; // {lat, lon, genauigkeit} oder null
let USER_POS_TS = 0;
let USER_POS_FEHLER = null;
let USER_WATCH = null; // watchPosition-Kennung, solange die Standortseite offen ist
let STANDORT_ADRESSE = null; // zuletzt aufgelöste Adresse (Cache über Koordinaten-Rundung)
let STANDORT_ADRESSE_KEY = null;
let STANDORTMENU_OFFEN = false;
@@ -779,6 +859,43 @@ function standortErfassenFallsNoetig() {
standortErfassen();
}
/* Fortlaufende Ortung, solange die Standortseite offen ist - "live bei
Verwendung". Die einmalige Abfrage oben bleibt daneben bestehen und ist
weiterhin das Richtige fuer die Uebersichtskachel: die zeigt nur einen
Abstand, und eine dauerhafte Hochgenauigkeits-Ortung auf der Startseite
waere Batterieverbrauch ohne Gegenwert.
Beendet wird die Verfolgung an denselben zwei Stellen, an denen auch SMAP
abgeraeumt wird - sonst liefe sie nach dem Verlassen der Seite unbemerkt
weiter. Genau das verlangt Apples Vorgabe fuer "Beim Verwenden der App":
erfassen, solange der Nutzer die Karte ansieht, und keine Sekunde laenger. */
function standortVerfolgungStarten() {
if (USER_WATCH !== null) return;
if (!navigator.geolocation) { USER_POS_FEHLER = "GPS offline"; standortAnsichtAktualisieren(); return; }
USER_WATCH = navigator.geolocation.watchPosition(
(pos) => {
USER_POS = { lat: pos.coords.latitude, lon: pos.coords.longitude, genauigkeit: pos.coords.accuracy };
USER_POS_TS = Date.now(); USER_POS_FEHLER = null;
standortAnsichtAktualisieren();
},
(err) => {
// Dieselbe Unterscheidung wie in standortErfassen(): die verweigerte
// Freigabe ist der einzige Fall mit einer anderen Abhilfe.
USER_POS_FEHLER = err.code === err.PERMISSION_DENIED ? "GPS-Freigabe fehlt" : "GPS offline";
USER_POS_TS = Date.now();
standortAnsichtAktualisieren();
},
// maximumAge klein: eine zwischengespeicherte Position aus der letzten
// halben Minute waere hier das Gegenteil von "live".
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 2000 }
);
}
function standortVerfolgungBeenden() {
if (USER_WATCH === null) return;
navigator.geolocation.clearWatch(USER_WATCH);
USER_WATCH = null;
}
/* Reverse-Geocoding über die öffentliche Nominatim-API (OpenStreetMap) -
wie die Kartenkacheln oben (TILE_URL) ohne eigenen Schlüssel; bei diesem
Aufrufvolumen (ein Fahrzeug, nur bei geöffneter Standortansicht) im Rahmen
@@ -961,7 +1078,11 @@ function standortMarkerZeichnen() {
}
function standortAufFahrzeug() { if (SMAP && standortBekannt()) SMAP.flyTo([CAR.standortLat, CAR.standortLon], Math.max(SMAP.getZoom(), 15)); }
function standortAufUser() {
if (!USER_POS) { standortErfassen(); return; }
// Noch keine Position: die Verfolgung neu anstossen. Das ist zugleich der
// Weg zurueck, wenn die Freigabe erst nachtraeglich erteilt wurde - ein
// einmal gescheiterter watchPosition-Auftrag meldet sich nicht von selbst
// wieder.
if (!USER_POS) { standortVerfolgungBeenden(); standortVerfolgungStarten(); return; }
if (SMAP) SMAP.flyTo([USER_POS.lat, USER_POS.lon], Math.max(SMAP.getZoom(), 15));
}
function standortBeideZeigen() {
@@ -986,7 +1107,7 @@ async function initStandortMap() {
standortMarkerZeichnen();
standortBeideZeigen();
standortAdresseLaden();
standortErfassenFallsNoetig();
standortVerfolgungStarten();
setTimeout(() => SMAP && SMAP.invalidateSize(), 60);
}
async function initVorschauMap() {
@@ -2254,7 +2375,7 @@ function vIdent() {
<dl class="rows" style="margin-top:10px">
<div class="row"><dt>Modell</dt><dd>${esc(CONFIG.fahrzeugtitel)}</dd></div>
<div class="row"><dt>Ausführung</dt><dd>${CONFIG.ausfuehrung ? esc(CONFIG.ausfuehrung) : "noch offen"}</dd></div>
<div class="row"><dt>Fahrgestellnummer</dt><dd>${esc(CAR.fin)}</dd></div>
<div class="row"><dt>FIN</dt><dd>${esc(CAR.fin)}</dd></div>
<div class="row"><dt>Motor und Leistung</dt><dd>${esc(CAR.details)}</dd></div>
<div class="row"><dt>Erstzulassung</dt><dd>${esc(CAR.erstzulassung)}</dd></div>
<div class="row"><dt>Kennzeichen</dt><dd>${esc(CONFIG.kennzeichen)}</dd></div>
@@ -2763,7 +2884,7 @@ function vEinst() {
</div>
<div class="tile"><span class="label">Kraftstoff-Rabatt</span>
<div class="feld"><label for="sdAktiv" style="display:flex;align-items:center;gap:6px">
<img src="${STATIK}/bilder/${MARKENLOGO_DATEI}" alt="Shell" style="height:14px;width:auto" onerror="this.style.display='none'"> SmartDeal aktiv</label>
<img src="${STATIK}/bilder/${MARKENLOGO_DATEI}" alt="Shell" style="height:16px;width:auto" onerror="this.style.display='none'"> SmartDeal aktiv</label>
<label class="switch"><input id="sdAktiv" type="checkbox" data-sd ${CAR.smartdeal.aktiv ? "checked" : ""}><span class="track"></span></label></div>
${CAR.smartdeal.aktiv ? `
<div class="feld" style="border-bottom:0"><label>Gültig bis</label>
@@ -3897,7 +4018,10 @@ const ICONS = {
/* Wagen von vorn - dasselbe Symbol wie in der myAudi-App (Audi-Symbolsatz,
assets/icons/car.svg). Flaeche statt Kontur, deshalb die Klasse "voll". */
audi: '<path d="M5.46 4.05a80 80 0 0 1 13.08 0l.24.02.14.2A55 55 0 0 1 21.9 9.2l2.1 2.1v8.2c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5V19l-14-.04v.54c0 .83-.67 1.5-1.5 1.5h-2A1.5 1.5 0 0 1 0 19.5v-8.2l2.1-2.1a55 55 0 0 1 2.98-4.93l.14-.2zM2.7 10 1 11.7v.3h4v1H1v6.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5v-1.55L20 18v1.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5V13h-4v-1h4v-.3L21.3 10zm17.94-1a45 45 0 0 0-2.43-3.98 78 78 0 0 0-12.44 0L4.51 7c-.3.5-.7 1.16-1.16 2.01h17.3ZM7.3 12h9.42l1.36 1.36-.1.3c-.31 1-.6 1.78-.88 2.31-.13.27-.27.5-.41.67-.13.15-.36.36-.68.36H8a.9.9 0 0 1-.68-.36c-.14-.17-.28-.4-.41-.67a17 17 0 0 1-.89-2.32l-.09-.29zm.42 1-.64.64c.28.84.52 1.47.73 1.89a2.4 2.4 0 0 0 .3.47h7.8l.02-.01c.07-.08.16-.23.28-.46.21-.42.45-1.05.73-1.9L16.3 13H7.71Z"/>',
trips: '<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"/>',
/* CI-Symbol tour-s statt der frueheren handgezeichneten Route - dieselbe
Fuellflaeche-statt-Kontur-Behandlung wie "audi" und "stat" (siehe
ICONS_VOLL). */
trips: '<path d="M3.4 3C2.6 3 2 3.64 2 4.5c0 .16.08.46.27.89a15.5 15.5 0 0 0 1.23 2.2 18.4 18.4 0 0 0 1.23-2.2c.19-.43.27-.73.27-.89C5 3.68 4.32 3 3.5 3zm.1 5.5-.41.28-.03-.03a18 18 0 0 1-.3-.45l-.7-1.14a14 14 0 0 1-.7-1.37A3.5 3.5 0 0 1 1 4.5C1 3.16 2 2 3.4 2h.1A2.5 2.5 0 0 1 6 4.5c0 .39-.17.86-.35 1.29a27 27 0 0 1-1.71 2.96l-.02.02v.01zm0 0 .41.28-.41.6-.41-.6zM12.4 3c-.8 0-1.4.64-1.4 1.5 0 .16.08.46.27.89a15.5 15.5 0 0 0 1.23 2.2 18.4 18.4 0 0 0 1.23-2.2c.19-.43.27-.73.27-.89 0-.82-.68-1.5-1.5-1.5zm.1 5.5-.41.28-.03-.03a12 12 0 0 1-.3-.45l-.7-1.14c-.25-.44-.5-.92-.7-1.37A3.5 3.5 0 0 1 10 4.5C10 3.16 11 2 12.4 2h.1A2.5 2.5 0 0 1 15 4.5c0 .39-.17.86-.35 1.29a27 27 0 0 1-1.71 2.96l-.02.02v.01zm0 0 .41.28-.41.6-.41-.6zm5.79-.5h.21a4.5 4.5 0 0 1 4.5 4.5c0 .66-.3 1.52-.67 2.38-.38.88-.9 1.83-1.4 2.7a54 54 0 0 1-1.97 3.14l-.04.05v.01l-.42-.28-.41.28-.01-.01-.04-.05a40 40 0 0 1-.58-.89 33 33 0 0 1-2.8-4.95A6.6 6.6 0 0 1 14 12.5 4.34 4.34 0 0 1 18.28 8Zm.21 12.5-.41.28.41.6.41-.6zm0-.9a53 53 0 0 0 1.57-2.53c.5-.85.98-1.76 1.35-2.6s.58-1.53.58-1.97c0-1.92-1.58-3.5-3.5-3.5h-.19c-1.94.1-3.4 1.58-3.31 3.48v.02c0 .44.2 1.13.58 1.98.37.83.86 1.74 1.35 2.6a53 53 0 0 0 1.57 2.52M9.94 11H3v-1h10.06l-7.61 5.44 13.29 7.12-.48.88-14.7-7.88L9.93 11Z"/>',
/* CI-Symbol polls-s statt der vorherigen handgezeichneten Balken - dieselbe
Fuellflaeche-statt-Kontur-Behandlung wie "audi" (siehe ICONS_VOLL). Anders
als die handgezeichneten Tab-Icons (die im 24er-Raster deutlich Rand
@@ -3920,7 +4044,7 @@ const TABS = [
{ id: "fuel", label: "Tanken", eyebrow: "", title: "Tanken" },
];
/* Symbole, die als Flaeche gezeichnet sind statt als Kontur */
const ICONS_VOLL = new Set(["audi", "stat"]);
const ICONS_VOLL = new Set(["audi", "stat", "trips"]);
let route = { name: "home" };
const ZURUECK = {
trip: "trips", fill: "fuel", service: "audi", reifen: "audi", ident: "audi",
@@ -4286,77 +4410,17 @@ const LUPE_SVG = `<svg viewBox="0 0 20 20" width="16" height="16" fill="none" st
/* Gemeinsames Präfix zweier domain-freier IDs, bis zum letzten gemeinsamen
"_" - oder null, wenn sie nicht einmal ein volles Segment teilen. */
function praefixPaar(a, b) {
let i = 0;
while (i < a.length && i < b.length && a[i].toLowerCase() === b[i].toLowerCase()) i++;
const idxUnter = a.slice(0, i).lastIndexOf("_");
return idxUnter > 0 ? a.slice(0, idxUnter + 1) : null;
}
/* Alle aktuell im Setup zugeordneten Entity-IDs (domain-frei), außer der
übergebenen selbst - Vergleichsbasis für entitaetIdKurz() unten. */
function setupAndereIdsOhneDomain(ausser) {
const ids = [];
for (const feld of setupKatalog()) {
const wert = setupZuordnung[feld.key];
if (feld.liste) { for (const w of (wert || [])) if (w && w !== ausser) ids.push(w); }
else if (wert && wert !== ausser) ids.push(wert);
}
return ids.map((id) => id.includes(".") ? id.split(".").slice(1).join(".") : id);
}
/* Die Entity-ID ohne Domain und ohne das installationsspezifische Präfix -
die tatsächliche, stabile Kennung des zugeordneten Sensors, unabhängig
davon, wie Geräte-Slug und Integrationsinstanz beim jeweiligen Nutzer
heißen (z. B. "testcar_b9_fmm003_testintegratoin_" oder
"audi_rs_4_avant_").
Das Präfix ist NICHT das mit dem einzelnen nächsten Verwandten geteilte
(frühere Fassung) - das kürzte in der Praxis zu viel: zwei Felder
derselben Sensorquelle können selbst wieder ein gemeinsames Namens-
Teilstück haben, das nichts mit der Installation zu tun hat (z. B. teilen
sich "oil_change_due" und "oil_change_distance" das Wortstück
"oil_change_", "can_fuel_volume" teilt sich "can_" mit einem anderen
CAN-Sensor) - genau das hat der Nutzer live beobachtet: "can_fuel_volume"
wurde zu bloß "fuel_volume", "oil_change_due" zu bloß "due". Stattdessen
zählt jedes Präfix, das eine Entität mit irgendeiner ANDEREN teilt, als
eine Stimme; das Präfix mit den MEISTEN Stimmen gewinnt. Das echte
Installationspräfix teilt sich die Entität mit fast allen anderen
Sensoren ihrer Quelle (viele Stimmen), ein zufällig geteiltes
Namens-Teilstück nur mit dem einen verwandten Feld (eine Stimme) - die
Mehrheit entscheidet also richtig, ohne einen einzelnen "nächsten"
Nachbarn zu bevorzugen. Bei Gleichstand gewinnt das kürzere Präfix
(vorsichtiger - weniger Kürzung im Zweifel). Mehrere gleichzeitige
Sensorquellen (z. B. FMM003 und eine zweite, "audi_rs_4_avant_"-
präfixte Integration) bleiben dabei weiterhin unabhängig voneinander
korrekt gekürzt, da die Stimmen pro Entität und nicht global gezählt
werden. Findet sich kein Verwandter mit gemeinsamem Präfix, bleibt die
volle domain-freie ID stehen statt einer geratenen, möglicherweise
falschen Kürzung. */
function entitaetIdKurz(entityId) {
if (!entityId) return null;
const ohneDomain = entityId.includes(".") ? entityId.split(".").slice(1).join(".") : entityId;
const stimmen = new Map();
for (const andere of setupAndereIdsOhneDomain(entityId)) {
const kandidat = praefixPaar(ohneDomain, andere);
if (!kandidat) continue;
stimmen.set(kandidat, (stimmen.get(kandidat) || 0) + 1);
}
let bestesPraefix = null, besteStimmen = 0;
for (const [kandidat, anzahl] of stimmen) {
const besser = anzahl > besteStimmen || (anzahl === besteStimmen && bestesPraefix && kandidat.length < bestesPraefix.length);
if (besser) { bestesPraefix = kandidat; besteStimmen = anzahl; }
}
return bestesPraefix ? ohneDomain.slice(bestesPraefix.length) : ohneDomain;
}
/* praefixPaar()/setupAndereIdsOhneDomain()/entitaetIdKurz() sind am 2026-08-31
entfallen: sie kürzten die Kennung des gerade zugeordneten Sensors für den
Beispieltext im Setup. Der kommt jetzt fest aus dem Feld-Katalog, damit er
sich nicht mehr mit jeder Auswahl ändert - siehe setupFeldzeile(). */
/* Die Art des erwarteten Werts, als kurzes Klammer-Etikett direkt neben der
Feld-Überschrift ("Tankfüllstand [%]", "12V-Batteriespannung [V]") - eine
feste Eigenschaft der FELD-ROLLE selbst (aus dem FELDER-Katalog,
einstellungen.py), unabhängig davon, ob und welcher Sensor gerade
zugeordnet ist. Bewusst NICHT der Live-Wert eines Sensors - der gehört
nirgends mehr hin, siehe entitaetIdKurz()'s eigenen Kommentar zur
Kopfzeile. binary_sensor liefert immer den Rohzustand "on"/"off"
nirgends mehr hin. binary_sensor liefert immer den Rohzustand "on"/"off"
(unabhängig von device_class - die beeinflusst nur, wie ANDERE
HA-Oberflächen ihn anzeigen, nicht den Rohwert, den diese App liest).
Bei mehreren möglichen Einheiten (z. B. "L"/"l") die klein geschriebene
@@ -4409,18 +4473,33 @@ function entitaetCombo(key, idx) {
}
function setupFeldzeile(feld) {
// Bei einem Listenfeld (vier Positionen wie Tür-/Fenstersensoren) gibt es
// keinen EINEN zugeordneten Sensor, dessen Kennung sich anzeigen ließe -
// jede Position hat ihre eigene Zuordnung, siehe die Unterfelder unten.
// Das Einheiten-Etikett neben dem Label gilt dagegen für die ganze Rolle
// und wird auch dort gezeigt.
// Einheit und Beispiel gelten beide für die ganze Feld-Rolle und stehen
// deshalb auch bei Listenfeldern (vier Positionen wie Tür-/Fenstersensoren).
const einheit = feldEinheitAnzeige(feld);
const erwartet = feld.liste ? "" : entitaetIdKurz(setupZuordnung[feld.key]);
// Nutzerkorrektur 2026-08-28: kein erfundenes statisches Beispiel mehr (war
// für Reichweite/Sofort-Aktualisierung schlicht falsch) - nur EINE Zeile,
// der tatsächlich zugeordnete Sensor (erwartet), als "(z. B. ...)"
// dargestellt. Der Klammertext selbst in Label-Größe/-Farbe, der Sensorname
// darin in derselben kleinen/grauen Schrift wie die Einheit ("[on/off]").
/* Das Beispiel steht fest im Feld-Katalog (FELDER in einstellungen.py,
Schlüssel "beispiel") und gehört damit zur FELD-ROLLE - genau wie das
Einheiten-Etikett daneben.
Vorgeschichte in zwei Schritten:
Bis 2026-08-28 stand hier ein von Hand ausgedachtes Beispiel. Das war
für Reichweite und Sofort-Aktualisierung schlicht falsch, und der
Nutzer hat es zu Recht beanstandet. Der Ersatz - die gekürzte Kennung
des GERADE ZUGEORDNETEN Sensors - war zwar nie falsch, aber auch kein
Beispiel mehr: er änderte sich mit jeder Auswahl mit und sagte einem
damit nur zurück, was man eben selbst eingestellt hatte (Nutzerkorrektur
2026-08-31: "Ich möchte, dass xxx permanent bleibt").
Jetzt wieder fest - aber nicht ausgedacht: die Namen sind die der
tatsächlich laufenden Integration, abgelesen aus der Zuordnung dieser
Installation. Damit ist das Beispiel stabil UND wahr, und beide
Beanstandungen sind zugleich erfüllt.
Listenfelder bekommen es ebenfalls: dort ist der Name der ersten
Position das Muster, nach dem sich die anderen drei richten. */
const erwartet = feld.beispiel || "";
// Der Klammertext in Label-Größe/-Farbe, der Sensorname darin in derselben
// kleinen/grauen Schrift wie die Einheit ("[on/off]").
const hinweis = erwartet
? `<span class="setup-feld-hinweis">(z. B. <span class="setup-feld-einheit">${esc(erwartet)}</span>)</span>`
: "";
@@ -4565,6 +4644,7 @@ function render() {
Aenderungspruefung in datenLaden(). */
if (MAP) { MAP.remove(); MAP = null; TILE = null; }
if (route.name === "trip" || route.name === "fill") initMap().catch(() => {}); // ohne Internet bleibt die Kartenflaeche leer
standortVerfolgungBeenden();
if (SMAP) { SMAP.remove(); SMAP = null; SMAP_TILE = null; FAHRZEUG_MARKER = null; USER_MARKER = null; }
if (route.name === "standort") initStandortMap().catch(() => {});
if (TMAP) { TMAP.remove(); TMAP = null; TMAP_TILE = null; }
@@ -5953,6 +6033,7 @@ class AudiDashboardPanel extends HTMLElement {
// (Standort- oder Uebersichtsseite offen) eine Leaflet-Instanz samt ihrem
// window-Resize-Horcher und laufenden Kachelabrufen am Leben.
if (MAP) { MAP.remove(); MAP = null; TILE = null; }
standortVerfolgungBeenden();
if (SMAP) { SMAP.remove(); SMAP = null; SMAP_TILE = null; FAHRZEUG_MARKER = null; USER_MARKER = null; }
if (TMAP) { TMAP.remove(); TMAP = null; TMAP_TILE = null; }
}
@@ -5992,6 +6073,7 @@ class AudiDashboardPanel extends HTMLElement {
<button class="back" id="back" aria-label="Zurück"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="m10.21 12 5.12-5.08-.7-.71L8.79 12l5.84 5.8.7-.72L10.22 12Z"/></svg></button> <button type="button" class="rings" id="marke" aria-label="Einstellungen"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 289 100" fill="currentColor" aria-hidden="true"><defs><clipPath id="audiRingsClip" clipPathUnits="userSpaceOnUse"><path d="M0 100h289V0H0z"/></clipPath></defs><g clip-path="url(#audiRingsClip)" transform="matrix(1 0 0 -1 0 100.00040017)"><path d="M239 11.1914c-8.885 0-17.075 3.008-23.624 8.05 6.646 8.489 10.624 19.165 10.624 30.759 0 11.593-3.978 22.269-10.624 30.759 6.549 5.041 14.739 8.05 23.624 8.05 21.399 0 38.809-17.41 38.809-38.809 0-21.4-17.41-38.809-38.809-38.809m-86.625 8.05c6.647 8.489 10.625 19.165 10.625 30.759 0 11.593-3.978 22.269-10.624 30.759 6.548 5.041 14.74 8.05 23.624 8.05 8.885 0 17.075-3.009 23.624-8.05-6.646-8.49-10.624-19.166-10.624-30.759 0-11.594 3.978-22.27 10.624-30.759-6.549-5.042-14.739-8.05-23.624-8.05-8.884 0-17.076 3.008-23.625 8.05m-63 0C96.022 27.7304 100 38.4064 100 50.0004c0 11.593-3.978 22.269-10.624 30.759 6.548 5.041 14.74 8.05 23.624 8.05 8.884 0 17.076-3.009 23.624-8.05-6.646-8.49-10.624-19.166-10.624-30.759 0-11.594 3.978-22.27 10.624-30.759-6.548-5.042-14.74-8.05-23.624-8.05-8.884 0-17.076 3.008-23.625 8.05m-78.184 30.759c0 21.399 17.41 38.809 38.809 38.809 8.884 0 17.076-3.009 23.624-8.05C66.978 72.2694 63 61.5934 63 50.0004c0-11.594 3.978-22.27 10.624-30.759-6.548-5.042-14.74-8.05-23.624-8.05-21.399 0-38.809 17.409-38.809 38.809M81.5 27.3734c-4.593 6.375-7.309 14.187-7.309 22.627 0 8.439 2.716 16.252 7.309 22.627 4.593-6.375 7.309-14.188 7.309-22.627 0-8.44-2.716-16.252-7.309-22.627m63 0c-4.593 6.375-7.309 14.187-7.309 22.627 0 8.439 2.716 16.252 7.309 22.627 4.593-6.375 7.309-14.188 7.309-22.627 0-8.44-2.716-16.252-7.309-22.627m63 0c-4.593 6.375-7.309 14.187-7.309 22.627 0 8.439 2.716 16.252 7.309 22.627 4.593-6.375 7.309-14.188 7.309-22.627 0-8.44-2.716-16.252-7.309-22.627m31.5 72.627c-11.932 0-22.896-4.207-31.5-11.207-8.604 7-19.568 11.207-31.5 11.207-11.932 0-22.896-4.207-31.5-11.207-8.604 7-19.569 11.207-31.5 11.207-11.932 0-22.896-4.207-31.5-11.207-8.604 7-19.569 11.207-31.5 11.207-27.57 0-50-22.43-50-50 0-27.571 22.43-50 50-50 11.931 0 22.896 4.206 31.5 11.207C90.104 4.2064 101.068.0004 113 .0004c11.931 0 22.896 4.206 31.5 11.207 8.604-7.001 19.568-11.207 31.5-11.207 11.932 0 22.896 4.206 31.5 11.207 8.604-7.001 19.568-11.207 31.5-11.207 27.57 0 50 22.429 50 50 0 27.57-22.43 50-50 50"/></g></svg></button>
<div class="head"><div class="eyebrow" id="eyebrow"></div><div class="title" id="title">Übersicht</div>
<div class="sync" id="sync"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="m18.65 4.95 2.56 2.55-2.56 2.55-.7-.7L19.29 8H7.2A5.24 5.24 0 0 0 2 13.38V14H1v-.6A6.24 6.24 0 0 1 7.2 7h12.1l-1.34-1.35.7-.7ZM23 11.59A6.24 6.24 0 0 1 16.8 18H4.7l1.35 1.35-.7.7-2.56-2.55 2.56-2.55.7.7L4.71 17H16.8a5.24 5.24 0 0 0 5.2-5.38V11h1v.6Z"/></svg><span></span></div></div>
<span class="kopfgegengewicht" aria-hidden="true"></span>
<button class="themebtn" id="theme" hidden aria-hidden="true"><svg id="themeicon" viewBox="0 0 20 20" width="15" height="15"></svg></button>
<button class="profilbtn" id="ringsbtn" aria-label="Einstellungen"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.6 1.6 0 0 0 .33 1.76l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.6 1.6 0 0 0-1.76-.33 1.6 1.6 0 0 0-1 1.47V21a2 2 0 1 1-4 0v-.1a1.6 1.6 0 0 0-1-1.47 1.6 1.6 0 0 0-1.77.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.6 1.6 0 0 0 4.6 15a1.6 1.6 0 0 0-1.47-1H3a2 2 0 1 1 0-4h.1a1.6 1.6 0 0 0 1.47-1 1.6 1.6 0 0 0-.33-1.77l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.6 1.6 0 0 0 9 4.6 1.6 1.6 0 0 0 10 3.13V3a2 2 0 1 1 4 0v.1a1.6 1.6 0 0 0 1 1.47 1.6 1.6 0 0 0 1.77-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.6 1.6 0 0 0 19.4 9v.04a1.6 1.6 0 0 0 1.47 1H21a2 2 0 1 1 0 4h-.1a1.6 1.6 0 0 0-1.5 1Z"/></svg></button>
</div>
@@ -105,6 +105,19 @@ html,body{height:100%}
.rings svg{height:17px}
.profilbtn{display:none}
/* Unterseite: kompakter, zentrierter Titel wie in einer iOS-Navigationsleiste */
/* Gegengewicht zum Zurueck-Pfeil auf Unterseiten.
Der Titel wird dort mittig gesetzt (.head{align-items:center}), aber nur
innerhalb von .head - und .head beginnt erst hinter dem Zurueck-Pfeil.
Rechts steht nichts, weil die Ringe auf Unterseiten ausgeblendet werden.
Die Ueberschrift sass deshalb um die halbe Pfeilbreite zu weit rechts;
in der App an derselben Stelle live gemessen: 17px (2026-08-31).
Gleiche Masse wie .back (34px, margin -8px), damit links und rechts
exakt derselbe Platz belegt ist. Auf breiten Bildschirmen steht der
Titel linksbuendig - dort ist der Platzhalter abgeschaltet. */
.kopfgegengewicht{display:none}
.phone:has(.back.on) .kopfgegengewicht{display:block;order:10;flex:none;width:34px;height:34px;margin-right:-8px}
.phone:has(.back.on) .topbar{padding:54px 16px 6px;gap:8px}
.phone:has(.back.on) .head{align-items:center}
.phone:has(.back.on) .title{font-size:17px;font-weight:400;letter-spacing:-.01em;text-align:center}
@@ -217,11 +230,19 @@ main#view{--seitenrand:16px;padding:0 var(--seitenrand) 30px;scroll-behavior:smo
.legend span{font-size:12.5px}
/* ---------------- Menüs und Popups ---------------- */
.bildmenu,.sdpopup{border:0;border-radius:14px;background:rgba(40,44,52,.92);
/* Blaetter statt schwebender Karten (siehe audi-dashboard.css). Die Radien
folgen der neuen Lage: das Aktionsblatt steht frei ueber der unteren
Kante und ist rundum rund, das Formularblatt sitzt auf der Kante auf und
ist nur oben rund. */
.bildmenu{border:0;border-radius:16px;background:rgba(40,44,52,.92);
-webkit-backdrop-filter:saturate(180%) blur(22px);backdrop-filter:saturate(180%) blur(22px);
box-shadow:0 12px 40px rgba(0,0,0,.5)}
.sdpopup{border:0;border-radius:16px 16px 0 0;background:rgba(40,44,52,.92);
-webkit-backdrop-filter:saturate(180%) blur(22px);backdrop-filter:saturate(180%) blur(22px);
box-shadow:0 -8px 40px rgba(0,0,0,.5)}
:host([data-theme="tag"]) .bildmenu,:host([data-theme="tag"]) .sdpopup{background:rgba(250,250,252,.92)}
.bildmenu button{padding:13px 16px;font-size:16px;border-bottom:1px solid var(--ios-sep)}
.bildmenu button{padding:16px 18px;font-size:16px;min-height:52px;border-bottom:1px solid var(--ios-sep)}
.bildmenu button:last-child{border-bottom:none}
/* ---------------- Tab-Leiste ---------------- */
.tabbar{border-top:1px solid var(--ios-sep);padding-bottom:26px;
@@ -319,6 +340,7 @@ main#view{--seitenrand:16px;padding:0 var(--seitenrand) 30px;scroll-behavior:smo
/* Die Kopf-Ringe setzt die App per Inline-Stil (display:inline-flex),
deshalb hier mit !important. */
.rings{display:none !important}
.kopfgegengewicht{display:none !important}
/* Nur die Box selbst hoeher setzen, nicht jedes Element mit .carfix: die
Klasse haengt auch am <img> und am Platzhalter-<div> (siehe
@@ -338,7 +360,7 @@ main#view{--seitenrand:16px;padding:0 var(--seitenrand) 30px;scroll-behavior:smo
ruhigen Karte. max-width + margin:auto zentriert sie innerhalb des
bestehenden left/right-Abstands, ohne die transform-basierte
Einblend-Animation dieser Elemente zu beruehren. */
.sdpopup,.sheet,.beleg-popup{max-width:560px;margin-left:auto;margin-right:auto}
.sdpopup,.sheet,.beleg-popup,.bildmenu{max-width:560px;margin-left:auto;margin-right:auto}
/* .setup-popup breiter als die übrigen Popups: die Sensorkennung neben der
Feld-Überschrift (entitaetIdKurz()) kann ein echter Rohsensorname von
Herstellerseite sein, teils 40-60 Zeichen lang
@@ -599,8 +599,12 @@ button.leaf:active { background: var(--tile-2); }
.szene.bleed::before {
content: "";
position: absolute; left: 0; right: 0; bottom: 0;
height: 42%;
background: linear-gradient(to bottom, transparent, var(--canvas) 78%);
/* 26/96 statt 42/78: der Verlauf soll die harte Bildkante ausblenden,
nicht das Fahrzeug. Mit 42% Hoehe und Vollton ab 78% war das untere
Drittel des Fotos praktisch einfarbig - Raeder und Schweller lagen
darunter (gemeldet vom Eigentuemer, 2026-08-31). */
height: 26%;
background: linear-gradient(to bottom, transparent, var(--canvas) 96%);
pointer-events: none;
z-index: 1;
}
@@ -668,7 +672,9 @@ button.leaf:active { background: var(--tile-2); }
.marke { display: inline-flex; align-items: center; gap: 6px; }
.marke-logo { display: inline-flex; align-items: center; }
.marke-logo img { height: 13px; width: auto; }
/* Auf Zeilenhoehe (16px) statt 13px: das Markenlogo stand neben Text
derselben Zeile und wirkte daneben zu klein. */
.marke-logo img { height: 16px; width: auto; }
.marke-logo .ph { display: none; font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--fg3); }
.marke-logo.logo-fehlt img { display: none; }
.marke-logo.logo-fehlt .ph { display: inline; }
@@ -1011,19 +1017,23 @@ button.leaf:active { background: var(--tile-2); }
gezaehlt, das Blatt war auf dem Geraet rund 16px hoeher als noetig und
schob sich entsprechend weiter hoch. Am Rechner faellt das nicht auf,
dort ist der Einzug 0. */
padding: 0 20px 18px;
padding: 0 20px 34px;
transform: translateY(calc(100% - var(--standort-peek)));
transition: transform .28s cubic-bezier(.22,.61,.36,1);
}
/* Aufgezogen rastet das Blatt auf der mittleren Hoehe ein, nicht auf seiner
Inhaltshoehe. Apple beschreibt genau das als "detents": Hoehen, auf denen
ein Blatt natuerlich zur Ruhe kommt - large ist das voll ausgefahrene
Blatt, medium etwa die Haelfte davon. Vorher war die Rasthoehe schlicht
die Hoehe des Inhalts, dadurch schlossen die Knoepfe unten buendig mit der
Menueleiste ab. Der Ueberschuss liegt unter den Knoepfen und haelt sie von
der Leiste frei. Die eingeklappte Hoehe bleibt unberuehrt: die Verschiebung
rechnet mit 100% der jeweiligen Blatthoehe minus dem Guckwert. */
.standortmenu.offen { transform: translateY(0); min-height: 50%; }
/* Aufgezogen bleibt unter den Knoepfen ein fester Abstand stehen, statt dass
sie buendig mit der Menueleiste abschliessen.
Vorher stand hier min-height:50% - Apples "medium detent". Gemessen ergab
das 89px (Panel) bzw. 76px (App) Luft unter dem letzten Knopf, und das war
sichtbar zu viel: das Blatt zog ueber die Haelfte des Bildschirms auf,
obwohl sein Inhalt dort laengst zu Ende war. Eine Raste ist nur dann
richtig, wenn zwischen ihr und der Inhaltshoehe kein grosses Loch klafft.
Jetzt bestimmt der Inhalt die Hoehe, und die 34px Fussabstand halten die
Knoepfe von der Leiste frei - hergeleitet aus der Beanstandung, nicht
geraten: 18px waren zu wenig, 76-89px zu viel. */
.standortmenu.offen { transform: translateY(0); }
.standortmenu-griffzone { padding: 10px 0 14px; touch-action: none; cursor: grab; position: relative; }
/* Der Griff ist jetzt ein Knopf (Tastaturzugang zum Menue). Die Knopf-
Voreinstellungen des Browsers werden deshalb zurueckgesetzt; die
@@ -1090,38 +1100,62 @@ button.leaf:active { background: var(--tile-2); }
.standort-pille[disabled] { opacity: .4; pointer-events: none; }
/* ------------------------------------------------------- Bilderverwaltung */
.bildmenu-catcher { position: fixed; inset: 0; z-index: 1000; }
/* ---------------------------------------------------------- Blätter
Apples Vorgabe kennt für etwas Modales auf dem Telefon kein frei
schwebendes Fenster: es kommt von der unteren Kante. Das Aktionsblatt
(eine kurze Liste von Wegen) und das Formularblatt (eine Eingabe) sind
die beiden Formen; die Karte in der Bildschirmmitte war keine von
beiden und stand ausserdem ohne abgedunkelten Grund über der voll
sichtbaren Seite.
Der Schleier gehört dazu und ist nicht Geschmack: modal heisst, dass
der Inhalt dahinter nicht bedienbar ist - das soll man sehen. */
.bildmenu-catcher {
position: absolute; inset: 0; z-index: 1000;
background: rgba(0,0,0,.45);
animation: sheet-fade .18s ease-out;
}
.bildmenu {
position: absolute; z-index: 1001;
background: var(--tile-2);
border-radius: 14px;
padding: var(--sp-2);
left: 10px; right: 10px;
bottom: max(10px, env(safe-area-inset-bottom));
top: auto;
transform: none;
background: var(--tile-deckend, var(--canvas));
border-radius: 16px;
overflow: hidden;
padding: 0;
display: flex; flex-direction: column;
min-width: 160px;
animation: sheet-rein .24s cubic-bezier(.22,.61,.36,1);
}
.bildmenu.rad { top: 90px; left: 22px; }
/* "Bild der Übersicht": das Vorschaubild füllt die ganze Kachelbreite, seine
Unterkante liegt daher immer nah an der Kachel-Unterkante - das Menü von
dort aus verankern statt mit einem festen top-Wert, der bei jedem
Bildseitenverhältnis neu nachgemessen werden müsste. */
.bildmenu.ansicht { bottom: var(--sp-5); left: 50%; transform: translateX(-50%); }
/* Die beiden Lage-Varianten sind gegenstandslos geworden - ein Blatt
kommt immer von unten. Die Klassen bleiben im Markup stehen, damit die
Aufrufstellen unberührt bleiben; hier tragen sie nichts mehr bei. */
.bildmenu.rad, .bildmenu.ansicht { left: 10px; right: 10px; top: auto; transform: none; }
.bildmenu button {
display: block; width: 100%; text-align: left;
background: none; border: none;
color: var(--fg); font-family: inherit; font-size: 15px;
padding: 13px 10px; min-height: 46px; border-radius: 10px;
color: var(--fg); font-family: inherit; font-size: 16px;
padding: 16px 18px; min-height: 52px; border-radius: 0;
border-bottom: 1px solid var(--line);
cursor: pointer;
}
.bildmenu button:last-child { border-bottom: none; }
.bildmenu button:active { background: var(--shade); }
.bildmenu button.loeschen { color: var(--bad); }
/* Formularblatt (siehe Kasten oben): unten angeschlagen, nur oben rund,
blickdicht. Stand vorher als Karte 60px unter dem oberen Rand - ausser
Daumenreichweite und ohne sichtbaren Weg hinaus. */
.sdpopup {
position: absolute; z-index: 1001;
left: var(--sp-5); right: var(--sp-5); top: 60px;
background: var(--tile-2);
border-radius: 14px;
padding: var(--sp-4);
left: 0; right: 0; bottom: 0; top: auto;
background: var(--tile-deckend, var(--canvas));
border-radius: 16px 16px 0 0;
padding: var(--sp-4) 20px max(20px, env(safe-area-inset-bottom));
animation: blatt-hoch .28s cubic-bezier(.22,.61,.36,1);
}
@keyframes blatt-hoch { from { transform: translateY(100%) } to { transform: none } }
/* Beleg-Fenster: PDF hineinziehen (Rechner) oder einfügen (Telefon). */
.beleg-catcher {
@@ -1129,15 +1163,20 @@ button.leaf:active { background: var(--tile-2); }
background: rgba(0,0,0,.45);
animation: sheet-fade .18s ease-out;
}
/* Formularblatt (siehe Kasten oben). Traegt drei Fenster: Beleg
hochladen, Daten importieren, Datensatz sichern/laden. */
.beleg-popup {
position: absolute; z-index: 1001;
left: var(--sp-5); right: var(--sp-5);
top: 50%; transform: translateY(-50%);
/* Wie .setup-popup: --tile-2 ist im Nacht-Theme transluzent, ein frei
schwebendes Fenster braucht einen blickdichten Grund. */
left: 0; right: 0; bottom: 0;
top: auto; transform: none;
max-height: 88%;
overflow-y: auto;
/* --tile-2 ist im Nacht-Theme transluzent; ein Blatt ueber der Seite
braucht einen blickdichten Grund. */
background: var(--tile-deckend, var(--canvas));
border-radius: var(--r-tile);
padding: var(--sp-4);
border-radius: 16px 16px 0 0;
padding: var(--sp-4) 20px max(20px, env(safe-area-inset-bottom));
animation: blatt-hoch .28s cubic-bezier(.22,.61,.36,1);
}
.beleg-drop {
margin-top: var(--sp-3);