Der Export staucht nicht mehr auf 1080 x 1350. Die Slide entstand bei Renderqualitaet k longst in 1080k x 1350k und wurde unmittelbar vor dem Speichern wieder heruntergerechnet -- eine Vorlage mit 2390 px kam so mit 916 px an, obwohl sie bereits in 2748 px gerendert vorlag. Gespeichert wird jetzt, was gerendert wurde; bei 3x sind das 3240 x 4050. Die Statuszeile nennt die Groesse, und wer exakt 1080 will, stellt die Stufe auf 1x. Die Kontrolle vor dem Speichern hat ihren Zweck verfehlt. Sie prueft, ob jede Bildflaeche im Ergebnis Pixel traegt, sah dafuer aber das achsenparallele Umrechteck an. Die Szenen-Displays stehen per matrix3d schraeg im Raum, in ihrem Umrechteck liegen Rasterlinien, Hintergrund und das Nachbargeraet -- deren Kontrast reichte immer, um durchzukommen. Eine Slide mit voellig leerem Display wurde deshalb stumm gespeichert. Jetzt werden die echten vier Ecken ausgemessen (ueber 1-px-Marken, Chrome kennt getBoxQuads nicht) und nur die Ueberlappung von Bild und Rahmen abgetastet. Beides einzeln fuehrt in die Irre: das Szenen-Display ist groesser als der sichtbare Ausschnitt, das Feature-Mockup umgekehrt. Die Geraeterahmen stehen neu mit auf der Liste, geprueft ueber den aus der Vorlage geeichten Dunkelanteil. Nachgestellt mit opacity 0: vorher still gespeichert, jetzt Abbruch mit Nennung der Flaeche. 11 von 11 echten Ausfuhren laufen ohne Fehlalarm und ohne Wiederholung durch. Die Szenen-Screenshots fuellen ihr Display randlos. Sie lagen als kleinere Rechtecke an handgemessenen Prozentwerten darin, die nicht aus der Glasflaeche des Mockups stammten: 656 px blieben rechts frei, 470 px unten, und die Werkzeugleiste des Boards verschwand oben trotzdem unter der Blende. Weil Prozentwerte in translate() an der Bildkante haengen, loesten Vorschau und Export sie unterschiedlich auf -- die Luecke war im Export groesser als im Canvas. object-fit uebernimmt das jetzt, es bleibt kein Versatz, der auseinanderlaufen koennte. Dazu behoben: post1-alt.html konnte vier Laptop-Layouts und Slide 2 ueberhaupt nicht exportieren. workflow.png haengt an keiner Slide mehr und fehlte in der Ladeliste, im Bild stand src="undefined". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2408 lines
109 KiB
HTML
2408 lines
109 KiB
HTML
<!doctype html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>HiveGrid Post 1 · Alternativ</title>
|
||
<link rel="stylesheet" href="./assets/fonts/manrope.css">
|
||
<style>
|
||
/* ─── HiveGrid design tokens (mirrored from app/globals.css) ─────────────
|
||
Plain hex on purpose — the export libraries choke on oklch()/Tailwind.
|
||
--dark-bg #30313D is the colour of the dark story section on the site; it
|
||
carries the whole shape layer here. */
|
||
:root {
|
||
--accent-yellow: #F1B729;
|
||
--accent-teal: #0D6E6E;
|
||
--surface: #FFFFFF;
|
||
--bg-primary: #FAFAFA;
|
||
--dark-bg: #30313D;
|
||
--accent-yellow-light: #FFE082;
|
||
--accent-yellow-dark: #3E2C0A;
|
||
--text-primary: #1A1A1A;
|
||
--text-secondary:#555555;
|
||
--text-on-dark: #C8D0DC;
|
||
--text-white: #FFFFFF;
|
||
}
|
||
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
html, body { overscroll-behavior: none; }
|
||
body {
|
||
font-family: 'Manrope', system-ui, sans-serif;
|
||
background: #17181F; color: #E8EAEE;
|
||
height: 100vh; overflow: hidden; display: flex;
|
||
}
|
||
|
||
/* ─── Projekt-Navigation ─────────────────────────────────────────────── */
|
||
.projects { display: flex; gap: 6px; margin-bottom: 18px; }
|
||
.projects a {
|
||
flex: 1; text-align: center; text-decoration: none;
|
||
font-size: 12px; font-weight: 700; padding: 9px 8px; border-radius: 8px;
|
||
background: #22242C; color: #8A8F9B; border: 1px solid #2E313B;
|
||
}
|
||
.projects a:hover { background: #2A2D36; color: #C7CCD6; }
|
||
.projects a.here { background: var(--accent-yellow); color: #1A1A1A; border-color: var(--accent-yellow); }
|
||
.saved {
|
||
font-size: 11px; color: #6FCF97; opacity: 0; transition: opacity .2s;
|
||
margin-left: 6px;
|
||
}
|
||
.saved.on { opacity: 1; }
|
||
.forget { font-size: 11px; color: #6E7480; background: none; border: 0;
|
||
cursor: pointer; padding: 0; font-family: inherit; }
|
||
.forget:hover { color: #EB7C6B; }
|
||
|
||
/* ─── Control panel ──────────────────────────────────────────────────── */
|
||
.panel {
|
||
width: 306px; flex: none; height: 100vh; overflow-y: auto;
|
||
padding: 28px 22px 40px 28px;
|
||
border-right: 1px solid #24262F; background: #17181F;
|
||
}
|
||
.panel h1 { font-size: 19px; font-weight: 800; letter-spacing: -0.01em; margin-bottom: 4px; }
|
||
.panel .sub { font-size: 13px; color: #8A8F9B; margin-bottom: 22px; line-height: 1.5; }
|
||
.panel a { color: #8A8F9B; }
|
||
|
||
.group { margin-bottom: 20px; }
|
||
.group > label,
|
||
.group > summary {
|
||
display: block; font-size: 11px; font-weight: 700; letter-spacing: 0.09em;
|
||
text-transform: uppercase; color: #6E7480; margin-bottom: 9px;
|
||
}
|
||
/* Zugeklappte Kategorien: der Marker ersetzt den Standard-Pfeil, damit die
|
||
Zeile bündig zur offenen Gruppe bleibt. */
|
||
details.group > summary {
|
||
cursor: pointer; list-style: none; margin-bottom: 0;
|
||
padding: 5px 0; display: flex; align-items: center; gap: 7px;
|
||
transition: color .15s;
|
||
}
|
||
details.group > summary::-webkit-details-marker { display: none; }
|
||
details.group > summary::before {
|
||
content: "▸"; font-size: 10px; color: #4E5360; transition: transform .15s;
|
||
}
|
||
details.group[open] > summary::before { transform: rotate(90deg); }
|
||
details.group > summary:hover { color: #C7CCD6; }
|
||
details.group[open] > summary { color: #8A8F9B; margin-bottom: 9px; }
|
||
details.group { margin-bottom: 4px; }
|
||
details.group[open] { margin-bottom: 20px; }
|
||
|
||
.opts { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
|
||
.opts.one { grid-template-columns: 1fr; }
|
||
.opts.three { grid-template-columns: 1fr 1fr 1fr; }
|
||
.opts button {
|
||
font-family: inherit; font-size: 12.5px; font-weight: 600;
|
||
padding: 10px; border-radius: 8px; cursor: pointer; text-align: left;
|
||
background: #22242C; color: #C7CCD6; border: 1px solid #2E313B;
|
||
transition: background .15s, border-color .15s, color .15s;
|
||
display: flex; align-items: center; gap: 8px;
|
||
}
|
||
.opts.three button { justify-content: center; text-align: center; padding: 10px 4px; }
|
||
.opts button:hover { background: #2A2D36; }
|
||
.opts button[aria-pressed="true"] {
|
||
background: var(--accent-yellow); color: #1A1A1A; border-color: var(--accent-yellow);
|
||
}
|
||
.num {
|
||
width: 18px; height: 18px; border-radius: 4px; flex: none;
|
||
display: grid; place-items: center; font-size: 10.5px; font-weight: 800;
|
||
background: rgba(255,255,255,.09);
|
||
}
|
||
button[aria-pressed="true"] .num { background: rgba(0,0,0,.16); }
|
||
.swatch { width: 12px; height: 12px; border-radius: 3px; flex: none; border: 1px solid rgba(0,0,0,.18); }
|
||
|
||
.row { display: flex; gap: 8px; }
|
||
.row button, .btn {
|
||
font-family: inherit; font-size: 13px; font-weight: 700;
|
||
padding: 12px 13px; border-radius: 8px; cursor: pointer; border: none;
|
||
background: var(--accent-yellow); color: #1A1A1A; flex: 1; transition: filter .15s;
|
||
}
|
||
.row button:hover, .btn:hover { filter: brightness(1.07); }
|
||
.btn.secondary { background: #22242C; color: #C7CCD6; border: 1px solid #2E313B; }
|
||
.btn:disabled { opacity: .45; cursor: not-allowed; filter: none; }
|
||
|
||
.keys { font-size: 12px; color: #6E7480; line-height: 1.85; }
|
||
.keys kbd {
|
||
background: #22242C; border: 1px solid #343843; border-bottom-width: 2px;
|
||
border-radius: 4px; padding: 1px 6px; font-family: ui-monospace, monospace;
|
||
font-size: 11px; color: #C7CCD6;
|
||
}
|
||
.edit-info {
|
||
font-size: 12px; line-height: 1.55; color: #C7CCD6;
|
||
background: #1D1F26; border: 1px solid #2E313B; border-radius: 8px;
|
||
padding: 9px 11px; margin-bottom: 9px; min-height: 56px;
|
||
}
|
||
.edit-info .dim { color: #6E7480; }
|
||
.std { margin-bottom: 22px; }
|
||
.std summary {
|
||
cursor: pointer; font-size: 11px; font-weight: 700; letter-spacing: .09em;
|
||
text-transform: uppercase; color: #6E7480; margin-bottom: 9px;
|
||
}
|
||
.std table { width: 100%; border-collapse: collapse; font-size: 11.5px; color: #8A8F9B; }
|
||
.std td { padding: 3px 0; }
|
||
.std td:last-child { text-align: right; color: #C7CCD6; font-variant-numeric: tabular-nums; }
|
||
.std h4 { font-size: 11px; color: #6E7480; margin: 10px 0 4px; font-weight: 700; }
|
||
|
||
.status { margin-top: 12px; font-size: 12.5px; color: #6E7480; min-height: 18px; }
|
||
.status.ok { color: #6FCF97; }
|
||
.status.err { color: #EB7C6B; }
|
||
|
||
/* ─── Stage ──────────────────────────────────────────────────────────── */
|
||
/* ─── Arbeitsfläche ───────────────────────────────────────────────────────
|
||
Klar abgesetzt von der Sidebar: eigener Grund, eigener Scroll, eigener
|
||
Zoom. Was hier passiert, lässt die Bedienleiste unberührt. */
|
||
.stage {
|
||
flex: 1; position: relative; height: 100vh; overflow: hidden;
|
||
background-color: #101116;
|
||
background-image: radial-gradient(circle, #282B35 1.6px, transparent 1.6px);
|
||
background-size: 24px 24px;
|
||
}
|
||
/* overscroll-behavior contain hält die Wisch-Geste des Browsers zurück:
|
||
Ohne das springt horizontales Trackpad-Scrollen am linken Rand auf den
|
||
vorigen Tab, statt im Canvas zu bleiben. */
|
||
.scroller {
|
||
position: absolute; inset: 0; overflow: auto;
|
||
overscroll-behavior: contain;
|
||
}
|
||
.scroller.panning { cursor: grabbing; }
|
||
.scroller.panready { cursor: grab; }
|
||
/* width/height: max-content ist hier entscheidend. Mit reinem
|
||
"justify-content: center" ragt breiter Inhalt über beide Seiten hinaus,
|
||
aber scrollLeft kann nicht negativ werden: Der linke Teil wäre dann
|
||
unerreichbar. So wächst die Fläche mit dem Inhalt, das Zentrieren greift
|
||
nur noch, solange etwas Platz übrig ist. */
|
||
.canvas {
|
||
width: max-content; min-width: 100%;
|
||
height: max-content; min-height: 100%;
|
||
box-sizing: border-box; padding: 60px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
|
||
.hud {
|
||
position: absolute; left: 0; right: 0; bottom: 0;
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
gap: 16px; padding: 14px 20px; pointer-events: none;
|
||
background: linear-gradient(transparent, rgba(16,17,22,.92) 55%);
|
||
}
|
||
.hud > * { pointer-events: auto; }
|
||
.stage-meta { font-size: 12px; color: #6E7480; letter-spacing: .04em; }
|
||
|
||
.zoom { display: flex; align-items: center; gap: 4px; }
|
||
.zoom button {
|
||
font-family: inherit; font-size: 13px; font-weight: 700; cursor: pointer;
|
||
background: #1D1F26; color: #C7CCD6; border: 1px solid #2E313B;
|
||
border-radius: 7px; padding: 6px 10px; min-width: 32px;
|
||
}
|
||
.zoom button:hover { background: #262932; }
|
||
.zoom .level { min-width: 58px; text-align: center; color: #8A8F9B; font-variant-numeric: tabular-nums; }
|
||
/* Der Streifen zeigt alle Slides nebeneinander und lückenlos, damit der
|
||
durchgehende Aufbau beim Arbeiten sichtbar ist. Der Rahmen reserviert die
|
||
skalierte Fläche; die Slides selbst bleiben immer echte 1080 x 1350. */
|
||
.strip-wrap { overflow: hidden; flex: none; align-self: flex-start; }
|
||
/* Bei gebrochenem Maßstab (etwa 41 %) ist eine Slide 442,8 px breit. Der
|
||
Browser rundet jede Box einzeln, und zwischen ihnen blitzt für Sekunden-
|
||
bruchteile eines Pixels der dunkle Grund durch. Der Streifen bekommt
|
||
deshalb die Slide-Farbe als Untergrund: Die Fuge zeigt dann dieselbe Farbe
|
||
und verschwindet. Im Export gibt es sie ohnehin nicht, dort wird jede
|
||
Slide einzeln in exakten 1080 x 1350 gerendert. */
|
||
.strip { display: flex; transform-origin: top left; background: #30313D; }
|
||
.holder { position: relative; flex: none; cursor: pointer; }
|
||
/* Auswahlrahmen sitzt auf dem Holder, nicht auf der Slide, damit er nie
|
||
im Export landet. */
|
||
.holder.sel::after {
|
||
content: ""; position: absolute; inset: 0; pointer-events: none;
|
||
outline: 10px solid var(--accent-yellow); outline-offset: -5px;
|
||
}
|
||
.strip[data-view="einzeln"] .holder { display: none; }
|
||
.strip[data-view="einzeln"] .holder.sel { display: block; }
|
||
|
||
/* ─── Slide base — exactly Instagram 4:5 (1080 × 1350) ───────────────── */
|
||
.slide {
|
||
width: 1080px; height: 1350px;
|
||
position: relative; overflow: hidden;
|
||
font-family: 'Manrope', system-ui, sans-serif;
|
||
}
|
||
|
||
/* ─── Background layers ──────────────────────────────────────────────────
|
||
Five interchangeable grounds. Shapes are built from the brand geometry
|
||
(chamfered blocks + flat-top hexagons) in --dark-bg, the colour of the dark
|
||
story section on the site. */
|
||
.bg { position: absolute; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
|
||
.slide[data-int="dezent"] .bg { --int-mul: .55; }
|
||
.slide[data-int="kraeftig"] .bg { --int-mul: 1; }
|
||
|
||
.bg > * { display: none; }
|
||
.slide[data-bg="formen"] .layer-shapes,
|
||
.slide[data-bg="formen-g"] .layer-shapes,
|
||
.slide[data-bg="formen-g"] .layer-gmark,
|
||
.slide[data-bg="nur-g"] .layer-shapes,
|
||
.slide[data-bg="nur-g"] .layer-gmark,
|
||
.slide[data-bg="waben"] .layer-waben,
|
||
.slide[data-bg="punkte"] .layer-punkte,
|
||
.slide[data-bg="raster"] .layer-raster { display: block; }
|
||
|
||
.layer-shapes, .layer-waben, .layer-punkte, .layer-raster {
|
||
position: absolute; inset: 0; width: 100%; height: 100%;
|
||
}
|
||
.layer-gmark { inset: auto; }
|
||
.slide[data-bg="nur-g"] .sh-block { display: none; }
|
||
|
||
/* Optische Ausrückung des Tags: Der Kasten steht bündig zur Inhaltskante,
|
||
sein TEXT dadurch um die 20 px Innenabstand eingerückt gegenüber der
|
||
Headline. Das negative Margin holt den Text auf dieselbe Linie. Zum
|
||
Vergleichen umschaltbar. */
|
||
.slide[data-tagout="an"] .badge { margin-left: -20px; }
|
||
|
||
.sh { position: absolute; }
|
||
|
||
/* Two large forms anchored to opposite corners and mostly off canvas, plus
|
||
the yellow hex as the one warm accent. Everything stays clear of the copy:
|
||
shapes crossing the headline are what made an earlier version look thrown
|
||
together. */
|
||
.sh-block {
|
||
top: -300px; right: -290px; width: 760px; height: 760px;
|
||
background: var(--shape-fill);
|
||
transform: rotate(-14deg);
|
||
clip-path: polygon(
|
||
74px 0, calc(100% - 74px) 0, 100% 74px, 100% calc(100% - 74px),
|
||
calc(100% - 74px) 100%, 74px 100%, 0 calc(100% - 74px), 0 74px
|
||
);
|
||
}
|
||
.sh-hex-line {
|
||
bottom: -300px; left: -260px; width: 660px; height: 572px;
|
||
opacity: var(--int-mul, 1);
|
||
border: 14px solid var(--shape-line);
|
||
clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
|
||
}
|
||
.sh-hex-accent {
|
||
top: 268px; left: -66px; width: 172px; height: 149px;
|
||
opacity: var(--int-mul, 1);
|
||
background: var(--shape-accent);
|
||
clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
|
||
}
|
||
.sh-hex-small {
|
||
bottom: 296px; right: -40px; width: 108px; height: 94px;
|
||
opacity: var(--int-mul, 1);
|
||
background: var(--shape-accent-2);
|
||
clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
|
||
}
|
||
|
||
/* Oversized G behind the device, cropped by whatever sits in front of it, so
|
||
only its top peeks out. Same idea as the watermark on the dark comparison
|
||
card on the site, just in grey. Lives in the background layer (z-index 0),
|
||
so the mockup always covers its lower half. */
|
||
.layer-gmark {
|
||
position: absolute; left: 50%; top: 400px;
|
||
width: 880px; height: auto;
|
||
transform: translateX(-50%);
|
||
color: var(--gmark-color);
|
||
opacity: var(--int-mul, 1);
|
||
}
|
||
|
||
/* ─── Durchgehendes Carousel ──────────────────────────────────────────────
|
||
Der Post ist ein einziges 5400 x 1350 grosses Blatt, das in fünf 1080er
|
||
Fenster geschnitten wird. Jede Slide zeigt ihren Ausschnitt, indem die
|
||
Ebene um index * 1080 nach links verschoben wird. Elemente auf den Nähten
|
||
werden dadurch zerschnitten und laufen auf der nächsten Slide weiter. */
|
||
.continuum {
|
||
position: absolute; top: 0; height: 100%;
|
||
left: calc(var(--slide-i, 0) * -1080px);
|
||
width: 5400px;
|
||
pointer-events: none;
|
||
opacity: var(--int-mul, 1);
|
||
}
|
||
.slide[data-cont="aus"] .continuum { display: none; }
|
||
.continuum > * { position: absolute; }
|
||
|
||
/* Sechsecke sitzen mittig auf den Nähten (1080, 2160, 3240, 4320) und werden
|
||
dort halbiert. Das ist das deutlichste Signal, dass es weitergeht. */
|
||
.ct-hex {
|
||
background: var(--shape-fill);
|
||
clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
|
||
}
|
||
.ct-hex-line {
|
||
border: 13px solid var(--shape-line);
|
||
clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
|
||
}
|
||
/* Ein Band über die volle Breite: die Klammer, die alle fünf zusammenhält. */
|
||
.ct-band {
|
||
left: 0; width: 5400px; height: 54px; bottom: 96px;
|
||
background: var(--shape-line);
|
||
}
|
||
.ct-g { color: var(--gmark-color); }
|
||
|
||
.layer-waben { color: var(--pat-color); opacity: calc(var(--pat-strength) * var(--int-mul, 1)); }
|
||
|
||
.layer-punkte {
|
||
background-image: radial-gradient(circle, var(--pat-color) 3px, transparent 3px);
|
||
background-size: 52px 52px;
|
||
opacity: calc(var(--pat-strength) * var(--int-mul, 1));
|
||
}
|
||
|
||
.layer-raster {
|
||
background-image:
|
||
linear-gradient(var(--pat-color) 2px, transparent 2px),
|
||
linear-gradient(90deg, var(--pat-color) 2px, transparent 2px);
|
||
background-size: 78px 78px;
|
||
opacity: calc(var(--pat-strength) * var(--int-mul, 1));
|
||
}
|
||
|
||
/* ─── Bearbeitung im Canvas ───────────────────────────────────────────────
|
||
Verschieben läuft über --ex/--ey, also über transform statt über den
|
||
Textfluss: So verschiebt ein Element nie seine Nachbarn und lässt sich
|
||
jederzeit exakt auf 0 zurücksetzen. Elemente mit eigener Transformation
|
||
komponieren die Offsets in ihre bestehende Regel hinein. */
|
||
[data-edit] { transform: translate(var(--ex, 0px), var(--ey, 0px)) rotate(var(--rot, 0deg)); }
|
||
|
||
.slide[data-editmode="an"] [data-edit] { cursor: grab; }
|
||
.slide[data-editmode="an"] [data-edit]:hover { outline: 3px dashed rgba(241,183,41,.75); outline-offset: 6px; }
|
||
.slide[data-editmode="an"] [data-edit].picked { outline: 4px solid var(--accent-yellow); outline-offset: 6px; }
|
||
|
||
/* Jede Slide trägt nicht nur ihren eigenen Inhalt, sondern auch den ihrer
|
||
Nachbarn, verschoben um deren Position. Dadurch läuft ein nach links
|
||
geschobenes Element aus Slide 3 tatsächlich in Slide 2 hinein, statt am
|
||
Rand zu verschwinden. Der Hintergrund bleibt pro Slide, die Identität
|
||
also erhalten. */
|
||
.sheet {
|
||
position: absolute; top: 0; height: 100%;
|
||
left: calc(var(--slide-i, 0) * -1080px);
|
||
width: 5400px; z-index: 1;
|
||
}
|
||
.page { position: absolute; top: 0; width: 1080px; height: 1350px; }
|
||
|
||
.content { position: relative; z-index: 2; padding: 92px 92px 0; }
|
||
|
||
/* Section badge, straight from the site: .chamfer-badge in globals.css with
|
||
accent-yellow-light on accent-yellow-dark. Scaled from the 12px web size to
|
||
this canvas; the 5px corner cut scales with it. */
|
||
.badge {
|
||
display: inline-block; margin-bottom: 38px;
|
||
font-size: 26px; font-weight: 600; letter-spacing: 0.1em;
|
||
padding: 9px 20px;
|
||
background: var(--accent-yellow-light);
|
||
color: var(--accent-yellow-dark);
|
||
clip-path: polygon(
|
||
11px 0, calc(100% - 11px) 0, 100% 11px, 100% calc(100% - 11px),
|
||
calc(100% - 11px) 100%, 11px 100%, 0 calc(100% - 11px), 0 11px
|
||
);
|
||
}
|
||
|
||
.headline {
|
||
font-size: 74px; font-weight: 800;
|
||
letter-spacing: -0.03em; line-height: 1.06;
|
||
color: var(--head-color);
|
||
}
|
||
.headline .a, .title-head .a, .wabe-tagline .a {
|
||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 48' preserveAspectRatio='none'%3E%3Cpath d='M1.5,12 C58,4.5 168,9.5 312,5 C316,7 318,11 317.5,16 C318.5,26 316,36 313,42.5 C232,47.5 104,41.5 6.5,44.5 C2.5,42 0.5,36 1,29 C0.5,22 0.8,16.5 1.5,12 Z' fill='%23F1B729'/%3E%3C/svg%3E");
|
||
background-repeat: no-repeat;
|
||
background-position: 0 68%;
|
||
background-size: 100% 62%;
|
||
-webkit-box-decoration-break: clone; box-decoration-break: clone;
|
||
padding: 0 14px; margin: 0 -6px;
|
||
}
|
||
|
||
.body {
|
||
margin-top: 30px; font-size: 30px; font-weight: 500;
|
||
line-height: 1.48; color: var(--body-color); max-width: 830px;
|
||
}
|
||
|
||
|
||
/* ─── Gerenderte Oberflächen statt Screenshots ────────────────────────────
|
||
Alles hier ist echtes HTML: bei jeder Auflösung scharf, kein Scrollbalken,
|
||
kein Fensterrand, und der Ausschnitt ist frei wählbar statt vom Screenshot
|
||
vorgegeben. Die Maße sind in px am 1080er Raster gedacht. */
|
||
/* Die Oberfläche wird in festen Entwurfsmaßen gebaut und danach in den
|
||
Rahmen skaliert. Nur so bleiben Knotenpositionen und Zeilenhöhen stimmig,
|
||
egal wie breit der Rahmen gerade ist. */
|
||
.ui-fit { position: absolute; inset: 0; overflow: hidden; background: #FFFFFF; }
|
||
.ui { position: absolute; top: 0; left: 0;
|
||
width: var(--dw); height: var(--dh);
|
||
transform: scale(var(--uk, 1)); transform-origin: top left;
|
||
background: #FFFFFF; overflow: hidden;
|
||
font-family: 'Manrope', system-ui, sans-serif; color: #171717; }
|
||
.ui * { box-sizing: border-box; }
|
||
|
||
/* Flow-Canvas */
|
||
.ui-flow { background: #F5F5F5; }
|
||
.ui-flow .dots {
|
||
position: absolute; inset: 0;
|
||
background-image: radial-gradient(circle, #D4D4D4 1.4px, transparent 1.4px);
|
||
background-size: 26px 26px;
|
||
}
|
||
.ui-flow .nodes { position: absolute; inset: 0; }
|
||
.ui-node { position: absolute; width: 116px; text-align: center; }
|
||
.ui-node .tile {
|
||
width: 116px; height: 116px; border-radius: 18px;
|
||
display: grid; place-items: center; margin-bottom: 12px;
|
||
}
|
||
.ui-node .tile svg { width: 46px; height: 46px; }
|
||
.ui-node .t { font-size: 17px; font-weight: 600; letter-spacing: -0.01em; }
|
||
.ui-node .s { font-size: 13.5px; color: #737373; margin-top: 3px; }
|
||
.ui-node.start .tile { border-radius: 58px 18px 18px 58px; }
|
||
.tile-green { background: #DCFCE7; color: #16A34A; }
|
||
.tile-red { background: #FEE2E2; color: #DC2626; }
|
||
.tile-purple { background: #EDE9FE; color: #7C3AED; }
|
||
.tile-blue { background: #DBEAFE; color: #2563EB; }
|
||
.tile-yellow { background: #FEF3C7; color: #B45309; }
|
||
.ui-flow .wires { position: absolute; inset: 0; pointer-events: none; }
|
||
.ui-flow .wires path { fill: none; stroke: #C4C4C4; stroke-width: 2.4; }
|
||
.ui-flow .wires circle { fill: #FFFFFF; stroke: #C4C4C4; stroke-width: 2.4; }
|
||
|
||
/* Bausteinliste */
|
||
.ui-picker { padding: 26px 24px; background: #FAFAFA; }
|
||
.ui-picker .sec {
|
||
display: flex; align-items: center; gap: 10px;
|
||
font-size: 15px; font-weight: 800; letter-spacing: .09em;
|
||
text-transform: uppercase; margin: 4px 0 14px;
|
||
}
|
||
.ui-picker .sec .dot { width: 11px; height: 11px; border-radius: 50%; }
|
||
.ui-picker .sec .hint {
|
||
font-size: 15px; font-weight: 500; letter-spacing: 0; text-transform: none; color: #737373;
|
||
}
|
||
.ui-picker .sec.trigger { color: #B45309; }
|
||
.ui-picker .sec.trigger .dot { background: #D97706; }
|
||
.ui-picker .sec.action { color: #1D4ED8; margin-top: 26px; }
|
||
.ui-picker .sec.action .dot { background: #2563EB; }
|
||
.ui-row {
|
||
display: flex; align-items: center; gap: 14px;
|
||
padding: 13px 14px; margin-bottom: 10px;
|
||
background: #FFFFFF; border: 1px solid #E5E5E5; border-radius: 13px;
|
||
}
|
||
.ui-row.on { background: #F5F5F5; border-color: #D4D4D4; }
|
||
.ui-row .ic { width: 46px; height: 46px; border-radius: 11px; display: grid; place-items: center; flex: none; }
|
||
.ui-row .ic svg { width: 24px; height: 24px; }
|
||
.ui-row .tx { flex: 1; min-width: 0; }
|
||
.ui-row .tx b { display: block; font-size: 17px; font-weight: 700; letter-spacing: -0.01em; }
|
||
.ui-row .tx span { display: block; font-size: 14px; color: #737373; margin-top: 1px; }
|
||
.ui-row .q {
|
||
width: 26px; height: 26px; border-radius: 50%; flex: none;
|
||
border: 1.6px solid #D4D4D4; color: #A3A3A3;
|
||
display: grid; place-items: center; font-size: 14px; font-weight: 700;
|
||
}
|
||
.ui-row.on .q { background: #FEF3C7; border-color: #FCD34D; color: #B45309; }
|
||
|
||
/* ─── Device mockup ──────────────────────────────────────────────────────
|
||
A chamfered frame, not a photoreal laptop: the website already frames every
|
||
screenshot this way (.screenshot-frame in globals.css), so this is the
|
||
on-brand shape and it ties the posts to the site. */
|
||
.mock {
|
||
position: absolute; z-index: 1;
|
||
left: var(--mock-left, 92px);
|
||
width: var(--mock-w, 1090px); /* default bleeds ~100px past the right edge */
|
||
bottom: 96px;
|
||
padding: 12px;
|
||
background: var(--frame-bg);
|
||
clip-path: polygon(
|
||
22px 0, calc(100% - 22px) 0, 100% 22px, 100% calc(100% - 22px),
|
||
calc(100% - 22px) 100%, 22px 100%, 0 calc(100% - 22px), 0 22px
|
||
);
|
||
/* Rahmenform ist umschaltbar. Bei "rund" muss clip-path weichen, sonst
|
||
gewinnt der Schnitt gegen border-radius; bei "aus" fällt auch Polsterung
|
||
und Fläche weg.
|
||
Kein Schatten: die Gestaltung arbeitet flächig, Tiefe entsteht über
|
||
Überlappung und Rahmenfarbe, nicht über Weichzeichnung. */
|
||
}
|
||
.slide[data-tilt="an"] .mock {
|
||
transform: translate(var(--ex, 0px), var(--ey, 0px))
|
||
perspective(2600px) rotateY(-7deg) rotateX(2.5deg)
|
||
rotate(calc(-1.2deg + var(--rot, 0deg)));
|
||
}
|
||
|
||
.mock-inner {
|
||
position: relative; overflow: hidden;
|
||
aspect-ratio: var(--mock-ar, 1.6); width: 100%;
|
||
background: #fff;
|
||
clip-path: polygon(
|
||
14px 0, calc(100% - 14px) 0, 100% 14px, 100% calc(100% - 14px),
|
||
calc(100% - 14px) 100%, 14px 100%, 0 calc(100% - 14px), 0 14px
|
||
);
|
||
}
|
||
|
||
/* Ein zweites Bild pro Slide. Es ist bewusst wieder ein .mock, damit alle
|
||
Rahmenformen, Schatten und Perspektiven ohne Sonderregeln greifen; nur
|
||
Position, Größe und Stapelebene kommen von aussen. */
|
||
.mock-2 {
|
||
z-index: var(--m2-z, 2);
|
||
bottom: var(--m2-bottom, 96px);
|
||
}
|
||
.slide[data-frame="stapel"] .mock-2 .sheet-back,
|
||
.slide[data-frame="stapel"] .mock-2 .sheet-mid { display: none; }
|
||
|
||
.slide[data-frame="rund"] .mock { clip-path: none; border-radius: 30px; }
|
||
.slide[data-frame="rund"] .mock-inner { clip-path: none; border-radius: 20px; }
|
||
|
||
.slide[data-frame="aus"] .mock {
|
||
clip-path: none; padding: 0; background: none; border-radius: 0;
|
||
}
|
||
.slide[data-frame="aus"] .mock-inner { clip-path: none; border-radius: 0; }
|
||
|
||
/* ─── Stapel ─────────────────────────────────────────────────────────────
|
||
Zwei versetzte Blätter hinter dem Screenshot. Der Rahmen selbst fällt weg,
|
||
getrennt werden sie nicht durch Schatten, sondern durch den Versatz und
|
||
eine Haarlinie: auf der hellen Variante läge sonst Weiss auf Weiss. */
|
||
.sheet-back, .sheet-mid {
|
||
display: none; position: absolute; inset: 0; z-index: 0;
|
||
background: #fff; border-radius: 22px; overflow: hidden;
|
||
border: 1px solid rgba(48,49,61,.18);
|
||
}
|
||
.slide[data-frame="stapel"] .mock {
|
||
clip-path: none; padding: 0; background: none;
|
||
}
|
||
.slide[data-frame="stapel"] .mock-inner {
|
||
clip-path: none; border-radius: 22px; z-index: 2;
|
||
border: 1px solid rgba(48,49,61,.18);
|
||
}
|
||
.slide[data-frame="stapel"] .sheet-mid {
|
||
display: block; z-index: 1;
|
||
transform: rotate(-2.4deg) translate(-2.2%, 3%) scale(.975);
|
||
}
|
||
.slide[data-frame="stapel"] .sheet-back {
|
||
display: block;
|
||
transform: rotate(3.2deg) translate(3%, 5.5%) scale(.95);
|
||
}
|
||
/* Das hinterste Blatt zeigt bewusst einen anderen Screen: der Stapel soll
|
||
nach mehreren Ansichten aussehen, nicht nach einer Kopie. */
|
||
.sheet-back img {
|
||
position: absolute; top: 0; left: 0; width: 100%;
|
||
transform-origin: top left;
|
||
}
|
||
|
||
/* Screens are ~2000px wide landscape captures; showing them whole makes the
|
||
UI text unreadable at this size, so each one is zoomed onto its telling
|
||
region via the per-slide vars below. */
|
||
.mock-inner img {
|
||
position: absolute; top: 0; left: 0; width: 100%;
|
||
transform-origin: top left;
|
||
transform: scale(var(--img-scale, 1)) translate(var(--img-x, 0%), var(--img-y, 0%));
|
||
}
|
||
|
||
/* ─── Title layout "wabe" ────────────────────────────────────────────────
|
||
The screenshot sits inside the brand hexagon. Its proportions are taken
|
||
from the logo mark itself (384.5 x 332.9, i.e. 2/sqrt3), so the shape is a
|
||
true regular hexagon rather than a squashed one. */
|
||
.hex-frame {
|
||
padding: 15px; background: var(--frame-bg);
|
||
clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
|
||
}
|
||
.hex-shot {
|
||
position: relative; overflow: hidden;
|
||
width: 748px; height: 648px;
|
||
background: #fff;
|
||
clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
|
||
}
|
||
.hex-shot img {
|
||
position: absolute; top: 0; left: 0; width: 100%;
|
||
transform-origin: top left;
|
||
transform: scale(1.533) translate(-17.4%, 0%);
|
||
}
|
||
|
||
/* Laptop mockup. The display was knocked out of the PNG, so the screenshot
|
||
sits BEHIND the device and the notch plus the rounded corners stay on top,
|
||
the way a real mockup reads. Rect measured from the file: 739 x 489 of
|
||
1050 x 700. */
|
||
.laptop { position: relative; width: 960px; }
|
||
.laptop-frame { width: 100%; display: block; position: relative; z-index: 2; }
|
||
.laptop-screen {
|
||
position: absolute; z-index: 1; overflow: hidden;
|
||
left: 14.857%; top: 11.857%; width: 70.381%; height: 69.857%;
|
||
background: #fff;
|
||
}
|
||
/* Scoped to the screenshot: an unscoped ".laptop-screen img" also caught the
|
||
wordmark in the logo variant and forced it to width 100% at top left. */
|
||
.laptop-screen > img.laptop-shot {
|
||
position: absolute; top: 0; left: 0; width: 100%;
|
||
transform-origin: top left;
|
||
transform: scale(1.566) translate(-18.1%, 0%);
|
||
}
|
||
/* Blurred variant: just enough softening to seat the wordmark, the app stays
|
||
readable underneath. Slightly overscaled so the blur never pulls the empty
|
||
edge of the clip box into frame, and re-centred for that scale. */
|
||
.laptop-screen[data-blur="an"] > img.laptop-shot {
|
||
filter: blur(5px);
|
||
transform: scale(1.04) translate(-1.9%, -2.2%);
|
||
}
|
||
|
||
/* Variant with the wordmark on the display: dead centre, same 430px as the
|
||
lockup in the plain laptop layout. No scrim, the screenshot stays as it is. */
|
||
.laptop-screen > img.screen-logo {
|
||
position: absolute; z-index: 2;
|
||
left: 50%; top: 50%;
|
||
transform: translate(-50%, -50%) translate(var(--ex, 0px), var(--ey, 0px));
|
||
width: 430px; height: auto;
|
||
}
|
||
|
||
.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 · Alternativ</h1>
|
||
<div class="projects"><a class="" href="./post1.html">Post 1</a><a class="here" href="./post1-alt.html">Post 1 · Alt</a><a class="" href="./index.html">Post 2</a></div>
|
||
<p class="sub">Carousel · gerenderte UI statt Screenshots<span class="saved" id="saved"></span><br><button class="forget" id="forget">Projekt zurücksetzen</button></p>
|
||
|
||
<div class="group">
|
||
<label>Slide</label>
|
||
<div class="opts one" id="slides-nav"></div>
|
||
</div>
|
||
|
||
<details class="group" data-grp="variante">
|
||
<summary>Variante</summary>
|
||
<div class="opts" id="variants">
|
||
<button data-v="dunkel" aria-pressed="true"><span class="swatch" style="background:#30313D"></span>Dunkel</button>
|
||
<button data-v="hell" aria-pressed="false"><span class="swatch" style="background:#FFFFFF"></span>Hell</button>
|
||
</div>
|
||
</details>
|
||
|
||
<details class="group" data-grp="layout-nur-diese-slide">
|
||
<summary>Layout (nur diese Slide)</summary>
|
||
<div class="opts" id="layouts"></div>
|
||
</details>
|
||
|
||
<details class="group" data-grp="tag-optisch-ausger-ckt">
|
||
<summary>Tag optisch ausgerückt</summary>
|
||
<div class="opts" id="tagouts">
|
||
<button data-o="an" aria-pressed="true">An</button>
|
||
<button data-o="aus" aria-pressed="false">Aus</button>
|
||
</div>
|
||
</details>
|
||
|
||
<details class="group" data-grp="bearbeiten">
|
||
<summary>Bearbeiten</summary>
|
||
<div class="opts" id="editmodes" style="margin-bottom:8px">
|
||
<button data-e="an" aria-pressed="false">An</button>
|
||
<button data-e="aus" aria-pressed="true">Aus</button>
|
||
</div>
|
||
<div class="edit-info" id="edit-info"><span class="dim">Nichts ausgewählt</span></div>
|
||
<div class="row" style="margin-bottom:8px">
|
||
<button class="btn secondary" id="sz-minus">A−</button>
|
||
<button class="btn secondary" id="sz-plus">A+</button>
|
||
</div>
|
||
<div class="row">
|
||
<button class="btn secondary" id="reset-one">Element zurück</button>
|
||
</div>
|
||
<button class="btn secondary" id="reset-all" style="width:100%;margin-top:8px">Alles auf Standard</button>
|
||
</details>
|
||
|
||
<details class="std">
|
||
<summary>Standardwerte</summary>
|
||
<div id="std-list"></div>
|
||
</details>
|
||
|
||
<details class="group" data-grp="ansicht">
|
||
<summary>Ansicht</summary>
|
||
<div class="opts" id="views">
|
||
<button data-w="streifen" aria-pressed="true">Streifen</button>
|
||
<button data-w="einzeln" aria-pressed="false">Einzeln</button>
|
||
</div>
|
||
</details>
|
||
|
||
<details class="group" data-grp="durchgehend-alle-slides">
|
||
<summary>Durchgehend (alle Slides)</summary>
|
||
<div class="opts" id="conts">
|
||
<button data-c="an" aria-pressed="false">An</button>
|
||
<button data-c="aus" aria-pressed="true">Aus</button>
|
||
</div>
|
||
</details>
|
||
|
||
<details class="group" data-grp="muster-nur-diese-slide">
|
||
<summary>Muster (nur diese Slide)</summary>
|
||
<div class="opts three" id="bgs">
|
||
<button data-b="formen" aria-pressed="true">Formen</button>
|
||
<button data-b="formen-g" aria-pressed="false">Formen + G</button>
|
||
<button data-b="nur-g" aria-pressed="false">Nur G</button>
|
||
<button data-b="waben" aria-pressed="false">Waben</button>
|
||
<button data-b="punkte" aria-pressed="false">Punkte</button>
|
||
<button data-b="raster" aria-pressed="false">Raster</button>
|
||
<button data-b="aus" aria-pressed="false">Aus</button>
|
||
</div>
|
||
</details>
|
||
|
||
<details class="group" data-grp="intensit-t">
|
||
<summary>Intensität</summary>
|
||
<div class="opts" id="ints">
|
||
<button data-i="dezent" aria-pressed="true">Dezent</button>
|
||
<button data-i="kraeftig" aria-pressed="false">Kräftig</button>
|
||
</div>
|
||
</details>
|
||
|
||
<details class="group" data-grp="bildrahmen">
|
||
<summary>Bildrahmen</summary>
|
||
<div class="opts three" id="frames">
|
||
<button data-f="chamfer" aria-pressed="true">Chamfer</button>
|
||
<button data-f="rund" aria-pressed="false">Rund</button>
|
||
<button data-f="stapel" aria-pressed="false">Stapel</button>
|
||
<button data-f="aus" aria-pressed="false">Aus</button>
|
||
</div>
|
||
</details>
|
||
|
||
<details class="group" data-grp="mockup-perspektive">
|
||
<summary>Mockup-Perspektive</summary>
|
||
<div class="opts" id="tilts">
|
||
<button data-t="an" aria-pressed="true">Gekippt</button>
|
||
<button data-t="aus" aria-pressed="false">Gerade</button>
|
||
</div>
|
||
</details>
|
||
|
||
<details class="group" data-grp="renderqualit-t">
|
||
<summary>Renderqualität</summary>
|
||
<div class="opts three" id="quality">
|
||
<button data-q="1" aria-pressed="false">1×</button>
|
||
<button data-q="2" aria-pressed="true">2×</button>
|
||
<button data-q="3" aria-pressed="false">3×</button>
|
||
</div>
|
||
</details>
|
||
|
||
<div class="group">
|
||
<label>Download</label>
|
||
<div class="row" style="margin-bottom:8px">
|
||
<button id="dl-png" disabled>Diese Slide</button>
|
||
</div>
|
||
<button id="dl-all" class="btn secondary" style="width:100%" disabled>Alle 5 Slides als ZIP</button>
|
||
</div>
|
||
|
||
<details class="group" style="margin-bottom:0" data-grp="tastatur">
|
||
<summary>Tastatur</summary>
|
||
<div class="keys">
|
||
<kbd>←</kbd><kbd>→</kbd> Slide · <kbd>V</kbd> Variante<br>
|
||
<kbd>L</kbd> Layout · <kbd>M</kbd> Muster · <kbd>I</kbd> Intensität<br>
|
||
<kbd>C</kbd> Durchgehend · <kbd>W</kbd> Ansicht · <kbd>G</kbd> Tag<br>
|
||
<kbd>R</kbd> Bildrahmen<br>
|
||
<kbd>T</kbd> Perspektive<br>
|
||
<kbd>E</kbd> Bearbeiten · Pfeile verschieben (<kbd>⇧</kbd> = 10 px)<br>
|
||
<kbd>+</kbd> <kbd>−</kbd> <kbd>0</kbd> Zoom · ⌘/Strg + Rad<br>
|
||
<kbd>Leer</kbd> + ziehen verschiebt die Fläche<br>
|
||
<kbd>D</kbd> Slide laden · <kbd>A</kbd> alle als ZIP
|
||
</div>
|
||
</details>
|
||
|
||
<div class="status" id="status">Lade Schriften und Screens ...</div>
|
||
</aside>
|
||
|
||
<main class="stage" id="stage">
|
||
<div class="scroller" id="scroller">
|
||
<div class="canvas">
|
||
<div class="strip-wrap" id="strip-wrap">
|
||
<div class="strip" id="deck" data-view="streifen"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="hud">
|
||
<p class="stage-meta" id="meta"></p>
|
||
<div class="zoom">
|
||
<button id="zoom-out" title="Auszoomen">−</button>
|
||
<button class="level" id="zoom-fit" title="Einpassen">Fit</button>
|
||
<button id="zoom-in" title="Einzoomen">+</button>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
<script src="./vendor/html-to-image.js"></script>
|
||
<script src="./vendor/jszip.min.js"></script>
|
||
<script>
|
||
(function () {
|
||
"use strict";
|
||
|
||
/* A syntax or temporal-dead-zone error at this level kills the whole script
|
||
silently: the panel renders from static markup, but nothing is ever built
|
||
and no status is set. Surface it instead of showing an empty stage. */
|
||
window.addEventListener("error", e => {
|
||
const el = document.getElementById("status");
|
||
if (el) {
|
||
el.textContent = "Skriptfehler: " + (e.message || e.error);
|
||
el.className = "status err";
|
||
}
|
||
});
|
||
|
||
const deck = document.getElementById("deck");
|
||
const nav = document.getElementById("slides-nav");
|
||
const statusEl = document.getElementById("status");
|
||
const metaEl = document.getElementById("meta");
|
||
|
||
/* Slide content. Feature bodies are the website copy verbatim
|
||
(app/playground/PlaygroundClient.tsx → solutionModules) so Social and
|
||
Website say exactly the same thing; the headlines are new and short,
|
||
because a full sentence does not work as a carousel hook.
|
||
img-scale/x/y frame each capture on its telling region. */
|
||
const SLIDES = [
|
||
{
|
||
key: "titel", type: "title",
|
||
layouts: [{ id: "klassisch", label: "Klassisch" }, { id: "wabe", label: "Wabe" }, { id: "laptop", label: "Laptop" }, { id: "laptop-logo", label: "Laptop · Logo" }, { id: "laptop-blur", label: "Laptop · Blur" }, { id: "laptop-footer", label: "Laptop · Logo unten" }],
|
||
head: 'Vom <span class="a">Handwerker</span><br>f\u00fcr Handwerker',
|
||
sub: "Die Software, die deine Branchenprogramme nicht ersetzt, sondern verbindet.",
|
||
},
|
||
{
|
||
key: "projektmanagement", type: "feature",
|
||
layouts: [{ id: "standard", label: "Standard" }],
|
||
eyebrow: "Projektmanagement",
|
||
head: 'Du siehst auf einen Blick,<br><span class="a">wo jeder Auftrag steht.</span>',
|
||
body: "Projekte, Kunden, Angebote, F\u00f6rderantr\u00e4ge. Kein Suchen, kein Nachfragen, kein Zettelchaos.",
|
||
img: "./assets/screens/pipelines.png",
|
||
mockW: 1090, mockAR: 1.771,
|
||
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öser wählen, Aktionen dranhängen, fertig. Alles automatisch und ohne eine Zeile Code.",
|
||
screen: "flow",
|
||
mockW: 940, mockAR: 1.944, // 1400 x 720 der gerenderten Flaeche
|
||
// Die Bausteinliste ragt über den Ablauf hinaus: sie zeigt, woraus er entsteht.
|
||
media2: { screen: "picker", w: 360, ar: 0.62, left: 690, bottom: 96, z: 2, rot: 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's sofort. Aus jeder Nachricht wird mit einem Klick eine Aufgabe.",
|
||
img: "./assets/screens/chat.png",
|
||
mockW: 1030, mockAR: 1.703,
|
||
imgScale: 1, imgX: "0%", imgY: "0%",
|
||
// Das Anhang-Menü zeigt, was aus einer Nachricht alles werden kann.
|
||
media2: {
|
||
img: "./assets/screens/chat-menue.png",
|
||
w: 300, ar: 0.866, left: 120, bottom: 55, z: 2, rot: -3,
|
||
},
|
||
},
|
||
{
|
||
key: "form-builder", type: "feature",
|
||
layouts: [{ id: "standard", label: "Standard" }],
|
||
eyebrow: "Form Builder",
|
||
head: 'Formulare selbst bauen.<br><span class="a">Per Drag-and-drop.</span>',
|
||
body: "Von der Kontaktanfrage bis zum Aufmaß mit Unterschrift vor Ort. Teil sie per Link oder QR-Code, jede Einsendung wird automatisch zu Lead, Kontakt oder Auftrag.",
|
||
img: "./assets/screens/form-builder.png",
|
||
mockW: 540, mockAR: 0.75, mockLeft: 270,
|
||
imgScale: 3.8, imgX: "-36.9%", imgY: "-10%",
|
||
},
|
||
];
|
||
|
||
/* Standardwerte. Reset und die Übersicht auf der Seite lesen beide von hier,
|
||
damit die Doku nicht auseinanderläuft. size = null heisst: nicht über die
|
||
Schriftgröße steuerbar (Bilder, Geräte). */
|
||
const STANDARD = {
|
||
"badge": { label: "Tag", size: 26, prop: "font-size" },
|
||
"headline": { label: "Überschrift", size: 74, prop: "font-size" },
|
||
"body": { label: "Beschreibung", size: 30, prop: "font-size" },
|
||
"wabe-tagline": { label: "Tagline", size: 74, prop: "font-size" },
|
||
"wabe-claim": { label: "Claim", size: 31, prop: "font-size" },
|
||
"title-head": { label: "Überschrift", size: 88, prop: "font-size" },
|
||
"title-sub": { label: "Claim", size: 31, prop: "font-size" },
|
||
"wabe-logo": { label: "Logo", size: 430, prop: "width" },
|
||
"logo-full": { label: "Logo", size: 560, prop: "width" },
|
||
"screen-logo": { label: "Logo im Screen", size: 430, prop: "width" },
|
||
"laptop": { label: "Laptop", size: 960, prop: "width" },
|
||
"mock": { label: "Bild", size: null, prop: "width" },
|
||
"mock-2": { label: "Zweites 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;
|
||
if (key === "mock-2") return (SLIDES[i].media2 || {}).w || 400;
|
||
return (STANDARD[key] || {}).size;
|
||
}
|
||
|
||
// Schriftgrößen in 2er-Schritten, Breiten in 2 % ihrer Ausgangsgröße:
|
||
// 2 px auf ein 1090 px breites Bild würde man nie sehen.
|
||
function stepFor(key, i) {
|
||
const std = STANDARD[key];
|
||
if (!std || !std.prop) return 0;
|
||
if (std.prop === "font-size") return 2;
|
||
return Math.max(4, Math.round(stdSize(key, i) * 0.02));
|
||
}
|
||
|
||
let edits = SLIDES.map(() => ({}));
|
||
|
||
let picked = null; // { slide, key }
|
||
let editMode = "aus";
|
||
|
||
let current = 0;
|
||
let variant = "dunkel", tilt = "an", superSample = 2, continuous = "aus", view = "streifen", tagOut = "an", frameStyle = "chamfer";
|
||
|
||
/* ─── Projektstand ────────────────────────────────────────────────────────
|
||
Alles, was am Projekt hängt, wird gespeichert: Einstellungen, Layouts,
|
||
Muster und die Verschiebungen im Canvas. Das Werkzeug soll den Stand
|
||
halten, nicht der Kopf. */
|
||
const PROJECT = "post1-alt";
|
||
const STORE = "hivegrid.projekt." + PROJECT;
|
||
|
||
const STATE_URL = "./state/" + PROJECT + ".json";
|
||
|
||
function currentState() {
|
||
return {
|
||
v: 1, variant, tilt, superSample, continuous, view, tagOut, frameStyle,
|
||
current, layoutState, bgState, edits,
|
||
};
|
||
}
|
||
|
||
/* Der Stand geht in zwei Ablagen. Sofort in den localStorage, damit die
|
||
Rückmeldung nicht auf das Netz wartet, und gebündelt in
|
||
state/<projekt>.json. Nur die Datei lässt sich pushen, also ist sie die
|
||
Wahrheit; der localStorage ist der Rückfall, wenn der Server nicht läuft. */
|
||
let pushTimer = null;
|
||
function saveState() {
|
||
const state = currentState();
|
||
try { localStorage.setItem(STORE, JSON.stringify(state)); }
|
||
catch (err) { console.warn("Speichern fehlgeschlagen", err); }
|
||
markSaved();
|
||
clearTimeout(pushTimer);
|
||
pushTimer = setTimeout(() => pushState(state), 600);
|
||
}
|
||
|
||
async function pushState(state) {
|
||
pushTimer = null;
|
||
try {
|
||
const res = await fetch("/state?project=" + PROJECT, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(state),
|
||
});
|
||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||
} catch (err) { console.warn("Stand nicht in die Datei geschrieben", err); }
|
||
}
|
||
|
||
// Ein Reload darf keine Änderung verschlucken, die noch im Timer hängt.
|
||
addEventListener("beforeunload", () => {
|
||
if (!pushTimer) return;
|
||
clearTimeout(pushTimer);
|
||
navigator.sendBeacon("/state?project=" + PROJECT,
|
||
new Blob([JSON.stringify(currentState())], { type: "application/json" }));
|
||
});
|
||
|
||
async function loadState() {
|
||
let d = null, fromFile = false;
|
||
// Erst die Datei aus dem Repo, dann der Browser. Wer das Projekt frisch
|
||
// gezogen hat, soll den gepushten Stand sehen und nicht seinen eigenen alten.
|
||
try {
|
||
const res = await fetch(STATE_URL, { cache: "no-store" });
|
||
if (res.ok) { d = await res.json(); fromFile = true; }
|
||
} catch (_) {}
|
||
if (!d) {
|
||
try { d = JSON.parse(localStorage.getItem(STORE) || "null"); } catch (_) {}
|
||
}
|
||
if (!d || d.v !== 1) return false;
|
||
// Stand aus dem Browser, aber noch keine Datei: einmalig hinueberschreiben,
|
||
// sonst bliebe ein ueber Wochen gewachsener Stand ungeteilt liegen.
|
||
if (!fromFile) setTimeout(() => pushState(currentState()), 0);
|
||
// Nur übernehmen, was zur aktuellen Slide-Anzahl passt: Änderungen am
|
||
// Aufbau dürfen keinen kaputten Stand einspielen.
|
||
if (Array.isArray(d.layoutState) && d.layoutState.length === SLIDES.length) {
|
||
d.layoutState.forEach((id, i) => {
|
||
if (SLIDES[i].layouts.some(l => l.id === id)) layoutState[i] = id;
|
||
});
|
||
}
|
||
if (Array.isArray(d.bgState) && d.bgState.length === SLIDES.length) {
|
||
d.bgState.forEach((b, i) => { if (b && b.bg) bgState[i] = b; });
|
||
}
|
||
if (Array.isArray(d.edits) && d.edits.length === SLIDES.length) edits = d.edits;
|
||
variant = d.variant || variant;
|
||
tilt = d.tilt || tilt;
|
||
superSample = d.superSample || superSample;
|
||
continuous = d.continuous || continuous;
|
||
view = d.view || view;
|
||
tagOut = d.tagOut || tagOut;
|
||
frameStyle = d.frameStyle || frameStyle;
|
||
current = Number.isInteger(d.current) ? d.current : 0;
|
||
return true;
|
||
}
|
||
|
||
let savedTimer = null;
|
||
function markSaved() {
|
||
const el = document.getElementById("saved");
|
||
if (!el) return;
|
||
el.textContent = "gespeichert";
|
||
el.classList.add("on");
|
||
clearTimeout(savedTimer);
|
||
savedTimer = setTimeout(() => el.classList.remove("on"), 1200);
|
||
}
|
||
|
||
function forgetProject() {
|
||
clearTimeout(pushTimer); pushTimer = null;
|
||
try { localStorage.removeItem(STORE); } catch (_) {}
|
||
// Auch die Datei muss weg, sonst spielt sie den alten Stand beim nächsten
|
||
// Laden wieder ein.
|
||
fetch("/state?project=" + PROJECT, { method: "DELETE" })
|
||
.catch(() => {})
|
||
.then(() => location.reload());
|
||
}
|
||
// Per-slide so a single slide can carry a different ground.
|
||
const bgState = SLIDES.map(() => ({ bg: "formen", int: "dezent" }));
|
||
// Each slide picks its own layout from its own catalogue.
|
||
const layoutState = SLIDES.map(sl => sl.layouts[0].id);
|
||
// Every asset is inlined once at boot, so re-rendering a layout stays synchronous.
|
||
const assets = {};
|
||
let fontEmbedCss = "";
|
||
|
||
/* Der Streifen wird auf die verfügbare Breite skaliert, die Einzelansicht auf
|
||
feste 45 %. Der Rahmen bekommt die skalierte Größe, damit die Seite nicht
|
||
springt. */
|
||
/* zoom === null bedeutet: automatisch einpassen. Sobald der Nutzer zoomt,
|
||
hält der Wert, bis er auf "Fit" zurückgeht. */
|
||
let zoom = null;
|
||
|
||
function stripWidth() { return view === "streifen" ? 1080 * SLIDES.length : 1080; }
|
||
|
||
function autoFit() {
|
||
const scroller = document.getElementById("scroller");
|
||
const availW = Math.max(320, scroller.clientWidth - 120);
|
||
const availH = Math.max(240, scroller.clientHeight - 120);
|
||
return Math.min(availW / stripWidth(), availH / 1350, 0.45);
|
||
}
|
||
|
||
function fitStrip() {
|
||
const wrap = document.getElementById("strip-wrap");
|
||
const k = zoom || autoFit();
|
||
deck.style.transform = "scale(" + k + ")";
|
||
wrap.style.width = Math.round(stripWidth() * k) + "px";
|
||
wrap.style.height = Math.round(1350 * k) + "px";
|
||
const lvl = document.getElementById("zoom-fit");
|
||
if (lvl) lvl.textContent = zoom ? Math.round(k * 100) + " %" : "Fit";
|
||
}
|
||
|
||
function setZoom(k, anchor) {
|
||
const scroller = document.getElementById("scroller");
|
||
const before = zoom || autoFit();
|
||
const ax = anchor ? anchor.x : scroller.clientWidth / 2;
|
||
const ay = anchor ? anchor.y : scroller.clientHeight / 2;
|
||
/* Um den Mauszeiger zoomen, nicht um die linke obere Ecke — und dafür den
|
||
Punkt unter dem Zeiger messen statt ihn aus scrollLeft hochzurechnen.
|
||
Das Blatt sitzt in einer gepolsterten Fläche und wird zentriert, solange
|
||
Platz übrig ist; beides verschiebt den Ursprung. Ein reiner Dreisatz
|
||
ließ den Inhalt deshalb bei jedem Schritt um das Polster wegwandern. */
|
||
const s0 = scroller.getBoundingClientRect();
|
||
const r0 = deck.getBoundingClientRect();
|
||
const u = (s0.left + ax - r0.left) / before;
|
||
const v = (s0.top + ay - r0.top) / before;
|
||
zoom = Math.min(2, Math.max(0.05, k));
|
||
fitStrip();
|
||
const r1 = deck.getBoundingClientRect();
|
||
scroller.scrollLeft += (r1.left + u * zoom) - (s0.left + ax);
|
||
scroller.scrollTop += (r1.top + v * zoom) - (s0.top + ay);
|
||
}
|
||
|
||
const setStatus = (msg, kind) => {
|
||
statusEl.textContent = msg;
|
||
statusEl.className = "status" + (kind ? " " + kind : "");
|
||
};
|
||
|
||
const SWIPE_SVG =
|
||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" ' +
|
||
'stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg>';
|
||
|
||
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);
|
||
}
|
||
|
||
|
||
/* ─── Oberflächen als HTML ────────────────────────────────────────────────
|
||
Statt eines Screenshots baut jede Slide ihre Ansicht selbst auf. Vorteil:
|
||
bei jeder Renderstufe scharf, kein Fensterrand, kein Scrollbalken, und der
|
||
Ausschnitt lässt sich frei wählen. */
|
||
const ICON = {
|
||
inbox: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>',
|
||
person: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="7" r="4"/><path d="M2 21v-2a5 5 0 0 1 5-5h3"/><path d="M18.4 10.6a2 2 0 1 1 2.8 2.8L17 17.6l-3 .6.6-3z"/></svg>',
|
||
branch: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><circle cx="7" cy="18" r="2.5"/><circle cx="17" cy="6" r="2.5"/><path d="M7 15.5V9a4 4 0 0 1 4-4h3.5"/></svg>',
|
||
bell: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 1 1 12 0c0 5 2 6 2 6H4s2-1 2-6"/><path d="M10.3 20a2 2 0 0 0 3.4 0"/></svg>',
|
||
check: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="3.5" width="17" height="17" rx="3"/><path d="M8.5 12.2l2.6 2.6 4.6-5"/></svg>',
|
||
timer: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="13" r="8"/><path d="M12 9.5V13l2.2 2.2M9.5 2.5h5"/></svg>',
|
||
contact:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><circle cx="10" cy="8" r="3.5"/><path d="M3.5 20v-1.5a5 5 0 0 1 5-5H11"/><path d="M15.5 15.5l2 2 4-4"/></svg>',
|
||
cal: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="5" width="17" height="16" rx="3"/><path d="M3.5 10h17M8 3v4M16 3v4"/><circle cx="15.5" cy="15" r="2.2"/></svg>',
|
||
money: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="6" width="19" height="12" rx="3"/><circle cx="12" cy="12" r="2.2"/><path d="M6 12h.01M18 12h.01"/></svg>',
|
||
task: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 7h11M3.5 12h8M3.5 17h6"/><path d="M17.5 12v7M14 15.5h7"/></svg>',
|
||
mail: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="5" width="19" height="14" rx="3"/><path d="M3 7l9 6 9-6"/></svg>',
|
||
db: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="6" rx="8" ry="3.2"/><path d="M4 6v12c0 1.8 3.6 3.2 8 3.2s8-1.4 8-3.2V6"/><path d="M4 12c0 1.8 3.6 3.2 8 3.2s8-1.4 8-3.2"/></svg>',
|
||
};
|
||
|
||
// Knoten im Ablauf: x/y sind Mittelpunkte im 1400 x 720 Raum der Fläche.
|
||
const FLOW_NODES = [
|
||
{ x: 118, y: 360, c: "green", i: "inbox", t: "Formular eingegangen", s: "Website-Anfrage erfasst", start: 1 },
|
||
{ x: 330, y: 360, c: "red", i: "person", t: "Daten vereinheitlichen", s: "Kontakt und Firma ergänzen" },
|
||
{ x: 542, y: 360, c: "purple", i: "branch", t: "Passender Lead?", s: "Vollständigkeit prüfen" },
|
||
{ x: 790, y: 180, c: "red", i: "person", t: "CRM aktualisieren", s: "Lead und Aktivität speichern" },
|
||
{ x: 1002, y: 180, c: "blue", i: "bell", t: "Bestätigung senden", s: "Persönliche E-Mail" },
|
||
{ x: 1214, y: 180, c: "yellow",i: "check", t: "Lead zuweisen", s: "Team informieren" },
|
||
{ x: 790, y: 545, c: "blue", i: "bell", t: "Angaben nachfragen", s: "Infos anfordern" },
|
||
{ x: 1002, y: 545, c: "purple",i: "timer", t: "48 Stunden warten", s: "Auf Ergänzung warten" },
|
||
{ x: 1214, y: 545, c: "yellow",i: "check", t: "Manuell prüfen", s: "Offenen Lead nachfassen" },
|
||
];
|
||
|
||
function flowHtml() {
|
||
const nodes = FLOW_NODES.map(n =>
|
||
'<div class="ui-node' + (n.start ? " start" : "") + '" style="left:' + (n.x - 58) +
|
||
'px;top:' + (n.y - 58) + 'px">' +
|
||
'<div class="tile tile-' + n.c + '">' + ICON[n.i] + '</div>' +
|
||
'<div class="t">' + n.t + '</div><div class="s">' + n.s + '</div>' +
|
||
'</div>').join("");
|
||
|
||
const seg = (x1, y1, x2, y2) => {
|
||
const mx = (x1 + x2) / 2;
|
||
return '<path d="M' + x1 + ' ' + y1 + ' C' + mx + ' ' + y1 + ' ' + mx + ' ' + y2 +
|
||
' ' + x2 + ' ' + y2 + '"/>' +
|
||
'<circle cx="' + x1 + '" cy="' + y1 + '" r="5"/>' +
|
||
'<circle cx="' + x2 + '" cy="' + y2 + '" r="5"/>';
|
||
};
|
||
const wires =
|
||
seg(176, 360, 272, 360) + seg(388, 360, 484, 360) +
|
||
seg(600, 330, 732, 180) + seg(600, 390, 732, 545) +
|
||
seg(848, 180, 944, 180) + seg(1060, 180, 1156, 180) +
|
||
seg(848, 545, 944, 545) + seg(1060, 545, 1156, 545);
|
||
|
||
return '<div class="dots"></div>' +
|
||
'<svg class="wires" viewBox="0 0 1400 720" preserveAspectRatio="none">' + wires + '</svg>' +
|
||
'<div class="nodes">' + nodes + '</div>';
|
||
}
|
||
|
||
const PICKER_ROWS = [
|
||
["trigger", "AUSLÖSER", "Startet den Ablauf"],
|
||
["r", "inbox", "Neue Anfrage", "Startet, sobald eine neue Anfrage eingeht", 1],
|
||
["r", "contact", "Kontakt geändert", "Wenn sich Daten eines Kontakts ändern"],
|
||
["r", "cal", "Termin in Kürze", "Kurz vor einem anstehenden Termin"],
|
||
["r", "money", "Zahlungsziel überschritten", "Wenn eine Rechnung überfällig ist"],
|
||
["r", "timer", "Zeitplan", "Zu festen Zeiten, z. B. jeden Montag"],
|
||
["action", "AKTIONEN", "Führt etwas aus"],
|
||
["b", "task", "Aufgabe anlegen", "Erstellt eine Aufgabe für Ihr Team"],
|
||
["b", "mail", "E-Mail senden", "Verschickt eine E-Mail an den Kontakt"],
|
||
["b", "db", "Datensatz ändern", "Aktualisiert Felder eines Kontakts"],
|
||
];
|
||
|
||
function pickerHtml() {
|
||
return PICKER_ROWS.map(r => {
|
||
if (r[0] === "trigger" || r[0] === "action") {
|
||
return '<div class="sec ' + r[0] + '"><span class="dot"></span>' + r[1] +
|
||
'<span class="hint">' + r[2] + '</span></div>';
|
||
}
|
||
const tone = r[0] === "r" ? "tile-yellow" : "tile-blue";
|
||
return '<div class="ui-row' + (r[4] ? " on" : "") + '">' +
|
||
'<div class="ic ' + tone + '">' + ICON[r[1]] + '</div>' +
|
||
'<div class="tx"><b>' + r[2] + '</b><span>' + r[3] + '</span></div>' +
|
||
'<div class="q">?</div>' +
|
||
'</div>';
|
||
}).join("");
|
||
}
|
||
|
||
// Entwurfsmaße: darin sind alle Positionen gedacht.
|
||
const SCREENS = {
|
||
flow: { w: 1400, h: 720, html: flowHtml },
|
||
picker: { w: 560, h: 900, html: pickerHtml },
|
||
};
|
||
|
||
/* Baut die Oberfläche und skaliert sie auf die Rahmenbreite. Die 24 px sind
|
||
die Polsterung des Rahmens, die von der Außenbreite abgeht. */
|
||
function screenHtml(key, frameW) {
|
||
const sc = SCREENS[key];
|
||
const k = (frameW - 24) / sc.w;
|
||
return '<div class="ui-fit"><div class="ui ui-' + key +
|
||
'" style="--dw:' + sc.w + 'px;--dh:' + sc.h + 'px;--uk:' + k.toFixed(5) + '">' +
|
||
sc.html() + '</div></div>';
|
||
}
|
||
|
||
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; });
|
||
}
|
||
// ".mock" trifft auch das zweite Bild, weil es dieselbe Klasse trägt.
|
||
// Der spezifischere Schlüssel muss gewinnen, sonst teilen sich beide
|
||
// Bilder eine Bearbeitung.
|
||
slideEl.querySelectorAll(".mock-2").forEach(n => { n.dataset.edit = "mock-2"; });
|
||
}
|
||
|
||
function applyEdits(slideEl, i) {
|
||
const set = edits[i] || {};
|
||
slideEl.querySelectorAll("[data-edit]").forEach(n => {
|
||
const e = set[n.dataset.edit] || {};
|
||
n.style.setProperty("--ex", (e.dx || 0) + "px");
|
||
n.style.setProperty("--ey", (e.dy || 0) + "px");
|
||
const std = STANDARD[n.dataset.edit];
|
||
if (std && std.prop) {
|
||
n.style[std.prop === "font-size" ? "fontSize" : "width"] =
|
||
e.size ? e.size + "px" : "";
|
||
}
|
||
});
|
||
}
|
||
|
||
// 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]
|
||
|| withImg[0];
|
||
const inner = sl.screen
|
||
? '<div class="mock-inner">' + screenHtml(sl.screen, sl.mockW || 1090) + '</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>';
|
||
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>' + inner;
|
||
page.appendChild(mock);
|
||
|
||
if (sl.media2) {
|
||
const m2 = sl.media2;
|
||
const el2 = document.createElement("div");
|
||
el2.className = "mock mock-2";
|
||
el2.style.cssText =
|
||
"--mock-w:" + m2.w + "px;--mock-ar:" + m2.ar +
|
||
";--mock-left:" + m2.left + "px;--m2-bottom:" + m2.bottom +
|
||
"px;--m2-z:" + m2.z + ";--rot:" + (m2.rot || 0) + "deg";
|
||
el2.innerHTML = '<div class="mock-inner">' +
|
||
(m2.screen ? screenHtml(m2.screen, m2.w) :
|
||
'<img class="laptop-shot" alt="" src="' + assets[m2.img] + '">') +
|
||
'</div>';
|
||
page.appendChild(el2);
|
||
}
|
||
} else {
|
||
const sw = document.createElement("div");
|
||
sw.className = "swipe";
|
||
sw.innerHTML = "<span>Swipe</span>" + SWIPE_SVG;
|
||
page.appendChild(sw);
|
||
}
|
||
|
||
renderPage(page, j);
|
||
return page;
|
||
}
|
||
|
||
function renderPage(page, j) {
|
||
page.querySelector(".content").innerHTML = contentHtml(SLIDES[j], j);
|
||
if (SLIDES[j].type === "title") page.classList.add("title");
|
||
// Das Layout hängt an der Seite, damit eine Titel-Seite auch in einer
|
||
// Nachbar-Slide korrekt aussieht.
|
||
page.dataset.layout = layoutState[j];
|
||
tagEditable(page);
|
||
applyEdits(page, j);
|
||
}
|
||
|
||
function buildSlide(s, i) {
|
||
const el = document.createElement("div");
|
||
el.className = "slide" + (s.type === "title" ? " title" : "");
|
||
el.dataset.variant = variant;
|
||
el.dataset.tilt = tilt;
|
||
el.dataset.bg = bgState[i].bg;
|
||
el.dataset.int = bgState[i].int;
|
||
el.dataset.layout = layoutState[i];
|
||
el.dataset.cont = continuous;
|
||
el.dataset.tagout = tagOut;
|
||
el.dataset.frame = frameStyle;
|
||
el.dataset.editmode = editMode;
|
||
el.style.setProperty("--slide-i", i);
|
||
el.innerHTML = BG_HTML;
|
||
buildWaben(el.querySelector(".layer-waben"), i);
|
||
|
||
// Nur die direkten Nachbarn: weiter als eine Slide wandert in der Praxis
|
||
// nichts, und jede zusätzliche Seite kostet Renderzeit.
|
||
const sheet = document.createElement("div");
|
||
sheet.className = "sheet";
|
||
for (let j = Math.max(0, i - 1); j <= Math.min(SLIDES.length - 1, i + 1); j++) {
|
||
sheet.appendChild(buildPage(j));
|
||
}
|
||
el.appendChild(sheet);
|
||
return el;
|
||
}
|
||
|
||
/* Every asset is inlined as a data URL once, before the deck is built.
|
||
Same-origin already satisfies the canvas, but inlining removes every fetch
|
||
and timing failure mode from the export path, and it lets a layout switch
|
||
re-render without touching the network. */
|
||
async function preloadAssets() {
|
||
const paths = ["./assets/brand/logo.svg", "./assets/brand/logo-white.svg"]
|
||
.concat(SLIDES.filter(s => s.img).map(s => s.img))
|
||
.concat(SLIDES.filter(s => s.media2 && s.media2.img).map(s => s.media2.img));
|
||
/* workflow.png hängt an keiner Slide mehr, die Laptop-Layouts fordern es
|
||
aber weiter an. Ohne diesen Eintrag stand src="undefined" im Bild und
|
||
der Export brach mit "1 Bild(er) nicht geladen" ab — betraf alle vier
|
||
Laptop-Layouts der Titel-Slide. */
|
||
paths.push("./assets/screens/pipelines.png", "./assets/screens/workflow.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, { cache: "no-store" }).catch(() => null);
|
||
if (!res || !res.ok) {
|
||
if (optional.has(path)) continue;
|
||
throw new Error(path + " -> HTTP " + (res ? res.status : "kein Zugriff"));
|
||
}
|
||
const blob = await res.blob();
|
||
assets[path] = await new Promise((ok, fail) => {
|
||
const fr = new FileReader();
|
||
fr.onload = () => ok(fr.result);
|
||
fr.onerror = fail;
|
||
fr.readAsDataURL(blob);
|
||
});
|
||
}
|
||
}
|
||
|
||
function press(id, attr, value) {
|
||
document.querySelectorAll("#" + id + " button").forEach(b =>
|
||
b.setAttribute("aria-pressed", String(b.dataset[attr] === value)));
|
||
}
|
||
|
||
function applyAll() {
|
||
deck.style.background = variant === "hell" ? "#FFFFFF" : "#30313D";
|
||
deck.querySelectorAll(".slide").forEach((el, i) => {
|
||
el.dataset.variant = variant;
|
||
el.dataset.tilt = tilt;
|
||
el.dataset.bg = bgState[i].bg;
|
||
el.dataset.int = bgState[i].int;
|
||
el.dataset.layout = layoutState[i];
|
||
el.dataset.cont = continuous;
|
||
el.dataset.tagout = tagOut;
|
||
el.dataset.frame = frameStyle;
|
||
if (SLIDES[i].type === "title") rebuildPage(i);
|
||
el.dataset.editmode = editMode;
|
||
});
|
||
}
|
||
|
||
// The layout catalogue differs per slide, so the buttons are rebuilt on every
|
||
// slide change rather than just re-pressed.
|
||
function syncLayoutButtons() {
|
||
const box = document.getElementById("layouts");
|
||
box.innerHTML = "";
|
||
const cat = SLIDES[current].layouts;
|
||
box.className = "opts" + (cat.length === 1 ? " one" : "");
|
||
cat.forEach(l => {
|
||
const b = document.createElement("button");
|
||
b.dataset.l = l.id;
|
||
b.textContent = l.label;
|
||
b.setAttribute("aria-pressed", String(l.id === layoutState[current]));
|
||
box.appendChild(b);
|
||
});
|
||
}
|
||
|
||
// Reflect the active slide's own ground in the panel.
|
||
function syncBgButtons() {
|
||
press("bgs", "b", bgState[current].bg);
|
||
press("ints", "i", bgState[current].int);
|
||
}
|
||
|
||
function show(i) {
|
||
current = (i + SLIDES.length) % SLIDES.length;
|
||
saveState();
|
||
deck.querySelectorAll(".holder").forEach((h, n) =>
|
||
h.classList.toggle("sel", n === current));
|
||
document.querySelectorAll("#slides-nav button").forEach((b, n) =>
|
||
b.setAttribute("aria-pressed", String(n === current)));
|
||
syncBgButtons();
|
||
syncLayoutButtons();
|
||
fitStrip();
|
||
metaEl.textContent = view === "streifen"
|
||
? "Slide " + (current + 1) + " ausgewählt. Klick auf eine Slide wechselt die Auswahl"
|
||
: "Slide " + (current + 1) + " von " + SLIDES.length;
|
||
}
|
||
|
||
/* Copy grows, the mockup does not: once the text block reaches the frame the
|
||
slide silently breaks. Rather than discover that in an export, measure it
|
||
and say so. */
|
||
function checkCollisions() {
|
||
const problems = [];
|
||
deck.querySelectorAll(".holder").forEach((holder, i) => {
|
||
const own = holder.querySelector('.page[data-j="' + i + '"]');
|
||
if (!own) return;
|
||
const mock = own.querySelector(".mock");
|
||
if (!mock) return;
|
||
const restore = holder.style.display;
|
||
holder.style.display = "block";
|
||
const content = own.querySelector(".content");
|
||
const gap = mock.offsetTop - (content.offsetTop + content.offsetHeight);
|
||
holder.style.display = restore;
|
||
if (gap < 24) problems.push("Slide " + (i + 1) + " (" + SLIDES[i].eyebrow + "): " + Math.round(gap) + "px");
|
||
});
|
||
if (problems.length) {
|
||
console.warn("Text kollidiert mit dem Mockup:\n" + problems.join("\n"));
|
||
setStatus("Achtung: Text zu nah am Mockup (" + problems.length + "x), siehe Konsole", "err");
|
||
}
|
||
return problems.length === 0;
|
||
}
|
||
|
||
/* Die Bibliothek baut ein SVG in Knotengröße und zieht es beim Zeichnen auf
|
||
die Canvas-Größe hoch. WebKit rasterisiert das SVG dabei in seiner
|
||
*intrinsischen* Größe und interpoliert danach: Ein höherer pixelRatio
|
||
erzeugt so keine zusätzlichen Details, nur weichere Kanten. Genau deshalb
|
||
wirkte 3x nicht schärfer als 1x.
|
||
|
||
Der Ausweg: Das SVG bekommt gleich die Zielgröße über width/height, die
|
||
Slide behält per style ihre echten 1080 x 1350 und wird darin skaliert.
|
||
Die Bibliothek setzt style nach width/height, das Überschreiben greift.
|
||
pixelRatio bleibt 1, weil die Vergrößerung schon im SVG steckt. */
|
||
function renderOptions() {
|
||
const k = superSample;
|
||
return {
|
||
width: 1080 * k,
|
||
height: 1350 * k,
|
||
pixelRatio: 1,
|
||
fontEmbedCSS: fontEmbedCss,
|
||
style: {
|
||
width: "1080px",
|
||
height: "1350px",
|
||
transform: k === 1 ? "none" : "scale(" + k + ")",
|
||
transformOrigin: "top left",
|
||
},
|
||
};
|
||
}
|
||
|
||
/* The browser download route was unreliable here: a data: URL opened a blank
|
||
tab, a blob: URL opened the image in a tab, both times ignoring the download
|
||
attribute. Since this only runs against the local server, the bytes go to
|
||
/save instead and land in the Downloads folder directly. */
|
||
async function downloadBlob(blob, filename) {
|
||
const res = await fetch("/save", {
|
||
method: "POST",
|
||
headers: { "X-Filename": filename },
|
||
body: blob,
|
||
});
|
||
if (!res.ok) throw new Error("Speichern fehlgeschlagen: HTTP " + res.status);
|
||
return (await res.json()).path;
|
||
}
|
||
|
||
// Numeric prefix keeps the upload order intact in the file picker.
|
||
const fileName = i => String(i + 1).padStart(2, "0") + "-" + SLIDES[i].key + "-" + variant + ".png";
|
||
|
||
/* html-to-image serialises the slide into a data:image/svg+xml URL and paints
|
||
that as a single image. Raster children of a foreignObject decode
|
||
asynchronously and can miss that paint, which is why a screenshot went
|
||
missing every few exports while the rest of the slide was fine. It is a
|
||
race, so the fix is not to hope but to verify: render, check that every
|
||
screenshot area actually carries pixels, and repeat if it does not. */
|
||
/* Geprüft wird jede Fläche, die ein Bild tragen muss — die Displays und die
|
||
Geräterahmen selbst. Die Rahmen fehlten in dieser Liste, deshalb blieb ein
|
||
Export ohne Macbook unbemerkt. */
|
||
const SHOT_BOXES =
|
||
".mock-inner, .laptop-screen, .hex-shot, .laptop-frame";
|
||
const MAX_TRIES = 5;
|
||
// Ab hier gilt eine Fläche als leer. Da nur noch das Innere des Vierecks
|
||
// abgetastet wird, ist kein Beiwerk mehr dabei, das die Spanne aufbläht.
|
||
const LEER_SPANNE = 6;
|
||
|
||
/* Die vier Ecken eines Kindes in Slide-Koordinaten.
|
||
Vorher stand hier das achsenparallele Umrechteck. Das war der Fehler:
|
||
.szene-mb und .szene-ip stehen per matrix3d schräg im Raum, ihr Umrechteck
|
||
umfasst Raster, Hintergrund und das Nachbargerät. Darin fand die Prüfung
|
||
immer genug Helligkeitsspanne und winkte eine leere Displayfläche durch —
|
||
genau so kam ein Export ohne Screenshots durch die Kontrolle.
|
||
getBoxQuads gäbe die echten Ecken, kennt Chrome aber nicht. Also werden
|
||
vier 1-px-Marken in die Ecken gesetzt und ausgemessen: die Layout-Engine
|
||
rechnet dabei jede Transformation der Vorfahren korrekt mit. Die Marken
|
||
verschwinden vor dem Rendern wieder. */
|
||
function quadInSlide(el, child) {
|
||
const sr = el.getBoundingClientRect();
|
||
const k = sr.width / 1080;
|
||
let host = child, stell = null;
|
||
/* <img> kann keine Kinder tragen. Also entsteht im Elternknoten ein
|
||
deckungsgleicher Stellvertreter — mit derselben Box UND derselben
|
||
eigenen Transformation. Ohne letztere fehlt genau der Versatz
|
||
verschobener Bilder: .mock-inner > img wird per scale und translate in
|
||
seinem Fenster verschoben, das Viereck läge sonst daneben und die
|
||
Messung geriete auf Rahmen statt auf Bild. */
|
||
if (child.tagName === "IMG") {
|
||
const eltern = child.offsetParent || child.parentElement;
|
||
if (!eltern) return null;
|
||
const cs = getComputedStyle(child);
|
||
stell = document.createElement("div");
|
||
stell.style.cssText = "position:absolute;margin:0;padding:0;border:0;" +
|
||
"pointer-events:none;left:" + child.offsetLeft + "px;top:" + child.offsetTop +
|
||
"px;width:" + child.offsetWidth + "px;height:" + child.offsetHeight +
|
||
"px;transform:" + cs.transform + ";transform-origin:" + cs.transformOrigin + ";";
|
||
eltern.appendChild(stell);
|
||
host = stell;
|
||
}
|
||
const bw = host.offsetWidth, bh = host.offsetHeight;
|
||
if (bw < 4 || bh < 4) { if (stell) stell.remove(); return null; }
|
||
// Absolute Marken brauchen einen positionierten Bezug.
|
||
const posVorher = host.style.position;
|
||
if (getComputedStyle(host).position === "static") host.style.position = "relative";
|
||
const marken = [[0, 0], [1, 0], [1, 1], [0, 1]].map(([fx, fy]) => {
|
||
const m = document.createElement("div");
|
||
m.style.cssText = "position:absolute;margin:0;padding:0;border:0;width:1px;" +
|
||
"height:1px;pointer-events:none;left:" + (fx * bw) + "px;top:" + (fy * bh) + "px;";
|
||
host.appendChild(m);
|
||
return m;
|
||
});
|
||
const pts = marken.map(m => {
|
||
const r = m.getBoundingClientRect();
|
||
return { x: ((r.left + r.width / 2) - sr.left) / k,
|
||
y: ((r.top + r.height / 2) - sr.top) / k };
|
||
});
|
||
marken.forEach(m => m.remove());
|
||
host.style.position = posVorher;
|
||
if (stell) stell.remove();
|
||
return pts;
|
||
}
|
||
|
||
/* A screenshot area is never a flat surface: it carries UI, text and colour.
|
||
If the sampled pixels are all but identical, the image did not make it. */
|
||
/* Ein fehlendes Bild hinterlässt die reine Hintergrundfarbe des Rahmens,
|
||
also eine vollkommen gleichmäßige Fläche. Genau darauf wird geprüft, nicht
|
||
auf "genug Kontrast": Eine helle Formular-Oberfläche hat wenig Spanne, ist
|
||
aber nie gleichmäßig. Ein zu hoher Schwellenwert hat sie fälschlich als
|
||
fehlend gemeldet. */
|
||
const quadMitte = q => ({ x: (q[0].x + q[1].x + q[2].x + q[3].x) / 4,
|
||
y: (q[0].y + q[1].y + q[2].y + q[3].y) / 4 });
|
||
|
||
function quadEinruecken(q, f) {
|
||
const m = quadMitte(q);
|
||
return q.map(p => ({ x: p.x + (m.x - p.x) * f, y: p.y + (m.y - p.y) * f }));
|
||
}
|
||
|
||
function quadFlaeche(q) {
|
||
let s = 0;
|
||
for (let i = 0; i < 4; i++) { const a = q[i], b = q[(i + 1) % 4]; s += a.x * b.y - b.x * a.y; }
|
||
return Math.abs(s) / 2;
|
||
}
|
||
|
||
function quadEnthaelt(q, p) {
|
||
let pos = 0, neg = 0;
|
||
for (let i = 0; i < 4; i++) {
|
||
const a = q[i], b = q[(i + 1) % 4];
|
||
const d = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
|
||
if (d > 0) pos++; else if (d < 0) neg++;
|
||
}
|
||
return pos === 0 || neg === 0;
|
||
}
|
||
|
||
/* Messpunkte dort, wo Bild und Rahmen sich überlappen — und nur dort.
|
||
Beides einzeln führt in die Irre: Das Szenen-Display ist im Entwurfsraum
|
||
größer als der sichtbare Ausschnitt, weil Deckelblende, Tastaturschale und
|
||
das davorstehende iPad Teile verdecken; diese Randzonen tragen Kontrast
|
||
und täuschen ein vorhandenes Bild vor. Beim Feature-Mockup ist es
|
||
umgekehrt: das gezoomte Bild ragt weit über sein Fenster hinaus, dort
|
||
läge die Messung außerhalb der Slide. Das Raster wird über das kleinere
|
||
der beiden Vierecke gelegt und gegen das größere geprüft. */
|
||
function ueberlappPunkte(qBox, qImg) {
|
||
const A = quadEinruecken(qBox, 0.08), B = quadEinruecken(qImg, 0.08);
|
||
const raster = quadFlaeche(A) <= quadFlaeche(B) ? A : B;
|
||
const gegen = raster === A ? B : A;
|
||
const pts = [];
|
||
for (let a = 0; a <= 16; a++) {
|
||
for (let b = 0; b <= 16; b++) {
|
||
const u = a / 16, v = b / 16;
|
||
const tx = raster[0].x + (raster[1].x - raster[0].x) * u;
|
||
const ty = raster[0].y + (raster[1].y - raster[0].y) * u;
|
||
const bx = raster[3].x + (raster[2].x - raster[3].x) * u;
|
||
const by = raster[3].y + (raster[2].y - raster[3].y) * u;
|
||
const p = { x: Math.round(tx + (bx - tx) * v), y: Math.round(ty + (by - ty) * v) };
|
||
if (quadEnthaelt(gegen, p)) pts.push(p);
|
||
}
|
||
}
|
||
return pts;
|
||
}
|
||
|
||
function spanneAn(lum, pts) {
|
||
let min = 255, max = 0, n = 0;
|
||
for (const p of pts) {
|
||
const l = lum(p.x, p.y);
|
||
if (l < 0) continue;
|
||
if (l < min) min = l;
|
||
if (l > max) max = l;
|
||
n++;
|
||
}
|
||
// Zu wenige Punkte in der Slide: nichts zu beurteilen, also nicht bemängeln.
|
||
return n < 20 ? 255 : max - min;
|
||
}
|
||
|
||
/* Der Anteil dunkler Punkte, den eine Vorlage auf weißem Grund erzeugt.
|
||
Für die Geräterahmen ist Gleichmäßigkeit allein kein brauchbares Merkmal:
|
||
fehlt der Rahmen, bleiben die Rasterlinien des Hintergrunds stehen und
|
||
täuschen Spanne vor. Ein fehlendes Macbook lässt aber eine helle Fläche
|
||
zurück, und der Erwartungswert dafür wird aus der Vorlage selbst geeicht,
|
||
damit kein fester Schwellenwert an einer bestimmten Grafik klebt. */
|
||
function dunkelAnteilVorlage(im) {
|
||
const c = document.createElement("canvas");
|
||
c.width = c.height = 48;
|
||
const g = c.getContext("2d", { willReadFrequently: true });
|
||
g.fillStyle = "#fff";
|
||
g.fillRect(0, 0, 48, 48);
|
||
try { g.drawImage(im, 0, 0, 48, 48); } catch (_) { return 0; }
|
||
const d = g.getImageData(0, 0, 48, 48).data;
|
||
let dunkel = 0;
|
||
for (let i = 0; i < d.length; i += 4)
|
||
if ((d[i] * 299 + d[i + 1] * 587 + d[i + 2] * 114) / 1000 < 140) dunkel++;
|
||
return dunkel / (48 * 48);
|
||
}
|
||
|
||
function dunkelAn(lum, pts) {
|
||
let dunkel = 0, n = 0;
|
||
for (const p of pts) {
|
||
const l = lum(p.x, p.y);
|
||
if (l < 0) continue;
|
||
if (l < 140) dunkel++;
|
||
n++;
|
||
}
|
||
return n < 20 ? 1 : dunkel / n;
|
||
}
|
||
|
||
async function renderSlide(i) {
|
||
const holder = deck.querySelectorAll(".holder")[i];
|
||
const el = holder.querySelector(".slide");
|
||
// In der Einzelansicht sind die übrigen Holder ausgeblendet; zum Messen und
|
||
// Rendern muss der Holder kurz sichtbar sein.
|
||
const restore = holder.style.display;
|
||
holder.style.display = "block";
|
||
// Auswahlrahmen und Hover-Kontur sind Werkzeug, kein Inhalt: für den Export
|
||
// abschalten, sonst wird die gelbe Umrandung mitgerendert.
|
||
const modeBefore = el.dataset.editmode;
|
||
el.dataset.editmode = "aus";
|
||
try {
|
||
await document.fonts.ready;
|
||
const imgs = [...el.querySelectorAll("img")];
|
||
await Promise.all(imgs.map(im =>
|
||
im.complete ? im.decode().catch(() => {})
|
||
: new Promise(r => { im.onload = r; im.onerror = r; })));
|
||
const broken = imgs.filter(im => !im.naturalWidth);
|
||
if (broken.length) throw new Error(broken.length + " Bild(er) nicht geladen");
|
||
|
||
/* Jede prüfpflichtige Fläche mit ihrem Viereck und, bei Geräterahmen,
|
||
mit dem aus der Vorlage geeichten Erwartungswert. */
|
||
const ziele = [...el.querySelectorAll(SHOT_BOXES)].map(c => {
|
||
// Der Geräterahmen ist selbst ein Bild; sonst steckt es im Rahmen.
|
||
const rahmen = c.tagName === "IMG";
|
||
const bild = rahmen ? c : c.querySelector("img");
|
||
if (!bild) return null;
|
||
const qBox = quadInSlide(el, rahmen ? c.parentElement : c);
|
||
const qImg = quadInSlide(el, bild);
|
||
if (!qBox || !qImg) return null;
|
||
return {
|
||
name: c.className || c.tagName.toLowerCase(),
|
||
pts: ueberlappPunkte(qBox, qImg),
|
||
rahmen,
|
||
erwartetDunkel: rahmen ? dunkelAnteilVorlage(bild) : 0,
|
||
};
|
||
}).filter(Boolean);
|
||
|
||
for (let attempt = 1; attempt <= MAX_TRIES; attempt++) {
|
||
/* Die Slide entsteht bei Renderqualität k in 1080k × 1350k, und genau
|
||
das wird auch gespeichert. Hier stand ein downscaleTo auf 1080 × 1350
|
||
— das warf bei k = 3 zwei Drittel der linearen Auflösung wieder weg.
|
||
Eine Vorlage mit 2390 px landete dadurch mit 916 px im Ergebnis,
|
||
obwohl sie schon in 2748 px gerendert vorlag. Wer eine hoch
|
||
aufgelöste Aufnahme liefert, soll sie auch im Export wiederfinden;
|
||
kleiner rechnen kann jedes Zielsystem selbst. */
|
||
const canvas = await htmlToImage.toCanvas(el, renderOptions());
|
||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||
// Die Messpunkte stehen in Slide-Koordinaten (1080 breit) und werden
|
||
// auf die tatsächliche Ausgabegröße umgerechnet.
|
||
const AW = canvas.width, AH = canvas.height, af = AW / 1080;
|
||
const px = ctx.getImageData(0, 0, AW, AH).data;
|
||
const lum = (x, y) => {
|
||
const sx = Math.round(x * af), sy = Math.round(y * af);
|
||
if (sx < 0 || sy < 0 || sx >= AW || sy >= AH) return -1;
|
||
const j = (sy * AW + sx) * 4;
|
||
return (px[j] * 299 + px[j + 1] * 587 + px[j + 2] * 114) / 1000;
|
||
};
|
||
|
||
const befund = ziele.map(z => {
|
||
const spanne = spanneAn(lum, z.pts);
|
||
// Eine leere Fläche trägt die reine Hintergrundfarbe ihres Rahmens.
|
||
let fehlt = spanne <= LEER_SPANNE;
|
||
let dunkel = null;
|
||
// Beim Geräterahmen greift Gleichmäßigkeit nicht: fehlt er, bleibt
|
||
// der gerasterte Hintergrund stehen. Dann zählt, wie viel Dunkles
|
||
// ankommt, gemessen gegen die Vorlage.
|
||
if (!fehlt && z.rahmen && z.erwartetDunkel >= 0.08) {
|
||
dunkel = dunkelAn(lum, z.pts);
|
||
if (dunkel < z.erwartetDunkel * 0.5) fehlt = true;
|
||
}
|
||
return { name: z.name, spanne, dunkel, erwartet: z.erwartetDunkel, fehlt };
|
||
});
|
||
const fehlend = befund.filter(b => b.fehlt);
|
||
|
||
if (!fehlend.length) {
|
||
const blob = await new Promise(r => canvas.toBlob(r, "image/png"));
|
||
if (blob) return blob;
|
||
}
|
||
if (attempt < MAX_TRIES) {
|
||
console.warn("Slide " + (i + 1) + ": Versuch " + attempt + " unvollstaendig. " +
|
||
befund.map(b => b.name + " Spanne " + b.spanne.toFixed(1) +
|
||
(b.dunkel === null ? "" : ", dunkel " + b.dunkel.toFixed(3) +
|
||
" erwartet " + b.erwartet.toFixed(3)) + (b.fehlt ? " <== leer" : "")).join(" | "));
|
||
setStatus("Slide " + (i + 1) + ": " + fehlend.map(b => b.name).join(", ") +
|
||
" fehlte, Versuch " + (attempt + 1) + " ...");
|
||
await new Promise(r => setTimeout(r, 180 * attempt));
|
||
}
|
||
}
|
||
throw new Error("Slide " + (i + 1) + " nach " + MAX_TRIES +
|
||
" Versuchen unvollstaendig. Details in der Konsole.");
|
||
} finally {
|
||
el.dataset.editmode = modeBefore;
|
||
holder.style.display = restore;
|
||
}
|
||
}
|
||
|
||
// Die Ausgabegröße hängt an der Renderqualität und wird mitgesagt, damit
|
||
// sichtbar ist, was tatsächlich auf der Platte landet.
|
||
const ausgabeGroesse = () => (1080 * superSample) + " × " + (1350 * superSample) + " px";
|
||
|
||
async function downloadOne() {
|
||
setStatus("Rendere Slide " + (current + 1) + " …");
|
||
downloadBlob(await renderSlide(current), fileName(current));
|
||
setStatus("Slide " + (current + 1) + " gespeichert, " + ausgabeGroesse(), "ok");
|
||
}
|
||
|
||
async function downloadAll() {
|
||
const zip = new JSZip();
|
||
for (let i = 0; i < SLIDES.length; i++) {
|
||
setStatus("Rendere Slide " + (i + 1) + " von " + SLIDES.length + " …");
|
||
zip.file(fileName(i), await renderSlide(i));
|
||
}
|
||
show(current);
|
||
setStatus("Packe ZIP …");
|
||
const path = await downloadBlob(await zip.generateAsync({ type: "blob" }),
|
||
"hivegrid-post1-alt-" + variant + ".zip");
|
||
setStatus("ZIP gespeichert: " + path.split("/").pop() + ", " + ausgabeGroesse(), "ok");
|
||
}
|
||
|
||
const guard = fn => () => fn().catch(err => {
|
||
console.error(err);
|
||
setStatus("Fehler: " + err.message, "err");
|
||
});
|
||
|
||
function buildDeck() {
|
||
SLIDES.forEach((sl, i) => {
|
||
const holder = document.createElement("div");
|
||
holder.className = "holder";
|
||
holder.dataset.n = String(i);
|
||
holder.appendChild(buildSlide(sl, i));
|
||
deck.appendChild(holder);
|
||
});
|
||
SLIDES.forEach((sl, i) => {
|
||
const b = document.createElement("button");
|
||
b.dataset.n = String(i);
|
||
b.setAttribute("aria-pressed", String(i === 0));
|
||
b.innerHTML = '<span class="num">' + (i + 1) + '</span>' +
|
||
(sl.type === "title" ? "Titel" : sl.eyebrow);
|
||
nav.appendChild(b);
|
||
});
|
||
}
|
||
|
||
deck.addEventListener("click", e => {
|
||
if (editMode === "an" && e.target.closest("[data-edit]")) return;
|
||
const h = e.target.closest(".holder");
|
||
if (h) show(Number(h.dataset.n));
|
||
});
|
||
|
||
/* Ziehen: Die Vorschau ist skaliert, die Slide nicht. Mausdeltas werden
|
||
deshalb durch den Maßstab geteilt, sonst wandert das Element bei kleiner
|
||
Vorschau um ein Vielfaches. */
|
||
deck.addEventListener("mousedown", e => {
|
||
if (editMode !== "an") return;
|
||
const node = e.target.closest("[data-edit]");
|
||
if (!node) return;
|
||
const holder = node.closest(".holder");
|
||
const page = node.closest(".page");
|
||
const idx = Number(page.dataset.j); // Besitzer-Seite, nicht die angeklickte Slide
|
||
const key = node.dataset.edit;
|
||
e.preventDefault();
|
||
|
||
show(Number(holder.dataset.n));
|
||
pick(idx, key);
|
||
|
||
// Den Maßstab nehmen, mit dem fitStrip gerechnet hat, nicht das Rechteck
|
||
// messen: .strip ist ein Flex-Kind der schon skalierten Hülle, seine
|
||
// Border-Box misst deshalb Breite mal Maßstab hoch zwei. Geteilt wurde
|
||
// damit durch das Quadrat — bei eingepasster Ansicht wanderte das Element
|
||
// um ein Vielfaches der Mausbewegung.
|
||
const k = zoom || autoFit();
|
||
const set = (edits[idx][key] = edits[idx][key] || {});
|
||
const startX = e.clientX, startY = e.clientY;
|
||
const baseX = set.dx || 0, baseY = set.dy || 0;
|
||
node.style.cursor = "grabbing";
|
||
|
||
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);
|
||
|
||
/* deltaMode: 0 = Pixel, 1 = Zeilen (Firefox), 2 = Seiten. Ohne Umrechnung
|
||
zoomt Firefox pro Rastung um ein Vielfaches weniger als Chrome. */
|
||
function wheelPx(v, mode) { return v * (mode === 1 ? 16 : mode === 2 ? 400 : 1); }
|
||
|
||
/* Blankes Rad zoomt — eine gewöhnliche PC-Maus soll ohne Zusatztaste
|
||
auskommen. Strg/Cmd + Rad tut dasselbe, damit der Trackpad-Pinch (der
|
||
ctrlKey mitschickt) weiter greift. Waagerechtes Wischen und ⇧ + Rad
|
||
schieben dagegen den Ausschnitt: ein Trackpad darf sich nicht wie eine
|
||
Zoomwippe anfühlen. */
|
||
document.getElementById("scroller").addEventListener("wheel", e => {
|
||
const sc = e.currentTarget;
|
||
const dx = wheelPx(e.deltaX, e.deltaMode), dy = wheelPx(e.deltaY, e.deltaMode);
|
||
if (e.shiftKey || (!dy && dx)) {
|
||
e.preventDefault();
|
||
sc.scrollLeft += (dx || dy);
|
||
return;
|
||
}
|
||
if (!dy) return;
|
||
e.preventDefault();
|
||
const r = sc.getBoundingClientRect();
|
||
// Exponentiell gerechnet fühlen sich Rastung (~100 px auf einen Schlag)
|
||
// und Trackpad (viele kleine Werte) gleich an. Der Deckel hält die
|
||
// Schwung-Schübe im Zaum, die macOS nach dem Loslassen nachschiebt.
|
||
const k = Math.min(3, Math.max(1 / 3, Math.exp(-dy * 0.0015)));
|
||
setZoom((zoom || autoFit()) * k,
|
||
{ x: e.clientX - r.left, y: e.clientY - r.top });
|
||
}, { passive: false });
|
||
|
||
/* Panning: linke Maustaste auf der Fläche reicht. Leertaste und mittlere
|
||
Taste bleiben als gewohnte Wege bestehen — im Bearbeiten-Modus sind sie
|
||
der einzige, denn dort gehört der linke Zug dem Element unter dem Zeiger. */
|
||
(function panning() {
|
||
const sc = document.getElementById("scroller");
|
||
let space = false, active = null, dragged = false;
|
||
|
||
document.addEventListener("keydown", e => {
|
||
if (e.code !== "Space" || e.target.matches("input, textarea")) return;
|
||
e.preventDefault();
|
||
if (!space) { space = true; sc.classList.add("panready"); }
|
||
});
|
||
document.addEventListener("keyup", e => {
|
||
if (e.code !== "Space") return;
|
||
space = false; sc.classList.remove("panready");
|
||
});
|
||
|
||
sc.addEventListener("mousedown", e => {
|
||
dragged = false;
|
||
if (!space && e.button !== 1) {
|
||
// Linke Taste zieht die Fläche, außer sie hat im Bearbeiten-Modus
|
||
// ein Element erwischt: das verschiebt sich selbst.
|
||
if (e.button !== 0) return;
|
||
if (editMode === "an" && e.target.closest("[data-edit]")) return;
|
||
}
|
||
e.preventDefault();
|
||
active = { x: e.clientX, y: e.clientY, l: sc.scrollLeft, t: sc.scrollTop };
|
||
sc.classList.add("panning");
|
||
});
|
||
document.addEventListener("mousemove", e => {
|
||
if (!active) return;
|
||
const dx = e.clientX - active.x, dy = e.clientY - active.y;
|
||
// Erst ab ein paar Pixeln ist es ein Zug und kein Klick, sonst
|
||
// verschluckt jede zittrige Hand die Slide-Auswahl.
|
||
if (Math.abs(dx) + Math.abs(dy) > 3) dragged = true;
|
||
sc.scrollLeft = active.l - dx;
|
||
sc.scrollTop = active.t - dy;
|
||
});
|
||
document.addEventListener("mouseup", () => {
|
||
active = null; sc.classList.remove("panning");
|
||
});
|
||
// Nach echtem Ziehen den Klick schlucken: sonst springt die Auswahl auf
|
||
// die Slide, über der die Maus zufällig loslässt.
|
||
sc.addEventListener("click", e => {
|
||
if (!dragged) return;
|
||
dragged = false;
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
}, true);
|
||
})();
|
||
|
||
document.getElementById("zoom-in").addEventListener("click", () => setZoom((zoom || autoFit()) * 1.25));
|
||
document.getElementById("zoom-out").addEventListener("click", () => setZoom((zoom || autoFit()) / 1.25));
|
||
document.getElementById("zoom-fit").addEventListener("click", () => { zoom = null; fitStrip(); });
|
||
|
||
nav.addEventListener("click", e => {
|
||
const b = e.target.closest("button"); if (b) show(Number(b.dataset.n));
|
||
});
|
||
document.getElementById("variants").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (b) { variant = b.dataset.v; press("variants", "v", variant); applyAll(); saveState(); }
|
||
});
|
||
document.getElementById("layouts").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (b) { layoutState[current] = b.dataset.l; syncLayoutButtons(); rebuildPage(current); applyAll(); saveState(); }
|
||
});
|
||
document.getElementById("conts").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (b) { continuous = b.dataset.c; press("conts", "c", continuous); applyAll(); saveState(); }
|
||
});
|
||
document.getElementById("bgs").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (b) { bgState[current].bg = b.dataset.b; syncBgButtons(); applyAll(); saveState(); }
|
||
});
|
||
document.getElementById("ints").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (b) { bgState[current].int = b.dataset.i; syncBgButtons(); applyAll(); saveState(); }
|
||
});
|
||
document.getElementById("frames").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (b) { frameStyle = b.dataset.f; press("frames", "f", frameStyle); applyAll(); saveState(); }
|
||
});
|
||
document.getElementById("tilts").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (b) { tilt = b.dataset.t; press("tilts", "t", tilt); applyAll(); saveState(); }
|
||
});
|
||
|
||
document.getElementById("quality").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (b) { superSample = Number(b.dataset.q); press("quality", "q", b.dataset.q); saveState(); }
|
||
});
|
||
|
||
document.getElementById("dl-png").addEventListener("click", guard(downloadOne));
|
||
document.getElementById("dl-all").addEventListener("click", guard(downloadAll));
|
||
|
||
const BG_CYCLE = ["formen", "formen-g", "nur-g", "waben", "punkte", "raster", "aus"];
|
||
document.addEventListener("keydown", e => {
|
||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||
const k = e.key.toLowerCase();
|
||
if (editMode === "an" && picked && e.key.startsWith("Arrow")) {
|
||
const step = e.shiftKey ? 10 : 1;
|
||
if (e.key === "ArrowRight") nudge(step, 0);
|
||
else if (e.key === "ArrowLeft") nudge(-step, 0);
|
||
else if (e.key === "ArrowDown") nudge(0, step);
|
||
else if (e.key === "ArrowUp") nudge(0, -step);
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
if (e.key === "ArrowRight") show(current + 1);
|
||
else if (e.key === "ArrowLeft") show(current - 1);
|
||
else if (k === "v") { variant = variant === "dunkel" ? "hell" : "dunkel"; press("variants", "v", variant); applyAll(); saveState(); }
|
||
else if (k === "l") {
|
||
const cat = SLIDES[current].layouts;
|
||
const n = cat.findIndex(x => x.id === layoutState[current]);
|
||
layoutState[current] = cat[(n + 1) % cat.length].id;
|
||
syncLayoutButtons(); rebuildPage(current); applyAll(); saveState();
|
||
}
|
||
else if (k === "m") {
|
||
const st = bgState[current];
|
||
st.bg = BG_CYCLE[(BG_CYCLE.indexOf(st.bg) + 1) % BG_CYCLE.length];
|
||
syncBgButtons(); applyAll();
|
||
}
|
||
else if (k === "i") {
|
||
const st = bgState[current];
|
||
st.int = st.int === "dezent" ? "kraeftig" : "dezent";
|
||
syncBgButtons(); applyAll();
|
||
}
|
||
else if (k === "t") { tilt = tilt === "an" ? "aus" : "an"; press("tilts", "t", tilt); applyAll(); saveState(); }
|
||
else if (e.key === "+" || e.key === "=") { setZoom((zoom || autoFit()) * 1.25); }
|
||
else if (e.key === "-") { setZoom((zoom || autoFit()) / 1.25); }
|
||
else if (e.key === "0") { zoom = null; fitStrip(); }
|
||
else if (k === "w") { view = view === "streifen" ? "einzeln" : "streifen"; deck.dataset.view = view; press("views", "w", view); zoom = null; show(current); }
|
||
else if (k === "e") { document.querySelector('#editmodes button[data-e="' + (editMode === "an" ? "aus" : "an") + '"]').click(); }
|
||
else if (k === "r") {
|
||
const cyc = ["chamfer", "rund", "stapel", "aus"];
|
||
frameStyle = cyc[(cyc.indexOf(frameStyle) + 1) % cyc.length];
|
||
press("frames", "f", frameStyle); applyAll(); saveState();
|
||
}
|
||
else if (k === "g") { tagOut = tagOut === "an" ? "aus" : "an"; press("tagouts", "o", tagOut); applyAll(); saveState(); }
|
||
else if (k === "c") { continuous = continuous === "an" ? "aus" : "an"; press("conts", "c", continuous); applyAll(); saveState(); }
|
||
else if (k === "d") guard(downloadOne)();
|
||
else if (k === "a") guard(downloadAll)();
|
||
});
|
||
|
||
/* Boot: inline the screens, wait for Manrope to actually be resident
|
||
(otherwise the first export silently falls back to a system font), and
|
||
pre-compute the font CSS once instead of on every render. */
|
||
(async function boot() {
|
||
try {
|
||
setStatus("Lade Screens ...");
|
||
await preloadAssets();
|
||
const restored = await loadState();
|
||
buildDeck();
|
||
deck.dataset.view = view;
|
||
press("variants", "v", variant);
|
||
press("tilts", "t", tilt);
|
||
press("quality", "q", String(superSample));
|
||
press("conts", "c", continuous);
|
||
press("tagouts", "o", tagOut);
|
||
press("frames", "f", frameStyle);
|
||
press("views", "w", view);
|
||
show(current);
|
||
if (restored) setStatus("Projektstand geladen", "ok");
|
||
await document.fonts.ready;
|
||
fontEmbedCss = await htmlToImage.getFontEmbedCSS(deck.querySelector(".slide"));
|
||
["dl-png", "dl-all"].forEach(id => document.getElementById(id).disabled = false);
|
||
if (checkCollisions()) setStatus("Bereit. 5 Slides, 1080 × 1350 px", "ok");
|
||
} catch (err) {
|
||
console.error(err);
|
||
setStatus("Fehler beim Laden: " + err.message, "err");
|
||
}
|
||
})();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|