Files
posts/post1.html
T
dejanandClaude Opus 5 170b72a538 Canvas-Bearbeitung, Projektspeicher und Arbeitsfläche
Der Generator ist von einer Vorschau zu einer Werkbank geworden: Slides lassen
sich direkt im Canvas verschieben und skalieren, der Stand bleibt erhalten.

- Streifenansicht mit allen Slides nebeneinander, Auswahl per Klick
- Durchlaufendes Blatt: jede Slide trägt die Inhalte ihrer Nachbarn, damit
  verschobene Elemente über die Kante hinweg weiterlaufen
- Bearbeiten im Canvas: ziehen, Schriftgrößen und Bildbreiten, Reset je
  Element oder komplett
- Projektstand pro Post im Browser gespeichert, Navigation zwischen den Posts
- Arbeitsfläche vom Bedienfeld getrennt, eigener Zoom und Panning
- Bildrahmen als Chamfer, rund, Stapel oder aus; Schatten schaltbar
- Screenshots zeigen den vollen Screen statt eines Ausschnitts
- Kategorien in der Sidebar standardmäßig zugeklappt

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 14:07:23 +02:00

1935 lines
83 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)); }
.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, der Schatten bleibt als einzige Tiefe. */
/* clip-path also clips box-shadow, so depth has to come from a filter —
documented caveat in lib/chamfer.ts. */
filter: drop-shadow(0 34px 60px rgba(0,0,0,.30));
}
.slide[data-tilt="an"] .mock {
transform: translate(var(--ex, 0px), var(--ey, 0px))
perspective(2600px) rotateY(-7deg) rotateX(2.5deg) rotate(-1.2deg);
}
.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
);
}
.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,
jedes Blatt trägt seinen eigenen Schatten: box-shadow statt drop-shadow,
weil ohne clip-path nichts mehr beschnitten wird und jede Karte einzeln
abheben soll. */
.sheet-back, .sheet-mid {
display: none; position: absolute; inset: 0; z-index: 0;
background: #fff; border-radius: 22px; overflow: hidden;
}
.slide[data-frame="stapel"] .mock {
clip-path: none; padding: 0; background: none; filter: none;
}
.slide[data-frame="stapel"] .mock-inner {
clip-path: none; border-radius: 22px; z-index: 2;
box-shadow: 0 34px 64px rgba(0,0,0,.30), 0 3px 10px rgba(0,0,0,.14);
}
.slide[data-frame="stapel"] .sheet-mid {
display: block; z-index: 1;
transform: rotate(-2.4deg) translate(-2.2%, 3%) scale(.975);
box-shadow: 0 22px 44px rgba(0,0,0,.20);
}
.slide[data-frame="stapel"] .sheet-back {
display: block;
transform: rotate(3.2deg) translate(3%, 5.5%) scale(.95);
box-shadow: 0 18px 38px rgba(0,0,0,.16);
}
/* 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;
}
/* Schatten global abschaltbar. Zwei Techniken im Spiel: drop-shadow für die
chamferten Rahmen (clip-path würde box-shadow wegschneiden) und box-shadow
für den Stapel. Beide müssen mit. */
.slide[data-shadow="aus"] .mock,
.slide[data-shadow="aus"] .laptop,
.slide[data-shadow="aus"] .hex-frame { filter: none; }
.slide[data-shadow="aus"] .mock-inner,
.slide[data-shadow="aus"] .sheet-mid,
.slide[data-shadow="aus"] .sheet-back { box-shadow: none; }
/* 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%);
filter: drop-shadow(0 26px 48px rgba(0,0,0,.26));
}
.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.75) translate(-21%, -4%);
}
/* 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; filter: drop-shadow(0 28px 50px rgba(0,0,0,.30)); }
.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) translate(0%, 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;
}
.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 {
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 · Lösung</a><a class="" href="./index.html">Post 2 · Statement</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>
<label style="display:block;margin:14px 0 9px">Schatten</label>
<div class="opts" id="shadows">
<button data-s="an" aria-pressed="true">An</button>
<button data-s="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 · <kbd>S</kbd> Schatten<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" }],
head: 'Vom <span class="a">Handwerker</span><br>f\u00fcr den 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",
mockAR: 1.8519, // 2000 x 1080
imgScale: 1, imgX: "0%", imgY: "0%",
},
{
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\u00f6ser w\u00e4hlen, Aktionen dranh\u00e4ngen, fertig. Anfrage rein, Aufgabe raus, Best\u00e4tigung an den Kunden, alles automatisch und ohne eine Zeile Code.",
img: "./assets/screens/workflow.png",
mockAR: 1.5038, // 2000 x 1330
imgScale: 1, imgX: "0%", imgY: "0%",
},
{
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\u2019s sofort und es landet direkt in den Projektdateien. Aus jeder Nachricht wird mit einem Klick eine Aufgabe.",
img: "./assets/screens/chat.png",
// 2000 x 1357. Etwas schmaler, sonst reicht die Hoehe unter dem Text nicht.
mockW: 1070, mockAR: 1.4738,
imgScale: 1, imgX: "0%", imgY: "0%",
},
{
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\u00df 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" },
"mock": { label: "Bild", 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;
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", shadow = "an";
/* ─── 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;
function saveState() {
try {
localStorage.setItem(STORE, JSON.stringify({
v: 1, variant, tilt, superSample, continuous, view, tagOut, frameStyle, shadow,
current, layoutState, bgState, edits,
}));
markSaved();
} catch (err) { console.warn("Speichern fehlgeschlagen", err); }
}
function loadState() {
let d = null;
try { d = JSON.parse(localStorage.getItem(STORE) || "null"); } catch (_) {}
if (!d || d.v !== 1) return false;
// 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;
shadow = d.shadow || shadow;
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() {
try { localStorage.removeItem(STORE); } catch (_) {}
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();
zoom = Math.min(2, Math.max(0.05, k));
// Um den Mauszeiger zoomen, nicht um die linke obere Ecke.
const ratio = zoom / before;
const ax = anchor ? anchor.x : scroller.clientWidth / 2;
const ay = anchor ? anchor.y : scroller.clientHeight / 2;
fitStrip();
scroller.scrollLeft = (scroller.scrollLeft + ax) * ratio - ax;
scroller.scrollTop = (scroller.scrollTop + ay) * ratio - 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>';
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] === "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; });
}
}
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" : "";
}
});
}
// 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);
} 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.shadow = shadow;
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));
paths.push("./assets/screens/pipelines.png");
// Optional: the layout falls back to a hint if the file is not there yet.
const optional = new Set(["./assets/mockups/laptop.png"]);
paths.push("./assets/mockups/laptop.png");
for (const path of [...new Set(paths)]) {
const res = await fetch(path).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;
el.dataset.shadow = shadow;
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;
}
function renderOptions() {
return {
width: 1080, height: 1350, pixelRatio: superSample,
fontEmbedCSS: fontEmbedCss,
// The scaler transform is preview-only — neutralise it for the export,
// otherwise the bitmap comes out at 45 %.
style: { transform: "none", 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";
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. */
function areaHasContent(ctx, b) {
const x = Math.max(0, b.x + 4), y = Math.max(0, b.y + 4);
const w = Math.min(1080 - x, b.w - 8), h = Math.min(1350 - y, b.h - 8);
if (w < 8 || h < 8) return true;
const data = ctx.getImageData(x, y, w, h).data;
const step = Math.max(4, Math.floor((w * h) / 4000)) * 4;
let min = 255, max = 0;
for (let i = 0; i < data.length; i += step) {
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 > 12;
}
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;
await Promise.all([...el.querySelectorAll("img")].map(im =>
im.complete ? im.decode().catch(() => {})
: new Promise(r => { im.onload = r; im.onerror = r; })));
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 missing = boxes.filter(b => !areaHasContent(ctx, b)).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" +
(missing ? " (" + missing + " Screenshot-Flaeche leer)" : ""));
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");
} 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);
const k = deck.getBoundingClientRect().width / (1080 * (view === "streifen" ? SLIDES.length : 1));
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"]],
];
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);
/* Strg/Cmd + Rad zoomt, blankes Rad scrollt. Trackpad-Pinch schickt
ctrlKey mit, verhält sich also automatisch richtig. */
document.getElementById("scroller").addEventListener("wheel", e => {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
const r = e.currentTarget.getBoundingClientRect();
setZoom((zoom || autoFit()) * (e.deltaY < 0 ? 1.12 : 1 / 1.12),
{ x: e.clientX - r.left, y: e.clientY - r.top });
}, { passive: false });
/* Panning als verlässlicher Weg, unabhängig von Trackpad-Gesten:
Leertaste gedrückt halten und ziehen, oder mittlere Maustaste. */
(function panning() {
const sc = document.getElementById("scroller");
let space = false, active = null;
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 => {
if (!space && e.button !== 1) 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;
sc.scrollLeft = active.l - (e.clientX - active.x);
sc.scrollTop = active.t - (e.clientY - active.y);
});
document.addEventListener("mouseup", () => {
active = null; sc.classList.remove("panning");
});
})();
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("shadows").addEventListener("click", e => {
const b = e.target.closest("button");
if (b) { shadow = b.dataset.s; press("shadows", "s", shadow); 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);
press("shadows", "s", shadow); applyAll(); saveState();
}
else if (k === "s") { shadow = shadow === "an" ? "aus" : "an"; press("shadows", "s", shadow); 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 = 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("shadows", "s", shadow);
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>