Zum Inhalt
← Glas & Material

Halbton-Raster

Programmseite eines Zürcher Kulturhauses: Jedes Bild erscheint als Raster in der Farbe seiner Sparte, beim Bildwechsel wächst das Raster kurz auf, beim Hover wird es feiner.

Das Bild wird in ein Raster aus Zellen geteilt, meist 6 bis 16 px groß und um 15 bis 45 Grad gedreht. Für jede Zelle zählt die Helligkeit am Mittelpunkt. Daraus entsteht ein Punkt, eine Linie oder ein Quadrat: je dunkler, desto größer. In der CMYK-Variante liegen vier Raster mit eigenen Winkeln übereinander (C 15°, M 75°, Y 0°, K 45°) und werden multipliziert. Interaktiv lässt sich die Zellgröße animieren, etwa beim Hover oder beim Bildwechsel.

Das Raster erinnert an Zeitung, Plakat und Comic. Es macht aus Fotos unterschiedlicher Herkunft eine einheitliche Bildsprache und gibt der Marke eine analoge, grafische Handschrift.

Wann einsetzen

Kultur, Musik, Mode, Editorial und Portfolios. Immer dann, wenn uneinheitliches Bildmaterial zu einer Bildsprache werden soll.

Wann nicht

Bei Produktfotos, deren Details zählen, und bei kleinen Bildern: Das Raster frisst Information. Auch nicht bei Essen, wo Farbe und Struktur das Bild tragen.

Varianten

  • Punkt-Halbton in einer Schmuckfarbe
  • CMYK-Halbton mit vier Rasterwinkeln
  • Linien-Halbton
  • Interaktive Auflösung: Zelle wird beim Hover feiner oder löst sich in Cursornähe zum Foto auf
  • Halbton als Übergang zwischen Bildern

Umsetzung

Fragment-Shader auf der Bildtextur. Die Pixelkoordinate wird um den Rasterwinkel gedreht, auf die Zellmitte gerundet und zurückgedreht; dort wird die Textur gelesen. Der Radius ist Zelle · 0.707 · √Dunkelheit, so wächst die Punktfläche proportional zur Dunkelheit. Die Kante glättet smoothstep über etwa einen Gerätepixel oder fwidth(). Für viele kleine Bilder reicht ein zweiter WebGL-Kontext, der offscreen malt und per drawImage in einzelne Canvas kopiert. CSS-Näherung: Punktmuster aus radial-gradient als Maske mit Kontrastfilter, allerdings nur mit gleich großen Punkten. Award-Belege fehlen bisher; aufgenommen wegen der Verbreitung in Shader-Werkzeugen (Paper) und der Nähe zum Dither-Trend.

Robust umsetzen

Telefon. Unproblematisch. Zellgröße in CSS-Pixeln festlegen und mit devicePixelRatio multiplizieren. Auf kleinen Bildern kleinere Zellen wählen. Statt Hover schaltet Tippen die feinere Stufe.

Ohne Bewegung. Animationen der Zellgröße und Bildwechsel entfallen, die Zustände springen. Das statische Raster bleibt.

Barrierefrei. Das Raster ist nur Darstellung. Die Bildbeschreibung gehört an das Bild (img mit alt oder role=img mit aria-label), der Canvas bekommt aria-hidden. Kein Text ins Raster legen. Wird eine Farbe einer Sparte oder Kategorie zugeordnet, steht sie zusätzlich als Text daneben.

Code

<!DOCTYPE html>
<!--
  Halbton-Raster
  Fundus · Glas & Material · https://cecodesign.de/lab/fundus/glas-material/halbton-raster/
  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>Halbton-Raster · 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: 'Schibsted Grotesk'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/schibsted-grotesk:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 900; font-style: normal; font-display: swap; }
