Files
posts/post1.html
T
raffael 124524482b HiveGrid Social: Design-Library und Post-Generator
Autarke Werkbank für Social-Grafiken: Slides als HTML, Bearbeitung im Canvas,
Export als Instagram-taugliche PNGs.

- Assets nach brand/partner/mockups/screens sortiert, Karteileichen entfernt
- G-Marke als eigene Datei statt nur als Polygon im Code
- Partner-Logos und Geerkens-Logo ergänzt
- Keine externen Referenzen: Schrift, Bibliotheken und Bilder liegen lokal
2026-08-01 18:36:18 +02:00

1635 lines
69 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; }
body {
font-family: 'Manrope', system-ui, sans-serif;
background: #17181F; color: #E8EAEE;
min-height: 100vh; display: flex; gap: 36px; padding: 28px;
align-items: flex-start;
}
/* ─── 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: 290px; flex: none; position: sticky; top: 28px; }
.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 {
display: block; font-size: 11px; font-weight: 700; letter-spacing: 0.09em;
text-transform: uppercase; color: #6E7480; margin-bottom: 9px;
}
.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 ──────────────────────────────────────────────────────────── */
.stage { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 12px; }
.stage-meta { font-size: 12px; color: #6E7480; letter-spacing: .04em; }
/* 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; }
.strip { display: flex; transform-origin: top left; }
.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
);
/* 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
);
}
/* 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>
<div class="group">
<label>Variante</label>
<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>
</div>
<div class="group">
<label>Layout (nur diese Slide)</label>
<div class="opts" id="layouts"></div>
</div>
<div class="group">
<label>Tag optisch ausgerückt</label>
<div class="opts" id="tagouts">
<button data-o="an" aria-pressed="true">An</button>
<button data-o="aus" aria-pressed="false">Aus</button>
</div>
</div>
<div class="group">
<label>Bearbeiten</label>
<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>
</div>
<details class="std">
<summary>Standardwerte</summary>
<div id="std-list"></div>
</details>
<div class="group">
<label>Ansicht</label>
<div class="opts" id="views">
<button data-w="streifen" aria-pressed="true">Streifen</button>
<button data-w="einzeln" aria-pressed="false">Einzeln</button>
</div>
</div>
<div class="group">
<label>Durchgehend (alle Slides)</label>
<div class="opts" id="conts">
<button data-c="an" aria-pressed="false">An</button>
<button data-c="aus" aria-pressed="true">Aus</button>
</div>
</div>
<div class="group">
<label>Muster (nur diese Slide)</label>
<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>
</div>
<div class="group">
<label>Intensität</label>
<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>
</div>
<div class="group">
<label>Mockup-Perspektive</label>
<div class="opts" id="tilts">
<button data-t="an" aria-pressed="true">Gekippt</button>
<button data-t="aus" aria-pressed="false">Gerade</button>
</div>
</div>
<div class="group">
<label>Renderqualität</label>
<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>
</div>
<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>
<div class="group" style="margin-bottom:0">
<label>Tastatur</label>
<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>T</kbd> Perspektive<br>
<kbd>E</kbd> Bearbeiten · Pfeile verschieben (<kbd>⇧</kbd> = 10 px)<br>
<kbd>D</kbd> Slide laden · <kbd>A</kbd> alle als ZIP
</div>
</div>
<div class="status" id="status">Lade Schriften und Screens ...</div>
</aside>
<main class="stage">
<div class="strip-wrap" id="strip-wrap">
<div class="strip" id="deck" data-view="streifen"></div>
</div>
<p class="stage-meta" id="meta">Vorschau 45 %, Export in voller Auflösung</p>
</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",
imgScale: 1.28, imgX: "-2%", imgY: "-4%",
},
{
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",
imgScale: 1.34, imgX: "-8%", imgY: "-22%",
},
{
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",
imgScale: 1.30, imgX: "-14%", imgY: "-8%",
},
{
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 },
"hex-frame": { label: "Wabe", size: null },
};
const EDIT_KEYS = Object.keys(STANDARD);
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";
/* ─── 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,
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;
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. */
function fitStrip() {
const wrap = document.getElementById("strip-wrap");
const total = 1080 * SLIDES.length;
const avail = Math.max(320, wrap.parentElement.clientWidth);
const k = view === "streifen" ? Math.min(avail / total, 0.45) : 0.45;
deck.style.transform = "scale(" + k + ")";
wrap.style.width = Math.round((view === "streifen" ? total : 1080) * k) + "px";
wrap.style.height = Math.round(1350 * k) + "px";
}
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");
mock.innerHTML =
'<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.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.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;
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(delta) {
if (!picked) return;
const std = STANDARD[picked.key];
if (!std || !std.prop) return;
const set = (edits[picked.slide][picked.key] = edits[picked.slide][picked.key] || {});
set.size = Math.max(8, (set.size || std.size) + delta);
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 val = e.size || std.size;
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 " + std.size + ")" : "") + "<br>" : "") +
"Versatz " + (e.dx || 0) + " / " + (e.dy || 0) + " px";
}
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(2));
document.getElementById("sz-minus").addEventListener("click", () => resize(-2));
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); show(current); saveState(); }
});
window.addEventListener("resize", 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("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 (k === "w") { view = view === "streifen" ? "einzeln" : "streifen"; deck.dataset.view = view; press("views", "w", view); show(current); }
else if (k === "e") { document.querySelector('#editmodes button[data-e="' + (editMode === "an" ? "aus" : "an") + '"]').click(); }
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("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>