Navigation leichter: Handy-Leiste, Schwarz nur für die Hauptaktion, Verträge mit zwei Reitern, kürzeres Konto (V3, V5, V7, V8)

V3: In der Fahrstuhl-Zeile läuft unter dem aktiven Wort eine dünne Linie statt des schwarzen Blocks; das Wort wird dunkel und fett. Die weiße Overlay-Kopie bleibt im Baum, wird aber nicht mehr gezeigt.
V5: Terminplaner: Tag/Woche/Monat als hellgrauer Umschalter mit weißer Auswahl, Filter als leise Chips. „Einschalten“ in der Liste Entdecken ist ein heller Knopf; schwarz bleibt der Knopf der Bestätigungsseite.
V7: Verträge nur noch mit Abo und Rechnungen. Das Stundenkonto steht beim Wartungs-Abo als Abschnitt unter dem Abo (ohne doppelte Warnungen), #stunden springt dorthin. Der E-Mail-Speicher ist die Unterseite #speicher (Link „ändern“ in der Abo-Zeile), ohne Widerrufsstreifen wie die anderen Bestellseiten. Alte Links bleiben gültig.
V8: Konto mit vier Zeilen (Passwort, Verträge kündigen, Vertrag widerrufen, Abmelden), Datenschutz und Impressum klein darunter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-29 17:27:30 +00:00
1 parent a270ff51da
commit 83ac903ab3
18 files changed
+152 -96

No files matched your search

