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

120 KiB
Raw Permalink Blame History

Kundenansicht neu, P1 Fundament und Hülle: Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Übersicht/Verträge: /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht/docs/superpowers/plans/2026-10-16-kundenansicht-00-uebersicht.md (gilt wörtlich)

Worktree und Branch: /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui, Branch kn/ui (aus Tag kn-p0). Tag nach Abschluss: kn-p1. Das Anlegen des Worktrees steht in Task 1, Step 0.

Goal: Die weiße Hülle der neuen Kundenansicht steht und ist mit Testdaten sichtbar: Tokens und Basis-CSS, Routen und Router, Fahrstuhl-Zeile (Handy) und Menü (Desktop), Konto, Widerrufsstreifen, Zähler, Etage 2, Start über /api/auth/me und die Seite public/neu/dashboard.html mit leeren Ansichten für P2 und P3. V2 und V3 der Übersicht sind damit vollständig erfüllt.

Architecture: Reine Logik (Routen, History-Regeln, Zähler, Zahlenfunktionen der Leiste, Ableitung aus /api/auth/me) steht in Modulen ohne DOM-Zugriff beim Import und wird mit node --test geprüft. DOM-Teile bauen nur mit h() und textContent und werden mit test/support/fakeDom.js geprüft. Geometrie, Animation und Tastatur prüft allein die Sichtprüfung über das Werkzeug aus P0 (Task 13). Jedes Modul bekommt seine Abhängigkeiten (api, Fenster, Zeitgeber, Lader) als Parameter mit Vorgabe, damit kein Test einen Browser braucht.

Tech Stack: Vanilla ES-Module unter public/neu/ (relative Importe, kein Build), CSS ohne Präprozessor, node --test mit fakeDom, Playwright nur über das Werkzeug aus P0.

Spec: docs/superpowers/specs/2026-10-15-kundenansicht-neu-design.md, Abschnitte 5.1 bis 5.3, 7 (C2, C3). Demo: /root/reports/kundenansicht-neu-2026-10-15/handy-leiste/final-2-frei.html. Code-Karte: karten/huelle-tests.md.

Global Constraints

