Files
posts/post1.html
T
dejanandClaude Opus 5 94e1b9a84a Canvas mit der Maus schieben und zoomen
Das blanke Rad zoomt jetzt, die linke Maustaste zieht die Flaeche. Eine
gewoehnliche PC-Maus kam vorher nicht an den Ausschnitt heran: Zoomen ging
nur mit Strg/Cmd, Schieben nur ueber Leertaste oder mittlere Taste.

Die Radschritte laufen exponentiell ueber deltaY statt in festen Spruengen,
mit Umrechnung der deltaMode-Einheiten -- ohne die zoomt Firefox pro Rastung
um ein Vielfaches weniger als Chrome. Waagerechtes Wischen und Shift+Rad
schieben weiter, damit sich ein Trackpad nicht wie eine Zoomwippe anfuehlt.
Im Bearbeiten-Modus behaelt das Element unter dem Zeiger die linke Taste.
Nach einem echten Zug wird der Klick geschluckt, sonst sprang die Auswahl
auf die Slide, ueber der die Maus zufaellig losliess.

Dabei zwei aeltere Fehler mitgenommen:

setZoom rechnete den Zeigerpunkt per Dreisatz auf scrollLeft hoch und
uebersah das Polster der Flaeche. Der Inhalt lief pro Schritt um 60 x
(Faktor-1) px weg. Der Punkt wird jetzt gemessen statt gerechnet.

Der Bearbeiten-Zug leitete seinen Massstab aus der Border-Box von .strip ab.
Die misst Breite mal Massstab hoch zwei, weil .strip ein Flex-Kind der schon
skalierten Huelle ist -- geteilt wurde also durch das Quadrat, das Element
wanderte bei eingepasster Ansicht um das 5,6-fache der Mausbewegung.

Geprueft mit echten Rad- und Zieh-Ereignissen ueber das DevTools-Protokoll,
15 Pruefungen gegen beide Dateien.

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

