Kundenansicht neu: Wasser-Leiste breiter mit Wörtern, Unterseiten im Vollbild

Wunsch vom 29.10. nach dem ersten Blick am iPhone:
- Die Leiste nutzt die Breite (12 px Rand, höchstens 480 px), Symbol über
  dem Wort, und steht ganz über dem Wisch-Balken des iPhones
  (Safe-Area plus 6 px statt minus 10 px).
- Unterseiten öffnen sich ganz und werden mit Unschärfe eingeblendet statt
  hochzufahren. Oben links die Überschrift, oben rechts „Zurück“ als
  Glasknopf, der beim Scrollen stehen bleibt (neuer Platz
  #kn-blatt-zurueck).
- Reiter einer Funktion stehen unten in derselben Wasser-Leiste (Art
  'reiter', Kurzwörter, kleinere Schrift wenn es eng wird), ohne Zurück.
  Ohne Reiter bleibt unten nichts. Die alte Leiste gibt beim Neuaufbau
  ihren WebGL-Kontext frei.

Glas und Unschärfe stehen weiter allein in wasser-leiste.css; huelle.css
bleibt ohne Glas. Desktop unverändert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-29 18:09:42 +00:00
1 parent c997703251
commit 1f350ca432
10 files changed
+147 -95

No files matched your search

+1
View File
@@ -73,6 +73,7 @@
<section data-etage="analyse" hidden><div id="analyse-body"></div></section>
<section data-etage="seite" hidden><div id="etage-body"></div></section>
</div>
<div id="kn-blatt-zurueck" class="kn-blatt-zurueck"></div>
</div>
</div>
+19 -15
View File
@@ -1,29 +1,32 @@
/* Kundenansicht neu: Etage 2. Eine Unterseite (Funktion, Detailseite, Passwort …) schiebt sich als Blatt über die Hauptseite.
Handy: Die Zeile unten fährt eine Etage hoch und zeigt „Zurück“ und, falls die Seite Reiter meldet, deren Kurzlabels als Balken.
Desktop: Brotkrume statt Zeile, Reiter als Unterstrich im Slot unter der Überschrift.
/* Kundenansicht neu: Etage 2. Eine Unterseite (Funktion, Detailseite, Passwort …) legt sich als Blatt über die Hauptseite.
Handy: Das Blatt deckt den ganzen Bildschirm, „Zurück“ steht oben rechts (zurueckSlot), und meldet die Seite Reiter, zeigt die Zeile unten
deren Kurzlabels als Wasser-Leiste (wasser-leiste.js, Art 'reiter') an Stelle der vier Bereiche; ohne Reiter bleibt unten nichts.
Desktop: Brotkrume statt Zurück, Reiter als Unterstrich im Slot unter der Überschrift.
Der Inhalt meldet Reiter und Titel ohne Import per CustomEvent (bubbles): kn:reiter { eintraege, aktiv, bei } (leere Liste = keine Reiter)
und kn:titel { text }. Alles Sichtbare wird mit h() und textContent gebaut. */
import { h, leeren, text } from './dom.js'
import { baueReiter } from './reiter.js'
import { baueWasserLeiste } from './wasser-leiste.js'
const gleicheEintraege = (a, b) => a.length === b.length && a.every((x, i) => x.key === b[i].key && x.label === b[i].label && (x.kurz ?? '') === (b[i].kurz ?? ''))
export function baueEtage({ blatt, haupt, zeile, titel, brot, reiterSlot, istHandy, onZurueck }) {
export function baueEtage({ blatt, haupt, zeile, zurueckSlot, titel, brot, reiterSlot, istHandy, onZurueck }) {
const stand = { offen: false, titel: '', eltern: null, seite: '', reiter: null, chatPunkt: false, reiterEl: null, zurueckEl: null }
const zurueckLabel = () => `Zurück zu ${stand.eltern.label}${stand.chatPunkt ? ', neue Nachricht im Chat' : ''}`
function baueZurueck(voll) {
return h('button', { class: voll ? 'kn-zurueck kn-zurueck--voll' : 'kn-zurueck', type: 'button', 'aria-label': zurueckLabel(), on: { click: () => onZurueck() } },
function baueZurueck() {
return h('button', { class: 'kn-zurueck', type: 'button', 'aria-label': zurueckLabel(), on: { click: () => onZurueck() } },
h('span', { class: 'kn-l' }, 'Zurück', stand.chatPunkt ? h('span', { class: 'kn-punkt' }) : null))
}
function neuerReiter(art) {
const { eintraege, aktiv } = stand.reiter
// Handy zeigt die Kurzlabels (der Balken hat wenig Platz), Desktop die vollen Beschriftungen
const zeigen = art === 'balken' ? eintraege : eintraege.map(({ kurz, ...rest }) => rest)
return baueReiter({ eintraege: zeigen, aktiv, art, ariaLabel: 'Reiter', bei: (key) => stand.reiter?.bei?.(key) })
const bei = (key) => stand.reiter?.bei?.(key)
// Handy: Wasser-Leiste mit den Kurzlabels (vorgelesen wird das volle Wort); Desktop: Unterstrich mit den vollen Beschriftungen
if (art === 'wasser') return baueWasserLeiste({ eintraege: eintraege.map(({ key, label, kurz }) => ({ key, label, kurz })), aktiv, art: 'reiter', ariaLabel: 'Reiter', bei })
return baueReiter({ eintraege: eintraege.map(({ kurz, ...rest }) => rest), aktiv, art, ariaLabel: 'Reiter', bei })
}
function zeichneBrot() {
@@ -37,18 +40,19 @@ export function baueEtage({ blatt, haupt, zeile, titel, brot, reiterSlot, istHan
brot.append(h('a', { href: stand.eltern.hash, on: { click: verlassen } }, stand.eltern.label), h('span', { class: 'kn-brot-trenner', 'aria-hidden': 'true' }, ' / '), h('span', { 'aria-current': 'page' }, stand.titel))
}
// Zeile (Handy) und Reiter-Slot (Desktop) aus dem Stand neu aufbauen
// Zurück und Zeile (Handy) bzw. Reiter-Slot (Desktop) aus dem Stand neu aufbauen
function zeichne() {
leeren(zeile); leeren(reiterSlot)
stand.reiterEl?.beende?.() // die alte Wasser-Leiste gibt ihren WebGL-Kontext frei
leeren(zeile); leeren(reiterSlot); if (zurueckSlot) leeren(zurueckSlot)
stand.zurueckEl = null; stand.reiterEl = null
zeichneBrot()
if (!stand.offen) return
if (istHandy()) {
stand.zurueckEl = baueZurueck(!stand.reiter)
zeile.append(stand.zurueckEl)
stand.zurueckEl = baueZurueck()
;(zurueckSlot ?? zeile).append(stand.zurueckEl)
if (stand.reiter) {
stand.reiterEl = neuerReiter('balken')
zeile.append(h('span', { class: 'kn-vl' }), stand.reiterEl)
stand.reiterEl = neuerReiter('wasser')
zeile.append(stand.reiterEl)
}
} else if (stand.reiter) {
stand.reiterEl = neuerReiter('unterstrich')
+23 -10
View File
@@ -1,4 +1,5 @@
/* Kundenansicht neu: Hülle (Menü, Kopf, Blatt, Leiste, Streifen, Konto). Weiß, Haarlinie, Schwarz; keine Verläufe, kein Glas.
/* Kundenansicht neu: Hülle (Menü, Kopf, Blatt, Leiste, Streifen, Konto). Weiß, Haarlinie, Schwarz; keine Verläufe, kein Glas (das Glas der Handy-Leiste
und des Zurück-Knopfs steht allein in wasser-leiste.css).
Die Grundregeln gelten am Desktop, die Handy-Abfrage überschreibt für das Handy. */
/* ---------- Blatt (Etage 2) ---------- */
@@ -13,20 +14,28 @@
.kn-zurueck { position: relative; display: flex; align-items: center; gap: 6px; height: 44px; padding: 0 8px 0 6px; font-size: 15px; font-weight: 600; color: var(--kn-text); }
.kn-zurueck::before { content: ""; width: 9px; height: 9px; margin-left: 4px; border: solid; border-width: 0 0 2px 2px; transform: rotate(45deg); }
.kn-zurueck--voll { flex: 1; }
.kn-zurueck .kn-l { position: relative; line-height: 20px; }
.kn-vl { flex: none; width: 1px; height: 24px; margin: 0 4px; background: var(--kn-line); }
.kn-blatt-zurueck { display: none; } /* „Zurück“ der Unterseite, nur am Handy (etage.js); der Desktop hat die Brotkrume */
.kn-punkt { position: absolute; top: -2px; left: 100%; width: 9px; height: 9px; margin-left: 2px; border-radius: 50%; background: #b3093c; box-shadow: 0 0 0 2px var(--kn-bg); }
@media (max-width: 880px), (pointer: coarse) and (max-height: 500px) {
.kn { --kn-unten: calc(var(--kn-zeile-h) + env(safe-area-inset-bottom)); }
/* Die Unterseite öffnet sich ganz (unter der Statusleiste) und wird eingeblendet (die Unschärfe dazu in wasser-leiste.css). Oben links die
Überschrift, oben rechts „Zurück“; er bleibt beim Scrollen stehen. Reiter stehen unten in der Wasser-Leiste. */
.kn { --kn-zurueck-oben: 17px; } /* so steht der 40 px hohe Knopf auf der Mitte der ersten Zeile der Überschrift */
.kn-blatt {
position: fixed; inset: calc(var(--kn-banner-h) + var(--kn-kopf-h) + env(safe-area-inset-top)) 0 var(--kn-unten, var(--kn-zeile-h)); z-index: 20;
overflow-y: auto; overscroll-behavior: contain; border-radius: 12px 12px 0 0;
box-shadow: 0 0 0 1px rgba(26,31,54,.06), 0 -8px 24px rgba(26,31,54,.1);
transform: translateY(100%); transition: transform 550ms var(--kn-bremse);
position: fixed; inset: calc(var(--kn-banner-h) + env(safe-area-inset-top)) 0 0; z-index: 20; overflow-y: auto; overscroll-behavior: contain;
opacity: 0; transition: opacity 380ms ease;
}
.kn-blatt--auf { transform: none; }
.kn-blatt--auf { opacity: 1; }
.kn-blatt > .kn-titel { padding-right: 108px; }
.kn-blatt-zurueck {
display: block; position: fixed; top: calc(var(--kn-banner-h) + env(safe-area-inset-top) + var(--kn-zurueck-oben)); right: max(16px, env(safe-area-inset-right)); z-index: 25;
visibility: hidden; opacity: 0; transition: opacity 380ms ease, visibility 0s 380ms;
}
.kn-blatt--auf ~ .kn-blatt-zurueck { visibility: visible; opacity: 1; transition-delay: 0s; }
.kn-blatt-zurueck .kn-zurueck { height: 40px; padding: 0 16px 0 10px; border-radius: 999px; background: var(--kn-bg); box-shadow: var(--kn-shadow-pop); }
.kn .kn-blatt-zurueck .kn-zurueck:focus-visible { outline-offset: 2px; }
.kn-haupt[inert] { display: block; } /* am Handy bleibt die Hauptseite unter dem Blatt stehen */
.kn-brot { display: none; }
}
@@ -131,7 +140,7 @@
.kn-kopf-titel { display: block; font-size: 15px; line-height: 22px; font-weight: 600; opacity: 0; transform: translateY(5px); transition: opacity 250ms, transform 250ms; }
.kn-titled .kn-kopf-titel { opacity: 1; transform: none; }
.kn-haupt { max-width: none; padding: 2px max(16px, env(safe-area-inset-right)) calc(var(--kn-zeile-h) + env(safe-area-inset-bottom) + 28px) max(16px, env(safe-area-inset-left)); }
.kn-blatt { max-width: none; padding: 0 max(16px, env(safe-area-inset-right)) 24px max(16px, env(safe-area-inset-left)); }
.kn-blatt { max-width: none; padding: 14px max(16px, env(safe-area-inset-right)) calc(var(--kn-unten, var(--kn-zeile-h)) + 24px) max(16px, env(safe-area-inset-left)); }
.kn-haupt .kn-titel, .kn-blatt .kn-titel { margin: 6px 0 10px; font-size: 28px; line-height: 34px; }
.kn-banner button { min-height: 44px; }
#kn-fehler { margin: 12px max(16px, env(safe-area-inset-right)) 0 max(16px, env(safe-area-inset-left)); }
@@ -204,7 +213,9 @@
@media (max-height: 500px) and (max-width: 880px), (max-height: 500px) and (pointer: coarse) {
.kn { --kn-zeile-h: 44px; --kn-kopf-h: 44px; }
.kn-leiste-reel[data-etage="offen"] .kn-rw { transform: translateY(-44px); }
.kn-haupt .kn-titel, .kn-blatt .kn-titel { display: none; }
.kn-haupt .kn-titel { display: none; }
.kn-blatt .kn-titel { margin: 0 0 6px; font-size: 20px; line-height: 26px; } /* die Unterseite deckt den Kopf ab: ihre Überschrift bleibt, kleiner */
.kn { --kn-zurueck-oben: 7px; }
.kn-kopf-titel { opacity: 1; transform: none; }
}
@@ -217,6 +228,8 @@
.kn-leiste-reel[data-etage="offen"] #kn-zeile-haupt, .kn-leiste-reel:not([data-etage="offen"]) #kn-zeile-etage { transition: none; }
.kn-blatt { transform: none; opacity: 0; transition: opacity 150ms; }
.kn-blatt--auf { opacity: 1; }
.kn-blatt-zurueck { transition: opacity 150ms, visibility 0s 150ms; }
.kn-blatt--auf ~ .kn-blatt-zurueck { transition: opacity 150ms; }
.kn-konto-scrim, .kn-konto { animation: kn-einblenden 150ms ease-out; }
.kn-zahl { animation: none; }
.kn-zahl--weg { transition: none; }
+2 -2
View File
@@ -14,7 +14,7 @@ import { erzeugeZaehler } from './zaehler.js'
import { baueStreifen, ladeFristen, ladeFristenNeu } from './fristen-streifen.js'
import { baueAvatar, baueKonto, installAngebot } from './konto.js'
export const 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']
export const 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', 'kn-blatt-zurueck']
// Bereich → [Modul, Funktion, Wurzel]; die Ansicht heißt ansicht-<name>.js und exportiert zeige<Name>
export const ANSICHTEN = {
@@ -128,7 +128,7 @@ export async function starteHuelle({ win = window, doc = document, api = apiStan
const streifen = baueStreifen(el('kn-streifen'), { koerper: doc.body })
streifen.setze(null)
const navigation = baueNavigation({ haupt: el('kn-zeile-haupt'), menue: el('kn-menue'), onWahl: (bereich) => router.geheZu(hashFuerRoute({ bereich })) })
const etage = baueEtage({ blatt: el('kn-blatt'), haupt: el('kn-inhalt'), zeile: el('kn-zeile-etage'), titel: el('kn-etage-titel'), brot: el('kn-brot'), reiterSlot: el('kn-reiter-slot'), istHandy, onZurueck: () => router.zurueck() })
const etage = baueEtage({ blatt: el('kn-blatt'), haupt: el('kn-inhalt'), zeile: el('kn-zeile-etage'), zurueckSlot: el('kn-blatt-zurueck'), titel: el('kn-etage-titel'), brot: el('kn-brot'), reiterSlot: el('kn-reiter-slot'), istHandy, onZurueck: () => router.zurueck() })
let aktuell = null // die Route, die gerade gezeigt wird
let herkunft = '' // Hash der Route davor (Rückweg der Passwort-Seite)
const zaehler = erzeugeZaehler({
+33 -37
View File
@@ -1,7 +1,7 @@
/* Kundenansicht neu: Wasser-Leiste (wasser-leiste.js). Pille aus Glas mit Wassertropfen, nach der Komponente „Nimbus UI · Liquid Navbar“
(guglielmogiannattasio.it). Die Regeln der Pille sind die der Vorlage mit kn-wl-Namen; darunter der Einbau in die Hülle am Handy:
Die Leiste ist keine weiße Zeile mehr, sondern eine schwebende Pille über dem Inhalt. Unterseiten (Etage 2) zeigen statt der Bereiche eine
gläserne Pille mit „Zurück“ und Reitern; der aktive Reiter liegt in einem Tropfen wie der CSS-Tropfen der Pille. Lädt nach huelle.css. */
(guglielmogiannattasio.it). Die Regeln der Pille sind die der Vorlage mit kn-wl-Namen; darunter die zwei Arten und der Einbau in die Hülle
am Handy: Die Leiste ist keine weiße Zeile mehr, sondern eine breite schwebende Pille über dem Inhalt. Hauptseiten zeigen die vier Bereiche
(Symbol über dem Wort), Unterseiten mit Reitern deren Wörter in derselben Pille; „Zurück“ steht dort oben rechts (huelle.css). Lädt nach huelle.css. */
/* ---------- Pille ---------- */
@@ -69,66 +69,59 @@
.kn-wl-ico[data-fx="wave"] svg { animation: kn-wl-wink .9s ease-in-out both; transform-origin: 50% 92%; }
@keyframes kn-wl-wink { 0% { transform: none; } 20% { transform: rotate(-15deg); } 40% { transform: rotate(12deg); } 60% { transform: rotate(-8deg); } 80% { transform: rotate(4deg); } 100% { transform: translateY(-1px) scale(1.08); } }
/* Schmal (wie die Vorlage unter 520 px): nur Symbole, die Wörter bleiben für Screenreader im Knopf */
@media (max-width: 520px) {
.kn .kn-wl-item { padding: 0 13px; }
.kn-wl-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
}
/* ---------- Zwei Arten ---------- */
/* Bereiche (Hauptleiste): Symbol über dem Wort, alle gleich breit */
.kn-wl[data-art="bereiche"] .kn-wl-item { flex: 1 1 0; flex-direction: column; justify-content: center; gap: 4px; min-width: 0; height: 52px; padding: 0 4px; font-size: 11px; line-height: 13px; letter-spacing: 0; }
.kn-wl[data-art="bereiche"] .kn-wl-item[aria-selected="true"] { font-weight: 600; }
.kn-wl[data-art="bereiche"] .kn-wl-ico svg { width: 21px; height: 21px; }
/* Reiter einer Unterseite: nur Wörter; passen sie nicht, setzt wasser-leiste.js data-eng */
.kn-wl[data-art="reiter"] .kn-wl-item { flex: 1 1 auto; justify-content: center; min-width: 0; padding: 0 9px; font-size: 13px; }
.kn-wl[data-art="reiter"][data-eng] .kn-wl-item { padding: 0 5px; font-size: 12px; letter-spacing: -.02em; }
.kn-wl-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
/* ---------- Einbau in die Hülle (Handy) ----------
Die Pille schwebt: --kn-wl-luft Abstand zum unteren Rand (über dem Home-Balken des iPhones), darüber 10 px Luft zum Inhalt.
--kn-wl-zone ist die Höhe dieser Zone; --kn-unten (Blatt, Chat) und der Abstand unter dem Inhalt rechnen damit statt mit der alten Zeile. */
Die Pille schwebt mit --kn-wl-luft Abstand über dem unteren Rand: auf dem iPhone ganz über dem Wisch-Balken (Safe-Area plus 6 px), sonst 14 px.
Darüber 10 px Luft zum Inhalt. --kn-wl-zone ist die Höhe dieser Zone; --kn-unten (Blatt, Chat) und der Abstand unter dem Inhalt rechnen damit. */
.kn { --kn-wl-h: 54px; }
.kn { --kn-wl-h: 64px; }
@media (max-width: 880px), (pointer: coarse) and (max-height: 500px) {
.kn { --kn-zeile-h: calc(var(--kn-wl-h) + 10px); --kn-wl-luft: max(10px, calc(env(safe-area-inset-bottom) - 10px)); --kn-wl-zone: calc(var(--kn-zeile-h) + var(--kn-wl-luft)); --kn-unten: var(--kn-wl-zone); }
.kn { --kn-zeile-h: calc(var(--kn-wl-h) + 10px); --kn-wl-luft: max(14px, calc(env(safe-area-inset-bottom) + 6px)); --kn-wl-zone: calc(var(--kn-zeile-h) + var(--kn-wl-luft)); --kn-unten: var(--kn-wl-zone); }
.kn.kn-hat-streifen { --kn-unten: calc(var(--kn-wl-zone) + 44px); }
.kn.kn-tastatur { --kn-unten: 0px; }
.kn-haupt { padding-bottom: calc(var(--kn-wl-zone) + 28px); }
.kn-hat-streifen .kn-haupt { padding-bottom: calc(var(--kn-wl-zone) + 28px + 44px); }
.kn-streifen { bottom: var(--kn-wl-zone); }
/* Das Blatt reicht bis unten, sein Inhalt läuft unter der Pille durch */
.kn-blatt { bottom: 0; padding-bottom: calc(var(--kn-unten) + 24px); }
.kn-leiste { border-top: 0; background: none; padding-bottom: var(--kn-wl-luft); pointer-events: none; }
.kn-leiste-reel { position: relative; overflow: visible; }
.kn-leiste .kn-rw {
position: absolute; right: 12px; bottom: 0; left: 12px; width: fit-content; max-width: calc(100% - 24px); height: var(--kn-wl-h); margin: 0 auto; padding: 0;
pointer-events: auto; transition: transform 450ms var(--kn-feder), opacity 250ms ease, visibility 0s;
position: absolute; right: 12px; bottom: 0; left: 12px; max-width: 480px; height: var(--kn-wl-h); margin: 0 auto; padding: 0; align-items: flex-end;
pointer-events: none; transition: transform 450ms var(--kn-feder), opacity 250ms ease, visibility 0s;
}
.kn-leiste .kn-rw > * { pointer-events: auto; }
.kn-leiste .kn-wl { display: flex; width: 100%; }
/* huelle.css schiebt beide Zeilen eine Etage hoch; hier liegen die Pillen übereinander und tauschen mit kurzem Auf- und Abtauchen */
.kn-leiste-reel[data-etage="offen"] .kn-rw { transform: none; }
.kn-leiste-reel[data-etage="offen"] #kn-zeile-haupt, .kn-leiste-reel:not([data-etage="offen"]) #kn-zeile-etage {
opacity: 0; transform: translateY(18px) scale(.94); transition: transform 300ms var(--kn-bremse), opacity 200ms ease, visibility 0s 300ms;
}
/* Etage 2: gläserne Pille mit „Zurück“ und Reitern */
#kn-zeile-etage {
min-width: 132px; padding: 5px; border-radius: 999px; isolation: isolate; background: rgba(255,255,255,.66);
-webkit-backdrop-filter: blur(12px) saturate(1.3) brightness(1.06); backdrop-filter: blur(12px) saturate(1.3) brightness(1.06);
box-shadow: inset 0 1px 1px rgba(255,255,255,.7), inset 0 0 0 1px rgba(255,255,255,.2), 0 12px 32px -14px rgba(0,40,90,.5), 0 2px 6px rgba(0,40,90,.1);
}
#kn-zeile-etage .kn-zurueck { height: 44px; padding: 0 14px 0 8px; border-radius: 999px; }
#kn-zeile-etage .kn-zurueck--voll { justify-content: center; }
#kn-zeile-etage .kn-balken { height: 44px; padding-right: 4px; border-radius: 999px; }
#kn-zeile-etage .kn-t { padding: 0 12px; }
#kn-zeile-etage .kn-gleiter {
z-index: 0; top: 4px; bottom: auto; height: 36px; border-radius: 999px; outline-offset: -3px;
background: radial-gradient(120% 140% at 30% 0%, rgba(255,255,255,.75), rgba(255,255,255,0) 55%), rgba(255,255,255,.38);
box-shadow: inset 0 1px 1px rgba(255,255,255,.95), inset 0 -2px 6px rgba(0,162,255,.22), inset 0 0 0 1px rgba(255,255,255,.55), 0 6px 16px -6px rgba(0,70,140,.45);
}
#kn-zeile-etage .kn-overlay { display: none; }
#kn-zeile-etage .kn-t[aria-selected="true"] { color: var(--kn-text); }
.kn #kn-zeile-etage .kn-balken:has(.kn-t:focus-visible) .kn-gleiter { outline-color: var(--kn-accent); }
/* Unterseite (Aufbau in huelle.css): mit Unschärfe einblenden statt hochfahren; „Zurück“ oben rechts aus Glas */
.kn-blatt { filter: blur(14px); transition: opacity 380ms ease, filter 460ms var(--kn-bremse); }
.kn-blatt--auf { filter: none; }
.kn-blatt-zurueck { filter: blur(8px); transition: opacity 380ms ease, filter 460ms var(--kn-bremse), visibility 0s 460ms; }
.kn-blatt--auf ~ .kn-blatt-zurueck { filter: none; }
.kn-blatt-zurueck .kn-zurueck { background: rgba(255,255,255,.62); -webkit-backdrop-filter: blur(12px) saturate(1.3) brightness(1.06); backdrop-filter: blur(12px) saturate(1.3) brightness(1.06); box-shadow: inset 0 1px 1px rgba(255,255,255,.7), inset 0 0 0 1px rgba(255,255,255,.2), 0 12px 32px -14px rgba(0,40,90,.5), 0 2px 6px rgba(0,40,90,.1); }
}
/* Querformat: niedrigere Pille */
@media (max-height: 500px) and (max-width: 880px), (max-height: 500px) and (pointer: coarse) {
.kn { --kn-wl-h: 48px; }
.kn .kn-wl-item { height: 36px; }
#kn-zeile-etage { padding: 2px; }
.kn { --kn-wl-h: 54px; }
.kn-wl[data-art="bereiche"] .kn-wl-item { height: 42px; gap: 2px; }
.kn-wl[data-art="bereiche"] .kn-wl-ico svg { width: 18px; height: 18px; }
}
/* Bewegung reduziert: keine Szenen, Pillen tauschen ohne Fahrt */
@@ -136,4 +129,7 @@
.kn-wl-ico svg, .kn-wl-ico::before, .kn-wl-ico::after, .kn-wl-tuer { animation: none !important; transition: none; }
.kn-leiste .kn-rw,
.kn-leiste-reel[data-etage="offen"] #kn-zeile-haupt, .kn-leiste-reel:not([data-etage="offen"]) #kn-zeile-etage { transform: none; transition: opacity 150ms, visibility 0s; }
.kn-blatt { filter: none; transition: opacity 150ms; }
.kn-blatt-zurueck { filter: none; transition: opacity 150ms, visibility 0s 150ms; }
.kn-blatt--auf ~ .kn-blatt-zurueck { filter: none; transition: opacity 150ms; }
}
+23 -12
View File
@@ -3,7 +3,8 @@
Bereich, reißt bei langen Sprüngen in Tröpfchen und fließt mit einer Welle wieder zusammen; jeder Tipp schickt eine Welle durch die Pille, jedes
Symbol spielt eine kleine Szene. Das Wasser zeichnet ein kleiner WebGL-Shader (nur in der Pille). Er ruht, solange die Pille nicht zu sehen ist,
und kommt einige Sekunden nach der letzten Bedienung zur Ruhe (Akku). Ohne WebGL zeigt die Pille einen CSS-Tropfen (wasser-leiste.css).
Rollen wie die Fahrstuhl-Zeile davor: tablist mit tab, aria-selected und aria-current; Zähler als .kn-zahl am Symbol, Beschriftung per aria-label.
Zwei Arten: 'bereiche' (Hauptleiste: Symbol über dem Wort, Zähler als .kn-zahl am Symbol) und 'reiter' (Reiter einer Unterseite: nur Wörter,
Kurzwort `kurz` falls vorhanden). Rollen wie die Fahrstuhl-Zeile davor: tablist mit tab, aria-selected und aria-current, Beschriftung per aria-label.
Die Rechenfunktionen (Feder, Tröpfchen, Wellen) sind rein und werden mit node --test geprüft; ohne Browser baut das Modul nur die Knöpfe. */
import { h } from './dom.js'
@@ -291,15 +292,17 @@ const beschriftung = (e) => {
}
/**
* Wasser-Leiste bauen. Eintrag { key, label, symbol?, fx?, einheit?, ausrufText? }; symbol und fx folgen ohne Angabe dem key (SYMBOLE, SZENEN).
* bei(key) wird bei jedem Tipp gerufen, auch auf den aktiven Bereich (die Hülle scrollt dann nach oben).
* Liefert das Element mit setzeAktiv(key), setzeAnzeige(key, { zahl, ausruf }), messen() und beende().
* Wasser-Leiste bauen. Eintrag { key, label, kurz?, symbol?, fx?, einheit?, ausrufText? }. Art 'bereiche': symbol und fx folgen ohne Angabe dem key
* (SYMBOLE, SZENEN). Art 'reiter': kein Symbol, keine Szene, sichtbar ist `kurz` (sonst label), vorgelesen label.
* bei(key) wird bei jedem Tipp gerufen, auch auf den aktiven Eintrag (die Hülle scrollt dann nach oben).
* Liefert das Element mit setzeAktiv(key), setzeAnzeige(key, { zahl, ausruf }), messen() und beende() (gibt auch den WebGL-Kontext frei).
*/
export function baueWasserLeiste({ eintraege, aktiv = '', ariaLabel = 'Bereiche', bei = () => {}, warte = (fn, ms) => setTimeout(fn, ms) }) {
export function baueWasserLeiste({ eintraege, aktiv = '', art = 'bereiche', ariaLabel = 'Bereiche', bei = () => {}, warte = (fn, ms) => setTimeout(fn, ms) }) {
const win = globalThis.window
const doc = globalThis.document
const s = neuerZustand()
const liste = eintraege.map((e) => ({ ...e, fx: e.fx ?? SZENEN[e.key] ?? 'pop', zahl: 0, ausruf: false }))
const bereiche = art === 'bereiche'
const liste = eintraege.map((e) => ({ ...e, fx: e.fx ?? (bereiche ? SZENEN[e.key] ?? 'pop' : 'none'), zahl: 0, ausruf: false }))
let aktuell = aktiv
let glc = null
let glVersucht = false // WebGL erst, wenn die Pille zu sehen ist (am Desktop nie)
@@ -308,15 +311,15 @@ export function baueWasserLeiste({ eintraege, aktiv = '', ariaLabel = 'Bereiche'
const jetzt = () => (globalThis.performance?.now ? globalThis.performance.now() : Date.now())
for (const e of liste) {
e.ico = h('span', { class: 'kn-wl-ico' }, baueSymbol(e.symbol ?? e.key, doc))
e.ico = bereiche ? h('span', { class: 'kn-wl-ico' }, baueSymbol(e.symbol ?? e.key, doc)) : null
e.tab = h('button', { class: 'kn-wl-item', role: 'tab', type: 'button', 'data-key': e.key, 'aria-selected': 'false', 'aria-label': e.label, tabindex: '-1', on: { click: () => waehle(e.key) } },
e.ico, h('span', { class: 'kn-wl-label' }, e.label))
e.ico, h('span', { class: 'kn-wl-label' }, e.kurz ?? e.label))
}
const tabs = liste.map((e) => e.tab)
const canvas = h('canvas', { class: 'kn-wl-wasser', 'aria-hidden': 'true' })
const tropfen = h('span', { class: 'kn-wl-tropfen', 'aria-hidden': 'true' })
const wurzel = h('div', {
class: 'kn-wl', role: 'tablist', 'aria-label': ariaLabel, 'data-gl': 'on', 'data-brechung': 'aus',
class: 'kn-wl', role: 'tablist', 'aria-label': ariaLabel, 'data-art': bereiche ? 'bereiche' : 'reiter', 'data-gl': 'on', 'data-brechung': 'aus',
on: { keydown: beiTaste, pointermove: beiMaus, pointerleave: () => { s.maus.on = false; schleife() }, pointerdown: beiDruck },
}, baueFilter(doc), h('span', { class: 'kn-wl-glas', 'aria-hidden': 'true' }), canvas, tropfen, tabs)
@@ -370,6 +373,10 @@ export function baueWasserLeiste({ eintraege, aktiv = '', ariaLabel = 'Bereiche'
function messen() {
if (!(wurzel.offsetWidth > 0)) return // nicht zu sehen (Desktop, Tastatur offen) oder ohne Browser
if (!glVersucht) starteGL()
if (!bereiche) { // passen die Wörter nicht, wird die Schrift kleiner (wie schriftStufe in reiter.js)
wurzel.removeAttribute('data-eng')
if (wurzel.scrollWidth > wurzel.clientWidth + 1) wurzel.setAttribute('data-eng', '')
}
s.w = wurzel.offsetWidth
s.h = wurzel.offsetHeight
const el = liste.find((e) => e.key === aktuell)?.tab
@@ -398,13 +405,14 @@ export function baueWasserLeiste({ eintraege, aktiv = '', ariaLabel = 'Bereiche'
wecke()
}
function symbolMitte(i) {
const b = liste[i]?.ico?.getBoundingClientRect?.()
const b = (liste[i]?.ico ?? liste[i]?.tab)?.getBoundingClientRect?.()
return b ? lokal(b.left + b.width / 2, b.top + b.height / 2) : null
}
/** Szene des Symbols (neu) starten; die anderen Symbole kehren in die Ruhe zurück. */
function spiele(i) {
liste.forEach((e, j) => { if (j !== i) e.ico.removeAttribute('data-fx') })
liste.forEach((e, j) => { if (j !== i) e.ico?.removeAttribute('data-fx') })
const e = liste[i]
if (!e.ico) return
e.ico.removeAttribute('data-fx')
if (e.fx === 'none') return
void e.ico.offsetWidth // Stil auflösen, damit die Animation von vorn beginnt
@@ -467,7 +475,7 @@ export function baueWasserLeiste({ eintraege, aktiv = '', ariaLabel = 'Bereiche'
function setzeAnzeige(key, { zahl = 0, ausruf = false } = {}) {
const e = liste.find((x) => x.key === key)
if (!e) return
if (!e?.ico) return
e.zahl = zahl
e.ausruf = ausruf
const text = anzeigeText({ zahl, ausruf })
@@ -522,6 +530,9 @@ export function baueWasserLeiste({ eintraege, aktiv = '', ariaLabel = 'Bereiche'
for (const fn of aufraeumen.splice(0)) fn()
if (raf) win?.cancelAnimationFrame?.(raf)
raf = 0
glc?.gl.getExtension('WEBGL_lose_context')?.loseContext() // Browser halten nur wenige Kontexte: jede Unterseite baut ihre Reiter neu
glc = null
glVersucht = true
}
wurzel.setzeAktiv = setzeAktiv
+2 -1
View File
@@ -81,7 +81,8 @@ test('M4: der Mitarbeiter-Banner steht oben (sticky), Kopf, Menü, Blatt und Kon
assert.match(HUELLE, /\.kn-banner \{[^}]*position:\s*sticky;[^}]*top:\s*0/)
assert.match(HUELLE, /\.kn-menue \{[^}]*inset:\s*var\(--kn-banner-h\) auto 0 0/)
assert.match(HUELLE, /\.kn-kopf \{[^}]*position:\s*sticky;\s*top:\s*var\(--kn-banner-h\)/)
assert.match(HUELLE, /\.kn-blatt \{[^}]*inset:\s*calc\(var\(--kn-banner-h\) \+ var\(--kn-kopf-h\) \+ env\(safe-area-inset-top\)\)/)
assert.match(HUELLE, /\.kn-blatt \{[^}]*inset:\s*calc\(var\(--kn-banner-h\) \+ env\(safe-area-inset-top\)\) 0 0/) // Wasser-Leiste: Unterseite als Vollbild unter dem Banner
assert.match(HUELLE, /\.kn-blatt-zurueck \{[^}]*top:\s*calc\(var\(--kn-banner-h\) \+ env\(safe-area-inset-top\)/)
assert.match(HUELLE, /\.kn-konto \{[^}]*top:\s*calc\(var\(--kn-banner-h\) \+ 56px\)/)
assert.doesNotMatch(HUELLE, /\.kn-banner:not\(\[hidden\]\) ~ \.kn-huelle \.kn-menue/, 'die feste Höhe 36 px ist weg')
const js = quelle('huelle.js')
+22 -13
View File
@@ -5,24 +5,24 @@ import { h } from '../public/neu/dom.js'
import { baueEtage } from '../public/neu/etage.js'
import { lies, handyTeil } from './support/knHuelle.js'
const teile = () => ({ blatt: h('div', { hidden: true }, h('section', { 'data-etage': 'termine' })), haupt: h('main'), zeile: h('div'), titel: h('h1'), brot: h('nav'), reiterSlot: h('div') })
const teile = () => ({ blatt: h('div', { hidden: true }, h('section', { 'data-etage': 'termine' })), haupt: h('main'), zeile: h('div'), zurueckSlot: 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 () => {
test('oeffne und schliesse: Titel, Zurück oben rechts 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'])
const z = fx.finde(t.zurueckSlot, 'button'); assert.deepEqual([z.textContent, z.getAttribute('aria-label'), t.zeile.children.length], ['Zurück', 'Zurück zu Funktionen', 0])
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])
e.schliesse(); assert.deepEqual([t.blatt.hidden, t.haupt.hasAttribute('inert'), fx.alle(t.zurueckSlot, '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 () => {
test('kn:reiter vom Inhalt: Handy Kurzlabels als Wasser-Leiste in der Zeile, Desktop Unterstrich im Slot, ohne Reiter bleibt die Zeile leer (C2)', async () => {
const fx = installFakeDom()
try {
const gew = []; let handy = true; const t = teile(); const e = baueEtage({ ...t, istHandy: () => handy, onZurueck() {} })
@@ -30,9 +30,10 @@ test('kn:reiter vom Inhalt: Handy Kurzlabels in der Zeile, Desktop Unterstrich i
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')
assert.deepEqual([fx.finde(t.zeile, '.kn-wl').getAttribute('data-art'), fx.alle(t.zeile, 'button:not([role=tab])').length], ['reiter', 0]) // unten kein Zurück
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])
assert.deepEqual([t.zeile.children.length, fx.alle(t.zurueckSlot, '.kn-zurueck').length], [0, 1])
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')
@@ -44,14 +45,22 @@ test('Chat-Punkt am Zurück', () => {
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')
assert.ok(fx.finde(t.zurueckSlot, '.kn-punkt')); assert.equal(fx.finde(t.zurueckSlot, '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)
test('Blatt am Handy: ganzer Bildschirm, mit Unschärfe eingeblendet, Zurück als Glasknopf oben rechts (Glas nur in wasser-leiste.css), Desktop ohne', () => {
const css = lies('huelle.css'); const handy = handyTeil(css); const wl = handyTeil(lies('wasser-leiste.css'))
assert.match(handy, /\.kn-blatt\s*\{(?=[^}]*inset:\s*calc\(var\(--kn-banner-h\) \+ env\(safe-area-inset-top\)\) 0 0)(?=[^}]*opacity:\s*0)/s)
assert.doesNotMatch(handy, /translateY\(100%\)\s*;\s*transition:\s*transform 550ms/) // fährt nicht mehr hoch
assert.match(handy, /\.kn-blatt-zurueck\s*\{(?=[^}]*position:\s*fixed)(?=[^}]*right:\s*max\(16px, env\(safe-area-inset-right\)\))/s)
assert.match(handy, /\.kn-blatt--auf ~ \.kn-blatt-zurueck\s*\{[^}]*visibility:\s*visible/)
assert.match(css.slice(0, css.indexOf('@media')), /\.kn-blatt-zurueck\s*\{\s*display:\s*none/) // Desktop: Brotkrume statt Zurück
assert.match(wl, /\.kn-blatt\s*\{\s*filter:\s*blur\(14px\);\s*transition:[^}]*filter/)
assert.match(wl, /\.kn-blatt--auf\s*\{\s*filter:\s*none/)
assert.match(wl, /\.kn-blatt-zurueck \.kn-zurueck\s*\{[^}]*backdrop-filter:\s*blur/)
assert.match(lies('wasser-leiste.css'), /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.kn-blatt\s*\{\s*filter:\s*none/)
assert.match(lies('dashboard.html'), /<\/div>\s*<div id="kn-blatt-zurueck" class="kn-blatt-zurueck"><\/div>/) // Geschwister nach dem Blatt (Selektor ~)
})
test('Etage: Brotkrume führt wie Zurück, Chat-Punkt bleibt für die nächste Etage, Reiter anderer Seiten und Reste werden verworfen, gleiche Reiter bleiben stehen', async () => {
@@ -60,7 +69,7 @@ test('Etage: Brotkrume führt wie Zurück, Chat-Punkt bleibt für die nächste E
const t = teile(); t.blatt.append(h('section', { 'data-etage': 'webshop' })); let zu = 0
const e = baueEtage({ ...t, istHandy: () => true, onZurueck: () => { zu += 1 } })
e.setzeChatPunkt(true) // vor dem Öffnen gemerkt
e.oeffne({ titel: 'Terminplaner', eltern: EL, seite: 'termine' }); assert.ok(fx.finde(t.zeile, '.kn-punkt'))
e.oeffne({ titel: 'Terminplaner', eltern: EL, seite: 'termine' }); assert.ok(fx.finde(t.zurueckSlot, '.kn-punkt'))
assert.deepEqual(t.blatt.children.map((s) => s.hidden), [false, true]) // nur die Seite der Etage ist sichtbar
await fx.klick(fx.finde(t.brot, 'a'))
assert.equal(zu, 1) // Klick auf die Brotkrume: wie Zurück, ohne Fragment-Navigation
@@ -72,7 +81,7 @@ test('Etage: Brotkrume führt wie Zurück, Chat-Punkt bleibt für die nächste E
await fx.sende(termine, 'kn:reiter', { detail: { eintraege: REITER, aktiv: 'r3', bei() {} } })
assert.deepEqual([fx.finde(t.zeile, '[role=tablist]') === erster, fx.alle(t.zeile, '[role=tab]').map((x) => x.getAttribute('aria-selected')).indexOf('true')], [true, 3]) // gleiche Reiter: nur die Auswahl wechselt
e.oeffne({ titel: 'Webshop', eltern: EL, seite: 'webshop' })
assert.deepEqual([fx.alle(t.zeile, '[role=tab]').length, Boolean(fx.finde(t.zeile, '.kn-zurueck--voll')), Boolean(fx.finde(t.zeile, '.kn-punkt'))], [0, true, true]) // neue Etage: alte Reiter weg, Punkt bleibt
assert.deepEqual([fx.alle(t.zeile, '[role=tab]').length, Boolean(fx.finde(t.zurueckSlot, '.kn-zurueck')), Boolean(fx.finde(t.zurueckSlot, '.kn-punkt'))], [0, true, true]) // neue Etage: alte Reiter weg, Punkt bleibt
e.schliesse(); await fx.sende(termine, 'kn:reiter', { detail: { eintraege: REITER, aktiv: 'r0', bei() {} } })
assert.equal(fx.alle(t.zeile, '[role=tab]').length, 0) // geschlossen: späte Meldungen tun nichts
} finally { fx.restore() }
+1 -1
View File
@@ -76,7 +76,7 @@ test('Etage Terminplaner: wunschProjekt aus ?projekt=, Modul auf #termine-body m
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])
assert.deepEqual([b.el('kn-blatt').hidden, b.el('kn-etage-titel').textContent, Boolean(b.fx.finde(b.el('kn-blatt-zurueck'), '.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.')
})
+21 -4
View File
@@ -90,6 +90,20 @@ test('Bauen: vier Reiter mit Wort, Symbol-Platz und Szene, Auswahl, Zähler am S
} finally { fx.restore() }
})
test('Art reiter: nur Wörter (Kurzwort sichtbar, volles Wort vorgelesen), keine Szene, kein Zähler; beende räumt auf', async () => {
const fx = installFakeDom()
try {
const gew = []
const w = baueWasserLeiste({ eintraege: [{ key: 'termine', label: 'Termine' }, { key: 'team', label: 'Team & Plätze', kurz: 'Team' }], aktiv: 'termine', art: 'reiter', ariaLabel: 'Reiter', bei: (k) => gew.push(k) })
const tabs = fx.alle(w, '[role=tab]')
assert.deepEqual([w.getAttribute('data-art'), w.getAttribute('aria-label'), fx.alle(w, '.kn-wl-ico').length], ['reiter', 'Reiter', 0])
assert.deepEqual(tabs.map((t) => [t.textContent, t.getAttribute('aria-label')]), [['Termine', 'Termine'], ['Team', 'Team & Plätze']])
await fx.klick(tabs[1]); assert.deepEqual([gew, tabs[1].getAttribute('aria-selected')], [['team'], 'true'])
w.setzeAnzeige('team', { zahl: 2 }); assert.equal(fx.finde(w, '.kn-zahl'), null)
w.beende(); w.beende() // zweimal ist kein Fehler
} finally { fx.restore() }
})
test('Symbole und Szenen: für jeden Bereich der Leiste ein Symbol und eine eigene Szene', () => {
for (const { key } of WOERTER) { assert.ok(SYMBOLE[key]?.length > 0, key); assert.ok(SZENEN[key], key) }
assert.equal(new Set(WOERTER.map((w) => SZENEN[w.key])).size, WOERTER.length)
@@ -100,15 +114,18 @@ test('wasser-leiste.css: Pille wie die Vorlage, am Handy schwebend über dem Inh
const css = lies('wasser-leiste.css'); const handy = handyTeil(css)
assert.match(css, /\.kn-wl-glas\s*\{[^}]*backdrop-filter:\s*blur\(10px\) saturate\(1\.2\) brightness\(1\.08\)/s)
assert.match(css, /\.kn-wl\[data-gl="off"\] \.kn-wl-tropfen\s*\{\s*display:\s*block/)
assert.match(css, /@media \(max-width: 520px\)\s*\{[^@]*\.kn-wl-label\s*\{[^}]*clip:\s*rect\(0 0 0 0\)/s) // schmal nur Symbole, Wort bleibt für Screenreader
assert.doesNotMatch(css, /max-width: 520px/) // die Wörter stehen immer da (Wunsch 29.10.)
assert.match(css, /\.kn-wl\[data-art="bereiche"\] \.kn-wl-item\s*\{[^}]*flex:\s*1 1 0;[^}]*flex-direction:\s*column/) // Symbol über dem Wort, gleich breit
assert.match(css, /\.kn-wl\[data-art="reiter"\]\[data-eng\] \.kn-wl-item\s*\{[^}]*font-size:\s*12px/)
for (const fx of ['pop', 'plane', 'drop', 'spin', 'glow', 'wave']) assert.match(css, new RegExp(`\\.kn-wl-ico\\[data-fx="${fx}"\\] svg\\s*\\{[^}]*animation:`))
assert.ok(handy.startsWith(HANDY))
assert.match(handy, /\.kn\s*\{[^}]*--kn-wl-luft:\s*max\(10px, calc\(env\(safe-area-inset-bottom\) - 10px\)\)[^}]*--kn-unten:\s*var\(--kn-wl-zone\)/s)
assert.match(handy, /\.kn\s*\{[^}]*--kn-wl-luft:\s*max\(14px, calc\(env\(safe-area-inset-bottom\) \+ 6px\)\)[^}]*--kn-unten:\s*var\(--kn-wl-zone\)/s) // ganz über dem Wisch-Balken des iPhones
assert.match(handy, /\.kn\.kn-hat-streifen\s*\{\s*--kn-unten:\s*calc\(var\(--kn-wl-zone\) \+ 44px\)/)
assert.ok(handy.indexOf('.kn.kn-tastatur') > handy.indexOf('.kn.kn-hat-streifen')) // Tastatur schlägt den Streifen
assert.match(handy, /\.kn-leiste\s*\{[^}]*background:\s*none[^}]*pointer-events:\s*none/s)
assert.match(handy, /\.kn-blatt\s*\{\s*bottom:\s*0;\s*padding-bottom:\s*calc\(var\(--kn-unten\) \+ 24px\)/)
assert.match(handy, /#kn-zeile-etage\s*\{[^}]*border-radius:\s*999px[^}]*backdrop-filter/s)
assert.match(handy, /\.kn-leiste \.kn-rw\s*\{[^}]*right:\s*12px;[^}]*left:\s*12px;[^}]*max-width:\s*480px/s) // breit: Rand 12 px, höchstens 480 px
assert.match(handy, /\.kn-leiste \.kn-wl\s*\{\s*display:\s*flex;\s*width:\s*100%/)
assert.match(handy, /\.kn-leiste \.kn-rw\s*\{[^}]*pointer-events:\s*none/s); assert.match(handy, /\.kn-leiste \.kn-rw > \*\s*\{\s*pointer-events:\s*auto/)
assert.match(css, /@media \(prefers-reduced-motion: reduce\)\s*\{[^@]*animation:\s*none !important/s)
// huelle.css bleibt unverändert die Grundlage (Desktop und Etage-Logik)
assert.match(lies('huelle.css'), /\.kn-leiste\s*\{[^}]*border-top:\s*1px solid var\(--kn-line\)/)