Zum Inhalt
← Zugang & Robustheit

Favicon-Satz und Teilbild je Seite

Aus einer Marke entstehen favicon.ico, icon.svg mit Dunkelmodus, apple-touch-icon, 192, 512, ein maskierbares Icon und das Manifest, dazu je Seite ein Teilbild 1200 × 630 aus Rubrik und Titel, im Browser gerendert und für den Bau im Starter-Generator beschrieben.

Grundausstattung jeder Kundenseite: Symbole für Tab, Lesezeichen, Startbildschirm und Android aus einer Marke.

Aus einer Marke entsteht der ganze Satz an Symbolen: favicon.ico mit 32 px, icon.svg mit eigener Farbe im Dunkelmodus, apple-touch-icon mit 180 px und Rand, Icons mit 192 und 512 px, ein maskierbares Icon mit Schutzzone und das Manifest. Dazu bekommt jede Seite ein eigenes Teilbild (og:image, 1200 × 630) aus Seitentitel, Rubrik und Marke. Die Vorlage ist HTML bzw. Canvas und wird beim Bauen gerendert. Die Demo zeigt die Symbole im Browser-Tab hell und dunkel, auf dem Startbildschirm und in den Masken von Android, die Teilbilder in einer neutralen Link-Vorschau, und wie der Satz in den Starter-Generator passt.

Das Favicon ist das kleinste und meistgesehene Markenzeichen: im Tab, in Lesezeichen, auf dem Startbildschirm. Ein schwarzes Zeichen verschwindet im dunklen Tab. Geteilte Links sind die Visitenkarte im Messenger; ohne og:image zeigt die Vorschau ein zufälliges Bild oder nichts. Ein Teilbild je Seite mit lesbarem Titel sagt vor dem Klick, worum es geht.

Wann einsetzen

Jede Kundenseite als Grundausstattung. Besonders, wenn Seiten oft geteilt werden: Veranstaltungen, Artikel, Stellen, Objekte, Angebote. Für Betriebe, deren Kundschaft die Seite auf den Startbildschirm legt (Praxis, Apotheke, Verein, Fitness).

Wann nicht

Kein Manifest mit display: standalone, wenn die Seite keine App sein soll; für den Startbildschirm reichen Icons und Name. Keine Fotos von Personen automatisch ins Teilbild ohne Freigabe. Kein Teilbild mit Text unter 40 px Höhe, das wird in kleinen Vorschauen unlesbar. Fremde Logos oder Siegel nur mit Recht zur Nutzung, vom Kunden prüfen lassen.

Varianten

  • Favicon-Satz: ico, SVG mit Dunkelmodus, apple-touch, 192, 512, maskierbar, Manifest
  • Teilbild je Seite: Vorlage aus Titel, Rubrik und Marke, automatisch gesetzt, mit og:image:alt
  • Einbau: Kopfzeilen je Seite und Bau-Schritt im Starter-Generator (Marke aus firma.json)

Umsetzung

Quelle ist eine Marke als SVG mit quadratischer viewBox und Farben aus tokens.css. icon.svg bekommt einen <style> mit @media (prefers-color-scheme: dark), der Grund und Zeichen tauscht. Die PNGs entstehen aus derselben SVG über Canvas (Browser) oder Playwright (Bau): 32 für favicon.ico (ICO-Datei mit eingebettetem PNG), 180 mit 20 px Rand und vollem Grund für apple-touch-icon, 192 und 512 transparent, 512 maskierbar mit dem Zeichen im Kreis von 40 % Radius und vollflächigem Grund. manifest.webmanifest mit name, short_name, icons, theme_color und background_color aus tokens.css. Kopf: <link rel="icon" href="/favicon.ico" sizes="32x32">, <link rel="icon" href="/icon.svg" type="image/svg+xml">, <link rel="apple-touch-icon" href="/apple-touch-icon.png">, <link rel="manifest" href="/manifest.webmanifest">. Teilbild: eine Vorlage (HTML oder Canvas 1200 × 630) mit Rubrik, Titel, Marke und Domain; der Titel wird gemessen und in 2 bis 3 Zeilen gesetzt, die Schriftgröße sinkt bis zu einer Untergrenze, lange Wörter werden getrennt. Beim Bauen rendert ein Skript für jede Seite aus <title> bzw. og:title ein PNG nach og/<seite>.png und setzt og:image, og:image:width, og:image:height, og:image:alt und twitter:card. Schriften vorher laden (document.fonts.load), sonst rendert die Ersatzschrift.

Robust umsetzen

Telefon. Die Vorschauen stehen untereinander: Tab, Startbildschirm, Masken, Link-Vorschau. Downloads als Liste mit großen Zeilen.

Ohne Bewegung. Wechsel zwischen hell und dunkel und zwischen den Masken ohne Übergang.

Barrierefrei. Jedes Teilbild bekommt og:image:alt mit dem Titel. Der Titel im Teilbild hat mindestens 4,5 : 1 Kontrast. Die Vorschauen sind Bilder mit Textalternative, Umschalter echte Knöpfe mit aria-pressed. Im Kontrastmodus bleibt das SVG-Favicon sichtbar, weil Grund und Zeichen eigene Farben haben.

Code

<!DOCTYPE html>
<!--
  Favicon-Satz und Teilbild je Seite
  Fundus · Zugang & Robustheit · https://cecodesign.de/lab/fundus/zugang/favicon-teilbild-system/
  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>Favicon-Satz und Teilbild je Seite · 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: 'Libre Caslon Display'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/libre-caslon-display@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'Familjen Grotesk'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/familjen-grotesk:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 700; 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; }

