Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-10-16-kundenansicht-02-fundament-huelle.md

1257 lines
120 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Kundenansicht neu, P1 Fundament und Hülle: Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Übersicht/Verträge:** /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht/docs/superpowers/plans/2026-10-16-kundenansicht-00-uebersicht.md (gilt wörtlich)
**Worktree und Branch:** `/home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui`, Branch `kn/ui` (aus Tag `kn-p0`). Tag nach Abschluss: `kn-p1`. Das Anlegen des Worktrees steht in Task 1, Step 0.
**Goal:** Die weiße Hülle der neuen Kundenansicht steht und ist mit Testdaten sichtbar: Tokens und Basis-CSS, Routen und Router, Fahrstuhl-Zeile (Handy) und Menü (Desktop), Konto, Widerrufsstreifen, Zähler, Etage 2, Start über `/api/auth/me` und die Seite `public/neu/dashboard.html` mit leeren Ansichten für P2 und P3. V2 und V3 der Übersicht sind damit vollständig erfüllt.
**Architecture:** Reine Logik (Routen, History-Regeln, Zähler, Zahlenfunktionen der Leiste, Ableitung aus `/api/auth/me`) steht in Modulen ohne DOM-Zugriff beim Import und wird mit `node --test` geprüft. DOM-Teile bauen nur mit `h()` und `textContent` und werden mit `test/support/fakeDom.js` geprüft. Geometrie, Animation und Tastatur prüft allein die Sichtprüfung über das Werkzeug aus P0 (Task 13). Jedes Modul bekommt seine Abhängigkeiten (api, Fenster, Zeitgeber, Lader) als Parameter mit Vorgabe, damit kein Test einen Browser braucht.
**Tech Stack:** Vanilla ES-Module unter `public/neu/` (relative Importe, kein Build), CSS ohne Präprozessor, `node --test` mit `fakeDom`, Playwright nur über das Werkzeug aus P0.
**Spec:** `docs/superpowers/specs/2026-10-15-kundenansicht-neu-design.md`, Abschnitte 5.1 bis 5.3, 7 (C2, C3). Demo: `/root/reports/kundenansicht-neu-2026-10-15/handy-leiste/final-2-frei.html`. Code-Karte: `karten/huelle-tests.md`.
## Global Constraints
Alle Global Constraints der Übersicht gelten wörtlich (Aussehen, „du“, keine Preise für Funktionen, Hashes, rechtlich fest, Tests, Commits, Sicherheitsblock). Paketeigene Zusätze:
- Nur Dateien aus der Dateiübersicht anlegen oder ändern; am Ende zeigt `git diff --name-only kn-p0` genau diese Liste. Nie `public/app.js`, die alte `public/dashboard.html` oder `public/*.css` anfassen.
- `public/neu/*.js`: relative Importe, kein DOM-Zugriff beim Import (Module müssen in Node importierbar sein), kein `innerHTML`/`insertAdjacentHTML`/`outerHTML`; Kundentext nur über `h()` oder `textContent`; `inert` nur über `setAttribute`/`removeAttribute` (fakeDom kennt die Eigenschaft nicht). Absolute Importe gibt es nur als `import()` der gemeinsamen Funktionsmodule (und von `/neu/funktion-einschalten.js`, Task 11) in `huelle.js`, über einen einspritzbaren Lader.
- Kein Wort `admin.html`, `kalkulation.html`, `/api/portal-admin` in neuen Dateien (`test/adminPanelGone.test.js:59` liest `public/` rekursiv).
- **CSS:** Die Grundregeln gelten am Desktop, die Handy-Abfrage `(max-width: 880px), (pointer: coarse) and (max-height: 500px)` überschreibt für das Handy. Es gibt keine gegenläufige zweite Abfrage (`not` gilt in einer Abfrageliste nur für das erste Glied). In JS steht sie genau einmal als `HANDY_ABFRAGE` (`leiste.js`).
- Bewegung: Federn `cubic-bezier(.3,1.25,.4,1)` 550 ms (Balken), Blatt und Reel `cubic-bezier(.2,.8,.2,1)` 550 ms. Unter `prefers-reduced-motion: reduce` höchstens 150 ms Überblenden. Knöpfe und Felder am Handy mindestens 44 px hoch, Textfelder 16 px.
- **Tests:** Dateien heißen `test/knHuelle<Name>.test.js`. Jede beginnt mit `import { test } from 'node:test'`, `import assert from 'node:assert/strict'`, `installFakeDom` aus `./support/fakeDom.js` (bei DOM-Tests), `h` aus `../public/neu/dom.js` und den Importen der getesteten Module. Die Hilfen `lies`, `da`, `handyTeil`, `HANDY` kommen aus `test/support/knHuelle.js` (Task 1). Jede Datei läuft einzeln mit dem Befehl der Übersicht.
- **Werkzeug:** Es liest den Quellbaum aus `WT`. Für diesen Strang gilt `KN_STRANG_ENV=/root/reports/kundenansicht-neu-2026-10-15/werkzeug/tmp/strang-ui.env` mit der Zeile `export WT=/home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui` (Task 13, Step 1).
- Commit-Trailer wörtlich `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>` als zweites `-m`; Dateien einzeln hinzufügen.
## Review Focus
1. **Alte Links verlieren ihre Query oder landen falsch.** `#termine?bestellt=1&projekt=x`, `#webshop?stripe=zurueck&projekt=x`, `#news?bestellt=1`, `#fortschritt`, `#wartung`, `#abo`, `?abo=success` vor dem Hash: Query bleibt Zeichen für Zeichen, Ziel stimmt, die Alias-Umschreibung löscht nichts, `?abo=` bleibt in `location.search`, bis P2 es aufräumt. `#abo` und `#wartung` ergeben den ausdrücklichen Reiter und den Hash `#abo`, nie `#vertraege`: Nur `#vertraege` lässt die Ansicht den Standardreiter wählen (P2 `waehleReiter` unterscheidet am rohen Hash), sonst springt ein Kunde mit offener Rechnung vom Reiter Abo zurück auf Rechnungen und erreicht Abo nie. Module löschen mit `replaceState(null, …)` den Verlaufsstempel des Routers (`termine.js:40`, `shop.js:59`, `zeiten.js:39`, `funktionen.js:487`). Tests: Task 3 („Alte Links“, „Umkehrung“), Task 4 („Alias“, „Reiter und Bereiche“, „Stempel“), Task 11 („wunschProjekt“).
2. **Zurück-Taste und Android-Geste.** Erst Sheet, dann Etage 2, dann Website; Einstieg per Link direkt in Etage 2 hat einen Eltern-Eintrag; Reiterwechsel erzeugt keine Einträge; Neuladen mit Overlay-Stempel öffnet nichts. Tests: Task 4, Task 9, Task 13 („kn-huelle-konto“).
3. **Schmale Handys und viele Reiter.** 320 px breit: vier Wörter passen; „99+“ schiebt nichts hinaus; Etage 2 mit sechs Reitern scrollt waagerecht und rückt den aktiven Reiter ins Bild (C2); Overlay-Rückfall bei abweichenden Breiten. Tests: Task 5 (Zahlenfunktionen), Task 6 („kn:reiter“), Task 13 („kn-huelle-etage“ bei 320 px).
4. **Tastatur, Querformat, Safe-Area.** Android schließt die Tastatur ohne Blur: Leiste kommt zurück. Drehen setzt die volle Höhe zurück. Leiste und Streifen verschwinden nur bei Texteingabe. Tests: Task 10 („tastaturZustand“), Task 13 („kn-huelle-frist“, „kn-huelle-quer“).
5. **Start bei kaputtem oder fremdem Zustand.** `/api/auth/me` 500 oder Netz weg, abgelaufene Kundenansicht, geändertes Passwort; `/api/fristen` 404 (Server ohne P5) oder 500; Abo und Rechnungen fallen aus: nie eine leere weiße Seite, Zähler behalten den letzten Wert, „Vertrag widerrufen“ bleibt im Konto. Tests: Task 2 (ApiFehler), Task 7, 8, 11 („me 500“, „Fristen 404“), Task 13 („kn-huelle-quellen-fehler“).
---
## Dateiübersicht
| Datei | Aufgabe | Task |
|---|---|---|
| `public/neu/tokens.css`, `basis.css` | Tokens `--kn-*`; Knöpfe, Felder, Unterstrich-Reiter, Pillen, Listen, Dialog/Sheet, Leerzustände, Meldungen | 1 |
| `public/neu/huelle.css` | Blatt (6), Streifen (8), Konto (9), Leiste, Menü, Kopf, Querformat, reduzierte Bewegung (10) | 6, 8, 9, 10 |
| `public/neu/dom.js`, `api.js` | `h`, `leeren`, `text`; `api`, `ApiFehler` | 2 |
| `public/neu/routen.js`, `router.js` | Routentabelle; History-Regeln, Overlay, Stempel | 3, 4 |
| `public/neu/reiter.js`, `etage.js` | gemeinsame Reiter; Etage 2 | 5, 6 |
| `public/neu/zaehler.js`, `fristen-streifen.js` | Zähler V3; Widerrufsstreifen | 7, 8 |
| `public/neu/konto.js`, `leiste.js` | Avatar und Konto; Fahrstuhl-Zeile, Menü, Tastaturzustand | 9, 10 |
| `public/neu/huelle.js` | Start, Ableitung aus `/api/auth/me`, Zusammenbau, Modul-Lader | 11 |
| `public/neu/dashboard.html` (ersetzt den Platzhalter aus P0), `manifest.webmanifest` | Seite und PWA (weiß) | 12 |
| `public/neu/ansicht-{website,funktionen,chat,vertraege,passwort,gutschrift,wechsel,bestellen}.js`, `ansichten.css`, `funktionen.css`, `module.css` | Stubs, die P2 bis P4 füllen (Vertragsfrage 3) | 12 |
| `test/support/knHuelle.js` | Testhilfen | 1 |
| `test/knHuelle{Css,DomApi,Routen,Router,Reiter,Etage,Zaehler,Fristen,Konto,Leiste,Start,Seite,Quelltext}.test.js` | Tests, je Task eine | 1 bis 12, 14 |
| **Pin aus P0:** `test/kundenansichtDashboard.test.js`, Hilfsfunktion `istNeu` (Marker `data-kn-platzhalter`) | auf `<meta name="kn-seite" content="neu">` umstellen, weil die Seite den Platzhalter ersetzt | 12 |
| Werkzeug (`/root/reports/kundenansicht-neu-2026-10-15/werkzeug/`, eigenes Git): `views.mjs`, `gates.mjs`, `test/statik.test.mjs` | Ansichten `kn-huelle-*`, Gate `p1`, die zwei Platzhalter-Ansichten und der Marker im Statik-Test (Task 3 von P0 und Task 10 von P0) | 13 |
**Bestehende Tests:** sonst keine Änderung nötig. Geprüft: `test/termineView.test.js:397,418` und die Pins in `shopView`, `shopWiring`, `zeitenView`, `zeitenBestellen` lesen `public/` ohne Unterordner; `test/adminPanelGone.test.js:59` sucht rekursiv nur die verbotenen Wörter; `test/auftraegeKundenportalHuelle.test.js:50` pinnt die alte `public/manifest.webmanifest`, die unberührt bleibt.
**Festlegungen des Pakets (ohne Vertragswirkung):**
- Verlauf: Reiter- und Bereichswechsel `replaceState`; Etage 2 und Konto-Sheet `pushState`, in beiden Layouts (heute nur Handy, `app.js:2226-2229`). Bei Einstieg per Link legt der Router eine Anker-Kette an (Website, Eltern, Ziel), damit „Zurück“ bis zur Website führt (Demo `back()`).
- Das Konto-Sheet ist ein Menü (Tipp aufs Dunkle schließt). Formular-Dialoge `.kn-dialog` schließen nur per × und Esc.
- Desktop-Menü: aktiver Eintrag wie im Ticketsystem (`#111`, Gewicht 600, Fläche `--kn-accent-soft`).
- Das Konto hat weder Ansprechpartner-Karte noch „Abo verwalten“ (Spec 5.2 nennt sie nicht).
- iOS-Statusleiste `default`, `theme-color #ffffff`; der 1-px-Kniff der alten Seite (`login.css:644-655`) gilt nur für `black-translucent` und entfällt. Gerätecheck in Task 14.
- Fristdaten werden in `Europe/Berlin` formatiert (wie `app.js:816`). Die Hülle ruft die Ansicht bei jedem `zeige` neu auf (wie `viewRefreshers`, `app.js:1937`).
---
### Task 1: Tokens und Basis-CSS
**Files:**
- Create: `public/neu/tokens.css`, `public/neu/basis.css`, `test/support/knHuelle.js`
- Test: `test/knHuelleCss.test.js`
**Interfaces:**
- Consumes: nichts.
- Produces: `--kn-*` auf `.kn` (Namen im Test) und diese Klassen (die Namen, die P2 in seinem VF5 annimmt): `.kn-knopf`, `.kn-knopf-primaer`, `.kn-knopf-zweit`, `.kn-textknopf`, `.kn-link-danger`, `.kn-feld` (Beschriftung, Feld, Hinweis), `.kn-input`, `.kn-select`, `.kn-textarea`, `.kn-check`, `.kn-reiter` mit `.kn-reiter-t` (Unterstrich), `.kn-pille` mit `-grau|-blau|-orange|-violett|-gruen|-rot|-schlicht` (Status), `.kn-liste`, `.kn-zeile` (`.kn-zeile-link`), `.kn-ueberlagerung`, `.kn-dialog`, `.kn-sheet`, `.kn-leer`, `.kn-meldung` (`-ok`, `-warn`), `.kn-fehler`, `.kn-titel`, `.kn-abschnitt`.
- Testhilfe `test/support/knHuelle.js`: `HANDY` (`'@media (max-width: 880px), (pointer: coarse) and (max-height: 500px)'`), `lies(datei)` (liest `public/neu/<datei>` als Text), `da(datei) -> boolean`, `handyTeil(css)` (Text ab dem ersten `HANDY`, sonst `''`).
- [ ] **Step 0: Vorbedingungen und Worktree**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && git tag --list kn-p0 && ls public/neu/dashboard.html`
Expected: `kn-p0` und der Platzhalter. Fehlt eines, Abbruch und Controller melden (P0 nicht fertig).
Existiert `dev-kn-ui` nicht: `git -C /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht worktree add ../dev-kn-ui -b kn/ui kn-p0 && cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && ln -s ../build/node_modules node_modules`; danach `git status --short` leer.
- [ ] **Step 1: Write the failing test** (zuerst `test/support/knHuelle.js` nach obiger Signatur, dann)
```js
import { lies, da, handyTeil } from './support/knHuelle.js'
const tokens = (css) => Object.fromEntries([...css.matchAll(/(--kn-[\w-]+)\s*:\s*([^;]+);/g)].map((m) => [m[1], m[2].trim()]))
test('tokens.css: Werte wörtlich aus der Übersicht und global.css:7-51', () => {
const t = tokens(lies('tokens.css'))
const soll = {
'--kn-bg': '#fff', '--kn-bg-hover': '#f6f8fa', '--kn-bg-subtle': '#f8f9fb',
'--kn-text': '#1a1f36', '--kn-text-2': '#5e6678', '--kn-text-3': '#8a93a3',
'--kn-line': '#ebeef1', '--kn-line-strong': '#d8dde3', '--kn-accent': '#111',
'--kn-grau-bg': '#ebeef1', '--kn-grau-fg': '#414552', '--kn-blau-bg': '#e0f2fe', '--kn-blau-fg': '#075985',
'--kn-orange-bg': '#fcedb9', '--kn-orange-fg': '#a82c00', '--kn-violett-bg': '#efeafe', '--kn-violett-fg': '#5b21b6',
'--kn-gruen-bg': '#d7f7c2', '--kn-gruen-fg': '#006908', '--kn-rot-bg': '#ffe7f2', '--kn-rot-fg': '#b3093c',
'--kn-danger': '#c0123c', '--kn-radius': '6px', '--kn-radius-lg': '12px', '--kn-sidebar-w': '232px',
'--kn-ring': '0 0 0 1px rgba(60,66,87,.16), 0 1px 1px rgba(0,0,0,.06)',
'--kn-font': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
}
for (const [name, wert] of Object.entries(soll)) assert.equal(t[name], wert, name)
})
const VERBOTEN = [/gradient\(/i, /backdrop-filter/i, /filter:\s*blur/i, /prefers-color-scheme/i, /#9d3dfe|#d4b4ff|rgba\(157/i, /font-weight:\s*[89]00/, /dot-pattern|aurora/i]
test('keine verbotenen Muster (Verläufe, Glas, Dunkelmodus, Lila, Gewicht 800)', () => {
for (const datei of ['tokens.css', 'basis.css', 'huelle.css'].filter(da)) for (const m of VERBOTEN) assert.doesNotMatch(lies(datei), m, `${datei} ${m}`)
})
test('basis.css: Bausteine, Maße des Ticketsystems, 44 px und 16 px am Handy', () => {
const css = lies('basis.css')
for (const k of ['.kn-knopf', '.kn-knopf-primaer', '.kn-knopf-zweit', '.kn-textknopf', '.kn-link-danger', '.kn-feld', '.kn-input', '.kn-select', '.kn-textarea', '.kn-check', '.kn-reiter-t', '.kn-pille-gruen', '.kn-liste', '.kn-zeile-link', '.kn-ueberlagerung', '.kn-dialog', '.kn-sheet', '.kn-leer', '.kn-meldung-warn', '.kn-fehler', '.kn-titel', '.kn-abschnitt']) assert.ok(css.includes(k), k)
assert.match(css, /\.kn-pille\s*\{(?=[^}]*height:\s*20px)(?=[^}]*border-radius:\s*4px)/s)
assert.match(css, /\.kn-titel\s*\{(?=[^}]*font-size:\s*28px)(?=[^}]*line-height:\s*34px)(?=[^}]*font-weight:\s*700)/s)
assert.match(css, /:focus-visible\s*\{(?=[^}]*outline:\s*2px solid #111)(?=[^}]*outline-offset:\s*2px)/s)
assert.match(css, /:disabled[^{]*\{[^}]*opacity:\s*\.55/s)
assert.match(handyTeil(css), /min-height:\s*44px/); assert.match(handyTeil(css), /font-size:\s*16px/)
})
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleCss.test.js`
Expected: FAIL (ENOENT `tokens.css`).
- [ ] **Step 3: Implement `tokens.css` und `basis.css`**
`tokens.css`: alle Namen aus dem Test auf `.kn`, dazu `--kn-accent-hover:#2b2b2b`, `--kn-accent-soft:rgba(17,17,17,.06)`, `--kn-accent-ring:rgba(17,17,17,.14)`, `--kn-danger-ring:rgba(192,18,60,.18)`, `--kn-shadow-btn`, `--kn-shadow-pop` (beide wörtlich aus `woms-design.md` Abschnitt 1), `--kn-overlay:rgba(26,31,54,.32)`, `--kn-sp-1…6` (4/8/12/16/24/32 px), `--kn-feder:cubic-bezier(.3,1.25,.4,1)`, `--kn-bremse:cubic-bezier(.2,.8,.2,1)`, `--kn-zeile-h:56px`, `color-scheme: light`.
`basis.css`: Maße und Selektoren 1:1 aus `/root/reports/kundenansicht-neu-2026-10-15/bestand/woms-design.md` Abschnitt 2 und 4, Klassen mit Präfix `kn-` (`.btn`→`.kn-knopf`, `.btn-primary`→`.kn-knopf-primaer`, `.btn-quiet`→`.kn-textknopf`, `.badge`→`.kn-pille`, `.tab`→`.kn-reiter-t`, `.overlay`→`.kn-ueberlagerung`, `.empty`→`.kn-leer`, `.msg.err`→`.kn-fehler`); `.kn-knopf-zweit` sieht aus wie `.kn-knopf`. Unter `HANDY`: Knöpfe, Felder und Reiter mindestens 44 px, Textfelder 16 px, `.kn-dialog`/`.kn-sheet` unten verankert (max. 92 vh).
- [ ] **Step 4: Run test to verify it passes**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleCss.test.js`
Expected: PASS (3 Tests).
- [ ] **Step 5: Commit**
```bash
git add public/neu/tokens.css public/neu/basis.css test/support/knHuelle.js test/knHuelleCss.test.js
git commit -m "Kundenansicht neu P1: Tokens und Basis-CSS im weißen Stil" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 2: dom.js, api.js und Quelltext-Regel
**Files:**
- Create: `public/neu/dom.js`, `public/neu/api.js`
- Test: `test/knHuelleDomApi.test.js`, `test/knHuelleQuelltext.test.js`
**Interfaces:**
- Consumes: nichts.
- Produces (V2):
- `h(tag, attrs = {}, ...kinder) -> Element`: `attrs.class`, `attrs.on = { click: fn }`, `true` setzt ein Attribut ohne Wert, `false/null/undefined` entfallen, Kinder werden flach gelegt, Strings werden Textknoten.
- `leeren(el) -> el`, `text(el, wert) -> el` (`textContent = String(wert ?? '')`).
- `async api(pfad, { method = 'GET', body, roh } = {}) -> Object`. `roh` ist ein Rohdaten-Body, der unverändert mit `Content-Type: application/octet-stream` geht (wie `fn-util.js:request`, Vertragsfrage 8).
- `class ApiFehler extends Error` mit `status`, `code`, `message`, `field`, `body` (immer ein Objekt).
- [ ] **Step 1: Write the failing tests**
`test/knHuelleDomApi.test.js`:
```js
import { api, ApiFehler } from '../public/neu/api.js'
import { leeren, text } from '../public/neu/dom.js'
test('h: Kundentext bleibt Text; on, boolesche und fehlende Attribute; leeren, text', async () => {
const fx = installFakeDom()
try {
let klicks = 0
const k = h('button', { class: 'kn-knopf', hidden: false, 'data-x': true, on: { click: () => { klicks += 1 } } }, '<b>x</b>', [null, 'y'])
await fx.klick(k)
assert.deepEqual([klicks, k.className, k.hasAttribute('hidden'), k.getAttribute('data-x')], [1, 'kn-knopf', false, ''])
assert.deepEqual([fx.text(k), k.children.length], ['<b>x</b>y', 0])
assert.equal(leeren(k).childNodes.length, 0); assert.equal(text(k, null).textContent, '')
} finally { fx.restore() }
})
test('api: JSON, ApiFehler, Netzfehler und roh (Review Focus 5)', async () => {
const fx = installFakeDom({ antworten: [{ body: { ok: 1 } }, { status: 409, body: { error: 'Schon da', code: 'bereits_eingeschaltet', field: 'x', extra: 1 } }, { status: 502, body: {} }] })
try {
assert.deepEqual(await api('/api/x', { method: 'POST', body: { a: 1 } }), { ok: 1 })
assert.deepEqual(fx.fetchLog[0], { method: 'POST', url: '/api/x', body: { a: 1 } })
const e = await api('/api/x').catch((x) => x)
assert.ok(e instanceof ApiFehler); assert.deepEqual([e.status, e.code, e.message, e.field, e.body.extra], [409, 'bereits_eingeschaltet', 'Schon da', 'x', 1])
assert.equal((await api('/api/x').catch((x) => x)).message, 'Server nicht erreichbar. Bitte kurz warten und noch einmal versuchen.')
globalThis.fetch = async () => { throw new TypeError('Failed to fetch') }
const n = await api('/api/x').catch((x) => x); assert.deepEqual([n.status, n.message], [0, 'Keine Verbindung. Bitte versuch es gleich noch einmal.'])
let init; globalThis.fetch = async (_u, i) => { init = i; return { ok: true, status: 200, json: async () => ({}) } }
const blob = new Blob(['abc']); await api('/api/up', { method: 'POST', roh: blob })
assert.deepEqual([init.headers['Content-Type'], init.body], ['application/octet-stream', blob])
} finally { fx.restore() }
})
test('api: session_revoked leitet genau einmal zur Anmeldung (letzter Test der Datei)', async () => {
const weg = { status: 401, body: { error: 'Sitzung beendet', code: 'session_revoked' } }
const fx = installFakeDom({ antworten: [weg, weg] })
try {
await api('/api/x').catch(() => {}); assert.equal(fx.window.location.href, '/login.html?error=Sitzung%20beendet')
fx.window.location.href = 'anderswo'; await api('/api/x').catch(() => {}); assert.equal(fx.window.location.href, 'anderswo')
} finally { fx.restore() }
})
```
`test/knHuelleQuelltext.test.js`:
```js
import { readFileSync, existsSync } from 'node:fs'
const P1 = ['dom', 'api', 'routen', 'router', 'reiter', 'etage', 'zaehler', 'fristen-streifen', 'konto', 'leiste', 'huelle'].map((n) => `${n}.js`)
const pfad = (f) => new URL(`../public/neu/${f}`, import.meta.url)
const ohneKommentare = (t) => t.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '')
test('P1-Module: kein innerHTML, nur relative Importe, keine Verweise auf entfernte Seiten', () => {
for (const f of P1.filter((d) => existsSync(pfad(d)))) {
const q = ohneKommentare(readFileSync(pfad(f), 'utf8'))
assert.doesNotMatch(q, /innerHTML|insertAdjacentHTML|outerHTML|document\.write|\beval\(/, f)
assert.doesNotMatch(q, /admin\.html|kalkulation\.html|\/api\/portal-admin/, f)
for (const m of q.matchAll(/from '([^']+)'/g)) assert.match(m[1], /^\.\//, `${f}: nur relative Importe`)
}
})
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/knHuelleDomApi.test.js test/knHuelleQuelltext.test.js`
Expected: `knHuelleDomApi` FAIL (Modul nicht gefunden); `knHuelleQuelltext` PASS (noch keine P1-Datei da; wird mit jedem Task schärfer).
- [ ] **Step 3: Implement `dom.js` and `api.js`**
`h` wie `termine-dom.js:7` (gleiche Regeln, plus `attrs.on`). `api`: `fetch(pfad, { method, credentials: 'same-origin', headers, body })`; Fehlertext `data.error`, bei 502 der Text aus dem Test, sonst `Fehler ${status}`; fetch wirft → `status 0`; `session_revoked` setzt einmal `window.location.href = '/login.html?error=' + encodeURIComponent(message)` (Merker im Modul).
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/knHuelleDomApi.test.js test/knHuelleQuelltext.test.js`
Expected: PASS (4 Tests).
- [ ] **Step 5: Commit**
```bash
git add public/neu/dom.js public/neu/api.js test/knHuelleDomApi.test.js test/knHuelleQuelltext.test.js
git commit -m "Kundenansicht neu P1: h(), api() mit ApiFehler und Quelltext-Regel" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 3: routen.js (Routentabelle, alte Links)
**Files:**
- Create: `public/neu/routen.js`
- Test: `test/knHuelleRouten.test.js`
**Interfaces:**
- Consumes: nichts.
- Produces (V2 in der Fassung von Vertragsfrage 4: `vertraege` ohne Reiter, `abo` und `wartung` mit Reiter `abo`): `BEREICHE`, `ROUTEN`, `routeAusHash(hash) -> Route`, `hashFuerRoute(route) -> string`. `Route = { bereich, reiter?, etage?, produkt?, scroll?, query }`; `query` ist immer ein String ohne `?`, nicht gesetzte Felder fehlen. `produkt` (`'hosting'|'wartung'`) und `scroll` (`'aktivitaet'`) sind die Zusatzfelder zu V2 „plus Produkt“ und „scrollt zur Aktivität“ (Vertragsfrage 4).
- Zusätzlich: `hashFuerAnsicht(name) -> '#…' | null` (alter `data-view`-Name zum Hash, die Brücke für die gemeinsamen Module); `startHash({ search, hash }) -> string`; `ankerKette(route) -> string[]` (Hashes von der Website bis zum Ziel); `elternVon(route, herkunft = '') -> Route | null`; `titelFuer(route) -> string`; `FUNKTIONS_ETAGEN = ['news','termine','webshop','buchungen','zeiten','analyse']`.
- [ ] **Step 1: Write the failing test**
```js
import { BEREICHE, ROUTEN, routeAusHash, hashFuerRoute, hashFuerAnsicht, startHash, ankerKette, elternVon, titelFuer } from '../public/neu/routen.js'
const FAELLE = [ // Alte Links (Review Focus 1)
['', { bereich: 'website', query: '' }],
['#termine?bestellt=1&projekt=x', { bereich: 'funktionen', etage: 'termine', query: 'bestellt=1&projekt=x' }],
['#webshop?stripe=zurueck&projekt=x', { bereich: 'funktionen', etage: 'webshop', query: 'stripe=zurueck&projekt=x' }],
['#buchungen?stripe=neu&projekt=x', { bereich: 'funktionen', etage: 'buchungen', query: 'stripe=neu&projekt=x' }],
['#news?bestellt=1', { bereich: 'funktionen', etage: 'news', query: 'bestellt=1' }],
['#funktionen', { bereich: 'funktionen', reiter: 'meine', query: '' }], ['#entdecken', { bereich: 'funktionen', reiter: 'entdecken', query: '' }],
['#rechnungen', { bereich: 'vertraege', reiter: 'rechnungen', query: '' }], ['#stunden', { bereich: 'vertraege', reiter: 'stunden', query: '' }],
['#speicher', { bereich: 'vertraege', reiter: 'email', query: '' }], ['#gutschrift', { bereich: 'vertraege', etage: 'gutschrift', query: '' }],
['#wartung', { bereich: 'vertraege', reiter: 'abo', query: '' }], ['#abo', { bereich: 'vertraege', reiter: 'abo', query: '' }], ['#vertraege', { bereich: 'vertraege', query: '' }], // #vertraege ohne Reiter: die Ansicht wählt den Standardreiter
['#fortschritt', { bereich: 'website', scroll: 'aktivitaet', query: '' }], ['#projekt?id=p1', { bereich: 'website', etage: 'projekt', query: 'id=p1' }],
['#Chat', { bereich: 'chat', query: '' }], ['#passwort', { bereich: 'website', etage: 'passwort', query: '' }],
['#bestellen-wartung', { bereich: 'vertraege', etage: 'bestellen', produkt: 'wartung', query: '' }],
['#bestellen-hosting', { bereich: 'vertraege', etage: 'bestellen', produkt: 'hosting', query: '' }],
['#gibtesnicht', { bereich: 'website', query: '' }],
]
test('Alte Links: Ziel und Query stimmen (Review Focus 1), V2-Beispiel', () => {
for (const [hash, soll] of FAELLE) assert.deepEqual(routeAusHash(hash), soll, hash)
assert.deepEqual(routeAusHash('#termine?bestellt=1'), { bereich: 'funktionen', etage: 'termine', query: 'bestellt=1' })
})
test('hashFuerRoute ist die Umkehrung: Query bleibt, #wartung wird #abo, #vertraege und #abo fallen nicht zusammen; Hash-Namen nur [a-z]', () => {
const kanon = { '#wartung': '#abo', '#Chat': '#chat', '#gibtesnicht': '#', '': '#' }
for (const [hash] of FAELLE) assert.equal(hashFuerRoute(routeAusHash(hash)), kanon[hash] ?? hash, hash)
assert.deepEqual(['#vertraege', '#abo', '#wartung'].map((hash) => hashFuerRoute(routeAusHash(hash))), ['#vertraege', '#abo', '#abo']) // Review Focus 1: Reiterklick auf Abo darf nie #vertraege schreiben
assert.equal('reiter' in routeAusHash('#vertraege'), false); assert.equal(routeAusHash('#abo').reiter, 'abo')
assert.deepEqual(BEREICHE, ['website', 'funktionen', 'chat', 'vertraege'])
for (const k of Object.keys(ROUTEN).filter((k) => !k.startsWith('bestellen-'))) assert.match(k, /^[a-z]*$/, k) // Login-Whitelist app.js:466
})
test('data-view-Brücke: jeder Name aus VIEW_HASHES (app.js:1933 wörtlich) führt zum gleichen Hash', () => {
const ALT = { projects: '', funktionen: 'funktionen', news: 'news', analyse: 'analyse', termine: 'termine', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort', webshop: 'webshop', buchungen: 'buchungen', zeiten: 'zeiten' }
for (const [name, hash] of Object.entries(ALT)) assert.equal(hashFuerAnsicht(name), `#${hash}`, name)
assert.equal(hashFuerAnsicht('unbekannt'), null)
})
test('startHash: ?abo= führt zum Reiter Abo (die Query bleibt in search), #wartung wird #abo, #vertraege bleibt, sonstige Query bleibt', () => {
assert.equal(startHash({ search: '?abo=success', hash: '' }), '#abo')
assert.equal(startHash({ search: '?abo=cancel', hash: '#chat' }), '#abo')
assert.equal(startHash({ search: '', hash: '#wartung' }), '#abo')
assert.equal(startHash({ search: '', hash: '#vertraege' }), '#vertraege')
assert.equal(startHash({ search: '?x=1', hash: '#termine?bestellt=1&projekt=p1' }), '#termine?bestellt=1&projekt=p1')
assert.equal(startHash({ search: '', hash: '' }), '#')
})
test('ankerKette, elternVon, titelFuer', () => {
const r = routeAusHash
assert.deepEqual(ankerKette(r('#termine?bestellt=1')), ['#', '#funktionen', '#termine?bestellt=1'])
assert.deepEqual(ankerKette(r('#chat')), ['#', '#chat']); assert.deepEqual(ankerKette(r('#gutschrift')), ['#', '#vertraege', '#gutschrift'])
assert.deepEqual(ankerKette(r('#abo')), ['#', '#abo']); assert.deepEqual(ankerKette(r('#vertraege')), ['#', '#vertraege'])
assert.deepEqual(ankerKette(r('#passwort')), ['#', '#passwort']); assert.deepEqual(ankerKette(r('#fortschritt')), ['#fortschritt'])
assert.deepEqual(elternVon(r('#termine')), { bereich: 'funktionen', reiter: 'meine', query: '' })
assert.deepEqual(elternVon(r('#gutschrift')), { bereich: 'vertraege', query: '' }) // Bereichs-Anker #vertraege, kein Reiter
assert.deepEqual(elternVon(r('#passwort'), '#chat'), { bereich: 'chat', query: '' }); assert.equal(elternVon(r('#passwort')).bereich, 'website'); assert.equal(elternVon(r('#chat')), null)
assert.deepEqual(['#termine', '#zeiten', '#bestellen-wartung', '#passwort', '#chat'].map((h) => titelFuer(r(h))), ['Terminplaner', 'Öffnungszeiten', 'Abo bestellen', 'Passwort ändern', 'Chat'])
})
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleRouten.test.js`
Expected: FAIL (Modul nicht gefunden).
- [ ] **Step 3: Implement `routen.js`**
`ROUTEN` ist ein Objekt `name → Route-ohne-query` (V2 „Pflichtinhalt“); je Ziel steht die kanonische Schreibweise zuerst. `vertraege` ist ein eigenes Ziel ohne `reiter` (die Ansicht wählt den Standardreiter); `abo` und `wartung` sind dasselbe Ziel `{ bereich:'vertraege', reiter:'abo' }`, kanonisch `#abo` (`abo` steht vor `wartung`). So schreibt ein Klick auf den Reiter Abo `#abo` und nie `#vertraege`, und die Ansicht (P2 `waehleReiter`) kann die ausdrückliche Wahl vom Standardsprung unterscheiden. `routeAusHash` trennt am ersten `?`, klein geschrieben wird nur der Name, unbekannte Namen ergeben die Website. `hashFuerRoute` sucht den ersten Eintrag mit gleichen Feldern (`bereich`, `reiter`, `etage`, `produkt`, `scroll`), hängt `?query` an; die Website ohne Felder ergibt `'#'`. `ankerKette`: Website-Wurzel, bei Etage außerhalb der Website der Bereich (`funktionen` mit Reiter `meine`, `vertraege` ohne Reiter, Hash `#vertraege`), dann die Route selbst; eine Website-Route mit `scroll` steht allein. `elternVon` einer Etage ist derselbe Bereich, bei `passwort` die Route aus `herkunft` (sonst Website). `startHash`: ein Parameter `abo` in `search` ergibt `#abo` (der Reiter, auf dem P2 die Rückkehr von Stripe zeigt), Aliase werden kanonisch. Titel: Bereiche `Website|Funktionen|Chat|Verträge`; Etagen `Projekt|News|Terminplaner|Webshop|Buchungskalender|Öffnungszeiten|Analyse|Übernahmegutschrift|Abo wechseln|Abo bestellen|Passwort ändern`.
- [ ] **Step 4: Run test to verify it passes**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleRouten.test.js`
Expected: PASS (5 Tests).
- [ ] **Step 5: Commit**
```bash
git add public/neu/routen.js test/knHuelleRouten.test.js
git commit -m "Kundenansicht neu P1: Routentabelle mit allen alten Hashes" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 4: router.js (History-Regeln)
**Files:**
- Create: `public/neu/router.js`
- Test: `test/knHuelleRouter.test.js`
**Interfaces:**
- Consumes: `routeAusHash`, `hashFuerRoute`, `startHash`, `ankerKette`, `elternVon` aus `./routen.js`.
- Produces (V2):
- `starteRouter({ zeige, umgebung = fenster() }) -> { beende() }`; `zeige(route, { nav })`, `nav` ist `'tab'|'push'|'back'|'same'`; `umgebung = { location, history, addEventListener, removeEventListener }`, Vorgabe `window`.
- `geheZu(hash, { ersetzen = false } = {})`: schreibt den Verlauf und ruft danach selbst `zeige`.
- Zusätzlich `oeffneOverlay({ schliesse })`, `beendeOverlay()`, `zurueck()` (Zurück der Etage), `stempeln()`, `aktuelleRoute()`.
- Regeln (Stempel `history.state = { kn: { tiefe, overlay } }`; URL der Website ist `pathname + search` ohne `#`):
- R1 Bereichs- und Reiterwechsel: `replaceState`, `nav 'tab'`. Geschrieben wird immer `hashFuerRoute(route)` (kanonisch: `#wartung` wird `#abo`; `#abo` und `#vertraege` sind verschiedene Routen, ein Wechsel von `#vertraege` auf `#abo` schreibt `#abo` mit `nav 'tab'` und nie `#vertraege`). Gleiche Route: nichts schreiben, `zeige` mit `'same'`.
- R2 von Ebene 1 in eine Etage (und von Etage zu Etage): `pushState`, `nav 'push'`. `ersetzen: true` erzwingt `replaceState` (nav bleibt).
- R3 `popstate`: Ebene höher → `'push'`, sonst `'back'`.
- R4 Start: `startHash` anwenden, `zeige` einmal mit `'tab'`. `location.search` bleibt am Zieleintrag unberührt (P2 liest `?abo=` dort, Vertragsfrage 7), die Anker-Einträge davor tragen `search` ohne den Parameter `abo`. Fehlt der Stempel, legt der Router die `ankerKette` an (erster Eintrag `replaceState`, die übrigen `pushState`); mit Stempel passiert nichts.
- R5 `zurueck()`: `tiefe > 0` → `history.back()`, sonst `geheZu(Eltern, { ersetzen: true })` mit `'back'`.
- R6 Overlay: `oeffneOverlay` schiebt einen Eintrag mit `overlay: true`; `popstate` darauf ruft `schliesse()` und kein `zeige`. `geheZu` bei offenem Overlay ersetzt den Overlay-Eintrag und ruft `schliesse()` genau einmal. Ein Overlay-Stempel beim Start wird gelöscht.
- R7 `stempeln()` schreibt den Stempel nach einem `replaceState(null, …)` eines Moduls neu.
- [ ] **Step 1: Write the failing test**
Kopf: `starteRouter, geheZu, oeffneOverlay, beendeOverlay, zurueck, stempeln` aus `../public/neu/router.js`, `hashFuerRoute` aus `../public/neu/routen.js`. Hilfe in der Datei: `baueVerlauf(url, state = null)` liefert `{ umgebung, urls(), aktuell(), zurueck() }` (ca. 25 Zeilen: Einträge `{ url, state }` mit Index; `pushState` schneidet Vorwärtseinträge ab; `zurueck()` setzt den Index und ruft die `popstate`-Handler synchron; `location.pathname|search|hash` und `history.state` kommen aus dem aktuellen Eintrag). `start(url, state)` ruft `starteRouter({ zeige, umgebung })` und sammelt `log = [[hashFuerRoute(route), nav], …]`.
```js
test('Start auf Chat legt den Anker an; Zurück führt zur Website (Review Focus 2)', () => {
const { v, log } = start('/dashboard.html#chat')
assert.deepEqual(v.urls(), ['/dashboard.html', '/dashboard.html#chat']); assert.deepEqual(log, [['#chat', 'tab']])
v.zurueck(); assert.deepEqual(log.at(-1), ['#', 'back'])
})
test('Start in Etage 2: Kette Website, Funktionen, Terminplaner; Query bleibt (Review Focus 1)', () => {
assert.deepEqual(start('/dashboard.html#termine?bestellt=1&projekt=x').v.urls(), ['/dashboard.html', '/dashboard.html#funktionen', '/dashboard.html#termine?bestellt=1&projekt=x'])
})
test('Alias wird kanonisch (#wartung wird #abo, #abo bleibt #abo), ?abo= führt zum Reiter Abo und bleibt am Zieleintrag; Neuladen mit Stempel baut keine zweite Kette', () => {
assert.equal(start('/dashboard.html#wartung').v.aktuell(), '/dashboard.html#abo'); assert.equal(start('/dashboard.html#abo').v.aktuell(), '/dashboard.html#abo')
const a = start('/dashboard.html?abo=success#chat')
assert.deepEqual([a.v.urls(), a.log[0]], [['/dashboard.html', '/dashboard.html?abo=success#abo'], ['#abo', 'tab']])
assert.equal(start('/dashboard.html#termine', { kn: { tiefe: 2, overlay: false } }).v.urls().length, 1)
})
test('Reiter und Bereiche ersetzen, #abo wird nie #vertraege, Etage 2 schiebt, same, ersetzen erzwingt replaceState', () => {
const { v, log } = start('/dashboard.html#vertraege'); const n = v.urls().length
geheZu('#rechnungen'); geheZu('#chat'); assert.equal(v.urls().length, n); assert.deepEqual(log.slice(1), [['#rechnungen', 'tab'], ['#chat', 'tab']])
geheZu('#chat'); assert.equal(log.at(-1)[1], 'same')
geheZu('#vertraege'); geheZu('#abo'); assert.deepEqual([v.aktuell(), log.at(-1), v.urls().length], ['/dashboard.html#abo', ['#abo', 'tab'], n]) // Reiterklick Abo: ausdrücklicher Hash (Review Focus 1)
geheZu('#funktionen'); geheZu('#termine'); assert.equal(v.urls().length, n + 1); assert.deepEqual(log.at(-1), ['#termine', 'push'])
zurueck(); assert.deepEqual(log.at(-1), ['#funktionen', 'back']); assert.equal(v.urls().length, n + 1)
geheZu('#termine', { ersetzen: true }); assert.equal(v.urls().length, n + 1)
})
test('Stempel: Modul löscht ihn mit replaceState(null), stempeln() stellt ihn her, Zurück bleibt history.back', () => {
const { v } = start('/dashboard.html#funktionen'); geheZu('#termine')
v.umgebung.history.replaceState(null, '', '/dashboard.html#termine') // wie termine.js:40
assert.equal(v.umgebung.history.state, null); stempeln(); assert.ok(v.umgebung.history.state.kn.tiefe >= 1)
const n = v.urls().length; zurueck(); assert.deepEqual([v.urls().length, v.aktuell()], [n, '/dashboard.html#funktionen'])
})
test('Zurück ohne eigenen Eintrag ersetzt durch das Elternziel', () => {
const { v, log } = start('/dashboard.html#termine', { kn: { tiefe: 0, overlay: false } }); const n = v.urls().length
zurueck(); assert.deepEqual([v.aktuell(), v.urls().length, log.at(-1)], ['/dashboard.html#funktionen', n, ['#funktionen', 'back']])
})
test('Overlay: Zurück schließt zuerst das Sheet; Navigation ersetzt den Overlay-Eintrag; Neuladen mit Overlay-Stempel', () => {
const { v, log } = start('/dashboard.html#chat'); let zu = 0; const sch = { schliesse: () => { zu += 1 } }
oeffneOverlay(sch); assert.equal(v.urls().length, 3)
v.zurueck(); assert.deepEqual([zu, log.length], [1, 1]) // zeige nicht erneut gerufen
oeffneOverlay(sch); geheZu('#passwort')
assert.deepEqual([zu, v.aktuell(), v.urls().length, log.at(-1)[1]], [2, '/dashboard.html#passwort', 3, 'push'])
const b = start('/dashboard.html#chat', { kn: { tiefe: 1, overlay: true } }); assert.equal(b.v.umgebung.history.state.kn.overlay, false)
oeffneOverlay(sch); beendeOverlay(); assert.equal(zu, 3)
})
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleRouter.test.js`
Expected: FAIL (Modul nicht gefunden).
- [ ] **Step 3: Implement `router.js`**
Moduleigener Zustand (`aktuell`, `zeigeFn`, `overlayFn`, `tiefe`) wird von `starteRouter` zurückgesetzt; `beende()` entfernt den `popstate`-Hörer. Route aus `location.hash`, Tiefe aus dem Stempel (fehlt er: unverändert). Kein Zugriff auf `window` beim Import.
- [ ] **Step 4: Run test to verify it passes**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleRouter.test.js`
Expected: PASS (7 Tests).
- [ ] **Step 5: Commit**
```bash
git add public/neu/router.js test/knHuelleRouter.test.js
git commit -m "Kundenansicht neu P1: Router mit Anker-Kette, Overlay und Verlaufsstempel" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 5: reiter.js (gemeinsame Reiter, Zahlenfunktionen)
**Files:**
- Create: `public/neu/reiter.js`
- Test: `test/knHuelleReiter.test.js`
**Interfaces:**
- Consumes: `h` aus `./dom.js`.
- Produces:
- `baueReiter({ eintraege, aktiv, art = 'unterstrich', ariaLabel = 'Reiter', bei, warte = (fn, ms) => setTimeout(fn, ms) }) -> HTMLElement` (Form wie P3 VF1). Eintrag `{ key, label, kurz?, einheit?, ausrufText? }`. Der Knoten trägt die Methoden `setzeAktiv(key)`, `setzeAnzeige(key, { zahl = 0, ausruf = false })`, `messen()`. `art 'balken'` baut den Gleiter (schwarzer Balken) mit Overlay-Kopie nach `final-2-frei.html` (`.kn-balken > .kn-overlay`, Overlay `aria-hidden`, ohne Rollen), `art 'unterstrich'` die Reiter von `basis.css`.
- Rein: `balkenMasse({ basis: [{ links, breite }], aktiv, gesamtBreite }) -> { links, breite, overlayVersatz, overlayBreite }`; `overlayPasst(basisBreiten, overlayBreiten, toleranz = 0.5) -> boolean`; `schriftStufe({ inhaltsBreite, verfuegbar }) -> 15 | 14`; `sichtbarMachen({ scrollLinks, sichtbarBreite, itemLinks, itemBreite, rand = 16 }) -> number`; `anzeigeText({ zahl, ausruf }) -> string`.
- [ ] **Step 1: Write the failing test**
```js
import { balkenMasse, overlayPasst, schriftStufe, sichtbarMachen, anzeigeText, baueReiter } from '../public/neu/reiter.js'
test('Zahlenfunktionen: Balken, Overlay-Rückfall, Schriftstufe, Sichtbarmachen (C2)', () => {
const basis = [{ links: 0, breite: 90 }, { links: 90, breite: 100 }, { links: 190, breite: 60 }, { links: 250, breite: 90 }]
assert.deepEqual(balkenMasse({ basis, aktiv: 1, gesamtBreite: 340 }), { links: 90, breite: 100, overlayVersatz: -90, overlayBreite: 340 })
assert.deepEqual([overlayPasst([90, 100], [90, 100.4]), overlayPasst([90, 100], [90, 101])], [true, false])
assert.deepEqual([schriftStufe({ inhaltsBreite: 342, verfuegbar: 342 }), schriftStufe({ inhaltsBreite: 343, verfuegbar: 342 })], [15, 14])
const s = (scrollLinks, itemLinks) => sichtbarMachen({ scrollLinks, sichtbarBreite: 300, itemLinks, itemBreite: 80 })
assert.deepEqual([s(0, 420), s(200, 120), s(100, 150), s(50, 5)], [216, 104, 100, 0])
})
test('anzeigeText: Zahl, "!" und Deckel 99+ (Review Focus 3)', () => {
assert.deepEqual([0, 7, 99, 100, 1500].map((zahl) => anzeigeText({ zahl })), ['', '7', '99', '99+', '99+'])
assert.deepEqual([anzeigeText({ zahl: 3, ausruf: true }), anzeigeText({ zahl: 0, ausruf: true })], ['!', '!'])
})
test('baueReiter: Rollen, Auswahl, Kurzlabel, Pfeiltasten, Anzeige mit Beschriftung', async () => {
const fx = installFakeDom()
try {
const gew = []
const eintraege = [{ key: 'termine', label: 'Termine' }, { key: 'team', label: 'Team & Plätze', kurz: 'Team' }, { key: 'chat', label: 'Chat', einheit: 'ungelesen' }]
const r = baueReiter({ eintraege, aktiv: 'termine', art: 'balken', ariaLabel: 'Bereiche', bei: (k) => gew.push(k), warte: (fn) => fn() })
const tabs = fx.alle(r, '[role=tab]')
assert.deepEqual(tabs.map((t) => t.textContent), ['Termine', 'Team', 'Chat']); assert.equal(tabs[1].getAttribute('aria-label'), 'Team & Plätze')
assert.deepEqual(tabs.map((t) => t.getAttribute('aria-selected')), ['true', 'false', 'false']); assert.equal(tabs[0].getAttribute('aria-current'), 'page')
assert.equal(fx.alle(r, '.kn-overlay [role=tab]').length, 0)
await fx.klick(tabs[1]); await fx.sende(tabs[1], 'keydown', { key: 'ArrowRight' }); assert.deepEqual(gew, ['team', 'chat'])
r.setzeAktiv('chat'); assert.equal(tabs[2].getAttribute('aria-selected'), 'true')
r.setzeAnzeige('chat', { zahl: 2 })
assert.deepEqual([fx.finde(tabs[2], '.kn-zahl').textContent, tabs[2].getAttribute('aria-label')], ['2', 'Chat, 2 ungelesen'])
r.setzeAnzeige('chat', { zahl: 0 }); assert.deepEqual([fx.finde(tabs[2], '.kn-zahl'), tabs[2].hasAttribute('aria-label')], [null, false])
} finally { fx.restore() }
})
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleReiter.test.js`
Expected: FAIL (Modul nicht gefunden).
- [ ] **Step 3: Implement `reiter.js`**
Reiter sind `button[role=tab]` in `div[role=tablist][aria-label]`; `aria-selected` an allen, `aria-current="page"` nur am aktiven; Pfeiltasten wählen den Nachbarn (rufen `bei`). Anzeige als `span.kn-zahl` im Tab; bei `zahl 0` ohne `ausruf` bekommt sie `kn-zahl--weg` und wird nach 260 ms über `warte` entfernt. Beschriftung `${label}, ${zahl} ${einheit}`, bei `ausruf` `${label}, ${ausrufText}`, ohne Anzeige kein `aria-label` (außer bei `kurz`: dann `label`). Die Anzeige wird im Overlay gespiegelt. `messen()` (nur mit `getBoundingClientRect`) setzt Gleiter und Overlay nach `balkenMasse`, die Klasse `kn-schrift-klein` nach `schriftStufe`, den Rückfall `kn-fb` bei `!overlayPasst` und rückt den aktiven Reiter per `sichtbarMachen` im Scroll-Container ins Bild; ein `ResizeObserver` (falls vorhanden) ruft `messen()`.
- [ ] **Step 4: Run test to verify it passes**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleReiter.test.js`
Expected: PASS (3 Tests).
- [ ] **Step 5: Commit**
```bash
git add public/neu/reiter.js test/knHuelleReiter.test.js
git commit -m "Kundenansicht neu P1: gemeinsame Reiter mit Balken, Overlay-Rückfall und Zählern" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 6: etage.js (Etage 2) und Blatt-CSS
**Files:**
- Create: `public/neu/etage.js`, `public/neu/huelle.css`
- Test: `test/knHuelleEtage.test.js`
**Interfaces:**
- Consumes: `h` aus `./dom.js`; `baueReiter`, `sichtbarMachen` aus `./reiter.js`.
- Produces:
- `baueEtage({ blatt, haupt, zeile, titel, brot, reiterSlot, istHandy, onZurueck }) -> Etage` (alles Elemente aus `dashboard.html`, `istHandy: () => boolean`).
- `Etage = { oeffne({ titel, eltern: { label, hash }, seite }), schliesse(), setzeReiter({ eintraege, aktiv, bei }), neuZeichnen(), setzeChatPunkt(an), istOffen() }`.
- **Ereignisse** (Vertragsfrage 5): `blatt` hört auf `kn:reiter` (`detail: { eintraege: [{ key, label, kurz? }], aktiv, bei }`, leere Liste = keine Reiter) und `kn:titel` (`detail: { text }`); beide steigen aus jedem Nachfahren auf.
- [ ] **Step 1: Write the failing test**
```js
import { baueEtage } from '../public/neu/etage.js'
import { lies, handyTeil } from './support/knHuelle.js'
const teile = () => ({ blatt: h('div', { hidden: true }, h('section', { 'data-etage': 'termine' })), haupt: h('main'), zeile: h('div'), titel: h('h1'), brot: h('nav'), reiterSlot: h('div') })
const REITER = ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen', 'Kalender', 'Rechnungen'].map((label, i) => ({ key: `r${i}`, label, kurz: label.split(' ')[0] }))
const EL = { label: 'Funktionen', hash: '#funktionen' }
test('oeffne und schliesse: Titel, Zurück mit Ziel, Brotkrume, Hauptseite gesperrt', async () => {
const fx = installFakeDom()
try {
const t = teile(); let zu = 0; const e = baueEtage({ ...t, istHandy: () => true, onZurueck: () => { zu += 1 } })
e.oeffne({ titel: 'Terminplaner', eltern: EL, seite: 'termine' })
assert.deepEqual([t.blatt.hidden, t.haupt.getAttribute('inert'), e.istOffen(), t.titel.textContent], [false, '', true, 'Terminplaner'])
assert.deepEqual([fx.text(t.brot), fx.finde(t.brot, 'a').getAttribute('href')], ['Funktionen / Terminplaner', '#funktionen'])
const z = fx.finde(t.zeile, 'button'); assert.deepEqual([z.textContent, z.getAttribute('aria-label')], ['Zurück', 'Zurück zu Funktionen'])
await fx.klick(z); assert.equal(zu, 1)
e.schliesse(); assert.deepEqual([t.blatt.hidden, t.haupt.hasAttribute('inert'), fx.alle(t.zeile, 'button').length], [true, false, 0])
} finally { fx.restore() }
})
test('kn:reiter vom Inhalt: Handy Kurzlabels in der Zeile, Desktop Unterstrich im Slot, ohne Reiter füllt Zurück die Zeile (C2)', async () => {
const fx = installFakeDom()
try {
const gew = []; let handy = true; const t = teile(); const e = baueEtage({ ...t, istHandy: () => handy, onZurueck() {} })
e.oeffne({ titel: 'Terminplaner', eltern: EL, seite: 'termine' }); const kind = t.blatt.children[0]
await fx.sende(kind, 'kn:reiter', { detail: { eintraege: REITER, aktiv: 'r0', bei: (k) => gew.push(k) } })
const tabs = fx.alle(t.zeile, '[role=tab]')
assert.deepEqual(tabs.map((x) => x.textContent), ['Termine', 'Leistungen', 'Team', 'Einstellungen', 'Kalender', 'Rechnungen']); assert.equal(tabs[2].getAttribute('aria-label'), 'Team & Plätze')
await fx.klick(tabs[2]); assert.deepEqual(gew, ['r2'])
await fx.sende(kind, 'kn:reiter', { detail: { eintraege: [], aktiv: '', bei() {} } })
assert.deepEqual([fx.alle(t.zeile, '[role=tab]').length, Boolean(fx.finde(t.zeile, '.kn-zurueck--voll'))], [0, true])
handy = false; e.neuZeichnen(); await fx.sende(kind, 'kn:reiter', { detail: { eintraege: REITER, aktiv: 'r0', bei() {} } })
assert.deepEqual([fx.alle(t.reiterSlot, '[role=tab]').length, fx.alle(t.zeile, '[role=tab]').length], [6, 0])
await fx.sende(kind, 'kn:titel', { detail: { text: 'Kalender' } }); assert.equal(t.titel.textContent, 'Kalender')
} finally { fx.restore() }
})
test('Chat-Punkt am Zurück', () => {
const fx = installFakeDom()
try {
const t = teile(); const e = baueEtage({ ...t, istHandy: () => true, onZurueck() {} })
e.oeffne({ titel: 'Webshop', eltern: EL, seite: 'webshop' }); e.setzeChatPunkt(true)
assert.ok(fx.finde(t.zeile, '.kn-punkt')); assert.equal(fx.finde(t.zeile, 'button').getAttribute('aria-label'), 'Zurück zu Funktionen, neue Nachricht im Chat')
} finally { fx.restore() }
})
test('huelle.css (Blatt): Handy fährt von unten ein, Zurück füllt die Zeile', () => {
const css = lies('huelle.css')
assert.match(handyTeil(css), /\.kn-blatt\s*\{(?=[^}]*border-radius:\s*12px 12px 0 0)(?=[^}]*transition:[^}]*550ms[^}]*var\(--kn-bremse\))/s)
assert.match(css, /\.kn-zurueck--voll\s*\{[^}]*flex:\s*1/s)
})
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleEtage.test.js`
Expected: FAIL (Modul nicht gefunden).
- [ ] **Step 3: Implement `etage.js` und den Abschnitt „Blatt“ in `huelle.css`**
`setzeChatPunkt` merkt den Zustand auch für spätere `oeffne`-Aufrufe. `oeffne` zeigt `blatt`, sperrt `haupt` per `inert`, setzt Titel, baut Brotkrume (`<a>`, ` / `, Titel; nur am Desktop sichtbar) und am Handy die Zeile (`button.kn-zurueck` mit CSS-Spitze statt Icon, davor kein Zeichen im Text), zeigt nur `section[data-etage=seite]`. Reiter: am Handy in der Zeile hinter einem senkrechten Strich `.kn-vl` als `art 'balken'`, am Desktop in `reiterSlot` als `art 'unterstrich'`; Beschriftung `kurz ?? label`; nach `bei` rückt `sichtbarMachen` den aktiven Reiter ins Bild (nur mit `getBoundingClientRect`). CSS: Desktop-Grundregel: `.kn-blatt` füllt die Spalte ohne Schatten. Unter `HANDY`: `position:fixed; inset:0 0 var(--kn-zeile-h)`, `border-radius:12px 12px 0 0`, Schatten `0 0 0 1px rgba(26,31,54,.06), 0 -8px 24px rgba(26,31,54,.1)`, `translateY(100%) → 0` in `550ms var(--kn-bremse)`.
- [ ] **Step 4: Run test to verify it passes**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleEtage.test.js`
Expected: PASS (4 Tests).
- [ ] **Step 5: Commit**
```bash
git add public/neu/etage.js public/neu/huelle.css test/knHuelleEtage.test.js
git commit -m "Kundenansicht neu P1: Etage 2 mit Zurück, Reiterzeile und Brotkrume" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 7: zaehler.js (V3)
**Files:**
- Create: `public/neu/zaehler.js`
- Test: `test/knHuelleZaehler.test.js`
**Interfaces:**
- Consumes: nichts (`api` wird übergeben).
- Produces:
- `berechneVertraege({ rechnungen = [], rueckfragen = [], paymentProblem = false }) -> { zahl, ausruf }`: `zahl` = Rechnungen mit `Number(balance) > 0` plus Rückfragen mit `status === 'rueckfrage'`; `ausruf = paymentProblem`.
- `erzeugeZaehler({ api, onChange, sichtbar = () => true, imChat = () => false, zeitgeber = { setze: setInterval, loesche: clearInterval } }) -> { async start(), stop(), async aktualisiere(was = 'alle'), setzeChat(zahl), stand() }`, `was` ist `'chat'|'vertraege'|'alle'`. `stand() = { chat, vertraege: { zahl, ausruf } }`; `onChange(stand)` bei jeder Änderung. `const CHAT_POLL_MS = 45000`. Die „Rechnung offen“-Zeile aus Spec 5.3 zeichnet dieses Paket nicht (Vertragsfrage 6).
- Quellen (geprüft): `GET /api/chat/unread-count` → `{ unread }` (`chat.js:237`); `GET /api/invoices` → `{ invoices }` (`customerData.js:18`); `GET /api/abo/takeover-credits` → `{ requests:[{ status }] }`; `GET /api/abo` → `{ subscription: { paymentProblem, switchPending } | null }` (`abo.js:115,144`).
- [ ] **Step 1: Write the failing test**
```js
import { berechneVertraege, erzeugeZaehler, CHAT_POLL_MS } from '../public/neu/zaehler.js'
test('Verträge: Rechnungen plus Rückfragen; "!" nur bei paymentProblem, nie bei switchPending (V3)', async () => {
assert.deepEqual(berechneVertraege({ rechnungen: [{ balance: 50 }, { balance: 0 }, { balance: '12.50' }], rueckfragen: [{ status: 'rueckfrage' }, { status: 'offen' }, { status: 'rueckfrage' }] }), { zahl: 4, ausruf: false })
assert.deepEqual(berechneVertraege({ paymentProblem: true }), { zahl: 0, ausruf: true })
const karte = { '/api/invoices': { invoices: [{ id: 'i1', number: 'RE-1', balance: 50 }] }, '/api/abo/takeover-credits': { requests: [{ status: 'rueckfrage' }] }, '/api/abo': { subscription: { paymentProblem: false, switchPending: { since: 'x', paymentUrl: null } } } }
const z = erzeugeZaehler({ api: async (p) => karte[p], onChange() {} }); await z.aktualisiere('vertraege')
assert.deepEqual(z.stand().vertraege, { zahl: 2, ausruf: false })
})
test('Fehler einer Quelle behalten ihren letzten Wert (Review Focus 5)', async () => {
let kaputt = false
const api = async (p) => {
if (p === '/api/invoices') { if (kaputt) throw Object.assign(new Error('x'), { status: 500 }); return { invoices: [{ id: 'i1', number: 'RE-1', balance: 50 }] } }
return p === '/api/abo' ? { subscription: null } : { requests: [] }
}
const z = erzeugeZaehler({ api, onChange() {} }); await z.aktualisiere('vertraege'); kaputt = true; await z.aktualisiere('vertraege')
assert.deepEqual(z.stand().vertraege, { zahl: 1, ausruf: false })
})
test('Chat: Poll alle 45 s, nicht im Chat und nicht bei verstecktem Tab; setzeChat(0) sofort', async () => {
const timer = []; const staende = []; let sicht = true; let chat = false; let aufrufe = 0
const z = erzeugeZaehler({
api: async (p) => { if (p === '/api/chat/unread-count') { aufrufe += 1; return { unread: 3 } } return p === '/api/abo' ? { subscription: null } : p === '/api/invoices' ? { invoices: [] } : { requests: [] } },
onChange: (s) => staende.push(s.chat), sichtbar: () => sicht, imChat: () => chat, zeitgeber: { setze: (fn, ms) => timer.push({ fn, ms }) - 1, loesche() {} } })
await z.start(); assert.deepEqual([timer.map((t) => t.ms), z.stand().chat], [[CHAT_POLL_MS], 3])
await timer[0].fn(); assert.equal(aufrufe, 2)
sicht = false; await timer[0].fn(); sicht = true; chat = true; await timer[0].fn(); assert.equal(aufrufe, 2)
z.setzeChat(0); assert.deepEqual([z.stand().chat, staende.at(-1)], [0, 0])
})
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleZaehler.test.js`
Expected: FAIL (Modul nicht gefunden).
- [ ] **Step 3: Implement `zaehler.js`**
Jede der vier Quellen wird einzeln abgefragt (`Promise.allSettled`), nur erfolgreiche Antworten ersetzen den gemerkten Wert. `start()` lädt alles einmal und startet den Chat-Poll mit `CHAT_POLL_MS`; der Poll überspringt bei `!sichtbar()` oder `imChat()`. `stop()` löscht den Zeitgeber. `onChange` nur bei tatsächlicher Änderung.
- [ ] **Step 4: Run test to verify it passes**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleZaehler.test.js`
Expected: PASS (3 Tests).
- [ ] **Step 5: Commit**
```bash
git add public/neu/zaehler.js test/knHuelleZaehler.test.js
git commit -m "Kundenansicht neu P1: Zähler für Chat und Verträge nach V3" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 8: fristen-streifen.js (Widerrufsstreifen)
**Files:**
- Create: `public/neu/fristen-streifen.js`
- Modify: `public/neu/huelle.css` (Abschnitt „Streifen“)
- Test: `test/knHuelleFristen.test.js`
**Interfaces:**
- Consumes: `h` aus `./dom.js`.
- Produces:
- `async ladeFristen(api) -> { fristen, fruehestesEnde, anzeigen } | null`: ruft `api('/api/fristen')`; Fehler jeder Art (404, 500, Status 0) und `anzeigen !== true` ergeben `null`.
- `streifenText(iso) -> 'Widerrufsfrist bis TT.MM.'` (`Europe/Berlin`); `fruehesteFrist(daten) -> iso | ''` (`fruehestesEnde`, sonst das kleinste `bis`).
- `baueStreifen(wurzel, { koerper = document.body } = {}) -> { setze(daten | null) }`: füllt `wurzel` (`hidden` bei `null`) und setzt `kn-hat-streifen` am `koerper`.
- [ ] **Step 1: Write the failing test**
```js
import { ladeFristen, streifenText, fruehesteFrist, baueStreifen } from '../public/neu/fristen-streifen.js'
import { lies, handyTeil } from './support/knHuelle.js'
const FRIST = { fristen: [{ quelle: 'abschluss', bezeichnung: 'Abo', bis: '2026-10-21T21:59:59.999Z' }], fruehestesEnde: '2026-10-21T21:59:59.999Z', anzeigen: true }
test('ladeFristen: 404, 500, Netzfehler und anzeigen:false ergeben null; sonst die Daten (Review Focus 5)', async () => {
for (const status of [404, 500, 0]) assert.equal(await ladeFristen(async () => { throw Object.assign(new Error('x'), { status }) }), null)
assert.equal(await ladeFristen(async () => ({ fristen: [], fruehestesEnde: null, anzeigen: false })), null)
assert.deepEqual(await ladeFristen(async (p) => { assert.equal(p, '/api/fristen'); return FRIST }), FRIST)
})
test('streifenText: Berliner Datum, nicht UTC; fruehesteFrist fällt auf das kleinste bis zurück', () => {
assert.equal(streifenText('2026-10-21T21:59:59.999Z'), 'Widerrufsfrist bis 21.10.'); assert.equal(streifenText('2026-10-21T22:30:00.000Z'), 'Widerrufsfrist bis 22.10.')
assert.equal(fruehesteFrist({ fruehestesEnde: null, fristen: [{ bis: '2026-11-03T10:00:00Z' }, { bis: '2026-10-30T10:00:00Z' }] }), '2026-10-30T10:00:00Z')
})
test('baueStreifen: Text mit Link, nicht wegtippbar, null versteckt', () => {
const fx = installFakeDom()
try {
const w = h('div', { hidden: true }); const s = baueStreifen(w); s.setze(FRIST)
assert.deepEqual([w.hidden, fx.text(w)], [false, 'Widerrufsfrist bis 21.10. · Widerrufen'])
assert.deepEqual([fx.finde(w, 'a').getAttribute('href'), fx.alle(w, 'button').length, fx.document.body.classList.contains('kn-hat-streifen')], ['/widerrufen.html', 0, true])
s.setze(null); assert.deepEqual([w.hidden, fx.document.body.classList.contains('kn-hat-streifen')], [true, false])
} finally { fx.restore() }
})
test('huelle.css (Streifen): 44 px, getönt, am Handy über der Leiste, bei Tastatur weg', () => {
const css = lies('huelle.css')
assert.match(css, /\.kn-streifen\s*\{(?=[^}]*min-height:\s*44px)(?=[^}]*background:\s*var\(--kn-orange-bg\))(?=[^}]*color:\s*var\(--kn-orange-fg\))/s)
assert.match(handyTeil(css), /\.kn-streifen\s*\{[^}]*position:\s*fixed/s)
assert.match(css, /\.kn-tastatur \.kn-streifen[^{]*\{[^}]*display:\s*none/s)
})
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleFristen.test.js`
Expected: FAIL (Modul nicht gefunden).
- [ ] **Step 3: Implement `fristen-streifen.js` und den Abschnitt „Streifen“ in `huelle.css`**
Datum über `toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit' })`. Der Streifen ist ein `div.kn-streifen[role=region][aria-label="Widerrufsfrist"]` mit dem Text, ` · ` und `a.kn-streifen-link[href="/widerrufen.html"]` („Widerrufen“). CSS: Desktop-Grundregel: Streifen oben in der Spalte, nicht fixiert, Fläche `--kn-orange-bg`, Schrift `--kn-orange-fg` 14 px/600. Unter `HANDY`: `position:fixed` direkt über der Leiste (`bottom: calc(var(--kn-zeile-h) + env(safe-area-inset-bottom))`); `.kn-hat-streifen .kn-haupt` bekommt 44 px zusätzlichen Fußabstand.
- [ ] **Step 4: Run test to verify it passes**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleFristen.test.js`
Expected: PASS (4 Tests).
- [ ] **Step 5: Commit**
```bash
git add public/neu/fristen-streifen.js public/neu/huelle.css test/knHuelleFristen.test.js
git commit -m "Kundenansicht neu P1: Widerrufsstreifen mit GET /api/fristen" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 9: konto.js (Avatar, Konto-Sheet, Popover)
**Files:**
- Create: `public/neu/konto.js`
- Modify: `public/neu/huelle.css` (Abschnitt „Konto“)
- Test: `test/knHuelleKonto.test.js`
**Interfaces:**
- Consumes: `h` aus `./dom.js`; `oeffneOverlay`, `beendeOverlay`, `geheZu` aus `./router.js`.
- Produces:
- `initialenVon(name) -> string`; `installAngebot({ handy, standalone, prompt, ios }) -> 'prompt' | 'ios' | null`.
- `kontoEintraege({ fristen, install }) -> [{ id, text, href?, neuerTab?, pille?, betont? }]` in der Reihenfolge von V3: `passwort`, `datenschutz`, `impressum`, `installieren` (nur mit `install`), `kuendigen`, `widerrufen`, `abmelden`.
- `baueAvatar({ name, kundenId }) -> HTMLButtonElement` (`aria-label="Mein Konto"`, Initialen, Bild von `/api/avatar.svg?c=<kundenId>` erst nach `load`).
- `baueKonto({ me, fristen, install, onAbmelden, onInstall, overlay = { oeffne: oeffneOverlay, beende: beendeOverlay }, navigiere = geheZu }) -> { wurzel, oeffne(ausloeser), schliesse(), istOffen(), aktualisiere({ fristen, install }) }`; `aktualisiere` baut die Zeilen neu und behält den offenen Zustand.
- [ ] **Step 1: Write the failing test**
```js
import { initialenVon, installAngebot, kontoEintraege, baueAvatar, baueKonto } from '../public/neu/konto.js'
import { lies, handyTeil } from './support/knHuelle.js'
test('Reihenfolge, Texte und Ziele des Kontos (V3); Widerrufen immer da, "Frist läuft" nur in der Frist', () => {
const ids = (o) => kontoEintraege(o).map((e) => e.id)
assert.deepEqual(ids({ fristen: null, install: null }), ['passwort', 'datenschutz', 'impressum', 'kuendigen', 'widerrufen', 'abmelden'])
assert.deepEqual(ids({ fristen: null, install: 'ios' }), ['passwort', 'datenschutz', 'impressum', 'installieren', 'kuendigen', 'widerrufen', 'abmelden'])
const e = Object.fromEntries(kontoEintraege({ fristen: null, install: 'prompt' }).map((x) => [x.id, x]))
assert.deepEqual([e.passwort.text, e.passwort.href, e.datenschutz.href, e.impressum.href, e.kuendigen.href, e.kuendigen.betont], ['Passwort ändern', '#passwort', '/datenschutz.html', 'https://webklar.com/impressum', '/kuendigen.html', true])
assert.deepEqual([e.widerrufen.text, e.widerrufen.href, e.widerrufen.pille, e.installieren.text, e.abmelden.text], ['Vertrag widerrufen', '/widerrufen.html', undefined, 'App installieren', 'Abmelden'])
assert.equal(kontoEintraege({ fristen: { anzeigen: true }, install: null }).find((x) => x.id === 'widerrufen').pille, 'Frist läuft')
})
test('installAngebot: nur am Handy, nicht in der installierten App (wie app.js:2267)', () => {
assert.deepEqual([{ handy: true, standalone: false, prompt: true, ios: false }, { handy: true, standalone: false, prompt: false, ios: true }, { handy: true, standalone: true, prompt: true, ios: true }, { handy: false, standalone: false, prompt: true, ios: true }].map((o) => installAngebot(o)), ['prompt', 'ios', null, null])
})
test('initialenVon und Avatar: Bild erst nach load', async () => {
assert.deepEqual(['Salon Amin', 'Amin', '', ' ', 'Äsop Öl', '– Test'].map(initialenVon), ['SA', 'AM', '', '', 'ÄÖ', 'TE'])
const fx = installFakeDom()
try {
const a = baueAvatar({ name: 'Salon Amin', kundenId: 'c1' }); assert.deepEqual([a.textContent, a.getAttribute('aria-label')], ['SA', 'Mein Konto'])
const img = fx.finde(a, 'img'); assert.equal(img.getAttribute('src'), '/api/avatar.svg?c=c1')
await fx.sende(img, 'load'); assert.ok(a.classList.contains('has-img'))
} finally { fx.restore() }
})
test('baueKonto: Kopf als Text, Sheet-Verhalten, Overlay-Kopplung, Navigation, Abmelden, aktualisiere', async () => {
const fx = installFakeDom()
try {
const log = []; const trigger = h('button'); fx.document.body.append(trigger)
const me = { customer: { id: 'c1', name: 'Anna', companyName: '<img src=x onerror=alert(1)>Salon', email: 'a@b.de' } }
const k = baueKonto({ me, fristen: null, install: null, onAbmelden: () => log.push('abmelden'), onInstall() {}, overlay: { oeffne: (o) => log.push(['overlay', typeof o.schliesse]), beende: () => log.push('beende') }, navigiere: (hash) => log.push(['geheZu', hash]) })
fx.document.body.append(k.wurzel); const zeilen = () => fx.alle(k.wurzel, '.kn-konto-zeile')
assert.deepEqual([fx.finde(k.wurzel, '.kn-konto-name').textContent, fx.finde(k.wurzel, '.kn-konto-mail').textContent], ['<img src=x onerror=alert(1)>Salon', 'a@b.de'])
assert.deepEqual(zeilen().map((z) => z.textContent), ['Passwort ändern', 'Datenschutz', 'Impressum', 'Verträge kündigen', 'Vertrag widerrufen', 'Abmelden'])
assert.deepEqual([k.wurzel.getAttribute('role'), k.wurzel.getAttribute('aria-modal')], ['dialog', 'true'])
k.oeffne(trigger); assert.deepEqual([k.istOffen(), trigger.getAttribute('aria-expanded'), log[0]], [true, 'true', ['overlay', 'function']])
await fx.sende(k.wurzel, 'keydown', { key: 'Escape' }); assert.deepEqual([k.istOffen(), log.at(-1), fx.document.activeElement === trigger], [false, 'beende', true])
k.oeffne(trigger); await fx.klick(zeilen()[0]); assert.deepEqual(log.at(-1), ['geheZu', '#passwort'])
await fx.klick(zeilen().at(-1)); assert.equal(log.at(-1), 'abmelden')
k.aktualisiere({ fristen: { anzeigen: true }, install: 'ios' }); assert.ok(k.istOffen()); assert.match(fx.text(k.wurzel), /App installieren[\s\S]*Vertrag widerrufen.*Frist läuft/)
await fx.klick(fx.finde(k.wurzel, '.kn-konto-scrim')); assert.equal(k.istOffen(), false)
} finally { fx.restore() }
})
test('huelle.css (Konto): Popover als Desktop-Grundregel, Sheet am Handy', () => {
const css = lies('huelle.css')
assert.match(css, /\.kn-konto\s*\{(?=[^}]*width:\s*280px)(?=[^}]*top:)/s)
assert.match(handyTeil(css), /\.kn-konto\s*\{[^}]*border-radius:\s*12px 12px 0 0/s)
})
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleKonto.test.js`
Expected: FAIL (Modul nicht gefunden).
- [ ] **Step 3: Implement `konto.js` und den Abschnitt „Konto“ in `huelle.css`**
`initialenVon` wie `app.js:2006`. Kopf: `.kn-konto-name` (`companyName || name || email`) und `.kn-konto-mail`. Zeilen sind `a.kn-konto-zeile` (Ziele wie im Test; Datenschutz und Impressum mit `target=_blank rel=noopener`), `installieren` und `abmelden` sind `button`; „Frist läuft“ ist ein `span.kn-pille.kn-pille-orange` in der Zeile. Klick auf eine interne Zeile: `preventDefault()` und `navigiere(hash)` (der Router schließt das Overlay selbst). `Abmelden` ruft nur `onAbmelden`, `installieren` nur `onInstall` (am iPhone zeigt die Zeile „Tippe auf Teilen und dann auf Zum Home-Bildschirm.“). Esc, Scrim (`.kn-konto-scrim` in `wurzel`) und „Schließen“ rufen `schliesse()`, das nur bei zuvor offenem und nicht vom Router geschlossenem Konto `overlay.beende()` ruft. Fokus beim Öffnen auf die erste Zeile, beim Schließen zurück auf `ausloeser`. CSS: Desktop-Grundregel Popover rechts oben unter dem Avatar, 280 px, `--kn-shadow-pop`; unter `HANDY` unten verankertes Sheet mit Scrim `--kn-overlay`, Radius `12px 12px 0 0`, Safe-Area unten.
- [ ] **Step 4: Run test to verify it passes**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleKonto.test.js`
Expected: PASS (5 Tests).
- [ ] **Step 5: Commit**
```bash
git add public/neu/konto.js public/neu/huelle.css test/knHuelleKonto.test.js
git commit -m "Kundenansicht neu P1: Konto mit Avatar, Sheet und Popover" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 10: leiste.js (Fahrstuhl-Zeile, Menü, Kopf, Tastatur)
**Files:**
- Create: `public/neu/leiste.js`
- Modify: `public/neu/huelle.css` (Abschnitte „Leiste“, „Menü“, „Kopf“, „Querformat“, „Bewegung reduziert“)
- Test: `test/knHuelleLeiste.test.js`
**Interfaces:**
- Consumes: `h` aus `./dom.js`; `baueReiter`, `anzeigeText` aus `./reiter.js`.
- Produces:
- `HANDY_ABFRAGE = '(max-width: 880px), (pointer: coarse) and (max-height: 500px)'`.
- `WOERTER = [{ key:'website', label:'Website' }, { key:'funktionen', label:'Funktionen' }, { key:'chat', label:'Chat', einheit:'ungelesen' }, { key:'vertraege', label:'Verträge', einheit:'offen', ausrufText:'Zahlung fehlgeschlagen' }]`.
- `baueNavigation({ haupt, menue, onWahl }) -> { setzeAktiv(bereich), setzeZaehler({ chat = 0, vertraege = { zahl: 0, ausruf: false } }), setzeEtage(offen) }`: `haupt` ist die Hauptzeile der Leiste (`#kn-zeile-haupt`), `menue` das Desktop-Menü (`#kn-menue`). `setzeEtage` setzt `data-etage="offen"` am Elternelement von `haupt` (dem Reel) und `inert` an `haupt`.
- `tastaturZustand(alt, ereignis) -> { voll, breite, offen, tippt }` mit `ereignis` `{ art:'vv', hoehe, breite, textfeldAktiv?, grob? }`, `{ art:'fokus', textfeld, grob }`, `{ art:'unfokus', textfeldAktiv }`; `leisteSichtbar(zustand) -> boolean`; `beobachteTastatur({ win, doc, onChange })` (Verdrahtung mit `visualViewport`, `focusin`, `focusout`; ersetzt `app.js:2475-2494` und `TEXT_INPUT`, `app.js:2137`).
- [ ] **Step 1: Write the failing test**
```js
import { HANDY_ABFRAGE, baueNavigation, tastaturZustand, leisteSichtbar } from '../public/neu/leiste.js'
import { lies, handyTeil, HANDY } from './support/knHuelle.js'
test('Leiste: vier Wörter, Auswahl, Zähler mit Beschriftung, "!" und 99+, Etage sperrt die Hauptzeile', async () => {
const fx = installFakeDom()
try {
const haupt = h('div'); const reel = h('div', null, haupt); const menue = h('nav'); const gew = []
const n = baueNavigation({ haupt, menue, onWahl: (k) => gew.push(k) }); const tabs = fx.alle(haupt, '[role=tab]')
assert.deepEqual(tabs.map((t) => t.textContent), ['Website', 'Funktionen', 'Chat', 'Verträge'])
n.setzeAktiv('chat'); assert.deepEqual(tabs.map((t) => t.getAttribute('aria-selected')), ['false', 'false', 'true', 'false']); assert.equal(fx.finde(menue, '[aria-current=page]').textContent, 'Chat')
await fx.klick(tabs[3]); assert.deepEqual(gew, ['vertraege'])
n.setzeZaehler({ chat: 2, vertraege: { zahl: 1, ausruf: false } })
assert.deepEqual([tabs[2].getAttribute('aria-label'), tabs[3].getAttribute('aria-label'), fx.finde(menue, '.kn-nav-zahl').textContent], ['Chat, 2 ungelesen', 'Verträge, 1 offen', '2'])
n.setzeZaehler({ chat: 150, vertraege: { zahl: 5, ausruf: true } })
assert.deepEqual([fx.finde(tabs[2], '.kn-zahl').textContent, fx.finde(tabs[3], '.kn-zahl').textContent, tabs[3].getAttribute('aria-label')], ['99+', '!', 'Verträge, Zahlung fehlgeschlagen'])
n.setzeZaehler({ chat: 0, vertraege: { zahl: 0, ausruf: false } }); assert.equal(fx.finde(menue, '.kn-nav-zahl'), null)
assert.deepEqual(fx.alle(menue, 'button').map((b) => b.textContent), ['Website', 'Funktionen', 'Chat', 'Verträge'])
n.setzeEtage(true); assert.deepEqual([reel.getAttribute('data-etage'), haupt.hasAttribute('inert')], ['offen', true])
n.setzeEtage(false); assert.equal(haupt.hasAttribute('inert'), false)
} finally { fx.restore() }
})
test('tastaturZustand: öffnen unter voll-150, Android-Geste ohne Blur, Drehen, nur Textfelder am Touch (Review Focus 4)', () => {
let z = { voll: 844, breite: 390, offen: false, tippt: false }
z = tastaturZustand(z, { art: 'vv', hoehe: 560, breite: 390, textfeldAktiv: true, grob: true }); assert.deepEqual([z.offen, z.tippt, leisteSichtbar(z)], [true, true, false])
z = tastaturZustand(z, { art: 'vv', hoehe: 700, breite: 390 }); assert.deepEqual([z.offen, z.tippt, leisteSichtbar(z)], [false, false, true]) // 700 >= 694, ohne Blur
z = tastaturZustand(z, { art: 'vv', hoehe: 390, breite: 844 }); assert.deepEqual([z.voll, z.breite, z.offen], [390, 844, false])
assert.deepEqual([true, false].map((grob) => tastaturZustand(z, { art: 'fokus', textfeld: true, grob }).tippt), [true, false])
assert.equal(tastaturZustand({ ...z, tippt: true }, { art: 'unfokus', textfeldAktiv: false }).tippt, false)
})
test('huelle.css: Desktop ist Grundregel, Handy überschreibt (Abfrage wörtlich, 44 px, Safe-Area, Reel, Gleiter, Menü, Bewegung)', () => {
assert.equal(`@media ${HANDY_ABFRAGE}`, HANDY)
const css = lies('huelle.css'); const handy = handyTeil(css)
assert.match(css, /\.kn-t\s*\{(?=[^}]*min-height:\s*44px)(?=[^}]*font-size:\s*15px)(?=[^}]*font-weight:\s*600)/s)
assert.match(css, /\.kn-schrift-klein \.kn-t\s*\{[^}]*font-size:\s*14px/s)
assert.match(css, /\.kn-menue\s*\{(?=[^}]*width:\s*var\(--kn-sidebar-w\))(?=[^}]*border-right:\s*1px solid var\(--kn-line\))/s)
assert.match(handy, /\.kn-menue\s*\{[^}]*display:\s*none/s); assert.match(handy, /\.kn-leiste\s*\{[^}]*padding-bottom:\s*env\(safe-area-inset-bottom\)/s)
assert.match(css, /\[data-etage="offen"\][^{]*\{[^}]*translateY\(-56px\)/s)
assert.match(css, /\.kn-gleiter\s*\{(?=[^}]*top:\s*4px)(?=[^}]*height:\s*36px)(?=[^}]*border-radius:\s*6px)(?=[^}]*background:\s*var\(--kn-accent\))/s)
assert.match(css, /\.kn-tastatur \.kn-leiste[^{]*\{[^}]*(translateY\(110%\)|display:\s*none)/s)
assert.match(css, /@media \(prefers-reduced-motion: reduce\)[\s\S]*150ms/)
})
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleLeiste.test.js`
Expected: FAIL (Modul nicht gefunden).
- [ ] **Step 3: Implement `leiste.js` und die Abschnitte in `huelle.css`**
`baueNavigation`: in `haupt` ein `baueReiter({ art:'balken', eintraege: WOERTER, ariaLabel:'Bereiche', bei: onWahl })`; im `menue` das Wort „WEBklar“ (Text, kein Icon) und je Wort ein `button.kn-nav-item` (`aria-current="page"` am aktiven, Zähler `span.kn-nav-zahl` mit `anzeigeText`). `tastaturZustand` ist rein: `offen = hoehe < voll − 150`; ein Breitenwechsel setzt `voll := hoehe`; schließt die Tastatur per `vv`, wird `tippt` falsch; `tippt` wird wahr bei Fokus auf ein Textfeld mit `grob` oder bei offener Tastatur mit `textfeldAktiv` und `grob`.
Feste Werte (`final-2-frei.html`, Klassen mit Präfix `kn-`):
- Leiste (nur unter `HANDY`, sonst `display:none`): `position:fixed; left:0; right:0; bottom:0`, Fläche `--kn-bg`, oben 1 px `--kn-line`, `padding-bottom: env(safe-area-inset-bottom)`. Zeile 56 px, Reel `overflow:hidden`; `[data-etage="offen"]` schiebt das Reel `translateY(-56px)` in `550ms var(--kn-bremse)`.
- Tab `.kn-t`: `flex:1 1 auto; min-height:44px; padding:0 4px; font:600 15px; color:--kn-text-2; transition: color 250ms`; `.kn-schrift-klein` 14 px. Gleiter `.kn-gleiter`: `top:4px; height:36px; background:--kn-accent; border-radius:6px; transition: transform und width 550ms var(--kn-feder)`; Overlay-Kopie `.kn-overlay` (`top:-4px`, Schrift weiß); `.kn-fb` blendet das Overlay aus und färbt den aktiven Tab weiß.
- Zähler `.kn-zahl`: `min-width:16px; height:16px; padding:0 4px; border-radius:8px; background:#b3093c; color:#fff; font:700 11px/16px; box-shadow:0 0 0 2px #fff` (im Overlay `#111`), `top:-9px; left:100%; margin-left:-6px`; Eintritt 400 ms `cubic-bezier(.3,1.5,.5,1)` von `scale(0)`; `.kn-zahl--weg` `scale(0)` in 250 ms.
- Menü `.kn-menue` (Grundregel): 232 px, `border-right`, Einträge `padding:6px 10px; font:500 14px/20px; color:--kn-text-2`, aktiv `#111`/600/`--kn-accent-soft`. Inhalt `.kn-haupt`: max. 1160 px, Abstände wie `.page` (`woms-design.md` Abschnitt 2: 36/48/80, unter 1100 px 32/28/80). Unter `HANDY` 16 px Seitenrand und Fußabstand `calc(var(--kn-zeile-h) + env(safe-area-inset-bottom) + 28px)`.
- Kopf `.kn-kopf`: sticky, 52 px plus Safe-Area oben; kleiner Titel (15 px/600) blendet bei `.kn-titled` ein; Rand `--kn-line` bei `.kn-scrolled`; Avatar-Fläche 44 × 44 px (Kreis 32 px).
- `.kn-tastatur` blendet Leiste und Streifen aus. Querformat (`HANDY` mit `max-height:500px`): Zeile 44 px, `.kn-haupt .kn-titel` weg. Unter `prefers-reduced-motion: reduce` alle Dauern höchstens 150 ms, Blatt und Seiten nur `opacity`.
- [ ] **Step 4: Run test to verify it passes**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleLeiste.test.js`
Expected: PASS (3 Tests).
- [ ] **Step 5: Commit**
```bash
git add public/neu/leiste.js public/neu/huelle.css test/knHuelleLeiste.test.js
git commit -m "Kundenansicht neu P1: Fahrstuhl-Zeile, Desktop-Menü und Tastaturzustand" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 11: huelle.js (Start, Ableitung, Zusammenbau)
**Files:**
- Create: `public/neu/huelle.js`
- Test: `test/knHuelleStart.test.js`
**Interfaces:**
- Consumes: alles aus Task 2 bis 10.
- Produces:
- `GERUEST_IDS`: `kn-banner, kn-ansage, kn-fehler, kn-menue, kn-kopf, kn-kopf-titel, kn-kopf-konto, kn-inhalt, bereich-website, bereich-funktionen, bereich-chat, bereich-vertraege, website-body, funktionen-body, chat-body, vertraege-body, kn-blatt, kn-brot, kn-etage-titel, kn-reiter-slot, news-body, termine-body, webshop-body, buchungen-body, zeiten-body, analyse-body, etage-body, kn-streifen, kn-leiste, kn-zeile-haupt, kn-zeile-etage, kn-konto-wurzel`.
- `ANSICHTEN = { website:['/neu/ansicht-website.js','zeigeWebsite','website-body'], funktionen:[…'zeigeFunktionen','funktionen-body'], chat:[…'zeigeChat','chat-body'], vertraege:[…'zeigeVertraege','vertraege-body'] }`; `ETAGE_ANSICHTEN = { projekt:['/neu/ansicht-website.js','zeigeWebsite','etage-body'], passwort:[…'zeigePasswort','etage-body'], gutschrift:[…'zeigeGutschrift','etage-body'], wechsel:[…'zeigeWechsel','etage-body'], bestellen:[…'zeigeBestellen','etage-body'] }` (Datei `ansicht-<name>.js`, Funktion `zeige` + Name groß).
- `FUNKTIONS_MODULE = { news:['/funktionen.js','initNews','news-body'], termine:['/termine.js','initTermine','termine-body'], webshop:['/shop.js','initShop','webshop-body'], buchungen:['/buchungen.js','initBuchungen','buchungen-body'], zeiten:['/zeiten.js','initZeiten','zeiten-body'], analyse:['/analyse.js','initAnalyse','analyse-body'] }` (wörtlich aus `app.js:1938-1945`).
- `MIT_EINSCHALTEN = ['news', 'termine', 'webshop', 'buchungen', 'zeiten']` (die Etagen, deren Modul die Init-Option `einschalten` bekommt; `analyse` nicht, sie nutzt `POST /api/funktionen/analyse/activate`) und `einschaltenOption(lade) -> (platz, o) => Promise`, das `lade('/neu/funktion-einschalten.js')` erst beim Aufruf startet und dann `m.zeigeEinschalten(platz, o)` ruft (P3 liefert die Datei und den Export; Vertragsfrage 12).
- `leiteAb(me, { hash = '' }) -> { art:'weiter', ziel } | { art:'zeigen', kunde, viewAs, viewId, admin, anzeigename }`; `scrollZiel(nav, gemerkt) -> { top, weich }`.
- `async starteHuelle({ win = window, doc = document, api = apiStandard, lade = (p) => import(p), router = routerStandard, hash = win.location.hash } = {})`.
- Ablauf von `zeige(route, { nav })`: (1) Bereich oder Etage zeigen, Leiste, Menü, Kopftitel und `#kn-ansage` nachziehen; bei `vertraege` `zaehler.aktualisiere('vertraege')`; bei Chat `zaehler.setzeChat(0)`. (2) Steht die Wurzel leer da, den Text „Wird geladen …“ (`p.kn-leer`) hineinschreiben. (3) Ansicht aus `ANSICHTEN`/`ETAGE_ANSICHTEN` über `lade` holen und `zeigeX(wurzel, { route, me, nav })` rufen (V2); Funktionsetagen: `wurzel.dataset.wunschProjekt` aus dem Parameter `projekt` der Query setzen (falls vorhanden), `init*(wurzel, { einschalten: einschaltenOption(lade) })` rufen (nur für die Etagen aus `MIT_EINSCHALTEN`; `initAnalyse(wurzel)` ohne zweites Argument) und danach `router.stempeln()`. Die Hülle übergibt kein `projectId`, die Projektwahl bleibt `dataset.wunschProjekt` (V2). (4) Ladefehler: „Konnte nicht geladen werden.“ als `p.kn-fehler` in die Wurzel. (5) Scroll nach `scrollZiel`.
- [ ] **Step 1: Write the failing test**
Hilfen in der Datei: `baueGeruest(fx)` legt jede ID aus `GERUEST_IDS` als `div#id` an `fx.document.body`. `boot({ karte, hash, lade })` ruft `starteHuelle` mit einem Fenster (fakeDom-Fenster plus `matchMedia: () => ({ matches: true, addEventListener() {} })` und `KbView: { setId: (id) => log.push(['setId', id]) }`), einem `api` (`karte[pfad]`; ein `Error` wirft; ein fehlender Pfad wirft `{ status: 404 }`; jeder Aufruf steht als `['api', pfad, method]` im `log`), einem `doc` (`fx.document` mit `addEventListener`/`dispatchEvent` eines Node-`EventTarget`), einem `lade` (Vorgabe `async () => new Proxy({}, { get: () => async () => {} })`) und einer Router-Attrappe (`starteRouter({ zeige })` merkt `zeige` und ruft es sofort mit `routeAusHash(hash)` und `{ nav: 'tab' }`; `geheZu(h)` und `stempeln()` schreiben ins `log`). Danach `await fx.ruhe()`. Rückgabe `{ fx, win, doc, log, el(id), zeige(hash, nav) }`. Konstanten: `ME_KUNDE = { authenticated: true, role: 'customer', customer: { id: 'c1', name: 'Anna', companyName: '', email: 'a@b.de' } }`, `ME_VIEW = { ...ME_KUNDE, viewAs: true, viewId: 'v1', admin: { name: 'Kenso' } }`, `KARTE_KUNDE` mit `ME_KUNDE` und leeren Antworten für `/api/chat/unread-count` (`{ unread: 0 }`), `/api/invoices`, `/api/abo`, `/api/abo/takeover-credits`.
```js
import { leiteAb, scrollZiel, GERUEST_IDS } from '../public/neu/huelle.js'
test('leiteAb: Anmeldung, Mitarbeiter, abgelaufen, Passwort geändert, Kundenansicht (wie app.js:2043-2064)', () => {
assert.deepEqual(leiteAb({ authenticated: false }, { hash: '' }), { art: 'weiter', ziel: '/login.html' })
assert.deepEqual(leiteAb({ authenticated: false }, { hash: '#chat' }), { art: 'weiter', ziel: '/login.html?next=%2Fdashboard.html%23chat' })
assert.deepEqual(leiteAb({ authenticated: false, reason: 'staff', redirect: 'https://ticket.webklar.com' }, {}), { art: 'weiter', ziel: 'https://ticket.webklar.com' })
assert.equal(leiteAb({ authenticated: false, reason: 'view_expired', message: 'Bitte neu anmelden.' }, { hash: '#chat' }).ziel, '/login.html?next=%2Fdashboard.html%23chat&error=Bitte+neu+anmelden.')
assert.equal(leiteAb({ authenticated: false, reason: 'password_changed', message: 'Bitte neu anmelden.' }, {}).ziel, '/login.html?error=Bitte+neu+anmelden.')
assert.equal(leiteAb({ authenticated: true }, {}).ziel, '/login.html')
const z = leiteAb(ME_VIEW, {}); assert.deepEqual([z.art, z.viewAs, z.viewId, z.anzeigename], ['zeigen', true, 'v1', 'Anna'])
})
test('scrollZiel: push beginnt oben, same scrollt weich nach oben, back und tab stellen wieder her', () => {
assert.deepEqual([scrollZiel('push', 120), scrollZiel('same', 120), scrollZiel('back', 120), scrollZiel('tab', undefined)], [{ top: 0, weich: false }, { top: 0, weich: true }, { top: 120, weich: false }, { top: 0, weich: false }])
})
test('Kundenansicht: setId vor jedem weiteren API-Aufruf, Banner, Beenden', async () => {
const b = await boot({ hash: '#chat', karte: { ...KARTE_KUNDE, '/api/auth/me': ME_VIEW, '/api/auth/kundenansicht/ende': { redirect: 'https://ticket.webklar.com/customers/c1' } } })
assert.deepEqual(b.log.slice(0, 2), [['api', '/api/auth/me', 'GET'], ['setId', 'v1']])
assert.equal(b.fx.text(b.el('kn-banner')), 'Kundenansicht: Anna · geöffnet von KensoBeenden')
await b.fx.klick(b.fx.finde(b.el('kn-banner'), 'button')); assert.equal(b.win.location.href, 'https://ticket.webklar.com/customers/c1')
})
test('abgemeldet leitet um ohne weitere Aufrufe; me 500 zeigt eine Meldung statt leerer Seite (Review Focus 5)', async () => {
const a = await boot({ hash: '#chat', karte: { '/api/auth/me': { authenticated: false } } })
assert.equal(a.win.location.href, '/login.html?next=%2Fdashboard.html%23chat'); assert.equal(a.log.filter((l) => l[0] === 'api').length, 1)
const f = await boot({ hash: '', karte: { '/api/auth/me': Object.assign(new Error('Fehler 500'), { status: 500 }) } })
assert.deepEqual([f.el('kn-fehler').hidden, f.el('kn-fehler').textContent], [false, 'Fehler 500'])
})
test('Route Chat: Ansicht bekommt Wurzel, route, me, nav; Navigation, Titel, Ansage folgen', async () => {
const gerufen = []
const b = await boot({ hash: '#chat', karte: KARTE_KUNDE, lade: async (p) => ({ zeigeChat: (w, c) => gerufen.push([p, w.id, c.route, c.nav, c.me.customer.id]) }) })
assert.deepEqual(gerufen, [['/neu/ansicht-chat.js', 'chat-body', { bereich: 'chat', query: '' }, 'tab', 'c1']])
assert.deepEqual([b.el('bereich-chat').hidden, b.el('bereich-website').hidden, b.el('kn-blatt').hidden, b.el('kn-kopf-titel').textContent, b.el('kn-ansage').textContent], [false, true, true, 'Chat', 'Chat'])
})
test('Ladezustand als Text, solange die Ansicht lädt', async () => {
const b = await boot({ hash: '#chat', karte: KARTE_KUNDE, lade: () => new Promise(() => {}) })
assert.equal(b.el('chat-body').textContent, 'Wird geladen …')
})
test('Etage Terminplaner: wunschProjekt aus ?projekt=, Modul auf #termine-body mit Option einschalten, danach stempeln, Chat-Punkt am Zurück; Ladefehler zeigt Text', async () => {
const seen = []
const b = await boot({ hash: '#termine?bestellt=1&projekt=p7', karte: { ...KARTE_KUNDE, '/api/chat/unread-count': { unread: 3 } }, lade: async () => ({ initTermine: (root, opt) => seen.push([root.id, root.dataset.wunschProjekt, typeof opt?.einschalten]) }) })
assert.deepEqual(seen, [['termine-body', 'p7', 'function']]); assert.ok(b.log.some((l) => l[0] === 'stempeln'))
assert.deepEqual([b.el('kn-blatt').hidden, b.el('kn-etage-titel').textContent, Boolean(b.fx.finde(b.el('kn-zeile-etage'), '.kn-punkt'))], [false, 'Terminplaner', true])
const x = await boot({ hash: '#webshop', karte: KARTE_KUNDE, lade: async () => { throw new Error('404') } })
assert.equal(x.el('webshop-body').textContent, 'Konnte nicht geladen werden.')
})
test('Option einschalten: news, termine, webshop, buchungen und zeiten bekommen sie, analyse nicht; der Aufruf lädt funktion-einschalten.js erst dann', async () => {
const MODULE = { news: ['/funktionen.js', 'initNews'], termine: ['/termine.js', 'initTermine'], webshop: ['/shop.js', 'initShop'], buchungen: ['/buchungen.js', 'initBuchungen'], zeiten: ['/zeiten.js', 'initZeiten'], analyse: ['/analyse.js', 'initAnalyse'] }
for (const [etage, [pfad, init]] of Object.entries(MODULE)) {
const geladen = []; const aufrufe = []
const lade = async (p) => { geladen.push(p); return p === '/neu/funktion-einschalten.js' ? { zeigeEinschalten: (platz, o) => aufrufe.push(['zeige', platz, o]) } : { [init]: (root, opt) => aufrufe.push(['init', root.id, opt]) } }
await boot({ hash: `#${etage}`, karte: KARTE_KUNDE, lade })
const aufruf = aufrufe.find((a) => a[0] === 'init'); assert.ok(geladen.includes(pfad), pfad); assert.equal(aufruf[1], `${etage}-body`)
if (etage === 'analyse') { assert.equal(aufruf[2], undefined, 'analyse bekommt keine Option'); continue }
assert.equal(typeof aufruf[2].einschalten, 'function', etage); assert.equal(geladen.includes('/neu/funktion-einschalten.js'), false, `${etage}: lazy`)
const platz = { id: 'platz' }; await aufruf[2].einschalten(platz, { key: etage, projectId: 'p1' })
assert.ok(geladen.includes('/neu/funktion-einschalten.js')); assert.deepEqual(aufrufe.at(-1), ['zeige', platz, { key: etage, projectId: 'p1' }])
}
let versprechen // Ladefehler der Datei geht an das Modul (es zeigt E4), die Hülle schluckt ihn nicht
await boot({ hash: '#termine', karte: KARTE_KUNDE, lade: async (p) => { if (p.endsWith('funktion-einschalten.js')) throw new Error('404'); return { initTermine: (_w, o) => { versprechen = o.einschalten({}, {}); versprechen.catch(() => {}) } } } })
await assert.rejects(versprechen, /404/)
})
test('data-view-Brücke der Module: Name zu Hash, data-project wird ?projekt=', async () => {
const b = await boot({ hash: '#termine', karte: KARTE_KUNDE, lade: async () => ({ initTermine() {} }) })
const chat = h('button', { 'data-view': 'chat' }); const shop = h('button', { 'data-view': 'webshop', 'data-project': 'p1' }); b.el('termine-body').append(chat, shop)
await b.fx.klick(chat); await b.fx.klick(shop)
assert.deepEqual(b.log.filter((l) => l[0] === 'geheZu').map((l) => l[1]), ['#chat', '#webshop?projekt=p1'])
})
test('Fristen 404 (Server ohne P5): kein Streifen, "Vertrag widerrufen" bleibt ohne Pille; mit Frist Streifen und Pille', async () => {
const b = await boot({ hash: '', karte: KARTE_KUNDE })
assert.equal(b.el('kn-streifen').hidden, true)
const konto = b.fx.text(b.el('kn-konto-wurzel')); assert.match(konto, /Vertrag widerrufen/); assert.doesNotMatch(konto, /Frist läuft/)
const p = await boot({ hash: '', karte: { ...KARTE_KUNDE, '/api/fristen': { fristen: [{ quelle: 'abschluss', bezeichnung: 'Abo', bis: '2026-10-21T21:59:59.999Z' }], fruehestesEnde: '2026-10-21T21:59:59.999Z', anzeigen: true } } })
assert.deepEqual([p.el('kn-streifen').hidden, p.fx.text(p.el('kn-streifen'))], [false, 'Widerrufsfrist bis 21.10. · Widerrufen']); assert.match(p.fx.text(p.el('kn-konto-wurzel')), /Frist läuft/)
})
test('Zähler: Verträge laden beim Betreten und bei kn:zaehler-neu (document) neu; der Chat-Zähler fällt beim Öffnen des Chats weg', async () => {
const b = await boot({ hash: '', karte: { ...KARTE_KUNDE, '/api/chat/unread-count': { unread: 3 } } }); const n = (pfad) => b.log.filter((l) => l[1] === pfad).length
const i0 = n('/api/invoices'); const chatTab = () => b.fx.finde(b.el('kn-zeile-haupt'), '[role=tab][aria-label="Chat, 3 ungelesen"]')
assert.ok(chatTab())
await b.zeige('#rechnungen', 'tab'); assert.equal(n('/api/invoices'), i0 + 1)
b.doc.dispatchEvent(new CustomEvent('kn:zaehler-neu', { detail: { was: 'vertraege' } })); await b.fx.ruhe(); assert.equal(n('/api/invoices'), i0 + 2)
await b.zeige('#chat', 'tab'); assert.equal(chatTab(), null)
})
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleStart.test.js`
Expected: FAIL (Modul nicht gefunden).
- [ ] **Step 3: Implement `huelle.js`**
`leiteAb` baut die Adresse mit `URLSearchParams` (`next` zuerst, dann `error`). `starteHuelle`: (1) `api('/api/auth/me')`; ein `ApiFehler` mit Status 401 führt zur Anmeldung, jeder andere Text nach `#kn-fehler`. (2) `leiteAb`. (3) Bei `viewAs` sofort `win.KbView?.setId(viewId)`, dann Banner mit `h()` („Kundenansicht: **Name** · geöffnet von X“, Knopf „Beenden“ → `POST /api/auth/kundenansicht/ende`, Ziel `redirect || 'https://ticket.webklar.com/customers'`). (4) Avatar in `#kn-kopf-konto`, Konto in `#kn-konto-wurzel` (mit `overlay` und `navigiere` aus dem Router; `aktualisiere` sobald Fristen oder Installationsangebot da sind), Streifen, Navigation, Etage und Zähler bauen; `ladeFristen` und `zaehler.start()` laufen parallel und dürfen den Start nie abbrechen; bei jeder Zähleränderung `navigation.setzeZaehler(…)` und `etage.setzeChatPunkt(stand.chat > 0)`. (5) Klick auf `doc.body`: `closest('[data-view]')` → `hashFuerAnsicht(name)` (plus `?projekt=` aus `data-project`) → `preventDefault` → `router.geheZu`. (6) Hörer: `kn:zaehler-neu` am `doc` (`detail.was`, Vertragsfrage 6), `visibilitychange` (sichtbar → `aktualisiere('alle')`), `beforeinstallprompt`/`appinstalled`, Scroll (rAF-gedrosselt: `kn-scrolled`, `kn-titled`, `scroll`-Merker je Hash ohne Query), `beobachteTastatur` (setzt `kn-tastatur` am Body), Medienwechsel (`etage.neuZeichnen()`). (7) `router.starteRouter({ zeige })`. (8) `navigator.serviceWorker.register('/sw.js')` nach `load` wie `app.js:2556-2560` (nur `isSecureContext`; so fängt `boot.js` der Vorschau den Aufruf ab). Am Dateiende: Start nur, wenn `document.body.classList.contains('kn')` und `#kn-leiste` existiert.
Funktionsmodule in `zeige` (Interfaces, Ablauf 3): `einschaltenOption(lade)` liefert `(platz, o) => lade('/neu/funktion-einschalten.js').then((m) => m.zeigeEinschalten(platz, o))`. Der Import (P3) startet erst beim Aufruf, ein Ladefehler geht als abgelehntes Versprechen an das Modul zurück, das dann seine Fehlerkarte E4 zeigt (P4); die Hülle fängt ihn nicht ab. Alte Signaturen (`initTermine(root)`) ignorieren das zweite Argument, deshalb ist der Aufruf vor P3 und P4 unschädlich.
- [ ] **Step 4: Run test to verify it passes**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleStart.test.js`
Expected: PASS (11 Tests).
- [ ] **Step 5: Commit**
```bash
git add public/neu/huelle.js test/knHuelleStart.test.js
git commit -m "Kundenansicht neu P1: Hülle mit Start über /api/auth/me und Modul-Lader" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 12: dashboard.html, Manifest und Stubs
**Files:**
- Modify: `public/neu/dashboard.html` (ersetzt den Platzhalter aus P0), `test/kundenansichtDashboard.test.js` (Hilfsfunktion `istNeu`)
- Create: `public/neu/manifest.webmanifest`, `public/neu/ansicht-website.js`, `ansicht-funktionen.js`, `ansicht-chat.js`, `ansicht-vertraege.js`, `ansicht-passwort.js`, `ansicht-gutschrift.js`, `ansicht-wechsel.js`, `ansicht-bestellen.js`, `public/neu/ansichten.css`, `public/neu/funktionen.css`, `public/neu/module.css`
- Test: `test/knHuelleSeite.test.js`
**Interfaces:**
- Consumes: `GERUEST_IDS`, `ANSICHTEN`, `ETAGE_ANSICHTEN` aus `./huelle.js`.
- Produces: die Seite, die `GET /dashboard.html` im Pilot ausliefert (Marker `<meta name="kn-seite" content="neu">`, `<body class="kn">`); acht Stubs mit je `export async function zeigeX(wurzel, { route, me, nav })`, die nur einen Satz „<Bereich> (folgt)“ nach `wurzel` schreiben; drei CSS-Stubs mit einem Kommentar zum künftigen Besitzer.
- [ ] **Step 1: Write the failing test** (und `istNeu` in `test/kundenansichtDashboard.test.js` auf `String(r.body).includes('<meta name="kn-seite" content="neu">')` statt `data-kn-platzhalter` umstellen; `<body class="kn">` bleibt)
```js
import { GERUEST_IDS, ANSICHTEN, ETAGE_ANSICHTEN } from '../public/neu/huelle.js'
import { lies } from './support/knHuelle.js'
const html = lies('dashboard.html')
test('dashboard.html: weiße PWA-Metas, Stile in fester Reihenfolge, klassisches Skript vor dem Modul, nichts Altes (P2 darf chat-composer ergänzen)', () => {
assert.match(html, /<meta name="kn-seite" content="neu">/); assert.match(html, /<meta name="theme-color" content="#ffffff">/)
assert.match(html, /apple-mobile-web-app-status-bar-style" content="default"/); assert.match(html, /<link rel="manifest" href="\/neu\/manifest\.webmanifest">/)
assert.match(html, /viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content"/)
const css = [...html.matchAll(/<link rel="stylesheet" href="([^"]+)"/g)].map((m) => m[1]); const ORDNUNG = ['/neu/tokens.css', '/neu/basis.css', '/neu/huelle.css', '/neu/ansichten.css', '/neu/funktionen.css', '/neu/module.css']
assert.deepEqual(css.filter((f) => f !== '/chat-composer.css'), ORDNUNG); assert.ok(css.indexOf('/chat-composer.css') === -1 || css.indexOf('/chat-composer.css') > css.indexOf('/neu/huelle.css'))
assert.match(html, /<body class="kn">/)
const skripte = [...html.matchAll(/<script([^>]*)>/g)].map((m) => m[1].trim())
assert.deepEqual([skripte[0], skripte.at(-1)], ['src="/kundenansicht.js"', 'type="module" src="/neu/huelle.js"']); assert.ok(skripte.slice(1, -1).every((s) => s === 'src="/chat-composer.js"'))
assert.doesNotMatch(html, /login\.css|kundenbereich\.css|\/app\.js|dot-background|kb-glow|class="[^"]*\bkb-/)
})
test('dashboard.html: jede Gerüst-ID genau einmal, dazu die Body-IDs der Funktionsmodule und beide Navigationen', () => {
for (const id of GERUEST_IDS) assert.equal(html.split(`id="${id}"`).length - 1, 1, id)
for (const id of ['news-body', 'termine-body', 'webshop-body', 'buchungen-body', 'zeiten-body', 'analyse-body', 'funktionen-body']) assert.ok(GERUEST_IDS.includes(id), id)
for (const id of ['kn-menue', 'kn-leiste']) assert.match(html, new RegExp(`<nav(?=[^>]*id="${id}")(?=[^>]*aria-label="Hauptnavigation")`))
})
test('manifest.webmanifest: weiß, gleiche Identität wie die alte App, Shortcuts Chat, Rechnungen, Verträge', () => {
const m = JSON.parse(lies('manifest.webmanifest'))
assert.deepEqual([m.id, m.start_url, m.scope, m.display, m.background_color, m.theme_color], ['/dashboard.html', '/dashboard.html', '/', 'standalone', '#ffffff', '#ffffff'])
assert.deepEqual(m.shortcuts.map((s) => [s.name, s.url]), [['Chat', '/dashboard.html#chat'], ['Rechnungen', '/dashboard.html#rechnungen'], ['Verträge', '/dashboard.html#vertraege']])
assert.deepEqual(m.icons.map((i) => i.src), ['/img/app-icon-192.png', '/img/app-icon-512.png', '/img/app-icon-maskable-512.png'])
})
test('Stubs: jede Ansicht der Hülle existiert unter dem Namen aus V2 (zeige + Dateiname)', async () => {
const soll = { website: 'zeigeWebsite', funktionen: 'zeigeFunktionen', chat: 'zeigeChat', vertraege: 'zeigeVertraege', passwort: 'zeigePasswort', gutschrift: 'zeigeGutschrift', wechsel: 'zeigeWechsel', bestellen: 'zeigeBestellen' }
for (const [name, fn] of Object.entries(soll)) {
assert.equal(typeof (await import(`../public/neu/ansicht-${name}.js`))[fn], 'function', fn)
assert.deepEqual(Object.values({ ...ANSICHTEN, ...ETAGE_ANSICHTEN }).find(([p]) => p === `/neu/ansicht-${name}.js`).slice(0, 2), [`/neu/ansicht-${name}.js`, fn])
}
})
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleSeite.test.js`
Expected: FAIL (Platzhalter ohne Metas, Stubs fehlen).
- [ ] **Step 3: Write the page, manifest and stubs**
`dashboard.html`: Kopf wie im Test (Zeilen wörtlich: `<meta name="kn-seite" content="neu">`, `<meta name="theme-color" content="#ffffff">`, `<link rel="manifest" href="/neu/manifest.webmanifest">`), dazu `<title>Kundenbereich – WEBklar</title>`, Icons wie `public/dashboard.html:8-10`, `mobile-web-app-capable`, `apple-mobile-web-app-capable yes`, `apple-mobile-web-app-title WEBklar`. Skripte exakt `<script src="/kundenansicht.js"></script>` und `<script type="module" src="/neu/huelle.js"></script>`. Gerüst in dieser Reihenfolge: Skip-Link „Zum Inhalt“, `#kn-banner[hidden]`, Spalte mit `nav#kn-menue`, `header#kn-kopf` (`#kn-kopf-titel`, `#kn-kopf-konto`), `p#kn-fehler.kn-fehler[hidden][role=alert]`, `main#kn-inhalt.kn-haupt` mit vier `section#bereich-*` (je `h1.kn-titel` mit dem Bereichsnamen und `div#…-body`; kein Erklärsatz), `div#kn-blatt.kn-blatt[hidden]` mit `#kn-brot`, `h1#kn-etage-titel`, `#kn-reiter-slot` und je einer `section[data-etage]` für `news`, `termine`, `webshop`, `buchungen`, `zeiten`, `analyse` (mit ihrem `…-body`) und `seite` (mit `#etage-body`), dann `#kn-streifen[hidden]`, `nav#kn-leiste` mit `div.kn-leiste-reel` (darin `#kn-zeile-haupt` und `#kn-zeile-etage`), `#kn-konto-wurzel` und `#kn-ansage[aria-live=polite].kn-nur-leser`. Manifest: Felder wie im Test, `name "WEBklar Kundenbereich"`, `short_name "WEBklar"`, `lang de`, Beschreibung „Website, Funktionen, Chat und Verträge – der WEBklar-Kundenbereich als App.“, `categories` und `icons` wie `public/manifest.webmanifest`. Stubs: `import { h } from './dom.js'` und `wurzel.replaceChildren(h('p', { class: 'kn-leer' }, '<Name> (folgt)'))`.
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/knHuelleSeite.test.js test/kundenansichtDashboard.test.js`
Expected: PASS (4 Tests plus die Tests von P0, `fail 0`).
- [ ] **Step 5: Commit**
```bash
git add public/neu/dashboard.html public/neu/manifest.webmanifest public/neu/ansicht-website.js public/neu/ansicht-funktionen.js public/neu/ansicht-chat.js public/neu/ansicht-vertraege.js public/neu/ansicht-passwort.js public/neu/ansicht-gutschrift.js public/neu/ansicht-wechsel.js public/neu/ansicht-bestellen.js public/neu/ansichten.css public/neu/funktionen.css public/neu/module.css test/knHuelleSeite.test.js test/kundenansichtDashboard.test.js
git commit -m "Kundenansicht neu P1: weiße Seite, Manifest und leere Ansichten für P2 und P3" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 13: Sichtprüfung über das Werkzeug (Ansichten, Gate `p1`)
**Files:**
- Modify (Werkzeug-Git, `/root/reports/kundenansicht-neu-2026-10-15/werkzeug/`, Abkürzung `KW`): `views.mjs` (Ansichten von P1, die zwei Platzhalter-Ansichten), `gates.mjs` (Gate `p1`), `test/statik.test.mjs` (Marker).
- Modify (nur bei Verstößen, Portal-Worktree): `public/neu/huelle.css`, `basis.css`, `reiter.js`, `leiste.js`, `etage.js`.
**Interfaces:**
- Consumes (aus P0, Task 6, 8, 9, 10): Fälle `betrieb-wartung`, `privat-frist` (Frist bis `2026-10-25`), `rechnung-offen` (`RE-2026-0042`, 50 €), `chat-ungelesen` (3), `mitarbeiter`, `abgemeldet`, `fehler` (500 für `abo`, `invoices`, `funktionen`); `GERAETE` (`handy` 390×844, `handy-quer` 844×390, `tablet` 820×1180, `desktop` 1440×900); Ansichtsfelder `{ id, fall, adresse, geraete, warte, schritte(seite), bewegung?, ignorieren? }`; `GATES.p<N> = { checks, ansichten }` mit `ALWAYS_ON`; Prüfungen `dark-surface`, `low-contrast`, `overflow-x`, `lila`; Standard `reducedMotion: 'reduce'` (mit `bewegung: true` volle Bewegung). `schritte(seite)` wirft bei Verstoß (wird zu `step-failed`).
- Produces: Gate `p1 = { checks: [...ALWAYS_ON, 'dark-surface', 'low-contrast', 'overflow-x', 'lila'], ansichten: ['kn-huelle-*'] }` grün; die Aufnahmen unter `KW/tmp/shots-p1/` als Beleg.
- [ ] **Step 1: Umgebung für diesen Strang**
Run: `printf 'export WT=/home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui\n' > /root/reports/kundenansicht-neu-2026-10-15/werkzeug/tmp/strang-ui.env && cat /root/reports/kundenansicht-neu-2026-10-15/werkzeug/README.md | head -60 && git -C /root/reports/kundenansicht-neu-2026-10-15/werkzeug status --short`
Expected: Datei geschrieben, README von P0 gelesen, Werkzeug-Git sauber. Alle folgenden Läufe mit `KN_STRANG_ENV=/root/reports/kundenansicht-neu-2026-10-15/werkzeug/tmp/strang-ui.env`; vor jedem Lauf `free -m` (über 2000 MB verfügbar).
- [ ] **Step 2: Ansichten und Gate schreiben**
Ein Beispiel als Code, die übrigen als Tabelle (jede Zeile: genau diese Prüfungen mit diesen Werten, per `assert` in `schritte`):
```js
{ id: 'kn-huelle-website', fall: 'betrieb-wartung', adresse: '/dashboard.html', geraete: ['handy', 'tablet', 'desktop'], warte: '#kn-inhalt',
schritte: async (seite) => {
assert.equal(await seite.evaluate(() => getComputedStyle(document.body).backgroundColor), 'rgb(255, 255, 255)')
const breite = seite.viewportSize().width
if (breite <= 880) {
const r = await seite.evaluate(() => ({ unten: document.querySelector('.kn-leiste').getBoundingClientRect().bottom, innen: innerHeight, tabs: [...document.querySelectorAll('.kn-leiste [role=tab]')].map((t) => { const q = t.getBoundingClientRect(); return [q.width, q.height] }) }))
assert.equal(r.unten, r.innen); assert.ok(r.tabs.length === 4 && r.tabs.every(([b, hoehe]) => b >= 44 && hoehe >= 44))
} else assert.equal(await seite.evaluate(() => getComputedStyle(document.querySelector('.kn-menue')).width), '232px')
} }
```
| id | Fall, Adresse, Geräte, `warte` | Prüfung |
|---|---|---|
| `kn-huelle-kopf` | `betrieb-wartung`, `/dashboard.html`, `handy`, `desktop`, `#kn-inhalt` | Step streckt `#website-body` auf 2000 px und scrollt 300 px: `body` trägt `kn-scrolled` und `kn-titled`, `#kn-kopf-titel` hat Opacity 1 und den Text „Website“; zurück nach oben: beide Klassen weg |
| `kn-huelle-wechsel` | `betrieb-wartung`, `/dashboard.html`, `handy`, `bewegung: true`, `#kn-leiste [role=tab]` | nacheinander Funktionen, Chat, Verträge, Website antippen: `history.length` unverändert, Hash folgt; 700 ms nach dem Tipp liegt die Mitte des Gleiters (±2 px) im Tab; Overlay-Schrift `rgb(255, 255, 255)` |
| `kn-huelle-etage` | `betrieb-wartung`, `/dashboard.html#termine`, `handy`, `desktop`, `#kn-blatt:not([hidden])` | Handy: Reel `translateY(-56px)`, „Zurück“ ≥ 44 px, Hauptzeile `inert`, `history.state.kn.tiefe` ≥ 1 auch nach dem Start des Moduls (Stempel, Review Focus 1), Tipp auf Zurück ergibt `#funktionen`; Step sendet `kn:reiter` mit sechs Reitern und danach `bei` des letzten: Zeile scrollt (`scrollWidth > clientWidth`), der letzte liegt ganz im Bild (C2); danach `setViewportSize` 320 × 640: kein `overflow-x`. Desktop: Brotkrume „Funktionen / Terminplaner“, Reiter als Unterstrich |
| `kn-huelle-konto` | `privat-frist`, `/dashboard.html`, `handy`, `desktop`, `#kn-avatar` | Profilbild tippen: acht Zeilen in V3-Reihenfolge (mit „Frist läuft“); Esc schließt, Fokus am Profilbild; `page.goBack()` schließt zuerst das Sheet, Hash unverändert (Review Focus 2); Desktop: Popover rechts oben, 280 px |
| `kn-huelle-frist` | `privat-frist`, `/dashboard.html#chat`, `handy`, `#kn-streifen:not([hidden])` | Text `Widerrufsfrist bis 25.10. · Widerrufen` unmittelbar über der Leiste, kein Schließen-Knopf; Tastatur: `setViewportSize` auf 390 × 544 und ein eingefügtes `input` fokussieren → Leiste und Streifen nicht sichtbar, zurück auf 844 → sichtbar (Review Focus 4) |
| `kn-huelle-zaehler` | `chat-ungelesen`, `/dashboard.html`, `handy`, `.kn-zahl`, `ignorieren: { 'dark-surface': ['.kn-zahl'] }` | Badge „3“ am Chat, `aria-label` „Chat, 3 ungelesen“; Tipp auf Chat entfernt es |
| `kn-huelle-ausruf` | `betrieb-wartung`, `/dashboard.html`, `handy`, `#kn-leiste [role=tab]`, `ignorieren` wie oben | Step überschreibt mit `seite.route('**/api/abo', …)` die Antwort zuerst mit `{ subscription: { paymentProblem: true, switchPending: null } }` und sendet `document.dispatchEvent(new CustomEvent('kn:zaehler-neu', { detail: { was: 'vertraege' } }))`: Badge „!“; dann `{ subscription: { paymentProblem: false, switchPending: { since: 'x', paymentUrl: null } } }`: kein Badge |
| `kn-huelle-quer` | `betrieb-wartung`, `/dashboard.html#funktionen`, `handy-quer`, `#kn-leiste [role=tab]` | Leiste ≤ 48 px hoch, `.kn-haupt .kn-titel` nicht sichtbar, kein `overflow-x` |
| `kn-huelle-reduziert` | `betrieb-wartung`, `/dashboard.html`, `handy` (Standard `reduce`), `#kn-leiste [role=tab]` | jede `transition-duration` an Leiste, Gleiter und Blatt ≤ 0,15 s |
| `kn-huelle-kundenansicht` | `mitarbeiter`, `/dashboard.html`, `handy`, `desktop`, `#kn-banner:not([hidden])` | Banner „Kundenansicht: … · geöffnet von …“ mit „Beenden“; nach `kn:zaehler-neu` trägt die abgefangene Anfrage `/api/invoices` (`seite.waitForRequest`) den Header `x-kundenansicht` |
| `kn-huelle-grenze` | `betrieb-wartung`, `/dashboard.html`, `desktop`, `#kn-inhalt` | `setViewportSize` 880 × 800: Leiste da, Menü nicht; 881 × 800: Menü da, Leiste nicht; 1024 × 768: Menü, Inhalt höchstens 1160 px breit |
| `kn-huelle-quellen-fehler` | `fehler`, `/dashboard.html`, `handy`, `#kn-leiste [role=tab]` | `#kn-fehler` bleibt versteckt, die Leiste steht, kein Badge am Reiter Verträge (Review Focus 5) |
Außerdem: in `platzhalter` das `warte` auf `'#kn-platzhalter-ansicht:not(:empty), #kn-inhalt'` und in `platzhalter-abgemeldet` auf `'form'` (der Lauf endet auf `/login.html`) stellen, IDs unverändert (P0 pinnt sie in `test/gates.test.mjs`). In `test/statik.test.mjs` den Marker auf `/data-kn-platzhalter|<meta name="kn-seite" content="neu">/` erweitern (gilt für Platzhalter und neue Seite).
- [ ] **Step 3: Erster Lauf (erwartet rot) und Sichtprüfung**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && KN_STRANG_ENV=/root/reports/kundenansicht-neu-2026-10-15/werkzeug/tmp/strang-ui.env /root/reports/kundenansicht-neu-2026-10-15/werkzeug/shots.sh /root/reports/kundenansicht-neu-2026-10-15/werkzeug/tmp/shots-p1 --gate p1`
Expected: mindestens ein Verstoß, weil das CSS bis hier nur textlich geprüft wurde. Danach die Aufnahmen für Handy, Tablet und Desktop mit dem Read-Werkzeug ansehen und gegen die Demo prüfen: schwarzer Balken mit weißer Schrift, Wörter nicht abgeschnitten (auch bei 320 px), Zähler nicht beschnitten, Haarlinie über der Leiste, keine Schatten außer am Blatt und am Konto, Menü ohne Icons.
- [ ] **Step 4: Korrigieren**
Verstöße im Portal-Worktree beheben (nur die Dateien aus „Files“), Textabweichungen in `test/knHuelle*.test.js` mitziehen und diese Tests erneut einzeln laufen lassen.
- [ ] **Step 5: Run gate to verify it passes**
Run: derselbe Befehl wie in Step 3. Dazu `cd /root/reports/kundenansicht-neu-2026-10-15/werkzeug && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/gates.test.mjs test/statik.test.mjs`.
Expected: Exit 0 ohne Verstöße; Werkzeug-Tests `fail 0`; danach `ls -l /dev/null` zeigt `crw-rw-rw-`.
- [ ] **Step 6: Commit**
```bash
git -C /root/reports/kundenansicht-neu-2026-10-15/werkzeug add views.mjs gates.mjs test/statik.test.mjs
git -C /root/reports/kundenansicht-neu-2026-10-15/werkzeug commit -m "P1 Hülle: Ansichten kn-huelle-*, Gate p1, Platzhalter-Ansichten angepasst" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
# Portal-Dateien nur, wenn Step 4 etwas geändert hat; jede geänderte Datei einzeln nennen:
git add public/neu/huelle.css
git commit -m "Kundenansicht neu P1: Korrekturen aus der Sichtprüfung" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 14: Selbstprüfung
**Files:**
- Modify: `test/knHuelleQuelltext.test.js` (Vollständigkeits-Test)
**Interfaces:**
- Consumes: alle Tasks. Produces: Tag `kn-p1`.
- [ ] **Step 1: Vollständigkeits-Test anhängen und laufen lassen**
An `test/knHuelleQuelltext.test.js` anhängen:
```js
test('Dateiübersicht P1 ist vollständig vorhanden', () => {
const soll = [...P1, 'tokens.css', 'basis.css', 'huelle.css', 'dashboard.html', 'manifest.webmanifest', 'ansichten.css', 'funktionen.css', 'module.css',
...['website', 'funktionen', 'chat', 'vertraege', 'passwort', 'gutschrift', 'wechsel', 'bestellen'].map((n) => `ansicht-${n}.js`)]
for (const f of soll) assert.ok(existsSync(pfad(f)), f)
})
```
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleQuelltext.test.js`
Expected: PASS (2 Tests).
- [ ] **Step 2: Alle eigenen Testdateien und die Nachbarn mit Pins**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && free -m | sed -n 2p && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/knHuelleCss.test.js test/knHuelleDomApi.test.js test/knHuelleRouten.test.js test/knHuelleRouter.test.js test/knHuelleReiter.test.js test/knHuelleEtage.test.js test/knHuelleZaehler.test.js test/knHuelleFristen.test.js test/knHuelleKonto.test.js test/knHuelleLeiste.test.js test/knHuelleStart.test.js test/knHuelleSeite.test.js test/knHuelleQuelltext.test.js`
Expected: `# fail 0`.
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/kundenansichtDashboard.test.js test/adminPanelGone.test.js test/auftraegeKundenportalHuelle.test.js test/kundenansichtClient.test.js test/termineView.test.js`
Expected: `# fail 0` (alte Ansicht, ihre Pins und die Weiche von P0 sind unberührt).
- [ ] **Step 3: Gate noch einmal, Verträge abhaken, Diff prüfen**
Run: derselbe Gate-Befehl wie in Task 13, Step 3. Expected: Exit 0.
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git diff --name-only kn-p0 | sort`
Expected: genau die Dateien der Dateiübersicht (`public/neu/…`, `test/knHuelle…`, `test/support/knHuelle.js`, `test/kundenansichtDashboard.test.js`), sonst nichts.
Verträge sind abgehakt, wenn der Beleg grün ist:
| Vertrag | Beleg |
|---|---|
| V2 Grundregeln (`public/neu`, ES-Module, `<body class="kn">`, Präfix `kn-`), `dom.js`, `api.js` | Seite (12), Css (1), DomApi (2), Quelltext |
| V2 `routen.js` samt Pflichtinhalt, `router.js` (`starteRouter`, `geheZu`, `nav`-Werte) | Routen (3), Router (4) |
| V2 Hülle (`huelle.js`, `leiste.js`, `konto.js`, `fristen-streifen.js`, `zaehler.js`, `etage.js`, `reiter.js`), Ansichten `zeigeX`, Module über `#…-body`, `wunschProjekt`, Init-Option `einschalten` für die fünf Module (Test „Option einschalten“) | Tests 5 bis 12 |
| V3 Handy-Abfrage, Wörter, Gleiter 36 px/Radius 6, Tap-Ziele, Safe-Area, Tastatur | Leiste (10), Gate `kn-huelle-website`, `-frist` |
| V3 Etage 2 (Zurück, Reiter, Scrollen, aktiver Reiter im Bild), Desktop (232 px, Haarlinie, Brotkrume, Popover) | Etage (6), Reiter (5), Konto (9), Gate `-etage`, `-grenze` |
| V3 Konto in der Reihenfolge, Widerrufen immer da; Widerrufsstreifen | Konto (9), Fristen (8), Start (11) |
| V3 Zähler (Chat 45 s, Verträge, „!“ nur bei `paymentProblem`), Bewegung reduziert | Zaehler (7), Gate `-ausruf`, `-reduziert` |
- [ ] **Step 4: Sauberkeit und Tag**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && git status --short && ls -l /dev/null && git tag kn-p1 && git -C /root/reports/kundenansicht-neu-2026-10-15/werkzeug status --short`
Expected: beide `git status` leer (der Symlink `node_modules` ist ignoriert), `/dev/null` zeigt `crw-rw-rw-`, Tag gesetzt.
Im Abschlussbericht nennen, was hier niemand prüfen kann: iPhone-Standalone (Statusleiste `default`, Leiste bündig unten, kein Sprung beim Öffnen des Sheets), Android-Zurück-Geste in der installierten App, Fahrstuhl-Zeile auf einem echten Handy, Schriftbreiten (der Host hat nur DejaVu Sans).
- [ ] **Step 5: Commit**
```bash
git add test/knHuelleQuelltext.test.js
git commit -m "Kundenansicht neu P1: Vollständigkeitstest der Dateiübersicht" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
## Vertragsfragen an den Controller
Die Pläne von P0, P2, P3 und P4 lagen beim Schreiben vor. Alle Vorschläge sind in diesem Plan umgesetzt; wo ein anderer Plan etwas anderes annimmt, steht es dabei.
1. **Platzhalter und Weichen-Test (P0 ↔ P1).** P0 pinnt den Platzhalter über `data-kn-platzhalter` (`test/kundenansichtDashboard.test.js`, `istNeu`; Werkzeug `test/statik.test.mjs`). P1 ersetzt die Seite und stellt `istNeu` in Task 12 auf `<meta name="kn-seite" content="neu">` um; die Werkzeug-Tests akzeptieren beide Marker (Task 13). Besser wäre, P0 nähme den Meta-Marker von Anfang an. Außerdem gelten im Werkzeug die Ansichten `platzhalter` und `platzhalter-abgemeldet` weiter (nur `warte` wird angepasst), sonst bricht `test/gates.test.mjs` von P0.
2. **Werkzeug.** P1 nutzt die Form aus P0 (Ansichten, Gate `p1`, `KN_STRANG_ENV` mit `WT=…/dev-kn-ui`, `shots.sh <ausgabe> --gate p1`). P1 ändert im Werkzeug nur `views.mjs`, `gates.mjs` und den Marker in `test/statik.test.mjs`, keine Fälle. Zwei Folgen aus P0: Die Prüfung `dark-surface` meldet den roten Zähler (16 px, `#b3093c`), deshalb `ignorieren: { 'dark-surface': ['.kn-zahl'] }` in den Ansichten mit Zähler. Und `paymentProblem`/`switchPending` kommen in keinem Fall vor, deshalb überschreibt der Step die Antwort von `/api/abo` mit `seite.route`.
3. **Stubs und Besitz.** P1 legt acht Ansichts-Stubs (`ansicht-website|funktionen|chat|vertraege|passwort|gutschrift|wechsel|bestellen.js`) und drei CSS-Stubs (`ansichten.css`, `funktionen.css`, `module.css`) an und verlinkt die CSS-Dateien statisch in `dashboard.html` (P3 VF9, P4 VF5). Danach gehören die Dateien P2, P3 und P4 (gleicher Strang, kein Merge-Konflikt). Export-Namen: `zeige` + Dateiname groß; die Hülle ruft die vier Bereiche und die Etagen `projekt` (über `zeigeWebsite`), `passwort`, `gutschrift`, `wechsel`, `bestellen` mit `{ route, me, nav }` (P2 VF2 exportiert diese Funktionen alle). `zeigeVertraege` rendert die Reiter `rechnungen|abo|stunden|email` selbst. Neu in der Zuständigkeitstabelle: `public/neu/manifest.webmanifest` und `test/support/knHuelle.js` (beide P1; die Testhilfe dürfen P2 bis P4 benutzen).
**Bausteinklassen:** `basis.css` führt die Namen aus P2 VF5 (`kn-knopf`, `-primaer`, `-zweit`, `kn-textknopf`, `kn-pille-<farbe>`, `kn-feld`, `kn-liste`, `kn-zeile`, `kn-abschnitt`, `kn-meldung`, `kn-fehler`, `kn-leer`). Der schwarze Balken der Leiste heißt deshalb `.kn-gleiter`, nicht `.kn-pille`.
**Chat-Composer:** `/chat-composer.js` und `/chat-composer.css` (P2 VF9) lädt P1 noch nicht. `chat-composer.css` enthält dreimal das Lila (`#9d3dfe`), und die Prüfung `lila` von P0 liest jedes geladene Stylesheet als Text; die Zeilen würden das Gate `p1` kippen. P2 trägt beide Zeilen in `dashboard.html` ein (der Test in Task 12 erlaubt sie) und löst das Lila (Vorschlag: eine bereinigte Kopie unter `public/neu/` oder eine begründete Ausnahme in `lila`, P0).
4. **V2 ergänzen (Routen).** Route-Felder `produkt` (`'hosting'|'wartung'`) und `scroll` (`'aktivitaet'`), `query` immer als String. Zusätzliche Exporte von `routen.js`: `hashFuerAnsicht(name)` (die `data-view`-Brücke, auf die die gemeinsamen Module angewiesen sind: `data-view="chat"`, `"vertraege"` und die Funktionsnamen), `startHash`, `ankerKette`, `elternVon`, `titelFuer`, `FUNKTIONS_ETAGEN`. Ein `data-project` an einem `[data-view]`-Element wird zu `?projekt=<id>`. Das entspricht P3 VF2 und VF3.
**Änderung am „Pflichtinhalt von ROUTEN“ (V2 der Übersicht muss nachgezogen werden, Befund 2):** Die Zeile `vertraege`, `abo`, `wartung` → vertraege, reiter `abo` entfällt. Neu: `vertraege` → vertraege **ohne Reiter** (die Ansicht wählt den Standardreiter); `abo` und `wartung` → vertraege, reiter `abo`, kanonischer Hash `#abo` (`#wartung` wird `#abo`, `#vertraege` bleibt `#vertraege`). Grund: Fielen `#abo` und `#vertraege` zusammen, könnte P2 `waehleReiter` (nur `#vertraege` darf bei offener Rechnung auf Rechnungen springen, Reiterklicks und `#abo`/`#wartung` nie) den ausdrücklichen Reiter Abo nicht vom Standardsprung unterscheiden. `ankerKette` und `elternVon` liefern weiter `#vertraege` als Bereichs-Anker. `?abo=success|cancel` führt zu `#abo` (P2 zeigt die Rückkehr von Stripe auf dem Reiter Abo und räumt `?abo=` dort auf). Für P2: Auf `#vertraege` ist `route.reiter` jetzt nicht gesetzt, `waehleReiter` fällt dann wie bei einem nicht sichtbaren Reiter auf `abo` zurück.
5. **Reiter und Etage (P2 VF3, P3 VF1/VF2, P4 VF4).** Drei Pläne nehmen drei Formen von `baueReiter` an. P1 folgt P3: `baueReiter({ eintraege: [{ key, label, kurz? }], aktiv, art, ariaLabel, bei(key) })` liefert ein `HTMLElement` mit den Methoden `setzeAktiv`, `setzeAnzeige`, `messen`; P2 passt `vertraegeReiter()` an. Inhalte in Etage 2 melden Reiter und Titel ohne Import: `kn:reiter` mit `detail { eintraege, aktiv, bei }` (leere Liste = keine Reiter) und `kn:titel` mit `detail { text }`, jeweils `bubbles: true` aus einem Nachfahren des Blatts; das alte Dashboard ignoriert sie. P4 VF4 will die Modul-Reiter dagegen im Modul lassen (Unterstrich oben). Spec 5.2, V3 und C2 verlangen sie in Etage 2. Vorschlag: Spec gilt, P4 sendet `kn:reiter` aus den Modulen (nur im Abo-Modell) und bekommt dafür einen Folge-Task.
6. **Zähler und „Rechnung offen“ (P2 VF6/VF7, P3 VF8).** P1 zeichnet die Zeile aus Spec 5.3 nicht. P2 hat dafür `public/neu/rechnung-offen.js` (`ladeOffeneRechnungen`, `rechnungOffenZeile`), P3 soll sie von dort übernehmen; P3 VF8 erwartet sie von P1. Vorschlag: P2 liefert, P3 importiert. Bleibt P1 gewünscht, braucht P1 einen Folge-Task (`zaehler.stand().rechnungen`, zwei Zeilen im Gerüst). Zähler laden neu über `document.dispatchEvent(new CustomEvent('kn:zaehler-neu', { detail: { was: 'chat' | 'vertraege' | 'alle' } }))` (P2 VF6, am `document`, nicht am Fenster, weil `CustomEvent` nicht aufsteigt). Den Chat-Zähler setzt die Hülle selbst auf 0, wenn der Chat geöffnet wird.
7. **`?abo=success|cancel` (P2 VF4d).** Die Hülle öffnet dann Verträge auf dem Reiter Abo (`#abo`, Frage 4), rührt `location.search` am Zieleintrag aber nicht an. P2 liest den Parameter in `ansicht-vertraege.js`, wartet bis zu 20 s auf den Webhook (heute `stripeBilling.js:637`, `app.js:1557`) und räumt ihn danach mit `history.replaceState(history.state, '', location.pathname + location.hash)` auf (der Verlaufsstempel bleibt dabei erhalten).
8. **`api()`-Option `roh`.** Gemeint ist ein Rohdaten-Body (octet-stream) wie bei `fn-util.js:request(raw)` und `shop-tab-artikel.js:68`, nicht eine rohe Antwort. Bitte bestätigen.
9. **Funktionsmodule und der Verlaufsstempel.** `termine.js`, `shop.js`, `zeiten.js` und `funktionen.js` (News) schreiben nach dem Lesen der Rückkehr-Query `history.replaceState(null, …)`. P1 fängt das mit `stempeln()` ab. P4 darf diese Aufrufe nicht entfernen oder verändern, sonst gilt Task 4 („Stempel“) nicht mehr.
10. **Manifest und Service Worker.** `public/neu/manifest.webmanifest` hat dieselbe `id`, `start_url` und `scope` wie die alte, damit die installierte App dieselbe bleibt. `sw.js` bleibt gemeinsam und liefert bis P10 die alte, dunkle Offline-Seite.
11. **Überschriften (P2 VF3).** P2 will, dass jede Ansicht eine eigene `h1.kn-seitentitel` rendert und P1 sie beobachtet. P1 zeichnet die große Überschrift selbst (`h1.kn-titel` je Bereich in `dashboard.html`, Titel der Etage aus `titelFuer(route)`, ersetzbar per `kn:titel`) und steuert damit das Einklappen in den Kopf. Vorschlag: Ansichten rendern keine eigene `h1`, sonst entstehen zwei.
12. **`einschalten` für die Funktionsmodule (P4 VF1, P3 VF1/VF5, Befund 1 und 3).** Entschieden und in Task 11 umgesetzt: Die Hülle ruft `initNews`, `initTermine`, `initShop`, `initBuchungen` und `initZeiten` als `init*(wurzel, { einschalten })` mit `einschalten = (platz, o) => lade('/neu/funktion-einschalten.js').then((m) => m.zeigeEinschalten(platz, o))` (lazy über den Lader, `MIT_EINSCHALTEN`); `initAnalyse(wurzel)` bekommt keine Option. `projectId` übergibt die Hülle nicht, die Projektwahl bleibt `wurzel.dataset.wunschProjekt` (V2). Ohne die Option zeigen die Module im Abo-Modell nur die Fehlerkarte E4, obwohl die Tests von P3 und P4 grün sind, weil sie `einschalten` einspritzen. **V2 der Übersicht muss nachgezogen werden:** Im Absatz „Funktionsmodule“ (Zeile „werden von der neuen Hülle wie heute aufgerufen“) ergänzen: `initNews|initTermine|initShop|initBuchungen|initZeiten(wurzel, { einschalten })`, `einschalten(platz, { key, projectId, onFertig, viewAs? }) → Promise` ruft `zeigeEinschalten` aus `public/neu/funktion-einschalten.js` (P3); `initAnalyse(wurzel)` unverändert. Die Verdrahtung pinnt `test/knHuelleStart.test.js` („Option einschalten“); P4 Task 15 ersetzt seine Zeile `command grep -a -n einschalten public/neu/huelle.js` durch den Lauf dieses Tests.