+38 -14
View File
@@ -3,24 +3,23 @@ import assert from 'node:assert/strict'
import { zeigeVertraege, sichtbareReiter, waehleReiter, AUF_ABO_WARTEN } from '../public/neu/ansicht-vertraege.js'
import { merkeHinweis } from '../public/neu/ansicht-hilfen.js'
import { routeAusHash } from '../public/neu/routen.js'
import { mit, me, abo, aboAntwort, rechnung, neueWurzel, LIMITS } from './support/knDaten.js'
import { mit, me, abo, aboAntwort, ledger, rechnung, neueWurzel, LIMITS } from './support/knDaten.js'
const VIER = ['rechnungen', 'abo', 'stunden', 'email']
test('sichtbareReiter nach Abo', () => {
assert.deepEqual([null, aboAntwort(), aboAntwort({ subscription: abo({ active: false }) })].map((d) => sichtbareReiter(d)), [['rechnungen', 'abo'], ['rechnungen', 'abo'], ['rechnungen', 'abo']])
assert.deepEqual(sichtbareReiter(aboAntwort({ subscription: abo({ produkt: 'hosting' }) })), ['rechnungen', 'abo', 'email'])
assert.deepEqual(sichtbareReiter(aboAntwort({ subscription: abo() })), VIER)
const ZWEI = ['abo', 'rechnungen']
test('sichtbareReiter: immer Abo und Rechnungen (Navigation leichter V7), unabhängig vom Abo', () => {
const faelle = [null, aboAntwort(), aboAntwort({ subscription: abo({ active: false }) }), aboAntwort({ subscription: abo({ produkt: 'hosting' }) }), aboAntwort({ subscription: abo() })]
for (const d of faelle) assert.deepEqual(sichtbareReiter(d), ZWEI)
})
test('Vorbedingung (VF8): der Bereichs-Hash hat keinen Reiter, die Reiter-Hashes haben einen', () => {
assert.equal(routeAusHash('#vertraege').reiter, undefined)
for (const [hash, reiter] of [['#abo', 'abo'], ['#wartung', 'abo'], ['#rechnungen', 'rechnungen'], ['#stunden', 'stunden'], ['#speicher', 'email']]) assert.equal(routeAusHash(hash).reiter, reiter, hash)
for (const [hash, reiter] of [['#abo', 'abo'], ['#wartung', 'abo'], ['#rechnungen', 'rechnungen'], ['#stunden', 'stunden']]) assert.equal(routeAusHash(hash).reiter, reiter, hash)
assert.deepEqual(routeAusHash('#speicher'), { bereich: 'vertraege', etage: 'speicher', query: '' }) // der Speicher ist eine Unterseite
})
test('waehleReiter: nur der Bereichs-Einstieg springt, jede ausdrückliche Wahl bleibt (Review Focus 2)', () => {
const w = (o) => waehleReiter({ routeReiter: undefined, sichtbar: VIER, offeneRechnungen: 1, ...o })
const w = (o) => waehleReiter({ routeReiter: undefined, sichtbar: ZWEI, offeneRechnungen: 1, ...o })
assert.equal(w({}), 'rechnungen'); assert.equal(w({ offeneRechnungen: 0 }), 'abo')
assert.equal(w({ routeReiter: 'abo' }), 'abo') // #abo, #wartung und der Klick auf „Abo“ trotz offener Rechnung
assert.equal(w({ routeReiter: 'stunden' }), 'stunden'); assert.equal(w({ routeReiter: 'email' }), 'email'); assert.equal(w({ routeReiter: 'rechnungen', offeneRechnungen: 0 }), 'rechnungen')
assert.equal(w({ routeReiter: 'stunden', sichtbar: ['rechnungen', 'abo'] }), 'abo')
assert.equal(w({ routeReiter: 'stunden' }), 'abo'); assert.equal(w({ routeReiter: 'rechnungen', offeneRechnungen: 0 }), 'rechnungen') // #stunden öffnet das Abo
})
const SERVER = { 'GET /api/abo': aboAntwort(), 'GET /api/invoices': { invoices: [rechnung({ balance: 50 })], incomplete: false }, 'GET /api/auftraege': { aktiv: false }, 'GET /api/abo/takeover-credits': { requests: [], limits: LIMITS } }
const zeige = async (fx, route = {}) => { const w = neueWurzel(fx); await zeigeVertraege(w, { route: { bereich: 'vertraege', query: '', ...route }, me: me(), nav: 'tab' }); await fx.ruhe(); return w }
@@ -29,7 +28,7 @@ const WOHIN = (fx) => { fx.window.history.replaceState = (_s, _t, url) => { fx.w
test('Verträge: Reiter in fester Reihenfolge, Kündigen-Link unter jedem Reiter, ausdrückliche Reiterwahl bleibt trotz offener Rechnung', mit(SERVER, async (fx) => {
for (const [route, gewaehlt] of [[{ reiter: 'abo' }, 'Abo'], [{ reiter: 'rechnungen' }, 'Rechnungen'], [{}, 'Rechnungen']]) { // {} = Bereichs-Einstieg
const w = await zeige(fx, route)
assert.deepEqual(fx.alle(w, '[role="tab"]').map((t) => t.textContent), ['Rechnungen', 'Abo'])
assert.deepEqual(fx.alle(w, '[role="tab"]').map((t) => t.textContent), ['Abo', 'Rechnungen'])
assert.equal(aktiv(fx, w), gewaehlt)
assert.equal(fx.finde(w, 'a[href="/kuendigen.html"]').textContent, 'Verträge hier kündigen')
}
@@ -38,9 +37,10 @@ test('Verträge: die Route entscheidet, die Adressleiste nicht (keine zweite Wah
fx.window.location.hash = '#vertraege'; assert.equal(aktiv(fx, await zeige(fx, { reiter: 'abo' })), 'Abo') // widerspricht der Route absichtlich
fx.window.location.hash = '#abo'; assert.equal(aktiv(fx, await zeige(fx, {})), 'Rechnungen')
}))
test('Reiterklick navigiert ausdrücklich und ersetzt: #rechnungen, #abo, #stunden, #speicher', mit({ ...SERVER, 'GET /api/abo': aboAntwort({ subscription: abo() }) }, async (fx) => {
test('Reiterklick navigiert ausdrücklich und ersetzt: #abo, #rechnungen; auch mit Wartungs-Abo nur zwei Reiter', mit({ ...SERVER, 'GET /api/abo': aboAntwort({ subscription: abo() }) }, async (fx) => {
WOHIN(fx); const w = await zeige(fx, { reiter: 'rechnungen' })
for (const [label, hash] of [['Abo', '#abo'], ['Stunden', '#stunden'], ['E-Mail', '#speicher'], ['Rechnungen', '#rechnungen']]) {
assert.deepEqual(fx.alle(w, '[role="tab"]').map((t) => t.textContent), ['Abo', 'Rechnungen'])
for (const [label, hash] of [['Abo', '#abo'], ['Rechnungen', '#rechnungen']]) {
await fx.klick(fx.finde(w, (e) => e.getAttribute?.('role') === 'tab' && e.textContent === label)); assert.equal(fx.window.location.hash, hash, label)
}
}))
@@ -48,9 +48,33 @@ test('Verträge: /api/abo fällt aus, Reiter, Rechnungen und Kündigen bleiben',
const w = await zeige(fx, { reiter: 'rechnungen' })
assert.match(fx.text(w), /Deine Verträge konnten gerade nicht geladen werden\./); assert.ok(fx.finde(w, 'a[href="/kuendigen.html"]')); assert.ok(fx.finde(w, 'tbody tr'))
}))
test('route.reiter stunden ohne Wartung: Abo-Reiter mit Hinweis', mit({ ...SERVER, 'GET /api/abo': aboAntwort({ subscription: abo({ produkt: 'hosting' }) }) }, async (fx) => {
test('route.reiter stunden ohne Wartung: Abo-Reiter mit Hinweis, kein Stundenkonto', mit({ ...SERVER, 'GET /api/abo': aboAntwort({ subscription: abo({ produkt: 'hosting' }) }) }, async (fx) => {
const w = await zeige(fx, { reiter: 'stunden' })
assert.equal(aktiv(fx, w), 'Abo'); assert.match(fx.text(w), /Ein Stundenkonto gibt es im Wartungs-Abo\./)
assert.equal(fx.finde(w, '#kn-stundenkonto'), null)
}))
const LEDGER = ledger({ balanceMinutes: 90, usedMinutes: 120, usage: [], credits: [] })
test('Navigation leichter V7: Wartungs-Abo zeigt das Stundenkonto unter dem Abo, Warnungen und Bedingungen-Link nur einmal', mit({ ...SERVER, 'GET /api/abo': aboAntwort({ subscription: abo({ paymentProblem: true }), ledger: LEDGER }) }, async (fx) => {
for (const route of [{ reiter: 'abo' }, { reiter: 'stunden' }]) {
const w = await zeige(fx, route)
assert.equal(aktiv(fx, w), 'Abo')
const konto = fx.finde(w, '#kn-stundenkonto')
assert.ok(konto, 'Abschnitt Stundenkonto'); assert.equal(fx.finde(konto, 'h2').textContent, 'Stundenkonto')
assert.match(fx.text(konto), /verfügbare Zeit/); assert.match(fx.text(konto), /Verlauf/)
assert.equal(fx.text(w).split('Die letzte Abbuchung ist fehlgeschlagen').length - 1, 1, 'Zahlungswarnung genau einmal')
assert.doesNotMatch(fx.text(konto), /Leistungen & Bedingungen/)
assert.doesNotMatch(fx.text(w), /Ein Stundenkonto gibt es im Wartungs-Abo/)
}
}))
let speicherAbo = null
test('Navigation leichter V7: „ändern“ am E-Mail-Speicher führt auf #speicher, mit Hosting- und Wartungs-Abo', mit({ ...SERVER, 'GET /api/abo': () => ({ body: speicherAbo }) }, async (fx) => {
for (const produkt of ['hosting', 'wartung']) {
speicherAbo = aboAntwort({ subscription: abo({ produkt }), ledger: LEDGER })
WOHIN(fx); const w = await zeige(fx, { reiter: 'abo' })
const link = fx.finde(w, 'a[data-aktion="speicher"]')
assert.deepEqual([link.textContent, link.getAttribute('href')], ['ändern', '#speicher'], produkt)
await fx.klick(link); assert.equal(fx.window.location.hash, '#speicher', produkt)
}
}))
test('Hinweis aus einer Aktion steht genau einmal über den Reitern', mit(SERVER, async (fx) => {
merkeHinweis('Dein Wechsel ist abgeschlossen.'); assert.match(fx.text(await zeige(fx, { reiter: 'abo' })), /Dein Wechsel ist abgeschlossen\./)
+2 -2
View File
@@ -33,8 +33,8 @@ test('F3: Fokusring der Shell-Reiter liegt innen (Spezifität über .kn [tabinde
// keine Regel mit niedrigerer Spezifität, die den Versatz setzt
assert.doesNotMatch(HUELLE, /\n\.kn-t:focus-visible/)
assert.doesNotMatch(BASIS, /\n\.kn-reiter-t:focus-visible/)
// der aktive Reiter liegt auf dem schwarzen Gleiter: heller Innenring dort
assert.match(HUELLE, /\.kn \.kn-balken:has\(\.kn-t:focus-visible\) \.kn-gleiter\s*\{[^}]*outline:\s*2px solid #fff[^}]*outline-offset:\s*-3px/)
// Navigation leichter V3: der Gleiter ist nur noch eine Linie unter dem Wort, der Ring des Reiters selbst genügt (kein heller Ring am Gleiter)
assert.doesNotMatch(HUELLE, /\.kn-gleiter\s*\{[^}]*outline:\s*2px solid #fff/)
})
test('F3: Pflicht- und Zahlungslinks haben am Handy eine Tippfläche von 44 px', () => {
+9 -6
View File
@@ -5,10 +5,11 @@ import { h } from '../public/neu/dom.js'
import { initialenVon, installAngebot, kontoEintraege, baueAvatar, baueKonto } from '../public/neu/konto.js'
import { lies, handyTeil } from './support/knHuelle.js'
test('Reihenfolge, Texte und Ziele des Kontos (V3); Widerrufen immer da, "Frist läuft" nur in der Frist', () => {
test('Reihenfolge, Texte und Ziele des Kontos (V3 mit Navigation leichter V8); 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'])
assert.deepEqual(ids({ fristen: null, install: null }), ['passwort', 'kuendigen', 'widerrufen', 'abmelden', 'datenschutz', 'impressum'])
assert.deepEqual(ids({ fristen: null, install: 'ios' }), ['passwort', 'installieren', 'kuendigen', 'widerrufen', 'abmelden', 'datenschutz', 'impressum'])
assert.deepEqual(kontoEintraege({ fristen: null, install: null }).filter((e) => e.klein).map((e) => e.id), ['datenschutz', 'impressum'])
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'])
@@ -37,12 +38,13 @@ test('baueKonto: Kopf als Text, Sheet-Verhalten, Overlay-Kopplung, Navigation, A
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(zeilen().map((z) => z.textContent), ['Passwort ändern', 'Verträge kündigen', 'Vertrag widerrufen', 'Abmelden', 'Datenschutz', 'Impressum'])
assert.deepEqual(zeilen().filter((z) => z.classList.contains('kn-konto-zeile--klein')).map((z) => z.textContent), ['Datenschutz', 'Impressum'])
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')
await fx.klick(zeilen().find((z) => z.getAttribute('data-konto') === 'abmelden')); 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() }
@@ -62,7 +64,8 @@ test('Konto: Router schließt ohne zweites Zurück, Links öffnen richtig, Tab b
overlay: { oeffne: (o) => { vomRouter = o.schliesse }, beende: () => log.push('beende') }, navigiere() {} })
fx.document.body.append(k.wurzel); const zeilen = () => fx.alle(k.wurzel, '.kn-konto-zeile')
assert.equal(fx.finde(k.wurzel, '.kn-konto-name').textContent, 'a@b.de') // ohne Firma und Namen die E-Mail
const [pw, ds, im, inst] = zeilen()
const zeile = (id) => zeilen().find((z) => z.getAttribute('data-konto') === id)
const [pw, ds, im, inst] = ['passwort', 'datenschutz', 'impressum', 'installieren'].map(zeile)
assert.deepEqual([ds.getAttribute('target'), ds.getAttribute('rel'), im.getAttribute('target'), pw.hasAttribute('target')], ['_blank', 'noopener', '_blank', false])
assert.match(inst.textContent, /Tippe auf Teilen und dann auf Zum Home-Bildschirm\./)
k.oeffne(trigger); assert.equal(fx.document.activeElement, pw) // Fokus auf der ersten Zeile
+6 -2
View File
@@ -36,12 +36,16 @@ test('tastaturZustand: öffnen unter voll-150, Android-Geste ohne Blur, Drehen,
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-t\s*\{(?=[^}]*min-height:\s*44px)(?=[^}]*font-size:\s*15px)(?=[^}]*font-weight:\s*500)/s)
assert.match(css, /\.kn-t\[aria-selected="true"\]\s*\{(?=[^}]*color:\s*var\(--kn-text\))(?=[^}]*font-weight:\s*600)/s) // Navigation leichter V3: aktives Wort dunkel und fett
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)
// Navigation leichter V3: der Gleiter ist eine dünne Linie unter dem Wort, die weiße Overlay-Kopie wird nicht gezeigt
assert.match(css, /\.kn-gleiter\s*\{(?=[^}]*bottom:\s*2px)(?=[^}]*height:\s*2px)(?=[^}]*background:\s*var\(--kn-accent\))/s)
assert.match(css, /\.kn-overlay\s*\{[^}]*display:\s*none/s)
assert.doesNotMatch(css, /\.kn-t\[aria-selected="true"\]\s*\{[^}]*#fff/s) // das aktive Wort ist nie mehr weiß
assert.match(css, /\.kn-tastatur \.kn-leiste[^{]*\{[^}]*(translateY\(110%\)|display:\s*none)/s)
assert.match(css, /@media \(prefers-reduced-motion: reduce\)[\s\S]*150ms/)
})
+3 -2
View File
@@ -10,7 +10,8 @@ const FAELLE = [ // Alte Links (Review Focus 1)
['#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: '' }],
['#speicher', { bereich: 'vertraege', etage: 'speicher', query: '' }], // Navigation leichter V7: Unterseite statt Reiter E-Mail
['#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: '' }],
@@ -69,5 +70,5 @@ test('zeigtWiderrufsstreifen: nicht auf Abo bestellen, Wechsel, Zusatzspeicher u
assert.equal(zeigtWiderrufsstreifen(undefined), true) // ohne Route nichts verstecken
// jede Etage der Tabelle ist entschieden: versteckt sind genau die Bestellseiten
const versteckt = [...new Set(Object.values(ROUTEN).filter((r) => !zeigtWiderrufsstreifen(r)).map((r) => r.etage ?? `reiter:${r.reiter}`))].sort()
assert.deepEqual(versteckt, ['bestellen', 'gutschrift', 'reiter:email', 'wechsel'])
assert.deepEqual(versteckt, ['bestellen', 'gutschrift', 'speicher', 'wechsel'])
})