/* Rezept laden-vor-ort-sortiment */
:root {
  --grund: #F5F1E8; --text: #211F1C; --kraft: #E2D3B8; --tinte: #2C2A26; --gruen: #2E5B45; --leise: #615B51; --linie: #D9CFBE;
  --kurve: cubic-bezier(0.16, 1, 0.3, 1); --d-hover: 150ms; --d-auf: 300ms; --d-zu: 200ms;
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font: 400 17px/1.6 'Familjen Grotesk', system-ui, sans-serif; -webkit-text-size-adjust: 100%; }
:root:has(:focus-visible) { scroll-behavior: auto; }
button, input, select, textarea { font: inherit; color: inherit; }
button { cursor: pointer; background: none; border: 0; }
a { color: inherit; }
img, svg, canvas { display: block; max-width: 100%; }
:focus-visible { outline: 2px solid var(--gruen); outline-offset: 3px; border-radius: 3px; }
[hidden] { display: none !important; }
.vh { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
.label { font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.mono { font-family: 'Geist Mono', ui-monospace, monospace; font-size: .8125rem; }

.seite { max-width: 1240px; margin: 0 auto; padding: clamp(24px, 4vw, 56px) clamp(16px, 4vw, 48px) 96px; }
.kopf { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 16px 40px; align-items: end; padding-bottom: 24px; border-bottom: 1px solid var(--linie); }
.kopf h1 { font: 400 clamp(2.25rem, 1.4rem + 3.6vw, 4.25rem)/1.02 'Libre Caslon Display', Georgia, serif; letter-spacing: -.015em; text-wrap: balance; max-width: 18ch; }
.kopf p { margin-top: 12px; max-width: 58ch; color: var(--leise); }
.reiter { display: inline-flex; padding: 4px; border-radius: 999px; background: var(--kraft); position: relative; }
.reiter button { position: relative; z-index: 1; padding: 10px 18px; border-radius: 999px; font-weight: 600; font-size: .9375rem; min-height: 44px; transition: color var(--d-hover) var(--kurve); }
.reiter button[aria-selected="true"] { color: #fff; }
.reiter__marke { position: absolute; top: 4px; bottom: 4px; left: 0; border-radius: 999px; background: var(--gruen); transition: transform var(--d-auf) var(--kurve), width var(--d-auf) var(--kurve); }

.raster { display: grid; grid-template-columns: minmax(0, 340px) minmax(0, 1fr); gap: clamp(24px, 4vw, 56px); margin-top: 36px; align-items: start; }
.karte { background: #fff; border: 1px solid var(--linie); border-radius: 14px; padding: 22px; }
.karte + .karte { margin-top: 16px; }
.karte h2 { font: 400 1.5rem/1.15 'Libre Caslon Display', Georgia, serif; margin-bottom: 4px; }
.karte > p { font-size: .9375rem; color: var(--leise); }

/* Marke */
.marke-gross { aspect-ratio: 1; display: grid; place-items: center; border-radius: 12px; margin: 16px 0; background: repeating-conic-gradient(#efe9dc 0 25%, #f8f5ee 0 50%) 0 0 / 20px 20px; }
.marke-gross svg { width: 58%; height: auto; filter: drop-shadow(0 12px 24px rgba(33, 31, 28, .18)); }
.wahl { display: flex; flex-wrap: wrap; gap: 8px; border: 0; padding: 0; margin-top: 10px; }
.wahl legend { font-size: .875rem; font-weight: 600; margin-bottom: 8px; }
.wahl label { position: relative; display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px 6px 6px; border: 1px solid var(--linie); border-radius: 999px; font-size: .875rem; cursor: pointer; transition: border-color var(--d-hover) var(--kurve); }
.wahl label:has(:checked) { border-color: var(--text); box-shadow: inset 0 0 0 1px var(--text); }
.wahl label:has(:focus-visible) { outline: 2px solid var(--gruen); outline-offset: 2px; }
.wahl input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.wahl i { width: 22px; height: 22px; border-radius: 50%; display: block; border: 1px solid rgba(0, 0, 0, .15); }

/* Vorschauen */
.vorschauen { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.vorschauen .breit { grid-column: 1 / -1; }
.feld { background: #fff; border: 1px solid var(--linie); border-radius: 14px; padding: 18px; }
.feld > .label { display: block; margin-bottom: 12px; }
.tabs { border-radius: 10px; overflow: hidden; border: 1px solid var(--linie); }
.tabs + .tabs { margin-top: 10px; }
.tabs__leiste { display: flex; align-items: end; gap: 2px; padding: 8px 8px 0; background: #dfe1e5; }
.tabs[data-modus="dunkel"] .tabs__leiste { background: #1f2023; }
.tab { display: flex; align-items: center; gap: 8px; min-width: 0; max-width: 240px; flex: 1; padding: 8px 12px; border-radius: 8px 8px 0 0; font-size: 12.5px; color: #3c4043; }
.tab img { width: 16px; height: 16px; flex: none; }
.tab span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tab--aktiv { background: #fff; color: #202124; }
.tabs[data-modus="dunkel"] .tab { color: #bdc1c6; }
.tabs[data-modus="dunkel"] .tab--aktiv { background: #35363a; color: #e8eaed; }
.tab--leer i { width: 16px; height: 16px; border-radius: 50%; background: #9aa0a6; flex: none; }
.tabs__adresse { padding: 8px 12px; background: #fff; font: 12px/1.2 'Geist Mono', monospace; color: #5f6368; }
.tabs[data-modus="dunkel"] .tabs__adresse { background: #35363a; color: #bdc1c6; }
.tabs__titel { display: flex; justify-content: space-between; font-size: .8125rem; color: var(--leise); margin-bottom: 6px; }

.handy { border-radius: 18px; padding: 22px 18px 28px; background: linear-gradient(160deg, #3f5c50, #1d2a25 70%); display: grid; grid-template-columns: repeat(4, 1fr); align-content: start; gap: 16px 10px; }
.app { display: grid; justify-items: center; gap: 5px; font-size: 10.5px; color: #fff; text-align: center; }
.app i, .app img { width: 52px; height: 52px; border-radius: 12px; background: rgba(255, 255, 255, .18); display: block; }
.app img { border-radius: 12px; }
.masken { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.maske { display: grid; justify-items: center; gap: 6px; font-size: .8125rem; color: var(--leise); }
.maske__bild { position: relative; width: 100%; max-width: 110px; aspect-ratio: 1; }
.maske__bild img { width: 100%; height: 100%; }
.maske[data-form="kreis"] img { clip-path: circle(50%); }
.maske[data-form="squircle"] img { clip-path: inset(0 round 30%); }
.maske[data-form="tropfen"] img { clip-path: inset(0 round 50% 50% 50% 12%); }
.zone { position: absolute; inset: 10%; border-radius: 50%; border: 1.5px dashed #c0392b; pointer-events: none; opacity: 0; transition: opacity var(--d-auf) var(--kurve); }
.mit-zone .zone { opacity: 1; }
.schalter-zeile { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 12px; }
.schalter-zeile .label { margin: 0; }
.kipp { display: inline-flex; align-items: center; gap: 8px; font-size: .8125rem; min-height: 32px; }
.kipp span[aria-hidden] { width: 34px; height: 20px; border-radius: 10px; background: #b9b0a0; position: relative; transition: background-color var(--d-auf) var(--kurve); }
.kipp span[aria-hidden]::after { content: ''; position: absolute; top: 3px; left: 3px; width: 14px; height: 14px; border-radius: 50%; background: #fff; transition: transform var(--d-auf) var(--kurve); }
.kipp[aria-pressed="true"] span[aria-hidden] { background: var(--gruen); }
.kipp[aria-pressed="true"] span[aria-hidden]::after { transform: translateX(14px); }

/* Dateien */
.dateien { list-style: none; padding: 0; border-top: 1px solid var(--linie); }
.dateien li { display: grid; grid-template-columns: 40px minmax(0, 1fr) auto auto; align-items: center; gap: 4px 14px; padding: 10px 0; border-bottom: 1px solid var(--linie); }
.dateien li img, .dateien li .ico { width: 32px; height: 32px; border-radius: 6px; background: repeating-conic-gradient(#efe9dc 0 25%, #f8f5ee 0 50%) 0 0 / 8px 8px; }
.dateien li .ico { display: grid; place-items: center; font: 600 10px/1 'Geist Mono', monospace; color: var(--leise); }
.dateien b { font-weight: 600; font-size: .9375rem; }
.dateien small { display: block; color: var(--leise); font-size: .8125rem; }
.dateien .mono { color: var(--leise); text-align: right; font-variant-numeric: tabular-nums; }
.laden { display: inline-flex; align-items: center; justify-content: center; min-width: 44px; min-height: 44px; border-radius: 10px; text-decoration: none; transition: background-color var(--d-hover) var(--kurve); }
.laden:hover { background: var(--kraft); }
.laden svg { width: 18px; height: 18px; }

pre { margin: 0; padding: 16px; border-radius: 12px; background: var(--tinte); color: #EDE6D6; font: 12.5px/1.6 'Geist Mono', ui-monospace, monospace; overflow-x: auto; white-space: pre; tab-size: 2; }
pre .k { color: #C9B892; }
.code-kopf { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
.kopieren { display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--linie); font-size: .8125rem; background: #fff; transition: background-color var(--d-hover) var(--kurve); }
.kopieren:hover { background: var(--kraft); }

/* Teilbild */
.seiten { list-style: none; padding: 0; display: grid; gap: 6px; margin-top: 12px; }
.seiten label { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 2px 12px; padding: 10px 12px; border: 1px solid var(--linie); border-radius: 10px; cursor: pointer; transition: border-color var(--d-hover) var(--kurve), background-color var(--d-hover) var(--kurve); }
.seiten label:has(:checked) { border-color: var(--text); background: var(--grund); }
.seiten input { margin-top: 5px; accent-color: var(--gruen); width: 16px; height: 16px; }
.seiten b { font-weight: 600; font-size: .9375rem; }
.seiten small { grid-column: 2; font-size: .8125rem; color: var(--leise); }
.eigen { display: grid; gap: 6px; margin-top: 16px; font-size: .875rem; font-weight: 600; }
.eigen textarea { min-height: 76px; padding: 10px 12px; border: 1px solid #9c9282; border-radius: 10px; background: #fff; resize: vertical; font-weight: 400; font-size: .9375rem; }
.eigen small { font-weight: 400; color: var(--leise); }
.og-buehne { position: relative; border-radius: 14px; overflow: hidden; border: 1px solid var(--linie); background: #fff; }
.og-buehne canvas { width: 100%; height: auto; aspect-ratio: 1200 / 630; }
.og-buehne .masse { position: absolute; right: 10px; top: 10px; padding: 4px 8px; border-radius: 6px; background: rgba(33, 31, 28, .78); color: #fff; font: 500 11px/1 'Geist Mono', monospace; }
.og-leiste { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 10px; margin-top: 12px; }
.knopf { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 10px 18px; border-radius: 999px; background: var(--gruen); color: #fff; font-weight: 600; text-decoration: none; }
.teilen { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; margin-top: 16px; }
.chat { background: #e9e4da; border-radius: 14px; padding: 16px; }
.blase { max-width: 340px; margin-left: auto; background: #d9f2d0; border-radius: 12px 12px 2px 12px; padding: 6px; font-size: .8125rem; box-shadow: 0 1px 1px rgba(0, 0, 0, .08); }
.blase img { border-radius: 8px; width: 100%; aspect-ratio: 1200 / 630; object-fit: cover; }
.blase b { display: block; margin: 6px 6px 0; font-size: .875rem; line-height: 1.3; }
.blase span { display: block; margin: 2px 6px 4px; color: #5d6b58; }
.blase a { display: block; margin: 0 6px 4px; color: #1f5f8b; word-break: break-all; }
.post { background: #fff; border: 1px solid var(--linie); border-radius: 14px; overflow: hidden; font-size: .8125rem; }
.post__kopf { display: flex; align-items: center; gap: 10px; padding: 12px; }
.post__kopf img { width: 36px; height: 36px; border-radius: 50%; }
.post__kopf b { display: block; font-size: .875rem; }
.post > img { width: 100%; aspect-ratio: 1200 / 630; object-fit: cover; border-top: 1px solid var(--linie); }
.post__fuss { padding: 10px 12px 12px; background: #f6f4ef; }
.post__fuss small { color: var(--leise); text-transform: uppercase; letter-spacing: .06em; font-size: 10.5px; }
.post__fuss b { display: block; font-size: .9375rem; line-height: 1.3; }

/* Einbau */
.einbau { margin-top: 48px; border-top: 1px solid var(--linie); padding-top: 32px; display: grid; grid-template-columns: minmax(0, 340px) minmax(0, 1fr); gap: clamp(24px, 4vw, 56px); }
.einbau h2 { font: 400 clamp(1.75rem, 1.3rem + 1.6vw, 2.5rem)/1.1 'Libre Caslon Display', Georgia, serif; }
.einbau ol.schritte { margin: 16px 0 0; padding-left: 1.3em; font-size: .9375rem; display: grid; gap: 8px; }
.einbau .hinweis { margin-top: 16px; padding: 12px 14px; border-radius: 10px; background: var(--kraft); font-size: .875rem; }
.code-reiter { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; }
.code-reiter button { padding: 8px 12px; border-radius: 8px; font: 500 12px/1 'Geist Mono', monospace; border: 1px solid var(--linie); background: #fff; min-height: 36px; }
.code-reiter button[aria-selected="true"] { background: var(--tinte); color: #EDE6D6; border-color: var(--tinte); }

.status { min-height: 1.4em; font-size: .8125rem; color: var(--gruen); }
.ein { animation: ein var(--d-auf) var(--kurve) both; }
@keyframes ein { from { opacity: 0; transform: translateY(6px); } }

/* Pult */
.pult { position: fixed; right: 16px; bottom: 16px; z-index: 30; display: flex; flex-wrap: wrap; gap: 2px; align-items: center; padding: 8px 10px; border-radius: 10px; background: rgba(44, 42, 38, .95); box-shadow: 0 12px 30px -12px rgba(0, 0, 0, .5); }
.pult span { font: 500 10px/1 'Geist Mono', monospace; letter-spacing: .14em; text-transform: uppercase; color: rgba(237, 230, 214, .68); margin-right: 8px; }
.pult button { font: 500 10.5px/1 'Geist Mono', monospace; letter-spacing: .1em; text-transform: uppercase; color: rgba(237, 230, 214, .8); padding: 7px 9px; border-radius: 5px; }
.pult button[aria-pressed="true"] { background: #EDE6D6; color: var(--tinte); }
.pult :focus-visible { outline-color: #fff; }

@media (max-width: 1080px) {
  .raster, .einbau { grid-template-columns: minmax(0, 270px) minmax(0, 1fr); gap: 24px; }
  .kopf { grid-template-columns: minmax(0, 1fr); }
  .reiter { justify-self: start; }
}
@media (max-width: 820px) {
  .raster, .einbau { grid-template-columns: minmax(0, 1fr); }
  .marke-gross { max-width: 280px; }
}
@media (max-width: 480px) {
  .tab:not(.tab--aktiv) { display: none; }
  .tab--aktiv { max-width: none; }
}
@media (max-width: 640px) {
  .vorschauen, .teilen { grid-template-columns: minmax(0, 1fr); }
  .dateien li { grid-template-columns: 40px minmax(0, 1fr) auto; }
  .dateien .mono { display: none; }
  .reiter { display: flex; }
  .reiter button { flex: 1; padding: 10px 8px; }
}
.vorschau body { overflow: hidden; }
.vorschau .einbau { display: none; }
.vorschau .kopf > div > p:not(.label) { display: none; }
.vorschau .kopf h1 { font-size: 2.5rem; max-width: none; }
.vorschau .raster { margin-top: 20px; }

:root.reduziert *, :root.reduziert *::before, :root.reduziert *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; }
@media (hover: none) { .laden:hover, .kopieren:hover { background: none; } }
@media (forced-colors: active) {
  button, [role=button], [role=tab], [role=switch], input, select, textarea { border: 1px solid ButtonText !important; }
  .reiter__marke { background: Highlight; }
}
</style>
</head>
<body>
<main class="seite">
  <header class="kopf">
    <div>
      <p class="label">Grundausstattung · Buchhandlung Falzbein, Kaufbeuren</p>
      <h1>Eine Marke, alle Symbole und ein Teilbild je Seite</h1>
      <p>Favicon für helle und dunkle Tabs, Symbol für den Startbildschirm, maskierbares Icon für Android und für jede Seite ein eigenes Bild, wenn jemand den Link teilt. Alles wird aus derselben Marke erzeugt.</p>
    </div>
    <div class="reiter" role="tablist" aria-label="Teil des Systems">
      <span class="reiter__marke" aria-hidden="true"></span>
      <button type="button" role="tab" id="tab-satz" aria-controls="panel-satz" aria-selected="true" data-v="satz">Favicon-Satz</button>
      <button type="button" role="tab" id="tab-teilbild" aria-controls="panel-teilbild" aria-selected="false" tabindex="-1" data-v="teilbild">Teilbild je Seite</button>
    </div>
  </header>

  <!-- ═════ Favicon-Satz ═════ -->
  <section id="panel-satz" role="tabpanel" aria-labelledby="tab-satz" class="raster">
    <div>
      <div class="karte">
        <h2>Die Marke</h2>
        <p>Ein Zeichen als SVG, quadratisch, mit zwei Farben aus tokens.css.</p>
        <div class="marke-gross" data-marke-gross></div>
        <fieldset class="wahl" data-wahl>
          <legend>Grundfarbe</legend>
          <label><input type="radio" name="grund" value="gruen" checked><i style="background:#2E5B45"></i>Schaufenster</label>
          <label><input type="radio" name="grund" value="tinte"><i style="background:#2C2A26"></i>Tinte</label>
          <label><input type="radio" name="grund" value="kraft"><i style="background:#E2D3B8"></i>Karton</label>
        </fieldset>
      </div>
      <div class="karte">
        <div class="code-kopf"><span class="label">Kopf jeder Seite</span><button class="kopieren" type="button" data-kopieren="kopf-code">Kopieren</button></div>
        <pre id="kopf-code" tabindex="0" aria-label="HTML für den Kopf"></pre>
        <p class="status" role="status" data-status-kopf></p>
      </div>
    </div>
    <div class="vorschauen">
      <div class="feld breit">
        <span class="label">Im Tab, hell und dunkel</span>
        <div class="tabs" data-modus="hell"><div class="tabs__leiste"><div class="tab"><i class="tab--leer"></i><span>Posteingang</span></div><div class="tab tab--aktiv"><img alt="" data-ico="hell"><span>Buchhandlung Falzbein · Lesung am Freitag</span></div><div class="tab"><span>Wetter Kaufbeuren</span></div></div><div class="tabs__adresse">buchhandlung-falzbein.de/lesungen</div></div>
        <div class="tabs" data-modus="dunkel"><div class="tabs__leiste"><div class="tab"><span>Posteingang</span></div><div class="tab tab--aktiv"><img alt="" data-ico="dunkel"><span>Buchhandlung Falzbein · Lesung am Freitag</span></div><div class="tab"><span>Wetter Kaufbeuren</span></div></div><div class="tabs__adresse">buchhandlung-falzbein.de/lesungen</div></div>
        <p class="mono" style="margin-top:10px;color:var(--leise)">Dunkler Tab: icon.svg tauscht per @media (prefers-color-scheme: dark) Grund und Zeichen.</p>
      </div>
      <div class="feld">
        <span class="label">Startbildschirm · apple-touch-icon 180</span>
        <div class="handy" role="img" aria-label="Startbildschirm eines Telefons mit dem Symbol der Buchhandlung Falzbein zwischen anderen Symbolen">
          <div class="app"><i></i>Kamera</div><div class="app"><i></i>Karten</div><div class="app"><img alt="" data-bild="touch"><span>Falzbein</span></div><div class="app"><i></i>Wetter</div>
          <div class="app"><i></i>Notizen</div><div class="app"><i></i>Fotos</div><div class="app"><i></i>Uhr</div><div class="app"><i></i>Bahn</div>
        </div>
      </div>
      <div class="feld" data-masken>
        <div class="schalter-zeile"><span class="label">Maskierbar · 512</span><button class="kipp" type="button" aria-pressed="false" data-zone><span aria-hidden="true"></span>Schutzzone</button></div>
        <div class="masken">
          <div class="maske" data-form="kreis"><div class="maske__bild"><img alt="Maskierbares Symbol als Kreis" data-bild="mask"><span class="zone"></span></div>Kreis</div>
          <div class="maske" data-form="squircle"><div class="maske__bild"><img alt="Maskierbares Symbol als abgerundetes Quadrat" data-bild="mask"><span class="zone"></span></div>Squircle</div>
          <div class="maske" data-form="tropfen"><div class="maske__bild"><img alt="Maskierbares Symbol als Tropfen" data-bild="mask"><span class="zone"></span></div>Tropfen</div>
        </div>
        <p class="mono" style="margin-top:12px;color:var(--leise)">Zeichen im Kreis mit 40 % Radius, Grund bis zum Rand.</p>
      </div>
      <div class="feld breit">
        <span class="label">Erzeugte Dateien · im Browser gerendert</span>
        <ul class="dateien" data-dateien aria-live="polite"></ul>
      </div>
    </div>
  </section>

  <!-- ═════ Teilbild ═════ -->
  <section id="panel-teilbild" role="tabpanel" aria-labelledby="tab-teilbild" class="raster" hidden>
    <div class="karte">
      <h2>Seiten</h2>
      <p>Das Teilbild entsteht aus Rubrik, Titel und Marke. Lange Titel werden kleiner gesetzt und getrennt.</p>
      <ul class="seiten" data-seiten></ul>
      <label class="eigen">Eigener Titel zum Testen
        <textarea data-eigen maxlength="140" placeholder="z. B. Jahreshauptversammlung des Fördervereins der Stadtbücherei"></textarea>
        <small data-eigen-info>Bis 140 Zeichen. Wird beim Tippen neu gesetzt.</small>
      </label>
    </div>
    <div>
      <div class="og-buehne"><canvas width="1200" height="630" data-og role="img" aria-label="Teilbild"></canvas><span class="masse" aria-hidden="true">1200 × 630</span></div>
      <div class="og-leiste"><p class="mono" data-og-info style="color:var(--leise)"></p><a class="knopf" href="#" download="og.png" data-og-laden><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 4v11m-5-5 5 5 5-5M5 20h14"/></svg>PNG laden</a></div>
      <div class="teilen">
        <div class="chat" role="img" aria-label="Vorschau im Messenger: Teilbild mit Titel und Adresse" data-chat></div>
        <div class="post" role="img" aria-label="Vorschau in einem sozialen Netzwerk: Teilbild mit Titel" data-post></div>
      </div>
      <div class="karte" style="margin-top:16px">
        <div class="code-kopf"><span class="label">Meta-Angaben dieser Seite</span><button class="kopieren" type="button" data-kopieren="og-code">Kopieren</button></div>
        <pre id="og-code" tabindex="0" aria-label="Meta-Angaben"></pre>
        <p class="status" role="status" data-status-og></p>
      </div>
    </div>
  </section>

  <!-- ═════ Einbau in den Starter-Generator ═════ -->
  <section class="einbau" aria-labelledby="einbau-h">
    <div>
      <p class="label">Starter-Generator</p>
      <h2 id="einbau-h">So passt es in den Bau</h2>
      <ol class="schritte">
        <li>Die Marke steht einmal in <code>firma.json</code>: Zeichen als SVG, Farben hell und dunkel, Kurzname.</li>
        <li>Ein Bau-Schritt rendert daraus den Satz und für jede Seite das Teilbild (Playwright, gleiche Vorlage wie hier).</li>
        <li>Er schreibt die Kopfzeilen in jede Seite: Symbole, Manifest, og:image mit Breite, Höhe und Alt-Text.</li>
      </ol>
      <p class="hinweis">Vorschlag: <code>oneshot.py</code> erzeugt den Satz bisher nicht. Die Starter haben nur <code>favicon.svg</code> ohne Dunkelmodus.</p>
    </div>
    <div>
      <div class="code-reiter" role="tablist" aria-label="Datei">
        <button type="button" role="tab" aria-selected="true" data-code="firma">firma.json</button>
        <button type="button" role="tab" aria-selected="false" tabindex="-1" data-code="bau">favicon-satz.js</button>
        <button type="button" role="tab" aria-selected="false" tabindex="-1" data-code="manifest">manifest.webmanifest</button>
        <button type="button" role="tab" aria-selected="false" tabindex="-1" data-code="baum">Ausgabe</button>
      </div>
      <pre data-code-feld tabindex="0" role="tabpanel" aria-label="Code"></pre>
    </div>
  </section>
</main>

<div class="pult" role="group" aria-label="Steuerung der Demo"><span aria-hidden="true">Variante</span><button type="button" data-pv="satz" aria-pressed="true">Satz</button><button type="button" data-pv="teilbild" aria-pressed="false">Teilbild</button></div>
<p class="vh" role="status" data-ansage></p>

<script>
(() => {
  const H = document.documentElement;
  const reduziert = () => H.classList.contains('reduziert');
  const $ = (s, r = document) => r.querySelector(s), $$ = (s, r = document) => [...r.querySelectorAll(s)];
  const FARBEN = {
    gruen: { grund: '#2E5B45', zeichen: '#F5F1E8', ecke: '#E2D3B8', dGrund: '#E2D3B8', dZeichen: '#2C2A26', dEcke: '#2E5B45' },
    tinte: { grund: '#2C2A26', zeichen: '#F5F1E8', ecke: '#E2D3B8', dGrund: '#F5F1E8', dZeichen: '#2C2A26', dEcke: '#2E5B45' },
    kraft: { grund: '#E2D3B8', zeichen: '#2C2A26', ecke: '#2E5B45', dGrund: '#2E5B45', dZeichen: '#F5F1E8', dEcke: '#E2D3B8' }
  };
  let farbe = 'gruen', variante = 'satz';
  const urls = [];
  const ansage = t => { const a = $('[data-ansage]'); a.textContent = ''; setTimeout(() => a.textContent = t, 30); };

  /* ── Die Marke als SVG: F aus Flächen, gefalzte Ecke ── */
  function markeSVG({ modus = 'auto', rand = 0, voll = false, radius = 14 } = {}) {
    const f = FARBEN[farbe], s = 64, k = (s - 2 * rand) / 64, r = voll ? 0 : radius;
    const kachel = `<path class="g" d="M${r} 0H45L64 19V${64 - r}${r ? `Q64 64 ${64 - r} 64` : ''}H${r}${r ? `Q0 64 0 ${64 - r}` : 'L0 64'}V${r}${r ? `Q0 0 ${r} 0` : 'L0 0'}Z"/>`;
    const zeichen = '<path class="z" d="M20 15H42V23H28V29.5H39V37.5H28V49H20Z"/>';
    const ecke = '<path class="e" d="M45 0L64 19H49A4 4 0 0 1 45 15Z"/>';
    const grund = voll ? `<rect class="g" width="${s}" height="${s}"/>` : '';
    const tile = `${grund}<g transform="translate(${rand} ${rand}) scale(${k})">${voll ? '' : kachel}${zeichen}${ecke}</g>`;
    const hell = `.g{fill:${f.grund}}.z{fill:${f.zeichen}}.e{fill:${f.ecke}}`;
    const dunkel = `.g{fill:${f.dGrund}}.z{fill:${f.dZeichen}}.e{fill:${f.dEcke}}`;
    const stil = modus === 'hell' ? hell : modus === 'dunkel' ? dunkel : `${hell}@media (prefers-color-scheme: dark){${dunkel}}`;
    return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${s} ${s}"><style>${stil}</style>${tile}</svg>`;
  }
  const svgUrl = svg => 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
  function bild(svg) { return new Promise((ok, nein) => { const i = new Image(); i.onload = () => ok(i); i.onerror = nein; i.src = svgUrl(svg); }); }
  async function png(svg, groesse) {
    const c = document.createElement('canvas'); c.width = c.height = groesse;
    const ctx = c.getContext('2d'); ctx.drawImage(await bild(svg), 0, 0, groesse, groesse);
    return new Promise(ok => c.toBlob(ok, 'image/png'));
  }
  async function ico(blob) {
    const daten = new Uint8Array(await blob.arrayBuffer());
    const kopf = new DataView(new ArrayBuffer(22));
    kopf.setUint16(2, 1, true); kopf.setUint16(4, 1, true);
    kopf.setUint8(6, 32); kopf.setUint8(7, 32); kopf.setUint16(10, 1, true); kopf.setUint16(12, 32, true);
    kopf.setUint32(14, daten.length, true); kopf.setUint32(18, 22, true);
    return new Blob([kopf.buffer, daten], { type: 'image/x-icon' });
  }
  const groesse = n => n < 1024 ? n + ' B' : (n / 1024).toLocaleString('de-DE', { maximumFractionDigits: 1 }) + ' KB';
  const url = b => { const u = URL.createObjectURL(b); urls.push(u); return u; };

  const MANIFEST = () => JSON.stringify({
    name: 'Buchhandlung Falzbein', short_name: 'Falzbein', lang: 'de', start_url: '/', display: 'browser',
    theme_color: FARBEN[farbe].grund, background_color: '#F5F1E8',
    icons: [
      { src: '/icon-192.png', type: 'image/png', sizes: '192x192' },
      { src: '/icon-512.png', type: 'image/png', sizes: '512x512' },
      { src: '/icon-mask.png', type: 'image/png', sizes: '512x512', purpose: 'maskable' }
    ]
  }, null, 2);

  let lauf = 0;
  async function satz() {
    const meinLauf = ++lauf;
    urls.splice(0).forEach(u => URL.revokeObjectURL(u));
    $('[data-marke-gross]').innerHTML = markeSVG({ modus: 'hell' });
    $('[data-ico="hell"]').src = svgUrl(markeSVG({ modus: 'hell' }));
    $('[data-ico="dunkel"]').src = svgUrl(markeSVG({ modus: 'dunkel' }));
    const svgDatei = markeSVG();
    const touch = await png(markeSVG({ modus: 'hell', rand: 64 * 20 / 180, voll: true }), 180);
    const i192 = await png(markeSVG({ modus: 'hell' }), 192);
    const i512 = await png(markeSVG({ modus: 'hell' }), 512);
    const mask = await png(markeSVG({ modus: 'hell', rand: 64 * (512 - 409) / 2 / 512, voll: true, radius: 0 }), 512);
    const i32 = await png(markeSVG({ modus: 'hell' }), 32);
    const icoB = await ico(i32);
    if (meinLauf !== lauf) return;
    const touchU = url(touch), maskU = url(mask);
    $$('[data-bild="touch"]').forEach(i => i.src = touchU);
    $$('[data-bild="mask"]').forEach(i => i.src = maskU);
    const svgB = new Blob([svgDatei], { type: 'image/svg+xml' }), manB = new Blob([MANIFEST()], { type: 'application/manifest+json' });
    const D = [
      ['favicon.ico', '32 × 32, PNG in ICO, für alte Browser und Feeds', icoB, url(i32)],
      ['icon.svg', 'Vektor, mit Dunkelmodus', svgB, svgUrl(svgDatei)],
      ['apple-touch-icon.png', '180 × 180, Zeichen 140, voller Grund', touch, touchU],
      ['icon-192.png', '192 × 192, für das Manifest', i192, url(i192)],
      ['icon-512.png', '512 × 512, für das Manifest', i512, url(i512)],
      ['icon-mask.png', '512 × 512, maskierbar, Zeichen 409', mask, maskU],
      ['manifest.webmanifest', 'Name, Farben, Icons; kein App-Modus', manB, null]
    ];
    $('[data-dateien]').innerHTML = D.map(([n, t, b, v]) => `<li>${v ? `<img src="${v}" alt="">` : '<span class="ico">{ }</span>'}<div><b>${n}</b><small>${t}</small></div><span class="mono">${groesse(b.size)}</span><a class="laden" href="${url(b)}" download="${n}" aria-label="${n} herunterladen"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 4v11m-5-5 5 5 5-5M5 20h14"/></svg></a></li>`).join('');
    $('#kopf-code').innerHTML = [
      '<span class="k">&lt;link</span> rel="icon" href="/favicon.ico" sizes="32x32"&gt;',
      '<span class="k">&lt;link</span> rel="icon" href="/icon.svg" type="image/svg+xml"&gt;',
      '<span class="k">&lt;link</span> rel="apple-touch-icon" href="/apple-touch-icon.png"&gt;',
      '<span class="k">&lt;link</span> rel="manifest" href="/manifest.webmanifest"&gt;',
      `<span class="k">&lt;meta</span> name="theme-color" content="${FARBEN[farbe].grund}"&gt;`
    ].join('\n');
    codeZeigen();
  }

  /* ── Teilbild ── */
  const SEITEN = [
    { id: 'start', rubrik: 'Buchhandlung', titel: 'Bücher, Beratung und Lesungen in der Kaufbeurer Altstadt', pfad: '/', satz: 'Seit 1987 am Obstmarkt. Bestellt bis 18 Uhr, abholbereit am nächsten Morgen.' },
    { id: 'lesung', rubrik: 'Lesung', titel: 'Mira Hollensteiner liest aus „Das Haus am Wertachufer“', pfad: '/lesungen/hollensteiner', datum: 'FR 14.11. · 19:30', satz: 'Eintritt 12 €, Karten im Laden oder online.' },
    { id: 'tipp', rubrik: 'Buchtipp', titel: 'Fünf Romane für lange Novemberabende', pfad: '/tipps/november', satz: 'Unsere Auswahl, von Hand gelesen und für gut befunden.' },
    { id: 'stelle', rubrik: 'Stelle', titel: 'Buchhändlerin oder Buchhändler in Teilzeit gesucht', pfad: '/stellen/buchhandel', satz: '20 bis 30 Stunden, ab Januar, Bewerbung per E-Mail.' }
  ];
  let seite = SEITEN[1], eigen = '';
  $('[data-seiten]').innerHTML = SEITEN.map(s => `<li><label><input type="radio" name="seite" value="${s.id}" ${s === seite ? 'checked' : ''}><b>${s.titel}</b><small>${s.rubrik} · ${s.pfad}</small></label></li>`).join('');

  function zeilen(ctx, text, breite, max) {
    const woerter = text.split(/\s+/); const out = []; let z = '';
    for (let w of woerter) {
      const probe = z ? z + ' ' + w : w;
      if (ctx.measureText(probe).width <= breite) { z = probe; continue; }
      if (z) out.push(z);
      if (ctx.measureText(w).width > breite) {
        let teil = '';
        for (const ch of w) { if (ctx.measureText(teil + ch + '-').width > breite) { out.push(teil + '-'); teil = ''; } teil += ch; }
        z = teil;
      } else z = w;
    }
    if (z) out.push(z);
    return out.length <= max ? out : null;
  }
  async function og() {
    await Promise.all([document.fonts.load('400 80px "Libre Caslon Display"'), document.fonts.load('600 28px "Familjen Grotesk"'), document.fonts.load('500 22px "Geist Mono"')]);
    const c = $('[data-og]'), ctx = c.getContext('2d'), W = 1200, Hh = 630;
    const f = FARBEN[farbe];
    const titel = (eigen.trim() || seite.titel);
    ctx.fillStyle = '#F5F1E8'; ctx.fillRect(0, 0, W, Hh);
    /* Kartonstreifen rechts mit Falz */
    ctx.fillStyle = '#E2D3B8'; ctx.fillRect(W - 260, 0, 260, Hh);
    ctx.fillStyle = '#D3C19F'; ctx.beginPath(); ctx.moveTo(W - 260, 0); ctx.lineTo(W - 150, 0); ctx.lineTo(W - 260, 110); ctx.closePath(); ctx.fill();
    /* Marke */
    const m = await bild(markeSVG({ modus: 'hell' }));
    ctx.drawImage(m, 80, 64, 72, 72);
    ctx.fillStyle = '#211F1C'; ctx.font = '600 28px "Familjen Grotesk"'; ctx.textBaseline = 'middle';
    ctx.fillText('Buchhandlung Falzbein', 172, 100);
    /* Rubrik */
    ctx.font = '500 22px "Geist Mono"'; ctx.fillStyle = '#2E5B45';
    ctx.fillText((eigen.trim() ? 'Seite' : seite.rubrik).toUpperCase().split('').join(String.fromCharCode(8202)), 80, 200);
    /* Titel: 96 bis 52 px, höchstens 3 Zeilen */
    let gr = 96, z = null; const breite = W - 260 - 80 - 64;
    for (; gr >= 52; gr -= 4) { ctx.font = `400 ${gr}px "Libre Caslon Display"`; z = zeilen(ctx, titel, breite, 3); if (z && z.length * gr * 1.08 <= 280) break; z = null; }
    if (!z) { gr = 52; ctx.font = `400 ${gr}px "Libre Caslon Display"`; z = zeilen(ctx, titel, breite, 99).slice(0, 3); z[2] = z[2].replace(/\s*\S*$/, ' …'); }
    ctx.fillStyle = '#211F1C'; ctx.textBaseline = 'alphabetic';
    const lh = gr * 1.08; let y = 232 + gr;
    z.forEach(t => { ctx.fillText(t, 80, y); y += lh; });
    /* Fuß */
    ctx.font = '500 22px "Geist Mono"'; ctx.fillStyle = '#615B51';
    ctx.fillText('buchhandlung-falzbein.de' + (eigen.trim() ? '' : seite.pfad), 80, Hh - 64);
    if (seite.datum && !eigen.trim()) {
      ctx.save(); ctx.translate(W - 130, Hh / 2 + 40);
      ctx.fillStyle = f.grund === '#E2D3B8' ? '#2E5B45' : f.grund; ctx.beginPath(); ctx.arc(0, 0, 96, 0, Math.PI * 2); ctx.fill();
      ctx.fillStyle = '#F5F1E8'; ctx.textAlign = 'center';
      const [tag, zeit] = seite.datum.split(' · ');
      ctx.font = '400 40px "Libre Caslon Display"'; ctx.fillText(tag, 0, 4);
      ctx.font = '500 22px "Geist Mono"'; ctx.fillText(zeit, 0, 40);
      ctx.restore();
    } else {
      ctx.save(); ctx.globalAlpha = .9; ctx.drawImage(m, W - 194, Hh - 194, 128, 128); ctx.restore();
    }
    c.setAttribute('aria-label', 'Teilbild 1200 mal 630: ' + (eigen.trim() ? 'Seite' : seite.rubrik) + ', ' + titel + ', Buchhandlung Falzbein');
    $('[data-og-info]').textContent = `Titel ${gr} px · ${z.length} ${z.length === 1 ? 'Zeile' : 'Zeilen'}`;
    c.toBlob(b => {
      const u = URL.createObjectURL(b); const alt = $('[data-og-laden]');
      if (alt.dataset.u) URL.revokeObjectURL(alt.dataset.u); alt.dataset.u = u; alt.href = u; alt.download = 'og-' + (eigen.trim() ? 'eigen' : seite.id) + '.png';
      $('[data-chat]').innerHTML = `<div class="blase"><img src="${u}" alt=""><b></b><span></span><a href="#"></a></div>`;
      const bl = $('.blase'); bl.querySelector('b').textContent = titel + ' · Buchhandlung Falzbein'; bl.querySelector('span').textContent = eigen.trim() ? 'Beschreibung der Seite' : seite.satz; bl.querySelector('a').textContent = 'https://buchhandlung-falzbein.de' + (eigen.trim() ? '/' : seite.pfad);
      $('[data-post]').innerHTML = `<div class="post__kopf"><img src="${svgUrl(markeSVG({ modus: 'hell' }))}" alt=""><div><b>Leserunde Allgäu</b><small style="color:var(--leise)">teilte einen Link</small></div></div><img src="${u}" alt=""><div class="post__fuss"><small>buchhandlung-falzbein.de</small><b></b></div>`;
      $('.post__fuss b').textContent = titel;
    }, 'image/png');
    const pfad = eigen.trim() ? '/' : seite.pfad, datei = 'og/' + (eigen.trim() ? 'eigen' : seite.id) + '.png';
    const e = s => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
    $('#og-code').innerHTML = [
      `<span class="k">&lt;meta</span> property="og:title" content="${e(titel)}"&gt;`,
      `<span class="k">&lt;meta</span> property="og:url" content="https://buchhandlung-falzbein.de${pfad}"&gt;`,
      `<span class="k">&lt;meta</span> property="og:image" content="https://buchhandlung-falzbein.de/${datei}"&gt;`,
      '<span class="k">&lt;meta</span> property="og:image:width" content="1200"&gt;',
      '<span class="k">&lt;meta</span> property="og:image:height" content="630"&gt;',
      `<span class="k">&lt;meta</span> property="og:image:alt" content="${e(titel)}, Buchhandlung Falzbein"&gt;`,
      '<span class="k">&lt;meta</span> name="twitter:card" content="summary_large_image"&gt;'
    ].join('\n');
  }

  /* ── Einbau-Code ── */
  let codeWahl = 'firma';
  function codeZeigen() {
    const f = FARBEN[farbe];
    const C = {
      firma: `"marke": {\n  "zeichen": "bilder/marke.svg",\n  "kurzname": "Falzbein",\n  "hell":   { "grund": "${f.grund}", "zeichen": "${f.zeichen}", "ecke": "${f.ecke}" },\n  "dunkel": { "grund": "${f.dGrund}", "zeichen": "${f.dZeichen}", "ecke": "${f.dEcke}" },\n  "teilbild": { "vorlage": "teilbild.html", "domain": "buchhandlung-falzbein.de" }\n}`,
      bau: `// Vorschlag: node favicon-satz.js kunden/falzbein\n// liest firma.json, rendert mit Playwright, schreibt Dateien und Kopfzeilen\nconst satz = [\n  ['favicon.ico', 32], ['apple-touch-icon.png', 180, { rand: 20, voll: true }],\n  ['icon-192.png', 192], ['icon-512.png', 512],\n  ['icon-mask.png', 512, { rand: 51.5, voll: true }]\n];\nfor (const [datei, px, opt] of satz) await rendere(markeSvg(firma.marke, opt), px, datei);\n\nfor (const seite of seiten) {            // jede *.html im Ordner\n  const titel = seite.ogTitle || seite.title;\n  await page.setContent(vorlage({ titel, rubrik: seite.rubrik, marke }));\n  await page.waitForFunction(() => document.fonts.status === 'loaded');\n  await page.screenshot({ path: \`og/\${seite.name}.png\`, clip: { x: 0, y: 0, width: 1200, height: 630 } });\n  setzeKopf(seite, { titel, bild: \`og/\${seite.name}.png\` });\n}`,
      manifest: MANIFEST(),
      baum: `kunden/falzbein/\n├─ favicon.ico\n├─ icon.svg               ← hell und dunkel\n├─ apple-touch-icon.png\n├─ icon-192.png\n├─ icon-512.png\n├─ icon-mask.png\n├─ manifest.webmanifest\n└─ og/\n   ├─ index.png\n   ├─ lesungen-hollensteiner.png\n   ├─ tipps-november.png\n   └─ stellen-buchhandel.png`
    };
    $('[data-code-feld]').textContent = C[codeWahl];
  }
  $$('[data-code]').forEach(b => b.addEventListener('click', () => {
    codeWahl = b.dataset.code;
    $$('[data-code]').forEach(x => { x.setAttribute('aria-selected', String(x === b)); x.tabIndex = x === b ? 0 : -1; });
    codeZeigen();
  }));
  pfeile($$('[data-code]'));

  /* ── Bedienung ── */
  function pfeile(liste) {
    liste.forEach((b, i) => b.addEventListener('keydown', e => {
      const n = { ArrowRight: 1, ArrowLeft: -1 }[e.key]; if (!n) return;
      e.preventDefault(); const z = liste[(i + n + liste.length) % liste.length]; z.focus(); z.click();
    }));
  }
  function markeReiter() {
    const b = $(`[role="tab"][data-v="${variante}"]`), m = $('.reiter__marke');
    m.style.width = b.offsetWidth + 'px'; m.style.transform = `translateX(${b.offsetLeft}px)`;
  }
  function setzeVariante(v, still) {
    variante = v === 'teilbild' ? 'teilbild' : 'satz';
    $$('[role="tab"][data-v]').forEach(b => { const an = b.dataset.v === variante; b.setAttribute('aria-selected', String(an)); b.tabIndex = an ? 0 : -1; });
    $('#panel-satz').hidden = variante !== 'satz'; $('#panel-teilbild').hidden = variante !== 'teilbild';
    const p = $(variante === 'satz' ? '#panel-satz' : '#panel-teilbild'); if (!reduziert()) { p.classList.remove('ein'); void p.offsetWidth; p.classList.add('ein'); }
    $$('[data-pv]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.pv === variante)));
    markeReiter();
    if (variante === 'teilbild') og();
    if (!still) { const q = new URLSearchParams(location.search); q.set('v', variante); history.replaceState(null, '', location.pathname + '?' + q.toString().replace(/=(?=&|$)/g, '')); }
  }
  $$('[role="tab"][data-v]').forEach(b => b.addEventListener('click', () => setzeVariante(b.dataset.v)));
  pfeile($$('[role="tab"][data-v]'));
  $$('[data-pv]').forEach(b => b.addEventListener('click', () => setzeVariante(b.dataset.pv)));
  $('[data-wahl]').addEventListener('change', e => { farbe = e.target.value; satz(); if (variante === 'teilbild') og(); ansage('Grundfarbe ' + e.target.parentElement.textContent.trim() + ', alle Dateien neu erzeugt'); });
  $('[data-zone]').addEventListener('click', e => { const b = e.currentTarget, an = b.getAttribute('aria-pressed') !== 'true'; b.setAttribute('aria-pressed', String(an)); $('[data-masken]').classList.toggle('mit-zone', an); });
  $('[data-seiten]').addEventListener('change', e => { seite = SEITEN.find(s => s.id === e.target.value); eigen = ''; $('[data-eigen]').value = ''; og(); });
  let tipp = null;
  $('[data-eigen]').addEventListener('input', e => { eigen = e.target.value; clearTimeout(tipp); tipp = setTimeout(og, 120); });
  $$('[data-kopieren]').forEach(b => b.addEventListener('click', async () => {
    const text = document.getElementById(b.dataset.kopieren).textContent;
    const st = b.closest('.karte').querySelector('.status');
    try { await navigator.clipboard.writeText(text); st.textContent = 'Kopiert.'; } catch (e) { st.textContent = 'Kopieren nicht erlaubt. Bitte den Text markieren.'; }
    setTimeout(() => st.textContent = '', 2400);
  }));
  addEventListener('resize', markeReiter, { passive: true });
  document.fonts.ready.then(markeReiter);

  setzeVariante(new URLSearchParams(location.search).get('v'), true);
  satz();

  let schleife = null, schritt = 0;
  function neustart() { clearTimeout(schleife); farbe = 'gruen'; $('[data-wahl] input[value="gruen"]').checked = true; satz(); if (variante === 'teilbild') { seite = SEITEN[1]; og(); } }
  function vorfuehren() {
    clearTimeout(schleife); schritt++;
    if (variante === 'satz') {
      const f = ['gruen', 'tinte', 'kraft'][schritt % 3]; farbe = f; $(`[data-wahl] input[value="${f}"]`).checked = true; satz();
      const z = $('[data-zone]'); const an = schritt % 2 === 0; z.setAttribute('aria-pressed', String(an)); $('[data-masken]').classList.toggle('mit-zone', an);
    } else {
      seite = SEITEN[schritt % SEITEN.length]; $$('[data-seiten] input').forEach(i => i.checked = i.value === seite.id); og();
    }
    schleife = setTimeout(vorfuehren, 1800);
  }
  function pause() { clearTimeout(schleife); }
  function weiter() { vorfuehren(); }

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