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="analyse" hidden><div id="analyse-body"></div></section>
<section data-etage="seite" hidden><div id="etage-body"></div></section> <section data-etage="seite" hidden><div id="etage-body"></div></section>
</div> </div>
<div id="kn-blatt-zurueck" class="kn-blatt-zurueck"></div>
</div> </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. /* Kundenansicht neu: Etage 2. Eine Unterseite (Funktion, Detailseite, Passwort …) legt 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. Handy: Das Blatt deckt den ganzen Bildschirm, „Zurück“ steht oben rechts (zurueckSlot), und meldet die Seite Reiter, zeigt die Zeile unten
Desktop: Brotkrume statt Zeile, Reiter als Unterstrich im Slot unter der Überschrift. 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) 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. */ und kn:titel { text }. Alles Sichtbare wird mit h() und textContent gebaut. */
import { h, leeren, text } from './dom.js' import { h, leeren, text } from './dom.js'
import { baueReiter } from './reiter.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 ?? '')) 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 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' : ''}` const zurueckLabel = () => `Zurück zu ${stand.eltern.label}${stand.chatPunkt ? ', neue Nachricht im Chat' : ''}`
function baueZurueck(voll) { function baueZurueck() {
return h('button', { class: voll ? 'kn-zurueck kn-zurueck--voll' : 'kn-zurueck', type: 'button', 'aria-label': zurueckLabel(), on: { click: () => onZurueck() } }, 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)) h('span', { class: 'kn-l' }, 'Zurück', stand.chatPunkt ? h('span', { class: 'kn-punkt' }) : null))
} }
function neuerReiter(art) { function neuerReiter(art) {
const { eintraege, aktiv } = stand.reiter const { eintraege, aktiv } = stand.reiter
// Handy zeigt die Kurzlabels (der Balken hat wenig Platz), Desktop die vollen Beschriftungen const bei = (key) => stand.reiter?.bei?.(key)
const zeigen = art === 'balken' ? eintraege : eintraege.map(({ kurz, ...rest }) => rest) // Handy: Wasser-Leiste mit den Kurzlabels (vorgelesen wird das volle Wort); Desktop: Unterstrich mit den vollen Beschriftungen
return baueReiter({ eintraege: zeigen, aktiv, art, ariaLabel: 'Reiter', bei: (key) => stand.reiter?.bei?.(key) }) 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() { 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)) 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() { 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 stand.zurueckEl = null; stand.reiterEl = null
zeichneBrot() zeichneBrot()
if (!stand.offen) return if (!stand.offen) return
if (istHandy()) { if (istHandy()) {
stand.zurueckEl = baueZurueck(!stand.reiter) stand.zurueckEl = baueZurueck()
zeile.append(stand.zurueckEl) ;(zurueckSlot ?? zeile).append(stand.zurueckEl)
if (stand.reiter) { if (stand.reiter) {
stand.reiterEl = neuerReiter('balken') stand.reiterEl = neuerReiter('wasser')
zeile.append(h('span', { class: 'kn-vl' }), stand.reiterEl) zeile.append(stand.reiterEl)
} }
} else if (stand.reiter) { } else if (stand.reiter) {
stand.reiterEl = neuerReiter('unterstrich') 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. */ Die Grundregeln gelten am Desktop, die Handy-Abfrage überschreibt für das Handy. */
/* ---------- Blatt (Etage 2) ---------- */ /* ---------- 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 { 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::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-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); } .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) { @media (max-width: 880px), (pointer: coarse) and (max-height: 500px) {
.kn { --kn-unten: calc(var(--kn-zeile-h) + env(safe-area-inset-bottom)); } .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 { .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; position: fixed; inset: calc(var(--kn-banner-h) + env(safe-area-inset-top)) 0 0; z-index: 20; overflow-y: auto; overscroll-behavior: contain;
overflow-y: auto; overscroll-behavior: contain; border-radius: 12px 12px 0 0; opacity: 0; transition: opacity 380ms ease;
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);
} }
.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-haupt[inert] { display: block; } /* am Handy bleibt die Hauptseite unter dem Blatt stehen */
.kn-brot { display: none; } .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-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-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-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-haupt .kn-titel, .kn-blatt .kn-titel { margin: 6px 0 10px; font-size: 28px; line-height: 34px; }
.kn-banner button { min-height: 44px; } .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)); } #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) { @media (max-height: 500px) and (max-width: 880px), (max-height: 500px) and (pointer: coarse) {
.kn { --kn-zeile-h: 44px; --kn-kopf-h: 44px; } .kn { --kn-zeile-h: 44px; --kn-kopf-h: 44px; }
.kn-leiste-reel[data-etage="offen"] .kn-rw { transform: translateY(-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; } .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-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 { transform: none; opacity: 0; transition: opacity 150ms; }
.kn-blatt--auf { opacity: 1; } .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-konto-scrim, .kn-konto { animation: kn-einblenden 150ms ease-out; }
.kn-zahl { animation: none; } .kn-zahl { animation: none; }
.kn-zahl--weg { transition: 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 { baueStreifen, ladeFristen, ladeFristenNeu } from './fristen-streifen.js'
import { baueAvatar, baueKonto, installAngebot } from './konto.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> // Bereich → [Modul, Funktion, Wurzel]; die Ansicht heißt ansicht-<name>.js und exportiert zeige<Name>
export const ANSICHTEN = { 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 }) const streifen = baueStreifen(el('kn-streifen'), { koerper: doc.body })
streifen.setze(null) streifen.setze(null)
const navigation = baueNavigation({ haupt: el('kn-zeile-haupt'), menue: el('kn-menue'), onWahl: (bereich) => router.geheZu(hashFuerRoute({ bereich })) }) 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 aktuell = null // die Route, die gerade gezeigt wird
let herkunft = '' // Hash der Route davor (Rückweg der Passwort-Seite) let herkunft = '' // Hash der Route davor (Rückweg der Passwort-Seite)
const zaehler = erzeugeZaehler({ 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“ /* 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: (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
Die Leiste ist keine weiße Zeile mehr, sondern eine schwebende Pille über dem Inhalt. Unterseiten (Etage 2) zeigen statt der Bereiche eine am Handy: Die Leiste ist keine weiße Zeile mehr, sondern eine breite schwebende Pille über dem Inhalt. Hauptseiten zeigen die vier Bereiche
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. */ (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 ---------- */ /* ---------- 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%; } .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); } } @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 */ /* ---------- Zwei Arten ---------- */
@media (max-width: 520px) {
.kn .kn-wl-item { padding: 0 13px; } /* Bereiche (Hauptleiste): Symbol über dem Wort, alle gleich breit */
.kn-wl-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } .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) ---------- /* ---------- 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. 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.
--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. */ 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) { @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-hat-streifen { --kn-unten: calc(var(--kn-wl-zone) + 44px); }
.kn.kn-tastatur { --kn-unten: 0px; } .kn.kn-tastatur { --kn-unten: 0px; }
.kn-haupt { padding-bottom: calc(var(--kn-wl-zone) + 28px); } .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-hat-streifen .kn-haupt { padding-bottom: calc(var(--kn-wl-zone) + 28px + 44px); }
.kn-streifen { bottom: var(--kn-wl-zone); } .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 { border-top: 0; background: none; padding-bottom: var(--kn-wl-luft); pointer-events: none; }
.kn-leiste-reel { position: relative; overflow: visible; } .kn-leiste-reel { position: relative; overflow: visible; }
.kn-leiste .kn-rw { .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; 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: auto; transition: transform 450ms var(--kn-feder), opacity 250ms ease, visibility 0s; 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 */ /* 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-rw { transform: none; }
.kn-leiste-reel[data-etage="offen"] #kn-zeile-haupt, .kn-leiste-reel:not([data-etage="offen"]) #kn-zeile-etage { .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; 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 */ /* Unterseite (Aufbau in huelle.css): mit Unschärfe einblenden statt hochfahren; „Zurück“ oben rechts aus Glas */
#kn-zeile-etage { .kn-blatt { filter: blur(14px); transition: opacity 380ms ease, filter 460ms var(--kn-bremse); }
min-width: 132px; padding: 5px; border-radius: 999px; isolation: isolate; background: rgba(255,255,255,.66); .kn-blatt--auf { filter: none; }
-webkit-backdrop-filter: blur(12px) saturate(1.3) brightness(1.06); backdrop-filter: blur(12px) saturate(1.3) brightness(1.06); .kn-blatt-zurueck { filter: blur(8px); transition: opacity 380ms ease, filter 460ms var(--kn-bremse), visibility 0s 460ms; }
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-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); }
#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); }
} }
/* Querformat: niedrigere Pille */ /* Querformat: niedrigere Pille */
@media (max-height: 500px) and (max-width: 880px), (max-height: 500px) and (pointer: coarse) { @media (max-height: 500px) and (max-width: 880px), (max-height: 500px) and (pointer: coarse) {
.kn { --kn-wl-h: 48px; } .kn { --kn-wl-h: 54px; }
.kn .kn-wl-item { height: 36px; } .kn-wl[data-art="bereiche"] .kn-wl-item { height: 42px; gap: 2px; }
#kn-zeile-etage { padding: 2px; } .kn-wl[data-art="bereiche"] .kn-wl-ico svg { width: 18px; height: 18px; }
} }
/* Bewegung reduziert: keine Szenen, Pillen tauschen ohne Fahrt */ /* 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-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 .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-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 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, 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). 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. */ 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' 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). * Wasser-Leiste bauen. Eintrag { key, label, kurz?, symbol?, fx?, einheit?, ausrufText? }. Art 'bereiche': symbol und fx folgen ohne Angabe dem key
* bei(key) wird bei jedem Tipp gerufen, auch auf den aktiven Bereich (die Hülle scrollt dann nach oben). * (SYMBOLE, SZENEN). Art 'reiter': kein Symbol, keine Szene, sichtbar ist `kurz` (sonst label), vorgelesen label.
* Liefert das Element mit setzeAktiv(key), setzeAnzeige(key, { zahl, ausruf }), messen() und beende(). * 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 win = globalThis.window
const doc = globalThis.document const doc = globalThis.document
const s = neuerZustand() 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 aktuell = aktiv
let glc = null let glc = null
let glVersucht = false // WebGL erst, wenn die Pille zu sehen ist (am Desktop nie) 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()) const jetzt = () => (globalThis.performance?.now ? globalThis.performance.now() : Date.now())
for (const e of liste) { 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.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 tabs = liste.map((e) => e.tab)
const canvas = h('canvas', { class: 'kn-wl-wasser', 'aria-hidden': 'true' }) const canvas = h('canvas', { class: 'kn-wl-wasser', 'aria-hidden': 'true' })
const tropfen = h('span', { class: 'kn-wl-tropfen', 'aria-hidden': 'true' }) const tropfen = h('span', { class: 'kn-wl-tropfen', 'aria-hidden': 'true' })
const wurzel = h('div', { 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 }, 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) }, 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() { function messen() {
if (!(wurzel.offsetWidth > 0)) return // nicht zu sehen (Desktop, Tastatur offen) oder ohne Browser if (!(wurzel.offsetWidth > 0)) return // nicht zu sehen (Desktop, Tastatur offen) oder ohne Browser
if (!glVersucht) starteGL() 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.w = wurzel.offsetWidth
s.h = wurzel.offsetHeight s.h = wurzel.offsetHeight
const el = liste.find((e) => e.key === aktuell)?.tab const el = liste.find((e) => e.key === aktuell)?.tab
@@ -398,13 +405,14 @@ export function baueWasserLeiste({ eintraege, aktiv = '', ariaLabel = 'Bereiche'
wecke() wecke()
} }
function symbolMitte(i) { 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 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. */ /** Szene des Symbols (neu) starten; die anderen Symbole kehren in die Ruhe zurück. */
function spiele(i) { 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] const e = liste[i]
if (!e.ico) return
e.ico.removeAttribute('data-fx') e.ico.removeAttribute('data-fx')
if (e.fx === 'none') return if (e.fx === 'none') return
void e.ico.offsetWidth // Stil auflösen, damit die Animation von vorn beginnt 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 } = {}) { function setzeAnzeige(key, { zahl = 0, ausruf = false } = {}) {
const e = liste.find((x) => x.key === key) const e = liste.find((x) => x.key === key)
if (!e) return if (!e?.ico) return
e.zahl = zahl e.zahl = zahl
e.ausruf = ausruf e.ausruf = ausruf
const text = anzeigeText({ zahl, 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() for (const fn of aufraeumen.splice(0)) fn()
if (raf) win?.cancelAnimationFrame?.(raf) if (raf) win?.cancelAnimationFrame?.(raf)
raf = 0 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 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-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-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-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.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') assert.doesNotMatch(HUELLE, /\.kn-banner:not\(\[hidden\]\) ~ \.kn-huelle \.kn-menue/, 'die feste Höhe 36 px ist weg')
const js = quelle('huelle.js') 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 { baueEtage } from '../public/neu/etage.js'
import { lies, handyTeil } from './support/knHuelle.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 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' } 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() const fx = installFakeDom()
try { try {
const t = teile(); let zu = 0; const e = baueEtage({ ...t, istHandy: () => true, onZurueck: () => { zu += 1 } }) const t = teile(); let zu = 0; const e = baueEtage({ ...t, istHandy: () => true, onZurueck: () => { zu += 1 } })
e.oeffne({ titel: 'Terminplaner', eltern: EL, seite: 'termine' }) 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([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']) 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) 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() } } 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() const fx = installFakeDom()
try { try {
const gew = []; let handy = true; const t = teile(); const e = baueEtage({ ...t, istHandy: () => handy, onZurueck() {} }) 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) } }) await fx.sende(kind, 'kn:reiter', { detail: { eintraege: REITER, aktiv: 'r0', bei: (k) => gew.push(k) } })
const tabs = fx.alle(t.zeile, '[role=tab]') 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(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.klick(tabs[2]); assert.deepEqual(gew, ['r2'])
await fx.sende(kind, 'kn:reiter', { detail: { eintraege: [], aktiv: '', bei() {} } }) 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() {} } }) 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]) 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') 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 { try {
const t = teile(); const e = baueEtage({ ...t, istHandy: () => true, onZurueck() {} }) const t = teile(); const e = baueEtage({ ...t, istHandy: () => true, onZurueck() {} })
e.oeffne({ titel: 'Webshop', eltern: EL, seite: 'webshop' }); e.setzeChatPunkt(true) 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() } } finally { fx.restore() }
}) })
test('huelle.css (Blatt): Handy fährt von unten ein, Zurück füllt die Zeile', () => { 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 css = lies('huelle.css'); const handy = handyTeil(css); const wl = handyTeil(lies('wasser-leiste.css'))
assert.match(handyTeil(css), /\.kn-blatt\s*\{(?=[^}]*border-radius:\s*12px 12px 0 0)(?=[^}]*transition:[^}]*550ms[^}]*var\(--kn-bremse\))/s) 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.match(css, /\.kn-zurueck--voll\s*\{[^}]*flex:\s*1/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 () => { 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 t = teile(); t.blatt.append(h('section', { 'data-etage': 'webshop' })); let zu = 0
const e = baueEtage({ ...t, istHandy: () => true, onZurueck: () => { zu += 1 } }) const e = baueEtage({ ...t, istHandy: () => true, onZurueck: () => { zu += 1 } })
e.setzeChatPunkt(true) // vor dem Öffnen gemerkt 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 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')) await fx.klick(fx.finde(t.brot, 'a'))
assert.equal(zu, 1) // Klick auf die Brotkrume: wie Zurück, ohne Fragment-Navigation 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() {} } }) 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 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' }) 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() {} } }) 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 assert.equal(fx.alle(t.zeile, '[role=tab]').length, 0) // geschlossen: späte Meldungen tun nichts
} finally { fx.restore() } } 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 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]) }) }) 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(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') } }) 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.') 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() } } 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', () => { 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) } 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) 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) 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-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, /\.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:`)) 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.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.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.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-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-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-zeile-etage\s*\{[^}]*border-radius:\s*999px[^}]*backdrop-filter/s) 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) 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) // 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\)/) assert.match(lies('huelle.css'), /\.kn-leiste\s*\{[^}]*border-top:\s*1px solid var\(--kn-line\)/)