Files
posts/post1.html
T
dejanandClaude Opus 5 1923fbbb15 Ueberschriften nachgezogen, eigenes Blatt fuer die Chat-Slide
Projektmanagement heisst jetzt "wo alle Auftraege stehen", Team-Chat "Projekte
bekommen automatisch ihren Kanal". Beide bleiben zweizeilig; Zeile 2 der
Chat-Slide misst mit Marker 880 px bei 896 verfuegbaren, ein laengeres Wort
bricht dort sofort um.

Das hinterste Blatt im Stapel nahm bisher immer den Screen der naechsten
Feature-Slide. Auf der Chat-Slide war das der Form Builder, und dessen
3,8-facher Zoom kam mit: unscharf, dazu Kopieren- und Papierkorb-Symbole, die
zu einem anderen Feature gehoeren. Das Blatt ist jetzt ueber "back" pro Slide
ueberschreibbar; die Chat-Slide zeigt den Chat im Hochformat ohne Zoom.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 22:16:52 +02:00

2752 lines
123 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>HiveGrid Post 1 (Lösung, Carousel)</title>
<link rel="stylesheet" href="./assets/fonts/manrope.css">
<style>
/* ─── HiveGrid design tokens (mirrored from app/globals.css) ─────────────
Plain hex on purpose — the export libraries choke on oklch()/Tailwind.
--dark-bg #30313D is the colour of the dark story section on the site; it
carries the whole shape layer here. */
:root {
--accent-yellow: #F1B729;
--accent-teal: #0D6E6E;
--surface: #FFFFFF;
--bg-primary: #FAFAFA;
--dark-bg: #30313D;
--accent-yellow-light: #FFE082;
--accent-yellow-dark: #3E2C0A;
--text-primary: #1A1A1A;
--text-secondary:#555555;
--text-on-dark: #C8D0DC;
--text-white: #FFFFFF;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { overscroll-behavior: none; }
body {
font-family: 'Manrope', system-ui, sans-serif;
background: #17181F; color: #E8EAEE;
height: 100vh; overflow: hidden; display: flex;
}
/* ─── Projekt-Navigation ─────────────────────────────────────────────── */
.projects { display: flex; gap: 6px; margin-bottom: 18px; }
.projects a {
flex: 1; text-align: center; text-decoration: none;
font-size: 12px; font-weight: 700; padding: 9px 8px; border-radius: 8px;
background: #22242C; color: #8A8F9B; border: 1px solid #2E313B;
}
.projects a:hover { background: #2A2D36; color: #C7CCD6; }
.projects a.here { background: var(--accent-yellow); color: #1A1A1A; border-color: var(--accent-yellow); }
.saved {
font-size: 11px; color: #6FCF97; opacity: 0; transition: opacity .2s;
margin-left: 6px;
}
.saved.on { opacity: 1; }
.forget { font-size: 11px; color: #6E7480; background: none; border: 0;
cursor: pointer; padding: 0; font-family: inherit; }
.forget:hover { color: #EB7C6B; }
/* ─── Control panel ──────────────────────────────────────────────────── */
.panel {
width: 306px; flex: none; height: 100vh; overflow-y: auto;
padding: 28px 22px 40px 28px;
border-right: 1px solid #24262F; background: #17181F;
}
.panel h1 { font-size: 19px; font-weight: 800; letter-spacing: -0.01em; margin-bottom: 4px; }
.panel .sub { font-size: 13px; color: #8A8F9B; margin-bottom: 22px; line-height: 1.5; }
.panel a { color: #8A8F9B; }
.group { margin-bottom: 20px; }
.group > label,
.group > summary {
display: block; font-size: 11px; font-weight: 700; letter-spacing: 0.09em;
text-transform: uppercase; color: #6E7480; margin-bottom: 9px;
}
/* Zugeklappte Kategorien: der Marker ersetzt den Standard-Pfeil, damit die
Zeile bündig zur offenen Gruppe bleibt. */
details.group > summary {
cursor: pointer; list-style: none; margin-bottom: 0;
padding: 5px 0; display: flex; align-items: center; gap: 7px;
transition: color .15s;
}
details.group > summary::-webkit-details-marker { display: none; }
details.group > summary::before {
content: "▸"; font-size: 10px; color: #4E5360; transition: transform .15s;
}
details.group[open] > summary::before { transform: rotate(90deg); }
details.group > summary:hover { color: #C7CCD6; }
details.group[open] > summary { color: #8A8F9B; margin-bottom: 9px; }
details.group { margin-bottom: 4px; }
details.group[open] { margin-bottom: 20px; }
.opts { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
.opts.one { grid-template-columns: 1fr; }
.opts.three { grid-template-columns: 1fr 1fr 1fr; }
.opts button {
font-family: inherit; font-size: 12.5px; font-weight: 600;
padding: 10px; border-radius: 8px; cursor: pointer; text-align: left;
background: #22242C; color: #C7CCD6; border: 1px solid #2E313B;
transition: background .15s, border-color .15s, color .15s;
display: flex; align-items: center; gap: 8px;
}
.opts.three button { justify-content: center; text-align: center; padding: 10px 4px; }
.opts button:hover { background: #2A2D36; }
.opts button[aria-pressed="true"] {
background: var(--accent-yellow); color: #1A1A1A; border-color: var(--accent-yellow);
}
.num {
width: 18px; height: 18px; border-radius: 4px; flex: none;
display: grid; place-items: center; font-size: 10.5px; font-weight: 800;
background: rgba(255,255,255,.09);
}
button[aria-pressed="true"] .num { background: rgba(0,0,0,.16); }
.swatch { width: 12px; height: 12px; border-radius: 3px; flex: none; border: 1px solid rgba(0,0,0,.18); }
.row { display: flex; gap: 8px; }
.row button, .btn {
font-family: inherit; font-size: 13px; font-weight: 700;
padding: 12px 13px; border-radius: 8px; cursor: pointer; border: none;
background: var(--accent-yellow); color: #1A1A1A; flex: 1; transition: filter .15s;
}
.row button:hover, .btn:hover { filter: brightness(1.07); }
.btn.secondary { background: #22242C; color: #C7CCD6; border: 1px solid #2E313B; }
.btn:disabled { opacity: .45; cursor: not-allowed; filter: none; }
.keys { font-size: 12px; color: #6E7480; line-height: 1.85; }
.keys kbd {
background: #22242C; border: 1px solid #343843; border-bottom-width: 2px;
border-radius: 4px; padding: 1px 6px; font-family: ui-monospace, monospace;
font-size: 11px; color: #C7CCD6;
}
.edit-info {
font-size: 12px; line-height: 1.55; color: #C7CCD6;
background: #1D1F26; border: 1px solid #2E313B; border-radius: 8px;
padding: 9px 11px; margin-bottom: 9px; min-height: 56px;
}
.edit-info .dim { color: #6E7480; }
/* Ziehen ist gut fuer grob, tippen fuer genau. Ohne Eingabefelder laesst
sich ein Wert nicht ueber mehrere Slides hinweg exakt gleich setzen. */
.edit-info .fields {
display: grid; grid-template-columns: 42px 1fr 20px 1fr;
gap: 6px 7px; align-items: center; margin-top: 8px;
}
.edit-info label { color: #6E7480; font-size: 11px; }
.edit-info input {
width: 100%; box-sizing: border-box;
background: #14161C; border: 1px solid #343845; border-radius: 6px;
color: #E7EAF0; font: inherit; font-variant-numeric: tabular-nums;
padding: 4px 6px; text-align: right; -moz-appearance: textfield;
}
.edit-info input::-webkit-outer-spin-button,
.edit-info input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.edit-info input:focus { outline: none; border-color: var(--accent-yellow); }
.edit-info .hint { color: #6E7480; margin-top: 6px; }
.std { margin-bottom: 22px; }
.std summary {
cursor: pointer; font-size: 11px; font-weight: 700; letter-spacing: .09em;
text-transform: uppercase; color: #6E7480; margin-bottom: 9px;
}
.std table { width: 100%; border-collapse: collapse; font-size: 11.5px; color: #8A8F9B; }
.std td { padding: 3px 0; }
.std td:last-child { text-align: right; color: #C7CCD6; font-variant-numeric: tabular-nums; }
.std h4 { font-size: 11px; color: #6E7480; margin: 10px 0 4px; font-weight: 700; }
.status { margin-top: 12px; font-size: 12.5px; color: #6E7480; min-height: 18px; }
.status.ok { color: #6FCF97; }
.status.err { color: #EB7C6B; }
/* ─── Stage ──────────────────────────────────────────────────────────── */
/* ─── Arbeitsfläche ───────────────────────────────────────────────────────
Klar abgesetzt von der Sidebar: eigener Grund, eigener Scroll, eigener
Zoom. Was hier passiert, lässt die Bedienleiste unberührt. */
.stage {
flex: 1; position: relative; height: 100vh; overflow: hidden;
background-color: #101116;
background-image: radial-gradient(circle, #282B35 1.6px, transparent 1.6px);
background-size: 24px 24px;
}
/* overscroll-behavior contain hält die Wisch-Geste des Browsers zurück:
Ohne das springt horizontales Trackpad-Scrollen am linken Rand auf den
vorigen Tab, statt im Canvas zu bleiben. */
.scroller {
position: absolute; inset: 0; overflow: auto;
overscroll-behavior: contain;
}
.scroller.panning { cursor: grabbing; }
.scroller.panready { cursor: grab; }
/* width/height: max-content ist hier entscheidend. Mit reinem
"justify-content: center" ragt breiter Inhalt über beide Seiten hinaus,
aber scrollLeft kann nicht negativ werden: Der linke Teil wäre dann
unerreichbar. So wächst die Fläche mit dem Inhalt, das Zentrieren greift
nur noch, solange etwas Platz übrig ist. */
.canvas {
width: max-content; min-width: 100%;
height: max-content; min-height: 100%;
box-sizing: border-box; padding: 60px;
display: flex; align-items: center; justify-content: center;
}
.hud {
position: absolute; left: 0; right: 0; bottom: 0;
display: flex; align-items: center; justify-content: space-between;
gap: 16px; padding: 14px 20px; pointer-events: none;
background: linear-gradient(transparent, rgba(16,17,22,.92) 55%);
}
.hud > * { pointer-events: auto; }
.stage-meta { font-size: 12px; color: #6E7480; letter-spacing: .04em; }
.zoom { display: flex; align-items: center; gap: 4px; }
.zoom button {
font-family: inherit; font-size: 13px; font-weight: 700; cursor: pointer;
background: #1D1F26; color: #C7CCD6; border: 1px solid #2E313B;
border-radius: 7px; padding: 6px 10px; min-width: 32px;
}
.zoom button:hover { background: #262932; }
.zoom .level { min-width: 58px; text-align: center; color: #8A8F9B; font-variant-numeric: tabular-nums; }
/* Merkzettel neben der Zoomleiste: ein Stand, den man von Hand ablegt und
bewusst zurueckholt. Der laufende Projektstand speichert dauernd von
selbst und taugt deshalb nicht als Rueckfallpunkt. */
.snap { display: flex; align-items: center; gap: 4px; margin-right: 10px; }
.snap button {
font-family: inherit; font-size: 13px; font-weight: 600; cursor: pointer;
background: #1D1F26; color: #C7CCD6; border: 1px solid #2E313B;
border-radius: 7px; padding: 6px 11px;
}
.snap button:hover { background: #262932; }
.snap button:disabled { opacity: .4; cursor: default; }
/* Der Streifen zeigt alle Slides nebeneinander und lückenlos, damit der
durchgehende Aufbau beim Arbeiten sichtbar ist. Der Rahmen reserviert die
skalierte Fläche; die Slides selbst bleiben immer echte 1080 x 1350. */
.strip-wrap { overflow: hidden; flex: none; align-self: flex-start; }
/* Bei gebrochenem Maßstab (etwa 41 %) ist eine Slide 442,8 px breit. Der
Browser rundet jede Box einzeln, und zwischen ihnen blitzt für Sekunden-
bruchteile eines Pixels der dunkle Grund durch. Der Streifen bekommt
deshalb die Slide-Farbe als Untergrund: Die Fuge zeigt dann dieselbe Farbe
und verschwindet. Im Export gibt es sie ohnehin nicht, dort wird jede
Slide einzeln in exakten 1080 x 1350 gerendert. */
.strip { display: flex; transform-origin: top left; background: #30313D; }
.holder { position: relative; flex: none; cursor: pointer; }
/* Auswahlrahmen sitzt auf dem Holder, nicht auf der Slide, damit er nie
im Export landet. */
.holder.sel::after {
content: ""; position: absolute; inset: 0; pointer-events: none;
outline: 10px solid var(--accent-yellow); outline-offset: -5px;
}
.strip[data-view="einzeln"] .holder { display: none; }
.strip[data-view="einzeln"] .holder.sel { display: block; }
/* ─── Slide base — exactly Instagram 4:5 (1080 × 1350) ───────────────── */
.slide {
width: 1080px; height: 1350px;
position: relative; overflow: hidden;
font-family: 'Manrope', system-ui, sans-serif;
}
/* ─── Background layers ──────────────────────────────────────────────────
Five interchangeable grounds. Shapes are built from the brand geometry
(chamfered blocks + flat-top hexagons) in --dark-bg, the colour of the dark
story section on the site. */
.bg { position: absolute; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
.slide[data-int="dezent"] .bg { --int-mul: .55; }
.slide[data-int="kraeftig"] .bg { --int-mul: 1; }
.bg > * { display: none; }
.slide[data-bg="formen"] .layer-shapes,
.slide[data-bg="formen-g"] .layer-shapes,
.slide[data-bg="formen-g"] .layer-gmark,
.slide[data-bg="nur-g"] .layer-shapes,
.slide[data-bg="nur-g"] .layer-gmark,
.slide[data-bg="waben"] .layer-waben,
.slide[data-bg="punkte"] .layer-punkte,
.slide[data-bg="raster"] .layer-raster { display: block; }
.layer-shapes, .layer-waben, .layer-punkte, .layer-raster {
position: absolute; inset: 0; width: 100%; height: 100%;
}
.layer-gmark { inset: auto; }
.slide[data-bg="nur-g"] .sh-block { display: none; }
/* Optische Ausrückung des Tags: Der Kasten steht bündig zur Inhaltskante,
sein TEXT dadurch um die 20 px Innenabstand eingerückt gegenüber der
Headline. Das negative Margin holt den Text auf dieselbe Linie. Zum
Vergleichen umschaltbar. */
.slide[data-tagout="an"] .badge { margin-left: -20px; }
.sh { position: absolute; }
/* Two large forms anchored to opposite corners and mostly off canvas, plus
the yellow hex as the one warm accent. Everything stays clear of the copy:
shapes crossing the headline are what made an earlier version look thrown
together. */
.sh-block {
top: -300px; right: -290px; width: 760px; height: 760px;
background: var(--shape-fill);
transform: rotate(-14deg);
clip-path: polygon(
74px 0, calc(100% - 74px) 0, 100% 74px, 100% calc(100% - 74px),
calc(100% - 74px) 100%, 74px 100%, 0 calc(100% - 74px), 0 74px
);
}
.sh-hex-line {
bottom: -300px; left: -260px; width: 660px; height: 572px;
opacity: var(--int-mul, 1);
border: 14px solid var(--shape-line);
clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
}
.sh-hex-accent {
top: 268px; left: -66px; width: 172px; height: 149px;
opacity: var(--int-mul, 1);
background: var(--shape-accent);
clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
}
.sh-hex-small {
bottom: 296px; right: -40px; width: 108px; height: 94px;
opacity: var(--int-mul, 1);
background: var(--shape-accent-2);
clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
}
/* Oversized G behind the device, cropped by whatever sits in front of it, so
only its top peeks out. Same idea as the watermark on the dark comparison
card on the site, just in grey. Lives in the background layer (z-index 0),
so the mockup always covers its lower half. */
.layer-gmark {
position: absolute; left: 50%; top: 400px;
width: 880px; height: auto;
transform: translateX(-50%);
color: var(--gmark-color);
opacity: var(--int-mul, 1);
}
/* ─── Durchgehendes Carousel ──────────────────────────────────────────────
Der Post ist ein einziges 5400 x 1350 grosses Blatt, das in fünf 1080er
Fenster geschnitten wird. Jede Slide zeigt ihren Ausschnitt, indem die
Ebene um index * 1080 nach links verschoben wird. Elemente auf den Nähten
werden dadurch zerschnitten und laufen auf der nächsten Slide weiter. */
.continuum {
position: absolute; top: 0; height: 100%;
left: calc(var(--slide-i, 0) * -1080px);
width: 5400px;
pointer-events: none;
opacity: var(--int-mul, 1);
}
.slide[data-cont="aus"] .continuum { display: none; }
.continuum > * { position: absolute; }
/* Sechsecke sitzen mittig auf den Nähten (1080, 2160, 3240, 4320) und werden
dort halbiert. Das ist das deutlichste Signal, dass es weitergeht. */
.ct-hex {
background: var(--shape-fill);
clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
}
.ct-hex-line {
border: 13px solid var(--shape-line);
clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
}
/* Ein Band über die volle Breite: die Klammer, die alle fünf zusammenhält. */
.ct-band {
left: 0; width: 5400px; height: 54px; bottom: 96px;
background: var(--shape-line);
}
.ct-g { color: var(--gmark-color); }
.layer-waben { color: var(--pat-color); opacity: calc(var(--pat-strength) * var(--int-mul, 1)); }
.layer-punkte {
background-image: radial-gradient(circle, var(--pat-color) 3px, transparent 3px);
background-size: 52px 52px;
opacity: calc(var(--pat-strength) * var(--int-mul, 1));
}
.layer-raster {
background-image:
linear-gradient(var(--pat-color) 2px, transparent 2px),
linear-gradient(90deg, var(--pat-color) 2px, transparent 2px);
background-size: 78px 78px;
opacity: calc(var(--pat-strength) * var(--int-mul, 1));
}
/* ─── Bearbeitung im Canvas ───────────────────────────────────────────────
Verschieben läuft über --ex/--ey, also über transform statt über den
Textfluss: So verschiebt ein Element nie seine Nachbarn und lässt sich
jederzeit exakt auf 0 zurücksetzen. Elemente mit eigener Transformation
komponieren die Offsets in ihre bestehende Regel hinein. */
[data-edit] { transform: translate(var(--ex, 0px), var(--ey, 0px)) rotate(var(--rot, 0deg)); }
.slide[data-editmode="an"] [data-edit] { cursor: grab; }
.slide[data-editmode="an"] [data-edit]:hover { outline: 3px dashed rgba(241,183,41,.75); outline-offset: 6px; }
.slide[data-editmode="an"] [data-edit].picked { outline: 4px solid var(--accent-yellow); outline-offset: 6px; }
/* ─── Hilfslinien beim Ziehen ────────────────────────────────────────────
Nur solange gezogen wird und nur auf der Slide, an der gezogen wird: das
Raster deckt die 1080 × 1350 ab, nicht das Canvas. Ein Raster über alle
Slides hinweg hilft nicht, weil jede Slide ihre eigene Bühne ist.
pointer-events bleiben aus, sonst reisst die Ebene den Zug ab. */
.guides { position: absolute; inset: 0; z-index: 9; display: none; pointer-events: none; }
.slide[data-guides="an"] .guides { display: block; }
/* 90 px = 1080/12 = 1350/15, teilt beide Kanten ohne Rest. */
.guides .grid {
position: absolute; inset: 0;
background-image:
repeating-linear-gradient(to right, var(--guide-line) 0 1px, transparent 1px 90px),
repeating-linear-gradient(to bottom, var(--guide-line) 0 1px, transparent 1px 90px);
}
/* Der Innenabstand aus den Regeln, damit man sieht, wann Text ihn verlässt. */
.guides .safe { position: absolute; inset: 92px; border: 2px dashed var(--guide-safe); }
.guides .ax { position: absolute; background: var(--guide-axis); }
.guides .ax-v { left: 50%; top: 0; bottom: 0; width: 2px; margin-left: -1px; }
.guides .ax-h { top: 50%; left: 0; right: 0; height: 2px; margin-top: -1px; }
/* Liegt die Mitte des Elements auf der Achse, wird sie kräftig: das ist die
Rückmeldung, auf die es beim Zentrieren ankommt. */
.guides .ax.on { background: var(--accent-yellow); }
.guides .ax-v.on { width: 4px; margin-left: -2px; }
.guides .ax-h.on { height: 4px; margin-top: -2px; }
/* Fadenkreuz des gezogenen Elements, in Slide-Koordinaten gesetzt. */
.guides .mid { position: absolute; background: var(--guide-mid); }
.guides .mid-v { top: 0; bottom: 0; width: 2px; margin-left: -1px; }
.guides .mid-h { left: 0; right: 0; height: 2px; margin-top: -1px; }
/* Jede Slide trägt nicht nur ihren eigenen Inhalt, sondern auch den ihrer
Nachbarn, verschoben um deren Position. Dadurch läuft ein nach links
geschobenes Element aus Slide 3 tatsächlich in Slide 2 hinein, statt am
Rand zu verschwinden. Der Hintergrund bleibt pro Slide, die Identität
also erhalten. */
.sheet {
position: absolute; top: 0; height: 100%;
left: calc(var(--slide-i, 0) * -1080px);
width: 5400px; z-index: 1;
}
.page { position: absolute; top: 0; width: 1080px; height: 1350px; }
.content { position: relative; z-index: 2; padding: 92px 92px 0; }
/* Section badge, straight from the site: .chamfer-badge in globals.css with
accent-yellow-light on accent-yellow-dark. Scaled from the 12px web size to
this canvas; the 5px corner cut scales with it. */
.badge {
display: inline-block; margin-bottom: 38px;
font-size: 26px; font-weight: 600; letter-spacing: 0.1em;
padding: 9px 20px;
background: var(--accent-yellow-light);
color: var(--accent-yellow-dark);
clip-path: polygon(
11px 0, calc(100% - 11px) 0, 100% 11px, 100% calc(100% - 11px),
calc(100% - 11px) 100%, 11px 100%, 0 calc(100% - 11px), 0 11px
);
}
.headline {
font-size: 74px; font-weight: 800;
letter-spacing: -0.03em; line-height: 1.06;
color: var(--head-color);
}
.headline .a, .title-head .a, .wabe-tagline .a {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 48' preserveAspectRatio='none'%3E%3Cpath d='M1.5,12 C58,4.5 168,9.5 312,5 C316,7 318,11 317.5,16 C318.5,26 316,36 313,42.5 C232,47.5 104,41.5 6.5,44.5 C2.5,42 0.5,36 1,29 C0.5,22 0.8,16.5 1.5,12 Z' fill='%23F1B729'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: 0 68%;
background-size: 100% 62%;
-webkit-box-decoration-break: clone; box-decoration-break: clone;
padding: 0 14px; margin: 0 -6px;
}
.body {
margin-top: 30px; font-size: 30px; font-weight: 500;
line-height: 1.48; color: var(--body-color); max-width: 830px;
}
/* ─── Device mockup ──────────────────────────────────────────────────────
A chamfered frame, not a photoreal laptop: the website already frames every
screenshot this way (.screenshot-frame in globals.css), so this is the
on-brand shape and it ties the posts to the site. */
.mock {
position: absolute; z-index: 1;
left: var(--mock-left, 92px);
width: var(--mock-w, 1090px); /* default bleeds ~100px past the right edge */
bottom: 96px;
padding: 12px;
background: var(--frame-bg);
clip-path: polygon(
22px 0, calc(100% - 22px) 0, 100% 22px, 100% calc(100% - 22px),
calc(100% - 22px) 100%, 22px 100%, 0 calc(100% - 22px), 0 22px
);
/* Rahmenform ist umschaltbar. Bei "rund" muss clip-path weichen, sonst
gewinnt der Schnitt gegen border-radius; bei "aus" fällt auch Polsterung
und Fläche weg.
Kein Schatten: die Gestaltung arbeitet flächig, Tiefe entsteht über
Überlappung und Rahmenfarbe, nicht über Weichzeichnung. */
}
.slide[data-tilt="an"] .mock {
transform: translate(var(--ex, 0px), var(--ey, 0px))
perspective(2600px) rotateY(-7deg) rotateX(2.5deg)
rotate(calc(-1.2deg + var(--rot, 0deg)));
}
.mock-inner {
position: relative; overflow: hidden;
aspect-ratio: var(--mock-ar, 1.6); width: 100%;
background: #fff;
clip-path: polygon(
14px 0, calc(100% - 14px) 0, 100% 14px, 100% calc(100% - 14px),
calc(100% - 14px) 100%, 14px 100%, 0 calc(100% - 14px), 0 14px
);
}
/* Ein zweites Bild pro Slide. Es ist bewusst wieder ein .mock, damit alle
Rahmenformen, Schatten und Perspektiven ohne Sonderregeln greifen; nur
Position, Größe und Stapelebene kommen von aussen. */
.mock-2 {
z-index: var(--m2-z, 2);
bottom: var(--m2-bottom, 96px);
}
.slide[data-frame="stapel"] .mock-2 .sheet-back,
.slide[data-frame="stapel"] .mock-2 .sheet-mid { display: none; }
/* ─── Notizzettel ────────────────────────────────────────────────────────
Nachbau der StickyNote aus dem Pitch-Projekt
(ui/components/StickyNote/StickyNote.tsx), Variante "purple". Farbwerte
und Aufbau sind von dort übernommen, die Maße hochskaliert: die Vorlage
ist 200 px breit bei 14 px Text, hier liegt sie auf einem 1080er Blatt.
Bewusst echtes HTML statt Screenshot, damit die Kanten beim Export
genauso scharf sind wie die Schrift und der Text austauschbar bleibt. */
.note {
position: absolute; z-index: var(--note-z, 3);
left: var(--note-left, 60px);
bottom: var(--note-bottom, 150px);
width: var(--note-w, 320px);
border-radius: 10px; overflow: hidden;
font-family: Manrope, sans-serif;
}
.note-head {
height: 50px; background: #ce93d8;
display: flex; align-items: center; justify-content: flex-end;
padding-right: 12px;
}
.note-head svg { width: 26px; height: 26px; display: block; }
.note-body {
background: #f3e5f5; color: #424242;
padding: 18px 20px 26px; min-height: 168px;
font-size: 23px; font-weight: 400; line-height: 1.5;
}
/* Der Anfasser unten rechts: zwei ineinanderliegende Ecken, wie im Original
zwei Spans mit border-b/border-r in zwei Deckkraften. */
.note-grip { position: absolute; right: 9px; bottom: 9px; }
.note-grip i {
position: absolute; right: 0; bottom: 0; display: block;
border-right: 2px solid rgba(66,66,66,.45);
border-bottom: 2px solid rgba(66,66,66,.45);
}
.note-grip i:first-child { width: 17px; height: 17px; }
.note-grip i:last-child {
width: 10px; height: 10px;
border-right-color: rgba(66,66,66,.65);
border-bottom-color: rgba(66,66,66,.65);
}
.slide[data-frame="rund"] .mock { clip-path: none; border-radius: 30px; }
.slide[data-frame="rund"] .mock-inner { clip-path: none; border-radius: 20px; }
.slide[data-frame="aus"] .mock {
clip-path: none; padding: 0; background: none; border-radius: 0;
}
.slide[data-frame="aus"] .mock-inner { clip-path: none; border-radius: 0; }
/* ─── Stapel ─────────────────────────────────────────────────────────────
Zwei versetzte Blätter hinter dem Screenshot. Der Rahmen selbst fällt weg.
Getrennt werden die Blätter nicht durch Schatten, sondern durch den
Versatz und eine Haarlinie: auf der hellen Variante läge sonst Weiss auf
Weiss und der Stapel wäre nicht mehr zu erkennen. */
.sheet-back, .sheet-mid {
display: none; position: absolute; inset: 0; z-index: 0;
background: #fff; border-radius: 22px; overflow: hidden;
border: 1px solid rgba(48,49,61,.18);
}
.slide[data-frame="stapel"] .mock {
clip-path: none; padding: 0; background: none;
}
.slide[data-frame="stapel"] .mock-inner {
clip-path: none; border-radius: 22px; z-index: 2;
border: 1px solid rgba(48,49,61,.18);
}
.slide[data-frame="stapel"] .sheet-mid {
display: block; z-index: 1;
transform: rotate(-2.4deg) translate(-2.2%, 3%) scale(.975);
}
.slide[data-frame="stapel"] .sheet-back {
display: block;
transform: rotate(3.2deg) translate(3%, 5.5%) scale(.95);
}
/* Das hinterste Blatt zeigt bewusst einen anderen Screen: der Stapel soll
nach mehreren Ansichten aussehen, nicht nach einer Kopie. */
.sheet-back img {
position: absolute; top: 0; left: 0; width: 100%;
transform-origin: top left;
}
/* Screens are ~2000px wide landscape captures; showing them whole makes the
UI text unreadable at this size, so each one is zoomed onto its telling
region via the per-slide vars below. */
.mock-inner img {
position: absolute; top: 0; left: 0; width: 100%;
transform-origin: top left;
transform: scale(var(--img-scale, 1)) translate(var(--img-x, 0%), var(--img-y, 0%));
}
/* ─── Title layout "wabe" ────────────────────────────────────────────────
The screenshot sits inside the brand hexagon. Its proportions are taken
from the logo mark itself (384.5 x 332.9, i.e. 2/sqrt3), so the shape is a
true regular hexagon rather than a squashed one. */
.hex-frame {
padding: 15px; background: var(--frame-bg);
clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
}
.hex-shot {
position: relative; overflow: hidden;
width: 748px; height: 648px;
background: #fff;
clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
}
.hex-shot img {
position: absolute; top: 0; left: 0; width: 100%;
transform-origin: top left;
transform: scale(1.533) translate(-17.4%, 0%);
}
/* Laptop mockup. The display was knocked out of the PNG, so the screenshot
sits BEHIND the device and the notch plus the rounded corners stay on top,
the way a real mockup reads. Rect measured from the file: 739 x 489 of
1050 x 700. */
.laptop { position: relative; width: 960px; }
.laptop-frame { width: 100%; display: block; position: relative; z-index: 2; }
.laptop-screen {
position: absolute; z-index: 1; overflow: hidden;
left: 14.857%; top: 11.857%; width: 70.381%; height: 69.857%;
background: #fff;
}
/* Scoped to the screenshot: an unscoped ".laptop-screen img" also caught the
wordmark in the logo variant and forced it to width 100% at top left. */
.laptop-screen > img.laptop-shot {
position: absolute; top: 0; left: 0; width: 100%;
transform-origin: top left;
transform: scale(1.566) translate(-18.1%, 0%);
}
/* Blurred variant: just enough softening to seat the wordmark, the app stays
readable underneath. Slightly overscaled so the blur never pulls the empty
edge of the clip box into frame, and re-centred for that scale. */
.laptop-screen[data-blur="an"] > img.laptop-shot {
filter: blur(5px);
transform: scale(1.04) translate(-1.9%, -2.2%);
}
/* Variant with the wordmark on the display: dead centre, same 430px as the
lockup in the plain laptop layout. No scrim, the screenshot stays as it is. */
.laptop-screen > img.screen-logo {
position: absolute; z-index: 2;
left: 50%; top: 50%;
transform: translate(-50%, -50%) translate(var(--ex, 0px), var(--ey, 0px));
width: 430px; height: auto;
}
/* ─── Szenen-Mockup ──────────────────────────────────────────────────────
Macbook Air + iPad Pro in Perspektive (Pixeden-PSD, Hintergrund entfernt,
beide Displays ausgeknockt). Anders als beim frontalen Laptop reicht kein
Rechteck: die Displayflächen sind Vierecke. Die Screens werden deshalb
per matrix3d hineinprojiziert. Die Matrizen wurden aus den Eckpunkten der
Smart-Objekte im PSD gerechnet und bilden Design-Pixel auf Bildpixel des
5742 px breiten Originals ab; --sk skaliert von dort auf die Breite in
der Slide und wird beim Anwenden der Größe gesetzt. */
.szene { position: relative; width: 980px; }
.szene-dev { width: 100%; display: block; position: relative; z-index: 2; }
.szene-screen {
position: absolute; top: 0; left: 0; z-index: 1;
overflow: hidden; background: #fff;
transform-origin: 0 0;
}
.szene-mb {
width: 3456px; height: 2234px;
transform: scale(var(--sk, .1707))
matrix3d(0.853327,-0.145151,0,-0.000016242,
-0.025684,1.027061,0,-0.000007324,
0,0,1,0, 1614.15,493.94,0,1);
}
/* Das iPad-Viereck stammt aus der Glasflaeche, nicht aus dem Smart-Object
des PSD: das sass zu hoch und liess unten einen dreimal so breiten
schwarzen Streifen stehen. Die Glasecken sind ueber Ausgleichsgeraden auf
den vier Kanten bestimmt und geschnitten, nicht ueber Extrempunkte: die
lagen auf den Rundungen und ergaben ein zu kleines, schiefes Viereck.
Glas 1514 x 2240 mit Eckradius 95, davon 38 px Rand nach innen, also
Display 1438 x 2164 mit Radius 57. Der Radius ist konzentrisch, aussen
minus Randbreite, damit der schwarze Rand ueberall gleich breit bleibt. */
.szene-ip {
width: 1438px; height: 2164px;
border-radius: 57px; overflow: hidden;
transform: scale(var(--sk, .1707))
matrix3d(0.852061,-0.170328,0,-0.000024605,
0.005789,0.984772,0,0.000001055,
0,0,1,0, 4244.78,536.57,0,1);
}
/* Die Aufnahme fuellt das Display randlos, wie auf einem echten Bildschirm.
Vorher stand sie als kleineres Rechteck an per Hand gemessenen
Prozentwerten darin (Breite 78,877 %, Versatz 2,715 % / 14,645 %,
abgeleitet aus einem angenommenen "groessten vollstaendig sichtbaren
Rechteck" von 2726 x 1682 bei 74/154). Zwei Fehler steckten darin:
Die Konstanten stammten nicht aus der Glasflaeche des Mockups, deshalb
blieben 656 px rechts und 470 px unten leer waehrend die Werkzeugleiste
des Boards oben unter der Deckelblende verschwand. Und Prozentwerte in
translate() beziehen sich auf die Bildkante, nicht auf das Display —
Vorschau und Export loesten sie unterschiedlich auf, die Luecke war im
Export groesser als im Canvas.
object-fit uebernimmt das jetzt: kein Versatz, der auseinanderlaufen
koennte, und die Blende beschneidet von selbst. */
.szene-screen > img {
position: absolute; top: 0; left: 0;
width: 100%; height: 100%;
object-fit: cover; object-position: center;
transform-origin: top left;
}
/* Der Hintergrundton bleibt als Rueckfall stehen, falls eine Aufnahme
einmal nicht ankommt — dann faellt es als Flaeche auf, nicht als Loch. */
.szene-mb { background: #F5F4F4; }
.szene-ip { background: #FAFAFA; }
/* Das Board wird beim Fuellen seitlich beschnitten. Was wegfaellt, soll
rechts wegfallen: dort steht ohnehin das iPad davor. */
.szene-mb > img { object-position: left center; }
.laptop-missing {
width: 940px; padding: 80px 40px; text-align: center;
border: 3px dashed var(--shape-line); color: var(--body-color);
font-size: 26px; line-height: 1.5;
}
.title-top { transform: translateX(-140px); }
.title-top[data-edit] { transform: translate(calc(-140px + var(--ex, 0px)), var(--ey, 0px)); }
/* Footer variant: everything on the centre axis, wordmark closes the slide.
No left shift here, so the lockup is not tied to the dark block. */
.page.title[data-layout="laptop-footer"] .title-top { transform: none; }
.page.title[data-layout="laptop-footer"] .wabe-claim { margin-top: 26px; }
.page.title[data-layout="laptop-footer"] .wabe-logo { margin-top: 0; }
.page.title[data-layout="laptop-logo"] .wabe-tagline,
.page.title[data-layout="laptop-blur"] .wabe-tagline { margin-top: 172px; }
.wabe-logo { width: 430px; height: auto; display: block; margin-top: 37px; }
.wabe-tagline {
white-space: nowrap;
font-size: 74px; font-weight: 800; line-height: 1.06;
letter-spacing: -0.03em; color: var(--head-color);
}
.wabe-claim {
margin-top: 4px; font-size: 31px; font-weight: 500; line-height: 1.44;
color: var(--body-color); max-width: 800px;
}
/* ─── Title slide ────────────────────────────────────────────────────── */
.page.title .content {
padding: 0 104px; height: 100%;
display: flex; flex-direction: column; align-items: center; justify-content: center;
text-align: center;
}
.page.title[data-layout="wabe"] .content,
.page.title[data-layout="laptop"] .content,
.page.title[data-layout="laptop-logo"] .content,
.page.title[data-layout="laptop-blur"] .content,
.page.title[data-layout="laptop-footer"] .content,
.page.title[data-layout="szene"] .content {
padding: 92px 96px; justify-content: space-between;
}
.logo-full { width: 560px; height: auto; display: block; margin-bottom: 74px; }
.slide[data-variant="dunkel"] .logo-dark,
.slide[data-variant="hell"] .logo-light { display: none; }
.title-head {
font-size: 88px; font-weight: 800;
letter-spacing: -0.035em; line-height: 1.04;
color: var(--head-color);
}
.title-sub {
margin-top: 40px; font-size: 31px; font-weight: 500; line-height: 1.45;
color: var(--body-color); max-width: 760px;
}
/* Swipe cue, bottom right — carousels live or die on slide 2 being opened. */
.swipe {
position: absolute; z-index: 3; right: 92px; bottom: 62px;
display: flex; align-items: center; gap: 14px;
font-size: 24px; font-weight: 700; letter-spacing: .04em;
text-transform: uppercase; color: var(--body-color);
}
.swipe svg { width: 34px; height: 34px; }
/* ─── Variant: dunkel ────────────────────────────────────────────────── */
.slide[data-variant="dunkel"] {
background: var(--dark-bg);
--head-color: var(--text-white);
--body-color: #B9BDC9;
--accent-on: var(--accent-yellow);
--marker: var(--accent-yellow);
--frame-bg: rgba(255,255,255,.11);
--shape-fill: #414252;
--shape-line: rgba(255,255,255,.10);
--shape-accent: var(--accent-yellow);
--shape-accent-2: rgba(255,255,255,.14);
--pat-color: #FFFFFF; --pat-strength: .07;
--gmark-color: rgba(255,255,255,.13);
--guide-line: rgba(255,255,255,.16);
--guide-safe: rgba(255,255,255,.26);
--guide-axis: rgba(255,255,255,.40);
--guide-mid: rgba(241,183,41,.80);
}
.slide[data-variant="dunkel"] .headline .a,
.slide[data-variant="dunkel"] .title-head .a { color: #1A1A1A; }
/* ─── Variant: hell ──────────────────────────────────────────────────── */
.slide[data-variant="hell"] {
background: var(--surface);
--head-color: var(--text-primary);
--body-color: var(--text-secondary);
--accent-on: #C8901A;
--marker: var(--accent-yellow);
--frame-bg: #E8EBF0;
--shape-fill: var(--dark-bg);
--shape-line: rgba(48,49,61,.13);
--shape-accent: var(--accent-yellow);
--shape-accent-2: rgba(48,49,61,.16);
--pat-color: #30313D; --pat-strength: .07;
--gmark-color: rgba(48,49,61,.16);
--guide-line: rgba(48,49,61,.20);
--guide-safe: rgba(48,49,61,.30);
--guide-axis: rgba(48,49,61,.45);
--guide-mid: rgba(200,144,26,.75);
}
</style>
</head>
<body>
<aside class="panel">
<h1>Post 1 · Lösung</h1>
<div class="projects"><a class="here" href="./post1.html">Post 1</a><a class="" href="./post1-alt.html">Post 1 · Alt</a><a class="" href="./index.html">Post 2</a></div>
<p class="sub">Carousel, 5 Slides · 1080 × 1350 px<span class="saved" id="saved"></span><br><button class="forget" id="forget">Projekt zurücksetzen</button></p>
<div class="group">
<label>Slide</label>
<div class="opts one" id="slides-nav"></div>
</div>
<details class="group" data-grp="variante">
<summary>Variante</summary>
<div class="opts" id="variants">
<button data-v="dunkel" aria-pressed="true"><span class="swatch" style="background:#30313D"></span>Dunkel</button>
<button data-v="hell" aria-pressed="false"><span class="swatch" style="background:#FFFFFF"></span>Hell</button>
</div>
</details>
<details class="group" data-grp="layout-nur-diese-slide">
<summary>Layout (nur diese Slide)</summary>
<div class="opts" id="layouts"></div>
</details>
<details class="group" data-grp="tag-optisch-ausger-ckt">
<summary>Tag optisch ausgerückt</summary>
<div class="opts" id="tagouts">
<button data-o="an" aria-pressed="true">An</button>
<button data-o="aus" aria-pressed="false">Aus</button>
</div>
</details>
<details class="group" data-grp="bearbeiten">
<summary>Bearbeiten</summary>
<div class="opts" id="editmodes" style="margin-bottom:8px">
<button data-e="an" aria-pressed="false">An</button>
<button data-e="aus" aria-pressed="true">Aus</button>
</div>
<div class="edit-info" id="edit-info"><span class="dim">Nichts ausgewählt</span></div>
<div class="row" style="margin-bottom:8px">
<button class="btn secondary" id="sz-minus">A−</button>
<button class="btn secondary" id="sz-plus">A+</button>
</div>
<div class="row" style="margin-bottom:8px">
<button class="btn secondary" id="align-pos"
title="Dieses Element auf allen Slides an dieselbe Stelle">Position angleichen</button>
<button class="btn secondary" id="align-size"
title="Dieses Element auf allen Slides gleich groß">Größe angleichen</button>
</div>
<div class="row">
<button class="btn secondary" id="reset-one">Element zurück</button>
</div>
<button class="btn secondary" id="reset-all" style="width:100%;margin-top:8px">Alles auf Standard</button>
</details>
<details class="std">
<summary>Standardwerte</summary>
<div id="std-list"></div>
</details>
<details class="group" data-grp="ansicht">
<summary>Ansicht</summary>
<div class="opts" id="views">
<button data-w="streifen" aria-pressed="true">Streifen</button>
<button data-w="einzeln" aria-pressed="false">Einzeln</button>
</div>
</details>
<details class="group" data-grp="durchgehend-alle-slides">
<summary>Durchgehend (alle Slides)</summary>
<div class="opts" id="conts">
<button data-c="an" aria-pressed="false">An</button>
<button data-c="aus" aria-pressed="true">Aus</button>
</div>
</details>
<details class="group" data-grp="muster-nur-diese-slide">
<summary>Muster (nur diese Slide)</summary>
<div class="opts three" id="bgs">
<button data-b="formen" aria-pressed="true">Formen</button>
<button data-b="formen-g" aria-pressed="false">Formen + G</button>
<button data-b="nur-g" aria-pressed="false">Nur G</button>
<button data-b="waben" aria-pressed="false">Waben</button>
<button data-b="punkte" aria-pressed="false">Punkte</button>
<button data-b="raster" aria-pressed="false">Raster</button>
<button data-b="aus" aria-pressed="false">Aus</button>
</div>
</details>
<details class="group" data-grp="intensit-t">
<summary>Intensität</summary>
<div class="opts" id="ints">
<button data-i="dezent" aria-pressed="true">Dezent</button>
<button data-i="kraeftig" aria-pressed="false">Kräftig</button>
</div>
</details>
<details class="group" data-grp="bildrahmen">
<summary>Bildrahmen</summary>
<div class="opts three" id="frames">
<button data-f="chamfer" aria-pressed="true">Chamfer</button>
<button data-f="rund" aria-pressed="false">Rund</button>
<button data-f="stapel" aria-pressed="false">Stapel</button>
<button data-f="aus" aria-pressed="false">Aus</button>
</div>
</details>
<details class="group" data-grp="mockup-perspektive">
<summary>Mockup-Perspektive</summary>
<div class="opts" id="tilts">
<button data-t="an" aria-pressed="true">Gekippt</button>
<button data-t="aus" aria-pressed="false">Gerade</button>
</div>
</details>
<details class="group" data-grp="renderqualit-t">
<summary>Renderqualität</summary>
<div class="opts three" id="quality">
<button data-q="1" aria-pressed="false">1×</button>
<button data-q="2" aria-pressed="true">2×</button>
<button data-q="3" aria-pressed="false">3×</button>
</div>
</details>
<div class="group">
<label>Download</label>
<div class="row" style="margin-bottom:8px">
<button id="dl-png" disabled>Diese Slide</button>
</div>
<button id="dl-all" class="btn secondary" style="width:100%" disabled>Alle 5 Slides als ZIP</button>
</div>
<details class="group" style="margin-bottom:0" data-grp="tastatur">
<summary>Tastatur</summary>
<div class="keys">
<kbd>←</kbd><kbd>→</kbd> Slide · <kbd>V</kbd> Variante<br>
<kbd>L</kbd> Layout · <kbd>M</kbd> Muster · <kbd>I</kbd> Intensität<br>
<kbd>C</kbd> Durchgehend · <kbd>W</kbd> Ansicht · <kbd>G</kbd> Tag<br>
<kbd>R</kbd> Bildrahmen<br>
<kbd>T</kbd> Perspektive<br>
<kbd>E</kbd> Bearbeiten · Pfeile verschieben (<kbd>⇧</kbd> = 10 px)<br>
<kbd>+</kbd> <kbd>−</kbd> <kbd>0</kbd> Zoom · ⌘/Strg + Rad<br>
<kbd>Leer</kbd> + ziehen verschiebt die Fläche<br>
<kbd>D</kbd> Slide laden · <kbd>A</kbd> alle als ZIP
</div>
</details>
<div class="status" id="status">Lade Schriften und Screens ...</div>
</aside>
<main class="stage" id="stage">
<div class="scroller" id="scroller">
<div class="canvas">
<div class="strip-wrap" id="strip-wrap">
<div class="strip" id="deck" data-view="streifen"></div>
</div>
</div>
</div>
<div class="hud">
<p class="stage-meta" id="meta"></p>
<div class="snap">
<button id="snap-save" title="Diesen Stand als Rückfallpunkt ablegen">Stand merken</button>
<button id="snap-load" title="Zum gemerkten Stand zurück" disabled>Zurück zum Stand</button>
</div>
<div class="zoom">
<button id="zoom-out" title="Auszoomen">−</button>
<button class="level" id="zoom-fit" title="Einpassen">Fit</button>
<button id="zoom-in" title="Einzoomen">+</button>
</div>
</div>
</main>
<script src="./vendor/html-to-image.js"></script>
<script src="./vendor/jszip.min.js"></script>
<script>
(function () {
"use strict";
/* A syntax or temporal-dead-zone error at this level kills the whole script
silently: the panel renders from static markup, but nothing is ever built
and no status is set. Surface it instead of showing an empty stage. */
window.addEventListener("error", e => {
const el = document.getElementById("status");
if (el) {
el.textContent = "Skriptfehler: " + (e.message || e.error);
el.className = "status err";
}
});
const deck = document.getElementById("deck");
const nav = document.getElementById("slides-nav");
const statusEl = document.getElementById("status");
const metaEl = document.getElementById("meta");
/* Slide content. Feature bodies are the website copy verbatim
(app/playground/PlaygroundClient.tsx → solutionModules) so Social and
Website say exactly the same thing; the headlines are new and short,
because a full sentence does not work as a carousel hook.
img-scale/x/y frame each capture on its telling region. */
const SLIDES = [
{
key: "titel", type: "title",
layouts: [{ id: "klassisch", label: "Klassisch" }, { id: "wabe", label: "Wabe" }, { id: "laptop", label: "Laptop" }, { id: "laptop-logo", label: "Laptop · Logo" }, { id: "laptop-blur", label: "Laptop · Blur" }, { id: "laptop-footer", label: "Laptop · Logo unten" }, { id: "szene", label: "Szene" }],
head: 'Vom <span class="a">Handwerker</span><br>f\u00fcr Handwerker',
sub: "Die Software, die deine Branchenprogramme nicht ersetzt, sondern verbindet.",
},
{
key: "projektmanagement", type: "feature",
layouts: [{ id: "standard", label: "Standard" }],
eyebrow: "Projektmanagement",
head: 'Du siehst auf einen Blick,<br><span class="a">wo alle Auftr\u00e4ge stehen.</span>',
body: "Projekte, Kunden, Angebote, F\u00f6rderantr\u00e4ge. Kein Suchen, kein Nachfragen, kein Zettelchaos.",
img: "./assets/screens/pipelines.png",
// Nur die linke Bildhälfte: Zoom 2 bei Versatz 0 zeigt exakt den Anteil
// 0 bis 0,5. Das Rahmenverhältnis ist die halbierte Bildbreite.
mockW: 580, mockAR: 0.8855,
imgScale: 2, imgX: "0%", imgY: "0%",
// Detailspalte eines Projekts vor dem Board: zeigt, was hinter einer
// Karte steckt, ohne das Board zu verdecken.
media2: {
img: "./assets/screens/pipelines-detail.png",
w: 300, ar: 0.781, left: 500, bottom: 52, z: 2, rot: -2,
},
},
{
key: "workflow-engine", type: "feature",
layouts: [{ id: "standard", label: "Standard" }],
eyebrow: "Workflow-Engine",
head: '<span class="a">Wenn-dann</span><br>statt Handarbeit.',
body: "Auslöser wählen, Aktionen dranhängen, fertig. Alles automatisch und ohne eine Zeile Code.",
img: "./assets/screens/workflow.png",
mockW: 940, mockAR: 2.366,
imgScale: 1, imgX: "0%", imgY: "0%",
// Die Bausteinliste ragt über den Ablauf hinaus: sie zeigt, woraus er entsteht.
media2: {
img: "./assets/screens/workflow-bausteine.png",
w: 340, ar: 0.8326, left: 700, bottom: 96, z: 2, rot: 0,
},
// Der Zettel liegt bewusst schief und leicht über der linken Blattkante:
// er gehört zum Ablauf, ist aber sichtbar von Hand dazugelegt.
note: {
text: "Eigene Notizen direkt am Ablauf hinterlegen.",
w: 320, left: 44, bottom: 150, z: 3, rot: -3,
},
},
{
key: "chat", type: "feature",
layouts: [{ id: "standard", label: "Standard" }],
eyebrow: "Team-Chat",
// Zweizeilig, aber knapp: Zeile 2 misst mit Marker 880 px bei 896
// verfuegbaren. Ein laengeres Wort bricht sofort um.
head: 'Projekte bekommen<br>automatisch <span class="a">ihren Kanal.</span>',
body: "Foto von der Baustelle rein, alle sehen's sofort. Aus jeder Nachricht wird mit einem Klick eine Aufgabe.",
img: "./assets/screens/chat.png",
mockW: 1030, mockAR: 1.703,
imgScale: 1, imgX: "0%", imgY: "0%",
// Das hinterste Blatt zeigt sonst den Form Builder bei 3,8-fachem Zoom:
// unscharf, und die Kopieren- und Papierkorb-Symbole gehören zu einem
// anderen Feature. Stattdessen der Chat im Hochformat, ungezoomt.
back: { img: "./assets/screens/chat-tablet.png", imgScale: 1, imgX: "0%", imgY: "0%" },
// Das Anhang-Menü zeigt, was aus einer Nachricht alles werden kann.
media2: {
img: "./assets/screens/chat-menue.png",
w: 300, ar: 0.866, left: 120, bottom: 55, z: 2, rot: -3,
},
},
{
key: "form-builder", type: "feature",
layouts: [{ id: "standard", label: "Standard" }],
eyebrow: "Form Builder",
head: 'Formulare selbst bauen.<br><span class="a">Per Drag-and-drop.</span>',
body: "Von der Kontaktanfrage bis zum Aufmaß mit Unterschrift vor Ort. Teil sie per Link oder QR-Code, jede Einsendung wird automatisch zu Lead, Kontakt oder Auftrag.",
img: "./assets/screens/form-builder.png",
mockW: 540, mockAR: 0.75, mockLeft: 270,
imgScale: 3.8, imgX: "-36.9%", imgY: "-10%",
},
];
/* Standardwerte. Reset und die Übersicht auf der Seite lesen beide von hier,
damit die Doku nicht auseinanderläuft. size = null heisst: nicht über die
Schriftgröße steuerbar (Bilder, Geräte). */
const STANDARD = {
"badge": { label: "Tag", size: 26, prop: "font-size" },
"headline": { label: "Überschrift", size: 74, prop: "font-size" },
"body": { label: "Beschreibung", size: 30, prop: "font-size" },
"wabe-tagline": { label: "Tagline", size: 74, prop: "font-size" },
"wabe-claim": { label: "Claim", size: 31, prop: "font-size" },
"title-head": { label: "Überschrift", size: 88, prop: "font-size" },
"title-sub": { label: "Claim", size: 31, prop: "font-size" },
"wabe-logo": { label: "Logo", size: 430, prop: "width" },
"logo-full": { label: "Logo", size: 560, prop: "width" },
"screen-logo": { label: "Logo im Screen", size: 430, prop: "width" },
"laptop": { label: "Laptop", size: 960, prop: "width" },
"szene": { label: "Szene", size: 980, prop: "width" },
"mock": { label: "Bild", size: null, prop: "width" },
"mock-2": { label: "Zweites Bild", size: null, prop: "width" },
"note": { label: "Notizzettel", size: null, prop: "width" },
"hex-frame": { label: "Wabe", size: 778, prop: "width" },
};
const EDIT_KEYS = Object.keys(STANDARD);
/* Der Standard des Bildrahmens hängt an der Slide: Der Form Builder hat ein
Hochformat von 540, alle anderen 1090. Deshalb nicht aus der Tabelle,
sondern aus der Slide selbst. */
function stdSize(key, i) {
if (key === "mock") return SLIDES[i].mockW || 1090;
if (key === "mock-2") return (SLIDES[i].media2 || {}).w || 400;
if (key === "note") return (SLIDES[i].note || {}).w || 320;
return (STANDARD[key] || {}).size;
}
// Schriftgrößen in 2er-Schritten, Breiten in 2 % ihrer Ausgangsgröße:
// 2 px auf ein 1090 px breites Bild würde man nie sehen.
function stepFor(key, i) {
const std = STANDARD[key];
if (!std || !std.prop) return 0;
if (std.prop === "font-size") return 2;
return Math.max(4, Math.round(stdSize(key, i) * 0.02));
}
let edits = SLIDES.map(() => ({}));
let picked = null; // { slide, key }
let editMode = "aus";
let current = 0;
let variant = "dunkel", tilt = "an", superSample = 2, continuous = "aus", view = "streifen", tagOut = "an", frameStyle = "chamfer";
/* ─── Projektstand ────────────────────────────────────────────────────────
Alles, was am Projekt hängt, wird gespeichert: Einstellungen, Layouts,
Muster und die Verschiebungen im Canvas. Das Werkzeug soll den Stand
halten, nicht der Kopf. */
const PROJECT = "post1";
const STORE = "hivegrid.projekt." + PROJECT;
const STATE_URL = "./state/" + PROJECT + ".json";
const STATE_V = 2;
function currentState() {
return {
v: STATE_V, variant, tilt, superSample, continuous, view, tagOut, frameStyle,
current, layoutState, bgState, edits,
};
}
/* ─── Angleichung des Tags (einmalig, Stand 1 → 2) ───────────────────────
Der Tag war auf jeder Slide einzeln gezogen worden und stand deshalb
überall etwas anders. Er soll auf allen Feature-Slides denselben Abstand
zur oberen und linken Kante haben; Maß ist die erste Feature-Slide.
Das läuft hier und nicht über die Datei: solange ein Tab offen ist, hält
der seinen Stand im Speicher und schreibt jede Änderung an der Datei beim
nächsten Klick wieder zu. Im Ladepfad kann er das nicht. */
function migrateState(d) {
if (!d || (d.v || 1) >= 2) return false;
const feat = SLIDES.map((s, i) => (s.type === "feature" ? i : -1)).filter(i => i >= 0);
const ref = feat.length && Array.isArray(d.edits) ? (d.edits[feat[0]] || {}).badge : null;
if (ref) {
for (const i of feat.slice(1)) {
const e = d.edits[i] || (d.edits[i] = {});
e.badge = Object.assign({}, e.badge, { dx: ref.dx || 0, dy: ref.dy || 0 });
}
}
d.v = 2;
return true;
}
/* Der Stand geht in zwei Ablagen. Sofort in den localStorage, damit die
Rückmeldung nicht auf das Netz wartet, und gebündelt in
state/<projekt>.json. Nur die Datei lässt sich pushen, also ist sie die
Wahrheit; der localStorage ist der Rückfall, wenn der Server nicht läuft. */
let pushTimer = null;
function saveState() {
const state = currentState();
try { localStorage.setItem(STORE, JSON.stringify(state)); }
catch (err) { console.warn("Speichern fehlgeschlagen", err); }
markSaved();
clearTimeout(pushTimer);
pushTimer = setTimeout(() => pushState(state), 600);
}
async function pushState(state) {
pushTimer = null;
try {
const res = await fetch("/state?project=" + PROJECT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(state),
});
if (!res.ok) throw new Error("HTTP " + res.status);
} catch (err) { console.warn("Stand nicht in die Datei geschrieben", err); }
}
// Ein Reload darf keine Änderung verschlucken, die noch im Timer hängt.
addEventListener("beforeunload", () => {
if (!pushTimer) return;
clearTimeout(pushTimer);
navigator.sendBeacon("/state?project=" + PROJECT,
new Blob([JSON.stringify(currentState())], { type: "application/json" }));
});
async function loadState() {
let d = null, fromFile = false;
// Erst die Datei aus dem Repo, dann der Browser. Wer das Projekt frisch
// gezogen hat, soll den gepushten Stand sehen und nicht seinen eigenen alten.
try {
const res = await fetch(STATE_URL, { cache: "no-store" });
if (res.ok) { d = await res.json(); fromFile = true; }
} catch (_) {}
if (!d) {
try { d = JSON.parse(localStorage.getItem(STORE) || "null"); } catch (_) {}
}
if (!d || !(d.v === 1 || d.v === 2)) return false;
const migrated = migrateState(d);
// Stand aus dem Browser, aber noch keine Datei: einmalig hinueberschreiben,
// sonst bliebe ein ueber Wochen gewachsener Stand ungeteilt liegen.
// Nach einer Angleichung ebenso, damit sie nicht bei jedem Laden erneut greift.
if (!fromFile || migrated) setTimeout(() => pushState(currentState()), 0);
// Nur übernehmen, was zur aktuellen Slide-Anzahl passt: Änderungen am
// Aufbau dürfen keinen kaputten Stand einspielen.
if (Array.isArray(d.layoutState) && d.layoutState.length === SLIDES.length) {
d.layoutState.forEach((id, i) => {
if (SLIDES[i].layouts.some(l => l.id === id)) layoutState[i] = id;
});
}
if (Array.isArray(d.bgState) && d.bgState.length === SLIDES.length) {
d.bgState.forEach((b, i) => { if (b && b.bg) bgState[i] = b; });
}
if (Array.isArray(d.edits) && d.edits.length === SLIDES.length) edits = d.edits;
variant = d.variant || variant;
tilt = d.tilt || tilt;
superSample = d.superSample || superSample;
continuous = d.continuous || continuous;
view = d.view || view;
tagOut = d.tagOut || tagOut;
frameStyle = d.frameStyle || frameStyle;
current = Number.isInteger(d.current) ? d.current : 0;
return true;
}
let savedTimer = null;
function markSaved() {
const el = document.getElementById("saved");
if (!el) return;
el.textContent = "gespeichert";
el.classList.add("on");
clearTimeout(savedTimer);
savedTimer = setTimeout(() => el.classList.remove("on"), 1200);
}
function forgetProject() {
clearTimeout(pushTimer); pushTimer = null;
try { localStorage.removeItem(STORE); } catch (_) {}
// Auch die Datei muss weg, sonst spielt sie den alten Stand beim nächsten
// Laden wieder ein.
fetch("/state?project=" + PROJECT, { method: "DELETE" })
.catch(() => {})
.then(() => location.reload());
}
// Per-slide so a single slide can carry a different ground.
const bgState = SLIDES.map(() => ({ bg: "formen", int: "dezent" }));
// Each slide picks its own layout from its own catalogue.
const layoutState = SLIDES.map(sl => sl.layouts[0].id);
// Every asset is inlined once at boot, so re-rendering a layout stays synchronous.
const assets = {};
let fontEmbedCss = "";
/* Der Streifen wird auf die verfügbare Breite skaliert, die Einzelansicht auf
feste 45 %. Der Rahmen bekommt die skalierte Größe, damit die Seite nicht
springt. */
/* zoom === null bedeutet: automatisch einpassen. Sobald der Nutzer zoomt,
hält der Wert, bis er auf "Fit" zurückgeht. */
let zoom = null;
function stripWidth() { return view === "streifen" ? 1080 * SLIDES.length : 1080; }
function autoFit() {
const scroller = document.getElementById("scroller");
const availW = Math.max(320, scroller.clientWidth - 120);
const availH = Math.max(240, scroller.clientHeight - 120);
return Math.min(availW / stripWidth(), availH / 1350, 0.45);
}
function fitStrip() {
const wrap = document.getElementById("strip-wrap");
const k = zoom || autoFit();
deck.style.transform = "scale(" + k + ")";
wrap.style.width = Math.round(stripWidth() * k) + "px";
wrap.style.height = Math.round(1350 * k) + "px";
const lvl = document.getElementById("zoom-fit");
if (lvl) lvl.textContent = zoom ? Math.round(k * 100) + " %" : "Fit";
}
function setZoom(k, anchor) {
const scroller = document.getElementById("scroller");
const before = zoom || autoFit();
const ax = anchor ? anchor.x : scroller.clientWidth / 2;
const ay = anchor ? anchor.y : scroller.clientHeight / 2;
/* Um den Mauszeiger zoomen, nicht um die linke obere Ecke — und dafür den
Punkt unter dem Zeiger messen statt ihn aus scrollLeft hochzurechnen.
Das Blatt sitzt in einer gepolsterten Fläche und wird zentriert, solange
Platz übrig ist; beides verschiebt den Ursprung. Ein reiner Dreisatz
ließ den Inhalt deshalb bei jedem Schritt um das Polster wegwandern. */
const s0 = scroller.getBoundingClientRect();
const r0 = deck.getBoundingClientRect();
const u = (s0.left + ax - r0.left) / before;
const v = (s0.top + ay - r0.top) / before;
zoom = Math.min(2, Math.max(0.05, k));
fitStrip();
const r1 = deck.getBoundingClientRect();
scroller.scrollLeft += (r1.left + u * zoom) - (s0.left + ax);
scroller.scrollTop += (r1.top + v * zoom) - (s0.top + ay);
}
const setStatus = (msg, kind) => {
statusEl.textContent = msg;
statusEl.className = "status" + (kind ? " " + kind : "");
};
const SWIPE_SVG =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" ' +
'stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg>';
// FeatherMoreVertical aus dem Kopf der StickyNote: drei Punkte, keine Linien.
const NOTE_MENU_SVG =
'<svg viewBox="0 0 24 24" fill="rgba(66,66,66,.72)">' +
'<circle cx="12" cy="5" r="1.9"/><circle cx="12" cy="12" r="1.9"/>' +
'<circle cx="12" cy="19" r="1.9"/></svg>';
const SVG_NS = "http://www.w3.org/2000/svg";
const G_POINTS = "384.5,166.5 336.4,83.2 192.2,166.5 288.3,166.5 240.3,249.7 144.2,249.7 96.1,166.5 144.2,83.2 336.4,83.2 298,16.8 288.3,0 96.1,0 0,166.5 96.1,332.9 288.3,332.9";
const G_W = 384.5, G_H = 332.9;
// Positionen im 5400er Raum. Die Nähte liegen auf 1080 / 2160 / 3240 / 4320.
const CONTINUUM_HTML =
'<div class="continuum">' +
'<div class="ct-hex" style="left:870px; top:210px; width:420px; height:364px"></div>' +
'<div class="ct-hex-line" style="left:1935px; top:930px; width:450px; height:390px"></div>' +
'<div class="ct-hex" style="left:2980px; top:150px; width:520px; height:450px"></div>' +
'<div class="ct-hex-line" style="left:4130px; top:860px; width:380px; height:329px"></div>' +
'<div class="ct-band"></div>' +
'<svg class="ct-g" style="left:1840px; top:420px; width:640px" ' +
'viewBox="0 0 384.5 332.9" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' +
'<polygon fill="currentColor" points="' + G_POINTS + '"/></svg>' +
'</div>';
const BG_HTML =
'<div class="bg">' + CONTINUUM_HTML +
'<div class="layer-shapes">' +
'<div class="sh sh-block"></div>' +
'<div class="sh sh-hex-line"></div>' +
'<div class="sh sh-hex-accent"></div>' +
'<div class="sh sh-hex-small"></div>' +
'</div>' +
'<svg class="layer-gmark" viewBox="0 0 384.5 332.9" aria-hidden="true" ' +
'xmlns="http://www.w3.org/2000/svg"><polygon fill="currentColor" points="' +
G_POINTS + '"/></svg>' +
'<svg class="layer-waben" aria-hidden="true"></svg>' +
'<div class="layer-punkte"></div>' +
'<div class="layer-raster"></div>' +
'</div>';
/* Seamless flat-top honeycomb tile, generated from the same G polygon as the
logo mark. Columns step by 3/4 of the hex width and neighbouring columns sit
half a row apart, so one tile is two columns wide and one row high. The
copies hanging past the tile edges are what make the repeat seamless. */
function buildWaben(svg, uid) {
const colStep = G_W * 0.75, rowStep = G_H;
const tileW = colStep * 2, tileH = rowStep;
const centres = [
[0, 0], [tileW, 0], [0, tileH], [tileW, tileH],
[colStep, rowStep / 2], [colStep, -rowStep / 2], [colStep, tileH + rowStep / 2],
];
const id = "waben-" + uid;
const defs = document.createElementNS(SVG_NS, "defs");
const pat = document.createElementNS(SVG_NS, "pattern");
pat.setAttribute("id", id);
pat.setAttribute("patternUnits", "userSpaceOnUse");
pat.setAttribute("width", tileW);
pat.setAttribute("height", tileH);
pat.setAttribute("patternTransform", "scale(0.46)");
for (const [cx, cy] of centres) {
const poly = document.createElementNS(SVG_NS, "polygon");
poly.setAttribute("points", G_POINTS);
poly.setAttribute("fill", "currentColor");
poly.setAttribute("transform", "translate(" + (cx - G_W / 2) + "," + (cy - G_H / 2) + ")");
pat.appendChild(poly);
}
defs.appendChild(pat);
svg.appendChild(defs);
const rect = document.createElementNS(SVG_NS, "rect");
rect.setAttribute("width", "100%");
rect.setAttribute("height", "100%");
rect.setAttribute("fill", "url(#" + id + ")");
svg.appendChild(rect);
}
function contentHtml(s, i) {
if (s.type === "feature") {
return '<p class="badge">' + s.eyebrow + '</p>' +
'<h2 class="headline">' + s.head + '</h2>' +
'<p class="body">' + s.body + '</p>';
}
const logo = variant === "dunkel" ? assets["./assets/brand/logo-white.svg"] : assets["./assets/brand/logo.svg"];
if (layoutState[i] === "szene") {
const dev = assets["./assets/mockups/macbook-szene.png"]
? '<div class="szene">' +
'<div class="szene-screen szene-mb"><img alt="" src="' +
assets["./assets/screens/pipelines.png"] + '"></div>' +
'<div class="szene-screen szene-ip"><img alt="" src="' +
assets["./assets/screens/chat-tablet.png"] + '"></div>' +
'<img class="szene-dev" alt="" src="' +
assets["./assets/mockups/macbook-szene.png"] + '">' +
'</div>'
: '<div class="laptop-missing">Szenen-Mockup fehlt.<br>Das freigestellte PNG als <b>assets/mockups/macbook-szene.png</b> ablegen.</div>';
return '<div class="title-top">' +
'<p class="wabe-tagline">Vom Handwerker<br>für <span class="a">Handwerker</span></p>' +
'<img class="wabe-logo" alt="HiveGrid" src="' + logo + '">' +
'</div>' + dev +
'<p class="wabe-claim">' + s.sub + '</p>';
}
if (layoutState[i] === "laptop-footer") {
const dev = assets["./assets/mockups/laptop.png"]
? '<div class="laptop">' +
'<img class="laptop-frame" alt="" src="' + assets["./assets/mockups/laptop.png"] + '">' +
'<div class="laptop-screen" data-blur="aus">' +
'<img class="laptop-shot" alt="" src="' + assets["./assets/screens/workflow.png"] + '">' +
'</div>' +
'</div>'
: '<div class="laptop-missing">Mockup fehlt.<br>Das transparente PNG als <b>assets/laptop.png</b> ablegen.</div>';
return '<div class="title-top">' +
'<p class="wabe-tagline">Vom Handwerker<br>f\u00fcr <span class="a">Handwerker</span></p>' +
'<p class="wabe-claim">' + s.sub + '</p>' +
'</div>' + dev +
'<img class="wabe-logo" alt="HiveGrid" src="' + logo + '">';
}
if (layoutState[i].indexOf("laptop") === 0) {
// In the logo variants the wordmark moves onto the display and the tagline
// takes over its slot on the hexagon line; "laptop-blur" additionally
// blurs the app behind it.
const onScreen = layoutState[i] !== "laptop";
const blurred = layoutState[i] === "laptop-blur";
const overlay = onScreen
? '<img class="screen-logo" alt="HiveGrid" src="' + assets["./assets/brand/logo.svg"] + '">'
: '';
const dev = assets["./assets/mockups/laptop.png"]
? '<div class="laptop">' +
'<img class="laptop-frame" alt="" src="' + assets["./assets/mockups/laptop.png"] + '">' +
'<div class="laptop-screen" data-blur="' + (blurred ? "an" : "aus") + '">' +
'<img class="laptop-shot" alt="" src="' + assets["./assets/screens/workflow.png"] + '">' + overlay +
'</div>' +
'</div>'
: '<div class="laptop-missing">Mockup fehlt.<br>Das transparente PNG als <b>assets/laptop.png</b> ablegen.</div>';
return '<div class="title-top">' +
'<p class="wabe-tagline">Vom Handwerker<br>f\u00fcr <span class="a">Handwerker</span></p>' +
(onScreen ? '' : '<img class="wabe-logo" alt="HiveGrid" src="' + logo + '">') +
'</div>' + dev +
'<p class="wabe-claim">' + s.sub + '</p>';
}
if (layoutState[i] === "wabe") {
return '<div class="title-top">' +
'<p class="wabe-tagline">Vom Handwerker<br>f\u00fcr <span class="a">Handwerker</span></p>' +
'<img class="wabe-logo" alt="HiveGrid" src="' + logo + '">' +
'</div>' +
'<div class="hex-frame"><div class="hex-shot">' +
'<img alt="" src="' + assets["./assets/screens/pipelines.png"] + '">' +
'</div></div>' +
'<p class="wabe-claim">' + s.sub + '</p>';
}
return '<img class="logo-full" alt="HiveGrid" src="' + logo + '">' +
'<h1 class="title-head">' + s.head + '</h1>' +
'<p class="title-sub">' + s.sub + '</p>';
}
// Eine Seite kann in mehreren Slides liegen; alle Kopien mitziehen.
function rebuildPage(j) {
deck.querySelectorAll('.page[data-j="' + j + '"]').forEach(pg => renderPage(pg, j));
}
function applyEditsAll() {
deck.querySelectorAll(".page").forEach(pg => applyEdits(pg, Number(pg.dataset.j)));
}
// Jedes bearbeitbare Element bekommt seinen Schlüssel, abgeleitet aus der Klasse.
function tagEditable(slideEl) {
for (const key of EDIT_KEYS) {
slideEl.querySelectorAll("." + key).forEach(n => { n.dataset.edit = key; });
}
// ".mock" trifft auch das zweite Bild, weil es dieselbe Klasse trägt.
// Der spezifischere Schlüssel muss gewinnen, sonst teilen sich beide
// Bilder eine Bearbeitung.
slideEl.querySelectorAll(".mock-2").forEach(n => { n.dataset.edit = "mock-2"; });
}
function applyEdits(slideEl, i) {
const set = edits[i] || {};
slideEl.querySelectorAll("[data-edit]").forEach(n => {
const e = set[n.dataset.edit] || {};
n.style.setProperty("--ex", (e.dx || 0) + "px");
n.style.setProperty("--ey", (e.dy || 0) + "px");
const std = STANDARD[n.dataset.edit];
if (std && std.prop) {
n.style[std.prop === "font-size" ? "fontSize" : "width"] =
e.size ? e.size + "px" : "";
}
});
// Die Perspektiv-Matrizen der Szene rechnen in Bildpixeln des Originals.
// Der Faktor auf die Breite in der Slide haengt also an der Geraetebreite
// und muss jede Groessenaenderung mitmachen.
const sz = slideEl.querySelector(".szene");
if (sz) {
const w = (set["szene"] || {}).size || STANDARD["szene"].size;
sz.style.setProperty("--sk", w / SZENE_PX);
}
}
// Breite des freigestellten Mockups im Originalmassstab. Die matrix3d-Werte
// sind darauf bezogen, deshalb steht die Zahl hier und nicht im CSS.
const SZENE_PX = 5742;
// Eine Seite = der Inhalt genau einer Slide, unabhängig davon, in welcher
// Slide sie gerade mitgerendert wird.
function buildPage(j) {
const sl = SLIDES[j];
const page = document.createElement("div");
page.className = "page";
page.dataset.j = String(j);
page.style.left = (j * 1080) + "px";
const content = document.createElement("div");
content.className = "content";
page.appendChild(content);
if (sl.type === "feature") {
const mock = document.createElement("div");
mock.className = "mock";
if (sl.mockW) mock.style.setProperty("--mock-w", sl.mockW + "px");
if (sl.mockAR) mock.style.setProperty("--mock-ar", sl.mockAR);
if (sl.mockLeft) mock.style.setProperty("--mock-left", sl.mockLeft + "px");
/* Hinterstes Blatt bekommt standardmässig den Screen der nächsten
Feature-Slide. Das kann danebengehen: der nächste Screen ist
womöglich stark gezoomt und damit unscharf, und er zeigt Bedienung,
die zum Feature dieser Slide gar nicht passt. Deshalb ist er über
"back" pro Slide überschreibbar. */
const withImg = SLIDES.filter(x => x.img);
const auto = withImg[(withImg.findIndex(x => x.img === sl.img) + 1) % withImg.length];
const other = sl.back || { img: auto.img, imgScale: auto.imgScale, imgX: auto.imgX, imgY: auto.imgY };
mock.innerHTML =
'<div class="sheet-back"><img alt="" src="' + assets[other.img] +
'" style="transform:scale(' + other.imgScale + ') translate(' +
other.imgX + ',' + other.imgY + ')"></div>' +
'<div class="sheet-mid"></div>' +
'<div class="mock-inner" style="--img-scale:' + sl.imgScale +
';--img-x:' + sl.imgX + ';--img-y:' + sl.imgY + '">' +
'<img class="laptop-shot" alt="" src="' + assets[sl.img] + '"></div>';
page.appendChild(mock);
if (sl.media2) {
const m2 = sl.media2;
const el2 = document.createElement("div");
el2.className = "mock mock-2";
el2.style.cssText =
"--mock-w:" + m2.w + "px;--mock-ar:" + m2.ar +
";--mock-left:" + m2.left + "px;--m2-bottom:" + m2.bottom +
"px;--m2-z:" + m2.z + ";--rot:" + (m2.rot || 0) + "deg";
el2.innerHTML =
'<div class="mock-inner"><img class="laptop-shot" alt="" src="' +
assets[m2.img] + '"></div>';
page.appendChild(el2);
}
if (sl.note) {
const nt = sl.note;
const el3 = document.createElement("div");
el3.className = "note";
el3.style.cssText =
"--note-w:" + nt.w + "px;--note-left:" + nt.left +
"px;--note-bottom:" + nt.bottom + "px;--note-z:" + nt.z +
";--rot:" + (nt.rot || 0) + "deg";
el3.innerHTML =
'<div class="note-head">' + NOTE_MENU_SVG + '</div>' +
'<div class="note-body">' + nt.text + '</div>' +
'<div class="note-grip"><i></i><i></i></div>';
page.appendChild(el3);
}
} else {
const sw = document.createElement("div");
sw.className = "swipe";
sw.innerHTML = "<span>Swipe</span>" + SWIPE_SVG;
page.appendChild(sw);
}
renderPage(page, j);
return page;
}
function renderPage(page, j) {
page.querySelector(".content").innerHTML = contentHtml(SLIDES[j], j);
if (SLIDES[j].type === "title") page.classList.add("title");
// Das Layout hängt an der Seite, damit eine Titel-Seite auch in einer
// Nachbar-Slide korrekt aussieht.
page.dataset.layout = layoutState[j];
tagEditable(page);
applyEdits(page, j);
}
function buildSlide(s, i) {
const el = document.createElement("div");
el.className = "slide" + (s.type === "title" ? " title" : "");
el.dataset.variant = variant;
el.dataset.tilt = tilt;
el.dataset.bg = bgState[i].bg;
el.dataset.int = bgState[i].int;
el.dataset.layout = layoutState[i];
el.dataset.cont = continuous;
el.dataset.tagout = tagOut;
el.dataset.frame = frameStyle;
el.dataset.editmode = editMode;
el.style.setProperty("--slide-i", i);
el.innerHTML = BG_HTML;
buildWaben(el.querySelector(".layer-waben"), i);
// Nur die direkten Nachbarn: weiter als eine Slide wandert in der Praxis
// nichts, und jede zusätzliche Seite kostet Renderzeit.
const sheet = document.createElement("div");
sheet.className = "sheet";
for (let j = Math.max(0, i - 1); j <= Math.min(SLIDES.length - 1, i + 1); j++) {
sheet.appendChild(buildPage(j));
}
el.appendChild(sheet);
// Über allem, damit die Linien auch auf dem Mockup lesbar bleiben.
el.insertAdjacentHTML("beforeend",
'<div class="guides"><div class="grid"></div><div class="safe"></div>' +
'<div class="mid mid-v"></div><div class="mid mid-h"></div>' +
'<div class="ax ax-v"></div><div class="ax ax-h"></div></div>');
return el;
}
/* ─── Hilfslinien ────────────────────────────────────────────────────────
Die Linien gehören zur Slide, nicht zum Canvas: gemessen wird deshalb
gegen deren Rechteck. Der Maßstab kürzt sich dabei heraus, die Werte sind
also unabhängig vom Zoom. */
const SNAP = 4; // px in Slide-Koordinaten
let guideSlide = null;
function showGuides(slideEl) {
guideSlide = slideEl;
slideEl.dataset.guides = "an";
}
function hideGuides() {
if (!guideSlide) return;
delete guideSlide.dataset.guides;
guideSlide.querySelectorAll(".guides .on").forEach(n => n.classList.remove("on"));
guideSlide = null;
}
function updateGuides(node) {
if (!guideSlide) return;
const s = guideSlide.getBoundingClientRect();
const n = node.getBoundingClientRect();
if (!s.width || !n.width) return;
const k = s.width / 1080;
const cx = (n.left + n.width / 2 - s.left) / k;
const cy = (n.top + n.height / 2 - s.top) / k;
const g = guideSlide.querySelector(".guides");
g.querySelector(".mid-v").style.left = cx + "px";
g.querySelector(".mid-h").style.top = cy + "px";
g.querySelector(".ax-v").classList.toggle("on", Math.abs(cx - 540) <= SNAP);
g.querySelector(".ax-h").classList.toggle("on", Math.abs(cy - 675) <= SNAP);
}
/* Every asset is inlined as a data URL once, before the deck is built.
Same-origin already satisfies the canvas, but inlining removes every fetch
and timing failure mode from the export path, and it lets a layout switch
re-render without touching the network. */
async function preloadAssets() {
const paths = ["./assets/brand/logo.svg", "./assets/brand/logo-white.svg"]
.concat(SLIDES.filter(s => s.img).map(s => s.img))
.concat(SLIDES.filter(s => s.back).map(s => s.back.img))
.concat(SLIDES.filter(s => s.media2).map(s => s.media2.img));
paths.push("./assets/screens/pipelines.png", "./assets/screens/chat-tablet.png");
// Optional: the layout falls back to a hint if the file is not there yet.
const optional = new Set(["./assets/mockups/laptop.png", "./assets/mockups/macbook-szene.png"]);
paths.push("./assets/mockups/laptop.png", "./assets/mockups/macbook-szene.png");
for (const path of [...new Set(paths)]) {
const res = await fetch(path, { cache: "no-store" }).catch(() => null);
if (!res || !res.ok) {
if (optional.has(path)) continue;
throw new Error(path + " -> HTTP " + (res ? res.status : "kein Zugriff"));
}
const blob = await res.blob();
assets[path] = await new Promise((ok, fail) => {
const fr = new FileReader();
fr.onload = () => ok(fr.result);
fr.onerror = fail;
fr.readAsDataURL(blob);
});
}
}
function press(id, attr, value) {
document.querySelectorAll("#" + id + " button").forEach(b =>
b.setAttribute("aria-pressed", String(b.dataset[attr] === value)));
}
function applyAll() {
deck.style.background = variant === "hell" ? "#FFFFFF" : "#30313D";
deck.querySelectorAll(".slide").forEach((el, i) => {
el.dataset.variant = variant;
el.dataset.tilt = tilt;
el.dataset.bg = bgState[i].bg;
el.dataset.int = bgState[i].int;
el.dataset.layout = layoutState[i];
el.dataset.cont = continuous;
el.dataset.tagout = tagOut;
el.dataset.frame = frameStyle;
if (SLIDES[i].type === "title") rebuildPage(i);
el.dataset.editmode = editMode;
});
}
// The layout catalogue differs per slide, so the buttons are rebuilt on every
// slide change rather than just re-pressed.
function syncLayoutButtons() {
const box = document.getElementById("layouts");
box.innerHTML = "";
const cat = SLIDES[current].layouts;
box.className = "opts" + (cat.length === 1 ? " one" : "");
cat.forEach(l => {
const b = document.createElement("button");
b.dataset.l = l.id;
b.textContent = l.label;
b.setAttribute("aria-pressed", String(l.id === layoutState[current]));
box.appendChild(b);
});
}
// Reflect the active slide's own ground in the panel.
function syncBgButtons() {
press("bgs", "b", bgState[current].bg);
press("ints", "i", bgState[current].int);
}
function show(i) {
current = (i + SLIDES.length) % SLIDES.length;
saveState();
deck.querySelectorAll(".holder").forEach((h, n) =>
h.classList.toggle("sel", n === current));
document.querySelectorAll("#slides-nav button").forEach((b, n) =>
b.setAttribute("aria-pressed", String(n === current)));
syncBgButtons();
syncLayoutButtons();
fitStrip();
metaEl.textContent = view === "streifen"
? "Slide " + (current + 1) + " ausgewählt. Klick auf eine Slide wechselt die Auswahl"
: "Slide " + (current + 1) + " von " + SLIDES.length;
}
/* Copy grows, the mockup does not: once the text block reaches the frame the
slide silently breaks. Rather than discover that in an export, measure it
and say so. */
function checkCollisions() {
const problems = [];
deck.querySelectorAll(".holder").forEach((holder, i) => {
const own = holder.querySelector('.page[data-j="' + i + '"]');
if (!own) return;
const mock = own.querySelector(".mock");
if (!mock) return;
const restore = holder.style.display;
holder.style.display = "block";
const content = own.querySelector(".content");
const gap = mock.offsetTop - (content.offsetTop + content.offsetHeight);
holder.style.display = restore;
if (gap < 24) problems.push("Slide " + (i + 1) + " (" + SLIDES[i].eyebrow + "): " + Math.round(gap) + "px");
});
if (problems.length) {
console.warn("Text kollidiert mit dem Mockup:\n" + problems.join("\n"));
setStatus("Achtung: Text zu nah am Mockup (" + problems.length + "x), siehe Konsole", "err");
}
return problems.length === 0;
}
/* Die Bibliothek baut ein SVG in Knotengröße und zieht es beim Zeichnen auf
die Canvas-Größe hoch. WebKit rasterisiert das SVG dabei in seiner
*intrinsischen* Größe und interpoliert danach: Ein höherer pixelRatio
erzeugt so keine zusätzlichen Details, nur weichere Kanten. Genau deshalb
wirkte 3x nicht schärfer als 1x.
Der Ausweg: Das SVG bekommt gleich die Zielgröße über width/height, die
Slide behält per style ihre echten 1080 x 1350 und wird darin skaliert.
Die Bibliothek setzt style nach width/height, das Überschreiben greift.
pixelRatio bleibt 1, weil die Vergrößerung schon im SVG steckt. */
function renderOptions() {
const k = superSample;
return {
width: 1080 * k,
height: 1350 * k,
pixelRatio: 1,
fontEmbedCSS: fontEmbedCss,
style: {
width: "1080px",
height: "1350px",
transform: k === 1 ? "none" : "scale(" + k + ")",
transformOrigin: "top left",
},
};
}
/* The browser download route was unreliable here: a data: URL opened a blank
tab, a blob: URL opened the image in a tab, both times ignoring the download
attribute. Since this only runs against the local server, the bytes go to
/save instead and land in the Downloads folder directly. */
async function downloadBlob(blob, filename) {
const res = await fetch("/save", {
method: "POST",
headers: { "X-Filename": filename },
body: blob,
});
if (!res.ok) throw new Error("Speichern fehlgeschlagen: HTTP " + res.status);
return (await res.json()).path;
}
// Numeric prefix keeps the upload order intact in the file picker.
const fileName = i => String(i + 1).padStart(2, "0") + "-" + SLIDES[i].key + "-" + variant + ".png";
/* html-to-image serialises the slide into a data:image/svg+xml URL and paints
that as a single image. Raster children of a foreignObject decode
asynchronously and can miss that paint, which is why a screenshot went
missing every few exports while the rest of the slide was fine. It is a
race, so the fix is not to hope but to verify: render, check that every
screenshot area actually carries pixels, and repeat if it does not. */
/* Geprüft wird jede Fläche, die ein Bild tragen muss — die Displays und die
Geräterahmen selbst. Die Rahmen fehlten in dieser Liste, deshalb blieb ein
Export ohne Macbook unbemerkt. */
const SHOT_BOXES =
".mock-inner, .laptop-screen, .hex-shot, .szene-screen, .szene-dev, .laptop-frame";
const MAX_TRIES = 5;
// Ab hier gilt eine Fläche als leer. Da nur noch das Innere des Vierecks
// abgetastet wird, ist kein Beiwerk mehr dabei, das die Spanne aufbläht.
const LEER_SPANNE = 6;
/* Die vier Ecken eines Kindes in Slide-Koordinaten.
Vorher stand hier das achsenparallele Umrechteck. Das war der Fehler:
.szene-mb und .szene-ip stehen per matrix3d schräg im Raum, ihr Umrechteck
umfasst Raster, Hintergrund und das Nachbargerät. Darin fand die Prüfung
immer genug Helligkeitsspanne und winkte eine leere Displayfläche durch —
genau so kam ein Export ohne Screenshots durch die Kontrolle.
getBoxQuads gäbe die echten Ecken, kennt Chrome aber nicht. Also werden
vier 1-px-Marken in die Ecken gesetzt und ausgemessen: die Layout-Engine
rechnet dabei jede Transformation der Vorfahren korrekt mit. Die Marken
verschwinden vor dem Rendern wieder. */
function quadInSlide(el, child) {
const sr = el.getBoundingClientRect();
const k = sr.width / 1080;
let host = child, stell = null;
/* <img> kann keine Kinder tragen. Also entsteht im Elternknoten ein
deckungsgleicher Stellvertreter — mit derselben Box UND derselben
eigenen Transformation. Ohne letztere fehlt genau der Versatz
verschobener Bilder: .mock-inner > img wird per scale und translate in
seinem Fenster verschoben, das Viereck läge sonst daneben und die
Messung geriete auf Rahmen statt auf Bild. */
if (child.tagName === "IMG") {
const eltern = child.offsetParent || child.parentElement;
if (!eltern) return null;
const cs = getComputedStyle(child);
stell = document.createElement("div");
stell.style.cssText = "position:absolute;margin:0;padding:0;border:0;" +
"pointer-events:none;left:" + child.offsetLeft + "px;top:" + child.offsetTop +
"px;width:" + child.offsetWidth + "px;height:" + child.offsetHeight +
"px;transform:" + cs.transform + ";transform-origin:" + cs.transformOrigin + ";";
eltern.appendChild(stell);
host = stell;
}
const bw = host.offsetWidth, bh = host.offsetHeight;
if (bw < 4 || bh < 4) { if (stell) stell.remove(); return null; }
// Absolute Marken brauchen einen positionierten Bezug.
const posVorher = host.style.position;
if (getComputedStyle(host).position === "static") host.style.position = "relative";
const marken = [[0, 0], [1, 0], [1, 1], [0, 1]].map(([fx, fy]) => {
const m = document.createElement("div");
m.style.cssText = "position:absolute;margin:0;padding:0;border:0;width:1px;" +
"height:1px;pointer-events:none;left:" + (fx * bw) + "px;top:" + (fy * bh) + "px;";
host.appendChild(m);
return m;
});
const pts = marken.map(m => {
const r = m.getBoundingClientRect();
return { x: ((r.left + r.width / 2) - sr.left) / k,
y: ((r.top + r.height / 2) - sr.top) / k };
});
marken.forEach(m => m.remove());
host.style.position = posVorher;
if (stell) stell.remove();
return pts;
}
/* A screenshot area is never a flat surface: it carries UI, text and colour.
If the sampled pixels are all but identical, the image did not make it. */
/* Ein fehlendes Bild hinterlässt die reine Hintergrundfarbe des Rahmens,
also eine vollkommen gleichmäßige Fläche. Genau darauf wird geprüft, nicht
auf "genug Kontrast": Eine helle Formular-Oberfläche hat wenig Spanne, ist
aber nie gleichmäßig. Ein zu hoher Schwellenwert hat sie fälschlich als
fehlend gemeldet. */
const quadMitte = q => ({ x: (q[0].x + q[1].x + q[2].x + q[3].x) / 4,
y: (q[0].y + q[1].y + q[2].y + q[3].y) / 4 });
function quadEinruecken(q, f) {
const m = quadMitte(q);
return q.map(p => ({ x: p.x + (m.x - p.x) * f, y: p.y + (m.y - p.y) * f }));
}
function quadFlaeche(q) {
let s = 0;
for (let i = 0; i < 4; i++) { const a = q[i], b = q[(i + 1) % 4]; s += a.x * b.y - b.x * a.y; }
return Math.abs(s) / 2;
}
function quadEnthaelt(q, p) {
let pos = 0, neg = 0;
for (let i = 0; i < 4; i++) {
const a = q[i], b = q[(i + 1) % 4];
const d = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
if (d > 0) pos++; else if (d < 0) neg++;
}
return pos === 0 || neg === 0;
}
/* Messpunkte dort, wo Bild und Rahmen sich überlappen — und nur dort.
Beides einzeln führt in die Irre: Das Szenen-Display ist im Entwurfsraum
größer als der sichtbare Ausschnitt, weil Deckelblende, Tastaturschale und
das davorstehende iPad Teile verdecken; diese Randzonen tragen Kontrast
und täuschen ein vorhandenes Bild vor. Beim Feature-Mockup ist es
umgekehrt: das gezoomte Bild ragt weit über sein Fenster hinaus, dort
läge die Messung außerhalb der Slide. Das Raster wird über das kleinere
der beiden Vierecke gelegt und gegen das größere geprüft. */
function ueberlappPunkte(qBox, qImg) {
const A = quadEinruecken(qBox, 0.08), B = quadEinruecken(qImg, 0.08);
const raster = quadFlaeche(A) <= quadFlaeche(B) ? A : B;
const gegen = raster === A ? B : A;
const pts = [];
for (let a = 0; a <= 16; a++) {
for (let b = 0; b <= 16; b++) {
const u = a / 16, v = b / 16;
const tx = raster[0].x + (raster[1].x - raster[0].x) * u;
const ty = raster[0].y + (raster[1].y - raster[0].y) * u;
const bx = raster[3].x + (raster[2].x - raster[3].x) * u;
const by = raster[3].y + (raster[2].y - raster[3].y) * u;
const p = { x: Math.round(tx + (bx - tx) * v), y: Math.round(ty + (by - ty) * v) };
if (quadEnthaelt(gegen, p)) pts.push(p);
}
}
return pts;
}
function spanneAn(lum, pts) {
let min = 255, max = 0, n = 0;
for (const p of pts) {
const l = lum(p.x, p.y);
if (l < 0) continue;
if (l < min) min = l;
if (l > max) max = l;
n++;
}
// Zu wenige Punkte in der Slide: nichts zu beurteilen, also nicht bemängeln.
return n < 20 ? 255 : max - min;
}
/* Der Anteil dunkler Punkte, den eine Vorlage auf weißem Grund erzeugt.
Für die Geräterahmen ist Gleichmäßigkeit allein kein brauchbares Merkmal:
fehlt der Rahmen, bleiben die Rasterlinien des Hintergrunds stehen und
täuschen Spanne vor. Ein fehlendes Macbook lässt aber eine helle Fläche
zurück, und der Erwartungswert dafür wird aus der Vorlage selbst geeicht,
damit kein fester Schwellenwert an einer bestimmten Grafik klebt. */
function dunkelAnteilVorlage(im) {
const c = document.createElement("canvas");
c.width = c.height = 48;
const g = c.getContext("2d", { willReadFrequently: true });
g.fillStyle = "#fff";
g.fillRect(0, 0, 48, 48);
try { g.drawImage(im, 0, 0, 48, 48); } catch (_) { return 0; }
const d = g.getImageData(0, 0, 48, 48).data;
let dunkel = 0;
for (let i = 0; i < d.length; i += 4)
if ((d[i] * 299 + d[i + 1] * 587 + d[i + 2] * 114) / 1000 < 140) dunkel++;
return dunkel / (48 * 48);
}
function dunkelAn(lum, pts) {
let dunkel = 0, n = 0;
for (const p of pts) {
const l = lum(p.x, p.y);
if (l < 0) continue;
if (l < 140) dunkel++;
n++;
}
return n < 20 ? 1 : dunkel / n;
}
async function renderSlide(i) {
// Hilfslinien sind Werkzeug: koennten sie aus einem Tastendruck noch
// stehen, landeten sie im Bild.
clearTimeout(nudgeTimer); hideGuides();
const holder = deck.querySelectorAll(".holder")[i];
const el = holder.querySelector(".slide");
// In der Einzelansicht sind die übrigen Holder ausgeblendet; zum Messen und
// Rendern muss der Holder kurz sichtbar sein.
const restore = holder.style.display;
holder.style.display = "block";
// Auswahlrahmen und Hover-Kontur sind Werkzeug, kein Inhalt: für den Export
// abschalten, sonst wird die gelbe Umrandung mitgerendert.
const modeBefore = el.dataset.editmode;
el.dataset.editmode = "aus";
try {
await document.fonts.ready;
const imgs = [...el.querySelectorAll("img")];
await Promise.all(imgs.map(im =>
im.complete ? im.decode().catch(() => {})
: new Promise(r => { im.onload = r; im.onerror = r; })));
const broken = imgs.filter(im => !im.naturalWidth);
if (broken.length) throw new Error(broken.length + " Bild(er) nicht geladen");
/* Jede prüfpflichtige Fläche mit ihrem Viereck und, bei Geräterahmen,
mit dem aus der Vorlage geeichten Erwartungswert. */
const ziele = [...el.querySelectorAll(SHOT_BOXES)].map(c => {
// Der Geräterahmen ist selbst ein Bild; sonst steckt es im Rahmen.
const rahmen = c.tagName === "IMG";
const bild = rahmen ? c : c.querySelector("img");
if (!bild) return null;
const qBox = quadInSlide(el, rahmen ? c.parentElement : c);
const qImg = quadInSlide(el, bild);
if (!qBox || !qImg) return null;
return {
name: c.className || c.tagName.toLowerCase(),
pts: ueberlappPunkte(qBox, qImg),
rahmen,
erwartetDunkel: rahmen ? dunkelAnteilVorlage(bild) : 0,
};
}).filter(Boolean);
for (let attempt = 1; attempt <= MAX_TRIES; attempt++) {
/* Die Slide entsteht bei Renderqualität k in 1080k × 1350k, und genau
das wird auch gespeichert. Hier stand ein downscaleTo auf 1080 × 1350
— das warf bei k = 3 zwei Drittel der linearen Auflösung wieder weg.
Eine Vorlage mit 2390 px landete dadurch mit 916 px im Ergebnis,
obwohl sie schon in 2748 px gerendert vorlag. Wer eine hoch
aufgelöste Aufnahme liefert, soll sie auch im Export wiederfinden;
kleiner rechnen kann jedes Zielsystem selbst. */
const canvas = await htmlToImage.toCanvas(el, renderOptions());
const ctx = canvas.getContext("2d", { willReadFrequently: true });
// Die Messpunkte stehen in Slide-Koordinaten (1080 breit) und werden
// auf die tatsächliche Ausgabegröße umgerechnet.
const AW = canvas.width, AH = canvas.height, af = AW / 1080;
const px = ctx.getImageData(0, 0, AW, AH).data;
const lum = (x, y) => {
const sx = Math.round(x * af), sy = Math.round(y * af);
if (sx < 0 || sy < 0 || sx >= AW || sy >= AH) return -1;
const j = (sy * AW + sx) * 4;
return (px[j] * 299 + px[j + 1] * 587 + px[j + 2] * 114) / 1000;
};
const befund = ziele.map(z => {
const spanne = spanneAn(lum, z.pts);
// Eine leere Fläche trägt die reine Hintergrundfarbe ihres Rahmens.
let fehlt = spanne <= LEER_SPANNE;
let dunkel = null;
// Beim Geräterahmen greift Gleichmäßigkeit nicht: fehlt er, bleibt
// der gerasterte Hintergrund stehen. Dann zählt, wie viel Dunkles
// ankommt, gemessen gegen die Vorlage.
if (!fehlt && z.rahmen && z.erwartetDunkel >= 0.08) {
dunkel = dunkelAn(lum, z.pts);
if (dunkel < z.erwartetDunkel * 0.5) fehlt = true;
}
return { name: z.name, spanne, dunkel, erwartet: z.erwartetDunkel, fehlt };
});
const fehlend = befund.filter(b => b.fehlt);
if (!fehlend.length) {
const blob = await new Promise(r => canvas.toBlob(r, "image/png"));
if (blob) return blob;
}
if (attempt < MAX_TRIES) {
console.warn("Slide " + (i + 1) + ": Versuch " + attempt + " unvollstaendig. " +
befund.map(b => b.name + " Spanne " + b.spanne.toFixed(1) +
(b.dunkel === null ? "" : ", dunkel " + b.dunkel.toFixed(3) +
" erwartet " + b.erwartet.toFixed(3)) + (b.fehlt ? " <== leer" : "")).join(" | "));
setStatus("Slide " + (i + 1) + ": " + fehlend.map(b => b.name).join(", ") +
" fehlte, Versuch " + (attempt + 1) + " ...");
await new Promise(r => setTimeout(r, 180 * attempt));
}
}
throw new Error("Slide " + (i + 1) + " nach " + MAX_TRIES +
" Versuchen unvollstaendig. Details in der Konsole.");
} finally {
el.dataset.editmode = modeBefore;
holder.style.display = restore;
}
}
// Die Ausgabegröße hängt an der Renderqualität und wird mitgesagt, damit
// sichtbar ist, was tatsächlich auf der Platte landet.
const ausgabeGroesse = () => (1080 * superSample) + " × " + (1350 * superSample) + " px";
async function downloadOne() {
setStatus("Rendere Slide " + (current + 1) + " …");
downloadBlob(await renderSlide(current), fileName(current));
setStatus("Slide " + (current + 1) + " gespeichert, " + ausgabeGroesse(), "ok");
}
async function downloadAll() {
const zip = new JSZip();
for (let i = 0; i < SLIDES.length; i++) {
setStatus("Rendere Slide " + (i + 1) + " von " + SLIDES.length + " …");
zip.file(fileName(i), await renderSlide(i));
}
show(current);
setStatus("Packe ZIP …");
const path = await downloadBlob(await zip.generateAsync({ type: "blob" }),
"hivegrid-post1-loesung-" + variant + ".zip");
setStatus("ZIP gespeichert: " + path.split("/").pop() + ", " + ausgabeGroesse(), "ok");
}
const guard = fn => () => fn().catch(err => {
console.error(err);
setStatus("Fehler: " + err.message, "err");
});
function buildDeck() {
SLIDES.forEach((sl, i) => {
const holder = document.createElement("div");
holder.className = "holder";
holder.dataset.n = String(i);
holder.appendChild(buildSlide(sl, i));
deck.appendChild(holder);
});
SLIDES.forEach((sl, i) => {
const b = document.createElement("button");
b.dataset.n = String(i);
b.setAttribute("aria-pressed", String(i === 0));
b.innerHTML = '<span class="num">' + (i + 1) + '</span>' +
(sl.type === "title" ? "Titel" : sl.eyebrow);
nav.appendChild(b);
});
}
deck.addEventListener("click", e => {
if (editMode === "an" && e.target.closest("[data-edit]")) return;
const h = e.target.closest(".holder");
if (h) show(Number(h.dataset.n));
});
/* Ziehen: Die Vorschau ist skaliert, die Slide nicht. Mausdeltas werden
deshalb durch den Maßstab geteilt, sonst wandert das Element bei kleiner
Vorschau um ein Vielfaches. */
deck.addEventListener("mousedown", e => {
if (editMode !== "an") return;
const node = e.target.closest("[data-edit]");
if (!node) return;
const holder = node.closest(".holder");
const page = node.closest(".page");
const idx = Number(page.dataset.j); // Besitzer-Seite, nicht die angeklickte Slide
const key = node.dataset.edit;
e.preventDefault();
show(Number(holder.dataset.n));
pick(idx, key);
// Den Maßstab nehmen, mit dem fitStrip gerechnet hat, nicht das Rechteck
// messen: .strip ist ein Flex-Kind der schon skalierten Hülle, seine
// Border-Box misst deshalb Breite mal Maßstab hoch zwei. Geteilt wurde
// damit durch das Quadrat — bei eingepasster Ansicht wanderte das Element
// um ein Vielfaches der Mausbewegung.
const k = zoom || autoFit();
const set = (edits[idx][key] = edits[idx][key] || {});
const startX = e.clientX, startY = e.clientY;
const baseX = set.dx || 0, baseY = set.dy || 0;
node.style.cursor = "grabbing";
showGuides(holder.querySelector(".slide"));
updateGuides(node);
const move = ev => {
set.dx = Math.round(baseX + (ev.clientX - startX) / k);
set.dy = Math.round(baseY + (ev.clientY - startY) / k);
applyEditsAll(); // zieht alle Kopien der Seite mit
updateGuides(node);
syncEditPanel();
};
const up = () => {
node.style.cursor = "";
hideGuides();
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", up);
saveState();
};
document.addEventListener("mousemove", move);
document.addEventListener("mouseup", up);
});
function pick(idx, key) {
picked = { slide: idx, key };
deck.querySelectorAll("[data-edit]").forEach(n => n.classList.remove("picked"));
deck.querySelectorAll('.page[data-j="' + idx + '"] [data-edit="' + key + '"]')
.forEach(n => n.classList.add("picked"));
syncEditPanel();
}
let nudgeTimer = null;
function nudge(dx, dy) {
if (!picked) return;
const set = (edits[picked.slide][picked.key] = edits[picked.slide][picked.key] || {});
set.dx = (set.dx || 0) + dx;
set.dy = (set.dy || 0) + dy;
applyEditsAll();
pick(picked.slide, picked.key);
// Pixelweise schieben braucht dieselbe Rueckmeldung wie das Ziehen, nur
// soll sie danach von selbst verschwinden.
const holder = deck.querySelectorAll(".holder")[current];
const node = holder && holder.querySelector('.page[data-j="' + picked.slide +
'"] [data-edit="' + picked.key + '"]');
if (node) {
showGuides(holder.querySelector(".slide"));
updateGuides(node);
clearTimeout(nudgeTimer);
nudgeTimer = setTimeout(hideGuides, 900);
}
saveState();
}
function resize(dir) {
if (!picked) return;
const std = STANDARD[picked.key];
if (!std || !std.prop) return;
const base = stdSize(picked.key, picked.slide);
const set = (edits[picked.slide][picked.key] = edits[picked.slide][picked.key] || {});
set.size = Math.max(8, (set.size || base) + dir * stepFor(picked.key, picked.slide));
applyEditsAll();
pick(picked.slide, picked.key);
saveState();
}
function resetOne() {
if (!picked) return;
delete edits[picked.slide][picked.key];
applyEditsAll();
pick(picked.slide, picked.key);
saveState();
}
function resetAll() {
edits = SLIDES.map(() => ({}));
applyEditsAll();
picked = null;
deck.querySelectorAll("[data-edit]").forEach(n => n.classList.remove("picked"));
syncEditPanel();
saveState();
setStatus("Alle Slides auf Standard zurückgesetzt", "ok");
}
/* X und Y zaehlen ab der Mitte der Slide, nicht ab der Stelle, an der das
Element von sich aus laege. Nur so bedeuten gleiche Werte bei zwei
verschiedenen Elementen auch dieselbe Stelle.
Gemessen wird die OBERE LINKE ECKE, nicht der Mittelpunkt. Eine
zweizeilige und eine dreizeilige Ueberschrift sind unterschiedlich hoch;
bei gleichem Mittelpunkt liegen ihre Oberkanten 39 px auseinander und der
hoehere Block waechst nach oben ins Tag. Ab der ersten Zeile gerechnet
stimmt es unabhaengig von der Zeilenzahl.
Gemessen wird die erste sichtbare Kopie: in der Einzelansicht sind die
uebrigen Holder ausgeblendet und liefern ein leeres Rechteck. */
function centreOf(slideIdx, key) {
const nodes = deck.querySelectorAll(
'.page[data-j="' + slideIdx + '"] [data-edit="' + key + '"]');
for (const n of nodes) {
const r = n.getBoundingClientRect();
if (!r.width) continue;
const holder = n.closest(".slide");
const sr = holder && holder.getBoundingClientRect();
if (!sr || !sr.width) continue;
const k = sr.width / 1080;
return {
x: Math.round((r.left - sr.left) / k - 540),
y: Math.round((r.top - sr.top) / k - 675),
};
}
return null;
}
function syncEditPanel() {
const box = document.getElementById("edit-info");
if (!picked) {
box.dataset.for = "";
box.innerHTML = '<span class="dim">Nichts ausgewählt</span>';
return;
}
const std = STANDARD[picked.key] || {};
const e = (edits[picked.slide] || {})[picked.key] || {};
const base = stdSize(picked.key, picked.slide);
const id = picked.slide + ":" + picked.key;
// Nur bei einer anderen Auswahl neu aufbauen. Beim Ziehen laeuft das hier
// pro Mausbewegung; ein Neuaufbau wuerde die Eingabe unter den Fingern
// wegreissen.
if (box.dataset.for !== id) {
box.dataset.for = id;
box.innerHTML =
'<b>' + (std.label || picked.key) + '</b> · Slide ' + (picked.slide + 1) +
'<div class="fields">' +
(std.prop
? '<label for="f-size">' + (std.prop === "font-size" ? "Schrift" : "Breite") +
'</label><input id="f-size" type="number" step="1" style="grid-column:2/5">'
: '') +
'<label for="f-dx">X</label><input id="f-dx" type="number" step="1">' +
'<label for="f-dy">Y</label><input id="f-dy" type="number" step="1">' +
'</div>' +
'<div class="hint">' + (std.prop ? 'Standard ' + base + ' px · ' : '') +
'Obere linke Ecke, 0 / 0 ist die Mitte der Slide</div>';
}
const put = (sel, v) => {
const el = box.querySelector(sel);
if (el && document.activeElement !== el) el.value = v;
};
if (std.prop) put("#f-size", e.size || base);
const c = centreOf(picked.slide, picked.key);
if (c) { put("#f-dx", c.x); put("#f-dy", c.y); }
}
/* Eingabe wirkt sofort. Kein pick() danach: das baut das Feld neu auf und
der Fokus waere weg. */
document.getElementById("edit-info").addEventListener("input", ev => {
const el = ev.target;
if (!picked || !el.matches("input")) return;
const raw = el.value.trim();
const n = Number(raw);
if (raw !== "" && !Number.isFinite(n)) return;
const set = (edits[picked.slide][picked.key] = edits[picked.slide][picked.key] || {});
if (el.id === "f-dx" || el.id === "f-dy") {
// Der Wert ist eine Stelle auf der Slide, kein Verschiebebetrag. Der
// Unterschied zur jetzigen Stelle wandert deshalb in den Versatz.
const c = centreOf(picked.slide, picked.key);
if (!c) return;
const ziel = raw === "" ? 0 : Math.round(n);
if (el.id === "f-dx") set.dx = (set.dx || 0) + (ziel - c.x);
else set.dy = (set.dy || 0) + (ziel - c.y);
}
// Leere Groesse heisst Standard, nicht null.
else if (el.id === "f-size") {
if (raw === "") delete set.size;
else set.size = Math.max(8, Math.round(n));
}
applyEditsAll();
saveState();
});
/* Welche Kategorien offen sind, ist Bedienvorliebe und gehört nicht in den
Projektstand: gilt für beide Projekte gleichermaßen. */
(function rememberGroups() {
const KEY = "hivegrid.ui.gruppen";
let open = [];
try { open = JSON.parse(localStorage.getItem(KEY) || "[]"); } catch (_) {}
document.querySelectorAll("details.group[data-grp]").forEach(d => {
if (open.includes(d.dataset.grp)) d.open = true;
d.addEventListener("toggle", () => {
const now = [...document.querySelectorAll("details.group[open][data-grp]")]
.map(x => x.dataset.grp);
try { localStorage.setItem(KEY, JSON.stringify(now)); } catch (_) {}
});
});
})();
document.getElementById("forget").addEventListener("click", forgetProject);
document.getElementById("editmodes").addEventListener("click", e => {
const b = e.target.closest("button");
if (!b) return;
editMode = b.dataset.e;
press("editmodes", "e", editMode);
deck.querySelectorAll(".slide").forEach(el => { el.dataset.editmode = editMode; });
if (editMode === "aus") {
picked = null;
deck.querySelectorAll("[data-edit]").forEach(n => n.classList.remove("picked"));
syncEditPanel();
}
});
document.getElementById("sz-plus").addEventListener("click", () => resize(1));
document.getElementById("sz-minus").addEventListener("click", () => resize(-1));
document.getElementById("reset-one").addEventListener("click", resetOne);
/* ─── Angleichen über alle Slides ────────────────────────────────────────
Das gewählte Element ist die Vorlage, alle gleichnamigen auf den anderen
Slides ziehen nach. Angeglichen wird die Stelle, nicht der Versatz: jedes
Element liegt von sich aus woanders, und nur die Stelle ist vergleichbar.
In der Einzelansicht haben die übrigen Holder kein Layout, weil sie
ausgeblendet sind. Jeder wird deshalb kurz sichtbar gemacht — derselbe
Weg, den auch der Export geht. */
function alignAll(was) {
if (!picked) return;
const key = picked.key;
const ziel = was === "size" ? null : centreOf(picked.slide, key);
if (was !== "size" && !ziel) { setStatus("Element nicht messbar", "err"); return; }
const size = (edits[picked.slide][key] || {}).size;
const holders = deck.querySelectorAll(".holder");
let n = 0;
SLIDES.forEach((_, i) => {
if (i === picked.slide) return;
const holder = holders[i];
if (!holder || !holder.querySelector('.page[data-j="' + i + '"] [data-edit="' + key + '"]')) return;
const restore = holder.style.display;
holder.style.display = "block";
const set = (edits[i][key] = edits[i][key] || {});
if (was === "size") {
if (size == null) delete set.size; else set.size = size;
} else {
const c = centreOf(i, key);
if (c) {
set.dx = (set.dx || 0) + (ziel.x - c.x);
set.dy = (set.dy || 0) + (ziel.y - c.y);
}
}
holder.style.display = restore;
n++;
});
applyEditsAll();
syncEditPanel();
saveState();
const label = (STANDARD[key] || {}).label || key;
setStatus(label + ": " + (was === "size" ? "Größe" : "Position") +
" auf " + n + " weitere Slide" + (n === 1 ? "" : "s") + " übertragen", "ok");
}
document.getElementById("align-pos").addEventListener("click", () => alignAll("pos"));
document.getElementById("align-size").addEventListener("click", () => alignAll("size"));
/* ─── Merkzettel ─────────────────────────────────────────────────────────
Der gemerkte Stand liegt als eigenes Projekt in state/, also im Repo und
damit auch auf der anderen Maschine. Zurueckgeholt wird er, indem er zum
laufenden Stand gemacht und die Seite neu geladen wird: dann greift
derselbe Ladepfad wie sonst auch, statt dass ich hier von Hand jede
Einstellung nachziehe und dabei eine vergesse. */
const SNAP_PROJECT = PROJECT + "-merk";
const snapSave = document.getElementById("snap-save");
const snapLoad = document.getElementById("snap-load");
async function snapExists() {
try {
const res = await fetch("./state/" + SNAP_PROJECT + ".json", { cache: "no-store" });
return res.ok;
} catch (_) { return false; }
}
snapExists().then(ok => { snapLoad.disabled = !ok; });
snapSave.addEventListener("click", async () => {
snapSave.disabled = true;
try {
const res = await fetch("/state?project=" + SNAP_PROJECT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(currentState()),
});
if (!res.ok) throw new Error("HTTP " + res.status);
snapLoad.disabled = false;
setStatus("Stand gemerkt", "ok");
} catch (err) {
setStatus("Merken fehlgeschlagen: " + err.message, "err");
} finally { snapSave.disabled = false; }
});
snapLoad.addEventListener("click", async () => {
snapLoad.disabled = true;
try {
const res = await fetch("./state/" + SNAP_PROJECT + ".json", { cache: "no-store" });
if (!res.ok) throw new Error("kein gemerkter Stand");
const d = await res.json();
// Erst den laufenden Timer entschaerfen, sonst schreibt er beim Entladen
// den alten Stand ueber den gerade zurueckgeholten.
clearTimeout(pushTimer); pushTimer = null;
const put = await fetch("/state?project=" + PROJECT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(d),
});
if (!put.ok) throw new Error("HTTP " + put.status);
try { localStorage.setItem(STORE, JSON.stringify(d)); } catch (_) {}
location.reload();
} catch (err) {
snapLoad.disabled = false;
setStatus("Zurueckholen fehlgeschlagen: " + err.message, "err");
}
});
document.getElementById("reset-all").addEventListener("click", resetAll);
// Übersicht der Standards, direkt aus derselben Tabelle wie der Reset.
(function renderStandards() {
const groups = [
["Schrift", ["badge", "headline", "body", "wabe-tagline", "wabe-claim", "title-head", "title-sub"]],
["Breite", ["wabe-logo", "logo-full", "screen-logo", "laptop", "szene"]],
];
let html = "";
for (const [title, keys] of groups) {
html += "<h4>" + title + "</h4><table>";
for (const k of keys) {
const st = STANDARD[k];
html += "<tr><td>" + st.label + "</td><td>" + st.size + " px</td></tr>";
}
html += "</table>";
}
html += '<h4>Fest</h4><table>' +
'<tr><td>Slide</td><td>1080 × 1350</td></tr>' +
'<tr><td>Innenabstand</td><td>92 px</td></tr>' +
'<tr><td>Tag ausgerückt</td><td>−20 px</td></tr>' +
'<tr><td>Mockup</td><td>1090 px · 16:10</td></tr>' +
'</table>';
document.getElementById("std-list").innerHTML = html;
})();
document.getElementById("tagouts").addEventListener("click", e => {
const b = e.target.closest("button");
if (b) { tagOut = b.dataset.o; press("tagouts", "o", tagOut); applyAll(); saveState(); }
});
document.getElementById("views").addEventListener("click", e => {
const b = e.target.closest("button");
if (b) { view = b.dataset.w; deck.dataset.view = view; press("views", "w", view); zoom = null; show(current); saveState(); }
});
window.addEventListener("resize", fitStrip);
/* deltaMode: 0 = Pixel, 1 = Zeilen (Firefox), 2 = Seiten. Ohne Umrechnung
zoomt Firefox pro Rastung um ein Vielfaches weniger als Chrome. */
function wheelPx(v, mode) { return v * (mode === 1 ? 16 : mode === 2 ? 400 : 1); }
/* Blankes Rad zoomt — eine gewöhnliche PC-Maus soll ohne Zusatztaste
auskommen. Strg/Cmd + Rad tut dasselbe, damit der Trackpad-Pinch (der
ctrlKey mitschickt) weiter greift. Waagerechtes Wischen und ⇧ + Rad
schieben dagegen den Ausschnitt: ein Trackpad darf sich nicht wie eine
Zoomwippe anfühlen. */
document.getElementById("scroller").addEventListener("wheel", e => {
const sc = e.currentTarget;
const dx = wheelPx(e.deltaX, e.deltaMode), dy = wheelPx(e.deltaY, e.deltaMode);
if (e.shiftKey || (!dy && dx)) {
e.preventDefault();
sc.scrollLeft += (dx || dy);
return;
}
if (!dy) return;
e.preventDefault();
const r = sc.getBoundingClientRect();
// Exponentiell gerechnet fühlen sich Rastung (~100 px auf einen Schlag)
// und Trackpad (viele kleine Werte) gleich an. Der Deckel hält die
// Schwung-Schübe im Zaum, die macOS nach dem Loslassen nachschiebt.
const k = Math.min(3, Math.max(1 / 3, Math.exp(-dy * 0.0015)));
setZoom((zoom || autoFit()) * k,
{ x: e.clientX - r.left, y: e.clientY - r.top });
}, { passive: false });
/* Panning: linke Maustaste auf der Fläche reicht. Leertaste und mittlere
Taste bleiben als gewohnte Wege bestehen — im Bearbeiten-Modus sind sie
der einzige, denn dort gehört der linke Zug dem Element unter dem Zeiger. */
(function panning() {
const sc = document.getElementById("scroller");
let space = false, active = null, dragged = false;
document.addEventListener("keydown", e => {
if (e.code !== "Space" || e.target.matches("input, textarea")) return;
e.preventDefault();
if (!space) { space = true; sc.classList.add("panready"); }
});
document.addEventListener("keyup", e => {
if (e.code !== "Space") return;
space = false; sc.classList.remove("panready");
});
sc.addEventListener("mousedown", e => {
dragged = false;
if (!space && e.button !== 1) {
// Linke Taste zieht die Fläche, außer sie hat im Bearbeiten-Modus
// ein Element erwischt: das verschiebt sich selbst.
if (e.button !== 0) return;
if (editMode === "an" && e.target.closest("[data-edit]")) return;
}
e.preventDefault();
active = { x: e.clientX, y: e.clientY, l: sc.scrollLeft, t: sc.scrollTop };
sc.classList.add("panning");
});
document.addEventListener("mousemove", e => {
if (!active) return;
const dx = e.clientX - active.x, dy = e.clientY - active.y;
// Erst ab ein paar Pixeln ist es ein Zug und kein Klick, sonst
// verschluckt jede zittrige Hand die Slide-Auswahl.
if (Math.abs(dx) + Math.abs(dy) > 3) dragged = true;
sc.scrollLeft = active.l - dx;
sc.scrollTop = active.t - dy;
});
document.addEventListener("mouseup", () => {
active = null; sc.classList.remove("panning");
});
// Nach echtem Ziehen den Klick schlucken: sonst springt die Auswahl auf
// die Slide, über der die Maus zufällig loslässt.
sc.addEventListener("click", e => {
if (!dragged) return;
dragged = false;
e.stopPropagation();
e.preventDefault();
}, true);
})();
document.getElementById("zoom-in").addEventListener("click", () => setZoom((zoom || autoFit()) * 1.25));
document.getElementById("zoom-out").addEventListener("click", () => setZoom((zoom || autoFit()) / 1.25));
document.getElementById("zoom-fit").addEventListener("click", () => { zoom = null; fitStrip(); });
nav.addEventListener("click", e => {
const b = e.target.closest("button"); if (b) show(Number(b.dataset.n));
});
document.getElementById("variants").addEventListener("click", e => {
const b = e.target.closest("button");
if (b) { variant = b.dataset.v; press("variants", "v", variant); applyAll(); saveState(); }
});
document.getElementById("layouts").addEventListener("click", e => {
const b = e.target.closest("button");
if (b) { layoutState[current] = b.dataset.l; syncLayoutButtons(); rebuildPage(current); applyAll(); saveState(); }
});
document.getElementById("conts").addEventListener("click", e => {
const b = e.target.closest("button");
if (b) { continuous = b.dataset.c; press("conts", "c", continuous); applyAll(); saveState(); }
});
document.getElementById("bgs").addEventListener("click", e => {
const b = e.target.closest("button");
if (b) { bgState[current].bg = b.dataset.b; syncBgButtons(); applyAll(); saveState(); }
});
document.getElementById("ints").addEventListener("click", e => {
const b = e.target.closest("button");
if (b) { bgState[current].int = b.dataset.i; syncBgButtons(); applyAll(); saveState(); }
});
document.getElementById("frames").addEventListener("click", e => {
const b = e.target.closest("button");
if (b) { frameStyle = b.dataset.f; press("frames", "f", frameStyle); applyAll(); saveState(); }
});
document.getElementById("tilts").addEventListener("click", e => {
const b = e.target.closest("button");
if (b) { tilt = b.dataset.t; press("tilts", "t", tilt); applyAll(); saveState(); }
});
document.getElementById("quality").addEventListener("click", e => {
const b = e.target.closest("button");
if (b) { superSample = Number(b.dataset.q); press("quality", "q", b.dataset.q); saveState(); }
});
document.getElementById("dl-png").addEventListener("click", guard(downloadOne));
document.getElementById("dl-all").addEventListener("click", guard(downloadAll));
const BG_CYCLE = ["formen", "formen-g", "nur-g", "waben", "punkte", "raster", "aus"];
document.addEventListener("keydown", e => {
if (e.metaKey || e.ctrlKey || e.altKey) return;
// In einem Eingabefeld gehoeren die Tasten dem Feld, nicht den Kuerzeln.
if (e.target.matches("input, textarea, select")) return;
const k = e.key.toLowerCase();
if (editMode === "an" && picked && e.key.startsWith("Arrow")) {
const step = e.shiftKey ? 10 : 1;
if (e.key === "ArrowRight") nudge(step, 0);
else if (e.key === "ArrowLeft") nudge(-step, 0);
else if (e.key === "ArrowDown") nudge(0, step);
else if (e.key === "ArrowUp") nudge(0, -step);
e.preventDefault();
return;
}
if (e.key === "ArrowRight") show(current + 1);
else if (e.key === "ArrowLeft") show(current - 1);
else if (k === "v") { variant = variant === "dunkel" ? "hell" : "dunkel"; press("variants", "v", variant); applyAll(); saveState(); }
else if (k === "l") {
const cat = SLIDES[current].layouts;
const n = cat.findIndex(x => x.id === layoutState[current]);
layoutState[current] = cat[(n + 1) % cat.length].id;
syncLayoutButtons(); rebuildPage(current); applyAll(); saveState();
}
else if (k === "m") {
const st = bgState[current];
st.bg = BG_CYCLE[(BG_CYCLE.indexOf(st.bg) + 1) % BG_CYCLE.length];
syncBgButtons(); applyAll();
}
else if (k === "i") {
const st = bgState[current];
st.int = st.int === "dezent" ? "kraeftig" : "dezent";
syncBgButtons(); applyAll();
}
else if (k === "t") { tilt = tilt === "an" ? "aus" : "an"; press("tilts", "t", tilt); applyAll(); saveState(); }
else if (e.key === "+" || e.key === "=") { setZoom((zoom || autoFit()) * 1.25); }
else if (e.key === "-") { setZoom((zoom || autoFit()) / 1.25); }
else if (e.key === "0") { zoom = null; fitStrip(); }
else if (k === "w") { view = view === "streifen" ? "einzeln" : "streifen"; deck.dataset.view = view; press("views", "w", view); zoom = null; show(current); }
else if (k === "e") { document.querySelector('#editmodes button[data-e="' + (editMode === "an" ? "aus" : "an") + '"]').click(); }
else if (k === "r") {
const cyc = ["chamfer", "rund", "stapel", "aus"];
frameStyle = cyc[(cyc.indexOf(frameStyle) + 1) % cyc.length];
press("frames", "f", frameStyle); applyAll(); saveState();
}
else if (k === "g") { tagOut = tagOut === "an" ? "aus" : "an"; press("tagouts", "o", tagOut); applyAll(); saveState(); }
else if (k === "c") { continuous = continuous === "an" ? "aus" : "an"; press("conts", "c", continuous); applyAll(); saveState(); }
else if (k === "d") guard(downloadOne)();
else if (k === "a") guard(downloadAll)();
});
/* Boot: inline the screens, wait for Manrope to actually be resident
(otherwise the first export silently falls back to a system font), and
pre-compute the font CSS once instead of on every render. */
(async function boot() {
try {
setStatus("Lade Screens ...");
await preloadAssets();
const restored = await loadState();
buildDeck();
deck.dataset.view = view;
press("variants", "v", variant);
press("tilts", "t", tilt);
press("quality", "q", String(superSample));
press("conts", "c", continuous);
press("tagouts", "o", tagOut);
press("frames", "f", frameStyle);
press("views", "w", view);
show(current);
if (restored) setStatus("Projektstand geladen", "ok");
await document.fonts.ready;
fontEmbedCss = await htmlToImage.getFontEmbedCSS(deck.querySelector(".slide"));
["dl-png", "dl-all"].forEach(id => document.getElementById(id).disabled = false);
if (checkCollisions()) setStatus("Bereit. 5 Slides, 1080 × 1350 px", "ok");
} catch (err) {
console.error(err);
setStatus("Fehler beim Laden: " + err.message, "err");
}
})();
})();
</script>
</body>
</html>