diff --git a/public/neu/dashboard.html b/public/neu/dashboard.html index 38aae71..1b66384 100644 --- a/public/neu/dashboard.html +++ b/public/neu/dashboard.html @@ -23,6 +23,7 @@ + Zum Inhalt diff --git a/public/neu/leiste.js b/public/neu/leiste.js index 973a08a..4af89ca 100644 --- a/public/neu/leiste.js +++ b/public/neu/leiste.js @@ -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))])) diff --git a/public/neu/wasser-leiste.css b/public/neu/wasser-leiste.css new file mode 100644 index 0000000..f19049a --- /dev/null +++ b/public/neu/wasser-leiste.css @@ -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; } +} diff --git a/public/neu/wasser-leiste.js b/public/neu/wasser-leiste.js new file mode 100644 index 0000000..b97128f --- /dev/null +++ b/public/neu/wasser-leiste.js @@ -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 +} diff --git a/test/knHuelleSeite.test.js b/test/knHuelleSeite.test.js index f4af2d0..15d5803 100644 --- a/test/knHuelleSeite.test.js +++ b/test/knHuelleSeite.test.js @@ -9,7 +9,7 @@ test('dashboard.html: weiße PWA-Metas, Stile in fester Reihenfolge, klassisches assert.match(html, //); assert.match(html, //) assert.match(html, /apple-mobile-web-app-status-bar-style" content="default"/); assert.match(html, //) assert.match(html, /viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content"/) - const css = [...html.matchAll(/ 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(/ 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, //) const skripte = [...html.matchAll(/]*)>/g)].map((m) => m[1].trim()) diff --git a/test/knWasserLeiste.test.js b/test/knWasserLeiste.test.js new file mode 100644 index 0000000..c37dfa5 --- /dev/null +++ b/test/knWasserLeiste.test.js @@ -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\)/) +})