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