@font-face { font-family: 'Atkinson Hyperlegible Next'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/atkinson-hyperlegible-next:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 200 800; font-display: swap; }
@font-face { font-family: 'IBM Plex Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/ibm-plex-mono@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'IBM Plex Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/ibm-plex-mono@latest/latin-500-normal.woff2') format('woff2'); font-weight: 500; font-display: swap; }

:root {
  /* Rezept kultur-programm-haltung: Schwarz auf Off-White, je Sparte eine Farbe (AA auf Grund und als Fläche mit Weiß) */
  --grund: #efede9; --text: #0b0b0b; --leise: #5a5853; --linie: #0b0b0b; --haar: rgba(11, 11, 11, .16);
  --musik: #c4421a; --theater: #3d5f9a; --kunst: #2f7d4a; --film: #b0186b;
  --s: var(--kunst);                                  /* aktuelle Sparte */
  --kurve: cubic-bezier(0.215, 0.61, 0.355, 1);       /* Rezept: kurze Out-Kurve ohne Überschwingen */
  --ui: 200ms;
  --rand: clamp(18px, 3vw, 48px);
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Atkinson Hyperlegible Next', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { overflow-x: hidden; }
a { color: inherit; }
button { font: inherit; color: inherit; }
.mono { font: 500 .8125rem/1.35 'IBM Plex Mono', ui-monospace, monospace; font-variant-numeric: tabular-nums; }
.label { font: 500 11px/1.3 'IBM Plex Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
.nur-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
:focus-visible { outline: 3px solid var(--text); outline-offset: 3px; }

/* ---------- Kopf: 78 px, 1 px Unterkante (HKW) ---------- */
.kopf { position: sticky; top: 0; z-index: 4; height: 78px; display: flex; align-items: center; gap: clamp(16px, 2.4vw, 40px); padding: 0 var(--rand); background: var(--grund); border-bottom: 1px solid var(--linie); }
.marke { display: flex; align-items: center; gap: 10px; text-decoration: none; font: 900 26px/1 'Schibsted Grotesk', sans-serif; letter-spacing: -.03em; }
.marke svg { flex: none; }
.kopf nav { display: flex; gap: clamp(14px, 1.8vw, 28px); margin-left: auto; font: 600 16px/1 'Schibsted Grotesk', sans-serif; }
.kopf nav a { text-decoration: none; padding: 6px 0; background: linear-gradient(var(--text), var(--text)) 0 100% / 0 2px no-repeat; transition: background-size var(--ui) var(--kurve); }
.kopf nav a:hover, .kopf nav a[aria-current] { background-size: 100% 2px; }
.zugang { display: flex; gap: 6px; }
.zugang a { font: 500 12px/1 'IBM Plex Mono', monospace; text-decoration: none; border: 1px solid var(--haar); border-radius: 999px; padding: 8px 11px; transition: border-color var(--ui) var(--kurve); }
.zugang a:hover { border-color: var(--text); }
.tickets { display: inline-flex; align-items: center; height: 44px; padding: 0 18px; background: var(--text); color: var(--grund); text-decoration: none; font: 700 15px/1 'Schibsted Grotesk', sans-serif; transition: background-color var(--ui) var(--kurve); }
.tickets:hover { background: #2b2b2b; }
.menue { display: none; height: 44px; padding: 0 14px; border: 1px solid var(--text); background: none; font: 700 15px/1 'Schibsted Grotesk', sans-serif; }

/* ---------- Bühne ---------- */
.buehne { min-height: calc(100svh - 78px); display: grid; grid-template-rows: 1fr auto auto; }
.held { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: clamp(24px, 3.6vw, 64px); padding: clamp(24px, 3vw, 44px) var(--rand) clamp(18px, 2vw, 28px); }
.held { container-type: inline-size; }
.text { display: flex; flex-direction: column; min-width: 0; container-type: inline-size; }
.sparte { display: flex; align-items: center; gap: 10px; }
.sparte i { width: 14px; height: 14px; background: var(--s); transition: background-color 480ms var(--kurve); }
h1 { font: 900 clamp(2.5rem, 14.5cqi, 7rem)/1 'Schibsted Grotesk', sans-serif; letter-spacing: -.02em; margin-top: clamp(16px, 2vw, 28px); overflow-wrap: anywhere; hyphens: manual; }
.unter { font: 600 clamp(1.25rem, 1.6vw, 1.625rem)/1.2 'Schibsted Grotesk', sans-serif; margin-top: 14px; text-wrap: balance; }
.termin { margin-top: 18px; display: grid; gap: 3px; }
.termin span + span { color: var(--leise); }
.hilfen { list-style: none; padding: 0; margin-top: 22px; display: flex; flex-wrap: wrap; gap: 6px; }
.hilfen li { border: 1px solid var(--haar); border-radius: 999px; padding: 5px 10px; font-size: 12px; }
.lead { font-size: 1.125rem; line-height: 1.6; max-width: 40ch; margin-top: auto; padding-top: 28px; text-wrap: pretty; }
.aktionen { display: flex; flex-wrap: wrap; gap: 12px 20px; align-items: center; margin-top: 24px; }
.knopf { display: inline-flex; align-items: center; gap: 12px; height: 52px; padding: 0 22px; background: var(--text); color: var(--grund); text-decoration: none; font: 700 16px/1 'Schibsted Grotesk', sans-serif; transition: background-color var(--ui) var(--kurve); }
.knopf:hover { background: var(--s); }
.knopf svg { transition: transform var(--ui) var(--kurve); }
.knopf:hover svg { transform: translateX(3px); }
.merken { font: 600 16px/1 'Schibsted Grotesk', sans-serif; text-underline-offset: 5px; text-decoration-thickness: 1px; }

.bild { min-width: 0; display: flex; flex-direction: column; gap: 10px; }
.raster { position: relative; flex: 1 1 auto; min-height: 380px; overflow: hidden; background: var(--grund); cursor: zoom-in; }
.raster canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.raster .ersatz { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: none; filter: grayscale(1) contrast(1.2); mix-blend-mode: multiply; }
.raster .hinweis { position: absolute; left: 12px; bottom: 12px; display: none; background: var(--grund); padding: 8px 10px; }
.kein-gl .raster .ersatz, .kein-gl .raster .hinweis { display: block; }
.kein-gl .raster::after { content: ''; position: absolute; inset: 0; background: radial-gradient(circle, transparent 38%, var(--grund) 40%) 0 0 / 9px 9px; }
.bild figcaption { display: flex; justify-content: space-between; gap: 16px; color: var(--leise); }

/* Reiter: das Programm dieser Woche schaltet das Bild um */
.reiter { list-style: none; padding: 0 var(--rand); display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
.reiter button { width: 100%; text-align: left; background: none; border: 0; border-top: 1px solid var(--linie); padding: 14px 16px 18px 0; cursor: pointer; display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; align-items: center; position: relative; }
.reiter button::before { content: ''; position: absolute; left: 0; right: 16px; top: -1px; height: 5px; background: var(--f); transform: scaleY(0); transform-origin: top; transition: transform var(--ui) var(--kurve); }
.reiter button[aria-current='true']::before, .reiter button:hover::before { transform: scaleY(1); }
.reiter i { width: 10px; height: 10px; background: var(--f); }
.reiter .label { color: var(--leise); }
.reiter b { grid-column: 1 / -1; font: 800 clamp(1.25rem, 1.9vw, 1.75rem)/1.1 'Schibsted Grotesk', sans-serif; letter-spacing: -.01em; }
.reiter .wann { grid-column: 1 / -1; color: var(--leise); }
.reiter button[aria-current='true'] .wann { color: var(--text); }
.reiter li + li button { padding-left: 16px; border-left: 1px solid var(--haar); }
.reiter li + li button::before { left: 16px; }

/* Steuerung der Demo: dezent, Mono */
.pult { display: flex; justify-content: space-between; align-items: center; gap: 12px 28px; flex-wrap: wrap; padding: 12px var(--rand) 16px; border-top: 1px solid var(--haar); }
.modi { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.modi > span { margin-right: 6px; color: var(--leise); }
.modi button { height: 30px; padding: 0 12px; border: 1px solid var(--haar); border-radius: 999px; background: none; cursor: pointer; font: 500 11px/1 'IBM Plex Mono', monospace; letter-spacing: .14em; text-transform: uppercase; transition: border-color var(--ui) var(--kurve), background-color var(--ui) var(--kurve), color var(--ui) var(--kurve); }
.modi button:hover { border-color: var(--text); }
.modi button[aria-pressed='true'] { background: var(--text); border-color: var(--text); color: var(--grund); }
.werte { display: flex; flex-wrap: wrap; gap: 4px 16px; justify-content: flex-end; color: var(--leise); }
.werte b { color: var(--text); font-weight: 500; }

/* ---------- Programm ---------- */
.programm { padding: clamp(56px, 7vw, 112px) var(--rand); border-top: 1px solid var(--linie); }
.programm-kopf { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: clamp(24px, 3.6vw, 64px); align-items: end; margin-bottom: clamp(28px, 3vw, 48px); }
h2 { font: 900 clamp(2rem, 4.4vw, 4rem)/1 'Schibsted Grotesk', sans-serif; letter-spacing: -.02em; }
.programm-kopf p { font-size: 1.125rem; line-height: 1.6; max-width: 58ch; }
.karten { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: clamp(16px, 2vw, 32px); }
.karte { display: flex; flex-direction: column; gap: 10px; }
.karte .flaeche { position: relative; aspect-ratio: 4 / 5; overflow: hidden; }
.karte canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.karte .art { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
.karte .art i { width: 10px; height: 10px; background: var(--f); }
.karte h3 { font: 800 1.5rem/1.1 'Schibsted Grotesk', sans-serif; letter-spacing: -.01em; }
.karte p { font-size: 1rem; line-height: 1.5; }
.karte .zeile { display: flex; justify-content: space-between; align-items: center; gap: 12px; border-top: 1px solid var(--haar); padding-top: 10px; margin-top: auto; }
.karte .zeile a { font: 700 15px/1 'Schibsted Grotesk', sans-serif; text-underline-offset: 4px; }
.haus { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: clamp(24px, 3.6vw, 64px); padding: clamp(48px, 6vw, 96px) var(--rand); background: var(--text); color: var(--grund); }
.haus h2 { font-size: clamp(1.75rem, 3vw, 2.75rem); }
.haus p { font-size: 1.125rem; line-height: 1.6; max-width: 62ch; }
.haus p + p { margin-top: 14px; }
.fuss { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 10px 32px; padding: 28px var(--rand) 40px; }

/* Gedachter Zeiger der Vorführung */
.zeiger { position: fixed; z-index: 6; left: 0; top: 0; width: 26px; height: 26px; margin: -13px 0 0 -13px; border: 2px solid var(--text); border-radius: 50%; background: rgba(239, 237, 233, .5); pointer-events: none; display: none; transition: scale 150ms var(--kurve); }
.zeiger.drueckt { scale: .7; }
.vorfuehrung .zeiger { display: block; }

/* ---------- Vorschau 960 × 720: nur die Bühne ---------- */
.vorschau body { overflow: hidden; }
.vorschau .programm, .vorschau .haus, .vorschau .fuss, .vorschau .pult, .vorschau .kopf nav, .vorschau .zugang, .vorschau .lead, .vorschau .merken, .vorschau .hilfen { display: none; }
.vorschau .buehne { height: calc(100vh - 78px); min-height: 0; }
.vorschau .raster { min-height: 0; }
.vorschau .aktionen { margin-top: auto; }
.vorschau .kopf .tickets { margin-left: auto; }
.vorschau .kopf .menue { margin-left: 0; }
.vorschau .reiter .wann { font-size: 12px; }

:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }

@media (max-width: 1180px) { .zugang { display: none; } .werte span:nth-child(n+5) { display: none; } }
@media (max-width: 980px) {
  .kopf nav { display: none; } .menue { display: inline-flex; align-items: center; margin-left: auto; }
  .karten { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .programm-kopf, .haus { grid-template-columns: 1fr; }
}
@media (max-width: 760px) {
  .kopf { height: 64px; } .marke { font-size: 22px; }
  .buehne { min-height: 0; }
  .held { grid-template-columns: 1fr; gap: 0; padding-top: 22px; }
  .text { display: contents; }
  .sparte { order: 1; } h1 { order: 2; } .unter { order: 3; } .termin { order: 4; } .hilfen { order: 4; } .bild { order: 5; margin-top: 22px; } .lead { order: 6; padding-top: 20px; } .aktionen { order: 7; }
  .raster { flex: none; min-height: 0; aspect-ratio: 4 / 5; }
  .bild figcaption { flex-direction: column; gap: 2px; }
  .reiter { grid-template-columns: 1fr 1fr; margin-top: 8px; }
  .reiter li:nth-child(3) button { padding-left: 0; border-left: 0; }
  .reiter li:nth-child(3) button::before { left: 0; }
  .pult { flex-direction: column; align-items: flex-start; }
  .werte { justify-content: flex-start; }
  .karten { grid-template-columns: 1fr; }
  .tickets { height: 40px; padding: 0 14px; }
  .menue { height: 40px; }
}
/* 320 px (WCAG 1.4.10): Kopf mit Marke, Tickets und Menü passt ohne Querscrollen */
@media (max-width: 360px) {
  .kopf { gap: 8px; }
  .marke { font-size: 17px; gap: 7px; }
  .marke svg { width: 22px; height: 22px; }
  .tickets, .menue { padding: 0 9px; font-size: 14px; }
}
@media (forced-colors: active) {
  button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; }
}
@media (hover: none) {
  .raster { cursor: pointer; }
  .modi button { height: 38px; padding: 0 14px; }
}
</style>
</head>
<body>
<header class="kopf">
  <a class="marke" href="#titel" aria-label="Limmatbogen, Startseite">
    <svg width="30" height="30" viewBox="0 0 30 30" aria-hidden="true"><g fill="currentColor"><circle cx="4" cy="4" r="3.6"/><circle cx="12" cy="4" r="2.9"/><circle cx="20" cy="4" r="2.1"/><circle cx="28" cy="4" r="1.2"/><circle cx="4" cy="12" r="2.9"/><circle cx="12" cy="12" r="2.1"/><circle cx="20" cy="12" r="1.2"/><circle cx="4" cy="20" r="2.1"/><circle cx="12" cy="20" r="1.2"/><circle cx="4" cy="28" r="1.2"/></g></svg>
    Limmatbogen
  </a>
  <nav aria-label="Hauptmenü"><a href="#programm" aria-current="page">Programm</a><a href="#programm">Ausstellungen</a><a href="#programm">Kino</a><a href="#haus">Besuch planen</a></nav>
  <div class="zugang"><a href="#haus">Leichte Sprache</a><a href="#haus">Vorlesen</a></div>
  <a class="tickets" href="#programm">Tickets</a>
  <button class="menue" type="button" aria-expanded="false">Menü</button>
</header>

<main>
<section class="buehne" aria-labelledby="titel">
  <div class="held">
    <div class="text">
      <p class="sparte label"><i aria-hidden="true"></i><span data-sparte>Kunst · Ausstellung</span></p>
      <h1 id="titel" data-titel>Schwerkraft</h1>
      <p class="unter" data-unter>Selma Rüegg. Skulpturen aus Sandstein und Gips</p>
      <p class="termin mono" data-termin><span>07.11.2026 – 14.02.2027</span><span>Halle 2</span></p>
      <ul class="hilfen mono" data-hilfen aria-label="Barrierefreiheit"><li>Stufenlos</li><li>Sitzhocker</li><li>Führung in Gebärdensprache 22.11.</li></ul>
      <p class="lead" data-lead>Drei Tonnen Stein, gestützt auf einem Punkt. Rüegg zeigt 23 Arbeiten aus 28 Jahren, die meisten zum ersten Mal in Zürich.</p>
      <div class="aktionen">
        <a class="knopf" href="#programm" data-preis>Tickets · CHF 14 <svg width="16" height="10" viewBox="0 0 16 10" aria-hidden="true"><path d="M0 5h14M10 1l4 4-4 4" fill="none" stroke="currentColor" stroke-width="1.6"/></svg></a>
        <a class="merken" href="#programm">Auf die Merkliste</a>
      </div>
    </div>
    <figure class="bild">
      <div class="raster" data-raster role="img" aria-label="Ausstellungsansicht: eine große Steinkugel, eine liegende Form und eine schmale Stele im Seitenlicht, gedruckt als Punktraster.">
        <canvas data-gl aria-hidden="true"></canvas>
        <p class="hinweis label">WebGL nicht verfügbar · Raster als CSS-Näherung</p>
      </div>
      <figcaption><span data-bu>Ausstellungsansicht Halle 2</span><span class="mono" data-info>Punkt · 45° · 11 px</span></figcaption>
    </figure>
  </div>

  <ol class="reiter" aria-label="Diese Woche im Haus">
    <li><button type="button" data-nr="0" aria-current="true" style="--f: var(--kunst)"><i aria-hidden="true"></i><span class="label">Kunst</span><b>Schwerkraft</b><span class="wann mono">Ab Sa 07.11. · Halle 2</span></button></li>
    <li><button type="button" data-nr="1" aria-current="false" style="--f: var(--musik)"><i aria-hidden="true"></i><span class="label">Musik</span><b>Nachtstrom</b><span class="wann mono">Fr 13.11. · 20:30 · Saal</span></button></li>
    <li><button type="button" data-nr="2" aria-current="false" style="--f: var(--theater)"><i aria-hidden="true"></i><span class="label">Theater</span><b>Wartesaal</b><span class="wann mono">Sa 21.11. · 19:30 · Studio</span></button></li>
    <li><button type="button" data-nr="3" aria-current="false" style="--f: var(--film)"><i aria-hidden="true"></i><span class="label">Film</span><b>Seestücke</b><span class="wann mono">Ab 18.11. · 20:00 · Kino</span></button></li>
  </ol>

  <div class="pult">
    <div class="modi" role="group" aria-label="Raster wählen"><span class="label">Raster</span>
      <button type="button" data-modus="0" aria-pressed="true">Punkt</button>
      <button type="button" data-modus="1" aria-pressed="false">CMYK</button>
      <button type="button" data-modus="2" aria-pressed="false">Linien</button>
    </div>
    <p class="werte mono" data-werte></p>
  </div>
</section>

<section class="programm" id="programm" aria-labelledby="programm-titel">
  <div class="programm-kopf">
    <h2 id="programm-titel">November im Haus</h2>
    <p>Vier Sparten, eine Bildsprache: Jedes Bild im Programm erscheint als Raster in der Farbe seiner Sparte. Mit der Maus über einem Bild oder per Tippen wird das Raster feiner.</p>
  </div>
  <ul class="karten" data-karten>
    <li class="karte" style="--f: var(--kunst)"><div class="flaeche" data-karte="0" role="img" aria-label="Steinkugel und Stele im Seitenlicht"><canvas aria-hidden="true"></canvas></div><p class="art label"><i aria-hidden="true"></i>Kunst · Ausstellung</p><h3>Schwerkraft</h3><p>Selma Rüegg. Skulpturen aus Sandstein und Gips.</p><div class="zeile"><span class="mono">07.11. – 14.02.</span><a href="#programm">Tickets</a></div></li>
    <li class="karte" style="--f: var(--musik)"><div class="flaeche" data-karte="1" role="img" aria-label="Sängerin im Gegenlicht vor vollem Saal"><canvas aria-hidden="true"></canvas></div><p class="art label"><i aria-hidden="true"></i>Musik · Konzert</p><h3>Nachtstrom</h3><p>Ines Gadient und Band. Stimme, Kontrabass, Elektronik.</p><div class="zeile"><span class="mono">Fr 13.11. · 20:30</span><a href="#programm">Tickets</a></div></li>
    <li class="karte" style="--f: var(--theater)"><div class="flaeche" data-karte="2" role="img" aria-label="Offener Vorhang, ein Stuhl im Lichtkegel"><canvas aria-hidden="true"></canvas></div><p class="art label"><i aria-hidden="true"></i>Theater · Premiere</p><h3>Wartesaal</h3><p>Ein Abend über das Warten. Mit Übertitel auf Englisch.</p><div class="zeile"><span class="mono">Sa 21.11. · 19:30</span><a href="#programm">Tickets</a></div></li>
    <li class="karte" style="--f: var(--film)"><div class="flaeche" data-karte="3" role="img" aria-label="Abendsonne über dem See, Berge im Dunst, ein Ruderboot"><canvas aria-hidden="true"></canvas></div><p class="art label"><i aria-hidden="true"></i>Film · Reihe</p><h3>Seestücke</h3><p>Sechs Filme vom Wasser, jeden Mittwoch, mit Einführung.</p><div class="zeile"><span class="mono">ab 18.11. · Mi 20:00</span><a href="#programm">Tickets</a></div></li>
  </ul>
</section>

<section class="haus" id="haus" aria-labelledby="haus-titel">
  <h2 id="haus-titel">Ein Haus, das gedruckt hat</h2>
  <div>
    <p>Bis 1991 lief in der Halle am Limmatbogen die Rotation einer Zürcher Tageszeitung. Das Raster ihrer Bilder ist heute unsere Bildsprache.</p>
    <p class="mono">Di – So 11:00 – 20:00 · Do bis 22:00 · Hardturmstrasse 140, 8005 Zürich · Stufenloser Zugang, Induktionsschleife im Saal</p>
  </div>
</section>
<footer class="fuss mono"><span>Kulturhaus Limmatbogen</span><span>Tram 4 und 17, Haltestelle Förrlibuckstrasse</span></footer>
</main>

<div class="zeiger" data-zeiger aria-hidden="true"></div>
<p class="nur-sr" aria-live="polite" data-ansage></p>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const $ = s => document.querySelector(s), $$ = s => [...document.querySelectorAll(s)];

  /* ---------- Werte ---------- */
  const W = {
    zelle: 11, zelleFein: 6, zelleMobil: 7, zelleKarte: 7, zelleKarteFein: 4.5,  // CSS-px, mal Gerätepixel
    winkel: 45,                                   // Punkt und Linien
    cmyk: [15, 75, 0, 45],                        // C, M, Y, K
    kante: 0.75,                                  // Kantenglättung in Gerätepixeln
    wechsel: 480, puls: 1.9,                      // Bildwechsel: Zelle wächst bis × 1.9 und zurück
    fein: 350,                                    // Hover: Zelle wird feiner
    kurve: [0.215, 0.61, 0.355, 1],
  };
  const MODI = ['Punkt', 'CMYK', 'Linien'];
  const PROGRAMM = [
    { sparte: 'Kunst · Ausstellung', farbe: '#2f7d4a', titel: 'Schwerkraft', unter: 'Selma Rüegg. Skulpturen aus Sandstein und Gips', termin: ['07.11.2026 – 14.02.2027', 'Halle 2'], hilfen: ['Stufenlos', 'Sitzhocker', 'Führung in Gebärdensprache 22.11.'], lead: 'Drei Tonnen Stein, gestützt auf einem Punkt. Rüegg zeigt 23 Arbeiten aus 28 Jahren, die meisten zum ersten Mal in Zürich.', preis: 'Tickets · CHF 14', bu: 'Ausstellungsansicht Halle 2', alt: 'Ausstellungsansicht: eine große Steinkugel, eine liegende Form und eine schmale Stele im Seitenlicht, gedruckt als Punktraster.' },
    { sparte: 'Musik · Konzert', farbe: '#c4421a', titel: 'Nachtstrom', unter: 'Ines Gadient und Band. Stimme, Kontrabass, Elektronik', termin: ['Fr 13.11.2026 · 20:30', 'Saal · Türöffnung 19:30'], hilfen: ['Induktionsschleife', 'Rollstuhlplätze Galerie'], lead: 'Lieder aus dem neuen Album, live zum ersten Mal mit Streichquartett. Stehplätze und eine Sitzreihe auf der Galerie.', preis: 'Tickets · CHF 32', bu: 'Probe im Saal, Oktober 2026', alt: 'Sängerin im Gegenlicht auf der Bühne, vorne Köpfe und erhobene Hände des Publikums, gedruckt als Raster.' },
    { sparte: 'Theater · Premiere', farbe: '#3d5f9a', titel: 'Wartesaal', unter: 'Ensemble Limmatbogen. Ein Abend über das Warten', termin: ['Sa 21.11.2026 · 19:30', 'Studiobühne · 85 min'], hilfen: ['Englische Übertitel', 'Audiodeskription 28.11.'], lead: 'Sieben Menschen, ein Bahnhof, ein Zug, der nicht kommt. Mit englischen Übertiteln und Audiodeskription am 28.11.', preis: 'Tickets · CHF 28', bu: 'Bühnenbild Studiobühne', alt: 'Halb geöffneter Vorhang, auf der Bühne ein einzelner Stuhl im Lichtkegel, gedruckt als Raster.' },
    { sparte: 'Film · Reihe', farbe: '#b0186b', titel: 'Seestücke', unter: 'Sechs Filme vom Wasser, mit Einführung', termin: ['ab Mi 18.11.2026 · 20:00', 'Kino · jeden Mittwoch'], hilfen: ['Untertitel', 'Induktionsschleife'], lead: 'Von der Fähre bis zum Badehaus: Filme über Seen und die Menschen daran. Jeweils mit kurzer Einführung im Foyer.', preis: 'Tickets · CHF 16', bu: 'Standbild aus »Föhntag«, 1987', alt: 'Abendsonne über dem See, Berge im Dunst, ein Ruderboot auf dem Wasser, gedruckt als Raster.' },
  ];

  /* ---------- Kurve (cubic-bezier) für JS-Tweens ---------- */
  function bezier(x1, y1, x2, y2) {
    const A = (a, b) => 1 - 3 * b + 3 * a, B = (a, b) => 3 * b - 6 * a, C = a => 3 * a;
    const f = (t, a, b) => ((A(a, b) * t + B(a, b)) * t + C(a)) * t;
    const d = (t, a, b) => 3 * A(a, b) * t * t + 2 * B(a, b) * t + C(a);
    return x => { if (x <= 0) return 0; if (x >= 1) return 1; let t = x; for (let i = 0; i < 6; i++) { const s = d(t, x1, x2); if (Math.abs(s) < 1e-6) break; t -= (f(t, x1, x2) - x) / s; } return f(t, y1, y2); };
  }
  const kurve = bezier(...W.kurve);

  /* ---------- Bildwelt: vier Motive, auf Canvas gemalt (1000 × 1000) ---------- */
  const G = 1000;
  const zufall = s => () => (s = (s * 16807) % 2147483647) / 2147483647;
  function leinwand() { const c = document.createElement('canvas'); c.width = c.height = G; return [c, c.getContext('2d')]; }
  const lin = (x, x0, y0, x1, y1, st) => { const g = x.createLinearGradient(x0, y0, x1, y1); st.forEach(([o, c]) => g.addColorStop(o, c)); return g; };
  const rad = (x, cx, cy, r0, fx, fy, r1, st) => { const g = x.createRadialGradient(cx, cy, r0, fx, fy, r1); st.forEach(([o, c]) => g.addColorStop(o, c)); return g; };
  function glut(x, cx, cy, r, rgb, a) { x.fillStyle = rad(x, cx, cy, 0, cx, cy, r, [[0, `rgba(${rgb},${a})`], [1, `rgba(${rgb},0)`]]); x.fillRect(cx - r, cy - r, 2 * r, 2 * r); }
  function schatten(x, cx, cy, rx, ry, a) { x.save(); x.translate(cx, cy); x.scale(1, ry / rx); glut(x, 0, 0, rx, '24,22,20', a); x.restore(); }
  function korn(c, staerke, seed) {
    const x = c.getContext('2d'), d = x.getImageData(0, 0, G, G), p = d.data, r = zufall(seed);
    for (let i = 0; i < p.length; i += 4) { const n = (r() - .5) * staerke; p[i] += n; p[i + 1] += n; p[i + 2] += n; }
    x.putImageData(d, 0, 0); return c;
  }

  function malKunst() {
    const [c, x] = leinwand();
    x.fillStyle = lin(x, 0, 0, G, 0, [[0, '#ece8df'], [.6, '#cfc9be'], [1, '#a9a297']]); x.fillRect(0, 0, G, 700);
    /* Fensterlicht auf der Wand */
    x.fillStyle = 'rgba(255,253,246,.55)';
    x.beginPath(); x.moveTo(60, 90); x.lineTo(300, 60); x.lineTo(330, 520); x.lineTo(90, 560); x.fill();
    glut(x, 200, 300, 420, '255,252,240', .35);
    /* Boden */
    x.fillStyle = lin(x, 0, 690, 0, G, [[0, '#9c968b'], [.3, '#8a847a'], [1, '#4f4b45']]); x.fillRect(0, 690, G, 310);
    x.fillStyle = 'rgba(255,250,236,.22)'; x.beginPath(); x.moveTo(40, 700); x.lineTo(380, 700); x.lineTo(470, G); x.lineTo(0, G); x.fill();
    x.fillStyle = '#6d685f'; x.fillRect(0, 688, G, 4);
    /* Stele */
    schatten(x, 420, 735, 200, 26, .5);
    x.fillStyle = lin(x, 220, 0, 330, 0, [[0, '#f6f3ec'], [.35, '#ded8cd'], [1, '#7c766c']]);
    x.beginPath(); x.moveTo(222, 735); x.lineTo(232, 260); x.lineTo(268, 236); x.lineTo(320, 250); x.lineTo(330, 735); x.fill();
    /* Kugel */
    schatten(x, 640, 742, 270, 46, .62);
    x.fillStyle = rad(x, 500, 470, 8, 575, 555, 186, [[0, '#fbf8f1'], [.25, '#e3ddd1'], [.62, '#9d978c'], [.9, '#4f4a43'], [1, '#3e3a35']]);
    x.beginPath(); x.arc(575, 555, 180, 0, Math.PI * 2); x.fill();
    x.strokeStyle = 'rgba(40,36,32,.18)'; x.lineWidth = 3; x.beginPath(); x.arc(575, 555, 120, .2, 1.4); x.stroke();
    /* liegende Form */
    schatten(x, 880, 772, 150, 26, .55);
    x.fillStyle = rad(x, 820, 690, 6, 860, 724, 140, [[0, '#f2eee6'], [.5, '#b6b0a5'], [1, '#56514a']]);
    x.beginPath(); x.ellipse(862, 726, 128, 58, -.08, 0, Math.PI * 2); x.fill();
    return korn(c, 18, 5);
  }

  function malMusik() {
    const [c, x] = leinwand();
    x.fillStyle = lin(x, 0, 0, 0, G, [[0, '#09080c'], [1, '#17121a']]); x.fillRect(0, 0, G, G);
    /* Farbige Seitenscheinwerfer */
    const strahl = (x0, x1, farbe, a) => { x.fillStyle = lin(x, x0, 0, (x0 + x1) / 2, 820, [[0, `rgba(${farbe},${a})`], [1, `rgba(${farbe},0)`]]); x.beginPath(); x.moveTo(x0 - 30, 0); x.lineTo(x0 + 30, 0); x.lineTo(x1 + 160, 820); x.lineTo(x1 - 160, 820); x.fill(); };
    strahl(60, 360, '232,70,40', .55); strahl(940, 640, '220,40,140', .5);
    /* Dunst und Hauptlicht */
    glut(x, 500, 380, 420, '255,236,214', .4);
    x.fillStyle = lin(x, 0, 0, 0, 800, [[0, 'rgba(255,250,238,.7)'], [1, 'rgba(255,250,238,.12)']]);
    x.beginPath(); x.moveTo(470, 0); x.lineTo(530, 0); x.lineTo(740, 790); x.lineTo(260, 790); x.fill();
    x.save(); x.translate(500, 792); x.scale(1, .18); glut(x, 0, 0, 300, '255,246,226', .6); x.restore();
    /* Sängerin im Gegenlicht */
    x.fillStyle = '#0d0b0e';
    x.beginPath(); x.arc(500, 318, 46, 0, Math.PI * 2); x.fill();
    x.beginPath(); x.moveTo(478, 356); x.lineTo(522, 356); x.lineTo(590, 404); x.quadraticCurveTo(600, 520, 556, 600); x.lineTo(580, 790); x.lineTo(420, 790); x.lineTo(444, 600); x.quadraticCurveTo(400, 520, 410, 404); x.fill();
    x.strokeStyle = '#0d0b0e'; x.lineWidth = 26; x.lineCap = 'round';
    x.beginPath(); x.moveTo(420, 420); x.quadraticCurveTo(452, 470, 486, 360); x.stroke();
    x.lineWidth = 6; x.beginPath(); x.moveTo(560, 792); x.lineTo(528, 352); x.stroke();
    x.strokeStyle = 'rgba(255,238,210,.85)'; x.lineWidth = 6; x.lineCap = 'butt';
    x.beginPath(); x.arc(500, 318, 44, -2.6, -.6); x.stroke();
    x.beginPath(); x.moveTo(522, 358); x.lineTo(588, 404); x.stroke();
    /* Publikum */
    const r = zufall(9);
    x.fillStyle = '#050406';
    for (let i = 0; i < 15; i++) {
      const hx = i * 72 + r() * 30 - 20, hy = 870 + r() * 40, hr = 40 + r() * 10;
      x.beginPath(); x.arc(hx, hy, hr, 0, Math.PI * 2); x.fill(); x.fillRect(hx - hr - 18, hy + 20, 2 * hr + 36, 200);
      x.strokeStyle = 'rgba(255,214,180,.35)'; x.lineWidth = 4; x.beginPath(); x.arc(hx, hy, hr - 2, -2.4, -.7); x.stroke();
    }
    [[180, 640], [690, 600], [820, 690]].forEach(([hx, hy]) => { x.fillStyle = '#050406'; x.beginPath(); x.roundRect(hx - 14, hy, 28, 300, 12); x.fill(); x.beginPath(); x.ellipse(hx, hy, 24, 34, 0, 0, Math.PI * 2); x.fill(); });
    return korn(c, 16, 11);
  }

  function malTheater() {
    const [c, x] = leinwand();
    x.fillStyle = lin(x, 0, 0, 0, 760, [[0, '#120f10'], [1, '#2a2421']]); x.fillRect(0, 0, G, 760);
    glut(x, 500, 520, 360, '255,232,196', .18);
    /* Bühnenboden mit Lichtkegel */
    x.fillStyle = lin(x, 0, 740, 0, G, [[0, '#3d2c22'], [1, '#1b130e']]); x.fillRect(0, 740, G, 260);
    glut(x, 500, 120, 520, '255,240,214', .12);
    x.fillStyle = lin(x, 0, 0, 0, 840, [[0, 'rgba(255,246,228,.55)'], [1, 'rgba(255,246,228,.08)']]);
    x.beginPath(); x.moveTo(480, 0); x.lineTo(520, 0); x.lineTo(700, 840); x.lineTo(300, 840); x.fill();
    x.save(); x.translate(500, 842); x.scale(1, .2); glut(x, 0, 0, 230, '255,246,228', .9); x.restore();
    /* Stuhl */
    schatten(x, 640, 866, 240, 22, .8);
    x.fillStyle = '#0e0b09';
    x.fillRect(426, 500, 16, 366); x.fillRect(558, 500, 16, 366);       /* Lehne + Hinterbeine */
    x.fillRect(426, 500, 148, 20); x.fillRect(426, 580, 148, 14);
    x.fillRect(402, 712, 196, 22);                                        /* Sitz */
    x.fillRect(408, 726, 14, 140); x.fillRect(578, 726, 14, 140);
    x.fillStyle = 'rgba(255,236,206,.6)'; x.fillRect(402, 712, 196, 4); x.fillRect(426, 500, 148, 4);
    /* Vorhang links und rechts: Falten als Verläufe */
    const falten = (x0, x1, seed, raff) => {
      const r = zufall(seed); let px = x0;
      while (px < x1) {
        const w = 34 + r() * 30;
        x.fillStyle = lin(x, px, 0, px + w, 0, [[0, '#2a070b'], [.4, '#b8343d'], [.6, '#f08a86'], [1, '#2a070b']]);
        const t = (px - x0) / (x1 - x0), schwung = raff ? Math.sin(t * Math.PI / 2) * raff : 0;
        x.beginPath(); x.moveTo(px, 120); x.lineTo(px + w + 1, 120); x.lineTo(px + w + 1 + schwung, G); x.lineTo(px + schwung, G); x.fill();
        px += w;
      }
    };
    falten(-20, 300, 3, 60);
    falten(700, 1020, 4, -60);
    x.fillStyle = lin(x, 0, 120, 0, G, [[0, 'rgba(0,0,0,0)'], [.7, 'rgba(0,0,0,0)'], [1, 'rgba(0,0,0,.45)']]); x.fillRect(0, 120, G, 880);
    /* Bühnenrahmen oben: Querbehang */
    const r = zufall(8); let px = 0;
    while (px < G) { const w = 46 + r() * 20; x.fillStyle = lin(x, px, 0, px + w, 0, [[0, '#3a0a10'], [.5, '#ec7c7c'], [1, '#3a0a10']]); x.beginPath(); x.moveTo(px, 0); x.lineTo(px + w + 1, 0); x.lineTo(px + w + 1, 140); x.quadraticCurveTo(px + w / 2, 168, px, 140); x.fill(); px += w; }
    return korn(c, 16, 13);
  }

  function malFilm() {
    const [c, x] = leinwand();
    x.fillStyle = lin(x, 0, 0, 0, 600, [[0, '#2d3654'], [.55, '#8a6a7c'], [1, '#f0b07a']]); x.fillRect(0, 0, G, 600);
    glut(x, 640, 470, 360, '255,214,160', .55);
    glut(x, 640, 470, 120, '255,246,220', .95);
    x.fillStyle = '#fff6dc'; x.beginPath(); x.arc(640, 470, 58, 0, Math.PI * 2); x.fill();
    const berg = (y0, hoehe, farbe, seed, n) => {
      const r = zufall(seed); x.fillStyle = farbe; x.beginPath(); x.moveTo(0, 620);
      for (let i = 0; i <= n; i++) { const bx = i * G / n, by = y0 - (i % 2 ? hoehe * (.55 + r() * .45) : hoehe * r() * .35); x.lineTo(bx, by); }
      x.lineTo(G, 620); x.fill();
    };
    berg(520, 170, 'rgba(150,128,152,.75)', 21, 9);
    berg(560, 120, 'rgba(92,82,108,.92)', 22, 12);
    berg(600, 70, '#3a3446', 23, 16);
    /* See */
    x.fillStyle = lin(x, 0, 596, 0, G, [[0, '#d39a72'], [.25, '#7a5e6c'], [1, '#141826']]); x.fillRect(0, 596, G, 404);
    const r = zufall(31);
    for (let i = 0; i < 70; i++) {
      const y = 604 + i * 5.6, w = 30 + (1 - i / 70) * 70 + r() * 40, a = .9 - i / 90;
      x.fillStyle = `rgba(255,236,200,${a.toFixed(2)})`; x.fillRect(640 - w / 2 + (r() - .5) * 30, y, w, 2.4);
    }
    for (let i = 0; i < 60; i++) { x.fillStyle = 'rgba(255,255,255,.12)'; x.fillRect(r() * G, 640 + r() * 340, 20 + r() * 60, 2); }
    /* Ruderboot */
    x.fillStyle = '#0d0e14';
    x.beginPath(); x.moveTo(250, 742); x.lineTo(410, 742); x.quadraticCurveTo(398, 768, 372, 772); x.lineTo(278, 772); x.quadraticCurveTo(256, 764, 250, 742); x.fill();
    x.beginPath(); x.arc(332, 700, 13, 0, Math.PI * 2); x.fill(); x.fillRect(318, 712, 28, 32);
    x.lineWidth = 4; x.strokeStyle = '#0d0e14'; x.beginPath(); x.moveTo(300, 726); x.lineTo(230, 790); x.moveTo(364, 726); x.lineTo(440, 788); x.stroke();
    /* Ufer und Schilf */
    x.fillStyle = '#07080c'; x.beginPath(); x.moveTo(0, 900); x.quadraticCurveTo(260, 860, 420, G); x.lineTo(0, G); x.fill();
    x.strokeStyle = '#07080c'; x.lineWidth = 3;
    for (let i = 0; i < 40; i++) { const sx = r() * 300, h = 80 + r() * 160; x.beginPath(); x.moveTo(sx, G); x.quadraticCurveTo(sx + 8, G - h / 2, sx + 18 * (r() - .3), G - h); x.stroke(); }
    return korn(c, 16, 17);
  }

  const motive = [malKunst(), malMusik(), malTheater(), malFilm()];

  /* ---------- Shader: Punkt-, CMYK- und Linienraster ---------- */
  const VS = 'attribute vec2 p; void main(){ gl_Position = vec4(p, 0.0, 1.0); }';
  const FS = `
precision highp float;
uniform sampler2D uA, uB;
uniform float uMix, uZelle, uModus, uKante, uWinkel;
uniform vec2 uAuf;
uniform vec3 uTinte, uPapier;
const float PI = 3.14159265;
vec2 dreh(vec2 p, float a) { float c = cos(a), s = sin(a); return vec2(c * p.x - s * p.y, s * p.x + c * p.y); }
vec2 cover(vec2 f) {                       /* Motiv quadratisch, Fläche beliebig: wie object-fit: cover */
  vec2 uv = f / uAuf; float r = uAuf.x / uAuf.y;
  if (r > 1.0) uv.y = (uv.y - 0.5) / r + 0.5; else uv.x = (uv.x - 0.5) * r + 0.5;
  return clamp(uv, 0.002, 0.998);
}
vec3 bild(vec2 f) { vec2 uv = cover(f); return mix(texture2D(uA, uv).rgb, texture2D(uB, uv).rgb, uMix); }
float dunkel(vec3 c) { return clamp((1.0 - dot(c, vec3(0.299, 0.587, 0.114)) - 0.03) * 1.08, 0.0, 1.0); }
vec4 cmyk(vec3 c) {
  float k = 1.0 - max(max(c.r, c.g), c.b);
  vec3 cmy = (1.0 - c - k) / max(1.0 - k, 0.001);
  return vec4(cmy * 0.9, k * 0.88);                 /* etwas weniger Tonwertzunahme */
}
float kanal(vec3 c, float n) {
  if (n < -0.5) return dunkel(c);
  vec4 v = cmyk(c);
  return n < 0.5 ? v.x : n < 1.5 ? v.y : n < 2.5 ? v.z : v.w;
}
/* Ein Punkt pro Zelle, Radius aus der Dunkelheit am Zellmittelpunkt (Fläche proportional) */
float punkt(vec2 f, float winkel, float n) {
  vec2 m = 0.5 * uAuf;
  vec2 p = dreh(f - m, -winkel);
  vec2 z = (floor(p / uZelle) + 0.5) * uZelle;
  float v = kanal(bild(dreh(z, winkel) + m), n);
  float r = uZelle * 0.7071 * sqrt(v);
  return 1.0 - smoothstep(r - uKante, r + uKante, length(p - z));
}
/* Linienraster: Strichstärke aus der Dunkelheit entlang der Linie */
float linie(vec2 f, float winkel) {
  vec2 m = 0.5 * uAuf;
  vec2 p = dreh(f - m, -winkel);
  float y = (floor(p.y / uZelle) + 0.5) * uZelle;
  float v = dunkel(bild(dreh(vec2(p.x, y), winkel) + m));
  float h = 0.5 * uZelle * v;
  return 1.0 - smoothstep(h - uKante, h + uKante, abs(p.y - y));
}
void main() {
  vec2 f = gl_FragCoord.xy;
  vec3 col;
  if (uModus < 0.5) {
    col = mix(uPapier, uTinte, punkt(f, uWinkel, -1.0));
  } else if (uModus < 1.5) {
    float c = punkt(f, 15.0 * PI / 180.0, 0.0), mm = punkt(f, 75.0 * PI / 180.0, 1.0);
    float y = punkt(f, 0.0, 2.0), k = punkt(f, 45.0 * PI / 180.0, 3.0);
    col = uPapier * mix(vec3(1.0), vec3(0.0, 0.63, 0.88), c * 0.92) * mix(vec3(1.0), vec3(0.9, 0.05, 0.5), mm * 0.9)
        * mix(vec3(1.0), vec3(1.0, 0.9, 0.05), y * 0.95) * mix(vec3(1.0), vec3(0.09, 0.08, 0.09), k);
  } else {
    col = mix(uPapier, uTinte, linie(f, uWinkel));
  }
  gl_FragColor = vec4(col, 1.0);
}`;

  function maler(canvas) {
    const gl = canvas.getContext('webgl', { antialias: false, premultipliedAlpha: false, preserveDrawingBuffer: true });
    if (!gl) return null;
    const sh = (t, s) => { const o = gl.createShader(t); gl.shaderSource(o, s); gl.compileShader(o); if (!gl.getShaderParameter(o, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(o)); return o; };
    const pr = gl.createProgram();
    try { gl.attachShader(pr, sh(gl.VERTEX_SHADER, VS)); gl.attachShader(pr, sh(gl.FRAGMENT_SHADER, FS)); } catch (e) { console.warn(e); return null; }
    gl.linkProgram(pr); if (!gl.getProgramParameter(pr, gl.LINK_STATUS)) return null;
    gl.useProgram(pr);
    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 lp = gl.getAttribLocation(pr, 'p'); gl.enableVertexAttribArray(lp); gl.vertexAttribPointer(lp, 2, gl.FLOAT, false, 0, 0);
    gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
    const tex = motive.map(m => { const t = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, m);
      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); return t; });
    const U = {}; ['uA', 'uB', 'uMix', 'uZelle', 'uModus', 'uKante', 'uWinkel', 'uAuf', 'uTinte', 'uPapier'].forEach(n => U[n] = gl.getUniformLocation(pr, n));
    gl.uniform1i(U.uA, 0); gl.uniform1i(U.uB, 1);
    return {
      gl,
      malen(o) {   /* o: a, b, mix, zelle (Gerätepixel), modus, tinte [r,g,b] */
        gl.viewport(0, 0, canvas.width, canvas.height);
        gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, tex[o.a]);
        gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, tex[o.b]);
        gl.uniform1f(U.uMix, o.mix); gl.uniform1f(U.uZelle, o.zelle); gl.uniform1f(U.uModus, o.modus);
        gl.uniform1f(U.uKante, W.kante); gl.uniform1f(U.uWinkel, W.winkel * Math.PI / 180);
        gl.uniform2f(U.uAuf, canvas.width, canvas.height);
        gl.uniform3f(U.uTinte, ...o.tinte); gl.uniform3f(U.uPapier, 239 / 255, 237 / 255, 233 / 255);
        gl.drawArrays(gl.TRIANGLES, 0, 3);
      },
    };
  }
  const rgb = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255);
  const mischRgb = (a, b, t) => a.map((v, i) => v + (b[i] - v) * t);

  /* ---------- Zustand ---------- */
  const raster = $('[data-raster]'), canvas = $('[data-gl]'), ansage = $('[data-ansage]'), zeiger = $('[data-zeiger]');
  const reiter = $$('[data-nr]'), modiKnoepfe = $$('[data-modus]');
  const S = { nr: 0, von: 0, mix: 0, modus: 0, fein: 0, wechselT: 1, tinteVon: rgb(PROGRAMM[0].farbe) };
  let held = maler(canvas);
  if (!held) {
    html.classList.add('kein-gl');
    const e = motive[0]; e.className = 'ersatz'; e.setAttribute('aria-hidden', 'true'); raster.prepend(e);
  }
  const dpr = () => Math.min(devicePixelRatio || 1, 2);
  const basis = () => innerWidth <= 760 ? W.zelleMobil : W.zelle;

  function heldMalen() {
    if (!held) return;
    const t = S.wechselT, puls = t < 1 ? 1 + (W.puls - 1) * Math.sin(Math.PI * t) : 1;
    const zelle = (basis() + (W.zelleFein - basis()) * S.fein) * puls;
    const tinte = mischRgb(S.tinteVon, rgb(PROGRAMM[S.nr].farbe), t < 1 ? kurve(t) : 1);
    held.malen({ a: S.von, b: S.nr, mix: t < 1 ? kurve(t) : 1, zelle: zelle * dpr(), modus: S.modus, tinte });
    $('[data-info]').textContent = `${MODI[S.modus]} · ${S.modus === 1 ? 'C15 M75 Y0 K45' : W.winkel + '°'} · ${(zelle).toFixed(1).replace('.0', '')} px`;
  }
  function groesse() {
    if (!held) return;
    const r = raster.getBoundingClientRect();
    canvas.width = Math.max(1, Math.round(r.width * dpr())); canvas.height = Math.max(1, Math.round(r.height * dpr()));
    heldMalen(); kartenMalen(); werteZeigen();
  }

  /* ---------- Karten: ein zweiter Kontext malt offscreen und kopiert ---------- */
  const kartenFlaechen = $$('[data-karte]');
  const off = document.createElement('canvas');
  const kMaler = held ? maler(off) : null;
  const kFein = kartenFlaechen.map(() => 0);
  function karteMalen(i) {
    const el = kartenFlaechen[i], cv = el.querySelector('canvas'), r = el.getBoundingClientRect();
    if (!r.width) return;
    const w = Math.round(r.width * dpr()), h = Math.round(r.height * dpr());
    if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h; }
    if (!kMaler) { const x = cv.getContext('2d'); x.drawImage(motive[i], (G - G * w / h) / 2, 0, G * w / h, G, 0, 0, w, h); return; }
    if (off.width !== w || off.height !== h) { off.width = w; off.height = h; }
    const zelle = W.zelleKarte + (W.zelleKarteFein - W.zelleKarte) * kFein[i];
    kMaler.malen({ a: i, b: i, mix: 0, zelle: zelle * dpr(), modus: S.modus, tinte: rgb(PROGRAMM[i].farbe) });
    cv.getContext('2d').drawImage(off, 0, 0);
  }
  function kartenMalen() { if (!html.classList.contains('vorschau')) kartenFlaechen.forEach((_, i) => karteMalen(i)); }

  /* ---------- Tweens (eine Kurve: cubic-bezier(.215,.61,.355,1)) ---------- */
  const tweens = new Map();
  let laeuft = false;
  function tween(name, von, nach, dauer, setze) {
    if (reduziert() || dauer <= 0) { tweens.delete(name); setze(nach, 1); zeichnen(); return; }
    tweens.set(name, { von, nach, dauer, start: performance.now(), setze }); starten();
  }
  function starten() { if (!laeuft) { laeuft = true; requestAnimationFrame(schritt); } }
  let malHeld = false;
  const malKarten = new Set();
  function zeichnen() {
    if (malHeld) { malHeld = false; heldMalen(); }
    malKarten.forEach(karteMalen); malKarten.clear();
  }
  function schritt(t) {
    tweens.forEach((w, k) => {
      const p = Math.min(1, (t - w.start) / w.dauer);
      w.setze(w.von + (w.nach - w.von) * (k === 'wechsel' ? p : kurve(p)), p);
      if (p >= 1) tweens.delete(k);
    });
    zeichnen();
    if (vorfuehrt) vorfuehrSchritt(t);
    if (tweens.size || vorfuehrt) requestAnimationFrame(schritt); else laeuft = false;
  }

  /* ---------- Bildwechsel: Raster wächst, Motiv tauscht, Raster schließt sich ---------- */
  function zeige(i, ansagen) {
    if (i === S.nr && S.wechselT >= 1) return;
    S.tinteVon = S.wechselT < 1 ? mischRgb(S.tinteVon, rgb(PROGRAMM[S.nr].farbe), kurve(S.wechselT)) : rgb(PROGRAMM[S.nr].farbe);
    S.von = S.nr; S.nr = i; S.wechselT = 0;
    tween('wechsel', 0, 1, W.wechsel, v => { S.wechselT = v; malHeld = true; });
    texte(i, ansagen);
  }
  function texte(i, ansagen) {
    const P = PROGRAMM[i];
    html.style.setProperty('--s', P.farbe);
    $('[data-sparte]').textContent = P.sparte; $('[data-titel]').textContent = P.titel; $('[data-unter]').textContent = P.unter;
    $('[data-termin]').innerHTML = P.termin.map(t => `<span>${t}</span>`).join('');
    $('[data-hilfen]').innerHTML = P.hilfen.map(t => `<li>${t}</li>`).join('');
    $('[data-lead]').textContent = P.lead; $('[data-bu]').textContent = P.bu;
    $('[data-preis]').firstChild.textContent = P.preis + ' ';
    raster.setAttribute('aria-label', P.alt);
    reiter.forEach((b, k) => b.setAttribute('aria-current', k === i ? 'true' : 'false'));
    if (ansagen) ansage.textContent = `${P.sparte}: ${P.titel}`;
  }
  reiter.forEach(b => b.addEventListener('click', () => { stopVorfuehrung(); zeige(+b.dataset.nr, true); }));

  function fein(an) { tween('fein', S.fein, an ? 1 : 0, W.fein, v => { S.fein = v; malHeld = true; }); }
  raster.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse' && !vorfuehrt) fein(true); });
  raster.addEventListener('pointerleave', e => { if (e.pointerType === 'mouse' && !vorfuehrt) fein(false); });
  raster.addEventListener('click', e => { if (e.pointerType !== 'mouse') { stopVorfuehrung(); fein(S.fein < .5); } });

  kartenFlaechen.forEach((el, i) => {
    const an = v => tween('karte' + i, kFein[i], v, W.fein, x => { kFein[i] = x; malKarten.add(i); });
    el.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse') an(1); });
    el.addEventListener('pointerleave', e => { if (e.pointerType === 'mouse') an(0); });
    el.addEventListener('click', e => { if (e.pointerType !== 'mouse') an(kFein[i] < .5 ? 1 : 0); });
  });

  function setzeModus(m) {
    S.modus = m;
    modiKnoepfe.forEach(b => b.setAttribute('aria-pressed', +b.dataset.modus === m ? 'true' : 'false'));
    heldMalen(); kartenMalen(); werteZeigen();
  }
  modiKnoepfe.forEach(b => b.addEventListener('click', () => { stopVorfuehrung(); setzeModus(+b.dataset.modus); ansage.textContent = `Raster: ${MODI[+b.dataset.modus]}`; }));

  function werteZeigen() {
    const t = [
      ['Zelle', `${basis()} px → ${W.zelleFein} px`],
      ['Winkel', S.modus === 1 ? 'C15 M75 Y0 K45' : `${W.winkel}°`],
      ['Radius', 'Zelle · 0.707 · √Dunkel'],
      ['Kante', `${W.kante} px`],
      ['Wechsel', reduziert() ? 'sofort' : `${W.wechsel} ms · Zelle × ${W.puls}`],
      ['Kurve', 'cubic-bezier(.215, .61, .355, 1)'],
    ];
    $('[data-werte]').innerHTML = t.map(([a, b]) => `<span>${a} <b>${b}</b></span>`).join('');
  }
  new MutationObserver(werteZeigen).observe(html, { attributes: true, attributeFilter: ['class'] });

  /* ---------- Vorführung: gedachter Zeiger klickt das Programm durch, fährt übers Bild ---------- */
  const TAKT = 3600, FOLGE = [0, 0, 1, 1, 2, 2];   /* Modus je Takt: zwei Motive pro Raster */
  let vorfuehrt = false, vStart = 0, vTakt = -1, vFein = false, zp = { x: 0, y: 0 };
  const mitte = el => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2, r }; };
  function vorfuehrSchritt(t) {
    const z = t - vStart, takt = Math.floor(z / TAKT), s = (z % TAKT) / TAKT;
    if (takt !== vTakt) {
      vTakt = takt;
      const m = FOLGE[takt % FOLGE.length]; if (m !== S.modus) setzeModus(m);
      zeige((S.nr + 1) % 4);
      zeiger.classList.add('drueckt'); setTimeout(() => zeiger.classList.remove('drueckt'), 150);
    }
    const bild = mitte(raster), ziel = mitte(reiter[(S.nr + 1) % 4]);
    let p;
    if (s < .2) { const e = kurve(s / .2), k = mitte(reiter[S.nr]); p = { x: k.x + (bild.x - k.x) * e, y: k.y + (bild.y - k.y) * e }; }
    else if (s < .72) { const w = (s - .2) / .52 * Math.PI * 2; p = { x: bild.x + Math.sin(w) * bild.r.width * .22, y: bild.y + Math.sin(w * .5) * bild.r.height * .18 }; }
    else { const e = kurve((s - .72) / .28); p = { x: bild.x + (ziel.x - bild.x) * e, y: bild.y + (ziel.y - bild.y) * e }; }
    const imBild = s > .14 && s < .74;
    if (imBild !== vFein) { vFein = imBild; fein(imBild); }
    zeiger.style.transform = `translate(${p.x}px, ${p.y}px)`;
  }
  function stopVorfuehrung() { if (!vorfuehrt) return; vorfuehrt = false; html.classList.remove('vorfuehrung'); if (vFein) { vFein = false; fein(false); } }
  function neustart() {
    stopVorfuehrung();
    tweens.clear(); S.fein = 0; S.wechselT = 1; S.von = 0; S.nr = 0; S.tinteVon = rgb(PROGRAMM[0].farbe);
    texte(0); setzeModus(0);
  }
  function vorfuehren() {
    neustart();
    if (!html.classList.contains('vorschau')) scrollTo(0, 0);
    vorfuehrt = true; vStart = performance.now() - TAKT + 900; vTakt = 0; vFein = false;
    html.classList.add('vorfuehrung'); starten();
  }
  function pause() { stopVorfuehrung(); }
  function weiter() { vorfuehren(); }

  new ResizeObserver(groesse).observe(raster);
  addEventListener('load', groesse);
  document.fonts && document.fonts.ready.then(groesse);
  werteZeigen();

})();
</script>
</body>
</html>