120 KiB
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-p0genau diese Liste. Niepublic/app.js, die altepublic/dashboard.htmloderpublic/*.cssanfassen. public/neu/*.js: relative Importe, kein DOM-Zugriff beim Import (Module müssen in Node importierbar sein), keininnerHTML/insertAdjacentHTML/outerHTML; Kundentext nur überh()odertextContent;inertnur übersetAttribute/removeAttribute(fakeDom kennt die Eigenschaft nicht). Absolute Importe gibt es nur alsimport()der gemeinsamen Funktionsmodule (und von/neu/funktion-einschalten.js, Task 11) inhuelle.js, über einen einspritzbaren Lader.- Kein Wort
admin.html,kalkulation.html,/api/portal-adminin neuen Dateien (test/adminPanelGone.test.js:59liestpublic/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 (notgilt in einer Abfrageliste nur für das erste Glied). In JS steht sie genau einmal alsHANDY_ABFRAGE(leiste.js). - Bewegung: Federn
cubic-bezier(.3,1.25,.4,1)550 ms (Balken), Blatt und Reelcubic-bezier(.2,.8,.2,1)550 ms. Unterprefers-reduced-motion: reducehö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 mitimport { test } from 'node:test',import assert from 'node:assert/strict',installFakeDomaus./support/fakeDom.js(bei DOM-Tests),haus../public/neu/dom.jsund den Importen der getesteten Module. Die Hilfenlies,da,handyTeil,HANDYkommen austest/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 giltKN_STRANG_ENV=/root/reports/kundenansicht-neu-2026-10-15/werkzeug/tmp/strang-ui.envmit der Zeileexport 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
- 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=successvor dem Hash: Query bleibt Zeichen für Zeichen, Ziel stimmt, die Alias-Umschreibung löscht nichts,?abo=bleibt inlocation.search, bis P2 es aufräumt.#abound#wartungergeben den ausdrücklichen Reiter und den Hash#abo, nie#vertraege: Nur#vertraegelässt die Ansicht den Standardreiter wählen (P2waehleReiterunterscheidet am rohen Hash), sonst springt ein Kunde mit offener Rechnung vom Reiter Abo zurück auf Rechnungen und erreicht Abo nie. Module löschen mitreplaceState(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“). - 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“).
- 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).
- 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“).
- Start bei kaputtem oder fremdem Zustand.
/api/auth/me500 oder Netz weg, abgelaufene Kundenansicht, geändertes Passwort;/api/fristen404 (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-SheetpushState, 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 (Demoback()). - Das Konto-Sheet ist ein Menü (Tipp aufs Dunkle schließt). Formular-Dialoge
.kn-dialogschließ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ürblack-translucentund entfällt. Gerätecheck in Task 14. - Fristdaten werden in
Europe/Berlinformatiert (wieapp.js:816). Die Hülle ruft die Ansicht bei jedemzeigeneu auf (wieviewRefreshers,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-reitermit.kn-reiter-t(Unterstrich),.kn-pillemit-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)(liestpublic/neu/<datei>als Text),da(datei) -> boolean,handyTeil(css)(Text ab dem erstenHANDY, 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.jsnach obiger Signatur, dann)
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.cssundbasis.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
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 },truesetzt ein Attribut ohne Wert,false/null/undefinedentfallen, 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.rohist ein Rohdaten-Body, der unverändert mitContent-Type: application/octet-streamgeht (wiefn-util.js:request, Vertragsfrage 8).class ApiFehler extends Errormitstatus,code,message,field,body(immer ein Objekt).
-
Step 1: Write the failing tests
test/knHuelleDomApi.test.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:
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.jsandapi.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
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:
vertraegeohne Reiter,aboundwartungmit Reiterabo):BEREICHE,ROUTEN,routeAusHash(hash) -> Route,hashFuerRoute(route) -> string.Route = { bereich, reiter?, etage?, produkt?, scroll?, query };queryist immer ein String ohne?, nicht gesetzte Felder fehlen.produkt('hosting'|'wartung') undscroll('aktivitaet') sind die Zusatzfelder zu V2 „plus Produkt“ und „scrollt zur Aktivität“ (Vertragsfrage 4). -
Zusätzlich:
hashFuerAnsicht(name) -> '#…' | null(alterdata-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
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
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,elternVonaus./routen.js. -
Produces (V2):
starteRouter({ zeige, umgebung = fenster() }) -> { beende() };zeige(route, { nav }),navist'tab'|'push'|'back'|'same';umgebung = { location, history, addEventListener, removeEventListener }, Vorgabewindow.geheZu(hash, { ersetzen = false } = {}): schreibt den Verlauf und ruft danach selbstzeige.- Zusätzlich
oeffneOverlay({ schliesse }),beendeOverlay(),zurueck()(Zurück der Etage),stempeln(),aktuelleRoute().
-
Regeln (Stempel
history.state = { kn: { tiefe, overlay } }; URL der Website istpathname + searchohne#):- R1 Bereichs- und Reiterwechsel:
replaceState,nav 'tab'. Geschrieben wird immerhashFuerRoute(route)(kanonisch:#wartungwird#abo;#abound#vertraegesind verschiedene Routen, ein Wechsel von#vertraegeauf#aboschreibt#abomitnav 'tab'und nie#vertraege). Gleiche Route: nichts schreiben,zeigemit'same'. - R2 von Ebene 1 in eine Etage (und von Etage zu Etage):
pushState,nav 'push'.ersetzen: trueerzwingtreplaceState(nav bleibt). - R3
popstate: Ebene höher →'push', sonst'back'. - R4 Start:
startHashanwenden,zeigeeinmal mit'tab'.location.searchbleibt am Zieleintrag unberührt (P2 liest?abo=dort, Vertragsfrage 7), die Anker-Einträge davor tragensearchohne den Parameterabo. Fehlt der Stempel, legt der Router dieankerKettean (erster EintragreplaceState, die übrigenpushState); mit Stempel passiert nichts. - R5
zurueck():tiefe > 0→history.back(), sonstgeheZu(Eltern, { ersetzen: true })mit'back'. - R6 Overlay:
oeffneOverlayschiebt einen Eintrag mitoverlay: true;popstatedarauf ruftschliesse()und keinzeige.geheZubei offenem Overlay ersetzt den Overlay-Eintrag und ruftschliesse()genau einmal. Ein Overlay-Stempel beim Start wird gelöscht. - R7
stempeln()schreibt den Stempel nach einemreplaceState(null, …)eines Moduls neu.
- R1 Bereichs- und Reiterwechsel:
-
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], …].
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
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:
haus./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 MethodensetzeAktiv(key),setzeAnzeige(key, { zahl = 0, ausruf = false }),messen().art 'balken'baut den Gleiter (schwarzer Balken) mit Overlay-Kopie nachfinal-2-frei.html(.kn-balken > .kn-overlay, Overlayaria-hidden, ohne Rollen),art 'unterstrich'die Reiter vonbasis.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
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
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:
haus./dom.js;baueReiter,sichtbarMachenaus./reiter.js. -
Produces:
baueEtage({ blatt, haupt, zeile, titel, brot, reiterSlot, istHandy, onZurueck }) -> Etage(alles Elemente ausdashboard.html,istHandy: () => boolean).Etage = { oeffne({ titel, eltern: { label, hash }, seite }), schliesse(), setzeReiter({ eintraege, aktiv, bei }), neuZeichnen(), setzeChatPunkt(an), istOffen() }.- Ereignisse (Vertragsfrage 5):
blatthört aufkn:reiter(detail: { eintraege: [{ key, label, kurz? }], aktiv, bei }, leere Liste = keine Reiter) undkn:titel(detail: { text }); beide steigen aus jedem Nachfahren auf.
-
Step 1: Write the failing test
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.jsund den Abschnitt „Blatt“ inhuelle.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
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 (
apiwird übergeben). -
Produces:
berechneVertraege({ rechnungen = [], rueckfragen = [], paymentProblem = false }) -> { zahl, ausruf }:zahl= Rechnungen mitNumber(balance) > 0plus Rückfragen mitstatus === '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() },wasist'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
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
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:
haus./dom.js. -
Produces:
async ladeFristen(api) -> { fristen, fruehestesEnde, anzeigen } | null: ruftapi('/api/fristen'); Fehler jeder Art (404, 500, Status 0) undanzeigen !== trueergebennull.streifenText(iso) -> 'Widerrufsfrist bis TT.MM.'(Europe/Berlin);fruehesteFrist(daten) -> iso | ''(fruehestesEnde, sonst das kleinstebis).baueStreifen(wurzel, { koerper = document.body } = {}) -> { setze(daten | null) }: fülltwurzel(hiddenbeinull) und setztkn-hat-streifenamkoerper.
-
Step 1: Write the failing test
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.jsund den Abschnitt „Streifen“ inhuelle.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
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:
haus./dom.js;oeffneOverlay,beendeOverlay,geheZuaus./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 mitinstall),kuendigen,widerrufen,abmelden.baueAvatar({ name, kundenId }) -> HTMLButtonElement(aria-label="Mein Konto", Initialen, Bild von/api/avatar.svg?c=<kundenId>erst nachload).baueKonto({ me, fristen, install, onAbmelden, onInstall, overlay = { oeffne: oeffneOverlay, beende: beendeOverlay }, navigiere = geheZu }) -> { wurzel, oeffne(ausloeser), schliesse(), istOffen(), aktualisiere({ fristen, install }) };aktualisierebaut die Zeilen neu und behält den offenen Zustand.
-
Step 1: Write the failing test
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.jsund den Abschnitt „Konto“ inhuelle.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
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:
haus./dom.js;baueReiter,anzeigeTextaus./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) }:hauptist die Hauptzeile der Leiste (#kn-zeile-haupt),menuedas Desktop-Menü (#kn-menue).setzeEtagesetztdata-etage="offen"am Elternelement vonhaupt(dem Reel) undinertanhaupt.tastaturZustand(alt, ereignis) -> { voll, breite, offen, tippt }mitereignis{ art:'vv', hoehe, breite, textfeldAktiv?, grob? },{ art:'fokus', textfeld, grob },{ art:'unfokus', textfeldAktiv };leisteSichtbar(zustand) -> boolean;beobachteTastatur({ win, doc, onChange })(Verdrahtung mitvisualViewport,focusin,focusout; ersetztapp.js:2475-2494undTEXT_INPUT,app.js:2137).
-
Step 1: Write the failing test
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.jsund die Abschnitte inhuelle.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, sonstdisplay: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, Reeloverflow:hidden;[data-etage="offen"]schiebt das ReeltranslateY(-56px)in550ms 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-klein14 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-fbblendet 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 mscubic-bezier(.3,1.5,.5,1)vonscale(0);.kn-zahl--wegscale(0)in 250 ms. -
Menü
.kn-menue(Grundregel): 232 px,border-right, Einträgepadding: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.mdAbschnitt 2: 36/48/80, unter 1100 px 32/28/80). UnterHANDY16 px Seitenrand und Fußabstandcalc(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-titledein; Rand--kn-linebei.kn-scrolled; Avatar-Fläche 44 × 44 px (Kreis 32 px). -
.kn-tastaturblendet Leiste und Streifen aus. Querformat (HANDYmitmax-height:500px): Zeile 44 px,.kn-haupt .kn-titelweg. Unterprefers-reduced-motion: reducealle Dauern höchstens 150 ms, Blatt und Seiten nuropacity. -
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
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'] }(Dateiansicht-<name>.js, Funktionzeige+ 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 ausapp.js:1938-1945).MIT_EINSCHALTEN = ['news', 'termine', 'webshop', 'buchungen', 'zeiten'](die Etagen, deren Modul die Init-Optioneinschaltenbekommt;analysenicht, sie nutztPOST /api/funktionen/analyse/activate) undeinschaltenOption(lade) -> (platz, o) => Promise, daslade('/neu/funktion-einschalten.js')erst beim Aufruf startet und dannm.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-ansagenachziehen; beivertraegezaehler.aktualisiere('vertraege'); bei Chatzaehler.setzeChat(0). (2) Steht die Wurzel leer da, den Text „Wird geladen …“ (p.kn-leer) hineinschreiben. (3) Ansicht ausANSICHTEN/ETAGE_ANSICHTENüberladeholen undzeigeX(wurzel, { route, me, nav })rufen (V2); Funktionsetagen:wurzel.dataset.wunschProjektaus dem Parameterprojektder Query setzen (falls vorhanden),init*(wurzel, { einschalten: einschaltenOption(lade) })rufen (nur für die Etagen ausMIT_EINSCHALTEN;initAnalyse(wurzel)ohne zweites Argument) und danachrouter.stempeln(). Die Hülle übergibt keinprojectId, die Projektwahl bleibtdataset.wunschProjekt(V2). (4) Ladefehler: „Konnte nicht geladen werden.“ alsp.kn-fehlerin die Wurzel. (5) Scroll nachscrollZiel. -
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.
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
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(HilfsfunktionistNeu) - 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_ANSICHTENaus./huelle.js. -
Produces: die Seite, die
GET /dashboard.htmlim Pilot ausliefert (Marker<meta name="kn-seite" content="neu">,<body class="kn">); acht Stubs mit jeexport async function zeigeX(wurzel, { route, me, nav }), die nur einen Satz „ (folgt)“ nachwurzelschreiben; drei CSS-Stubs mit einem Kommentar zum künftigen Besitzer. -
Step 1: Write the failing test (und
istNeuintest/kundenansichtDashboard.test.jsaufString(r.body).includes('<meta name="kn-seite" content="neu">')stattdata-kn-platzhalterumstellen;<body class="kn">bleibt)
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
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ürzungKW):views.mjs(Ansichten von P1, die zwei Platzhalter-Ansichten),gates.mjs(Gatep1),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 bis2026-10-25),rechnung-offen(RE-2026-0042, 50 €),chat-ungelesen(3),mitarbeiter,abgemeldet,fehler(500 fürabo,invoices,funktionen);GERAETE(handy390×844,handy-quer844×390,tablet820×1180,desktop1440×900); Ansichtsfelder{ id, fall, adresse, geraete, warte, schritte(seite), bewegung?, ignorieren? };GATES.p<N> = { checks, ansichten }mitALWAYS_ON; Prüfungendark-surface,low-contrast,overflow-x,lila; StandardreducedMotion: 'reduce'(mitbewegung: truevolle Bewegung).schritte(seite)wirft bei Verstoß (wird zustep-failed). -
Produces: Gate
p1 = { checks: [...ALWAYS_ON, 'dark-surface', 'low-contrast', 'overflow-x', 'lila'], ansichten: ['kn-huelle-*'] }grün; die Aufnahmen unterKW/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):
{ 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
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:
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
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.
- Platzhalter und Weichen-Test (P0 ↔ P1). P0 pinnt den Platzhalter über
data-kn-platzhalter(test/kundenansichtDashboard.test.js,istNeu; Werkzeugtest/statik.test.mjs). P1 ersetzt die Seite und stelltistNeuin 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 Ansichtenplatzhalterundplatzhalter-abgemeldetweiter (nurwartewird angepasst), sonst brichttest/gates.test.mjsvon P0. - Werkzeug. P1 nutzt die Form aus P0 (Ansichten, Gate
p1,KN_STRANG_ENVmitWT=…/dev-kn-ui,shots.sh <ausgabe> --gate p1). P1 ändert im Werkzeug nurviews.mjs,gates.mjsund den Marker intest/statik.test.mjs, keine Fälle. Zwei Folgen aus P0: Die Prüfungdark-surfacemeldet den roten Zähler (16 px,#b3093c), deshalbignorieren: { 'dark-surface': ['.kn-zahl'] }in den Ansichten mit Zähler. UndpaymentProblem/switchPendingkommen in keinem Fall vor, deshalb überschreibt der Step die Antwort von/api/abomitseite.route. - 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 indashboard.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 Etagenprojekt(überzeigeWebsite),passwort,gutschrift,wechsel,bestellenmit{ route, me, nav }(P2 VF2 exportiert diese Funktionen alle).zeigeVertraegerendert die Reiterrechnungen|abo|stunden|emailselbst. Neu in der Zuständigkeitstabelle:public/neu/manifest.webmanifestundtest/support/knHuelle.js(beide P1; die Testhilfe dürfen P2 bis P4 benutzen). Bausteinklassen:basis.cssfü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.jsund/chat-composer.css(P2 VF9) lädt P1 noch nicht.chat-composer.cssenthält dreimal das Lila (#9d3dfe), und die Prüfunglilavon P0 liest jedes geladene Stylesheet als Text; die Zeilen würden das Gatep1kippen. P2 trägt beide Zeilen indashboard.htmlein (der Test in Task 12 erlaubt sie) und löst das Lila (Vorschlag: eine bereinigte Kopie unterpublic/neu/oder eine begründete Ausnahme inlila, P0). - V2 ergänzen (Routen). Route-Felder
produkt('hosting'|'wartung') undscroll('aktivitaet'),queryimmer als String. Zusätzliche Exporte vonrouten.js:hashFuerAnsicht(name)(diedata-view-Brücke, auf die die gemeinsamen Module angewiesen sind:data-view="chat","vertraege"und die Funktionsnamen),startHash,ankerKette,elternVon,titelFuer,FUNKTIONS_ETAGEN. Eindata-projectan 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 Zeilevertraege,abo,wartung→ vertraege, reiteraboentfällt. Neu:vertraege→ vertraege ohne Reiter (die Ansicht wählt den Standardreiter);aboundwartung→ vertraege, reiterabo, kanonischer Hash#abo(#wartungwird#abo,#vertraegebleibt#vertraege). Grund: Fielen#abound#vertraegezusammen, könnte P2waehleReiter(nur#vertraegedarf bei offener Rechnung auf Rechnungen springen, Reiterklicks und#abo/#wartungnie) den ausdrücklichen Reiter Abo nicht vom Standardsprung unterscheiden.ankerKetteundelternVonliefern weiter#vertraegeals Bereichs-Anker.?abo=success|cancelführt zu#abo(P2 zeigt die Rückkehr von Stripe auf dem Reiter Abo und räumt?abo=dort auf). Für P2: Auf#vertraegeistroute.reiterjetzt nicht gesetzt,waehleReiterfällt dann wie bei einem nicht sichtbaren Reiter aufabozurück. - Reiter und Etage (P2 VF3, P3 VF1/VF2, P4 VF4). Drei Pläne nehmen drei Formen von
baueReiteran. P1 folgt P3:baueReiter({ eintraege: [{ key, label, kurz? }], aktiv, art, ariaLabel, bei(key) })liefert einHTMLElementmit den MethodensetzeAktiv,setzeAnzeige,messen; P2 passtvertraegeReiter()an. Inhalte in Etage 2 melden Reiter und Titel ohne Import:kn:reitermitdetail { eintraege, aktiv, bei }(leere Liste = keine Reiter) undkn:titelmitdetail { text }, jeweilsbubbles: trueaus 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 sendetkn:reiteraus den Modulen (nur im Abo-Modell) und bekommt dafür einen Folge-Task. - 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 überdocument.dispatchEvent(new CustomEvent('kn:zaehler-neu', { detail: { was: 'chat' | 'vertraege' | 'alle' } }))(P2 VF6, amdocument, nicht am Fenster, weilCustomEventnicht aufsteigt). Den Chat-Zähler setzt die Hülle selbst auf 0, wenn der Chat geöffnet wird. ?abo=success|cancel(P2 VF4d). Die Hülle öffnet dann Verträge auf dem Reiter Abo (#abo, Frage 4), rührtlocation.searcham Zieleintrag aber nicht an. P2 liest den Parameter inansicht-vertraege.js, wartet bis zu 20 s auf den Webhook (heutestripeBilling.js:637,app.js:1557) und räumt ihn danach mithistory.replaceState(history.state, '', location.pathname + location.hash)auf (der Verlaufsstempel bleibt dabei erhalten).api()-Optionroh. Gemeint ist ein Rohdaten-Body (octet-stream) wie beifn-util.js:request(raw)undshop-tab-artikel.js:68, nicht eine rohe Antwort. Bitte bestätigen.- Funktionsmodule und der Verlaufsstempel.
termine.js,shop.js,zeiten.jsundfunktionen.js(News) schreiben nach dem Lesen der Rückkehr-Queryhistory.replaceState(null, …). P1 fängt das mitstempeln()ab. P4 darf diese Aufrufe nicht entfernen oder verändern, sonst gilt Task 4 („Stempel“) nicht mehr. - Manifest und Service Worker.
public/neu/manifest.webmanifesthat dieselbeid,start_urlundscopewie die alte, damit die installierte App dieselbe bleibt.sw.jsbleibt gemeinsam und liefert bis P10 die alte, dunkle Offline-Seite. - Überschriften (P2 VF3). P2 will, dass jede Ansicht eine eigene
h1.kn-seitentitelrendert und P1 sie beobachtet. P1 zeichnet die große Überschrift selbst (h1.kn-titelje Bereich indashboard.html, Titel der Etage austitelFuer(route), ersetzbar perkn:titel) und steuert damit das Einklappen in den Kopf. Vorschlag: Ansichten rendern keine eigeneh1, sonst entstehen zwei. einschaltenfür die Funktionsmodule (P4 VF1, P3 VF1/VF5, Befund 1 und 3). Entschieden und in Task 11 umgesetzt: Die Hülle ruftinitNews,initTermine,initShop,initBuchungenundinitZeitenalsinit*(wurzel, { einschalten })miteinschalten = (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 bleibtwurzel.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 sieeinschalteneinspritzen. 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? }) → PromiseruftzeigeEinschaltenauspublic/neu/funktion-einschalten.js(P3);initAnalyse(wurzel)unverändert. Die Verdrahtung pinnttest/knHuelleStart.test.js(„Option einschalten“); P4 Task 15 ersetzt seine Zeilecommand grep -a -n einschalten public/neu/huelle.jsdurch den Lauf dieses Tests.