Kundenansicht neu: Glas-Logo auf der Anmeldung und Startbild der App
- public/neu/glas-logo.js: das Bauteil „liquid-glass-logo-reveal“ (21st.dev, Shader und Zeitleiste aus zanwei/glass-icon-reveal, MIT, Hinweis im Kopf) in reines JavaScript übertragen: WEBklar-Zeichen als Zeichenfläche (Stern und Punkt ausgestanzt), weiß, keine Schleife, Textur 512 px (auf dem Handy ~3× schneller bereit), Hintergrund läuft zum Rand aus, kein Rauschen auf reinem Weiß, ohne WebGL2 das ruhende Logo als CSS-Maske. - Anmeldung (Handy und PC): Glas-Logo statt App-Icon, der Name erscheint, sobald das Glas losgelassen hat; ein Klick spielt es erneut; reduzierte Bewegung zeigt das ruhende Logo. - Installierte App: startbild.js im Kopf von Übersicht und Anmeldung deckt beim ersten Öffnen je App-Start weiß ab, spielt das Glas-Logo (Tempo 1,25) und blendet dann weich aus; ein Tipp überspringt, spätestens nach 6,5 s ist es weg. Danach ruht das Logo der Anmeldung. - Tests: knGlasLogo (Zeitleiste, Glasbreite < 1,3 Kacheln, MIT-Hinweis, Startbild in der VM), Seitenprüfungen der Anmeldung und der Übersicht kennen die neuen Skripte. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1725aaa91c
commit
c997703251
9 files changed
+1221
-10
No files matched your search
@@ -24,6 +24,8 @@
|
||||
<link rel="stylesheet" href="/neu/funktionen.css">
|
||||
<link rel="stylesheet" href="/neu/module.css">
|
||||
<link rel="stylesheet" href="/neu/wasser-leiste.css">
|
||||
<!-- Installierte App: weißes Startbild mit dem Glas-Logo, läuft vor dem ersten Bild -->
|
||||
<script src="/neu/startbild.js"></script>
|
||||
</head>
|
||||
<body class="kn">
|
||||
<a class="kn-skip" href="#kn-inhalt">Zum Inhalt</a>
|
||||
|
||||
@@ -0,0 +1,926 @@
|
||||
/*
|
||||
* Glas-Logo: das WEBklar-Logo erscheint groß hinter einer klaren Glaskachel, deren Rand es an den Ecken
|
||||
* farbig bricht; dann lässt das Glas los und das blanke Logo bleibt stehen. Ein WebGL2-Durchgang, keine
|
||||
* Abhängigkeiten. Genutzt von der Anmeldung (login-logo.js) und vom Startbild der App (startbild.js).
|
||||
*
|
||||
* Übertragen aus dem React-Bauteil „liquid-glass-logo-reveal“ (21st.dev, Glass Reveal) in reines JavaScript;
|
||||
* Shader und Feder-Zeitleiste stammen aus zanwei/glass-icon-reveal: https://github.com/zanwei/glass-icon-reveal
|
||||
* Gegenüber dem Original: nur der helle Stil, keine Schleife, Standbild statt Endlosschleife, das Logo kommt
|
||||
* als Zeichenfläche, der Hintergrund (Schatten, Welle) läuft zum Rand der Zeichenfläche aus und das Rauschen gegen
|
||||
* Farbstufen lässt reines Weiß unberührt: so sitzt die Fläche nahtlos auf weißen Seiten.
|
||||
*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2026 Zanwei Guo
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
/** Heller Stil des Originals (LIGHT); Hintergrund und Logofarbe setzt glasLogo() für WEBklar. */
|
||||
const HELL = {
|
||||
tileSize: 256, logoScale: 0.7, finalScale: 0.69, logoOffset: [0, 0], iconPlate: false, keepPlate: false, originalColors: false,
|
||||
opticalDepth: 1.1, dispersion: 1.7, colorBleed: 1.25, doubleBevel: 1, bevelWidth: 0.14, corner: 4.8,
|
||||
cornerPull: 2.1, cornerWidth: 2.2, cornerFocus: 0.58, edgeSmear: 2.85, stretch: 0, tilt: 0.35, bulge: 1,
|
||||
specular: 1, halo: 0, shadow: 1, ripple: 1,
|
||||
bounce: 0, response: 0.42, startSize: 0.2, startZoom: 2.6, glide: 2.3, present: 2.4, drift: 0.12, swell: 0.5, focus: 1,
|
||||
sheen: 1, lightTurn: 0.9,
|
||||
background: '#e9e9eb', plateColor: '#ffffff', logoColor: '#0e1428',
|
||||
}
|
||||
|
||||
/** Vorgaben für das WEBklar-Logo: weiß wie die Seiten, Oval in #111111, Größen wie beim 21st.dev-Zeichen. */
|
||||
export const WEBKLAR = { background: '#ffffff', logoColor: '#111111', logoScale: 0.6, finalScale: 0.8, startZoom: 2.3 }
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Logo → Textur + vorzeichenbehaftetes Distanzfeld
|
||||
* ------------------------------------------------------------------ */
|
||||
const TEX = 512 // Kantenlänge der Logotextur (Original 1024; 512 reicht für das Logo und rechnet auf dem Handy ~4× schneller)
|
||||
const BOX = 0.75 // Anteil der Textur, den das Logo füllt (der Rest ist Platz für das Distanzfeld)
|
||||
|
||||
/**
|
||||
* Das WEBklar-Zeichen (Oval mit Stern und Punkt, wie favicon.svg) als Zeichenfläche: Stern und Punkt sind
|
||||
* ausgestanzt, damit der Shader die Form samt Löchern bricht. Gezeichnet statt als SVG-Bild geladen, so gibt es
|
||||
* keinen Ladeweg und keine „verschmutzte“ Zeichenfläche in älteren Safari-Versionen.
|
||||
*/
|
||||
export function webklarZeichen(doc = document) {
|
||||
const c = doc.createElement('canvas')
|
||||
c.width = c.height = TEX
|
||||
const ctx = c.getContext('2d')
|
||||
ctx.scale(TEX / 512, TEX / 512)
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.save()
|
||||
ctx.translate(256, 256)
|
||||
ctx.rotate((-28 * Math.PI) / 180)
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(0, 0, 210, 150, 0, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.globalCompositeOperation = 'destination-out'
|
||||
ctx.fill(new Path2D('M 0,-170 C 22,-105 60,-72 120,-56 C 60,-40 22,-8 0,64 C -22,-8 -60,-40 -120,-56 C -60,-72 -22,-105 0,-170 Z'))
|
||||
ctx.restore()
|
||||
ctx.globalCompositeOperation = 'destination-out'
|
||||
ctx.beginPath()
|
||||
ctx.arc(356, 172, 18, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
return c
|
||||
}
|
||||
|
||||
// Zeichnet die Quelle in eine TEX×TEX-Fläche, beschnitten auf ihre sichtbaren Pixel und in die Logobox eingepasst.
|
||||
// Undurchsichtige Bilder (JPG, flaches PNG) verlieren ihre Eckfarbe, damit das Zeichen die Form wird.
|
||||
function fitToTexture(source, sw, sh) {
|
||||
const tmp = document.createElement('canvas')
|
||||
tmp.width = tmp.height = TEX
|
||||
const tc = tmp.getContext('2d', { willReadFrequently: true })
|
||||
const s0 = Math.min(TEX / sw, TEX / sh)
|
||||
const dx0 = (TEX - sw * s0) / 2
|
||||
const dy0 = (TEX - sh * s0) / 2
|
||||
tc.drawImage(source, dx0, dy0, sw * s0, sh * s0)
|
||||
const d = tc.getImageData(0, 0, TEX, TEX).data
|
||||
|
||||
let opaque = true
|
||||
for (let i = 3; i < d.length; i += 4 * 97) if (d[i] < 250) { opaque = false; break }
|
||||
const key = [d[0], d[1], d[2]]
|
||||
const keyAlpha = (r, g, b) => Math.min(1, Math.max(0, (Math.hypot(r - key[0], g - key[1], b - key[2]) / 441 - 0.08) / 0.2))
|
||||
|
||||
let x0 = TEX, y0 = TEX, x1 = -1, y1 = -1
|
||||
for (let y = 0; y < TEX; y++) {
|
||||
for (let x = 0; x < TEX; x++) {
|
||||
const i = (y * TEX + x) * 4
|
||||
const a = opaque ? keyAlpha(d[i], d[i + 1], d[i + 2]) * 255 : d[i + 3]
|
||||
if (a > 8) {
|
||||
if (x < x0) x0 = x
|
||||
if (x > x1) x1 = x
|
||||
if (y < y0) y0 = y
|
||||
if (y > y1) y1 = y
|
||||
}
|
||||
}
|
||||
}
|
||||
if (x1 < 0) throw new Error('Glas-Logo: das Logo ist leer')
|
||||
const bw = x1 - x0 + 1
|
||||
const bh = y1 - y0 + 1
|
||||
const s = (TEX * BOX) / Math.max(bw, bh)
|
||||
|
||||
const out = document.createElement('canvas')
|
||||
out.width = out.height = TEX
|
||||
const oc = out.getContext('2d', { willReadFrequently: true })
|
||||
oc.imageSmoothingQuality = 'high'
|
||||
oc.drawImage(source, TEX / 2 - (x0 + bw / 2 - dx0) * s, TEX / 2 - (y0 + bh / 2 - dy0) * s, sw * s0 * s, sh * s0 * s)
|
||||
if (opaque) {
|
||||
const im = oc.getImageData(0, 0, TEX, TEX)
|
||||
const e = im.data
|
||||
for (let i = 0; i < e.length; i += 4) e[i + 3] = Math.round(e[i + 3] * keyAlpha(e[i], e[i + 1], e[i + 2]))
|
||||
oc.putImageData(im, 0, 0)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Distanzfeld aus dem Alphakanal (TinySDF-Verfahren): + außen, − innen, in Textureinheiten.
|
||||
function buildSdf(canvas) {
|
||||
const n = TEX * TEX
|
||||
const alpha = canvas.getContext('2d', { willReadFrequently: true }).getImageData(0, 0, TEX, TEX).data
|
||||
const INF = 1e20
|
||||
const outer = new Float64Array(n)
|
||||
const inner = new Float64Array(n)
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = alpha[i * 4 + 3] / 255
|
||||
if (a >= 1) { outer[i] = 0; inner[i] = INF }
|
||||
else if (a <= 0) { outer[i] = INF; inner[i] = 0 }
|
||||
else {
|
||||
const dd = 0.5 - a
|
||||
outer[i] = dd > 0 ? dd * dd : 0
|
||||
inner[i] = dd < 0 ? dd * dd : 0
|
||||
}
|
||||
}
|
||||
const f = new Float64Array(TEX)
|
||||
const z = new Float64Array(TEX + 1)
|
||||
const v = new Uint16Array(TEX)
|
||||
edt(outer, f, v, z, INF)
|
||||
edt(inner, f, v, z, INF)
|
||||
const sdf = new Float32Array(n)
|
||||
for (let i = 0; i < n; i++) sdf[i] = (Math.sqrt(outer[i]) - Math.sqrt(inner[i])) / TEX
|
||||
return sdf
|
||||
}
|
||||
|
||||
function edt(grid, f, v, z, INF) {
|
||||
for (let x = 0; x < TEX; x++) edt1d(grid, x, TEX, f, v, z, INF)
|
||||
for (let y = 0; y < TEX; y++) edt1d(grid, y * TEX, 1, f, v, z, INF)
|
||||
}
|
||||
|
||||
function edt1d(grid, offset, stride, f, v, z, INF) {
|
||||
v[0] = 0
|
||||
z[0] = -INF
|
||||
z[1] = INF
|
||||
f[0] = grid[offset]
|
||||
for (let q = 1, k = 0, s = 0; q < TEX; q++) {
|
||||
f[q] = grid[offset + q * stride]
|
||||
const q2 = q * q
|
||||
do {
|
||||
const r = v[k]
|
||||
s = (f[q] - f[r] + q2 - r * r) / (q - r) / 2
|
||||
} while (s <= z[k] && --k > -1)
|
||||
k++
|
||||
v[k] = q
|
||||
z[k] = s
|
||||
z[k + 1] = INF
|
||||
}
|
||||
for (let q = 0, k = 0; q < TEX; q++) {
|
||||
while (z[k + 1] < q) k++
|
||||
const r = v[k]
|
||||
const qr = q - r
|
||||
grid[offset + q * stride] = f[r] + qr * qr
|
||||
}
|
||||
}
|
||||
|
||||
// Ein Distanzfeld je Logo für die ganze Seite: der Aufbau dauert ~100 ms, Startbild und Anmeldung teilen es.
|
||||
const logoCache = new Map()
|
||||
|
||||
function ladeLogo(schluessel, quelle) {
|
||||
let job = logoCache.get(schluessel)
|
||||
if (!job) {
|
||||
job = new Promise((resolve, reject) => {
|
||||
// Erst nach dem nächsten Bild rechnen, damit Weiß und Seite vorher stehen
|
||||
requestAnimationFrame(() => setTimeout(() => {
|
||||
try {
|
||||
const c = quelle()
|
||||
const canvas = fitToTexture(c, c.width, c.height)
|
||||
resolve({ canvas, sdf: buildSdf(canvas) })
|
||||
} catch (err) { reject(err) }
|
||||
}, 0))
|
||||
})
|
||||
logoCache.set(schluessel, job)
|
||||
job.catch(() => logoCache.delete(schluessel))
|
||||
}
|
||||
return job
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Shader (unverändert bis auf den auslaufenden Rand und das Rauschen in der letzten Zeile)
|
||||
* ------------------------------------------------------------------ */
|
||||
const VERT = `#version 300 es
|
||||
in vec2 aPos;
|
||||
void main() { gl_Position = vec4(aPos, 0.0, 1.0); }`
|
||||
|
||||
const FRAG = `#version 300 es
|
||||
precision highp float;
|
||||
out vec4 outColor;
|
||||
|
||||
uniform vec2 uCenter; // device px, GL coords
|
||||
uniform float uTile; // tile size, device px
|
||||
uniform sampler2D uSdf; // signed distance, texture units, + outside
|
||||
uniform sampler2D uCol; // premultiplied colour, mipmapped
|
||||
uniform float uTexSize;
|
||||
uniform float uBoxFrac;
|
||||
|
||||
uniform float uPlateScale; // plate size relative to the tile
|
||||
uniform float uShapeN; // superellipse exponent: 2 = circle
|
||||
uniform float uCornerN; // final exponent, used by the ghost
|
||||
uniform float uOptics; // 1 = full refraction, 0 = flat
|
||||
uniform float uPlateAlpha; // 1 = glass shown, 0 = bare logo
|
||||
uniform float uLogoHalf; // logo half size in plate units (zoom included)
|
||||
uniform vec2 uLogoOff; // logo centre, plate units
|
||||
uniform float uBold; // stroke swell, in logo half-size units
|
||||
uniform float uBlur; // edge softness, plate units
|
||||
uniform float uFrost; // milky lift inside a young bubble
|
||||
uniform float uDome; // lens bulge height
|
||||
uniform float uDrop; // 1 while the glass is a round droplet, 0 once it has spread into the tile
|
||||
uniform float uPinch; // the face's stretch toward its corners, at the rim
|
||||
uniform float uTilt; // extra stretch of the rising diagonal's corners (0–1)
|
||||
uniform float uRim;
|
||||
uniform float uHalo;
|
||||
uniform float uShadow;
|
||||
uniform float uGhost;
|
||||
uniform float uRipR; // ripple radius, plate sizes
|
||||
uniform float uRipA;
|
||||
uniform float uBare; // 1 once the logo may show without glass (the ending)
|
||||
uniform float uReach; // the rim band's full width, plate units
|
||||
uniform float uCornerPull; // how much harder the corners pull than the sides
|
||||
uniform float uStreak; // how far a corner smears content along the rim, in band widths
|
||||
uniform float uWiden; // how much wider the folding band grows toward the corners
|
||||
uniform float uSlab; // how far the slab stands above the logo, plate units
|
||||
uniform float uEtaSpread; // index spread across the spectrum (red highest)
|
||||
uniform float uFocus; // how far in along the diagonal a corner focuses, plate units
|
||||
uniform float uFocusK; // how quickly the view slides back from the focus across the band
|
||||
uniform float uFocusSplit; // how much further in red meets than violet, plate units
|
||||
uniform float uRimLine; // how far across the band the sight line takes to turn in to the focus
|
||||
uniform float uCornerOn; // 0 while the glass is round, 1 once it has spread into the tile
|
||||
uniform float uDissolve; // 0 → 1 as the glass lets go of the logo
|
||||
uniform float uGlow; // colour bleed in the rim, plate units on screen
|
||||
uniform float uFaceDisp; // lateral colour across the face
|
||||
uniform float uVivid; // chroma lift of the dispersed light
|
||||
|
||||
uniform float uDisp;
|
||||
uniform float uDouble;
|
||||
uniform float uBevelW;
|
||||
uniform float uSpec;
|
||||
uniform float uIconPlate;
|
||||
uniform float uUseColor;
|
||||
uniform float uLightScene; // 1 = light backdrop, 0 = dark
|
||||
uniform float uLightGlass; // the same for what the glass sits on (the plate, when there is one)
|
||||
uniform float uKeep; // 1 = the glass settles into a flat plate instead of letting go of it
|
||||
uniform float uSheen; // strength of the light band crossing the face
|
||||
uniform float uSweep; // where that band is, plate sizes along its diagonal
|
||||
uniform float uLightAng; // how far the rim light has turned, radians
|
||||
|
||||
uniform vec3 uBg;
|
||||
uniform vec3 uPlateCol;
|
||||
uniform vec3 uLogoCol;
|
||||
|
||||
float px; // one device pixel in plate units
|
||||
|
||||
vec2 rot(vec2 v, float a) { float c = cos(a), s = sin(a); return vec2(c * v.x - s * v.y, s * v.x + c * v.y); }
|
||||
|
||||
// aa: footprint of one screen pixel at q, plate units.
|
||||
vec4 logoAt(vec2 q, float blur, float aa) {
|
||||
float L = uLogoHalf;
|
||||
float k = 0.5 * uBoxFrac / L;
|
||||
vec2 uv = vec2(0.5 + (q.x - uLogoOff.x) * k, 0.5 - (q.y - uLogoOff.y) * k);
|
||||
float d = texture(uSdf, uv).r / k;
|
||||
float w = 0.75 * aa + blur;
|
||||
float a = 1.0 - smoothstep(-w, w, d - uBold * L);
|
||||
vec3 c = uLogoCol;
|
||||
if (uUseColor > 0.5) {
|
||||
float lod = log2(max(1.0, w * k * uTexSize));
|
||||
vec4 c0 = textureLod(uCol, uv, lod);
|
||||
vec4 c1 = textureLod(uCol, uv, lod + 3.0);
|
||||
vec4 c2 = textureLod(uCol, uv, 7.0);
|
||||
c = c2.rgb / max(c2.a, 1e-4);
|
||||
c = mix(c, c1.rgb / max(c1.a, 1e-4), smoothstep(0.0, 0.06, c1.a));
|
||||
c = mix(c, c0.rgb / max(c0.a, 1e-4), smoothstep(0.02, 0.3, c0.a));
|
||||
}
|
||||
return vec4(c, a);
|
||||
}
|
||||
|
||||
// Superellipse field. Returns signed distance (plate units, − inside); g = 1 on the rim.
|
||||
float shape(vec2 p, float scale, float n, out vec2 nOut, out float g, out vec2 dg) {
|
||||
vec2 pp = p / scale;
|
||||
vec2 a = max(abs(pp), vec2(1e-6));
|
||||
g = pow(pow(a.x, n) + pow(a.y, n), 1.0 / n);
|
||||
dg = sign(pp) * pow(a, vec2(n - 1.0)) * pow(max(g, 1e-6), 1.0 - n);
|
||||
float dgl = max(length(dg), 1e-6);
|
||||
nOut = dg / dgl;
|
||||
return (g - 1.0) / dgl * scale;
|
||||
}
|
||||
|
||||
// Curvature radius (plate units) of the shape's contour through p.
|
||||
float contourRadius(vec2 p, float scale, float n) {
|
||||
vec2 a = max(abs(p / scale), vec2(1e-4));
|
||||
vec2 a1 = pow(a, vec2(n - 1.0));
|
||||
vec2 a2 = pow(a, vec2(n - 2.0));
|
||||
float k = (n - 1.0) * (a2.x * a1.y * a1.y + a2.y * a1.x * a1.x) / pow(max(dot(a1, a1), 1e-30), 1.5);
|
||||
return scale / max(k, 1e-3);
|
||||
}
|
||||
|
||||
// Where the glass at x = s / bwF across its rounded edge looks: a slab uSlab above the logo with a
|
||||
// quarter-round edge of radius bwF. A straight-down sight line bends inward by Snell's law, so the
|
||||
// outer rim looks deepest and the view eases back to the face across the edge.
|
||||
vec2 refractSample(float x, float bwF, float eta, float R, vec2 dirOut, vec2 p, float dome, float offMul) {
|
||||
float u = clamp(1.0 - x, 0.0, 0.995);
|
||||
float th = asin(u), thr = asin(u / eta);
|
||||
float z = uSlab + bwF * sqrt(1.0 - u * u);
|
||||
float off = offMul * z * tan(th - thr);
|
||||
off = off / (1.0 + off / (6.0 * R));
|
||||
return p - (dirOut * off + p * dome) * uOptics;
|
||||
}
|
||||
|
||||
float gauss(float x, float c, float w) { float d = (x - c) / w; return exp(-0.5 * d * d); }
|
||||
float band(float x, float a, float b, float w) { return smoothstep(a - w, a, x) * (1.0 - smoothstep(b, b + w, x)); }
|
||||
|
||||
// Visible spectrum, red (0) to violet (1).
|
||||
float bump(float x, float c, float w) { float d = (x - c) / w; return exp(-0.5 * d * d); }
|
||||
vec3 spectrum(float x) {
|
||||
return vec3(bump(x, 0.34, 0.2) + 0.08 * bump(x, 0.93, 0.05), bump(x, 0.58, 0.1), bump(x, 0.8, 0.11));
|
||||
}
|
||||
|
||||
float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
|
||||
|
||||
void main() {
|
||||
px = 2.0 / uTile;
|
||||
vec2 p = (gl_FragCoord.xy - uCenter) * px;
|
||||
|
||||
vec4 lg = logoAt(p, uBlur, px);
|
||||
vec3 plain = mix(uBg, lg.rgb, lg.a * uBare);
|
||||
|
||||
vec2 nOut, dg; float g;
|
||||
float sd = shape(p, uPlateScale, uShapeN, nOut, g, dg);
|
||||
float s = -sd;
|
||||
float bw = max(uBevelW, 0.004) * uPlateScale;
|
||||
float x = clamp(s / bw, 0.0, 1.0);
|
||||
float R = contourRadius(p, uPlateScale, uShapeN);
|
||||
vec2 pq = p * p;
|
||||
float diag = 4.0 * pq.x * pq.y / max((pq.x + pq.y) * (pq.x + pq.y), 1e-6);
|
||||
float tilt = mix(1.0 - uTilt, 1.0, smoothstep(-0.02, 0.02, p.x * p.y));
|
||||
float pinchPart = uPinch * min(g * g, 1.0) * diag * diag * tilt;
|
||||
float dome = uDome * (1.0 - min(g * g, 1.0)) + pinchPart;
|
||||
vec2 dirOut = normalize(mix(nOut, p / max(length(p), 1e-4), 0.2));
|
||||
// Rounded corners curve both ways, so they draw content in from deeper than the sides.
|
||||
float cornerness = max(smoothstep(0.1, 0.9, 4.0 * nOut.x * nOut.x * nOut.y * nOut.y), smoothstep(0.15, 0.75, diag));
|
||||
float offMul = 1.0 + 0.4 * max(uCornerPull - 1.0, 0.0) * tilt * cornerness * uCornerOn;
|
||||
// Around a corner every sight line meets at one focus a little way in along the diagonal.
|
||||
vec2 focus = sign(p) * (pow(0.5, 1.0 / uShapeN) - uFocus * 0.70710678) * uPlateScale;
|
||||
float focusW = tilt * cornerness * uOptics * uCornerOn;
|
||||
float bwF = max(uReach, 0.004) * (1.0 + uWiden * tilt * cornerness * uCornerOn);
|
||||
float xf = clamp(s / bwF, 0.0, 1.0);
|
||||
// Footprint of one pixel after refraction, measured in uniform control flow.
|
||||
vec2 qc = refractSample(xf, bwF, 1.5, R, dirOut, p, dome, offMul);
|
||||
float foot = max(max(length(dFdx(qc)), length(dFdy(qc))), 0.25 * px);
|
||||
|
||||
// Backdrop: anticipation ghost, ripple, soft lift shadow and glow.
|
||||
vec3 bg = uBg;
|
||||
vec3 lift = mix(uBg + vec3(0.06), mix(uBg, vec3(1.0), 0.55), uLightScene);
|
||||
if (uGhost > 0.001) {
|
||||
vec2 gn, gdg; float gg;
|
||||
float gsd = shape(p, 1.0, uCornerN, gn, gg, gdg);
|
||||
bg = mix(bg, lift, uGhost * 0.12 * (1.0 - smoothstep(-0.16, 0.14, gsd)));
|
||||
bg *= 1.0 - uGhost * 0.03 * uLightScene * exp(-max(gsd, 0.0) / 0.12) * step(0.0, gsd);
|
||||
}
|
||||
float outside = max(sd, 0.0);
|
||||
if (uRipA > 0.001) {
|
||||
float d0 = (uRipR - 1.0) * uPlateScale;
|
||||
float rx = (sd - d0) / 0.022;
|
||||
float ring = exp(-rx * rx);
|
||||
bg += uRipA * ring * (0.04 * (0.55 + 0.45 * dot(nOut, normalize(vec2(-0.5, 0.8))))) * mix(0.5, 1.0, uLightScene);
|
||||
bg -= uRipA * 0.016 * uLightScene * exp(-(rx - 1.6) * (rx - 1.6));
|
||||
}
|
||||
float shadowFall = max(sd, 0.0) / (0.9 * uPlateScale);
|
||||
bg *= 1.0 - uShadow * 0.03 * exp(-shadowFall * shadowFall) * step(0.0, sd);
|
||||
vec3 haloCol = mix(vec3(0.62, 0.69, 0.80), vec3(1.0), uLightScene);
|
||||
bg = mix(bg, haloCol, clamp(uHalo * (0.30 * exp(-outside / (0.025 * uPlateScale)) + 0.07 * exp(-outside / (0.12 * uPlateScale))), 0.0, 1.0) * step(0.0, sd));
|
||||
// Schatten, Welle und Schein laufen zum Rand der Zeichenfläche hin aus: keine Kante, wo die Fläche auf die Seite trifft
|
||||
vec2 border = min(gl_FragCoord.xy, 2.0 * uCenter - gl_FragCoord.xy);
|
||||
bg = mix(uBg, bg, smoothstep(0.0, 0.2 * uTile, min(border.x, border.y)));
|
||||
|
||||
vec3 col = bg;
|
||||
float soft = px + uBlur * 0.8 + 0.06 * uDissolve * (1.0 - uKeep) * uPlateScale;
|
||||
if (sd < soft) {
|
||||
// Lighting surface: a superellipse quarter, flat toward the face and steep at the rim.
|
||||
float y1 = 1.0 - x;
|
||||
const float K = 2.6;
|
||||
float base1 = max(1.0 - pow(y1, K), 1e-4);
|
||||
float db = min(pow(y1, K - 1.0) * pow(base1, 1.0 / K - 1.0), 25.0);
|
||||
// A second, lower step just inside the first draws the faint inner line.
|
||||
float s1 = bw * 0.62;
|
||||
float w2 = bw * 0.22;
|
||||
float t2 = clamp((s - s1) / w2, -1.0, 1.0);
|
||||
float dh2 = uDouble * bw * 0.16;
|
||||
float dd = dh2 * 0.5 * cos(t2 * 1.5707963) * 1.5707963 / w2 * step(abs(t2), 0.999);
|
||||
vec2 gDome = -uDome * 2.0 * g * dg * step(g, 1.0);
|
||||
vec2 grad = -(db + dd) * nOut + gDome;
|
||||
|
||||
vec3 back = mix(uBg, uPlateCol, uIconPlate);
|
||||
vec3 liftG = mix(back + vec3(0.06), mix(back, vec3(1.0), 0.55), uLightGlass);
|
||||
float rblur = uBlur + 0.05 * length(qc - p) + min(uGlow * uOptics * (1.0 - xf) * foot / px, 0.04);
|
||||
|
||||
// Each wavelength refracts by its own index, so everything the edge draws in fans into colour
|
||||
// while the face stays crisp. Around a corner the samples also smear along the rim.
|
||||
float cornerW = smoothstep(0.1, 0.9, 4.0 * nOut.x * nOut.x * nOut.y * nOut.y);
|
||||
float streak = 0.35 * uStreak * bw * mix(0.15, 1.0, cornerW) * pow(1.0 - xf, 1.5) * uOptics;
|
||||
vec2 tangent = vec2(-nOut.y, nOut.x);
|
||||
vec3 acc = vec3(0.0), wsum = vec3(0.0);
|
||||
for (int i = 0; i < 20; i++) {
|
||||
float lam = (float(i) + 0.5) / 20.0;
|
||||
float c = uDisp * (1.0 - 2.0 * lam);
|
||||
vec2 along = tangent * (streak * 2.0 * (fract(float(i) * 0.618034 + 0.31) - 0.5));
|
||||
float cf = uDisp * (1.0 - 2.0 * lam) * (lam > 0.5 ? 1.0 : 0.5);
|
||||
vec2 sp = refractSample(xf, bwF, 1.5 + uEtaSpread * c, R, dirOut, p + along,
|
||||
dome + pinchPart * 0.3 * c, offMul);
|
||||
vec2 focusL = focus - sign(p) * (0.70710678 * uFocusSplit * c * uPlateScale);
|
||||
float wFocus = pow(1.0 - xf, uFocusK) * smoothstep(0.0, uRimLine * (1.0 + 0.6 * c), xf);
|
||||
sp = mix(sp, mix(p + along, focusL, wFocus), focusW);
|
||||
float faceOnly = smoothstep(0.7, 1.0, xf) * uOptics;
|
||||
sp *= 1.0 + uFaceDisp * cf * faceOnly;
|
||||
vec4 l = logoAt(sp, rblur + 0.12 * streak + uFaceDisp * 0.5 * max(c, 0.0) * faceOnly, foot);
|
||||
vec3 w = spectrum(lam);
|
||||
acc += mix(back, l.rgb, l.a) * w;
|
||||
wsum += w;
|
||||
}
|
||||
vec3 trans = acc / wsum;
|
||||
float tl = dot(trans, vec3(0.2126, 0.7152, 0.0722));
|
||||
trans = clamp(tl + (trans - tl) * (1.0 + uVivid * uOptics), 0.0, 1.0);
|
||||
// A young droplet is milky; the haze only ever lightens.
|
||||
float hazeLum = dot(trans, vec3(0.2126, 0.7152, 0.0722));
|
||||
float hazeGate = mix(1.0 - smoothstep(0.25, 0.75, hazeLum), smoothstep(0.25, 0.75, hazeLum), uLightGlass);
|
||||
trans = mix(trans, liftG, uFrost * 0.45 * hazeGate);
|
||||
|
||||
float glassAmt = pow(1.0 - uDissolve, 1.5);
|
||||
float lit = uSpec * glassAmt;
|
||||
vec3 rimCol = mix(vec3(0.80, 0.87, 0.97), vec3(1.0), uLightGlass);
|
||||
|
||||
// Light backdrop: the thick edge glows softly white over a clear face, with a hairline that is
|
||||
// bright along the top and right and a thin dark silhouette down the left and bottom.
|
||||
float sc = uPlateScale;
|
||||
float tLum = dot(trans, vec3(0.2126, 0.7152, 0.0722));
|
||||
float lightOnly = smoothstep(0.35, 0.85, tLum);
|
||||
float hw = smoothstep(-0.85, 0.5, dot(nOut, rot(vec2(0.5, 0.866), uLightAng)));
|
||||
float tileBand = band(s, 0.016 * sc, 0.075 * sc, 0.022 * sc);
|
||||
float dropBand = (1.0 - smoothstep(0.06 * sc, 0.13 * sc, s)) * smoothstep(0.0, 0.004 * sc, s);
|
||||
float glow = mix(0.32 * tileBand, 0.75 * dropBand, uDrop) * lightOnly;
|
||||
float edgeLine = gauss(s, 0.005 * sc, 0.0045 * sc);
|
||||
float lip = gauss(s, 0.15 * sc, 0.007 * sc) * uDouble * (1.0 - uDrop);
|
||||
vec3 lightGlass = mix(trans, vec3(1.0), glow * glassAmt);
|
||||
lightGlass += (0.075 * hw * edgeLine + 0.06 * (0.4 + 0.6 * hw) * lip) * lit;
|
||||
lightGlass *= 1.0 - 0.05 * (1.0 - hw) * edgeLine * glassAmt * (1.0 - uDrop);
|
||||
lightGlass = mix(lightGlass, vec3(1.0), 0.55 * uDrop * glassAmt * edgeLine);
|
||||
|
||||
// Dark backdrop: a cool rim light that grows toward the steep edge, and a small key highlight.
|
||||
vec3 Nl = normalize(vec3(-grad, 1.0));
|
||||
float edge = 1.0 - Nl.z;
|
||||
vec2 ldir = rot(normalize(vec2(-0.62, 0.78)), uLightAng);
|
||||
float side = dot(nOut, ldir);
|
||||
float fres = pow(edge, 1.5);
|
||||
vec3 rim = rimCol * fres * (0.35 + 0.65 * max(side, -side * 0.8)) * uRim * 0.65;
|
||||
vec3 Hv = normalize(normalize(vec3(-0.45, 0.6, 0.66)) + vec3(0.0, 0.0, 1.0));
|
||||
float spc = pow(max(dot(Nl, Hv), 0.0), 140.0) * 0.8 * smoothstep(0.0, 0.25, y1);
|
||||
vec3 darkGlass = trans * (1.0 - 0.25 * fres) + (rim + vec3(spc)) * lit;
|
||||
darkGlass = mix(darkGlass, rimCol, uDrop * 0.4 * uSpec * (1.0 - smoothstep(0.0, 0.1 * uPlateScale, s)));
|
||||
vec3 glass = mix(darkGlass, lightGlass, uLightGlass);
|
||||
|
||||
// While the glass holds the logo a soft band of light crosses the face, brightest near the rim.
|
||||
float sweepD = dot(p / uPlateScale, normalize(vec2(0.8, 0.6))) - uSweep;
|
||||
float sheen = uSheen * glassAmt * exp(-sweepD * sweepD / 0.03) * (0.55 + 0.45 * (1.0 - x));
|
||||
glass = mix(glass, vec3(1.0), sheen * mix(0.22, 0.42, uLightGlass));
|
||||
|
||||
float cov = clamp(0.5 - sd / soft, 0.0, 1.0);
|
||||
col = mix(bg, glass, cov);
|
||||
}
|
||||
|
||||
vec3 outc = mix(plain, col, uPlateAlpha);
|
||||
// Rauschen gegen Farbstufen, aber nicht auf reinem Weiß: dort bleibt die Fläche exakt so weiß wie die Seite
|
||||
float weiss = step(0.9995, min(min(outc.r, outc.g), outc.b));
|
||||
outc += (hash(gl_FragCoord.xy) - 0.5) / 255.0 * (1.0 - weiss);
|
||||
outColor = vec4(outc, 1.0);
|
||||
}`
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Zeichner
|
||||
* ------------------------------------------------------------------ */
|
||||
function createRenderer(canvas) {
|
||||
const gl = canvas.getContext('webgl2', { antialias: false, premultipliedAlpha: false })
|
||||
if (!gl) return null
|
||||
const compile = (type, src) => {
|
||||
const sh = gl.createShader(type)
|
||||
gl.shaderSource(sh, src)
|
||||
gl.compileShader(sh)
|
||||
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(sh) || 'shader')
|
||||
return sh
|
||||
}
|
||||
const prog = gl.createProgram()
|
||||
gl.attachShader(prog, compile(gl.VERTEX_SHADER, VERT))
|
||||
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FRAG))
|
||||
gl.linkProgram(prog)
|
||||
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog) || 'program')
|
||||
gl.useProgram(prog)
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer())
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW)
|
||||
const loc = gl.getAttribLocation(prog, 'aPos')
|
||||
gl.enableVertexAttribArray(loc)
|
||||
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0)
|
||||
|
||||
const U = {}
|
||||
const count = gl.getProgramParameter(prog, gl.ACTIVE_UNIFORMS)
|
||||
for (let i = 0; i < count; i++) {
|
||||
const info = gl.getActiveUniform(prog, i)
|
||||
U[info.name] = gl.getUniformLocation(prog, info.name)
|
||||
}
|
||||
|
||||
const sdfTex = gl.createTexture()
|
||||
const colTex = gl.createTexture()
|
||||
gl.uniform1i(U.uSdf, 0)
|
||||
gl.uniform1i(U.uCol, 1)
|
||||
let hasLogo = false
|
||||
|
||||
const params = (filter) => {
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
}
|
||||
|
||||
function setLogo(logo) {
|
||||
gl.activeTexture(gl.TEXTURE0)
|
||||
gl.bindTexture(gl.TEXTURE_2D, sdfTex)
|
||||
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false)
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.R16F, TEX, TEX, 0, gl.RED, gl.FLOAT, logo.sdf)
|
||||
params(gl.LINEAR)
|
||||
|
||||
gl.activeTexture(gl.TEXTURE1)
|
||||
gl.bindTexture(gl.TEXTURE_2D, colTex)
|
||||
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true)
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA, gl.UNSIGNED_BYTE, logo.canvas)
|
||||
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false)
|
||||
gl.generateMipmap(gl.TEXTURE_2D)
|
||||
params(gl.LINEAR_MIPMAP_LINEAR)
|
||||
hasLogo = true
|
||||
}
|
||||
|
||||
function render(fr, w, h, tilePx) {
|
||||
if (!hasLogo) return
|
||||
gl.viewport(0, 0, w, h)
|
||||
gl.uniform2f(U.uCenter, w / 2, h / 2)
|
||||
gl.uniform1f(U.uTile, tilePx)
|
||||
gl.uniform1f(U.uTexSize, TEX)
|
||||
gl.uniform1f(U.uBoxFrac, BOX)
|
||||
for (const k in fr) {
|
||||
const v = fr[k]
|
||||
if (typeof v === 'number' && U[k]) gl.uniform1f(U[k], v)
|
||||
}
|
||||
gl.uniform3fv(U.uBg, fr.uBg)
|
||||
gl.uniform3fv(U.uPlateCol, fr.uPlateCol)
|
||||
gl.uniform3fv(U.uLogoCol, fr.uLogoCol)
|
||||
gl.uniform2fv(U.uLogoOff, fr.uLogoOff)
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3)
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext()
|
||||
}
|
||||
|
||||
return { setLogo, render, dispose }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Feder-Zeitleiste
|
||||
* ------------------------------------------------------------------ */
|
||||
const TAIL = 1.1 // vom Loslassen des Glases bis alles ruht, Sekunden
|
||||
|
||||
/** Sekunden vom ersten Bild bis zum ruhenden Logo (bei Tempo 1). */
|
||||
export const dauer = (P = HELL) => P.present + TAIL
|
||||
|
||||
const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x)
|
||||
const mix = (a, b, t) => a + (b - a) * t
|
||||
const smooth = (a, b, x) => {
|
||||
const t = clamp01((x - a) / (b - a))
|
||||
return t * t * (3 - 2 * t)
|
||||
}
|
||||
const smoother = (a, b, x) => {
|
||||
const t = clamp01((x - a) / (b - a))
|
||||
return t * t * t * (t * (t * 6 - 15) + 10)
|
||||
}
|
||||
|
||||
function hexToRgb(hex) {
|
||||
let h = hex.replace('#', '')
|
||||
if (h.length === 3) h = h.replace(/./g, '$&$&')
|
||||
const v = parseInt(h.slice(0, 6), 16) || 0
|
||||
return [((v >> 16) & 255) / 255, ((v >> 8) & 255) / 255, (v & 255) / 255]
|
||||
}
|
||||
const luminance = ([r, g, b]) => 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
|
||||
// Sprungantwort einer Feder, losgelassen bei t = 0, von 0 nach 1 (Dämpfung 1 = kein Überschwingen,
|
||||
// response = Periode der ungedämpften Feder in Sekunden).
|
||||
function spring(t, response, damping) {
|
||||
if (t <= 0) return 0
|
||||
const w = (2 * Math.PI) / response
|
||||
if (damping >= 1) return 1 - Math.exp(-w * t) * (1 + w * t)
|
||||
const wd = w * Math.sqrt(1 - damping * damping)
|
||||
return 1 - Math.exp(-damping * w * t) * (Math.cos(wd * t) + ((damping * w) / wd) * Math.sin(wd * t))
|
||||
}
|
||||
|
||||
const reachCache = new Map()
|
||||
function timeToReach(level, response, damping) {
|
||||
const key = `${level}/${response}/${damping}`
|
||||
let t = reachCache.get(key)
|
||||
if (t === undefined) {
|
||||
let lo = 0
|
||||
let hi = 3
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const mid = (lo + hi) / 2
|
||||
if (spring(mid, response, damping) < level) lo = mid
|
||||
else hi = mid
|
||||
}
|
||||
reachCache.set(key, (t = hi))
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Bewegung steckt hier, der Shader sieht nur die Zahlen. Der Tropfen wächst auf einer Feder, wird beim
|
||||
* Landen vom Kreis zur Kachel, das Logo gleitet aus der Vergrößerung, das Glas hält es, während ein Lichtband
|
||||
* über die Fläche wandert; dann lässt das Glas los und das blanke Logo bleibt.
|
||||
*/
|
||||
export function frameAt(T, P = HELL, outro = 0) {
|
||||
const tx = P.present
|
||||
const keep = P.keepPlate ? 1 : 0
|
||||
const damping = 1 - P.bounce
|
||||
const grow = spring(T + 0.01, P.response, damping)
|
||||
const tl = timeToReach(0.85, P.response, damping) - 0.01 // der Tropfen landet: die Welle läuft los
|
||||
const tm = timeToReach(0.92, P.response, damping) - 0.01 // und breitet sich zur Kachel aus
|
||||
const morph = spring(T - tm, 0.4, 1)
|
||||
const drop = 1 - smooth(0.05, 1, morph)
|
||||
const appear = smooth(0, 0.08, T)
|
||||
const drift = P.drift * spring(T - 0.3, 2.2, 1)
|
||||
// Ein Viertel des Zooms geht mit dem landenden Tropfen, der Rest gleitet langsam, damit das Logo groß am
|
||||
// Rand verweilt, wo das Glas es am stärksten bricht.
|
||||
const zoomIn = 0.25 * spring(T, 0.7, 1) + 0.75 * smoother(0, P.glide, T)
|
||||
const settle = spring(T - 0.5, 2.4, 1)
|
||||
const exit = spring(T - tx, 0.95, 1)
|
||||
const release = spring(T - tx - 0.05, 0.65, 1)
|
||||
const dome = 0.15 * P.bulge * (1 - smooth(0.4, 0.95, grow))
|
||||
const view = (1 + (P.startZoom - 1) * (1 - zoomIn)) * (1 - 0.045 * settle) * mix(1, P.finalScale, exit) * (1 - 0.06 * outro)
|
||||
const bandW = mix(P.bevelWidth, Math.max(P.bevelWidth, 0.4), drop)
|
||||
const plateScale = mix(P.startSize, 1, grow) * (1 + drift) * (1 + 0.14 * release * (1 - keep)) * (1 - keep * (0.03 * release + 0.06 * outro))
|
||||
const bg = hexToRgb(P.background)
|
||||
return {
|
||||
uPlateScale: plateScale,
|
||||
uShapeN: mix(2, P.corner, morph),
|
||||
uCornerN: P.corner,
|
||||
uOptics: 1 - smooth(0.55, 1, release),
|
||||
uPlateAlpha: keep ? appear * (1 - outro) : appear * (1 - smooth(0.45, 1, release)),
|
||||
uLogoHalf: P.logoScale * view,
|
||||
uLogoOff: [P.logoOffset[0] * view, P.logoOffset[1] * view],
|
||||
uKeep: keep,
|
||||
uSheen: P.sheen,
|
||||
uSweep: mix(-1.6, 1.6, smoother(0.9, tx - 0.1, T)),
|
||||
uLightAng: P.lightTurn * smoother(0.4, tx + 0.4, T),
|
||||
uBold: P.swell * 0.03 * (1 - zoomIn),
|
||||
uBlur: P.focus * 0.05 * (1 - smooth(0.3, 0.95, grow)) + 0.03 * outro,
|
||||
uFrost: P.focus * (1 - smooth(0.5, 0.95, grow)),
|
||||
uPinch: 0.38 * P.stretch * smooth(0.1, 0.9, morph) * smooth(0.75, 0.97, zoomIn),
|
||||
uTilt: P.tilt,
|
||||
uDome: dome,
|
||||
uDrop: drop,
|
||||
uRim: 1 + 0.8 * drop,
|
||||
uHalo: P.halo * (0.5 + 0.5 * drop) * (1 - keep * release),
|
||||
uShadow: Math.max(P.shadow * smooth(0.45, 0.8, grow) * (1 - smooth(0, 0.6, morph)), 0.8 * P.shadow * keep * release),
|
||||
uGhost: smooth(0, 0.03, T) * (1 - smooth(0.04, 0.08, T)),
|
||||
uRipR: 1 + 0.9 * Math.max(0, T - tl),
|
||||
uRipA: P.ripple * smooth(tl, tl + 0.06, T) * Math.exp(-Math.max(0, T - tl) / 0.6),
|
||||
uBare: (1 - keep) * smooth(tx - 0.25, tx, T) * (1 - outro),
|
||||
uReach: bandW * plateScale,
|
||||
uCornerPull: P.cornerPull,
|
||||
uStreak: P.edgeSmear,
|
||||
uWiden: P.cornerWidth,
|
||||
uSlab: 0.3 * P.opticalDepth,
|
||||
uEtaSpread: 0.06,
|
||||
uFocus: P.cornerFocus,
|
||||
uFocusK: 1.0,
|
||||
uFocusSplit: 0.056 * P.colorBleed,
|
||||
uRimLine: 0.09,
|
||||
uCornerOn: smooth(0.35, 1, morph),
|
||||
uGlow: 0.012 * P.colorBleed,
|
||||
uFaceDisp: 0.012 * P.dispersion,
|
||||
uVivid: 0.85,
|
||||
uDissolve: release,
|
||||
uDisp: P.dispersion,
|
||||
uDouble: P.doubleBevel,
|
||||
uBevelW: bandW * (1 - release),
|
||||
uSpec: P.specular,
|
||||
uIconPlate: P.iconPlate ? 1 : 0,
|
||||
uUseColor: P.originalColors ? 1 : 0,
|
||||
uLightScene: luminance(bg) > 0.5 ? 1 : 0,
|
||||
uLightGlass: luminance(P.iconPlate ? hexToRgb(P.plateColor) : bg) > 0.5 ? 1 : 0,
|
||||
uBg: bg,
|
||||
uPlateCol: hexToRgb(P.plateColor),
|
||||
uLogoCol: hexToRgb(P.logoColor),
|
||||
}
|
||||
}
|
||||
|
||||
/** Einstellungen aus Vorgaben, WEBklar-Werten und eigenen Werten (wie die Props des Originals). */
|
||||
export function einstellungen(eigene = {}) {
|
||||
return { ...HELL, ...WEBKLAR, ...eigene }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Baustein
|
||||
* ------------------------------------------------------------------ */
|
||||
/**
|
||||
* Spielt das Glas-Logo in `wrap` (Größe gibt das Element vor; die Kachel passt sich an, höchstens tileSize).
|
||||
* optionen: { einstellungen, tempo, standbild (gleich das ruhende Logo zeigen), rand, label }
|
||||
* rand: so oft passt die Kachel in die kürzere Seite von `wrap` (Original 1,75; auf Weiß reichen ~1,4, weil Schatten
|
||||
* und Welle dort kaum sichtbar sind und das Glas höchstens 1,28 Kacheln breit wird).
|
||||
* Gibt { fertig, abspielen (true, wenn es neu startet), zeigeEnde, entfernen } zurück; `fertig` erfüllt sich, sobald das Logo ruht
|
||||
* (auch ohne WebGL2, bei reduzierter Bewegung oder wenn etwas schiefgeht, damit niemand darauf hängen bleibt).
|
||||
*/
|
||||
export function glasLogo(wrap, { einstellungen: eigene, tempo = 1, standbild = false, rand = 1.75, label = 'WEBklar' } = {}) {
|
||||
const P = einstellungen(eigene)
|
||||
let melde
|
||||
const fertig = new Promise((resolve) => { melde = resolve })
|
||||
wrap.setAttribute('role', 'img')
|
||||
wrap.setAttribute('aria-label', label)
|
||||
wrap.style.backgroundColor = P.background
|
||||
if (getComputedStyle(wrap).position === 'static') wrap.style.position = 'relative'
|
||||
wrap.style.overflow = 'hidden'
|
||||
|
||||
const motor = starteMotor(wrap, { P, tempo, rand, amEnde: () => melde() })
|
||||
if (!motor) {
|
||||
zeigeErsatz(wrap, P)
|
||||
melde()
|
||||
return { fertig, abspielen: () => false, zeigeEnde() {}, entfernen() { wrap.replaceChildren() } }
|
||||
}
|
||||
ladeLogo('webklar', () => webklarZeichen()).then(
|
||||
(data) => motor.setLogo(data, { standbild }),
|
||||
(err) => { console.error(err); melde() },
|
||||
)
|
||||
return {
|
||||
fertig,
|
||||
abspielen: () => motor.abspielen(),
|
||||
zeigeEnde: () => motor.zeigeEnde(),
|
||||
entfernen: () => motor.dispose(),
|
||||
}
|
||||
}
|
||||
|
||||
/** Ohne WebGL2: das ruhende Logo als CSS-Maske in der Logofarbe. */
|
||||
function zeigeErsatz(wrap, P) {
|
||||
const span = document.createElement('span')
|
||||
span.setAttribute('aria-hidden', 'true')
|
||||
const groesse = `${Math.round(P.tileSize * P.logoScale * P.finalScale * 0.955)}px`
|
||||
const url = `url("${webklarZeichen().toDataURL('image/png')}")`
|
||||
Object.assign(span.style, {
|
||||
position: 'absolute', inset: '0', margin: 'auto', display: 'block', width: groesse, height: groesse, maxWidth: '40%', maxHeight: '40%',
|
||||
backgroundColor: P.logoColor, maskImage: url, webkitMaskImage: url, maskSize: 'contain', webkitMaskSize: 'contain',
|
||||
maskRepeat: 'no-repeat', webkitMaskRepeat: 'no-repeat', maskPosition: 'center', webkitMaskPosition: 'center',
|
||||
})
|
||||
wrap.append(span)
|
||||
}
|
||||
|
||||
// Besitzt die Zeichenfläche: eine frische je Aufruf (ein verlorener Kontext lässt sich auf demselben Element nie
|
||||
// wiederbeleben), gezeichnet nur, solange sichtbar und nur, wenn sich etwas geändert hat.
|
||||
function starteMotor(wrap, live) {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.setAttribute('aria-hidden', 'true')
|
||||
canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block'
|
||||
wrap.appendChild(canvas)
|
||||
|
||||
let renderer = null
|
||||
try {
|
||||
renderer = createRenderer(canvas)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
if (!renderer) {
|
||||
canvas.remove()
|
||||
return null
|
||||
}
|
||||
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
const end = dauer(live.P)
|
||||
let logo = null
|
||||
let time = 0
|
||||
let playing = false
|
||||
let dirty = true
|
||||
let visible = false
|
||||
let lost = false
|
||||
let raf = 0
|
||||
let last = 0
|
||||
|
||||
function tick(now) {
|
||||
raf = 0
|
||||
const dt = Math.min(0.1, (now - last) / 1000)
|
||||
last = now
|
||||
if (playing && logo) {
|
||||
time += dt * live.tempo
|
||||
if (time >= end) {
|
||||
time = end
|
||||
playing = false
|
||||
live.amEnde()
|
||||
}
|
||||
dirty = true
|
||||
}
|
||||
if (dirty && logo && renderer && !lost) {
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
const cw = canvas.clientWidth
|
||||
const ch = canvas.clientHeight
|
||||
const w = Math.max(1, Math.round(cw * dpr))
|
||||
const h = Math.max(1, Math.round(ch * dpr))
|
||||
if (canvas.width !== w || canvas.height !== h) {
|
||||
canvas.width = w
|
||||
canvas.height = h
|
||||
}
|
||||
const tile = Math.min(live.P.tileSize, Math.min(cw, ch) / live.rand)
|
||||
renderer.render(frameAt(Math.min(time, end), live.P), w, h, tile * dpr)
|
||||
dirty = false
|
||||
}
|
||||
if (visible && playing && logo && !lost) raf = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
function kick() {
|
||||
if (raf || !visible || lost) return
|
||||
last = performance.now()
|
||||
raf = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
function zeigeEnde() {
|
||||
time = end
|
||||
playing = false
|
||||
dirty = true
|
||||
live.amEnde()
|
||||
kick()
|
||||
}
|
||||
|
||||
const io = new IntersectionObserver(([entry]) => {
|
||||
visible = entry.isIntersecting
|
||||
if (visible) kick()
|
||||
else if (raf) {
|
||||
cancelAnimationFrame(raf)
|
||||
raf = 0
|
||||
}
|
||||
})
|
||||
io.observe(wrap)
|
||||
const ro = new ResizeObserver(() => {
|
||||
dirty = true
|
||||
kick()
|
||||
})
|
||||
ro.observe(wrap)
|
||||
|
||||
const onLost = (e) => {
|
||||
e.preventDefault()
|
||||
lost = true
|
||||
}
|
||||
const onRestored = () => {
|
||||
lost = false
|
||||
try {
|
||||
renderer = createRenderer(canvas)
|
||||
if (renderer && logo) renderer.setLogo(logo)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
dirty = true
|
||||
kick()
|
||||
}
|
||||
canvas.addEventListener('webglcontextlost', onLost)
|
||||
canvas.addEventListener('webglcontextrestored', onRestored)
|
||||
|
||||
return {
|
||||
setLogo(data, { standbild }) {
|
||||
logo = data
|
||||
renderer?.setLogo(data)
|
||||
// Reduzierte Bewegung: gleich das ruhende Logo (das Original zeigte ein Standbild mit Glas)
|
||||
if (standbild || reduceMotion) return zeigeEnde()
|
||||
time = 0
|
||||
playing = true
|
||||
dirty = true
|
||||
kick()
|
||||
},
|
||||
abspielen() {
|
||||
if (!logo || reduceMotion) return false
|
||||
time = 0
|
||||
playing = true
|
||||
dirty = true
|
||||
kick()
|
||||
return true
|
||||
},
|
||||
zeigeEnde,
|
||||
dispose() {
|
||||
if (raf) cancelAnimationFrame(raf)
|
||||
io.disconnect()
|
||||
ro.disconnect()
|
||||
canvas.removeEventListener('webglcontextlost', onLost)
|
||||
canvas.removeEventListener('webglcontextrestored', onRestored)
|
||||
renderer?.dispose()
|
||||
canvas.remove()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/* Kundenansicht neu: das Glas-Logo auf der Anmeldung (Handy und PC). Spielt einmal beim Laden; ein Klick spielt es erneut.
|
||||
Lief gerade das Startbild der App (startbild.js setzt data-kn-startbild am <html>), ruht das Logo sofort, damit die
|
||||
Animation nicht zweimal hintereinander kommt. Der Name unter dem Logo blendet per CSS ein, sobald das Glas losgelassen hat
|
||||
(oeffentlich.css); ruht das Logo, steht er sofort da (Klasse kn-login-ruht). */
|
||||
|
||||
import { glasLogo } from './glas-logo.js'
|
||||
|
||||
const LOGIN_EINSTELLUNGEN = { finalScale: 0.9 }
|
||||
|
||||
const feld = document.querySelector('.kn-login-glas')
|
||||
const marke = feld?.closest('.kn-login-marke')
|
||||
if (feld && marke) {
|
||||
const ruht = document.documentElement.hasAttribute('data-kn-startbild') || window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
if (ruht) marke.classList.add('kn-login-ruht')
|
||||
const logo = glasLogo(feld, { standbild: ruht, rand: 1.4, einstellungen: LOGIN_EINSTELLUNGEN })
|
||||
feld.addEventListener('click', () => {
|
||||
if (!logo.abspielen()) return
|
||||
// Name wieder aus- und nach dem Glas einblenden: Animation neu starten
|
||||
marke.classList.remove('kn-login-ruht')
|
||||
const name = marke.querySelector(':scope > span')
|
||||
if (!name) return
|
||||
name.style.animation = 'none'
|
||||
void name.offsetWidth
|
||||
name.style.animation = ''
|
||||
})
|
||||
}
|
||||
@@ -16,6 +16,8 @@
|
||||
<meta name="apple-mobile-web-app-title" content="WEBklar">
|
||||
<link rel="stylesheet" href="/neu/tokens.css">
|
||||
<link rel="stylesheet" href="/neu/oeffentlich/oeffentlich.css">
|
||||
<!-- Installierte App: weißes Startbild mit dem Glas-Logo, läuft vor dem ersten Bild -->
|
||||
<script src="/neu/startbild.js"></script>
|
||||
</head>
|
||||
<body class="kn kn-seite kn-login-seite">
|
||||
<header class="kn-login-kopf">
|
||||
@@ -24,9 +26,9 @@
|
||||
|
||||
<main class="kn-login-haupt">
|
||||
<div class="kn-login-spalte">
|
||||
<!-- Handy: App-Icon wie beim Start einer App -->
|
||||
<!-- Logo mit Glas-Animation (login-logo.js), Handy und PC; der Name erscheint, sobald das Glas losgelassen hat -->
|
||||
<div class="kn-login-marke">
|
||||
<img src="/img/app-icon-192.png" alt="" width="64" height="64">
|
||||
<div class="kn-login-glas"></div>
|
||||
<span>WEBklar Kundenbereich</span>
|
||||
</div>
|
||||
<div class="kn-login-abschnitt">
|
||||
@@ -86,5 +88,6 @@
|
||||
<script>
|
||||
initLoginPage();
|
||||
</script>
|
||||
<script type="module" src="/neu/login-logo.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -19,8 +19,16 @@
|
||||
.kn-seite a.kn-login-logo { text-decoration: none; }
|
||||
.kn-login-haupt { min-height: 100svh; display: flex; align-items: center; justify-content: center; padding: calc(88px + env(safe-area-inset-top)) max(24px, env(safe-area-inset-right)) calc(40px + env(safe-area-inset-bottom)) max(24px, env(safe-area-inset-left)); }
|
||||
.kn-login-spalte { width: 100%; max-width: 360px; }
|
||||
.kn-login-marke { display: none; }
|
||||
.kn-login-titel { margin: 0 0 24px; font-size: 22px; line-height: 28px; font-weight: 700; letter-spacing: -.02em; }
|
||||
/* Logo mit Glas-Animation (login-logo.js): das Feld braucht Platz für die Glaskachel (Kachel = Höhe / 1,4), das ruhende Logo
|
||||
ist danach etwa 80 px breit. Der Name liegt im unteren Weiß des Feldes und erscheint erst, wenn das Glas losgelassen hat
|
||||
(nach ~3,2 s); nach dem Startbild der App und bei reduzierter Bewegung steht er sofort da. */
|
||||
.kn-login-marke { display: flex; flex-direction: column; align-items: center; margin: 0 0 28px; font-size: 15px; font-weight: 600; }
|
||||
.kn-login-glas { width: 100%; height: 220px; -webkit-tap-highlight-color: transparent; }
|
||||
.kn-login-marke > span { position: relative; margin-top: -58px; animation: kn-login-name .5s ease 3.2s both; }
|
||||
@keyframes kn-login-name { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
|
||||
.kn-login-marke.kn-login-ruht > span { animation: none; }
|
||||
@media (prefers-reduced-motion: reduce) { .kn-login-marke > span { animation: none; } }
|
||||
.kn-login-titel { margin: 0 0 24px; font-size: 22px; line-height: 28px; font-weight: 700; letter-spacing: -.02em; text-align: center; }
|
||||
.kn-login-form { display: flex; flex-direction: column; gap: 12px; }
|
||||
.kn-feld-eingabe {
|
||||
width: 100%; height: 44px; min-width: 0; margin: 0; padding: 0 12px; border: 0; border-radius: var(--kn-radius);
|
||||
@@ -44,9 +52,8 @@
|
||||
.kn-login-hinweis + .kn-login-hinweis { margin-top: 16px; }
|
||||
.kn-seite a.kn-login-wichtig { color: var(--kn-text); font-weight: 600; }
|
||||
@media (max-width: 880px), (pointer: coarse) and (max-height: 500px) {
|
||||
.kn-login-marke { display: flex; flex-direction: column; align-items: center; gap: 12px; margin: 0 0 32px; font-size: 15px; font-weight: 600; }
|
||||
.kn-login-marke img { width: 64px; height: 64px; border-radius: var(--kn-radius-lg); box-shadow: var(--kn-ring); }
|
||||
.kn-login-titel { text-align: center; }
|
||||
.kn-login-glas { height: 190px; }
|
||||
.kn-login-marke > span { margin-top: -50px; }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ Kündigen und Widerrufen (schlicht) --- */
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/* Kundenansicht neu: Startbild der installierten App. Beim ersten Öffnen je App-Start (sessionStorage) deckt eine weiße
|
||||
Fläche die Seite ab, darauf spielt das Glas-Logo (glas-logo.js); danach blendet die Fläche weich aus. Nur in der
|
||||
installierten App (display-mode standalone, iPhone navigator.standalone), nie im Browser.
|
||||
Klassisches Skript im <head> von dashboard.html und der Anmeldung: es läuft vor dem ersten Bild, damit die Seite nie
|
||||
kurz aufblitzt. Ein Tipp auf die Fläche überspringt das Startbild; hängt etwas, blendet es spätestens nach SICHERHEIT_MS aus. */
|
||||
;(function () {
|
||||
var SCHLUESSEL = 'kn-startbild'
|
||||
var TEMPO = 1.25 // etwas schneller als auf der Anmeldung: das Startbild soll kurz bleiben
|
||||
var HALTEN_MS = 120 // das ruhende Logo bleibt so lange stehen, bevor die Fläche ausblendet
|
||||
var AUSBLENDEN_MS = 450
|
||||
var SICHERHEIT_MS = 6500
|
||||
var html = document.documentElement
|
||||
|
||||
var standalone = false
|
||||
try {
|
||||
standalone = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true
|
||||
} catch (e) {
|
||||
standalone = false
|
||||
}
|
||||
if (!standalone) return
|
||||
// Ohne sessionStorage kein Startbild: sonst käme es bei jedem Seitenwechsel (Anmelden → Übersicht) erneut
|
||||
try {
|
||||
if (window.sessionStorage.getItem(SCHLUESSEL)) return
|
||||
window.sessionStorage.setItem(SCHLUESSEL, '1')
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
|
||||
var stil = document.createElement('style')
|
||||
stil.textContent =
|
||||
'html[data-kn-startbild="deckt"]::before{content:"";position:fixed;top:0;right:0;bottom:0;left:0;z-index:2147483646;background:#fff}' +
|
||||
'#kn-startbild{position:fixed;top:0;right:0;bottom:0;left:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;background:#fff;' +
|
||||
'opacity:1;transition:opacity ' + AUSBLENDEN_MS + 'ms ease;-webkit-tap-highlight-color:transparent}' +
|
||||
'#kn-startbild.kn-startbild-weg{opacity:0;pointer-events:none}' +
|
||||
'#kn-startbild .kn-startbild-logo{width:min(300px,80vw);height:min(300px,80vw)}' +
|
||||
'@media (prefers-reduced-motion:reduce){#kn-startbild{transition-duration:200ms}}'
|
||||
document.head.appendChild(stil)
|
||||
// Bis die Fläche im <body> steht, deckt das Pseudo-Element am <html> die Seite ab ("deckt"); danach liegt die Fläche
|
||||
// selbst darüber ("spielt"), damit beim Ausblenden die Seite darunter schon sichtbar ist. Die Anmeldung liest das Attribut: Logo ruht.
|
||||
html.setAttribute('data-kn-startbild', 'deckt')
|
||||
// Das Modul schon jetzt laden, nicht erst wenn die Seite fertig ist
|
||||
var modul = import('/neu/glas-logo.js')
|
||||
modul.catch(function () { /* unten behandelt */ })
|
||||
|
||||
var flaeche = null
|
||||
var logo = null
|
||||
var vorbei = false
|
||||
|
||||
function ausblenden() {
|
||||
if (vorbei) return
|
||||
vorbei = true
|
||||
if (!flaeche) return html.removeAttribute('data-kn-startbild')
|
||||
flaeche.classList.add('kn-startbild-weg')
|
||||
var weg = function () {
|
||||
if (!flaeche) return
|
||||
try { if (logo) logo.entfernen() } catch (e) { /* Zeichenfläche schon weg */ }
|
||||
flaeche.remove()
|
||||
flaeche = null
|
||||
html.removeAttribute('data-kn-startbild')
|
||||
}
|
||||
flaeche.addEventListener('transitionend', weg, { once: true })
|
||||
setTimeout(weg, AUSBLENDEN_MS + 150)
|
||||
}
|
||||
|
||||
function zeigen() {
|
||||
flaeche = document.createElement('div')
|
||||
flaeche.id = 'kn-startbild'
|
||||
flaeche.setAttribute('aria-hidden', 'true')
|
||||
var feld = document.createElement('div')
|
||||
feld.className = 'kn-startbild-logo'
|
||||
flaeche.appendChild(feld)
|
||||
document.body.appendChild(flaeche)
|
||||
html.setAttribute('data-kn-startbild', 'spielt')
|
||||
flaeche.addEventListener('click', ausblenden)
|
||||
setTimeout(ausblenden, SICHERHEIT_MS)
|
||||
modul
|
||||
.then(function (m) {
|
||||
if (vorbei) return
|
||||
logo = m.glasLogo(feld, { tempo: TEMPO, einstellungen: { finalScale: 0.9 } })
|
||||
return logo.fertig
|
||||
})
|
||||
.catch(function () { /* ohne Modul: die weiße Fläche blendet einfach aus */ })
|
||||
.then(function () { setTimeout(ausblenden, HALTEN_MS) })
|
||||
}
|
||||
|
||||
if (document.body) zeigen()
|
||||
else document.addEventListener('DOMContentLoaded', zeigen, { once: true })
|
||||
})()
|
||||
@@ -8,11 +8,15 @@ const PAARE = ['login', 'kuendigen', 'widerrufen', 'auftrag', 'offline'].map((n)
|
||||
const koerper = (html) => html.slice(html.search(/<body/i)) // Metas und Links im Kopf ändern sich bewusst (Stil, theme-color), geprüft werden sie unten
|
||||
const attribute = (html) => [...koerper(html).matchAll(/\s(name|id|value|type|autocomplete|maxlength|required|min|max|step|inputmode|placeholder|enterkeyhint|data-text|data-label|data-label-for)(?:="([^"]*)")?/g)].map((m) => `${m[1]}=${m[2] ?? ''}`).sort()
|
||||
const skripte = (html) => [...html.matchAll(/<script[^>]*>[\s\S]*?<\/script>/g)].map((m) => m[0].replace(/\s+/g, ' '))
|
||||
// Eigene Zusätze der neuen Anmeldung, die die alte Seite nicht hat: Glas-Logo (login-logo.js) und Startbild der App (startbild.js)
|
||||
const ZUSAETZE = ['<script src="/neu/startbild.js"></script>', '<script type="module" src="/neu/login-logo.js"></script>']
|
||||
const ohneZusaetze = (html) => ZUSAETZE.reduce((h, z) => h.replace(z, ''), html)
|
||||
for (const [alt, neu] of PAARE) {
|
||||
test(`${neu}: gleicher sichtbarer Text, gleiche Felder, gleiche Skripte wie ${alt}`, () => {
|
||||
// Kündigen und Widerrufen sprechen seit P7 du (Fassung 6, C14): ihr Text steht in test/knOeffentlichDu.test.js; Felder und Skripte werden weiter verglichen
|
||||
if (!['kuendigen.html', 'widerrufen.html'].includes(alt)) assert.equal(sichtbarerText(lese(neu)), sichtbarerText(lese(alt)))
|
||||
assert.deepEqual(attribute(lese(neu)), attribute(lese(alt))); assert.deepEqual(skripte(lese(neu)), skripte(lese(alt)))
|
||||
const n = ohneZusaetze(lese(neu))
|
||||
if (!['kuendigen.html', 'widerrufen.html'].includes(alt)) assert.equal(sichtbarerText(n), sichtbarerText(lese(alt)))
|
||||
assert.deepEqual(attribute(n), attribute(lese(alt))); assert.deepEqual(skripte(n), skripte(lese(alt)))
|
||||
})
|
||||
test(`${neu}: neuer Stil, hell, ohne Verläufe, Lila und Dunkelmodus`, () => {
|
||||
const n = lese(neu)
|
||||
@@ -24,6 +28,20 @@ for (const [alt, neu] of PAARE) {
|
||||
test('Login: Skript lädt app.js und startet initLoginPage, Statusleiste hell', () => {
|
||||
const n = lese('neu/oeffentlich/login.html'); assert.match(n, /<script src="\/app\.js"><\/script>/); assert.match(n, /initLoginPage\(\)/); assert.match(n, /apple-mobile-web-app-status-bar-style" content="default"/)
|
||||
})
|
||||
test('Login: Glas-Logo statt App-Icon (Handy und PC), Startbild der App als letztes im Kopf, Logo-Skript nach initLoginPage', () => {
|
||||
const n = lese('neu/oeffentlich/login.html')
|
||||
assert.match(n, /<script src="\/neu\/startbild\.js"><\/script>\s*<\/head>/)
|
||||
assert.match(n, /<div class="kn-login-marke">\s*<div class="kn-login-glas"><\/div>\s*<span>WEBklar Kundenbereich<\/span>/)
|
||||
assert.doesNotMatch(n, /app-icon-192\.png" alt=""/)
|
||||
assert.match(n, /initLoginPage\(\);\s*<\/script>\s*<script type="module" src="\/neu\/login-logo\.js"><\/script>\s*<\/body>/)
|
||||
const css = lese('neu/oeffentlich/oeffentlich.css')
|
||||
assert.doesNotMatch(css, /\.kn-login-marke\s*\{\s*display:\s*none/, 'die Marke steht jetzt auch am PC')
|
||||
assert.match(css, /\.kn-login-glas\s*\{[^}]*height:\s*220px/); assert.match(css, /\.kn-login-glas\s*\{\s*height:\s*190px;\s*\}/)
|
||||
// Der Name erscheint erst nach dem Glas, sofort wenn das Logo ruht (nach dem Startbild) oder bei reduzierter Bewegung
|
||||
assert.match(css, /\.kn-login-marke > span\s*\{[^}]*animation:\s*kn-login-name[^}]*\}/)
|
||||
assert.match(css, /\.kn-login-marke\.kn-login-ruht > span\s*\{\s*animation:\s*none;\s*\}/)
|
||||
assert.match(css, /@media \(prefers-reduced-motion: reduce\)\s*\{\s*\.kn-login-marke > span\s*\{\s*animation:\s*none;\s*\}\s*\}/)
|
||||
})
|
||||
test('Auftragsseite: Token bleibt geschützt (noindex, kein Referrer), Rückweg, Wurzel und Modul bleiben, hell, jede Klasse hat eine Regel', () => {
|
||||
const n = lese('neu/oeffentlich/auftrag.html')
|
||||
assert.match(n, /<meta name="robots" content="noindex, nofollow">/); assert.match(n, /<meta name="referrer" content="no-referrer">/); assert.match(n, /<meta name="color-scheme" content="light">/)
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import vm from 'node:vm'
|
||||
import { dauer, einstellungen, frameAt, WEBKLAR } from '../public/neu/glas-logo.js'
|
||||
|
||||
const lese = (p) => readFileSync(new URL(`../public/neu/${p}`, import.meta.url), 'utf8')
|
||||
const ohneKommentare = (q) => q.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`\\])\/\/[^\n]*/g, '$1')
|
||||
|
||||
test('Glas-Logo: WEBklar-Vorgaben (weiß, Oval #111111) und eigene Werte gehen vor', () => {
|
||||
const P = einstellungen({ finalScale: 0.9 })
|
||||
assert.equal(P.background, '#ffffff'); assert.equal(P.logoColor, '#111111'); assert.equal(P.finalScale, 0.9)
|
||||
assert.equal(P.logoScale, WEBKLAR.logoScale); assert.equal(P.present, 2.4)
|
||||
assert.equal(einstellungen().finalScale, WEBKLAR.finalScale)
|
||||
})
|
||||
|
||||
test('Glas-Logo: Zeitleiste beginnt leer, endet mit dem blanken Logo ohne Glas (3,5 s)', () => {
|
||||
const P = einstellungen({ finalScale: 0.9 })
|
||||
assert.equal(dauer(P), 3.5)
|
||||
assert.equal(frameAt(0, P).uPlateAlpha, 0)
|
||||
const mitte = frameAt(1.5, P)
|
||||
assert.equal(mitte.uPlateAlpha, 1); assert.equal(mitte.uOptics, 1); assert.equal(mitte.uBare, 0)
|
||||
const ende = frameAt(dauer(P), P)
|
||||
assert.ok(ende.uPlateAlpha < 0.01, `Glas noch sichtbar: ${ende.uPlateAlpha}`); assert.ok(ende.uOptics < 0.01); assert.equal(ende.uBare, 1)
|
||||
assert.deepEqual(ende.uBg, [1, 1, 1]); assert.deepEqual(ende.uLogoCol, [17 / 255, 17 / 255, 17 / 255])
|
||||
assert.ok(Math.abs(ende.uLogoHalf - 0.6 * 0.9 * 0.955) < 0.01, `Logogröße am Ende: ${ende.uLogoHalf}`)
|
||||
})
|
||||
|
||||
test('Glas-Logo: das Glas wird nie breiter als 1,3 Kacheln (die Anmeldung rechnet mit rand 1,4)', () => {
|
||||
const P = einstellungen({ finalScale: 0.9 })
|
||||
let max = 0
|
||||
for (let t = 0; t <= dauer(P) + 1e-9; t += 0.005) max = Math.max(max, frameAt(t, P).uPlateScale)
|
||||
assert.ok(max < 1.3, `größte Glasbreite ${max}`)
|
||||
assert.match(lese('login-logo.js'), /rand: 1\.4/)
|
||||
})
|
||||
|
||||
test('Glas-Logo: MIT-Hinweis des Originals bleibt im Quelltext', () => {
|
||||
const q = lese('glas-logo.js')
|
||||
assert.match(q, /Copyright \(c\) 2026 Zanwei Guo/); assert.match(q, /Permission is hereby granted, free of charge/)
|
||||
assert.match(q, /THE SOFTWARE IS PROVIDED "AS IS"/); assert.match(q, /github\.com\/zanwei\/glass-icon-reveal/)
|
||||
})
|
||||
|
||||
test('Glas-Logo, Anmeldung, Startbild: kein HTML-Einsetzen, nur relative Importe, kein console.log', () => {
|
||||
for (const datei of ['glas-logo.js', 'login-logo.js', 'startbild.js']) {
|
||||
const q = ohneKommentare(lese(datei))
|
||||
assert.doesNotMatch(q, /innerHTML|insertAdjacentHTML|outerHTML|document\.write|console\.log|\beval\(/, datei)
|
||||
for (const m of q.matchAll(/from '([^']+)'/g)) assert.match(m[1], /^\.\//, `${datei}: nur relative Importe`)
|
||||
}
|
||||
// Das Startbild ist ein klassisches Skript (läuft im Kopf vor dem ersten Bild) und lädt das Modul selbst
|
||||
assert.doesNotMatch(ohneKommentare(lese('startbild.js')), /^\s*(import|export)\s/m)
|
||||
assert.match(lese('startbild.js'), /import\('\/neu\/glas-logo\.js'\)/)
|
||||
})
|
||||
|
||||
/* ------------------------------------------------------------------ Startbild in einer VM --- */
|
||||
const STARTBILD = lese('startbild.js')
|
||||
|
||||
function element(tag) {
|
||||
const el = {
|
||||
tag, id: '', className: '', textContent: '', kinder: [], attrs: {}, klassen: new Set(), hoerer: {}, entfernt: false,
|
||||
setAttribute(k, v) { el.attrs[k] = v },
|
||||
appendChild(kind) { el.kinder.push(kind); return kind },
|
||||
addEventListener(typ, fn) { el.hoerer[typ] = fn },
|
||||
remove() { el.entfernt = true },
|
||||
classList: { add: (c) => el.klassen.add(c) },
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
function starte({ standalone = false, ios = false, gemerkt = false, speicherWirft = false } = {}) {
|
||||
const attr = new Map()
|
||||
const kopf = []
|
||||
const hoerer = {}
|
||||
const timer = []
|
||||
const speicher = new Map(gemerkt ? [['kn-startbild', '1']] : [])
|
||||
const wirf = () => { if (speicherWirft) throw new Error('Speicher gesperrt') }
|
||||
const document = {
|
||||
documentElement: { setAttribute: (k, v) => attr.set(k, v), removeAttribute: (k) => attr.delete(k) },
|
||||
head: { appendChild: (el) => kopf.push(el) },
|
||||
body: null,
|
||||
createElement: (tag) => element(tag),
|
||||
addEventListener: (typ, fn) => { hoerer[typ] = fn },
|
||||
}
|
||||
const window = {
|
||||
matchMedia: (q) => ({ matches: standalone && /display-mode:\s*standalone/.test(q) }),
|
||||
navigator: { standalone: ios },
|
||||
sessionStorage: { getItem: (k) => { wirf(); return speicher.get(k) ?? null }, setItem: (k, v) => { wirf(); speicher.set(k, v) } },
|
||||
}
|
||||
// import() ohne Modul-Lader schlägt in der VM fehl: wie ein Startbild, dessen Modul nicht lädt
|
||||
vm.runInNewContext(STARTBILD, { window, document, setTimeout: (fn, ms) => timer.push({ fn, ms }) })
|
||||
return { attr, kopf, hoerer, timer, speicher, document }
|
||||
}
|
||||
|
||||
const ruhe = () => new Promise((r) => setImmediate(r))
|
||||
|
||||
test('Startbild: im Browser (nicht installiert) passiert nichts', () => {
|
||||
const s = starte()
|
||||
assert.equal(s.attr.size, 0); assert.equal(s.kopf.length, 0); assert.equal(s.speicher.size, 0); assert.equal(s.hoerer.DOMContentLoaded, undefined)
|
||||
})
|
||||
|
||||
test('Startbild: nur einmal je App-Start (sessionStorage), ohne Speicher gar nicht', () => {
|
||||
for (const s of [starte({ standalone: true, gemerkt: true }), starte({ standalone: true, speicherWirft: true })]) {
|
||||
assert.equal(s.attr.size, 0); assert.equal(s.kopf.length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('Startbild: installierte App deckt sofort weiß ab, zeigt die Fläche und blendet sie aus, auch wenn das Modul nicht lädt', async () => {
|
||||
for (const art of [{ standalone: true }, { ios: true }]) {
|
||||
const s = starte(art)
|
||||
assert.equal(s.attr.get('data-kn-startbild'), 'deckt'); assert.equal(s.speicher.get('kn-startbild'), '1')
|
||||
assert.equal(s.kopf.length, 1); assert.match(s.kopf[0].textContent, /html\[data-kn-startbild="deckt"\]::before\{[^}]*position:fixed[^}]*background:#fff/)
|
||||
assert.match(s.kopf[0].textContent, /#kn-startbild\.kn-startbild-weg\{opacity:0/)
|
||||
assert.equal(typeof s.hoerer.DOMContentLoaded, 'function')
|
||||
|
||||
s.document.body = element('body')
|
||||
s.hoerer.DOMContentLoaded()
|
||||
const flaeche = s.document.body.kinder[0]
|
||||
assert.equal(flaeche.id, 'kn-startbild'); assert.equal(flaeche.attrs['aria-hidden'], 'true')
|
||||
assert.equal(flaeche.kinder[0].className, 'kn-startbild-logo')
|
||||
assert.equal(s.attr.get('data-kn-startbild'), 'spielt', 'die Fläche liegt jetzt selbst über der Seite')
|
||||
assert.ok(s.timer.some((t) => t.ms === 6500), 'Sicherheitszeit')
|
||||
|
||||
await ruhe()
|
||||
const halten = s.timer.find((t) => t.ms === 120)
|
||||
assert.ok(halten, 'ohne Modul blendet die Fläche nach kurzer Pause aus')
|
||||
halten.fn()
|
||||
assert.ok(flaeche.klassen.has('kn-startbild-weg'))
|
||||
flaeche.hoerer.transitionend()
|
||||
assert.equal(flaeche.entfernt, true); assert.equal(s.attr.has('data-kn-startbild'), false)
|
||||
}
|
||||
})
|
||||
|
||||
test('Startbild: ein Tipp überspringt es', () => {
|
||||
const s = starte({ standalone: true })
|
||||
s.document.body = element('body')
|
||||
s.hoerer.DOMContentLoaded()
|
||||
const flaeche = s.document.body.kinder[0]
|
||||
flaeche.hoerer.click()
|
||||
assert.ok(flaeche.klassen.has('kn-startbild-weg'))
|
||||
})
|
||||
@@ -12,7 +12,9 @@ test('dashboard.html: weiße PWA-Metas, Stile in fester Reihenfolge, klassisches
|
||||
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())
|
||||
// Startbild der installierten App: klassisches Skript als letztes im Kopf, damit es vor dem ersten Bild läuft
|
||||
assert.match(html, /<script src="\/neu\/startbild\.js"><\/script>\s*<\/head>/)
|
||||
const skripte = [...html.slice(html.indexOf('<body')).matchAll(/<script([^>]*)>/g)].map((m) => m[1].trim())
|
||||
assert.deepEqual([skripte[0], skripte.at(-1)], ['src="/kundenansicht.js"', 'type="module" src="/neu/huelle.js"']); assert.ok(skripte.slice(1, -1).every((s) => s === 'src="/chat-composer.js"'))
|
||||
assert.doesNotMatch(html, /login\.css|kundenbereich\.css|\/app\.js|dot-background|kb-glow|class="[^"]*\bkb-/)
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user