Alle Global Constraints der Übersicht gelten wörtlich (Aussehen, „du“, keine Preise für Funktionen, Hashes, rechtlich fest, Tests, Commits, Sicherheitsblock). Paketeigene Zusätze:

  • Nur Dateien aus der Dateiübersicht anlegen oder ändern; am Ende zeigt git diff --name-only kn-p0 genau diese Liste. Nie public/app.js, die alte public/dashboard.html oder public/*.css anfassen.
  • public/neu/*.js: relative Importe, kein DOM-Zugriff beim Import (Module müssen in Node importierbar sein), kein innerHTML/insertAdjacentHTML/outerHTML; Kundentext nur über h() oder textContent; inert nur über setAttribute/removeAttribute (fakeDom kennt die Eigenschaft nicht). Absolute Importe gibt es nur als import() der gemeinsamen Funktionsmodule (und von /neu/funktion-einschalten.js, Task 11) in huelle.js, über einen einspritzbaren Lader.
  • Kein Wort admin.html, kalkulation.html, /api/portal-admin in neuen Dateien (test/adminPanelGone.test.js:59 liest public/ rekursiv).
  • CSS: Die Grundregeln gelten am Desktop, die Handy-Abfrage (max-width: 880px), (pointer: coarse) and (max-height: 500px) überschreibt für das Handy. Es gibt keine gegenläufige zweite Abfrage (not gilt in einer Abfrageliste nur für das erste Glied). In JS steht sie genau einmal als HANDY_ABFRAGE (leiste.js).
  • Bewegung: Federn cubic-bezier(.3,1.25,.4,1) 550 ms (Balken), Blatt und Reel cubic-bezier(.2,.8,.2,1) 550 ms. Unter prefers-reduced-motion: reduce höchstens 150 ms Überblenden. Knöpfe und Felder am Handy mindestens 44 px hoch, Textfelder 16 px.
  • Tests: Dateien heißen test/knHuelle<Name>.test.js. Jede beginnt mit import { test } from 'node:test', import assert from 'node:assert/strict', installFakeDom aus ./support/fakeDom.js (bei DOM-Tests), h aus ../public/neu/dom.js und den Importen der getesteten Module. Die Hilfen lies, da, handyTeil, HANDY kommen aus test/support/knHuelle.js (Task 1). Jede Datei läuft einzeln mit dem Befehl der Übersicht.
  • Werkzeug: Es liest den Quellbaum aus WT. Für diesen Strang gilt KN_STRANG_ENV=/root/reports/kundenansicht-neu-2026-10-15/werkzeug/tmp/strang-ui.env mit der Zeile export WT=/home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui (Task 13, Step 1).
  • Commit-Trailer wörtlich Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> als zweites -m; Dateien einzeln hinzufügen.

Review Focus

  1. Alte Links verlieren ihre Query oder landen falsch. #termine?bestellt=1&projekt=x, #webshop?stripe=zurueck&projekt=x, #news?bestellt=1, #fortschritt, #wartung, #abo, ?abo=success vor dem Hash: Query bleibt Zeichen für Zeichen, Ziel stimmt, die Alias-Umschreibung löscht nichts, ?abo= bleibt in location.search, bis P2 es aufräumt. #abo und #wartung ergeben den ausdrücklichen Reiter und den Hash #abo, nie #vertraege: Nur #vertraege lässt die Ansicht den Standardreiter wählen (P2 waehleReiter unterscheidet am rohen Hash), sonst springt ein Kunde mit offener Rechnung vom Reiter Abo zurück auf Rechnungen und erreicht Abo nie. Module löschen mit replaceState(null, …) den Verlaufsstempel des Routers (termine.js:40, shop.js:59, zeiten.js:39, funktionen.js:487). Tests: Task 3 („Alte Links“, „Umkehrung“), Task 4 („Alias“, „Reiter und Bereiche“, „Stempel“), Task 11 („wunschProjekt“).
  2. Zurück-Taste und Android-Geste. Erst Sheet, dann Etage 2, dann Website; Einstieg per Link direkt in Etage 2 hat einen Eltern-Eintrag; Reiterwechsel erzeugt keine Einträge; Neuladen mit Overlay-Stempel öffnet nichts. Tests: Task 4, Task 9, Task 13 („kn-huelle-konto“).
  3. Schmale Handys und viele Reiter. 320 px breit: vier Wörter passen; „99+“ schiebt nichts hinaus; Etage 2 mit sechs Reitern scrollt waagerecht und rückt den aktiven Reiter ins Bild (C2); Overlay-Rückfall bei abweichenden Breiten. Tests: Task 5 (Zahlenfunktionen), Task 6 („kn:reiter“), Task 13 („kn-huelle-etage“ bei 320 px).
  4. Tastatur, Querformat, Safe-Area. Android schließt die Tastatur ohne Blur: Leiste kommt zurück. Drehen setzt die volle Höhe zurück. Leiste und Streifen verschwinden nur bei Texteingabe. Tests: Task 10 („tastaturZustand“), Task 13 („kn-huelle-frist“, „kn-huelle-quer“).
  5. Start bei kaputtem oder fremdem Zustand. /api/auth/me 500 oder Netz weg, abgelaufene Kundenansicht, geändertes Passwort; /api/fristen 404 (Server ohne P5) oder 500; Abo und Rechnungen fallen aus: nie eine leere weiße Seite, Zähler behalten den letzten Wert, „Vertrag widerrufen“ bleibt im Konto. Tests: Task 2 (ApiFehler), Task 7, 8, 11 („me 500“, „Fristen 404“), Task 13 („kn-huelle-quellen-fehler“).

Dateiübersicht

Datei Aufgabe Task
public/neu/tokens.css, basis.css Tokens --kn-*; Knöpfe, Felder, Unterstrich-Reiter, Pillen, Listen, Dialog/Sheet, Leerzustände, Meldungen 1
public/neu/huelle.css Blatt (6), Streifen (8), Konto (9), Leiste, Menü, Kopf, Querformat, reduzierte Bewegung (10) 6, 8, 9, 10
public/neu/dom.js, api.js h, leeren, text; api, ApiFehler 2
public/neu/routen.js, router.js Routentabelle; History-Regeln, Overlay, Stempel 3, 4
public/neu/reiter.js, etage.js gemeinsame Reiter; Etage 2 5, 6
public/neu/zaehler.js, fristen-streifen.js Zähler V3; Widerrufsstreifen 7, 8
public/neu/konto.js, leiste.js Avatar und Konto; Fahrstuhl-Zeile, Menü, Tastaturzustand 9, 10
public/neu/huelle.js Start, Ableitung aus /api/auth/me, Zusammenbau, Modul-Lader 11
public/neu/dashboard.html (ersetzt den Platzhalter aus P0), manifest.webmanifest Seite und PWA (weiß) 12
public/neu/ansicht-{website,funktionen,chat,vertraege,passwort,gutschrift,wechsel,bestellen}.js, ansichten.css, funktionen.css, module.css Stubs, die P2 bis P4 füllen (Vertragsfrage 3) 12
test/support/knHuelle.js Testhilfen 1
test/knHuelle{Css,DomApi,Routen,Router,Reiter,Etage,Zaehler,Fristen,Konto,Leiste,Start,Seite,Quelltext}.test.js Tests, je Task eine 1 bis 12, 14
Pin aus P0: test/kundenansichtDashboard.test.js, Hilfsfunktion istNeu (Marker data-kn-platzhalter) auf <meta name="kn-seite" content="neu"> umstellen, weil die Seite den Platzhalter ersetzt 12
Werkzeug (/root/reports/kundenansicht-neu-2026-10-15/werkzeug/, eigenes Git): views.mjs, gates.mjs, test/statik.test.mjs Ansichten kn-huelle-*, Gate p1, die zwei Platzhalter-Ansichten und der Marker im Statik-Test (Task 3 von P0 und Task 10 von P0) 13

Bestehende Tests: sonst keine Änderung nötig. Geprüft: test/termineView.test.js:397,418 und die Pins in shopView, shopWiring, zeitenView, zeitenBestellen lesen public/ ohne Unterordner; test/adminPanelGone.test.js:59 sucht rekursiv nur die verbotenen Wörter; test/auftraegeKundenportalHuelle.test.js:50 pinnt die alte public/manifest.webmanifest, die unberührt bleibt.

Festlegungen des Pakets (ohne Vertragswirkung):

  • Verlauf: Reiter- und Bereichswechsel replaceState; Etage 2 und Konto-Sheet pushState, in beiden Layouts (heute nur Handy, app.js:2226-2229). Bei Einstieg per Link legt der Router eine Anker-Kette an (Website, Eltern, Ziel), damit „Zurück“ bis zur Website führt (Demo back()).
  • Das Konto-Sheet ist ein Menü (Tipp aufs Dunkle schließt). Formular-Dialoge .kn-dialog schließen nur per × und Esc.
  • Desktop-Menü: aktiver Eintrag wie im Ticketsystem (#111, Gewicht 600, Fläche --kn-accent-soft).
  • Das Konto hat weder Ansprechpartner-Karte noch „Abo verwalten“ (Spec 5.2 nennt sie nicht).
  • iOS-Statusleiste default, theme-color #ffffff; der 1-px-Kniff der alten Seite (login.css:644-655) gilt nur für black-translucent und entfällt. Gerätecheck in Task 14.
  • Fristdaten werden in Europe/Berlin formatiert (wie app.js:816). Die Hülle ruft die Ansicht bei jedem zeige neu auf (wie viewRefreshers, app.js:1937).

Task 1: Tokens und Basis-CSS

Files:

  • Create: public/neu/tokens.css, public/neu/basis.css, test/support/knHuelle.js
  • Test: test/knHuelleCss.test.js

Interfaces:

  • Consumes: nichts.

  • Produces: --kn-* auf .kn (Namen im Test) und diese Klassen (die Namen, die P2 in seinem VF5 annimmt): .kn-knopf, .kn-knopf-primaer, .kn-knopf-zweit, .kn-textknopf, .kn-link-danger, .kn-feld (Beschriftung, Feld, Hinweis), .kn-input, .kn-select, .kn-textarea, .kn-check, .kn-reiter mit .kn-reiter-t (Unterstrich), .kn-pille mit -grau|-blau|-orange|-violett|-gruen|-rot|-schlicht (Status), .kn-liste, .kn-zeile (.kn-zeile-link), .kn-ueberlagerung, .kn-dialog, .kn-sheet, .kn-leer, .kn-meldung (-ok, -warn), .kn-fehler, .kn-titel, .kn-abschnitt.

  • Testhilfe test/support/knHuelle.js: HANDY ('@media (max-width: 880px), (pointer: coarse) and (max-height: 500px)'), lies(datei) (liest public/neu/<datei> als Text), da(datei) -> boolean, handyTeil(css) (Text ab dem ersten HANDY, sonst '').

  • Step 0: Vorbedingungen und Worktree

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && git tag --list kn-p0 && ls public/neu/dashboard.html Expected: kn-p0 und der Platzhalter. Fehlt eines, Abbruch und Controller melden (P0 nicht fertig). Existiert dev-kn-ui nicht: git -C /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht worktree add ../dev-kn-ui -b kn/ui kn-p0 && cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && ln -s ../build/node_modules node_modules; danach git status --short leer.

  • Step 1: Write the failing test (zuerst test/support/knHuelle.js nach obiger Signatur, dann)
import { lies, da, handyTeil } from './support/knHuelle.js'
const tokens = (css) => Object.fromEntries([...css.matchAll(/(--kn-[\w-]+)\s*:\s*([^;]+);/g)].map((m) => [m[1], m[2].trim()]))

test('tokens.css: Werte wörtlich aus der Übersicht und global.css:7-51', () => {
  const t = tokens(lies('tokens.css'))
  const soll = {
    '--kn-bg': '#fff', '--kn-bg-hover': '#f6f8fa', '--kn-bg-subtle': '#f8f9fb',
    '--kn-text': '#1a1f36', '--kn-text-2': '#5e6678', '--kn-text-3': '#8a93a3',
    '--kn-line': '#ebeef1', '--kn-line-strong': '#d8dde3', '--kn-accent': '#111',
    '--kn-grau-bg': '#ebeef1', '--kn-grau-fg': '#414552', '--kn-blau-bg': '#e0f2fe', '--kn-blau-fg': '#075985',
    '--kn-orange-bg': '#fcedb9', '--kn-orange-fg': '#a82c00', '--kn-violett-bg': '#efeafe', '--kn-violett-fg': '#5b21b6',
    '--kn-gruen-bg': '#d7f7c2', '--kn-gruen-fg': '#006908', '--kn-rot-bg': '#ffe7f2', '--kn-rot-fg': '#b3093c',
    '--kn-danger': '#c0123c', '--kn-radius': '6px', '--kn-radius-lg': '12px', '--kn-sidebar-w': '232px',
    '--kn-ring': '0 0 0 1px rgba(60,66,87,.16), 0 1px 1px rgba(0,0,0,.06)',
    '--kn-font': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
  }
  for (const [name, wert] of Object.entries(soll)) assert.equal(t[name], wert, name)
})
const VERBOTEN = [/gradient\(/i, /backdrop-filter/i, /filter:\s*blur/i, /prefers-color-scheme/i, /#9d3dfe|#d4b4ff|rgba\(157/i, /font-weight:\s*[89]00/, /dot-pattern|aurora/i]
test('keine verbotenen Muster (Verläufe, Glas, Dunkelmodus, Lila, Gewicht 800)', () => {
  for (const datei of ['tokens.css', 'basis.css', 'huelle.css'].filter(da)) for (const m of VERBOTEN) assert.doesNotMatch(lies(datei), m, `${datei} ${m}`)
})
test('basis.css: Bausteine, Maße des Ticketsystems, 44 px und 16 px am Handy', () => {
  const css = lies('basis.css')
  for (const k of ['.kn-knopf', '.kn-knopf-primaer', '.kn-knopf-zweit', '.kn-textknopf', '.kn-link-danger', '.kn-feld', '.kn-input', '.kn-select', '.kn-textarea', '.kn-check', '.kn-reiter-t', '.kn-pille-gruen', '.kn-liste', '.kn-zeile-link', '.kn-ueberlagerung', '.kn-dialog', '.kn-sheet', '.kn-leer', '.kn-meldung-warn', '.kn-fehler', '.kn-titel', '.kn-abschnitt']) assert.ok(css.includes(k), k)
  assert.match(css, /\.kn-pille\s*\{(?=[^}]*height:\s*20px)(?=[^}]*border-radius:\s*4px)/s)
  assert.match(css, /\.kn-titel\s*\{(?=[^}]*font-size:\s*28px)(?=[^}]*line-height:\s*34px)(?=[^}]*font-weight:\s*700)/s)
  assert.match(css, /:focus-visible\s*\{(?=[^}]*outline:\s*2px solid #111)(?=[^}]*outline-offset:\s*2px)/s)
  assert.match(css, /:disabled[^{]*\{[^}]*opacity:\s*\.55/s)
  assert.match(handyTeil(css), /min-height:\s*44px/); assert.match(handyTeil(css), /font-size:\s*16px/)
})
  • Step 2: Run test to verify it fails

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleCss.test.js Expected: FAIL (ENOENT tokens.css).

  • Step 3: Implement tokens.css und basis.css

tokens.css: alle Namen aus dem Test auf .kn, dazu --kn-accent-hover:#2b2b2b, --kn-accent-soft:rgba(17,17,17,.06), --kn-accent-ring:rgba(17,17,17,.14), --kn-danger-ring:rgba(192,18,60,.18), --kn-shadow-btn, --kn-shadow-pop (beide wörtlich aus woms-design.md Abschnitt 1), --kn-overlay:rgba(26,31,54,.32), --kn-sp-1…6 (4/8/12/16/24/32 px), --kn-feder:cubic-bezier(.3,1.25,.4,1), --kn-bremse:cubic-bezier(.2,.8,.2,1), --kn-zeile-h:56px, color-scheme: light. basis.css: Maße und Selektoren 1:1 aus /root/reports/kundenansicht-neu-2026-10-15/bestand/woms-design.md Abschnitt 2 und 4, Klassen mit Präfix kn- (.btn→.kn-knopf, .btn-primary→.kn-knopf-primaer, .btn-quiet→.kn-textknopf, .badge→.kn-pille, .tab→.kn-reiter-t, .overlay→.kn-ueberlagerung, .empty→.kn-leer, .msg.err→.kn-fehler); .kn-knopf-zweit sieht aus wie .kn-knopf. Unter HANDY: Knöpfe, Felder und Reiter mindestens 44 px, Textfelder 16 px, .kn-dialog/.kn-sheet unten verankert (max. 92 vh).

  • Step 4: Run test to verify it passes

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleCss.test.js Expected: PASS (3 Tests).

  • Step 5: Commit
git add public/neu/tokens.css public/neu/basis.css test/support/knHuelle.js test/knHuelleCss.test.js
git commit -m "Kundenansicht neu P1: Tokens und Basis-CSS im weißen Stil" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 2: dom.js, api.js und Quelltext-Regel

Files:

  • Create: public/neu/dom.js, public/neu/api.js
  • Test: test/knHuelleDomApi.test.js, test/knHuelleQuelltext.test.js

Interfaces:

  • Consumes: nichts.

  • Produces (V2):

    • h(tag, attrs = {}, ...kinder) -> Element: attrs.class, attrs.on = { click: fn }, true setzt ein Attribut ohne Wert, false/null/undefined entfallen, Kinder werden flach gelegt, Strings werden Textknoten.
    • leeren(el) -> el, text(el, wert) -> el (textContent = String(wert ?? '')).
    • async api(pfad, { method = 'GET', body, roh } = {}) -> Object. roh ist ein Rohdaten-Body, der unverändert mit Content-Type: application/octet-stream geht (wie fn-util.js:request, Vertragsfrage 8).
    • class ApiFehler extends Error mit status, code, message, field, body (immer ein Objekt).
  • Step 1: Write the failing tests

test/knHuelleDomApi.test.js:

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.js and api.js

h wie termine-dom.js:7 (gleiche Regeln, plus attrs.on). api: fetch(pfad, { method, credentials: 'same-origin', headers, body }); Fehlertext data.error, bei 502 der Text aus dem Test, sonst Fehler ${status}; fetch wirft → status 0; session_revoked setzt einmal window.location.href = '/login.html?error=' + encodeURIComponent(message) (Merker im Modul).

  • Step 4: Run tests to verify they pass

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/knHuelleDomApi.test.js test/knHuelleQuelltext.test.js Expected: PASS (4 Tests).

  • Step 5: Commit
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>"

Files:

  • Create: public/neu/routen.js
  • Test: test/knHuelleRouten.test.js

Interfaces:

  • Consumes: nichts.

  • Produces (V2 in der Fassung von Vertragsfrage 4: vertraege ohne Reiter, abo und wartung mit Reiter abo): BEREICHE, ROUTEN, routeAusHash(hash) -> Route, hashFuerRoute(route) -> string. Route = { bereich, reiter?, etage?, produkt?, scroll?, query }; query ist immer ein String ohne ?, nicht gesetzte Felder fehlen. produkt ('hosting'|'wartung') und scroll ('aktivitaet') sind die Zusatzfelder zu V2 „plus Produkt“ und „scrollt zur Aktivität“ (Vertragsfrage 4).

  • Zusätzlich: hashFuerAnsicht(name) -> '#…' | null (alter data-view-Name zum Hash, die Brücke für die gemeinsamen Module); startHash({ search, hash }) -> string; ankerKette(route) -> string[] (Hashes von der Website bis zum Ziel); elternVon(route, herkunft = '') -> Route | null; titelFuer(route) -> string; FUNKTIONS_ETAGEN = ['news','termine','webshop','buchungen','zeiten','analyse'].

  • Step 1: Write the failing test

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, elternVon aus ./routen.js.

  • Produces (V2):

    • starteRouter({ zeige, umgebung = fenster() }) -> { beende() }; zeige(route, { nav }), nav ist 'tab'|'push'|'back'|'same'; umgebung = { location, history, addEventListener, removeEventListener }, Vorgabe window.
    • geheZu(hash, { ersetzen = false } = {}): schreibt den Verlauf und ruft danach selbst zeige.
    • Zusätzlich oeffneOverlay({ schliesse }), beendeOverlay(), zurueck() (Zurück der Etage), stempeln(), aktuelleRoute().
  • Regeln (Stempel history.state = { kn: { tiefe, overlay } }; URL der Website ist pathname + search ohne #):

    • R1 Bereichs- und Reiterwechsel: replaceState, nav 'tab'. Geschrieben wird immer hashFuerRoute(route) (kanonisch: #wartung wird #abo; #abo und #vertraege sind verschiedene Routen, ein Wechsel von #vertraege auf #abo schreibt #abo mit nav 'tab' und nie #vertraege). Gleiche Route: nichts schreiben, zeige mit 'same'.
    • R2 von Ebene 1 in eine Etage (und von Etage zu Etage): pushState, nav 'push'. ersetzen: true erzwingt replaceState (nav bleibt).
    • R3 popstate: Ebene höher → 'push', sonst 'back'.
    • R4 Start: startHash anwenden, zeige einmal mit 'tab'. location.search bleibt am Zieleintrag unberührt (P2 liest ?abo= dort, Vertragsfrage 7), die Anker-Einträge davor tragen search ohne den Parameter abo. Fehlt der Stempel, legt der Router die ankerKette an (erster Eintrag replaceState, die übrigen pushState); mit Stempel passiert nichts.
    • R5 zurueck(): tiefe > 0 → history.back(), sonst geheZu(Eltern, { ersetzen: true }) mit 'back'.
    • R6 Overlay: oeffneOverlay schiebt einen Eintrag mit overlay: true; popstate darauf ruft schliesse() und kein zeige. geheZu bei offenem Overlay ersetzt den Overlay-Eintrag und ruft schliesse() genau einmal. Ein Overlay-Stempel beim Start wird gelöscht.
    • R7 stempeln() schreibt den Stempel nach einem replaceState(null, …) eines Moduls neu.
  • Step 1: Write the failing test

Kopf: starteRouter, geheZu, oeffneOverlay, beendeOverlay, zurueck, stempeln aus ../public/neu/router.js, hashFuerRoute aus ../public/neu/routen.js. Hilfe in der Datei: baueVerlauf(url, state = null) liefert { umgebung, urls(), aktuell(), zurueck() } (ca. 25 Zeilen: Einträge { url, state } mit Index; pushState schneidet Vorwärtseinträge ab; zurueck() setzt den Index und ruft die popstate-Handler synchron; location.pathname|search|hash und history.state kommen aus dem aktuellen Eintrag). start(url, state) ruft starteRouter({ zeige, umgebung }) und sammelt log = [[hashFuerRoute(route), nav], …].

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: h aus ./dom.js.

  • Produces:

    • baueReiter({ eintraege, aktiv, art = 'unterstrich', ariaLabel = 'Reiter', bei, warte = (fn, ms) => setTimeout(fn, ms) }) -> HTMLElement (Form wie P3 VF1). Eintrag { key, label, kurz?, einheit?, ausrufText? }. Der Knoten trägt die Methoden setzeAktiv(key), setzeAnzeige(key, { zahl = 0, ausruf = false }), messen(). art 'balken' baut den Gleiter (schwarzer Balken) mit Overlay-Kopie nach final-2-frei.html (.kn-balken > .kn-overlay, Overlay aria-hidden, ohne Rollen), art 'unterstrich' die Reiter von basis.css.
    • Rein: balkenMasse({ basis: [{ links, breite }], aktiv, gesamtBreite }) -> { links, breite, overlayVersatz, overlayBreite }; overlayPasst(basisBreiten, overlayBreiten, toleranz = 0.5) -> boolean; schriftStufe({ inhaltsBreite, verfuegbar }) -> 15 | 14; sichtbarMachen({ scrollLinks, sichtbarBreite, itemLinks, itemBreite, rand = 16 }) -> number; anzeigeText({ zahl, ausruf }) -> string.
  • Step 1: Write the failing test

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: h aus ./dom.js; baueReiter, sichtbarMachen aus ./reiter.js.

  • Produces:

    • baueEtage({ blatt, haupt, zeile, titel, brot, reiterSlot, istHandy, onZurueck }) -> Etage (alles Elemente aus dashboard.html, istHandy: () => boolean).
    • Etage = { oeffne({ titel, eltern: { label, hash }, seite }), schliesse(), setzeReiter({ eintraege, aktiv, bei }), neuZeichnen(), setzeChatPunkt(an), istOffen() }.
    • Ereignisse (Vertragsfrage 5): blatt hört auf kn:reiter (detail: { eintraege: [{ key, label, kurz? }], aktiv, bei }, leere Liste = keine Reiter) und kn:titel (detail: { text }); beide steigen aus jedem Nachfahren auf.
  • Step 1: Write the failing test

import { baueEtage } from '../public/neu/etage.js'
import { lies, handyTeil } from './support/knHuelle.js'
const teile = () => ({ blatt: h('div', { hidden: true }, h('section', { 'data-etage': 'termine' })), haupt: h('main'), zeile: h('div'), titel: h('h1'), brot: h('nav'), reiterSlot: h('div') })
const REITER = ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen', 'Kalender', 'Rechnungen'].map((label, i) => ({ key: `r${i}`, label, kurz: label.split(' ')[0] }))
const EL = { label: 'Funktionen', hash: '#funktionen' }

test('oeffne und schliesse: Titel, Zurück mit Ziel, Brotkrume, Hauptseite gesperrt', async () => {
  const fx = installFakeDom()
  try {
    const t = teile(); let zu = 0; const e = baueEtage({ ...t, istHandy: () => true, onZurueck: () => { zu += 1 } })
    e.oeffne({ titel: 'Terminplaner', eltern: EL, seite: 'termine' })
    assert.deepEqual([t.blatt.hidden, t.haupt.getAttribute('inert'), e.istOffen(), t.titel.textContent], [false, '', true, 'Terminplaner'])
    assert.deepEqual([fx.text(t.brot), fx.finde(t.brot, 'a').getAttribute('href')], ['Funktionen / Terminplaner', '#funktionen'])
    const z = fx.finde(t.zeile, 'button'); assert.deepEqual([z.textContent, z.getAttribute('aria-label')], ['Zurück', 'Zurück zu Funktionen'])
    await fx.klick(z); assert.equal(zu, 1)
    e.schliesse(); assert.deepEqual([t.blatt.hidden, t.haupt.hasAttribute('inert'), fx.alle(t.zeile, 'button').length], [true, false, 0])
  } finally { fx.restore() }
})
test('kn:reiter vom Inhalt: Handy Kurzlabels in der Zeile, Desktop Unterstrich im Slot, ohne Reiter füllt Zurück die Zeile (C2)', async () => {
  const fx = installFakeDom()
  try {
    const gew = []; let handy = true; const t = teile(); const e = baueEtage({ ...t, istHandy: () => handy, onZurueck() {} })
    e.oeffne({ titel: 'Terminplaner', eltern: EL, seite: 'termine' }); const kind = t.blatt.children[0]
    await fx.sende(kind, 'kn:reiter', { detail: { eintraege: REITER, aktiv: 'r0', bei: (k) => gew.push(k) } })
    const tabs = fx.alle(t.zeile, '[role=tab]')
    assert.deepEqual(tabs.map((x) => x.textContent), ['Termine', 'Leistungen', 'Team', 'Einstellungen', 'Kalender', 'Rechnungen']); assert.equal(tabs[2].getAttribute('aria-label'), 'Team & Plätze')
    await fx.klick(tabs[2]); assert.deepEqual(gew, ['r2'])
    await fx.sende(kind, 'kn:reiter', { detail: { eintraege: [], aktiv: '', bei() {} } })
    assert.deepEqual([fx.alle(t.zeile, '[role=tab]').length, Boolean(fx.finde(t.zeile, '.kn-zurueck--voll'))], [0, true])
    handy = false; e.neuZeichnen(); await fx.sende(kind, 'kn:reiter', { detail: { eintraege: REITER, aktiv: 'r0', bei() {} } })
    assert.deepEqual([fx.alle(t.reiterSlot, '[role=tab]').length, fx.alle(t.zeile, '[role=tab]').length], [6, 0])
    await fx.sende(kind, 'kn:titel', { detail: { text: 'Kalender' } }); assert.equal(t.titel.textContent, 'Kalender')
  } finally { fx.restore() }
})
test('Chat-Punkt am Zurück', () => {
  const fx = installFakeDom()
  try {
    const t = teile(); const e = baueEtage({ ...t, istHandy: () => true, onZurueck() {} })
    e.oeffne({ titel: 'Webshop', eltern: EL, seite: 'webshop' }); e.setzeChatPunkt(true)
    assert.ok(fx.finde(t.zeile, '.kn-punkt')); assert.equal(fx.finde(t.zeile, 'button').getAttribute('aria-label'), 'Zurück zu Funktionen, neue Nachricht im Chat')
  } finally { fx.restore() }
})
test('huelle.css (Blatt): Handy fährt von unten ein, Zurück füllt die Zeile', () => {
  const css = lies('huelle.css')
  assert.match(handyTeil(css), /\.kn-blatt\s*\{(?=[^}]*border-radius:\s*12px 12px 0 0)(?=[^}]*transition:[^}]*550ms[^}]*var\(--kn-bremse\))/s)
  assert.match(css, /\.kn-zurueck--voll\s*\{[^}]*flex:\s*1/s)
})
  • Step 2: Run test to verify it fails

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleEtage.test.js Expected: FAIL (Modul nicht gefunden).

  • Step 3: Implement etage.js und den Abschnitt „Blatt“ in huelle.css

setzeChatPunkt merkt den Zustand auch für spätere oeffne-Aufrufe. oeffne zeigt blatt, sperrt haupt per inert, setzt Titel, baut Brotkrume (<a>, /, Titel; nur am Desktop sichtbar) und am Handy die Zeile (button.kn-zurueck mit CSS-Spitze statt Icon, davor kein Zeichen im Text), zeigt nur section[data-etage=seite]. Reiter: am Handy in der Zeile hinter einem senkrechten Strich .kn-vl als art 'balken', am Desktop in reiterSlot als art 'unterstrich'; Beschriftung kurz ?? label; nach bei rückt sichtbarMachen den aktiven Reiter ins Bild (nur mit getBoundingClientRect). CSS: Desktop-Grundregel: .kn-blatt füllt die Spalte ohne Schatten. Unter HANDY: position:fixed; inset:0 0 var(--kn-zeile-h), border-radius:12px 12px 0 0, Schatten 0 0 0 1px rgba(26,31,54,.06), 0 -8px 24px rgba(26,31,54,.1), translateY(100%) → 0 in 550ms var(--kn-bremse).

  • Step 4: Run test to verify it passes

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleEtage.test.js Expected: PASS (4 Tests).

  • Step 5: Commit
git add public/neu/etage.js public/neu/huelle.css test/knHuelleEtage.test.js
git commit -m "Kundenansicht neu P1: Etage 2 mit Zurück, Reiterzeile und Brotkrume" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 7: zaehler.js (V3)

Files:

  • Create: public/neu/zaehler.js
  • Test: test/knHuelleZaehler.test.js

Interfaces:

  • Consumes: nichts (api wird übergeben).

  • Produces:

    • berechneVertraege({ rechnungen = [], rueckfragen = [], paymentProblem = false }) -> { zahl, ausruf }: zahl = Rechnungen mit Number(balance) > 0 plus Rückfragen mit status === 'rueckfrage'; ausruf = paymentProblem.
    • erzeugeZaehler({ api, onChange, sichtbar = () => true, imChat = () => false, zeitgeber = { setze: setInterval, loesche: clearInterval } }) -> { async start(), stop(), async aktualisiere(was = 'alle'), setzeChat(zahl), stand() }, was ist 'chat'|'vertraege'|'alle'. stand() = { chat, vertraege: { zahl, ausruf } }; onChange(stand) bei jeder Änderung. const CHAT_POLL_MS = 45000. Die „Rechnung offen“-Zeile aus Spec 5.3 zeichnet dieses Paket nicht (Vertragsfrage 6).
  • Quellen (geprüft): GET /api/chat/unread-count → { unread } (chat.js:237); GET /api/invoices → { invoices } (customerData.js:18); GET /api/abo/takeover-credits → { requests:[{ status }] }; GET /api/abo → { subscription: { paymentProblem, switchPending } | null } (abo.js:115,144).

  • Step 1: Write the failing test

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: h aus ./dom.js.

  • Produces:

    • async ladeFristen(api) -> { fristen, fruehestesEnde, anzeigen } | null: ruft api('/api/fristen'); Fehler jeder Art (404, 500, Status 0) und anzeigen !== true ergeben null.
    • streifenText(iso) -> 'Widerrufsfrist bis TT.MM.' (Europe/Berlin); fruehesteFrist(daten) -> iso | '' (fruehestesEnde, sonst das kleinste bis).
    • baueStreifen(wurzel, { koerper = document.body } = {}) -> { setze(daten | null) }: füllt wurzel (hidden bei null) und setzt kn-hat-streifen am koerper.
  • Step 1: Write the failing test

import { ladeFristen, streifenText, fruehesteFrist, baueStreifen } from '../public/neu/fristen-streifen.js'
import { lies, handyTeil } from './support/knHuelle.js'
const FRIST = { fristen: [{ quelle: 'abschluss', bezeichnung: 'Abo', bis: '2026-10-21T21:59:59.999Z' }], fruehestesEnde: '2026-10-21T21:59:59.999Z', anzeigen: true }
test('ladeFristen: 404, 500, Netzfehler und anzeigen:false ergeben null; sonst die Daten (Review Focus 5)', async () => {
  for (const status of [404, 500, 0]) assert.equal(await ladeFristen(async () => { throw Object.assign(new Error('x'), { status }) }), null)
  assert.equal(await ladeFristen(async () => ({ fristen: [], fruehestesEnde: null, anzeigen: false })), null)
  assert.deepEqual(await ladeFristen(async (p) => { assert.equal(p, '/api/fristen'); return FRIST }), FRIST)
})
test('streifenText: Berliner Datum, nicht UTC; fruehesteFrist fällt auf das kleinste bis zurück', () => {
  assert.equal(streifenText('2026-10-21T21:59:59.999Z'), 'Widerrufsfrist bis 21.10.'); assert.equal(streifenText('2026-10-21T22:30:00.000Z'), 'Widerrufsfrist bis 22.10.')
  assert.equal(fruehesteFrist({ fruehestesEnde: null, fristen: [{ bis: '2026-11-03T10:00:00Z' }, { bis: '2026-10-30T10:00:00Z' }] }), '2026-10-30T10:00:00Z')
})
test('baueStreifen: Text mit Link, nicht wegtippbar, null versteckt', () => {
  const fx = installFakeDom()
  try {
    const w = h('div', { hidden: true }); const s = baueStreifen(w); s.setze(FRIST)
    assert.deepEqual([w.hidden, fx.text(w)], [false, 'Widerrufsfrist bis 21.10. · Widerrufen'])
    assert.deepEqual([fx.finde(w, 'a').getAttribute('href'), fx.alle(w, 'button').length, fx.document.body.classList.contains('kn-hat-streifen')], ['/widerrufen.html', 0, true])
    s.setze(null); assert.deepEqual([w.hidden, fx.document.body.classList.contains('kn-hat-streifen')], [true, false])
  } finally { fx.restore() }
})
test('huelle.css (Streifen): 44 px, getönt, am Handy über der Leiste, bei Tastatur weg', () => {
  const css = lies('huelle.css')
  assert.match(css, /\.kn-streifen\s*\{(?=[^}]*min-height:\s*44px)(?=[^}]*background:\s*var\(--kn-orange-bg\))(?=[^}]*color:\s*var\(--kn-orange-fg\))/s)
  assert.match(handyTeil(css), /\.kn-streifen\s*\{[^}]*position:\s*fixed/s)
  assert.match(css, /\.kn-tastatur \.kn-streifen[^{]*\{[^}]*display:\s*none/s)
})
  • Step 2: Run test to verify it fails

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleFristen.test.js Expected: FAIL (Modul nicht gefunden).

  • Step 3: Implement fristen-streifen.js und den Abschnitt „Streifen“ in huelle.css

Datum über toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit' }). Der Streifen ist ein div.kn-streifen[role=region][aria-label="Widerrufsfrist"] mit dem Text, · und a.kn-streifen-link[href="/widerrufen.html"] („Widerrufen“). CSS: Desktop-Grundregel: Streifen oben in der Spalte, nicht fixiert, Fläche --kn-orange-bg, Schrift --kn-orange-fg 14 px/600. Unter HANDY: position:fixed direkt über der Leiste (bottom: calc(var(--kn-zeile-h) + env(safe-area-inset-bottom))); .kn-hat-streifen .kn-haupt bekommt 44 px zusätzlichen Fußabstand.

  • Step 4: Run test to verify it passes

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleFristen.test.js Expected: PASS (4 Tests).

  • Step 5: Commit
git add public/neu/fristen-streifen.js public/neu/huelle.css test/knHuelleFristen.test.js
git commit -m "Kundenansicht neu P1: Widerrufsstreifen mit GET /api/fristen" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 9: konto.js (Avatar, Konto-Sheet, Popover)

Files:

  • Create: public/neu/konto.js
  • Modify: public/neu/huelle.css (Abschnitt „Konto“)
  • Test: test/knHuelleKonto.test.js

Interfaces:

  • Consumes: h aus ./dom.js; oeffneOverlay, beendeOverlay, geheZu aus ./router.js.

  • Produces:

    • initialenVon(name) -> string; installAngebot({ handy, standalone, prompt, ios }) -> 'prompt' | 'ios' | null.
    • kontoEintraege({ fristen, install }) -> [{ id, text, href?, neuerTab?, pille?, betont? }] in der Reihenfolge von V3: passwort, datenschutz, impressum, installieren (nur mit install), kuendigen, widerrufen, abmelden.
    • baueAvatar({ name, kundenId }) -> HTMLButtonElement (aria-label="Mein Konto", Initialen, Bild von /api/avatar.svg?c=<kundenId> erst nach load).
    • baueKonto({ me, fristen, install, onAbmelden, onInstall, overlay = { oeffne: oeffneOverlay, beende: beendeOverlay }, navigiere = geheZu }) -> { wurzel, oeffne(ausloeser), schliesse(), istOffen(), aktualisiere({ fristen, install }) }; aktualisiere baut die Zeilen neu und behält den offenen Zustand.
  • Step 1: Write the failing test

import { initialenVon, installAngebot, kontoEintraege, baueAvatar, baueKonto } from '../public/neu/konto.js'
import { lies, handyTeil } from './support/knHuelle.js'
test('Reihenfolge, Texte und Ziele des Kontos (V3); Widerrufen immer da, "Frist läuft" nur in der Frist', () => {
  const ids = (o) => kontoEintraege(o).map((e) => e.id)
  assert.deepEqual(ids({ fristen: null, install: null }), ['passwort', 'datenschutz', 'impressum', 'kuendigen', 'widerrufen', 'abmelden'])
  assert.deepEqual(ids({ fristen: null, install: 'ios' }), ['passwort', 'datenschutz', 'impressum', 'installieren', 'kuendigen', 'widerrufen', 'abmelden'])
  const e = Object.fromEntries(kontoEintraege({ fristen: null, install: 'prompt' }).map((x) => [x.id, x]))
  assert.deepEqual([e.passwort.text, e.passwort.href, e.datenschutz.href, e.impressum.href, e.kuendigen.href, e.kuendigen.betont], ['Passwort ändern', '#passwort', '/datenschutz.html', 'https://webklar.com/impressum', '/kuendigen.html', true])
  assert.deepEqual([e.widerrufen.text, e.widerrufen.href, e.widerrufen.pille, e.installieren.text, e.abmelden.text], ['Vertrag widerrufen', '/widerrufen.html', undefined, 'App installieren', 'Abmelden'])
  assert.equal(kontoEintraege({ fristen: { anzeigen: true }, install: null }).find((x) => x.id === 'widerrufen').pille, 'Frist läuft')
})
test('installAngebot: nur am Handy, nicht in der installierten App (wie app.js:2267)', () => {
  assert.deepEqual([{ handy: true, standalone: false, prompt: true, ios: false }, { handy: true, standalone: false, prompt: false, ios: true }, { handy: true, standalone: true, prompt: true, ios: true }, { handy: false, standalone: false, prompt: true, ios: true }].map((o) => installAngebot(o)), ['prompt', 'ios', null, null])
})
test('initialenVon und Avatar: Bild erst nach load', async () => {
  assert.deepEqual(['Salon Amin', 'Amin', '', '  ', 'Äsop Öl', '– Test'].map(initialenVon), ['SA', 'AM', '', '', 'ÄÖ', 'TE'])
  const fx = installFakeDom()
  try {
    const a = baueAvatar({ name: 'Salon Amin', kundenId: 'c1' }); assert.deepEqual([a.textContent, a.getAttribute('aria-label')], ['SA', 'Mein Konto'])
    const img = fx.finde(a, 'img'); assert.equal(img.getAttribute('src'), '/api/avatar.svg?c=c1')
    await fx.sende(img, 'load'); assert.ok(a.classList.contains('has-img'))
  } finally { fx.restore() }
})
test('baueKonto: Kopf als Text, Sheet-Verhalten, Overlay-Kopplung, Navigation, Abmelden, aktualisiere', async () => {
  const fx = installFakeDom()
  try {
    const log = []; const trigger = h('button'); fx.document.body.append(trigger)
    const me = { customer: { id: 'c1', name: 'Anna', companyName: '<img src=x onerror=alert(1)>Salon', email: 'a@b.de' } }
    const k = baueKonto({ me, fristen: null, install: null, onAbmelden: () => log.push('abmelden'), onInstall() {}, overlay: { oeffne: (o) => log.push(['overlay', typeof o.schliesse]), beende: () => log.push('beende') }, navigiere: (hash) => log.push(['geheZu', hash]) })
    fx.document.body.append(k.wurzel); const zeilen = () => fx.alle(k.wurzel, '.kn-konto-zeile')
    assert.deepEqual([fx.finde(k.wurzel, '.kn-konto-name').textContent, fx.finde(k.wurzel, '.kn-konto-mail').textContent], ['<img src=x onerror=alert(1)>Salon', 'a@b.de'])
    assert.deepEqual(zeilen().map((z) => z.textContent), ['Passwort ändern', 'Datenschutz', 'Impressum', 'Verträge kündigen', 'Vertrag widerrufen', 'Abmelden'])
    assert.deepEqual([k.wurzel.getAttribute('role'), k.wurzel.getAttribute('aria-modal')], ['dialog', 'true'])
    k.oeffne(trigger); assert.deepEqual([k.istOffen(), trigger.getAttribute('aria-expanded'), log[0]], [true, 'true', ['overlay', 'function']])
    await fx.sende(k.wurzel, 'keydown', { key: 'Escape' }); assert.deepEqual([k.istOffen(), log.at(-1), fx.document.activeElement === trigger], [false, 'beende', true])
    k.oeffne(trigger); await fx.klick(zeilen()[0]); assert.deepEqual(log.at(-1), ['geheZu', '#passwort'])
    await fx.klick(zeilen().at(-1)); assert.equal(log.at(-1), 'abmelden')
    k.aktualisiere({ fristen: { anzeigen: true }, install: 'ios' }); assert.ok(k.istOffen()); assert.match(fx.text(k.wurzel), /App installieren[\s\S]*Vertrag widerrufen.*Frist läuft/)
    await fx.klick(fx.finde(k.wurzel, '.kn-konto-scrim')); assert.equal(k.istOffen(), false)
  } finally { fx.restore() }
})
test('huelle.css (Konto): Popover als Desktop-Grundregel, Sheet am Handy', () => {
  const css = lies('huelle.css')
  assert.match(css, /\.kn-konto\s*\{(?=[^}]*width:\s*280px)(?=[^}]*top:)/s)
  assert.match(handyTeil(css), /\.kn-konto\s*\{[^}]*border-radius:\s*12px 12px 0 0/s)
})
  • Step 2: Run test to verify it fails

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleKonto.test.js Expected: FAIL (Modul nicht gefunden).

  • Step 3: Implement konto.js und den Abschnitt „Konto“ in huelle.css

initialenVon wie app.js:2006. Kopf: .kn-konto-name (companyName || name || email) und .kn-konto-mail. Zeilen sind a.kn-konto-zeile (Ziele wie im Test; Datenschutz und Impressum mit target=_blank rel=noopener), installieren und abmelden sind button; „Frist läuft“ ist ein span.kn-pille.kn-pille-orange in der Zeile. Klick auf eine interne Zeile: preventDefault() und navigiere(hash) (der Router schließt das Overlay selbst). Abmelden ruft nur onAbmelden, installieren nur onInstall (am iPhone zeigt die Zeile „Tippe auf Teilen und dann auf Zum Home-Bildschirm.“). Esc, Scrim (.kn-konto-scrim in wurzel) und „Schließen“ rufen schliesse(), das nur bei zuvor offenem und nicht vom Router geschlossenem Konto overlay.beende() ruft. Fokus beim Öffnen auf die erste Zeile, beim Schließen zurück auf ausloeser. CSS: Desktop-Grundregel Popover rechts oben unter dem Avatar, 280 px, --kn-shadow-pop; unter HANDY unten verankertes Sheet mit Scrim --kn-overlay, Radius 12px 12px 0 0, Safe-Area unten.

  • Step 4: Run test to verify it passes

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleKonto.test.js Expected: PASS (5 Tests).

  • Step 5: Commit
git add public/neu/konto.js public/neu/huelle.css test/knHuelleKonto.test.js
git commit -m "Kundenansicht neu P1: Konto mit Avatar, Sheet und Popover" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 10: leiste.js (Fahrstuhl-Zeile, Menü, Kopf, Tastatur)

Files:

  • Create: public/neu/leiste.js
  • Modify: public/neu/huelle.css (Abschnitte „Leiste“, „Menü“, „Kopf“, „Querformat“, „Bewegung reduziert“)
  • Test: test/knHuelleLeiste.test.js

Interfaces:

  • Consumes: h aus ./dom.js; baueReiter, anzeigeText aus ./reiter.js.

  • Produces:

    • HANDY_ABFRAGE = '(max-width: 880px), (pointer: coarse) and (max-height: 500px)'.
    • WOERTER = [{ key:'website', label:'Website' }, { key:'funktionen', label:'Funktionen' }, { key:'chat', label:'Chat', einheit:'ungelesen' }, { key:'vertraege', label:'Verträge', einheit:'offen', ausrufText:'Zahlung fehlgeschlagen' }].
    • baueNavigation({ haupt, menue, onWahl }) -> { setzeAktiv(bereich), setzeZaehler({ chat = 0, vertraege = { zahl: 0, ausruf: false } }), setzeEtage(offen) }: haupt ist die Hauptzeile der Leiste (#kn-zeile-haupt), menue das Desktop-Menü (#kn-menue). setzeEtage setzt data-etage="offen" am Elternelement von haupt (dem Reel) und inert an haupt.
    • tastaturZustand(alt, ereignis) -> { voll, breite, offen, tippt } mit ereignis { art:'vv', hoehe, breite, textfeldAktiv?, grob? }, { art:'fokus', textfeld, grob }, { art:'unfokus', textfeldAktiv }; leisteSichtbar(zustand) -> boolean; beobachteTastatur({ win, doc, onChange }) (Verdrahtung mit visualViewport, focusin, focusout; ersetzt app.js:2475-2494 und TEXT_INPUT, app.js:2137).
  • Step 1: Write the failing test

import { HANDY_ABFRAGE, baueNavigation, tastaturZustand, leisteSichtbar } from '../public/neu/leiste.js'
import { lies, handyTeil, HANDY } from './support/knHuelle.js'
test('Leiste: vier Wörter, Auswahl, Zähler mit Beschriftung, "!" und 99+, Etage sperrt die Hauptzeile', async () => {
  const fx = installFakeDom()
  try {
    const haupt = h('div'); const reel = h('div', null, haupt); const menue = h('nav'); const gew = []
    const n = baueNavigation({ haupt, menue, onWahl: (k) => gew.push(k) }); const tabs = fx.alle(haupt, '[role=tab]')
    assert.deepEqual(tabs.map((t) => t.textContent), ['Website', 'Funktionen', 'Chat', 'Verträge'])
    n.setzeAktiv('chat'); assert.deepEqual(tabs.map((t) => t.getAttribute('aria-selected')), ['false', 'false', 'true', 'false']); assert.equal(fx.finde(menue, '[aria-current=page]').textContent, 'Chat')
    await fx.klick(tabs[3]); assert.deepEqual(gew, ['vertraege'])
    n.setzeZaehler({ chat: 2, vertraege: { zahl: 1, ausruf: false } })
    assert.deepEqual([tabs[2].getAttribute('aria-label'), tabs[3].getAttribute('aria-label'), fx.finde(menue, '.kn-nav-zahl').textContent], ['Chat, 2 ungelesen', 'Verträge, 1 offen', '2'])
    n.setzeZaehler({ chat: 150, vertraege: { zahl: 5, ausruf: true } })
    assert.deepEqual([fx.finde(tabs[2], '.kn-zahl').textContent, fx.finde(tabs[3], '.kn-zahl').textContent, tabs[3].getAttribute('aria-label')], ['99+', '!', 'Verträge, Zahlung fehlgeschlagen'])
    n.setzeZaehler({ chat: 0, vertraege: { zahl: 0, ausruf: false } }); assert.equal(fx.finde(menue, '.kn-nav-zahl'), null)
    assert.deepEqual(fx.alle(menue, 'button').map((b) => b.textContent), ['Website', 'Funktionen', 'Chat', 'Verträge'])
    n.setzeEtage(true); assert.deepEqual([reel.getAttribute('data-etage'), haupt.hasAttribute('inert')], ['offen', true])
    n.setzeEtage(false); assert.equal(haupt.hasAttribute('inert'), false)
  } finally { fx.restore() }
})
test('tastaturZustand: öffnen unter voll-150, Android-Geste ohne Blur, Drehen, nur Textfelder am Touch (Review Focus 4)', () => {
  let z = { voll: 844, breite: 390, offen: false, tippt: false }
  z = tastaturZustand(z, { art: 'vv', hoehe: 560, breite: 390, textfeldAktiv: true, grob: true }); assert.deepEqual([z.offen, z.tippt, leisteSichtbar(z)], [true, true, false])
  z = tastaturZustand(z, { art: 'vv', hoehe: 700, breite: 390 }); assert.deepEqual([z.offen, z.tippt, leisteSichtbar(z)], [false, false, true])   // 700 >= 694, ohne Blur
  z = tastaturZustand(z, { art: 'vv', hoehe: 390, breite: 844 }); assert.deepEqual([z.voll, z.breite, z.offen], [390, 844, false])
  assert.deepEqual([true, false].map((grob) => tastaturZustand(z, { art: 'fokus', textfeld: true, grob }).tippt), [true, false])
  assert.equal(tastaturZustand({ ...z, tippt: true }, { art: 'unfokus', textfeldAktiv: false }).tippt, false)
})
test('huelle.css: Desktop ist Grundregel, Handy überschreibt (Abfrage wörtlich, 44 px, Safe-Area, Reel, Gleiter, Menü, Bewegung)', () => {
  assert.equal(`@media ${HANDY_ABFRAGE}`, HANDY)
  const css = lies('huelle.css'); const handy = handyTeil(css)
  assert.match(css, /\.kn-t\s*\{(?=[^}]*min-height:\s*44px)(?=[^}]*font-size:\s*15px)(?=[^}]*font-weight:\s*600)/s)
  assert.match(css, /\.kn-schrift-klein \.kn-t\s*\{[^}]*font-size:\s*14px/s)
  assert.match(css, /\.kn-menue\s*\{(?=[^}]*width:\s*var\(--kn-sidebar-w\))(?=[^}]*border-right:\s*1px solid var\(--kn-line\))/s)
  assert.match(handy, /\.kn-menue\s*\{[^}]*display:\s*none/s); assert.match(handy, /\.kn-leiste\s*\{[^}]*padding-bottom:\s*env\(safe-area-inset-bottom\)/s)
  assert.match(css, /\[data-etage="offen"\][^{]*\{[^}]*translateY\(-56px\)/s)
  assert.match(css, /\.kn-gleiter\s*\{(?=[^}]*top:\s*4px)(?=[^}]*height:\s*36px)(?=[^}]*border-radius:\s*6px)(?=[^}]*background:\s*var\(--kn-accent\))/s)
  assert.match(css, /\.kn-tastatur \.kn-leiste[^{]*\{[^}]*(translateY\(110%\)|display:\s*none)/s)
  assert.match(css, /@media \(prefers-reduced-motion: reduce\)[\s\S]*150ms/)
})
  • Step 2: Run test to verify it fails

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleLeiste.test.js Expected: FAIL (Modul nicht gefunden).

  • Step 3: Implement leiste.js und die Abschnitte in huelle.css

baueNavigation: in haupt ein baueReiter({ art:'balken', eintraege: WOERTER, ariaLabel:'Bereiche', bei: onWahl }); im menue das Wort „WEBklar“ (Text, kein Icon) und je Wort ein button.kn-nav-item (aria-current="page" am aktiven, Zähler span.kn-nav-zahl mit anzeigeText). tastaturZustand ist rein: offen = hoehe < voll − 150; ein Breitenwechsel setzt voll := hoehe; schließt die Tastatur per vv, wird tippt falsch; tippt wird wahr bei Fokus auf ein Textfeld mit grob oder bei offener Tastatur mit textfeldAktiv und grob.

Feste Werte (final-2-frei.html, Klassen mit Präfix kn-):

  • Leiste (nur unter HANDY, sonst display:none): position:fixed; left:0; right:0; bottom:0, Fläche --kn-bg, oben 1 px --kn-line, padding-bottom: env(safe-area-inset-bottom). Zeile 56 px, Reel overflow:hidden; [data-etage="offen"] schiebt das Reel translateY(-56px) in 550ms var(--kn-bremse).

  • Tab .kn-t: flex:1 1 auto; min-height:44px; padding:0 4px; font:600 15px; color:--kn-text-2; transition: color 250ms; .kn-schrift-klein 14 px. Gleiter .kn-gleiter: top:4px; height:36px; background:--kn-accent; border-radius:6px; transition: transform und width 550ms var(--kn-feder); Overlay-Kopie .kn-overlay (top:-4px, Schrift weiß); .kn-fb blendet das Overlay aus und färbt den aktiven Tab weiß.

  • Zähler .kn-zahl: min-width:16px; height:16px; padding:0 4px; border-radius:8px; background:#b3093c; color:#fff; font:700 11px/16px; box-shadow:0 0 0 2px #fff (im Overlay #111), top:-9px; left:100%; margin-left:-6px; Eintritt 400 ms cubic-bezier(.3,1.5,.5,1) von scale(0); .kn-zahl--weg scale(0) in 250 ms.

  • Menü .kn-menue (Grundregel): 232 px, border-right, Einträge padding:6px 10px; font:500 14px/20px; color:--kn-text-2, aktiv #111/600/--kn-accent-soft. Inhalt .kn-haupt: max. 1160 px, Abstände wie .page (woms-design.md Abschnitt 2: 36/48/80, unter 1100 px 32/28/80). Unter HANDY 16 px Seitenrand und Fußabstand calc(var(--kn-zeile-h) + env(safe-area-inset-bottom) + 28px).

  • Kopf .kn-kopf: sticky, 52 px plus Safe-Area oben; kleiner Titel (15 px/600) blendet bei .kn-titled ein; Rand --kn-line bei .kn-scrolled; Avatar-Fläche 44 × 44 px (Kreis 32 px).

  • .kn-tastatur blendet Leiste und Streifen aus. Querformat (HANDY mit max-height:500px): Zeile 44 px, .kn-haupt .kn-titel weg. Unter prefers-reduced-motion: reduce alle Dauern höchstens 150 ms, Blatt und Seiten nur opacity.

  • Step 4: Run test to verify it passes

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knHuelleLeiste.test.js Expected: PASS (3 Tests).

  • Step 5: Commit
git add public/neu/leiste.js public/neu/huelle.css test/knHuelleLeiste.test.js
git commit -m "Kundenansicht neu P1: Fahrstuhl-Zeile, Desktop-Menü und Tastaturzustand" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 11: huelle.js (Start, Ableitung, Zusammenbau)

Files:

  • Create: public/neu/huelle.js
  • Test: test/knHuelleStart.test.js

Interfaces:

  • Consumes: alles aus Task 2 bis 10.

  • Produces:

    • GERUEST_IDS: kn-banner, kn-ansage, kn-fehler, kn-menue, kn-kopf, kn-kopf-titel, kn-kopf-konto, kn-inhalt, bereich-website, bereich-funktionen, bereich-chat, bereich-vertraege, website-body, funktionen-body, chat-body, vertraege-body, kn-blatt, kn-brot, kn-etage-titel, kn-reiter-slot, news-body, termine-body, webshop-body, buchungen-body, zeiten-body, analyse-body, etage-body, kn-streifen, kn-leiste, kn-zeile-haupt, kn-zeile-etage, kn-konto-wurzel.
    • ANSICHTEN = { website:['/neu/ansicht-website.js','zeigeWebsite','website-body'], funktionen:[…'zeigeFunktionen','funktionen-body'], chat:[…'zeigeChat','chat-body'], vertraege:[…'zeigeVertraege','vertraege-body'] }; ETAGE_ANSICHTEN = { projekt:['/neu/ansicht-website.js','zeigeWebsite','etage-body'], passwort:[…'zeigePasswort','etage-body'], gutschrift:[…'zeigeGutschrift','etage-body'], wechsel:[…'zeigeWechsel','etage-body'], bestellen:[…'zeigeBestellen','etage-body'] } (Datei ansicht-<name>.js, Funktion zeige + Name groß).
    • FUNKTIONS_MODULE = { news:['/funktionen.js','initNews','news-body'], termine:['/termine.js','initTermine','termine-body'], webshop:['/shop.js','initShop','webshop-body'], buchungen:['/buchungen.js','initBuchungen','buchungen-body'], zeiten:['/zeiten.js','initZeiten','zeiten-body'], analyse:['/analyse.js','initAnalyse','analyse-body'] } (wörtlich aus app.js:1938-1945).
    • MIT_EINSCHALTEN = ['news', 'termine', 'webshop', 'buchungen', 'zeiten'] (die Etagen, deren Modul die Init-Option einschalten bekommt; analyse nicht, sie nutzt POST /api/funktionen/analyse/activate) und einschaltenOption(lade) -> (platz, o) => Promise, das lade('/neu/funktion-einschalten.js') erst beim Aufruf startet und dann m.zeigeEinschalten(platz, o) ruft (P3 liefert die Datei und den Export; Vertragsfrage 12).
    • leiteAb(me, { hash = '' }) -> { art:'weiter', ziel } | { art:'zeigen', kunde, viewAs, viewId, admin, anzeigename }; scrollZiel(nav, gemerkt) -> { top, weich }.
    • async starteHuelle({ win = window, doc = document, api = apiStandard, lade = (p) => import(p), router = routerStandard, hash = win.location.hash } = {}).
  • Ablauf von zeige(route, { nav }): (1) Bereich oder Etage zeigen, Leiste, Menü, Kopftitel und #kn-ansage nachziehen; bei vertraege zaehler.aktualisiere('vertraege'); bei Chat zaehler.setzeChat(0). (2) Steht die Wurzel leer da, den Text „Wird geladen …“ (p.kn-leer) hineinschreiben. (3) Ansicht aus ANSICHTEN/ETAGE_ANSICHTEN über lade holen und zeigeX(wurzel, { route, me, nav }) rufen (V2); Funktionsetagen: wurzel.dataset.wunschProjekt aus dem Parameter projekt der Query setzen (falls vorhanden), init*(wurzel, { einschalten: einschaltenOption(lade) }) rufen (nur für die Etagen aus MIT_EINSCHALTEN; initAnalyse(wurzel) ohne zweites Argument) und danach router.stempeln(). Die Hülle übergibt kein projectId, die Projektwahl bleibt dataset.wunschProjekt (V2). (4) Ladefehler: „Konnte nicht geladen werden.“ als p.kn-fehler in die Wurzel. (5) Scroll nach scrollZiel.

  • Step 1: Write the failing test

Hilfen in der Datei: baueGeruest(fx) legt jede ID aus GERUEST_IDS als div#id an fx.document.body. boot({ karte, hash, lade }) ruft starteHuelle mit einem Fenster (fakeDom-Fenster plus matchMedia: () => ({ matches: true, addEventListener() {} }) und KbView: { setId: (id) => log.push(['setId', id]) }), einem api (karte[pfad]; ein Error wirft; ein fehlender Pfad wirft { status: 404 }; jeder Aufruf steht als ['api', pfad, method] im log), einem doc (fx.document mit addEventListener/dispatchEvent eines Node-EventTarget), einem lade (Vorgabe async () => new Proxy({}, { get: () => async () => {} })) und einer Router-Attrappe (starteRouter({ zeige }) merkt zeige und ruft es sofort mit routeAusHash(hash) und { nav: 'tab' }; geheZu(h) und stempeln() schreiben ins log). Danach await fx.ruhe(). Rückgabe { fx, win, doc, log, el(id), zeige(hash, nav) }. Konstanten: ME_KUNDE = { authenticated: true, role: 'customer', customer: { id: 'c1', name: 'Anna', companyName: '', email: 'a@b.de' } }, ME_VIEW = { ...ME_KUNDE, viewAs: true, viewId: 'v1', admin: { name: 'Kenso' } }, KARTE_KUNDE mit ME_KUNDE und leeren Antworten für /api/chat/unread-count ({ unread: 0 }), /api/invoices, /api/abo, /api/abo/takeover-credits.

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 (Hilfsfunktion istNeu)
  • Create: public/neu/manifest.webmanifest, public/neu/ansicht-website.js, ansicht-funktionen.js, ansicht-chat.js, ansicht-vertraege.js, ansicht-passwort.js, ansicht-gutschrift.js, ansicht-wechsel.js, ansicht-bestellen.js, public/neu/ansichten.css, public/neu/funktionen.css, public/neu/module.css
  • Test: test/knHuelleSeite.test.js

Interfaces:

  • Consumes: GERUEST_IDS, ANSICHTEN, ETAGE_ANSICHTEN aus ./huelle.js.

  • Produces: die Seite, die GET /dashboard.html im Pilot ausliefert (Marker <meta name="kn-seite" content="neu">, <body class="kn">); acht Stubs mit je export async function zeigeX(wurzel, { route, me, nav }), die nur einen Satz „ (folgt)“ nach wurzel schreiben; drei CSS-Stubs mit einem Kommentar zum künftigen Besitzer.

  • Step 1: Write the failing test (und istNeu in test/kundenansichtDashboard.test.js auf String(r.body).includes('<meta name="kn-seite" content="neu">') statt data-kn-platzhalter umstellen; <body class="kn"> bleibt)

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ürzung KW): views.mjs (Ansichten von P1, die zwei Platzhalter-Ansichten), gates.mjs (Gate p1), test/statik.test.mjs (Marker).
  • Modify (nur bei Verstößen, Portal-Worktree): public/neu/huelle.css, basis.css, reiter.js, leiste.js, etage.js.

Interfaces:

  • Consumes (aus P0, Task 6, 8, 9, 10): Fälle betrieb-wartung, privat-frist (Frist bis 2026-10-25), rechnung-offen (RE-2026-0042, 50 €), chat-ungelesen (3), mitarbeiter, abgemeldet, fehler (500 für abo, invoices, funktionen); GERAETE (handy 390×844, handy-quer 844×390, tablet 820×1180, desktop 1440×900); Ansichtsfelder { id, fall, adresse, geraete, warte, schritte(seite), bewegung?, ignorieren? }; GATES.p<N> = { checks, ansichten } mit ALWAYS_ON; Prüfungen dark-surface, low-contrast, overflow-x, lila; Standard reducedMotion: 'reduce' (mit bewegung: true volle Bewegung). schritte(seite) wirft bei Verstoß (wird zu step-failed).

  • Produces: Gate p1 = { checks: [...ALWAYS_ON, 'dark-surface', 'low-contrast', 'overflow-x', 'lila'], ansichten: ['kn-huelle-*'] } grün; die Aufnahmen unter KW/tmp/shots-p1/ als Beleg.

  • Step 1: Umgebung für diesen Strang

Run: printf 'export WT=/home/webklar/apps/kundenbereich.webklar.com/dev-kn-ui\n' > /root/reports/kundenansicht-neu-2026-10-15/werkzeug/tmp/strang-ui.env && cat /root/reports/kundenansicht-neu-2026-10-15/werkzeug/README.md | head -60 && git -C /root/reports/kundenansicht-neu-2026-10-15/werkzeug status --short Expected: Datei geschrieben, README von P0 gelesen, Werkzeug-Git sauber. Alle folgenden Läufe mit KN_STRANG_ENV=/root/reports/kundenansicht-neu-2026-10-15/werkzeug/tmp/strang-ui.env; vor jedem Lauf free -m (über 2000 MB verfügbar).

  • Step 2: Ansichten und Gate schreiben

Ein Beispiel als Code, die übrigen als Tabelle (jede Zeile: genau diese Prüfungen mit diesen Werten, per assert in schritte):

{ 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.

  1. Platzhalter und Weichen-Test (P0 ↔ P1). P0 pinnt den Platzhalter über data-kn-platzhalter (test/kundenansichtDashboard.test.js, istNeu; Werkzeug test/statik.test.mjs). P1 ersetzt die Seite und stellt istNeu in Task 12 auf <meta name="kn-seite" content="neu"> um; die Werkzeug-Tests akzeptieren beide Marker (Task 13). Besser wäre, P0 nähme den Meta-Marker von Anfang an. Außerdem gelten im Werkzeug die Ansichten platzhalter und platzhalter-abgemeldet weiter (nur warte wird angepasst), sonst bricht test/gates.test.mjs von P0.
  2. Werkzeug. P1 nutzt die Form aus P0 (Ansichten, Gate p1, KN_STRANG_ENV mit WT=…/dev-kn-ui, shots.sh <ausgabe> --gate p1). P1 ändert im Werkzeug nur views.mjs, gates.mjs und den Marker in test/statik.test.mjs, keine Fälle. Zwei Folgen aus P0: Die Prüfung dark-surface meldet den roten Zähler (16 px, #b3093c), deshalb ignorieren: { 'dark-surface': ['.kn-zahl'] } in den Ansichten mit Zähler. Und paymentProblem/switchPending kommen in keinem Fall vor, deshalb überschreibt der Step die Antwort von /api/abo mit seite.route.
  3. Stubs und Besitz. P1 legt acht Ansichts-Stubs (ansicht-website|funktionen|chat|vertraege|passwort|gutschrift|wechsel|bestellen.js) und drei CSS-Stubs (ansichten.css, funktionen.css, module.css) an und verlinkt die CSS-Dateien statisch in dashboard.html (P3 VF9, P4 VF5). Danach gehören die Dateien P2, P3 und P4 (gleicher Strang, kein Merge-Konflikt). Export-Namen: zeige + Dateiname groß; die Hülle ruft die vier Bereiche und die Etagen projekt (über zeigeWebsite), passwort, gutschrift, wechsel, bestellen mit { route, me, nav } (P2 VF2 exportiert diese Funktionen alle). zeigeVertraege rendert die Reiter rechnungen|abo|stunden|email selbst. Neu in der Zuständigkeitstabelle: public/neu/manifest.webmanifest und test/support/knHuelle.js (beide P1; die Testhilfe dürfen P2 bis P4 benutzen). Bausteinklassen: basis.css führt die Namen aus P2 VF5 (kn-knopf, -primaer, -zweit, kn-textknopf, kn-pille-<farbe>, kn-feld, kn-liste, kn-zeile, kn-abschnitt, kn-meldung, kn-fehler, kn-leer). Der schwarze Balken der Leiste heißt deshalb .kn-gleiter, nicht .kn-pille. Chat-Composer: /chat-composer.js und /chat-composer.css (P2 VF9) lädt P1 noch nicht. chat-composer.css enthält dreimal das Lila (#9d3dfe), und die Prüfung lila von P0 liest jedes geladene Stylesheet als Text; die Zeilen würden das Gate p1 kippen. P2 trägt beide Zeilen in dashboard.html ein (der Test in Task 12 erlaubt sie) und löst das Lila (Vorschlag: eine bereinigte Kopie unter public/neu/ oder eine begründete Ausnahme in lila, P0).
  4. V2 ergänzen (Routen). Route-Felder produkt ('hosting'|'wartung') und scroll ('aktivitaet'), query immer als String. Zusätzliche Exporte von routen.js: hashFuerAnsicht(name) (die data-view-Brücke, auf die die gemeinsamen Module angewiesen sind: data-view="chat", "vertraege" und die Funktionsnamen), startHash, ankerKette, elternVon, titelFuer, FUNKTIONS_ETAGEN. Ein data-project an einem [data-view]-Element wird zu ?projekt=<id>. Das entspricht P3 VF2 und VF3. Änderung am „Pflichtinhalt von ROUTEN“ (V2 der Übersicht muss nachgezogen werden, Befund 2): Die Zeile vertraege, abo, wartung → vertraege, reiter abo entfällt. Neu: vertraege → vertraege ohne Reiter (die Ansicht wählt den Standardreiter); abo und wartung → vertraege, reiter abo, kanonischer Hash #abo (#wartung wird #abo, #vertraege bleibt #vertraege). Grund: Fielen #abo und #vertraege zusammen, könnte P2 waehleReiter (nur #vertraege darf bei offener Rechnung auf Rechnungen springen, Reiterklicks und #abo/#wartung nie) den ausdrücklichen Reiter Abo nicht vom Standardsprung unterscheiden. ankerKette und elternVon liefern weiter #vertraege als Bereichs-Anker. ?abo=success|cancel führt zu #abo (P2 zeigt die Rückkehr von Stripe auf dem Reiter Abo und räumt ?abo= dort auf). Für P2: Auf #vertraege ist route.reiter jetzt nicht gesetzt, waehleReiter fällt dann wie bei einem nicht sichtbaren Reiter auf abo zurück.
  5. Reiter und Etage (P2 VF3, P3 VF1/VF2, P4 VF4). Drei Pläne nehmen drei Formen von baueReiter an. P1 folgt P3: baueReiter({ eintraege: [{ key, label, kurz? }], aktiv, art, ariaLabel, bei(key) }) liefert ein HTMLElement mit den Methoden setzeAktiv, setzeAnzeige, messen; P2 passt vertraegeReiter() an. Inhalte in Etage 2 melden Reiter und Titel ohne Import: kn:reiter mit detail { eintraege, aktiv, bei } (leere Liste = keine Reiter) und kn:titel mit detail { text }, jeweils bubbles: true aus einem Nachfahren des Blatts; das alte Dashboard ignoriert sie. P4 VF4 will die Modul-Reiter dagegen im Modul lassen (Unterstrich oben). Spec 5.2, V3 und C2 verlangen sie in Etage 2. Vorschlag: Spec gilt, P4 sendet kn:reiter aus den Modulen (nur im Abo-Modell) und bekommt dafür einen Folge-Task.
  6. Zähler und „Rechnung offen“ (P2 VF6/VF7, P3 VF8). P1 zeichnet die Zeile aus Spec 5.3 nicht. P2 hat dafür public/neu/rechnung-offen.js (ladeOffeneRechnungen, rechnungOffenZeile), P3 soll sie von dort übernehmen; P3 VF8 erwartet sie von P1. Vorschlag: P2 liefert, P3 importiert. Bleibt P1 gewünscht, braucht P1 einen Folge-Task (zaehler.stand().rechnungen, zwei Zeilen im Gerüst). Zähler laden neu über document.dispatchEvent(new CustomEvent('kn:zaehler-neu', { detail: { was: 'chat' | 'vertraege' | 'alle' } })) (P2 VF6, am document, nicht am Fenster, weil CustomEvent nicht aufsteigt). Den Chat-Zähler setzt die Hülle selbst auf 0, wenn der Chat geöffnet wird.
  7. ?abo=success|cancel (P2 VF4d). Die Hülle öffnet dann Verträge auf dem Reiter Abo (#abo, Frage 4), rührt location.search am Zieleintrag aber nicht an. P2 liest den Parameter in ansicht-vertraege.js, wartet bis zu 20 s auf den Webhook (heute stripeBilling.js:637, app.js:1557) und räumt ihn danach mit history.replaceState(history.state, '', location.pathname + location.hash) auf (der Verlaufsstempel bleibt dabei erhalten).
  8. api()-Option roh. Gemeint ist ein Rohdaten-Body (octet-stream) wie bei fn-util.js:request(raw) und shop-tab-artikel.js:68, nicht eine rohe Antwort. Bitte bestätigen.
  9. Funktionsmodule und der Verlaufsstempel. termine.js, shop.js, zeiten.js und funktionen.js (News) schreiben nach dem Lesen der Rückkehr-Query history.replaceState(null, …). P1 fängt das mit stempeln() ab. P4 darf diese Aufrufe nicht entfernen oder verändern, sonst gilt Task 4 („Stempel“) nicht mehr.
  10. Manifest und Service Worker. public/neu/manifest.webmanifest hat dieselbe id, start_url und scope wie die alte, damit die installierte App dieselbe bleibt. sw.js bleibt gemeinsam und liefert bis P10 die alte, dunkle Offline-Seite.
  11. Überschriften (P2 VF3). P2 will, dass jede Ansicht eine eigene h1.kn-seitentitel rendert und P1 sie beobachtet. P1 zeichnet die große Überschrift selbst (h1.kn-titel je Bereich in dashboard.html, Titel der Etage aus titelFuer(route), ersetzbar per kn:titel) und steuert damit das Einklappen in den Kopf. Vorschlag: Ansichten rendern keine eigene h1, sonst entstehen zwei.
  12. einschalten für die Funktionsmodule (P4 VF1, P3 VF1/VF5, Befund 1 und 3). Entschieden und in Task 11 umgesetzt: Die Hülle ruft initNews, initTermine, initShop, initBuchungen und initZeiten als init*(wurzel, { einschalten }) mit einschalten = (platz, o) => lade('/neu/funktion-einschalten.js').then((m) => m.zeigeEinschalten(platz, o)) (lazy über den Lader, MIT_EINSCHALTEN); initAnalyse(wurzel) bekommt keine Option. projectId übergibt die Hülle nicht, die Projektwahl bleibt wurzel.dataset.wunschProjekt (V2). Ohne die Option zeigen die Module im Abo-Modell nur die Fehlerkarte E4, obwohl die Tests von P3 und P4 grün sind, weil sie einschalten einspritzen. V2 der Übersicht muss nachgezogen werden: Im Absatz „Funktionsmodule“ (Zeile „werden von der neuen Hülle wie heute aufgerufen“) ergänzen: initNews|initTermine|initShop|initBuchungen|initZeiten(wurzel, { einschalten }), einschalten(platz, { key, projectId, onFertig, viewAs? }) → Promise ruft zeigeEinschalten aus public/neu/funktion-einschalten.js (P3); initAnalyse(wurzel) unverändert. Die Verdrahtung pinnt test/knHuelleStart.test.js („Option einschalten“); P4 Task 15 ersetzt seine Zeile command grep -a -n einschalten public/neu/huelle.js durch den Lauf dieses Tests.