2241 lines
98 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; }
.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; }
/* 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; }
/* 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);
}
.szene-screen > img {
position: absolute; top: 0; left: 0; width: 100%;
transform-origin: top left;
}
/* Beide Aufnahmen liegen originalgetreu auf ihrem Display: nichts gezoomt,
nichts abgeschnitten. Was frei bleibt, traegt den Hintergrundton der
Aufnahme und ist deshalb nicht als Rand zu erkennen.
Vom Macbook-Display ist nicht alles zu sehen: oben die Blende des
Deckels samt Kameraaussparung, unten die Tastaturschale, rechts das
davorstehende iPad. Das groesste vollstaendig sichtbare Rechteck im
Design-Raum ist 2726 x 1682 bei (74, 154) - gemessen, nicht geschaetzt.
Das Board liegt darin zentriert, deshalb Breite 78,877 % und der
Versatz unten. Der Chat steht mittig, sein Display verdeckt nichts. */
/* Exakt der Hintergrundton der jeweiligen Aufnahme, aus deren Ecken
gemessen. Schon fuenf Stufen Unterschied zeichnen die freie Flaeche
als Rand nach. */
.szene-mb { background: #F5F4F4; }
.szene-ip { background: #FAFAFA; }
.szene-mb > img { width: 78.877%; transform: translate(2.715%, 14.645%); }
.szene-ip > img { transform: translate(0, 16.49%); }
.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);
}
.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);
}
</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">
<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="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 jeder Auftrag steht.</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",
head: 'Jedes Projekt bekommt<br>automatisch <span class="a">seinen 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 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";
function currentState() {
return {
v: 1, variant, tilt, superSample, continuous, view, tagOut, frameStyle,
current, layoutState, bgState, edits,
};
}
/* 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) return false;
// Stand aus dem Browser, aber noch keine Datei: einmalig hinueberschreiben,
// sonst bliebe ein ueber Wochen gewachsener Stand ungeteilt liegen.
if (!fromFile) 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 den Screen der nächsten Feature-Slide.
const withImg = SLIDES.filter(x => x.img);
const other = withImg[(withImg.findIndex(x => x.img === sl.img) + 1) % withImg.length];
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);
return el;
}
/* 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.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;
}
/* Renders oversized, then scales down to exactly 1080x1350 in halving steps.
Two separate wins: the browser rasterises transforms, clip-paths and the
downscaled screenshots at a higher resolution, and Instagram never has to
resize the upload itself (widths of 320 to 1080 keep their resolution;
anything larger is resized and recompressed by Instagram, so quality is
lost twice). */
function downscaleTo(src, w, h) {
let cur = src;
while (cur.width >= w * 2 && cur.height >= h * 2) {
const step = document.createElement("canvas");
step.width = Math.max(w, Math.round(cur.width / 2));
step.height = Math.max(h, Math.round(cur.height / 2));
const c = step.getContext("2d");
c.imageSmoothingEnabled = true;
c.imageSmoothingQuality = "high";
c.drawImage(cur, 0, 0, step.width, step.height);
cur = step;
}
if (cur.width === w && cur.height === h) return cur;
const out = document.createElement("canvas");
out.width = w; out.height = h;
const c = out.getContext("2d");
c.imageSmoothingEnabled = true;
c.imageSmoothingQuality = "high";
c.drawImage(cur, 0, 0, w, h);
return out;
}
/* 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. */
const SHOT_BOXES = ".mock-inner, .laptop-screen, .hex-shot, .szene-screen";
const MAX_TRIES = 5;
// Rect of a child inside the slide, in slide coordinates (the preview is
// scaled, the canvas is not).
function boxInSlide(el, child) {
const sr = el.getBoundingClientRect();
const cr = child.getBoundingClientRect();
const k = sr.width / 1080;
return {
x: Math.round((cr.left - sr.left) / k),
y: Math.round((cr.top - sr.top) / k),
w: Math.round(cr.width / k),
h: Math.round(cr.height / k),
};
}
/* 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. */
function areaRange(ctx, b) {
const ix = Math.round(b.w * 0.08), iy = Math.round(b.h * 0.08);
const x = Math.max(0, b.x + ix), y = Math.max(0, b.y + iy);
const w = Math.min(1080 - x, b.w - ix * 2), h = Math.min(1350 - y, b.h - iy * 2);
if (w < 8 || h < 8) return 255;
const data = ctx.getImageData(x, y, w, h).data;
let min = 255, max = 0;
for (let i = 0; i < data.length; i += 16) { // jedes vierte Pixel
const lum = (data[i] * 299 + data[i + 1] * 587 + data[i + 2] * 114) / 1000;
if (lum < min) min = lum;
if (lum > max) max = lum;
}
return max - min;
}
async function renderSlide(i) {
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");
const boxes = [...el.querySelectorAll(SHOT_BOXES)].map(c => boxInSlide(el, c));
for (let attempt = 1; attempt <= MAX_TRIES; attempt++) {
const raw = await htmlToImage.toCanvas(el, renderOptions());
const canvas = downscaleTo(raw, 1080, 1350);
const ctx = canvas.getContext("2d", { willReadFrequently: true });
const ranges = boxes.map(b => areaRange(ctx, b));
const missing = ranges.filter(r => r <= 3).length;
if (!missing) {
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. " +
"Luminanzspannen je Bildflaeche: " + ranges.map(r => r.toFixed(1)).join(", "));
setStatus("Slide " + (i + 1) + ": Screenshot 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;
}
}
async function downloadOne() {
setStatus("Rendere Slide " + (current + 1) + " …");
downloadBlob(await renderSlide(current), fileName(current));
setStatus("Slide " + (current + 1) + " gespeichert", "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(), "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";
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
syncEditPanel();
};
const up = () => {
node.style.cursor = "";
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();
}
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);
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");
}
function syncEditPanel() {
const box = document.getElementById("edit-info");
if (!picked) { 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 val = e.size || base;
const unit = std.prop === "font-size" ? "px Schrift" : "px breit";
box.innerHTML =
'<b>' + (std.label || picked.key) + '</b> · Slide ' + (picked.slide + 1) + '<br>' +
(std.prop ? val + " " + unit + (e.size ? " (Standard " + base + ")" : "") + "<br>" : "") +
"Versatz " + (e.dx || 0) + " / " + (e.dy || 0) + " px";
}
/* 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);
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;
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>