Kundenansicht neu: Wasser-Leiste am Handy (Glas-Pille mit Wassertropfen)

Die Handy-Leiste ist jetzt eine schwebende Glas-Pille mit vier Symbolen
(Website, Funktionen, Chat, Verträge) nach der Komponente „Nimbus UI ·
Liquid Navbar – Waterline“ (guglielmogiannattasio.it), ohne React
nachgebaut: Wassertropfen per WebGL-Shader unter dem aktiven Bereich,
Tröpfchen bei langen Sprüngen, Wellen beim Tippen, eine Szene je Symbol.
Ohne WebGL ein CSS-Tropfen, bei „Bewegung reduzieren“ ohne Bewegung.
Das Wasser ruht außerhalb des Bildes und 6 s nach der letzten Bedienung.

Unterseiten zeigen „Zurück“ und Reiter als gläserne Pille, der aktive
Reiter im Tropfen. Abstände (Blatt, Chat, Streifen, Inhalt) rechnen mit
der neuen Zone über --kn-wl-zone. 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 17:39:11 +00:00
1 parent 83ac903ab3
commit 1725aaa91c
6 files changed
+794 -4

No files matched your search

+1
View File
@@ -23,6 +23,7 @@
<link rel="stylesheet" href="/neu/ansichten.css">
<link rel="stylesheet" href="/neu/funktionen.css">
<link rel="stylesheet" href="/neu/module.css">
<link rel="stylesheet" href="/neu/wasser-leiste.css">
</head>
<body class="kn">
<a class="kn-skip" href="#kn-inhalt">Zum Inhalt</a>
+5 -3
View File
@@ -3,7 +3,8 @@
(Leiste und Streifen verschwinden nur bei Texteingabe) und seine Verdrahtung mit visualViewport, focusin und focusout. */
import { h, leeren } from './dom.js'
import { baueReiter, anzeigeText } from './reiter.js'
import { anzeigeText } from './reiter.js'
import { baueWasserLeiste } from './wasser-leiste.js'
// Genau einmal in JS; das CSS führt dieselbe Abfrage (basis.css, huelle.css).
export const HANDY_ABFRAGE = '(max-width: 880px), (pointer: coarse) and (max-height: 500px)'
@@ -22,9 +23,10 @@ const beschriftung = (wort, anzeige) => {
return `${wort.label}, ${text}${wort.einheit ? ` ${wort.einheit}` : ''}`
}
/** Fahrstuhl-Zeile in `haupt` und Menü in `menue` bauen. onWahl(bereich) wird bei Klick auf ein Wort gerufen. */
/** Handy-Leiste in `haupt` und Menü in `menue` bauen. onWahl(bereich) wird bei Klick auf ein Wort gerufen.
* Am Handy ist die Hauptzeile die Wasser-Leiste (wasser-leiste.js): eine Glas-Pille mit Symbolen statt der vier Wörter mit Balken. */
export function baueNavigation({ haupt, menue, onWahl }) {
const reiter = baueReiter({ eintraege: WOERTER, aktiv: '', art: 'balken', ariaLabel: 'Bereiche', bei: (key) => onWahl(key) })
const reiter = baueWasserLeiste({ eintraege: WOERTER, aktiv: '', ariaLabel: 'Bereiche', bei: (key) => onWahl(key) })
leeren(haupt).append(reiter)
const knoepfe = Object.fromEntries(WOERTER.map((w) => [w.key, h('button', { class: 'kn-nav-item', type: 'button', 'data-bereich': w.key, on: { click: () => onWahl(w.key) } }, h('span', { class: 'kn-nav-text' }, w.label))]))
+139
View File
@@ -0,0 +1,139 @@
/* 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. */
/* ---------- Pille ---------- */
.kn-wl {
--wl-tint: rgba(255,255,255,.14); --wl-fg: var(--kn-text); --wl-fg-soft: rgba(26,31,54,.6); --wl-rim: rgba(255,255,255,.7);
position: relative; display: inline-flex; align-items: center; gap: 2px; padding: 6px; border-radius: 999px; isolation: isolate;
-webkit-font-smoothing: antialiased; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent;
}
.kn-wl-filter { position: absolute; }
.kn-wl-glas {
position: absolute; inset: 0; z-index: -2; overflow: hidden; border-radius: inherit; background: var(--wl-tint);
-webkit-backdrop-filter: blur(10px) saturate(1.2) brightness(1.08); backdrop-filter: blur(10px) saturate(1.2) brightness(1.08);
box-shadow: inset 0 1px 1px var(--wl-rim), 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-wl[data-brechung="an"] .kn-wl-glas { -webkit-backdrop-filter: url(#kn-wl-brechung) blur(2px) saturate(1.2) brightness(1.08); backdrop-filter: url(#kn-wl-brechung) blur(2px) saturate(1.2) brightness(1.08); }
.kn-wl-wasser { position: absolute; left: -14px; top: -14px; z-index: -1; pointer-events: none; }
.kn-wl[data-gl="off"] .kn-wl-wasser { display: none; }
.kn-wl-tropfen {
display: none; position: absolute; top: 6px; bottom: 6px; left: 0; z-index: -1; border-radius: 999px; pointer-events: none; will-change: transform, width;
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-wl[data-gl="off"] .kn-wl-tropfen { display: block; }
.kn-wl[data-leer] .kn-wl-tropfen { opacity: 0; }
.kn .kn-wl-item {
position: relative; z-index: 1; display: inline-flex; align-items: center; gap: 8px; height: 42px; padding: 0 16px; border-radius: 999px;
color: var(--wl-fg-soft); font-size: 14px; font-weight: 500; line-height: 1; letter-spacing: -.01em; white-space: nowrap; transition: color .25s;
}
.kn .kn-wl-item:hover, .kn .kn-wl-item[aria-selected="true"] { color: var(--wl-fg); }
.kn .kn-wl-item:focus-visible { outline-offset: 2px; }
.kn-wl-ico { position: relative; display: inline-flex; flex: none; }
.kn-wl-ico svg { transition: transform .45s cubic-bezier(.3,1.6,.5,1); }
.kn-wl-item[aria-selected="true"] .kn-wl-ico svg { transform: translateY(-1px) scale(1.08); }
.kn-wl-tuer { fill: transparent; }
/* Zähler am Symbol: oben rechts, wie .kn-zahl in huelle.css */
.kn-wl-ico .kn-zahl { top: -8px; margin-left: -9px; }
/* Szenen der Symbole */
.kn-wl-ico[data-fx="pop"] svg { animation: kn-wl-pop .5s cubic-bezier(.3,1.6,.5,1) both; }
@keyframes kn-wl-pop { 0% { transform: scale(.72); } 60% { transform: scale(1.2); } 100% { transform: translateY(-1px) scale(1.08); } }
.kn-wl-ico[data-fx="plane"] svg { animation: kn-wl-flug 1.15s cubic-bezier(.55,0,.3,1) both; }
@keyframes kn-wl-flug { 0% { transform: none; } 12% { transform: translate(-3px,2px) rotate(7deg); } 42% { transform: translate(64px,-26px) rotate(-24deg) scale(.9); opacity: 0; } 43% { transform: translate(-60px,16px) rotate(-12deg) scale(.9); opacity: 0; } 58% { opacity: 1; } 80% { transform: translate(3px,-1px) rotate(2deg); } 100% { transform: translateY(-1px) scale(1.08); } }
.kn-wl-ico[data-fx="plane"]::after { content: ""; position: absolute; left: 55%; top: 48%; width: 46px; height: 2px; border-radius: 2px; transform-origin: 0 50%; background: linear-gradient(90deg, rgba(255,255,255,0), rgba(255,255,255,.95)); animation: kn-wl-spur 1.15s ease-out both; pointer-events: none; }
@keyframes kn-wl-spur { 0%, 8% { opacity: 0; transform: rotate(-22deg) scaleX(0); } 30% { opacity: .9; transform: rotate(-22deg) scaleX(1); } 55%, 100% { opacity: 0; transform: rotate(-22deg) translateX(30px) scaleX(.2); } }
.kn-wl-ico[data-fx="drop"] svg { animation: kn-wl-tropf .95s cubic-bezier(.4,0,.3,1) both; transform-origin: 50% 100%; }
@keyframes kn-wl-tropf { 0% { transform: translateY(-1px); } 26% { transform: translateY(-13px) scale(.88,1.12); } 46% { transform: translateY(3px) scale(1.3,.68); } 62% { transform: translateY(-3px) scale(.92,1.08); } 80% { transform: translateY(0) scale(1.04,.97); } 100% { transform: translateY(-1px) scale(1.08); } }
.kn-wl-ico[data-fx="drop"]::before, .kn-wl-ico[data-fx="drop"]::after { content: ""; position: absolute; bottom: 0; left: 50%; width: 4px; height: 4px; margin-left: -2px; border-radius: 50%; background: currentColor; opacity: 0; pointer-events: none; }
.kn-wl-ico[data-fx="drop"]::before { animation: kn-wl-spritzer-l .95s ease-out both; }
.kn-wl-ico[data-fx="drop"]::after { animation: kn-wl-spritzer-r .95s ease-out both; }
@keyframes kn-wl-spritzer-l { 0%, 44% { opacity: 0; transform: none; } 50% { opacity: .9; transform: translate(-5px,-6px); } 75%, 100% { opacity: 0; transform: translate(-11px,1px) scale(.4); } }
@keyframes kn-wl-spritzer-r { 0%, 44% { opacity: 0; transform: none; } 50% { opacity: .9; transform: translate(5px,-7px); } 75%, 100% { opacity: 0; transform: translate(12px,1px) scale(.4); } }
.kn-wl-ico[data-fx="spin"] svg { animation: kn-wl-dreh 1s cubic-bezier(.2,.75,.25,1) both; }
@keyframes kn-wl-dreh { 0% { transform: perspective(80px) rotateY(0); } 100% { transform: perspective(80px) rotateY(360deg) translateY(-1px) scale(1.08); } }
.kn-wl-ico[data-fx="glow"] svg { animation: kn-wl-hops .8s cubic-bezier(.3,1.4,.5,1) both; transform-origin: 50% 100%; }
@keyframes kn-wl-hops { 0% { transform: none; } 30% { transform: translateY(-5px) scale(1.04,.96); } 55% { transform: translateY(0) scale(1.12,.88); } 75% { transform: translateY(-2px) scale(.98,1.04); } 100% { transform: translateY(-1px) scale(1.08); } }
.kn-wl-ico[data-fx="glow"] .kn-wl-tuer { animation: kn-wl-licht 1.3s ease both; }
@keyframes kn-wl-licht { 0% { fill: transparent; } 30% { fill: #fff3a8; } 100% { fill: rgba(126,232,255,.6); } }
.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; }
}
/* ---------- 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. */
.kn { --kn-wl-h: 54px; }
@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-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;
}
/* 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); }
}
/* 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; }
}
/* Bewegung reduziert: keine Szenen, Pillen tauschen ohne Fahrt */
@media (prefers-reduced-motion: reduce) {
.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; }
}
+533
View File
@@ -0,0 +1,533 @@
/* Kundenansicht neu: Wasser-Leiste (Handy). Nachbau der Komponente „Nimbus UI · Liquid Navbar – Waterline“ von guglielmogiannattasio.it,
ohne React und ohne Abhängigkeiten: eine Pille aus Glas, darin ein Wassertropfen unter dem aktiven Bereich. Der Tropfen dehnt sich zum gewählten
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.
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 { anzeigeText } from './reiter.js'
export const PAD = 14 // Rand der Zeichenfläche um die Pille, für Schatten und Wellen
const FEDER_MS = 260 // so lange bleibt eine ausgeblendete Zahl im Baum (wie reiter.js)
export const WACH_MS = 6000 // so lange bewegt sich das Wasser nach der letzten Bedienung von selbst
const SVG_NS = 'http://www.w3.org/2000/svg'
/* ---------- Symbole (24er Raster, Strich 1,8 wie die Vorlage) ---------- */
export const SYMBOLE = {
website: [['circle', { cx: 12, cy: 12, r: 8.5 }], ['path', { d: 'M3.5 12h17M12 3.5c2.6 2.4 2.6 14.6 0 17M12 3.5c-2.6 2.4-2.6 14.6 0 17' }]],
funktionen: [
['rect', { x: 4, y: 4, width: 6.5, height: 6.5, rx: 1.8 }],
['rect', { x: 13.5, y: 4, width: 6.5, height: 6.5, rx: 1.8 }],
['rect', { x: 4, y: 13.5, width: 6.5, height: 6.5, rx: 1.8 }],
['rect', { x: 13.5, y: 13.5, width: 6.5, height: 6.5, rx: 1.8, class: 'kn-wl-tuer' }], // leuchtet bei der Szene 'glow' auf
],
chat: [['path', { d: 'M20 11.5c0 4.1-3.6 7.5-8 7.5a8.7 8.7 0 0 1-3.3-.6L4 19.5l1.2-3.6A7.1 7.1 0 0 1 4 11.5C4 7.4 7.6 4 12 4s8 3.4 8 7.5Z' }]],
vertraege: [['path', { d: 'M14 3.5H7.5A1.5 1.5 0 0 0 6 5v14a1.5 1.5 0 0 0 1.5 1.5h9A1.5 1.5 0 0 0 18 19V7.5Z' }], ['path', { d: 'M14 3.5v4h4M9 12.5h6M9 16h4' }]],
}
/** Szene je Bereich (Namen der Vorlage): der Globus dreht sich, eine Kachel leuchtet auf, die Blase winkt, das Blatt springt und spritzt. */
export const SZENEN = { website: 'spin', funktionen: 'glow', chat: 'wave', vertraege: 'drop' }
function baueSymbol(name, doc) {
const teile = SYMBOLE[name]
if (!teile || typeof doc?.createElementNS !== 'function') return null // ohne Browser (node --test) nur das Wort
const neu = (tag, attrs) => {
const el = doc.createElementNS(SVG_NS, tag)
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v))
return el
}
const svg = neu('svg', { viewBox: '0 0 24 24', width: 18, height: 18, fill: 'none', stroke: 'currentColor', 'stroke-width': 1.8, 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true', focusable: 'false' })
for (const [tag, attrs] of teile) svg.append(neu(tag, attrs))
return svg
}
/** SVG-Filter für die Lichtbrechung des Glases (nur Chrome kann backdrop-filter mit url()). */
function baueFilter(doc) {
if (typeof doc?.createElementNS !== 'function') return null
const neu = (tag, attrs, ...kinder) => {
const el = doc.createElementNS(SVG_NS, tag)
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v))
el.append(...kinder)
return el
}
return neu('svg', { class: 'kn-wl-filter', width: 0, height: 0, 'aria-hidden': 'true', focusable: 'false' },
neu('filter', { id: 'kn-wl-brechung', x: 0, y: 0, width: '100%', height: '100%', 'color-interpolation-filters': 'sRGB' },
neu('feTurbulence', { type: 'fractalNoise', baseFrequency: '0.006 0.02', numOctaves: 2, seed: 7, result: 'noise' }),
neu('feGaussianBlur', { in: 'noise', stdDeviation: 2, result: 'soft' }),
neu('feDisplacementMap', { in: 'SourceGraphic', in2: 'soft', scale: 60, xChannelSelector: 'R', yChannelSelector: 'G' })))
}
/* ---------- Der Wasser-Shader (wörtlich aus der Vorlage) ---------- */
const VERT = 'attribute vec2 a;void main(){gl_Position=vec4(a,0.,1.);}'
const FRAG = `precision highp float;
uniform vec2 uRes;uniform float uDpr;uniform float uTime;uniform vec4 uPill;
uniform vec4 uBlob[6];uniform float uSy[6];uniform vec4 uRip[6];uniform vec3 uMouse;uniform float uDark;uniform float uAmb;
float sdRB(vec2 p,vec2 b,float r){vec2 q=abs(p)-b+r;return length(max(q,0.))+min(max(q.x,q.y),0.)-r;}
float sdCap(vec2 p,vec4 B,float sy){p-=B.xy;p.y/=sy;p.x=max(abs(p.x)-B.z,0.);return(length(p)-B.w)*min(1.,sy);}
float smin(float a,float b,float k){float h=clamp(.5+.5*(b-a)/k,0.,1.);return mix(b,a,h)-k*h*(1.-h);}
float dropD(vec2 p){float d=1e4;for(int i=0;i<6;i++){if(uBlob[i].w>.5){d=smin(d,sdCap(p,uBlob[i],uSy[i]),13.);}}return d;}
float pillD(vec2 p){vec2 h=uPill.zw*.5;return sdRB(p-uPill.xy-h,h,h.y);}
float lens(float d,float w){float t=clamp(-d/w,0.,1.);return sqrt(1.-(1.-t)*(1.-t));}
float waves(vec2 p){float w=0.;
for(int i=0;i<6;i++){vec4 R=uRip[i];if(R.w>0.){float x=length(p-R.xy)-R.z*240.;w+=R.w*exp(-R.z*1.7)*sin(x*.3)*exp(-x*x/700.);}}
vec2 m=p-uMouse.xy;w+=uMouse.z*3.6*exp(-dot(m,m)/1000.);
w+=uAmb*(sin(p.x*.05+uTime*1.2)*.45+sin(p.x*.13-uTime*.85+p.y*.2)*.25);return w;}
float H(vec2 p){float dp=pillD(p);return lens(dp,uPill.w*.36)*2.4+lens(dropD(p),11.)*7.5+waves(p)*smoothstep(0.,7.,-dp);}
float caus(vec2 p,float t){vec2 q=p;float c=0.;for(int i=0;i<3;i++){q+=vec2(sin(q.y*1.1+t),cos(q.x*1.3-t*.7))*.6;c+=1./(1.+16.*abs(sin(q.x)*sin(q.y)));}return c/3.;}
void main(){
vec2 p=vec2(gl_FragCoord.x,uRes.y*uDpr-gl_FragCoord.y)/uDpr;
float dp=pillD(p);float dd=dropD(p);
float h0=H(p);vec3 n=normalize(vec3(h0-H(p+vec2(1.,0.)),h0-H(p+vec2(0.,1.)),1.));
vec3 V=vec3(0.,0.,1.);
float spec=pow(max(dot(reflect(-normalize(vec3(-.45,-.8,.85)),n),V),0.),70.);
float spec2=pow(max(dot(reflect(-normalize(vec3(.6,.75,.7)),n),V),0.),22.)*.3;
float fres=clamp((1.-n.z)*4.,0.,1.);
vec3 irid=.5+.5*cos(6.2831*(vec3(0.,.33,.67)+fres*.85+p.x*.0028+uTime*.035));
float pm=smoothstep(1.,-1.,dp);float dm=smoothstep(1.,-1.,dd)*smoothstep(1.,-2.,dp);
float sh=smoothstep(9.,-9.,dropD(p-vec2(0.,5.)))*(1.-dm)*pm*(.11+.07*uDark);
vec3 c=vec3(0.,.14,.36)*sh;float a=sh;
float pa=clamp(pm*(fres*.5+spec*.9+spec2),0.,1.);
c=mix(vec3(1.),irid,.5*fres)*pa+c*(1.-pa);a=pa+a*(1.-pa);
float th=lens(dd,11.);
float cs=caus((p+n.xy*th*18.)*.085,uTime*.9);
vec3 tint=mix(vec3(.80,.95,1.),vec3(.55,.82,1.),uDark);
float rim=dm*(1.-smoothstep(0.,8.,-dd));
float cres=dm*smoothstep(.12,.55,dot(n.xy,normalize(vec2(.55,.85))))*smoothstep(0.,3.,-dd);
float body=.5+.5*clamp(-n.y*3.,-1.,1.);
float da=dm*(.26+.14*body)*(1.-.35*uDark);vec3 dc=mix(tint,vec3(1.),body*.6);
float la=rim*.22;vec3 lc=mix(vec3(0.,.42,.78),vec3(.1,.55,.95),uDark);dc=lc*la+dc*da*(1.-la);da=la+da*(1.-la);
la=clamp(cres*.7+smoothstep(.6,1.,cs)*.28*th*dm,0.,1.);dc=vec3(1.)*la+dc*(1.-la);da=la+da*(1.-la);
la=clamp(dm*(spec*1.2+spec2*.5),0.,1.);dc=vec3(1.)*la+dc*(1.-la);da=la+da*(1.-la);
la=dm*smoothstep(-2.,0.,dd)*.4;dc=mix(vec3(1.),irid,.35)*la+dc*(1.-la);da=la+da*(1.-la);
c=dc+c*(1.-da);a=da+a*(1.-da);
gl_FragColor=vec4(c,a);
}`
function initGL(canvas) {
let gl = null
try { gl = canvas.getContext('webgl', { premultipliedAlpha: true, alpha: true, antialias: false }) } catch { return null }
if (!gl) return null
const shader = (typ, quelle) => {
const s = gl.createShader(typ)
gl.shaderSource(s, quelle)
gl.compileShader(s)
return gl.getShaderParameter(s, gl.COMPILE_STATUS) ? s : null
}
const vs = shader(gl.VERTEX_SHADER, VERT)
const fs = shader(gl.FRAGMENT_SHADER, FRAG)
if (!vs || !fs) return null
const p = gl.createProgram()
gl.attachShader(p, vs)
gl.attachShader(p, fs)
gl.linkProgram(p)
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) return null
gl.useProgram(p)
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer())
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW)
const ort = gl.getAttribLocation(p, 'a')
gl.enableVertexAttribArray(ort)
gl.vertexAttribPointer(ort, 2, gl.FLOAT, false, 0, 0)
const u = {}
for (const n of ['uRes', 'uDpr', 'uTime', 'uPill', 'uBlob', 'uSy', 'uRip', 'uMouse', 'uDark', 'uAmb']) u[n] = gl.getUniformLocation(p, n)
return { gl, u }
}
function kannBrechen(win) {
const ua = win?.navigator?.userAgent ?? ''
return /Chrome\/|Chromium\/|Edg\//.test(ua) && !/Firefox\//.test(ua) && Boolean(globalThis.CSS?.supports?.('backdrop-filter', 'url(#a)'))
}
/* ---------- Rechnen: Zustand, Ziel, Feder (rein) ---------- */
/** Zustand des Wassers. a und b sind Kopf und Schwanz des Tropfens (Federn auf der x-Achse), sy die Stauchung, sats die Tröpfchen, rips die Wellen. */
export function neuerZustand() {
return {
a: { x: 0, v: 0 }, b: { x: 0, v: 0 }, sy: { s: 1, v: 0 }, lead: 0, lagFrom: 0, sats: [], rips: [],
t: { x: 0, cy: 0, q: 0, r: 21 }, q: 0, maus: { x: 0, y: 0, s: 0, on: false },
moving: false, split: false, ready: false, leer: true, w: 0, h: 0, visible: true, reduce: false,
amb: 0, wachBis: 0, time: 0,
}
}
/** Ziel aus den Maßen des aktiven Knopfs (relativ zur Pille). Die erste Messung setzt den Tropfen sofort, spätere lassen ihn hinüberfließen. */
export function setzeZiel(s, { links, oben, breite, hoehe }, jetzt) {
const T = s.t
T.r = hoehe / 2
T.q = Math.max(0, breite / 2 - T.r)
T.cy = PAD + oben + hoehe / 2
const nx = PAD + links + breite / 2
if (!s.ready) {
s.a.x = s.b.x = nx
s.a.v = s.b.v = 0
s.q = T.q
s.ready = true
} else if (Math.abs(nx - T.x) > 0.5) {
s.lead = Math.abs(nx - s.a.x) <= Math.abs(nx - s.b.x) ? 0 : 1 // der nähere Körper führt, der andere folgt mit Verzug
s.lagFrom = jetzt + 70
s.moving = true
s.split = false
}
T.x = nx
}
/** Ein Physikschritt (dt in Sekunden, jetzt in ms wie performance.now). zufall ist für Tests einsetzbar. */
export function schritt(s, dt, jetzt, zufall = Math.random) {
const T = s.t
s.time += dt
s.q += (T.q - s.q) * Math.min(1, dt * 12)
const koerper = [s.a, s.b]
let still = true
if (s.reduce) {
s.a.x = s.b.x = T.x
s.a.v = s.b.v = 0
s.sy.s = 1
s.sats = []
} else {
koerper.forEach((B, i) => {
const fuehrt = i === s.lead
const k = fuehrt ? 210 : 72
const c = fuehrt ? 25 : 13
const f = !fuehrt && jetzt < s.lagFrom ? 0.08 : 1
B.v += (k * (T.x - B.x) - c * B.v) * dt * f
B.x += B.v * dt
const lo = PAD + 6 + s.q + T.r - 3
const hi = PAD + s.w - 6 - s.q - T.r + 3
if (B.x < lo || B.x > hi) { // an die Wand: abprallen und stauchen
s.sy.v -= Math.min(Math.abs(B.v), 900) * 0.0016
B.x = Math.max(lo, Math.min(hi, B.x))
B.v *= -0.25
}
if (Math.abs(T.x - B.x) > 0.3 || Math.abs(B.v) > 3) still = false
})
const L = koerper[s.lead]
const syZiel = 1 - 0.2 * Math.min(Math.abs(L.v) / 1500, 1)
s.sy.v += (280 * (syZiel - s.sy.s) - 9 * s.sy.v) * dt
s.sy.s += s.sy.v * dt
if (Math.abs(s.sy.s - 1) > 0.002 || Math.abs(s.sy.v) > 0.02) still = false
// Der Hals reißt: zwischen Kopf und Schwanz entstehen Tröpfchen
const sep = Math.abs(s.a.x - s.b.x)
const r = T.r * (1 - 0.3 * Math.min(sep / (2 * (T.q + T.r) || 1), 1))
if (!s.split && s.moving && sep - 2 * T.q - 2 * r > -6) {
s.split = true
const lo = koerper[1 - s.lead]
const hi = koerper[s.lead]
;[0.3, 0.5, 0.72].forEach((t, j) => {
s.sats.push({
x: lo.x + (hi.x - lo.x) * t + (zufall() - 0.5) * 6,
y: T.cy + (zufall() - 0.5) * 10,
vx: (lo.v + hi.v) * 0.45,
vy: (zufall() - 0.5) * 60,
r: 5 + zufall() * 3 - j * 0.5,
k: 70 + zufall() * 90,
})
})
}
s.sats = s.sats.filter((d) => {
d.vx += (d.k * (T.x - d.x) - 9 * d.vx) * dt
d.vy += (90 * (T.cy - d.y) - 7 * d.vy) * dt
d.x += d.vx * dt
d.y += d.vy * dt
d.x = Math.max(PAD + 8 + d.r, Math.min(PAD + s.w - 8 - d.r, d.x))
d.y = Math.max(T.cy - T.r + d.r, Math.min(T.cy + T.r - d.r, d.y))
if (Math.abs(T.x - d.x) < T.q + T.r * 0.5) d.r -= dt * 26 // am Ziel angekommen: einsaugen
return d.r > 0.6
})
if (s.sats.length) still = false
}
if (s.moving && still) { // angekommen: eine Welle, bei gerissenem Tropfen eine kräftigere
s.moving = false
if (s.split || !s.reduce) s.rips.push({ x: T.x, y: T.cy, age: 0, amp: s.split ? 1.2 : 0.7 })
}
for (const p of s.rips) p.age += dt
s.rips = s.rips.filter((p) => p.age < 2.4)
s.maus.s += ((s.maus.on ? 1 : 0) - s.maus.s) * Math.min(1, dt * 7)
// Eigenbewegung des Wassers: nach der letzten Bedienung blendet sie weich aus
const ambZiel = !s.reduce && jetzt < s.wachBis ? 1 : 0
s.amb += (ambZiel - s.amb) * Math.min(1, dt * 2)
if (s.amb < 0.005 && ambZiel === 0) s.amb = 0
}
/** Muss die Schleife weiterlaufen? Bewegung, Tröpfchen, Wellen, Maus oder noch nicht ganz beruhigtes Wasser. */
export const istBeschaeftigt = (s, jetzt) => Boolean(s.moving || s.rips.length || s.sats.length || s.maus.s > 0.01 || s.amb > 0 || (!s.reduce && jetzt < s.wachBis))
/** Daten für den Shader: Kopf und Schwanz des Tropfens (ohne aktiven Bereich keiner), bis zu vier Tröpfchen und die letzten sechs Wellen. */
export function shaderDaten(s) {
const T = s.t
const sep = Math.abs(s.a.x - s.b.x)
const duenn = Math.min(sep / (2 * (s.q + T.r) || 1), 1)
const blobs = new Float32Array(24)
const sy = new Float32Array(6).fill(1)
if (!s.leer) {
const kopf = s.lead === 0 ? s.a : s.b
const schwanz = s.lead === 0 ? s.b : s.a
blobs.set([kopf.x, T.cy, s.q * (1 - 0.25 * duenn), T.r * (1 - 0.12 * duenn), schwanz.x, T.cy, s.q * (1 - 0.8 * duenn), T.r * (1 - 0.5 * duenn)], 0)
sy[0] = sy[1] = s.sy.s
}
s.sats.slice(0, 4).forEach((d, i) => blobs.set([d.x, d.y, 0, d.r], 8 + i * 4))
const rips = new Float32Array(24)
s.rips.slice(-6).forEach((p, i) => rips.set([p.x, p.y, p.age, p.amp], i * 4))
return { blobs, sy, rips }
}
/** Lage und Breite des CSS-Tropfens (ohne WebGL), relativ zur Pille. */
export function tropfenMasse(s) {
const T = s.t
const sep = Math.abs(s.a.x - s.b.x)
const r = T.r * (1 - 0.3 * Math.min(sep / (2 * (s.q + T.r) || 1), 1))
return { links: Math.min(s.a.x, s.b.x) - s.q - r - PAD, breite: sep + 2 * (s.q + r), skalaY: s.sy.s }
}
/* ---------- Bauen ---------- */
const beschriftung = (e) => {
const text = anzeigeText({ zahl: e.zahl, ausruf: e.ausruf })
if (!text) return e.label
if (e.ausruf) return `${e.label}, ${e.ausrufText ?? 'Hinweis'}`
return `${e.label}, ${text}${e.einheit ? ` ${e.einheit}` : ''}`
}
/**
* 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().
*/
export function baueWasserLeiste({ eintraege, aktiv = '', 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 }))
let aktuell = aktiv
let glc = null
let glVersucht = false // WebGL erst, wenn die Pille zu sehen ist (am Desktop nie)
let raf = 0
let zuletzt = 0
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.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))
}
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',
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)
/* -- Zeichnen -- */
function zeichne() {
if (!(s.w > 0)) return
if (glc) {
const { gl: g, u } = glc
const dpr = Math.min(win?.devicePixelRatio || 1, 2)
const d = shaderDaten(s)
g.viewport(0, 0, g.drawingBufferWidth, g.drawingBufferHeight)
g.uniform2f(u.uRes, s.w + PAD * 2, s.h + PAD * 2)
g.uniform1f(u.uDpr, dpr)
g.uniform1f(u.uTime, s.time)
g.uniform4f(u.uPill, PAD, PAD, s.w, s.h)
g.uniform4fv(u.uBlob, d.blobs)
g.uniform1fv(u.uSy, d.sy)
g.uniform4fv(u.uRip, d.rips)
g.uniform3f(u.uMouse, s.maus.x, s.maus.y, s.maus.s)
g.uniform1f(u.uDark, 0)
g.uniform1f(u.uAmb, s.amb)
g.clearColor(0, 0, 0, 0)
g.clear(g.COLOR_BUFFER_BIT)
g.drawArrays(g.TRIANGLES, 0, 3)
} else {
const m = tropfenMasse(s)
tropfen.style.width = `${m.breite}px`
tropfen.style.transform = `translateX(${m.links}px) scaleY(${m.skalaY})`
}
}
function schleife() {
if (raf || typeof win?.requestAnimationFrame !== 'function') return
zuletzt = jetzt()
const tick = (nun) => {
const dt = Math.min(Math.max(0, nun - zuletzt) / 1000, 1 / 30)
zuletzt = nun
schritt(s, dt, nun)
zeichne()
raf = s.visible && istBeschaeftigt(s, nun) ? win.requestAnimationFrame(tick) : 0
}
raf = win.requestAnimationFrame(tick)
}
function wecke() {
s.wachBis = jetzt() + WACH_MS
schleife()
}
/* -- Messen -- */
function messen() {
if (!(wurzel.offsetWidth > 0)) return // nicht zu sehen (Desktop, Tastatur offen) oder ohne Browser
if (!glVersucht) starteGL()
s.w = wurzel.offsetWidth
s.h = wurzel.offsetHeight
const el = liste.find((e) => e.key === aktuell)?.tab
if (el) setzeZiel(s, { links: el.offsetLeft, oben: el.offsetTop, breite: el.offsetWidth, hoehe: el.offsetHeight }, jetzt())
const dpr = Math.min(win?.devicePixelRatio || 1, 2)
const W = s.w + PAD * 2
const H = s.h + PAD * 2
if (canvas.width !== Math.round(W * dpr) || canvas.height !== Math.round(H * dpr)) {
canvas.width = Math.round(W * dpr)
canvas.height = Math.round(H * dpr)
canvas.style.width = `${W}px`
canvas.style.height = `${H}px`
}
zeichne()
schleife()
}
/* -- Bedienung -- */
const lokal = (x, y) => {
const r = wurzel.getBoundingClientRect?.()
return r ? { x: x - r.left + PAD, y: y - r.top + PAD } : null
}
function welle(x, y, amp) {
if (s.reduce) return
s.rips.push({ x, y, age: 0, amp })
wecke()
}
function symbolMitte(i) {
const b = liste[i]?.ico?.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') })
const e = liste[i]
e.ico.removeAttribute('data-fx')
if (e.fx === 'none') return
void e.ico.offsetWidth // Stil auflösen, damit die Animation von vorn beginnt
e.ico.setAttribute('data-fx', e.fx)
}
function waehle(key) {
const i = liste.findIndex((e) => e.key === key)
if (i === -1) return
spiele(i)
const c = symbolMitte(i)
if (c && liste[i].fx === 'drop') warte(() => welle(c.x, s.t.cy, 1.4), 430)
if (c && liste[i].fx === 'plane') welle(c.x, c.y, 0.6)
setzeAktiv(key)
bei(key)
}
function beiTaste(ev) {
const i = tabs.indexOf(ev.target?.closest?.('[role=tab]'))
if (i === -1) return
const n = tabs.length
const ziel = { ArrowRight: (i + 1) % n, ArrowLeft: (i + n - 1) % n, Home: 0, End: n - 1 }[ev.key]
if (ziel === undefined) return
ev.preventDefault()
tabs[ziel].focus()
if (ziel !== i) waehle(liste[ziel].key)
}
function beiMaus(ev) {
if (ev.pointerType !== 'mouse') return // die Wölbung unter dem Zeiger gibt es nur mit der Maus
const p = lokal(ev.clientX, ev.clientY)
if (!p) return
Object.assign(s.maus, { x: p.x, y: p.y, on: true })
wecke()
}
function beiDruck(ev) {
const p = lokal(ev.clientX, ev.clientY)
if (p) welle(p.x, p.y, 0.8)
}
/* -- Von außen -- */
function setzeAktiv(key) {
const wechsel = key !== aktuell
aktuell = key
const treffer = liste.some((e) => e.key === key)
for (const e of liste) {
const an = e.key === key
e.tab.setAttribute('aria-selected', String(an))
if (an) e.tab.setAttribute('aria-current', 'page')
else e.tab.removeAttribute('aria-current')
e.tab.setAttribute('tabindex', an || (!treffer && e === liste[0]) ? '0' : '-1')
}
s.leer = !treffer
if (treffer) wurzel.removeAttribute('data-leer')
else wurzel.setAttribute('data-leer', '')
if (wechsel && s.ready) s.wachBis = jetzt() + WACH_MS
messen()
}
function setzeAnzeige(key, { zahl = 0, ausruf = false } = {}) {
const e = liste.find((x) => x.key === key)
if (!e) return
e.zahl = zahl
e.ausruf = ausruf
const text = anzeigeText({ zahl, ausruf })
let punkt = e.ico.querySelector('.kn-zahl')
if (text) {
if (punkt?.classList.contains('kn-zahl--weg')) { punkt.remove(); punkt = null } // frisch erscheinen, damit die Feder noch einmal läuft
if (!punkt) { punkt = h('span', { class: 'kn-zahl', 'aria-hidden': 'true' }); e.ico.append(punkt) }
punkt.textContent = text
punkt.classList.toggle('kn-zahl--ausruf', ausruf)
} else if (punkt && !punkt.classList.contains('kn-zahl--weg')) {
punkt.classList.add('kn-zahl--weg')
warte(() => { if (punkt.classList.contains('kn-zahl--weg')) punkt.remove() }, FEDER_MS)
}
e.tab.setAttribute('aria-label', beschriftung(e))
}
/* -- Browser: WebGL, Beobachter -- */
const aufraeumen = []
function starteGL() {
glVersucht = true
glc = initGL(canvas)
if (!glc) { wurzel.setAttribute('data-gl', 'off'); return }
const verloren = (ev) => { ev.preventDefault(); glc = null; wurzel.setAttribute('data-gl', 'off'); zeichne() }
canvas.addEventListener('webglcontextlost', verloren)
aufraeumen.push(() => canvas.removeEventListener('webglcontextlost', verloren))
}
if (typeof canvas.getContext !== 'function') { glVersucht = true; wurzel.setAttribute('data-gl', 'off') } // ohne Browser gleich der CSS-Tropfen
if (kannBrechen(win)) wurzel.setAttribute('data-brechung', 'an')
s.reduce = Boolean(win?.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches)
if (typeof win?.IntersectionObserver === 'function') {
const io = new win.IntersectionObserver(([e]) => { s.visible = e.isIntersecting; if (s.visible) wecke() })
io.observe(wurzel)
aufraeumen.push(() => io.disconnect())
}
if (typeof win?.ResizeObserver === 'function') {
let breite = -1
const ro = new win.ResizeObserver(() => {
const w = wurzel.offsetWidth
if (w !== breite) { breite = w; s.ready = false; messen() } // neue Größe: der Tropfen springt an seinen Platz, statt zu fließen
})
ro.observe(wurzel)
aufraeumen.push(() => ro.disconnect())
}
if (doc && typeof doc.addEventListener === 'function' && 'visibilityState' in doc) {
const sicht = () => { s.visible = doc.visibilityState === 'visible'; if (s.visible) wecke() }
doc.addEventListener('visibilitychange', sicht)
aufraeumen.push(() => doc.removeEventListener('visibilitychange', sicht))
}
doc?.fonts?.ready?.then?.(() => { s.ready = false; messen() })
function beende() {
for (const fn of aufraeumen.splice(0)) fn()
if (raf) win?.cancelAnimationFrame?.(raf)
raf = 0
}
wurzel.setzeAktiv = setzeAktiv
wurzel.setzeAnzeige = setzeAnzeige
wurzel.messen = messen
wurzel.beende = beende
setzeAktiv(aktiv)
return wurzel
}
+1 -1
View File
@@ -9,7 +9,7 @@ test('dashboard.html: weiße PWA-Metas, Stile in fester Reihenfolge, klassisches
assert.match(html, /<meta name="kn-seite" content="neu">/); assert.match(html, /<meta name="theme-color" content="#ffffff">/)
assert.match(html, /apple-mobile-web-app-status-bar-style" content="default"/); assert.match(html, /<link rel="manifest" href="\/neu\/manifest\.webmanifest">/)
assert.match(html, /viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content"/)
const css = [...html.matchAll(/<link rel="stylesheet" href="([^"]+)"/g)].map((m) => m[1]); const ORDNUNG = ['/neu/tokens.css', '/neu/basis.css', '/neu/huelle.css', '/neu/ansichten.css', '/neu/funktionen.css', '/neu/module.css']
const css = [...html.matchAll(/<link rel="stylesheet" href="([^"]+)"/g)].map((m) => m[1]); const ORDNUNG = ['/neu/tokens.css', '/neu/basis.css', '/neu/huelle.css', '/neu/ansichten.css', '/neu/funktionen.css', '/neu/module.css', '/neu/wasser-leiste.css'] // Wasser-Leiste zuletzt: überschreibt die Handy-Zeile aus huelle.css
assert.deepEqual(css.filter((f) => f !== '/neu/chat-composer.css'), ORDNUNG); assert.ok(css.indexOf('/neu/chat-composer.css') === -1 || css.indexOf('/neu/chat-composer.css') > css.indexOf('/neu/huelle.css'))
assert.match(html, /<body class="kn">/)
const skripte = [...html.matchAll(/<script([^>]*)>/g)].map((m) => m[1].trim())
+115
View File
@@ -0,0 +1,115 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { installFakeDom } from './support/fakeDom.js'
import { PAD, WACH_MS, SZENEN, SYMBOLE, neuerZustand, setzeZiel, schritt, istBeschaeftigt, shaderDaten, tropfenMasse, baueWasserLeiste } from '../public/neu/wasser-leiste.js'
import { WOERTER } from '../public/neu/leiste.js'
import { lies, handyTeil, HANDY } from './support/knHuelle.js'
// Pille 194 px breit (vier Knöpfe zu 44 px, 2 px Abstand, 6 px Rand), Knöpfe 42 px hoch
const knopf = (i) => ({ links: 6 + i * 46, oben: 6, breite: 44, hoehe: 42 })
const zufall = () => 0.5
const laufe = (s, sekunden, start = 0) => { let t = start; for (let n = 0; n < sekunden * 60; n++) { t += 1000 / 60; schritt(s, 1 / 60, t, zufall) } return t }
const bereit = (i = 0) => { const s = neuerZustand(); s.w = 194; s.h = 54; s.leer = false; setzeZiel(s, knopf(i), 0); return s }
test('setzeZiel: die erste Messung setzt den Tropfen sofort unter den Knopf, eine spätere startet die Fahrt', () => {
const s = bereit(1)
assert.deepEqual([s.a.x, s.b.x, s.t.x, s.t.cy, s.t.r, s.t.q, s.moving], [PAD + 52 + 22, PAD + 74, PAD + 74, PAD + 27, 21, 1, false])
setzeZiel(s, knopf(1), 10); assert.equal(s.moving, false) // gleiche Stelle: nichts zu tun
setzeZiel(s, knopf(3), 20)
assert.deepEqual([s.t.x, s.moving, s.lagFrom, s.a.x], [PAD + 144 + 22, true, 90, PAD + 74])
})
test('schritt: der Tropfen fließt ans Ziel, kommt zur Ruhe und schlägt dort eine Welle', () => {
const s = bereit(0); setzeZiel(s, knopf(1), 0); laufe(s, 2)
assert.ok(Math.abs(s.a.x - s.t.x) < 0.5 && Math.abs(s.b.x - s.t.x) < 0.5, `${s.a.x} ${s.b.x} ${s.t.x}`)
assert.deepEqual([s.moving, s.split, Math.abs(s.sy.s - 1) < 0.01], [false, false, true])
assert.equal(s.rips.length, 1); assert.equal(s.rips[0].amp, 0.7); assert.equal(s.rips[0].x, s.t.x)
laufe(s, 3); assert.equal(s.rips.length, 0) // Wellen verebben nach 2,4 s
})
test('schritt: ein langer Sprung reißt den Tropfen in Tröpfchen, sie fließen am Ziel wieder zusammen (kräftigere Welle)', () => {
const s = bereit(0); setzeZiel(s, knopf(3), 0)
let t = 0; let maxTroepfchen = 0; let starkeWelle = false
for (let n = 0; n < 240; n++) { t += 1000 / 60; schritt(s, 1 / 60, t, zufall); maxTroepfchen = Math.max(maxTroepfchen, s.sats.length); starkeWelle ||= s.rips.some((r) => r.amp === 1.2) }
assert.equal(maxTroepfchen, 3); assert.equal(s.split, true)
assert.deepEqual([s.sats.length, s.moving, starkeWelle], [0, false, true])
for (const k of [s.a, s.b]) assert.ok(k.x >= PAD + 6 + s.q + s.t.r - 3 - 1e-9 && k.x <= PAD + s.w - 6 - s.q - s.t.r + 3 + 1e-9) // nie durch die Wand
})
test('schritt mit „Bewegung reduzieren“: springt sofort, ohne Tröpfchen, Welle und Eigenbewegung', () => {
const s = bereit(0); s.reduce = true; s.wachBis = 1e9; setzeZiel(s, knopf(3), 0)
schritt(s, 1 / 60, 16, zufall)
assert.deepEqual([s.a.x, s.b.x, s.sats.length, s.rips.length, s.moving, s.amb], [s.t.x, s.t.x, 0, 0, false, 0])
assert.equal(istBeschaeftigt(s, 16), false)
})
test('Eigenbewegung: blendet nach dem Wecken ein und nach WACH_MS weich aus, dann ruht die Schleife', () => {
const s = bereit(0); s.wachBis = WACH_MS
assert.equal(istBeschaeftigt(s, 0), true)
let t = laufe(s, 2); assert.ok(s.amb > 0.9, String(s.amb))
t = laufe(s, (WACH_MS - t) / 1000 + 6, t); assert.equal(s.amb, 0)
assert.equal(istBeschaeftigt(s, t), false)
})
test('shaderDaten: ohne aktiven Bereich kein Tropfen; mit Bereich führt der Kopf, Tröpfchen ab Platz 3, höchstens 6 Wellen', () => {
const s = bereit(1); s.leer = true
assert.deepEqual([...shaderDaten(s).blobs].slice(0, 8), [0, 0, 0, 0, 0, 0, 0, 0])
s.leer = false; s.lead = 1; s.b.x = 150; s.sats = [{ x: 90, y: 40, r: 6 }]; s.rips = Array.from({ length: 8 }, (_, i) => ({ x: i, y: 0, age: 0.1, amp: 1 }))
const d = shaderDaten(s)
assert.equal(d.blobs[0], 150); assert.equal(d.blobs[4], s.a.x); assert.deepEqual([...d.blobs.slice(8, 12)], [90, 40, 0, 6])
assert.deepEqual([d.rips[0], d.rips[20]], [2, 7]); assert.deepEqual([d.sy[0], d.sy[2]], [1, 1])
})
test('tropfenMasse: in Ruhe genau so breit wie der aktive Knopf und an seiner Stelle (CSS-Tropfen ohne WebGL)', () => {
const s = bereit(2)
const m = tropfenMasse(s)
assert.ok(Math.abs(m.links - 98) < 1e-9 && Math.abs(m.breite - 44) < 1e-9 && m.skalaY === 1, JSON.stringify(m))
})
test('Bauen: vier Reiter mit Wort, Symbol-Platz und Szene, Auswahl, Zähler am Symbol, kein WebGL ohne Browser', async () => {
const fx = installFakeDom()
try {
const gew = []
const w = baueWasserLeiste({ eintraege: WOERTER, aktiv: '', bei: (k) => gew.push(k) })
const tabs = fx.alle(w, '[role=tab]')
assert.deepEqual([w.getAttribute('role'), w.getAttribute('aria-label'), w.getAttribute('data-gl'), w.hasAttribute('data-leer')], ['tablist', 'Bereiche', 'off', true])
assert.deepEqual(tabs.map((t) => [t.textContent, t.getAttribute('aria-label'), t.getAttribute('tabindex')]), [['Website', 'Website', '0'], ['Funktionen', 'Funktionen', '-1'], ['Chat', 'Chat', '-1'], ['Verträge', 'Verträge', '-1']])
assert.equal(fx.alle(w, '.kn-wl-ico').length, 4)
w.setzeAktiv('chat')
assert.deepEqual(tabs.map((t) => [t.getAttribute('aria-selected'), t.getAttribute('aria-current')]), [['false', null], ['false', null], ['true', 'page'], ['false', null]])
assert.equal(w.hasAttribute('data-leer'), false)
await fx.klick(tabs[3]); assert.deepEqual(gew, ['vertraege']); assert.equal(fx.finde(tabs[3], '.kn-wl-ico').getAttribute('data-fx'), 'drop')
await fx.klick(tabs[3]); assert.deepEqual(gew, ['vertraege', 'vertraege']) // erneut auf den aktiven Bereich: die Hülle scrollt nach oben
await fx.klick(tabs[0]); assert.deepEqual([fx.finde(tabs[0], '.kn-wl-ico').getAttribute('data-fx'), fx.finde(tabs[3], '.kn-wl-ico').hasAttribute('data-fx')], ['spin', false])
await fx.sende(tabs[0], 'keydown', { key: 'ArrowLeft' }); assert.equal(gew.at(-1), 'vertraege') // ringsum
w.setzeAnzeige('chat', { zahl: 3 }); w.setzeAnzeige('vertraege', { zahl: 2, ausruf: true })
assert.deepEqual([fx.finde(fx.finde(tabs[2], '.kn-wl-ico'), '.kn-zahl').textContent, tabs[2].getAttribute('aria-label'), tabs[3].getAttribute('aria-label')], ['3', 'Chat, 3 ungelesen', 'Verträge, Zahlung fehlgeschlagen'])
w.setzeAnzeige('chat', { zahl: 0 }); assert.equal(tabs[2].getAttribute('aria-label'), 'Chat'); assert.ok(fx.finde(tabs[2], '.kn-zahl').classList.contains('kn-zahl--weg'))
w.setzeAktiv('gibtesnicht'); assert.deepEqual([fx.alle(w, '[aria-selected=true]').length, w.hasAttribute('data-leer'), tabs[0].getAttribute('tabindex')], [0, true, '0'])
w.beende()
} 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)
assert.ok(SYMBOLE.funktionen.some(([, a]) => a.class === 'kn-wl-tuer')) // die Kachel, die bei 'glow' aufleuchtet
})
test('wasser-leiste.css: Pille wie die Vorlage, am Handy schwebend über dem Inhalt, Abstände über --kn-wl-zone, Etage als Glas-Pille, Bewegung reduziert', () => {
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
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\.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(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\)/)
})