Shader auf echtem DOM (HTML-in-Canvas)
Ein echtes HTML-Produkt-UI wird zur Shader-Textur: es kippt in Perspektive, liegt unter einer Glaslinse oder löst sich beim Ansichtswechsel auf und bleibt dabei bedienbar.
Ein canvas bekommt echte DOM-Kinder: Attribut content="drawable" (vorher layoutsubtree). In 2D zeichnet drawElementImage() ein Element ins Canvas. In WebGL lädt texElementSubImage2D() das Element als Textur, der Speicher wird vorher mit texImage2D() angelegt. WebGPU nutzt drawElementImageToTexture(). Darauf laufen Shader wie Glaslinse, Wellen, Dissolve oder 3D-Neigung, und das UI bleibt bedienbar. In WebGL und WebGPU meldet canvas.updateElementGeometry() die Transformation, damit Klicks, Fokus und Barrierefreiheit an der sichtbaren Stelle liegen.
Es löst das Grundproblem von WebGL-Oberflächen: Der Effekt liegt auf echtem, zugänglichem und responsivem HTML statt auf einer Kopie oder einem Screenshot.
Wann einsetzen
Für Experimente und Lab-Seiten und als progressive Verbesserung in Chromium mit Origin-Trial-Token, etwa für ein Produkt-UI im Hero.
Wann nicht
Für produktionskritische Funktionen. Die API ist im Origin Trial und wurde mehrfach umbenannt.
Varianten
- DOM-Textur auf geneigter 3D-Fläche
- Glaslinse über dem DOM
- Shader-Übergang zwischen zwei DOM-Zuständen
Umsetzung
Per Feature-Erkennung prüfen, welche API-Generation da ist (texElementSubImage2D oder texElementImage2D), und verzweigen. Ohne Unterstützung bleibt normales DOM sichtbar. Wer den Effekt trotzdem zeigen will, nimmt einen Schnappschuss per SVG foreignObject mit eingebetteten Schriften und erneuert ihn nach jeder DOM-Änderung. Das ist teurer und hinkt bei Hover nach. Responsives Verhalten des UI dann über Container-Abfragen lösen, weil @media im Schnappschuss die Bildgröße sieht.
Robust umsetzen
Telefon. HTML-in-Canvas nur in Chromium für Android mit Origin Trial, sonst normales DOM. Effekte auf Tippen statt Hover legen.
Ohne Bewegung. Effekte aus, das DOM bleibt normal und unverändert.
Barrierefrei. Hauptvorteil: Das DOM bleibt zugänglich und per Tastatur bedienbar. Die Geometrie muss zur Darstellung passen, sonst liegen Klick und Fokus neben dem Bild. Das Canvas selbst bekommt aria-hidden.
Code
<!DOCTYPE html>
<!--
Shader auf echtem DOM (HTML-in-Canvas)
Fundus · 3D & WebGL · https://cecodesign.de/lab/fundus/3d-webgl/html-in-canvas-shader/
Eigenständige Datei. Bilder liegen hier unter bilder/ – eigene einsetzen.
-->
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Shader auf echtem DOM (HTML-in-Canvas) · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<style>
@font-face { font-family: 'Geist'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
:root {
/* Rezept saas-produkt-praezise: Graustufen + genau ein Markenton */
--grund: #08090a; --panel: #161718; --text: #f7f8f8; --zweit: #d0d6e0; --leise: #8a8f98; --linie: #242728;
--marke: #ff7847;
--kurve: cubic-bezier(0.25, 0.46, 0.45, 0.94); /* easeOutQuad wie Linear */
--ui: 160ms;
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font: 400 1rem/1.6 'Geist', system-ui, sans-serif; letter-spacing: -.006em; -webkit-font-smoothing: antialiased; }
body { overflow-x: hidden; }
a { color: inherit; text-decoration: none; }
.label { font: 500 .75rem/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
/* ---------- Kopf ---------- */
.kopf { display: flex; align-items: center; gap: 32px; max-width: 1320px; margin: 0 auto; height: 64px; padding: 0 clamp(20px, 4vw, 40px); }
.wortmarke { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 1.0625rem; letter-spacing: -.02em; }
.wortmarke svg { width: 22px; height: 22px; }
.kopf nav { display: flex; gap: 26px; font-size: .875rem; color: var(--leise); }
.kopf nav a { transition: color var(--ui) var(--kurve); }
.kopf nav a:hover { color: var(--text); }
.kopf .rechts { margin-left: auto; display: flex; align-items: center; gap: 18px; font-size: .875rem; }
.knopf { display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: 40px; padding: 0 16px; border-radius: 8px; font-weight: 500; font-size: .9375rem; border: 1px solid var(--linie); background: #121314; color: var(--text); transition: background-color var(--ui) var(--kurve), border-color var(--ui) var(--kurve); }
.knopf:hover { background: #1b1c1e; border-color: #34373a; }
.knopf.primaer { background: var(--marke); border-color: var(--marke); color: #1a0d06; }
.knopf.primaer:hover { background: #ff8a5f; }
.knopf.klein { height: 32px; padding: 0 12px; font-size: .8125rem; border-radius: 7px; }
.knopf:focus-visible, .kopf a:focus-visible { outline: 2px solid var(--marke); outline-offset: 2px; }
/* ---------- Hero ---------- */
.held { position: relative; max-width: 1320px; margin: 0 auto; padding: clamp(28px, 5vw, 56px) clamp(20px, 4vw, 40px) 120px; display: grid; grid-template-columns: minmax(0, 4fr) minmax(0, 8fr); gap: clamp(24px, 3vw, 48px); align-items: center; min-height: calc(100svh - 64px); }
.intro .label { display: inline-flex; align-items: center; gap: 10px; }
.intro .label i { width: 6px; height: 6px; border-radius: 50%; background: var(--marke); }
h1 { margin-top: 22px; font-size: clamp(2.5rem, 1.6rem + 3.6vw, 4.5rem); font-weight: 600; line-height: 1; letter-spacing: -.022em; text-wrap: balance; }
.unter { margin-top: 22px; max-width: 34ch; color: var(--zweit); font-size: 1.0625rem; }
.ctas { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 30px; }
.ctas .knopf { height: 44px; padding: 0 20px; }
.fein { margin-top: 18px; color: var(--leise); font-size: .8125rem; }
/* ---------- Bühne: Canvas über (oder um) echtem HTML ---------- */
.buehne { position: relative; container-type: inline-size; padding: 44px 0 64px 44px; margin-right: calc(-1 * clamp(20px, 4vw, 40px)); }
.buehne > canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; pointer-events: none; z-index: 2; }
.buehne > canvas > * { pointer-events: auto; }
/* Live-Weg: das UI ist Kind des Canvas und liegt dort, wo sonst das Polster der Bühne ist */
.buehne.live { min-height: 588px; }
.buehne.live > canvas > .app { position: absolute; left: 44px; top: 44px; width: calc(100% - 44px); }
.ohne-gl .buehne > canvas { display: none; }
.hinweis { position: absolute; left: 44px; bottom: 18px; display: none; color: var(--leise); }
.ohne-gl .hinweis, .reduziert .hinweis { display: block; }
/* ---------- Pult ---------- */
.pult { position: absolute; left: clamp(20px, 4vw, 40px); right: clamp(20px, 4vw, 40px); bottom: 26px; display: flex; align-items: center; gap: 20px; flex-wrap: wrap; z-index: 4; }
.schalter { display: inline-flex; padding: 3px; border: 1px solid var(--linie); border-radius: 9px; background: rgba(18, 19, 20, .85); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); }
.schalter button { font: 500 11px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); background: none; border: 0; border-radius: 6px; padding: 9px 12px; cursor: pointer; transition: color var(--ui) var(--kurve), background-color var(--ui) var(--kurve); }
.schalter button:hover { color: var(--text); }
.schalter button[aria-pressed="true"] { background: #232527; color: var(--text); }
.schalter button:focus-visible { outline: 2px solid var(--marke); outline-offset: 1px; }
.werte { font-size: 10.5px; line-height: 1.6; flex: 1 1 360px; }
.werte b { color: var(--zweit); font-weight: 500; }
.api { display: inline-flex; align-items: center; gap: 8px; }
.api::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: #ffc533; }
.api.live::before { background: #59d499; }
/* ---------- Kapitel unter dem Hero ---------- */
.kapitel { border-top: 1px solid var(--linie); }
.kapitel-innen { max-width: 1320px; margin: 0 auto; padding: 72px clamp(20px, 4vw, 40px) 96px; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 40px; }
.kapitel h2 { font-size: 1.25rem; font-weight: 600; letter-spacing: -.012em; line-height: 1.3; margin: 14px 0 10px; }
.kapitel p { color: var(--leise); max-width: 40ch; }
.kapitel .label { color: var(--marke); }
.zeiger { position: fixed; left: 0; top: 0; z-index: 9; width: 18px; height: 18px; pointer-events: none; display: none; }
.vorfuehrung .zeiger.an { display: block; }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
<style id="app-stil">
/* Produkt-UI: echtes HTML. Diese Regeln nimmt auch der Schnappschuss-Ersatz mit. */
.app { --p: #111213; --p2: #161718; --t: #f7f8f8; --t2: #d0d6e0; --t3: #8a8f98; --l: #242728; --m: #ff7847; --gut: #59d499; --knapp: #ffc533; --spaet: #ff6161;
position: relative; display: grid; grid-template-columns: 184px minmax(0, 1fr); grid-template-rows: 44px minmax(0, 1fr); width: 100%; height: 488px; overflow: hidden;
background: var(--p); color: var(--t); border: 1px solid var(--l); border-radius: 14px; font: 400 13px/1.45 'Geist', system-ui, sans-serif; letter-spacing: -.005em;
transform-origin: 50% 50%; z-index: 1; }
.app * { box-sizing: border-box; margin: 0; }
.app button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; padding: 0; }
.app .mono { font-family: 'Geist Mono', ui-monospace, monospace; font-size: 11px; letter-spacing: .02em; font-variant-numeric: tabular-nums; }
.app .leiste { grid-column: 1 / -1; display: flex; align-items: center; gap: 12px; padding: 0 14px; border-bottom: 1px solid var(--l); background: var(--p2); }
.app .raum { display: flex; align-items: center; gap: 8px; font-weight: 500; }
.app .raum i { display: grid; place-items: center; width: 20px; height: 20px; border-radius: 5px; background: var(--m); color: #1a0d06; font: 600 11px/1 'Geist', sans-serif; font-style: normal; }
.app .raum span { color: var(--t3); font-weight: 400; }
.app .suche { margin-left: auto; display: flex; align-items: center; gap: 24px; height: 28px; padding: 0 8px 0 10px; border: 1px solid var(--l); border-radius: 7px; color: var(--t3); background: var(--p); }
.app .suche kbd { font: 500 10.5px/1 'Geist Mono', monospace; padding: 3px 5px; border: 1px solid var(--l); border-radius: 4px; }
.app .ich { width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; font-size: 11px; font-weight: 600; background: #2c2f33; color: var(--t2); }
.app .seite { border-right: 1px solid var(--l); padding: 12px 8px; display: flex; flex-direction: column; gap: 2px; background: var(--p2); }
.app .seite a { display: flex; align-items: center; gap: 9px; height: 30px; padding: 0 9px; border-radius: 6px; color: var(--t3); text-decoration: none; }
.app .seite a svg { width: 15px; height: 15px; flex: none; }
.app .seite a b { margin-left: auto; font-weight: 500; font-size: 11px; color: var(--t3); font-family: 'Geist Mono', monospace; }
.app .seite a[aria-current] { background: #212326; color: var(--t); }
.app .seite a:is(:hover, [data-hover]) { color: var(--t); }
.app .auto { margin-top: auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 9px 4px; border-top: 1px solid var(--l); color: var(--t2); width: 100%; text-align: left; }
.app .auto small { display: block; color: var(--t3); font-size: 11px; }
.app .kipp { position: relative; flex: none; width: 30px; height: 18px; border-radius: 9px; background: #2c2f33; transition: background-color 160ms cubic-bezier(0.25, 0.46, 0.45, 0.94); }
.app .kipp::after { content: ''; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: var(--t); transition: transform 160ms cubic-bezier(0.25, 0.46, 0.45, 0.94); }
.app [aria-checked="true"] .kipp { background: var(--m); }
.app [aria-checked="true"] .kipp::after { transform: translateX(12px); }
.app .haupt { min-width: 0; display: flex; flex-direction: column; padding: 14px 16px 12px; gap: 12px; }
.app .titelzeile { display: flex; align-items: center; gap: 12px; }
.app h3 { font-size: 15px; font-weight: 600; letter-spacing: -.012em; }
.app .titelzeile .mono { color: var(--t3); }
.app .reiter { margin-left: auto; display: flex; padding: 2px; border: 1px solid var(--l); border-radius: 7px; background: var(--p2); }
.app .reiter button { height: 24px; padding: 0 10px; border-radius: 5px; color: var(--t3); font-weight: 500; font-size: 12px; transition: color 160ms cubic-bezier(0.25, 0.46, 0.45, 0.94), background-color 160ms cubic-bezier(0.25, 0.46, 0.45, 0.94); }
.app .reiter button[aria-selected="true"] { background: #2a2c2f; color: var(--t); }
.app .kennzahlen { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border: 1px solid var(--l); border-radius: 9px; }
.app .kennzahlen div { padding: 9px 12px; }
.app .kennzahlen div + div { border-left: 1px solid var(--l); }
.app .kennzahlen small { display: block; color: var(--t3); font-size: 11px; }
.app .kennzahlen strong { font-size: 17px; font-weight: 600; letter-spacing: -.012em; font-variant-numeric: tabular-nums; }
.app .kennzahlen em { font-style: normal; font-size: 11px; color: var(--gut); margin-left: 6px; }
.app .ansicht { flex: 1; min-height: 0; position: relative; }
.app [hidden] { display: none !important; }
.app .touren { list-style: none; padding: 0; border: 1px solid var(--l); border-radius: 9px; overflow: hidden; }
.app .touren li { display: grid; grid-template-columns: 26px minmax(0, 1.6fr) minmax(0, 1fr) 46px 92px 82px; align-items: center; gap: 12px; height: 41px; padding: 0 10px 0 12px; border-top: 1px solid var(--l); cursor: default; transition: background-color 160ms cubic-bezier(0.25, 0.46, 0.45, 0.94); }
.app .touren li:first-child { border-top: 0; }
.app .touren li:is(:hover, [data-hover]) { background: #18191b; }
.app .touren li[aria-selected="true"] { background: #1d1f22; box-shadow: inset 2px 0 0 var(--m); }
.app .kuerzel { width: 24px; height: 24px; border-radius: 50%; display: grid; place-items: center; font-size: 10px; font-weight: 600; color: #101112; }
.app .wer { min-width: 0; }
.app .wer strong { display: block; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.app .wer span { display: block; color: var(--t3); font-size: 11.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.app .stopps { display: flex; align-items: center; gap: 8px; color: var(--t3); }
.app .balken { flex: 1; height: 4px; border-radius: 2px; background: #2a2c2f; overflow: hidden; }
.app .balken i { display: block; height: 100%; background: var(--t2); border-radius: 2px; }
.app .eta { color: var(--t2); text-align: right; }
.app .status { justify-self: start; display: inline-flex; align-items: center; gap: 6px; height: 22px; padding: 0 8px; border-radius: 11px; font-size: 11.5px; font-weight: 500; white-space: nowrap; }
.app .status::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.app .gut { color: var(--gut); background: rgba(89, 212, 153, .12); }
.app .knapp { color: var(--knapp); background: rgba(255, 197, 51, .12); }
.app .spaet { color: var(--spaet); background: rgba(255, 97, 97, .13); }
.app .tun { justify-self: end; height: 26px; padding: 0 10px; border: 1px solid var(--l); border-radius: 6px; color: var(--t2); font-size: 12px; font-weight: 500; transition: border-color 160ms cubic-bezier(0.25, 0.46, 0.45, 0.94), color 160ms cubic-bezier(0.25, 0.46, 0.45, 0.94); }
.app .tun:is(:hover, [data-hover]) { border-color: #3a3d41; color: var(--t); }
.app .tun.wichtig { border-color: var(--m); color: var(--m); }
.app .touren li:not([aria-selected="true"]):not(:hover):not([data-hover]):not(:focus-within) .tun:not(.wichtig) { opacity: 0; } /* mit Tastaturfokus sichtbar */
.app .tun:disabled { opacity: .45; cursor: default; }
.app :is(button, a):is(:focus-visible, [data-fokus]) { outline: 2px solid var(--m); outline-offset: 1px; }
.app .karte { position: absolute; inset: 0; display: grid; grid-template-columns: minmax(0, 1fr) 190px; border: 1px solid var(--l); border-radius: 9px; overflow: hidden; }
.app .karte svg { width: 100%; height: 100%; display: block; background: #0d0e0f; }
.app .halte { border-left: 1px solid var(--l); padding: 10px 12px; display: flex; flex-direction: column; gap: 9px; background: var(--p2); }
.app .halte p { display: grid; grid-template-columns: 10px 1fr auto; gap: 8px; align-items: center; color: var(--t2); font-size: 12px; }
.app .halte p i { width: 8px; height: 8px; border-radius: 50%; }
.app .halte p span { color: var(--t3); }
.app .halte .label-klein { color: var(--t3); font: 500 10px/1.3 'Geist Mono', monospace; letter-spacing: .12em; text-transform: uppercase; }
.app .meldung { position: absolute; right: 16px; bottom: 14px; display: flex; align-items: center; gap: 12px; padding: 10px 12px; border: 1px solid #34373a; border-radius: 9px; background: #1d1f22; box-shadow: 0 8px 24px rgba(0, 0, 0, .4); font-size: 12.5px; color: var(--t2); }
.app .meldung b { color: var(--t); font-weight: 500; }
.app .meldung button { color: var(--m); font-weight: 500; }
/* Responsiv über Container-Abfragen (gilt auch im Schnappschuss, wo @media die Bildgröße sähe) */
@container (max-width: 720px) {
.app { grid-template-columns: 48px minmax(0, 1fr); }
.app .seite { padding: 12px 6px; align-items: center; }
.app .seite a { width: 34px; padding: 0; justify-content: center; font-size: 0; }
.app .seite a b, .app .auto { display: none; }
.app .touren li { grid-template-columns: 26px minmax(0, 1fr) 46px 92px 82px; }
.app .touren .stopps { display: none; }
.app .suche span { max-width: 90px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
}
@container (max-width: 520px) {
.app { grid-template-columns: minmax(0, 1fr); height: 470px; font-size: 12.5px; }
.app .seite, .app .suche span, .app .raum span { display: none; }
.app .suche { gap: 8px; }
.app .haupt { padding: 12px; gap: 10px; }
.app .kennzahlen { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.app .kennzahlen div:nth-child(3) { border-left: 0; }
.app .kennzahlen div:nth-child(n+3) { border-top: 1px solid var(--l); }
.app .touren li { grid-template-columns: 24px minmax(0, 1fr) auto; gap: 10px; height: 46px; }
.app .touren .stopps, .app .touren .eta, .app .touren .tun { display: none; }
.app .touren li:nth-child(n+6) { display: none; }
.app .karte { grid-template-columns: minmax(0, 1fr); }
.app .halte { display: none; }
.app .meldung { left: 12px; right: 12px; }
}
</style>
<style>
/* ---------- Vorschau: nur Bühne mit knapper Headline ---------- */
.vorschau body { overflow: hidden; }
.vorschau .kopf nav, .vorschau .fein, .vorschau .kapitel, .vorschau .unter { display: none !important; }
.vorschau .held { min-height: 0; padding-top: 18px; padding-bottom: 40px; grid-template-columns: minmax(0, 3.6fr) minmax(0, 8.4fr); gap: 8px; }
.vorschau h1 { font-size: 2.6rem; }
.vorschau .ctas .knopf + .knopf { display: none; }
/* ---------- Telefon ---------- */
@media (max-width: 760px) {
.kopf nav, .kopf .rechts > a:first-child { display: none; }
.held { grid-template-columns: minmax(0, 1fr); min-height: 0; padding-top: 28px; padding-bottom: 110px; gap: 8px; }
h1 { font-size: clamp(2.4rem, 11vw, 3rem); }
.unter { font-size: 1rem; }
.buehne { padding: 28px 0 40px 0; margin: 0 -8px; }
.hinweis { left: 8px; }
.pult { bottom: 22px; }
.kapitel-innen { grid-template-columns: minmax(0, 1fr); gap: 28px; padding-top: 56px; padding-bottom: 72px; }
}
@media (hover: none) { .schalter button { padding: 12px 12px; } }
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
</style>
</head>
<body>
<header class="kopf">
<a class="wortmarke" href="#" aria-label="Kurbel Startseite">
<svg viewBox="0 0 22 22" aria-hidden="true"><circle cx="11" cy="11" r="9.25" fill="none" stroke="#ff7847" stroke-width="1.5"/><path d="M11 11 L16.5 6.2" stroke="#f7f8f8" stroke-width="1.8" stroke-linecap="round"/><circle cx="16.5" cy="6.2" r="1.9" fill="#f7f8f8"/><circle cx="11" cy="11" r="2.1" fill="#ff7847"/></svg>
Kurbel
</a>
<nav aria-label="Hauptnavigation"><a href="#">Produkt</a><a href="#">Für Kurierdienste</a><a href="#">Preise</a><a href="#">Changelog</a><a href="#">Kunden</a></nav>
<div class="rechts"><a href="#">Anmelden</a><a class="knopf klein primaer" href="#">Kostenlos testen</a></div>
</header>
<main>
<section class="held" aria-labelledby="titel">
<div class="intro">
<p class="label"><i aria-hidden="true"></i>Neu · Auto-Disposition 2.0</p>
<h1 id="titel">Jede Tour in Echtzeit disponiert.</h1>
<p class="unter">Kurbel plant Lastenrad- und E-Transporter-Touren für Kurierdienste in der Stadt. Aufträge rein, Routen raus, alle Fahrer:innen informiert.</p>
<div class="ctas"><a class="knopf primaer" href="#">Kostenlos testen</a><a class="knopf" href="#">Demo buchen</a></div>
<p class="fein">14 Tage kostenlos · Server in Frankfurt · gebaut in Kreuzberg</p>
</div>
<div class="buehne" data-buehne>
<canvas data-gl aria-hidden="true"></canvas>
<div class="app" data-app role="region" aria-label="Produktansicht von Kurbel, echtes HTML">
<div class="leiste">
<p class="raum"><i>W</i>Wrangel Kurier eG <span>/ Disposition</span></p>
<p class="suche"><span>Tour, Auftrag, Adresse …</span><kbd>⌘K</kbd></p>
<p class="ich" aria-label="Angemeldet: Hanne Lüders">HL</p>
</div>
<nav class="seite" aria-label="App-Navigation">
<a href="#" aria-current="page"><svg viewBox="0 0 15 15" aria-hidden="true"><rect x="1.5" y="1.5" width="12" height="12" rx="3" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M1.5 6h12" stroke="currentColor" stroke-width="1.3"/></svg>Heute</a>
<a href="#"><svg viewBox="0 0 15 15" aria-hidden="true"><path d="M2 11.5c2.5 0 2.5-8 5.5-8s2.5 8 5.5 8" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>Touren<b>6</b></a>
<a href="#"><svg viewBox="0 0 15 15" aria-hidden="true"><rect x="2" y="3" width="11" height="9" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M2 6h11" stroke="currentColor" stroke-width="1.3"/></svg>Aufträge<b>38</b></a>
<a href="#"><svg viewBox="0 0 15 15" aria-hidden="true"><circle cx="7.5" cy="5" r="2.6" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M2.5 13c.6-2.6 2.6-4 5-4s4.4 1.4 5 4" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>Fahrer:innen</a>
<a href="#"><svg viewBox="0 0 15 15" aria-hidden="true"><path d="M2 13V6l5.5-3.5L13 6v7" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/><path d="M5.5 13V9h4v4" fill="none" stroke="currentColor" stroke-width="1.3"/></svg>Depots</a>
<a href="#"><svg viewBox="0 0 15 15" aria-hidden="true"><path d="M2.5 12.5V8M6 12.5V4.5M9.5 12.5V7M13 12.5V2.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>Auswertung</a>
<button class="auto" type="button" role="switch" aria-checked="true" data-auto><span>Auto-Disposition<small>bei Verspätung</small></span><span class="kipp" aria-hidden="true"></span></button>
</nav>
<div class="haupt">
<div class="titelzeile">
<h3>Heute</h3><span class="mono">Mo · 02.11. · 13:42</span>
<div class="reiter" role="tablist" aria-label="Ansicht">
<button type="button" role="tab" aria-selected="true" aria-controls="ansicht-board" id="reiter-board" data-reiter="board">Board</button>
<button type="button" role="tab" aria-selected="false" aria-controls="ansicht-karte" id="reiter-karte" tabindex="-1" data-reiter="karte">Karte</button>
</div>
</div>
<div class="kennzahlen">
<div><small>Aufträge</small><strong>38</strong><em>+6</em></div>
<div><small>Pünktlich</small><strong data-quote>92 %</strong></div>
<div><small>CO₂ gespart</small><strong>41 kg</strong></div>
<div><small>Ø Puffer</small><strong data-puffer>4 min</strong></div>
</div>
<div class="ansicht">
<div id="ansicht-board" role="tabpanel" aria-labelledby="reiter-board">
<ul class="touren" role="listbox" aria-label="Touren heute">
<li role="option" aria-selected="false"><span class="kuerzel" style="background:#c9b6ff">MA</span><span class="wer"><strong>T-01 Wrangelkiez</strong><span>Merve Aydın · Lastenrad</span></span><span class="stopps mono"><span class="balken"><i style="width:75%"></i></span>9/12</span><span class="eta mono">14:20</span><span class="status gut">Pünktlich</span><button class="tun" type="button">Details</button></li>
<li role="option" aria-selected="true" data-problem><span class="kuerzel" style="background:#ffb38f">JW</span><span class="wer"><strong>T-02 Graefekiez</strong><span>Jonas Weidlich · E-Transporter</span></span><span class="stopps mono"><span class="balken"><i style="width:40%"></i></span>4/10</span><span class="eta mono" data-eta>14:55</span><span class="status spaet" data-status>+12 min</span><button class="tun wichtig" type="button" data-umplanen>Umplanen</button></li>
<li role="option" aria-selected="false"><span class="kuerzel" style="background:#9fe3c1">KB</span><span class="wer"><strong>T-03 Bergmannstraße</strong><span>Ksenia Brandt · Lastenrad</span></span><span class="stopps mono"><span class="balken"><i style="width:79%"></i></span>11/14</span><span class="eta mono">14:05</span><span class="status gut">Pünktlich</span><button class="tun" type="button">Details</button></li>
<li role="option" aria-selected="false"><span class="kuerzel" style="background:#ffe08a">LH</span><span class="wer"><strong>T-04 Moritzplatz</strong><span>Lotte Haverkamp · Lastenrad</span></span><span class="stopps mono"><span class="balken"><i style="width:55%"></i></span>6/11</span><span class="eta mono">15:10</span><span class="status knapp">Knapp</span><button class="tun" type="button">Details</button></li>
<li role="option" aria-selected="false"><span class="kuerzel" style="background:#a9d4ff">EŞ</span><span class="wer"><strong>T-05 Kottbusser Tor</strong><span>Emre Şahin · Lastenrad</span></span><span class="stopps mono"><span class="balken"><i style="width:22%"></i></span>2/9</span><span class="eta mono">15:40</span><span class="status gut">Pünktlich</span><button class="tun" type="button">Details</button></li>
<li role="option" aria-selected="false"><span class="kuerzel" style="background:#f5b8d0">PN</span><span class="wer"><strong>T-06 Mehringdamm</strong><span>Paula Ndiaye · E-Transporter</span></span><span class="stopps mono"><span class="balken"><i style="width:12%"></i></span>1/8</span><span class="eta mono">16:15</span><span class="status gut">Pünktlich</span><button class="tun" type="button">Details</button></li>
</ul>
</div>
<div id="ansicht-karte" class="karte" role="tabpanel" aria-labelledby="reiter-karte" hidden>
<svg viewBox="0 0 520 300" preserveAspectRatio="xMidYMid slice" role="img" aria-label="Karte Kreuzberg mit sechs Touren">
<path d="M-10 70 C 90 52, 170 96, 260 78 S 430 30, 540 46" fill="none" stroke="#1d2a33" stroke-width="16"/>
<path d="M-10 214 C 80 196, 150 238, 250 220 S 420 190, 540 206" fill="none" stroke="#17242c" stroke-width="9"/>
<rect x="352" y="120" width="86" height="56" rx="10" fill="#14201a"/>
<g stroke="#202326" stroke-width="2" fill="none">
<path d="M0 140 H520"/><path d="M0 108 L520 132"/><path d="M0 176 L520 166"/><path d="M0 250 L520 262"/>
<path d="M90 0 L110 300"/><path d="M190 0 L176 300"/><path d="M286 0 L300 300"/><path d="M398 0 L380 300"/><path d="M470 0 L486 300"/>
</g>
<g fill="none" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M60 230 L110 176 L176 166 L190 120" stroke="#9fe3c1"/>
<path d="M190 120 L286 132 L300 176 L380 166" stroke="#ffb38f"/>
<path d="M380 166 L398 108 L470 96 L486 60" stroke="#c9b6ff"/>
<path d="M110 176 L90 108 L190 98" stroke="#ffe08a"/>
<path d="M300 176 L300 250 L380 262" stroke="#a9d4ff"/>
</g>
<g fill="#f7f8f8"><circle cx="60" cy="230" r="4"/><circle cx="176" cy="166" r="4"/><circle cx="190" cy="120" r="4"/><circle cx="286" cy="132" r="4"/><circle cx="380" cy="166" r="4"/><circle cx="470" cy="96" r="4"/><circle cx="90" cy="108" r="4"/><circle cx="300" cy="250" r="4"/></g>
<circle cx="300" cy="176" r="7" fill="#ff7847"/><circle cx="300" cy="176" r="13" fill="none" stroke="#ff7847" stroke-opacity=".45" stroke-width="2"/>
<text x="404" y="152" fill="#4a5a50" font-family="Geist Mono, monospace" font-size="9" letter-spacing="1">GÖRLI</text>
<text x="16" y="62" fill="#3c5363" font-family="Geist Mono, monospace" font-size="9" letter-spacing="1">LANDWEHRKANAL</text>
</svg>
<div class="halte">
<p class="label-klein">Nächste Stopps</p>
<p><i style="background:#ffb38f"></i>Wiener Str. 14<span class="mono">14:01</span></p>
<p><i style="background:#9fe3c1"></i>Zossener Str. 3<span class="mono">14:04</span></p>
<p><i style="background:#c9b6ff"></i>Falckensteinstr. 40<span class="mono">14:09</span></p>
<p><i style="background:#ffe08a"></i>Prinzenstr. 85<span class="mono">14:12</span></p>
<p><i style="background:#a9d4ff"></i>Skalitzer Str. 121<span class="mono">14:16</span></p>
</div>
</div>
</div>
</div>
<div class="meldung" data-meldung role="status" hidden><span><b>T-02 umgeplant:</b> 3 Stopps an T-05, Puffer +9 min</span><button type="button" data-zurueck>Rückgängig</button></div>
</div>
<p class="hinweis label" data-hinweis></p>
</div>
<div class="pult">
<div class="schalter" role="group" aria-label="Variante">
<button type="button" data-modus="kippen" aria-pressed="true">Kippen</button>
<button type="button" data-modus="glas" aria-pressed="false">Glas</button>
<button type="button" data-modus="wechsel" aria-pressed="false">Wechsel</button>
</div>
<p class="label werte" data-werte aria-live="polite"></p>
</div>
</section>
<section class="kapitel" aria-label="So arbeitet Kurbel">
<div class="kapitel-innen">
<div><p class="label">01 · Eingang</p><h2>Aufträge kommen von selbst.</h2><p>Shop, Telefon, Mail oder Schnittstelle: Kurbel liest Adresse, Zeitfenster und Paketgröße und legt den Auftrag an.</p></div>
<div><p class="label">02 · Planung</p><h2>Touren nach Rad und Ladung.</h2><p>Lastenrad, Anhänger oder E-Transporter. Kurbel kennt Zuladung, Akkustand und Einbahnstraßen im Kiez.</p></div>
<div><p class="label">03 · Unterwegs</p><h2>Umplanen mit einem Klick.</h2><p>Staut es am Kotti, verteilt die Auto-Disposition Stopps neu und schickt allen die neue Reihenfolge aufs Telefon.</p></div>
</div>
</section>
</main>
<svg class="zeiger" data-zeiger viewBox="0 0 18 18" aria-hidden="true"><path d="M2 1.5 15.5 9.2l-6 1.3-2.9 5.6Z" fill="#f7f8f8" stroke="#08090a" stroke-width="1.2" stroke-linejoin="round"/></svg>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const $ = s => document.querySelector(s), $$ = s => [...document.querySelectorAll(s)];
const buehne = $('[data-buehne]'), canvas = $('[data-gl]'), app = $('[data-app]'), zeiger = $('[data-zeiger]');
const beruehrung = matchMedia('(hover: none)').matches;
/* ---------- Werte (Token) ---------- */
const W = {
kurve: [0.25, 0.46, 0.45, 0.94], // easeOutQuad, Rezept saas-produkt-praezise
ui: 160, // Hover, Schalter, Reiter
kippen: { x: 12, y: 16, folgen: 4, perspektive: 1600, aufrichten: 600, lerp: 0.08 }, // Grad, px, ms
glas: { breite: 240, hoehe: 112, kante: 28, brechung: 26, zoom: 1.12, dispersion: 0.06, lerp: 0.14 },
wechsel:{ ms: 600, rauschen: 0.3, kante: 0.035 },
schnappschuss: { takt: 90 }, // ms Drosselung im Ersatz
};
const kurve = (() => { const [x1, y1, x2, y2] = W.kurve; return p => { if (p <= 0) return 0; if (p >= 1) return 1; let t = p; for (let i = 0; i < 8; i++) { const x = 3 * (1 - t) * (1 - t) * t * x1 + 3 * (1 - t) * t * t * x2 + t * t * t - p; const d = 3 * (1 - t) * (1 - t) * x1 + 6 * (1 - t) * t * (x2 - x1) + 3 * t * t * (1 - x2); if (Math.abs(d) < 1e-6) break; t -= x / d; } return 3 * (1 - t) * (1 - t) * t * y1 + 3 * (1 - t) * t * t * y2 + t * t * t; }; })();
let modus = 'kippen';
/* ---------- App: echte Bedienung ---------- */
const reiter = $$('[data-reiter]');
function zeigeAnsicht(name) {
reiter.forEach(b => { const an = b.dataset.reiter === name; b.setAttribute('aria-selected', an); b.tabIndex = an ? 0 : -1; document.getElementById(b.getAttribute('aria-controls')).hidden = !an; });
}
reiter.forEach((b, i) => {
b.addEventListener('click', e => { if (e.isTrusted) stoppeVorfuehrung(); wechsle(b.dataset.reiter, b); });
b.addEventListener('keydown', e => { const d = { ArrowRight: 1, ArrowLeft: -1 }[e.key]; if (d) { e.preventDefault(); const n = reiter[(i + d + reiter.length) % reiter.length]; n.focus(); wechsle(n.dataset.reiter, n); } });
});
$$('.touren li').forEach(li => li.addEventListener('click', e => { if (e.target.closest('button')) return; $$('.touren li').forEach(x => x.setAttribute('aria-selected', x === li)); }));
const auto = $('[data-auto]');
auto.addEventListener('click', () => auto.setAttribute('aria-checked', auto.getAttribute('aria-checked') !== 'true'));
const umplanen = $('[data-umplanen]'), meldung = $('[data-meldung]');
function setzeUmgeplant(an) {
const s = $('[data-status]');
s.className = 'status ' + (an ? 'gut' : 'spaet'); s.textContent = an ? 'Pünktlich' : '+12 min';
$('[data-eta]').textContent = an ? '14:41' : '14:55';
$('[data-quote]').textContent = an ? '97 %' : '92 %'; $('[data-puffer]').textContent = an ? '9 min' : '4 min';
umplanen.disabled = an; umplanen.classList.toggle('wichtig', !an); umplanen.textContent = an ? 'Umgeplant' : 'Umplanen';
meldung.hidden = !an;
}
umplanen.addEventListener('click', () => setzeUmgeplant(true));
$('[data-zurueck]').addEventListener('click', () => setzeUmgeplant(false));
// Hover und Fokus als Attribut spiegeln, damit der Schnappschuss sie zeigt
app.addEventListener('pointerover', e => { const z = e.target.closest('li, .tun, .seite a'); $$('[data-hover]').forEach(x => x !== z && x.removeAttribute('data-hover')); if (z) z.setAttribute('data-hover', ''); });
app.addEventListener('pointerleave', () => $$('[data-hover]').forEach(x => x.removeAttribute('data-hover')));
app.addEventListener('focusin', e => { if (e.target.matches(':focus-visible')) e.target.setAttribute('data-fokus', ''); });
app.addEventListener('focusout', e => e.target.removeAttribute('data-fokus'));
/* ---------- Welche API gibt es? ---------- */
let gl = null, live = null; // live: 'neu' (content=drawable, texElementSubImage2D) | 'alt' (layoutsubtree, texElementImage2D) | null
try { gl = canvas.getContext('webgl2', { premultipliedAlpha: true, antialias: false }) || canvas.getContext('webgl', { premultipliedAlpha: true, antialias: false }); } catch (e) { gl = null; }
if (gl) {
if (typeof gl.texElementSubImage2D === 'function') live = 'neu';
else if (typeof gl.texElementImage2D === 'function') live = 'alt';
} else html.classList.add('ohne-gl');
const software = (() => { if (!gl) return false; const d = gl.getExtension('WEBGL_debug_renderer_info'); return /swiftshader|llvmpipe|software/i.test(d ? gl.getParameter(d.UNMASKED_RENDERER_WEBGL) : ''); })();
/* ---------- Shader ---------- */
const VS = `attribute vec2 aPos; uniform vec2 uRes; varying vec2 vP;
void main(){ vP = vec2(aPos.x * 0.5 + 0.5, 0.5 - aPos.y * 0.5) * uRes; gl_Position = vec4(aPos, 0.0, 1.0); }`;
const FS = `precision highp float;
uniform sampler2D tA, tB;
uniform vec2 uRes, uKipp, uMaus, uLinse, uUrsprung;
uniform vec4 uRect;
uniform float uModus, uVoll, uPersp, uProg, uRadius, uKippStaerke, uKante, uBrechung, uZoom, uDisp, uRausch, uWKante, uLinseAn;
uniform vec3 uAkzent;
varying vec2 vP;
float box(vec2 p, vec2 b, float r){ vec2 q = abs(p) - b + r; return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; }
vec4 tafel(sampler2D t, vec2 uv){
float d = box((uv - 0.5) * uRect.zw, uRect.zw * 0.5, uRadius);
return texture2D(t, clamp(uv, 0.0, 1.0)) * (1.0 - smoothstep(-0.75, 0.75, d));
}
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float rausch(vec2 p){ vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i), hash(i + vec2(1, 0)), f.x), mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), f.x), f.y); }
float fbm(vec2 p){ float s = 0.0, a = 0.5; for (int i = 0; i < 4; i++) { s += a * rausch(p); p *= 2.03; a *= 0.5; } return s; }
vec2 zuUV(vec2 p){ return (p - uRect.xy) / uRect.zw; }
void main(){
vec2 mitte = uRect.xy + uRect.zw * 0.5;
vec4 col = vec4(0.0);
if (uModus < 0.5) {
/* Kippen: Strahl gegen gedrehte Ebene, dieselbe Projektion wie CSS perspective() rotateX() rotateY() */
float cx = cos(uKipp.x), sx = sin(uKipp.x), cy = cos(uKipp.y), sy = sin(uKipp.y);
mat3 rx = mat3(1.0, 0.0, 0.0, 0.0, cx, sx, 0.0, -sx, cx);
mat3 ry = mat3(cy, 0.0, -sy, 0.0, 1.0, 0.0, sy, 0.0, cy);
mat3 r = rx * ry;
vec3 n = r[2], u = r[0], v = r[1];
vec3 auge = vec3(0.0, 0.0, uPersp), dir = vec3(vP - mitte, -uPersp);
float t = -dot(n, auge) / dot(n, dir);
vec3 P = auge + t * dir;
vec2 uv = vec2(dot(P, u), dot(P, v)) / uRect.zw + 0.5;
col = tafel(tA, uv);
// Licht von oben links: Fläche wird je nach Neigung heller oder dunkler, dazu ein Glanzstreifen
vec3 l = normalize(vec3(-0.35, -0.6, 1.0));
float hell = 1.0 + (dot(n, l) - l.z) * 1.4;
float streif = exp(-pow(uv.x * 0.8 + uv.y * 0.55 - (0.62 + uKipp.y * 1.4 - uKipp.x * 0.8), 2.0) * 22.0) * 0.07;
col.rgb = col.rgb * hell + vec3(streif) * col.a * uKippStaerke;
// weicher Schatten unter der Fläche
float sd = box(vP - mitte - vec2(-18.0, 34.0) * uKippStaerke, uRect.zw * 0.47, 40.0);
float sch = (1.0 - smoothstep(-40.0, 60.0, sd)) * 0.55 * uKippStaerke;
col = col + (1.0 - col.a) * vec4(0.0, 0.0, 0.0, sch);
} else if (uModus < 1.5) {
/* Glas: Linse mit gerundeter Kante bricht, vergrößert und zerlegt die Farben der Live-UI */
if (uVoll > 0.5) col = tafel(tA, zuUV(vP));
vec2 q0 = vP - uMaus;
float r = uLinse.y;
float d = box(q0, uLinse, r);
float schatten = (1.0 - smoothstep(-6.0, 26.0, box(q0 - vec2(0.0, 10.0), uLinse, r))) * 0.32 * uLinseAn;
col = col + (1.0 - col.a) * vec4(0.0, 0.0, 0.0, schatten);
if (d < 1.0) {
float x = clamp(-d / uKante, 0.0, 1.0);
float h = sqrt(1.0 - (1.0 - x) * (1.0 - x));
vec2 g = normalize(vec2(box(q0 + vec2(1.0, 0.0), uLinse, r) - box(q0 - vec2(1.0, 0.0), uLinse, r),
box(q0 + vec2(0.0, 1.0), uLinse, r) - box(q0 - vec2(0.0, 1.0), uLinse, r)) + 1e-5);
float b = (1.0 - h) * uBrechung;
vec2 q = uMaus + q0 / uZoom - g * b;
float sp = b * uDisp;
vec4 cr = tafel(tA, zuUV(q - g * sp)), cg = tafel(tA, zuUV(q)), cb = tafel(tA, zuUV(q + g * sp));
vec4 innen = vec4(cr.r, cg.g, cb.b, cg.a);
innen = innen + (1.0 - innen.a) * vec4(vec3(0.07, 0.075, 0.08), 0.55);
float glanz = pow(1.0 - x, 6.0) * (0.2 + 0.8 * max(0.0, dot(g, normalize(vec2(-0.55, -0.85)))));
float unten = pow(1.0 - x, 3.0) * max(0.0, dot(g, vec2(0.0, 1.0))) * 0.3;
float rand = smoothstep(-1.6, -0.3, d) * 0.3;
innen.rgb = innen.rgb * (1.0 - unten) + vec3(0.025 + glanz * 0.32 + rand) * innen.a;
float a = (1.0 - smoothstep(-0.75, 0.75, d)) * uLinseAn;
col = mix(col, innen, a);
}
} else {
/* Wechsel: Rausch-Dissolve vom alten zum neuen Zustand, Kante im Markenton */
vec2 uv = zuUV(vP);
float n = fbm(uv * vec2(uRect.z / uRect.w, 1.0) * 5.0);
float f = length(vP - uUrsprung) / length(uRect.zw);
float e = f * (1.0 - uRausch) + n * uRausch;
float p = uProg * 1.25 - 0.1;
float neu = 1.0 - smoothstep(p - uWKante, p, e);
float nah = 1.0 - smoothstep(0.0, 0.1, abs(e - p));
vec4 alt = tafel(tB, uv + vec2(0.0, -nah * 0.012));
col = mix(alt, tafel(tA, uv), neu);
float linie = smoothstep(p - uWKante, p - uWKante * 0.5, e) * (1.0 - smoothstep(p - uWKante * 0.5, p, e));
col.rgb += uAkzent * linie * col.a * 0.9;
}
gl_FragColor = col;
}`;
let prog = null, U = {}, texA = null, texB = null;
function shaderBauen() {
const sh = (typ, src) => { const s = gl.createShader(typ); gl.shaderSource(s, src); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s)); return s; };
prog = gl.createProgram();
gl.attachShader(prog, sh(gl.VERTEX_SHADER, VS)); gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FS));
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog));
gl.useProgram(prog);
const buf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const a = gl.getAttribLocation(prog, 'aPos'); gl.enableVertexAttribArray(a); gl.vertexAttribPointer(a, 2, gl.FLOAT, false, 0, 0);
['tA', 'tB', 'uRes', 'uKipp', 'uMaus', 'uLinse', 'uUrsprung', 'uRect', 'uModus', 'uVoll', 'uPersp', 'uProg', 'uRadius', 'uKippStaerke', 'uKante', 'uBrechung', 'uZoom', 'uDisp', 'uRausch', 'uWKante', 'uAkzent', 'uLinseAn']
.forEach(n => U[n] = gl.getUniformLocation(prog, n));
const tex = () => { const t = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); 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);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array(4)); return t; };
texA = tex(); texB = tex();
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
gl.uniform1i(U.tA, 0); gl.uniform1i(U.tB, 1);
gl.uniform3f(U.uAkzent, 1.0, 0.47, 0.28);
gl.uniform1f(U.uPersp, W.kippen.perspektive); gl.uniform1f(U.uRadius, 14);
gl.uniform1f(U.uKante, W.glas.kante); gl.uniform1f(U.uBrechung, W.glas.brechung); gl.uniform1f(U.uZoom, W.glas.zoom); gl.uniform1f(U.uDisp, W.glas.dispersion);
gl.uniform1f(U.uRausch, W.wechsel.rauschen); gl.uniform1f(U.uWKante, W.wechsel.kante);
}
if (gl) { try { shaderBauen(); } catch (e) { console.warn(e); gl = null; html.classList.add('ohne-gl'); } }
/* ---------- Live-Weg: HTML-in-Canvas (Chromium mit Origin Trial) ---------- */
let texW = 0, texH = 0;
if (gl && live) {
try {
if (live === 'neu') canvas.setAttribute('content', 'drawable'); else canvas.setAttribute('layoutsubtree', '');
buehne.classList.add('live');
canvas.appendChild(app); // das UI wird Kind des Canvas: echtes DOM, aber vom Shader gezeichnet
canvas.addEventListener('paint', () => { hochladenLive(); schmutzig = true; });
if (canvas.requestPaint) canvas.requestPaint();
} catch (e) { live = null; buehne.insertBefore(app, canvas.nextSibling); }
}
function hochladenLive(ziel = texA) {
try {
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, ziel);
if (live === 'neu') {
const w = app.offsetWidth * dpr, h = app.offsetHeight * dpr;
if (w !== texW || h !== texH) { texW = w; texH = h; gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); }
gl.texElementSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, app);
} else gl.texElementImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, app);
return true;
} catch (e) { console.warn('HTML-in-Canvas nicht nutzbar, Ersatz aktiv', e); live = null; canvas.removeAttribute('content'); canvas.removeAttribute('layoutsubtree'); buehne.classList.remove('live'); buehne.insertBefore(app, canvas.nextSibling); einrichten(); return false; }
}
/* ---------- Ersatz-Weg: Schnappschuss per SVG foreignObject ----------
Genau der Umweg, den HTML-in-Canvas abschafft: eine Kopie des DOM als Bild, nach jeder Änderung neu. */
let schriftCss = '', snapBereit = false, snapLaeuft = false, snapNochmal = false, snapPlan = 0, dpr = 1;
const appCss = document.getElementById('app-stil').textContent;
async function schriftenEinbetten() {
const regeln = [];
for (const sh of document.styleSheets) { let rs; try { rs = sh.cssRules; } catch (e) { continue; } for (const r of rs) if (r.constructor.name === 'CSSFontFaceRule') regeln.push(r); }
const teile = await Promise.all(regeln.map(async r => {
const m = r.style.getPropertyValue('src').match(/url\(["']?([^"')]+)["']?\)/); if (!m) return '';
try {
const blob = await (await fetch(new URL(m[1], document.baseURI))).blob();
const daten = await new Promise((ok, nein) => { const fr = new FileReader(); fr.onload = () => ok(fr.result); fr.onerror = nein; fr.readAsDataURL(blob); });
return `@font-face{font-family:${r.style.getPropertyValue('font-family')};src:url(${daten.replace(/^data:[^;]*;/, 'data:font/woff2;')}) format('woff2');font-weight:${r.style.getPropertyValue('font-weight') || '100 900'}}`;
} catch (e) { return ''; }
}));
schriftCss = teile.join('');
}
function schnappschuss() {
const w = app.offsetWidth, h = app.offsetHeight;
const klon = app.cloneNode(true);
klon.removeAttribute('data-app');
klon.style.cssText = `width:${w}px;height:${h}px;transform:none;opacity:1;margin:0`;
const xhtml = new XMLSerializer().serializeToString(klon);
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${Math.round(w * dpr)}" height="${Math.round(h * dpr)}" viewBox="0 0 ${w} ${h}"><foreignObject x="0" y="0" width="${w}" height="${h}"><div xmlns="http://www.w3.org/1999/xhtml" style="width:${w}px;height:${h}px;container-type:inline-size"><style><![CDATA[${schriftCss}${appCss}]]></style>${xhtml}</div></foreignObject></svg>`;
return new Promise((ok, nein) => { const img = new Image(); img.onload = () => ok(img); img.onerror = nein; img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg); });
}
async function snapJetzt(ziel = texA) {
if (live) return hochladenLive(ziel);
if (snapLaeuft) { snapNochmal = true; return; }
snapLaeuft = true;
try {
const img = await schnappschuss();
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, ziel);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
snapBereit = true; schmutzig = true;
} catch (e) { console.warn('Schnappschuss fehlgeschlagen', e); }
snapLaeuft = false;
if (snapNochmal) { snapNochmal = false; snapPlanen(); }
}
function snapPlanen() { if (live || !gl) return; clearTimeout(snapPlan); snapPlan = setTimeout(() => snapJetzt(), W.schnappschuss.takt); }
new MutationObserver(liste => {
if (wechselLaeuft) return;
if (liste.some(r => !(r.target === app && r.attributeName === 'style'))) snapPlanen(); // eigene Transform-/Deckkraft-Änderungen zählen nicht
}).observe(app, { subtree: true, attributes: true, childList: true, characterData: true });
/* ---------- Geometrie ---------- */
let rect = { x: 0, y: 0, w: 1, h: 1 }, cw = 1, ch = 1;
function einrichten() {
if (!gl) return;
dpr = software ? 1 : Math.min(devicePixelRatio || 1, 2);
cw = buehne.clientWidth; ch = buehne.clientHeight;
canvas.width = Math.round(cw * dpr); canvas.height = Math.round(ch * dpr);
gl.viewport(0, 0, canvas.width, canvas.height);
rect = { x: app.offsetLeft, y: app.offsetTop, w: app.offsetWidth, h: app.offsetHeight };
if (live) { const s = getComputedStyle(buehne); rect.x = parseFloat(s.paddingLeft); rect.y = parseFloat(s.paddingTop); }
gl.uniform2f(U.uRes, cw, ch);
gl.uniform4f(U.uRect, rect.x, rect.y, rect.w, rect.h);
if (!glas.gesetzt) { glas.x = glas.zx = rect.x + rect.w * 0.62; glas.y = glas.zy = rect.y + rect.h * 0.42; }
const klein = innerWidth < 760;
linse.w = klein ? 150 : W.glas.breite; linse.h = klein ? 84 : W.glas.hoehe;
snapJetzt(); schmutzig = true;
}
/* ---------- Zustände ---------- */
const kipp = { auf: 0, von: 0, nach: 0, start: -1, fx: 0, fy: 0, zx: 0, zy: 0 }; // auf: 0 gekippt … 1 flach
const glas = { x: 0, y: 0, zx: 0, zy: 0, gesetzt: false, an: 1 };
const linse = { w: W.glas.breite, h: W.glas.hoehe };
let wechselLaeuft = false, wStart = 0, ursprung = [0, 0], schmutzig = true;
const rad = g => g * Math.PI / 180;
function aufrichten(ziel) {
if (kipp.nach === ziel && kipp.start >= 0) return;
if (reduziert()) { kipp.auf = ziel; kipp.nach = ziel; kipp.start = -1; return; }
kipp.von = kipp.auf; kipp.nach = ziel; kipp.start = performance.now();
}
// Zeiger über der Bühne: Kippen folgt leicht, über dem UI richtet es sich auf
function zeigerBei(x, y) {
const r = buehne.getBoundingClientRect(), lx = x - r.left, ly = y - r.top;
kipp.zx = Math.max(-1, Math.min(1, (lx / r.width) * 2 - 1)); kipp.zy = Math.max(-1, Math.min(1, (ly / r.height) * 2 - 1));
const ueber = lx > rect.x && lx < rect.x + rect.w && ly > rect.y && ly < rect.y + rect.h;
if (modus === 'kippen') aufrichten(ueber ? 1 : 0);
glas.zx = lx; glas.zy = ly; glas.gesetzt = true;
schmutzig = true;
}
buehne.addEventListener('pointermove', e => { if (vorf || e.pointerType === 'touch') return; zeigerBei(e.clientX, e.clientY); });
// Tastatur: Fokus im UI richtet es sofort auf, damit das echte HTML (mit Fokusring) sichtbar ist; verlässt der Fokus das UI, kippt es zurück
app.addEventListener('focusin', () => {
if (modus !== 'kippen' || kipp.auf === 1 && kipp.start < 0) return;
kipp.von = kipp.auf = kipp.nach = 1; kipp.start = -1; schmutzig = true; zeichnen(performance.now());
});
app.addEventListener('focusout', e => { if (modus === 'kippen' && !app.contains(e.relatedTarget) && !app.matches(':hover')) aufrichten(0); });
buehne.addEventListener('pointerleave', e => { if (vorf || e.pointerType === 'touch') return; kipp.zx = kipp.zy = 0; if (modus === 'kippen') aufrichten(0); });
// Touch: Tippen richtet auf bzw. setzt die Linse; zweites Tippen außerhalb kippt zurück
buehne.addEventListener('pointerdown', e => {
if (e.pointerType !== 'touch' || vorf) return;
const r = buehne.getBoundingClientRect();
if (modus === 'glas') { glas.zx = e.clientX - r.left; glas.zy = e.clientY - r.top; glas.gesetzt = true; schmutzig = true; }
if (modus === 'kippen') aufrichten(e.target.closest('[data-app]') ? 1 : 0);
});
function wechsle(name, knopf) {
const aktiv = $('[data-reiter][aria-selected="true"]').dataset.reiter;
if (name === aktiv) return;
if (!gl || reduziert() || modus !== 'wechsel' || !snapBereit && !live) { zeigeAnsicht(name); return; }
// alter Zustand liegt schon in texA: tauschen, DOM ändern, neuen Zustand aufnehmen, dann auflösen
const kr = knopf.getBoundingClientRect(), br = buehne.getBoundingClientRect();
ursprung = [kr.left + kr.width / 2 - br.left, kr.top + kr.height / 2 - br.top];
wechselLaeuft = true; wStart = -1; schmutzig = true;
if (live) { hochladenLive(texB); zeigeAnsicht(name); hochladenLive(texA); wStart = performance.now(); return; }
[texA, texB] = [texB, texA];
gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, texB);
app.style.opacity = '0'; zeichnen(0);
zeigeAnsicht(name);
clearTimeout(snapPlan);
const fertig = () => { wStart = performance.now(); schmutzig = true; };
if (snapLaeuft) { const warte = setInterval(() => { if (!snapLaeuft) { clearInterval(warte); snapJetzt().then(fertig); } }, 16); }
else snapJetzt().then(fertig);
}
/* ---------- Zeichnen ---------- */
function zeichnen(t) {
if (!gl) return;
gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT);
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, texA);
gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, texB);
let m = modus === 'kippen' ? 0 : modus === 'glas' ? 1 : 2, voll = 1, dom = 0;
const still = reduziert();
if (modus === 'kippen') {
const s = still ? 0 : 1 - kipp.auf; // reduziert: kein Kippen, flaches HTML
const kx = rad(W.kippen.x + kipp.fy * -W.kippen.folgen) * s, ky = rad(W.kippen.y + kipp.fx * W.kippen.folgen) * s;
gl.uniform2f(U.uKipp, kx, ky); gl.uniform1f(U.uKippStaerke, s);
// gleiche Geometrie für das echte DOM: Treffer und Fokus liegen dort, wo man sie sieht
const tf = s < 0.001 ? 'none' : `perspective(${W.kippen.perspektive}px) rotateX(${kx}rad) rotateY(${ky}rad)`;
if (live) { try { canvas.updateElementGeometry && canvas.updateElementGeometry(app, new DOMMatrix(tf === 'none' ? undefined : tf)); } catch (e) {} }
else { app.style.transform = tf; dom = s < 0.001 && kipp.start < 0 ? 1 : 0; }
} else if (modus === 'glas') {
gl.uniform2f(U.uMaus, glas.x, glas.y); gl.uniform2f(U.uLinse, linse.w / 2, linse.h / 2); gl.uniform1f(U.uLinseAn, glas.an);
voll = live ? 1 : 0; dom = 1; // Ersatz: echtes DOM bleibt sichtbar, nur die Linse kommt aus dem Canvas
} else {
gl.uniform1f(U.uProg, wechselLaeuft ? (wStart < 0 ? 0 : kurve(Math.min(1, (t - wStart) / W.wechsel.ms))) : 1);
gl.uniform2f(U.uUrsprung, ursprung[0], ursprung[1]);
dom = wechselLaeuft ? 0 : 1;
}
if (still) dom = 1;
if (!live) {
app.style.opacity = dom ? '1' : '0';
if (dom && modus !== 'glas') return; // nichts zu zeichnen: das echte HTML ist sichtbar
if (still) return;
}
gl.uniform1f(U.uModus, m); gl.uniform1f(U.uVoll, voll);
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
let letzte = performance.now(), mittel = 16, laeuft = false, angehalten = false;
function bild(t) {
if (angehalten) { laeuft = false; return; }
const dt = Math.min(80, t - letzte); letzte = t;
const k = n => 1 - Math.pow(1 - n, dt / 16.667);
if (kipp.start >= 0) {
const p = Math.min(1, (t - kipp.start) / W.kippen.aufrichten);
kipp.auf = kipp.von + (kipp.nach - kipp.von) * kurve(p);
if (p >= 1) kipp.start = -1;
schmutzig = true;
}
if (Math.abs(kipp.zx - kipp.fx) + Math.abs(kipp.zy - kipp.fy) > 0.0005) { kipp.fx += (kipp.zx - kipp.fx) * k(W.kippen.lerp); kipp.fy += (kipp.zy - kipp.fy) * k(W.kippen.lerp); if (modus === 'kippen') schmutzig = true; }
if (Math.abs(glas.zx - glas.x) + Math.abs(glas.zy - glas.y) > 0.05) { glas.x += (glas.zx - glas.x) * k(W.glas.lerp); glas.y += (glas.zy - glas.y) * k(W.glas.lerp); if (modus === 'glas') schmutzig = true; }
if (wechselLaeuft) { schmutzig = true; if (wStart >= 0 && t - wStart > W.wechsel.ms) { wechselLaeuft = false; if (!live) snapPlanen(); } }
if (schmutzig && (snapBereit || live)) { schmutzig = false; zeichnen(t); }
mittel += (dt - mittel) * 0.1;
if (mittel > 45) setTimeout(() => requestAnimationFrame(bild), 50); else requestAnimationFrame(bild);
}
function starten() { if (gl && !laeuft) { laeuft = true; angehalten = false; letzte = performance.now(); requestAnimationFrame(bild); } }
/* ---------- Varianten und Werte-Anzeige ---------- */
function zeigeWerte() {
const kv = `cubic-bezier(${W.kurve.join(', ')})`;
const z = {
kippen: `rotateX <b>${W.kippen.x}°</b> · rotateY <b>${W.kippen.y}°</b> · Zeiger <b>±${W.kippen.folgen}°</b> · Perspektive <b>${W.kippen.perspektive} px</b> · Aufrichten <b>${W.kippen.aufrichten} ms ${kv}</b>`,
glas: `Linse <b>${linse.w} × ${linse.h} px</b> · Kante <b>${W.glas.kante} px</b> · Brechung <b>${W.glas.brechung} px</b> · Zoom <b>${W.glas.zoom}</b> · Dispersion <b>${W.glas.dispersion}</b> · Lerp <b>${W.glas.lerp}</b>`,
wechsel: `Dissolve <b>${W.wechsel.ms} ms ${kv}</b> · Rauschen <b>${W.wechsel.rauschen}</b> · Kante <b>${W.wechsel.kante}</b> im Markenton`,
}[modus];
const api = live ? `<span class="api live">HTML-in-Canvas live (${live === 'neu' ? 'content=drawable' : 'layoutsubtree'})</span>`
: gl ? `<span class="api">API fehlt · Ersatz: Schnappschuss per SVG foreignObject</span>` : `<span class="api">Kein WebGL · normales HTML</span>`;
$('[data-werte]').innerHTML = `${api}<br>${reduziert() ? '<b>Reduzierte Bewegung: Shader aus, echtes HTML</b>' : z}`;
const h = $('[data-hinweis]');
h.textContent = !gl ? 'Kein WebGL: das UI bleibt normales HTML.' : reduziert() ? 'Reduzierte Bewegung: Shader aus, das UI ist normales HTML.' : '';
}
function setzeModus(m) {
modus = m;
$$('[data-modus]').forEach(b => b.setAttribute('aria-pressed', b.dataset.modus === m));
if (!live) app.style.transform = 'none';
if (m === 'kippen') { kipp.auf = 0; kipp.start = -1; kipp.nach = 0; }
if (wechselLaeuft) wechselLaeuft = false;
zeigeWerte(); schmutzig = true;
}
$$('[data-modus]').forEach(b => b.addEventListener('click', () => { stoppeVorfuehrung(); setzeModus(b.dataset.modus); }));
/* ---------- Vorführung ---------- */
let vorf = null, vorfStart = 0, phase = -1, klicks = [];
const RUNDE = 15000;
function zeigerZeigen(x, y) { const r = buehne.getBoundingClientRect(); zeiger.style.transform = `translate(${r.left + x - 2}px, ${r.top + y - 1}px)`; }
function vorfuehren() {
stoppeVorfuehrung(true);
angehalten = false; starten();
html.classList.add('vorfuehrung');
vorfStart = performance.now(); phase = -1;
if (reduziert() || !gl) { // ohne Bewegung: Ansicht wechselt hart alle 4 s
let i = 0; vorf = { intervall: setInterval(() => { i++; zeigeAnsicht(i % 2 ? 'karte' : 'board'); }, 4000) };
return;
}
const schritt = t => {
const e = (t - vorfStart) % RUNDE, p = e < 5000 ? 0 : e < 10000 ? 1 : 2;
if (p !== phase) {
phase = p;
setzeModus(['kippen', 'glas', 'wechsel'][p]);
zeiger.classList.add('an');
if (p === 0) { zeigeAnsicht('board'); setzeUmgeplant(false); }
if (p === 2) klicks = [[6200, 'karte'], [8600, 'board']];
}
const s = e / 1000;
let x, y;
if (p === 0) {
// von rechts unten über die Bühne, dann aufs UI (richtet sich auf), wieder hinaus
const q = s / 5;
// 0–2,9 s über die Bühne (Kippen folgt), 2,9–4,3 s auf dem UI (richtet sich auf), dann hinaus
x = rect.x + rect.w * (q < 0.58 ? 0.15 + q * 0.6 : q < 0.86 ? 0.5 : 0.5 + (q - 0.86) * 4);
y = rect.y + rect.h * (q < 0.58 ? 1.1 - Math.sin(q * 5.4) * 0.05 : q < 0.86 ? 0.55 : 0.55 + (q - 0.86) * 4.4);
zeigerBei(buehne.getBoundingClientRect().left + x, buehne.getBoundingClientRect().top + y);
} else if (p === 1) {
const u = (s - 5) * 0.95;
x = rect.x + rect.w * (0.5 + 0.3 * Math.sin(u * 1.3));
y = rect.y + rect.h * (0.52 + 0.22 * Math.sin(u * 2.1 + 0.6));
zeigerBei(buehne.getBoundingClientRect().left + x, buehne.getBoundingClientRect().top + y);
} else {
const ziel = klicks.length ? klicks[0][1] : 'board';
const kn = $(`[data-reiter="${ziel}"]`).getBoundingClientRect(), br = buehne.getBoundingClientRect();
x = kn.left - br.left + kn.width / 2; y = kn.top - br.top + kn.height / 2 + 2;
if (klicks.length && e >= klicks[0][0] + 5000) { const k = klicks.shift(); $(`[data-reiter="${k[1]}"]`).click(); }
}
zeigerZeigen(x, y);
vorf = requestAnimationFrame(schritt);
};
vorf = requestAnimationFrame(schritt);
}
function stoppeVorfuehrung(still) {
if (vorf && vorf.intervall) clearInterval(vorf.intervall); else if (vorf) cancelAnimationFrame(vorf);
vorf = null; zeiger.classList.remove('an');
if (!still) html.classList.remove('vorfuehrung');
}
function pause() { if (vorf && vorf.intervall) clearInterval(vorf.intervall); else if (vorf) cancelAnimationFrame(vorf); vorf = null; angehalten = true; }
function weiter() { angehalten = false; if (html.classList.contains('vorfuehrung')) vorfuehren(); else starten(); }
function neustart() {
stoppeVorfuehrung(); angehalten = false;
zeigeAnsicht('board'); setzeUmgeplant(false);
kipp.fx = kipp.fy = kipp.zx = kipp.zy = 0; glas.gesetzt = false;
setzeModus('kippen'); einrichten(); starten();
}
/* ---------- Start ---------- */
zeigeWerte();
if (gl) {
let rz = 0;
addEventListener('resize', () => { cancelAnimationFrame(rz); rz = requestAnimationFrame(() => { einrichten(); zeigeWerte(); }); });
const los = async () => { if (!live) { try { await document.fonts.ready; } catch (e) {} await schriftenEinbetten(); } einrichten(); starten(); };
los();
}
})();
</script>
</body>
</html>