Files
posts/post2-alt.html
T
dejanandClaude Opus 5 16b85a285a Projektuebersicht: alle Streifen untereinander auf einer Flaeche
Neu unter Projekt -> "Alle Projekte im Streifen". uebersicht.html zeigt die
Streifen aller drei Projekte gestapelt, ohne Sidebar, auf derselben Art
Flaeche wie der Generator: ziehen schiebt, das Rad zoomt, Fit passt ein.

Die Seite baut die Slides nicht nach. Jedes Projekt kommt als iframe mit
?nur=streifen; in diesem Modus blendet die Post-Seite ihre Bedienoberflaeche
aus und zeigt den Streifen in natuerlicher Groesse, skaliert wird aussen.
Ein Nachbau wuerde irgendwann von dem abweichen, was der Generator zeigt,
und waere dann schlimmer als keine Uebersicht.

Drei Dinge, die dieser Modus regelt:

- Es wird nichts gespeichert (stateLocked). Drei Kacheln, die beim
  Schliessen ihren Stand zurueckschreiben, waeren genau die Falle aus
  POST-2.md 6.
- Die Ansicht steht fest auf "streifen". Post 2 stand im Stand auf
  "einzeln" und meldete deshalb 1080 px statt der vollen Breite.
- Der Auswahlrahmen faellt weg; in der Uebersicht ist keine Slide "die
  aktuelle".

Die Streifenbreite kommt per postMessage von der Post-Seite: die Zahl der
Slides ist je Projekt anders und soll nicht zweimal gepflegt werden.

Beim Einpassen zaehlen Beschriftung, Abstaende und Polster nicht mit --
die skalieren nicht, weil sie ausserhalb des transformierten iframes
stehen. Ohne diese Rechnung lief die letzte Reihe unten hinaus.

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

5238 lines
244 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="de">
<head>
<script>
/* Uebersichts-Modus: die Seite laeuft dann als Kachel in uebersicht.html.
Das Attribut muss vor dem ersten Zeichnen stehen, sonst blitzt die
Bedienoberflaeche kurz auf. Deshalb hier oben und nicht im Hauptskript. */
if (location.search.indexOf("nur=streifen") >= 0)
document.documentElement.dataset.nur = "streifen";
</script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PostGrid Post 2 · Alternative</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; flex-direction: column;
}
/* ─── Menueleiste ────────────────────────────────────────────────────────
Statt einer Knopfreihe fuer die Projekte eine Leiste wie in einem
Bildprogramm. Sie bedient keine eigene Logik, sondern klickt die
vorhandenen Schalter im Bedienfeld an: sonst gaebe es zwei Wahrheiten
ueber denselben Zustand. */
.menubar {
flex: none; display: flex; align-items: stretch; gap: 2px;
height: 34px; padding: 0 8px;
background: #14161C; border-bottom: 1px solid #24262F;
font-size: 13px; user-select: none; position: relative; z-index: 60;
}
.menubar .marke {
display: flex; align-items: center; padding: 0 12px 0 4px;
font-weight: 800; letter-spacing: -0.01em; color: #E8EAEE;
}
.menu { position: relative; }
.menu > button {
height: 100%; padding: 0 11px; cursor: pointer;
background: none; border: 0; color: #C7CCD6;
font-family: inherit; font-size: 13px;
}
.menu > button:hover { background: #1D1F26; }
.menu[data-offen="ja"] > button { background: #262932; color: #fff; }
.menu > .liste { display: none; }
.menu[data-offen="ja"] > .liste {
display: block; position: absolute; top: 100%; left: 0;
min-width: 232px; padding: 5px;
background: #1D1F26; border: 1px solid #2E313B; border-radius: 9px;
}
.menu .liste button {
display: flex; width: 100%; align-items: center; gap: 14px;
padding: 6px 9px; cursor: pointer; text-align: left;
background: none; border: 0; border-radius: 6px;
color: #C7CCD6; font-family: inherit; font-size: 13px;
}
.menu .liste button:hover { background: #2A2D36; color: #fff; }
.menu .liste button:disabled { opacity: .38; cursor: default; background: none; }
.menu .liste button .kurz { margin-left: auto; color: #6E7480; font-size: 11.5px; }
.menu .liste button .haken { width: 12px; color: var(--accent-yellow); }
.menu .liste hr { height: 1px; margin: 5px 4px; background: #2E313B; border: 0; }
.arbeitsflaeche { flex: 1; display: flex; min-height: 0; }
/* Inhaltsfelder der gewaehlten Slide. Eine Spalte, damit lange Texte nicht
in einer schmalen Zelle kleben. */
#inhalt { display: flex; flex-direction: column; gap: 4px; }
.inh-label { font-size: 11px; color: #8A8F9B; letter-spacing: .04em; margin-top: 4px; }
#inhalt input, #inhalt textarea, #inhalt select {
width: 100%; box-sizing: border-box;
background: #22242C; color: #D6DAE3; border: 1px solid #2E313B;
border-radius: 7px; padding: 7px 8px;
font-family: inherit; font-size: 12px; line-height: 1.45;
}
#inhalt textarea { resize: vertical; min-height: 46px; }
#inhalt input:focus, #inhalt textarea:focus, #inhalt select:focus {
outline: none; border-color: var(--accent-yellow);
}
#slide-ops button { flex: none; padding: 8px 10px; }
/* Das Feld des gewaehlten Elements. Es steht rechts, damit links die Slide
und das Allgemeine bleiben und beides nicht durcheinandergeht. Es faehrt
nur bei einer Auswahl ein; die Buehne rechnet ihre Einpassung danach neu,
sonst haenge der Streifen schief. */
.panel-rechts {
display: none;
border-right: none; border-left: 1px solid #24262F;
padding: 22px 22px 40px 22px;
}
body[data-elem="an"] .panel-rechts { display: block; }
.el-kopf { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.el-kopf h1 { flex: 1; margin: 0; }
.el-zu {
flex: none; width: 26px; height: 26px; border-radius: 7px; cursor: pointer;
background: #22242C; color: #8A8F9B; border: 1px solid #2E313B;
font-family: inherit; font-size: 13px; line-height: 1;
}
.el-zu:hover { background: #2A2D36; color: #C7CCD6; }
#el-inhalt textarea {
width: 100%; box-sizing: border-box; resize: vertical; min-height: 62px;
background: #22242C; color: #D6DAE3; border: 1px solid #2E313B;
border-radius: 7px; padding: 7px 8px;
font-family: inherit; font-size: 12px; line-height: 1.45;
}
#el-inhalt textarea:focus { outline: none; border-color: var(--accent-yellow); }
#el-inhalt .abschnitt { font-size: 11px; color: #8A8F9B; letter-spacing: .09em;
text-transform: uppercase; margin: 14px 0 6px; }
#el-inhalt .opts button { flex: 1; }
/* Zielmarke beim Ablegen einer Datei. */
.holder.ablegen::before {
content: ""; position: absolute; inset: 0; z-index: 5; pointer-events: none;
outline: 12px dashed var(--accent-yellow); outline-offset: -6px;
background: rgba(241,183,41,.10);
}
.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: 100%; 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;
}
#el-inhalt { font-size: 12px; line-height: 1.55; color: #C7CCD6; }
#el-inhalt .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. */
#el-inhalt .fields {
display: grid; grid-template-columns: 42px 1fr 20px 1fr;
gap: 6px 7px; align-items: center;
}
#el-inhalt label { color: #6E7480; font-size: 11px; }
#el-inhalt 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;
}
#el-inhalt input::-webkit-outer-spin-button,
#el-inhalt input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
#el-inhalt input:focus { outline: none; border-color: var(--accent-yellow); }
#el-inhalt .hint { color: #6E7480; margin-top: 6px; }
#el-inhalt .row { margin-top: 0; }
.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 {
/* Nicht 100vh: darueber sitzt die Menueleiste. Mit voller
Fensterhoehe rutscht die Fussleiste samt Zoom und Merkzettel
unter den unteren Rand. */
flex: 1; position: relative; height: 100%; 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;
}
/* ─── Grund ──────────────────────────────────────────────────────────────
Ein einziger Grund: das Raster. Formen, Waben, Punkte und die grosse
G-Marke sind raus, ebenso das durchgehende Band mit den Sechsecken auf
den Naehten. Einstellbar bleibt nur die Staerke. */
.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; }
.layer-raster { position: absolute; inset: 0; width: 100%; height: 100%; }
/* 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. */
/* Das Raster gehoert zum Theme notebook. Hier bleibt der Grund normalerweise
ruhig; eine Textur nimmt den schwebenden Karten den Halt. Die Ebene bleibt
im Markup, damit beide Themes denselben Aufbau haben.
Zum Ausprobieren laesst es sich unter "Raster" wieder einschalten --
dieselben Werte wie in post1.html: 78 px Teilung, 2 px Strich. */
.layer-raster { background: none; }
.slide[data-raster="an"] .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(--int-mul, 1) * .17);
}
.slide[data-raster="an"][data-variant="dunkel"] .layer-raster {
opacity: calc(var(--int-mul, 1) * .26);
}
/* Ueberschrift und Fliesstext mittig. Der Fliesstext hat eine max-width,
also braucht er zusaetzlich automatische Seitenraender -- text-align
allein zentriert nur die Zeilen im Kasten, nicht den Kasten. */
.slide[data-mitte="an"] .content { text-align: center; }
.slide[data-mitte="an"] .body { margin-left: auto; margin-right: auto; }
/* ─── Das Band ──────────────────────────────────────────────────────────
Ein durchgehendes Band ueber den ganzen Streifen, jede Slide zeigt nur
ihren Ausschnitt. Die Verschiebung laeuft ueber --slide-i, das die
Slide ohnehin schon traegt: Slide 3 zeigt also die dritten 1080 px
derselben Zeichnung. An der Naht laeuft die Linie damit weiter, statt
neu anzusetzen -- das ist der Uebergang, den ein Carousel sonst nicht
hat, weil jede Slide fuer sich steht.
Es liegt in .bg und damit unter allem. Deckend heisst aber auch:
es deckt. Was darueber liegen soll, muss selbst deckend sein --
Linienwerk auf Transparenz verschwindet darin. */
.layer-band {
position: absolute; top: 0;
left: calc(var(--slide-i) * -1080px);
}
/* ─── Visualisierungen ───────────────────────────────────────────────────
Post 2 zeigt keine Screenshots, sondern das Stichwort aus dem Text als
Bild. Gebaut aus derselben Formensprache wie die Slides: chamferte
Kaesten, Sechsecke, der eine gelbe Akzent. Alles HTML, damit es beim
3-fachen Export genauso scharf bleibt wie die Schrift und sich im Canvas
verschieben laesst wie jedes andere Element.
Die Maße sind gemessen, nicht geschaetzt: der Inhaltsblock endet bei
y 415 bis 460, unten bleiben 92 px Rand. Rund 800 px Hoehe stehen also
zur Verfuegung, und die soll eine Visualisierung auch fuellen. */
.vis {
position: absolute; z-index: 1;
left: var(--vis-left, 92px);
bottom: var(--vis-bottom, 96px);
width: var(--vis-w, 896px);
color: var(--head-color);
}
.vis svg { display: block; width: 100%; height: auto; overflow: visible; }
/* Hier stand das Layout-CSS der alten Visualisierungen. Alle sechs sind
inzwischen gezeichnet, das SVG fuellt seinen Container selbst. Die Regeln
haben dreimal zugeschlagen, weil die Klasse weiter am Container haengt:
.vis-verbinden mit display:grid schob das SVG in eine Rasterzelle,
.vis-lose mit zwei Spalten auf ein Drittel Breite. Es darf hier kein
Layout-CSS mehr auftauchen. Gebraucht werden nur .vis und .vis svg.
/* Slide 5 ist gezeichnet und braucht kein eigenes Layout mehr: das SVG
fuellt den Container. Das alte Raster stand hier noch und hat die
Zeichnung als Rasterzelle auf ein Drittel zusammengeschoben. Wer eine
Visualisierung auf SVG umstellt, muss ihr Layout-CSS mit entfernen. */
/* Slide 6 ist gezeichnet, das alte Formular-Layout ist entfallen. Die
Klasse .vis-warteliste haengt weiter am Container: Layout-CSS darf hier
nicht wieder auftauchen, sonst rahmt es das SVG ein. */
/* ─── Teamkarte ──────────────────────────────────────────────────────────
Nachbau der Karte von der Ueber-uns-Seite im aktiven Zustand: Hintergrund
entsaettigt und abgedunkelt, die Person in voller Farbe, das gelbe
Namensschild sichtbar. Werte aus TeamCards.tsx uebernommen.
Ohne die weichgezeichnete Ellipse aus dem Original: die haengt an
backdrop-filter, und was der Export daraus macht, ist nicht verlaesslich. */
/* Der Halter traegt das Foto und darueber die Zeichenebene. Die Ebene
ragt bewusst ueber das Foto hinaus, oben am weitesten: dort sitzt der
Helm. Ihre Masse sind Prozent und keine Pixel, damit sie mitwaechst,
wenn die Karte im Canvas breiter gezogen wird. */
.karte-halter { position: relative; }
/* Der Selektor muss ".vis svg" schlagen, sonst zieht die allgemeine Regel
die Ebene auf Containerbreite und der Rahmen sitzt zu klein und
verschoben. Deshalb hier svg.karte-skizze und nicht nur die Klasse. */
.vis svg.karte-skizze {
position: absolute; z-index: 6; pointer-events: none;
left: -24.19%; width: 153.23%;
top: -23.23%; height: 141.29%;
}
/* Kein gezeichneter Rahmen mehr: das Foto ist selbst chamfert, das ist die
Markenform, und ein dunkles Bild auf hellem Grund braucht keine
Umrandung. Die Zeichenebene bleibt im Markup, zeichnet aber nichts. */
.vis svg.karte-skizze { display: none; }
/* Der Rahmen der Ueber-uns-Seite, Werte aus TeamCards.tsx: die Karte
traegt aussen Radius 38 auf teal-hellem Grund, das abgedunkelte Foto
sitzt 4,2 % eingerueckt mit Radius 27 (= 38 x .72). Die Person darf
ueber diesen Rand hinausragen und wird erst vom Aussenradius
beschnitten -- daher inset 0 und z-index 4. */
.karte {
position: relative; aspect-ratio: 1/1; overflow: hidden;
background: var(--accent-teal); isolation: isolate; border-radius: 38px;
}
.karte-grund {
position: absolute; inset: 4.2%; z-index: 1; overflow: hidden;
border-radius: 27px; background: #2b2d2a;
filter: grayscale(.04) saturate(.74) brightness(.73);
}
.karte-grund img { position: absolute; inset: 0; width: 100%; height: 100%;
object-fit: cover; object-position: 50% 34%; }
.karte-wash {
position: absolute; inset: 4.2%; border-radius: 27px;
background: linear-gradient(180deg, rgba(15,26,46,.01), rgba(15,26,46,.12));
}
.karte-person {
position: absolute; inset: 0; z-index: 4;
width: 100%; height: 100%; object-fit: cover; object-position: 50% 34%;
filter: saturate(1) contrast(1.01);
}
/* Die Infokarte schwebt ueber dem Foto, statt als flaches Schild darin zu
kleben. Weiss auf dunklem Bild, chamfert und ohne Rand: clip-path wuerde
ihn beschneiden (lib/chamfer.ts der Website). */
.karte-name {
position: absolute; left: 5.4%; bottom: 5.4%; z-index: 5;
display: flex; align-items: center; gap: 18px;
padding: 20px 28px 20px 20px; background: #FFFFFF;
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
);
}
/* Die G-Marke statt einer nackten Wabe. Sie hat fast dieselben
Proportionen (384,5 : 332,9), passt also in denselben Platz. Als
Inline-SVG und nicht als Datei: ein Bild im foreignObject kann beim
Export verschwinden (siehe 7). */
.karte-name .marke {
width: 52px; height: 45px; flex: none; display: block;
}
.karte-name .marke svg { display: block; width: 100%; height: 100%; }
.karte-name .zeilen { display: flex; flex-direction: column; gap: 2px; }
.karte-name b { font-size: 30px; font-weight: 800; letter-spacing: -.025em;
line-height: 1.1; color: #1A1A1A; }
.karte-name small { font-size: 20px; font-weight: 600; color: #555555; }
/* Das Badge der Landingpage, Werte aus LandingPageClient.tsx: Monospace,
um 10 Grad gedreht, rechts ueber die Kante haengend. Masse mit 1,9
skaliert -- die Website zeigt es auf einem 680er Foto, hier steht es auf
einem 1080er Blatt, das im Feed auf gut 400 px schrumpft. Der
drop-shadow der Vorlage faellt weg (§5).
Es liegt neben .karte und nicht darin: .karte hat overflow:hidden fuer
den Radius, und ein Badge, das ueber die Kante haengt, waere dort
abgeschnitten. */
.karte-chip {
position: absolute; right: -50px; top: 104px; z-index: 7;
padding: 24px 32px; background: var(--accent-yellow); color: #3F2F0A;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 25px; font-weight: 850; letter-spacing: .1em;
text-transform: uppercase; white-space: nowrap;
transform: rotate(10deg); transform-origin: right center;
clip-path: polygon(
19px 0, calc(100% - 19px) 0, 100% 19px, 100% calc(100% - 19px),
calc(100% - 19px) 100%, 19px 100%, 0 calc(100% - 19px), 0 19px
);
}
/* ─── 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: var(--sheet-w, 5400px); z-index: 1;
}
.page { position: absolute; top: 0; width: 1080px; height: 1350px; }
/* Im Theme modern traegt das Badge wieder Platz, deshalb beginnt der Block
bei 92 -- dem Rand der Safe Zone -- statt bei den 59 des Themes notebook. */
.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.
Frueher stand hier visibility: hidden, damit das Tag seinen Platz im
Textfluss behaelt. Genau der Platz war das Problem: 54 px Hoehe plus 38 px
Abstand haben die Ueberschrift 92 px nach unten geschoben, und Post 2 sass
dadurch 125 px tiefer als die Titel-Slide von Post 1. Jetzt gibt das Tag
den Platz frei, und der obere Abstand steht direkt im padding von
.content. Wer das Tag je sichtbar macht, muss das padding mitziehen. */
/* Das Chamfer-Badge der Website: gelb aufgehellte Flaeche, dunkler Text,
Versalien und weite Sperrung. Es steht dort ueber jeder Sektion. Im Theme
notebook ist es ausgeblendet, hier traegt es die Einordnung. */
/* Ausgeblendet. Das Badge stand testweise ueber jeder Slide, wie auf der
Website ueber jeder Sektion. Auf einer Slide nimmt es aber Platz weg,
ohne etwas zu sagen, was die Ueberschrift nicht schon sagt. */
.badge {
display: none; margin-bottom: 28px;
font-size: 24px; font-weight: 600; letter-spacing: 0.16em;
text-transform: uppercase;
padding: 11px 22px;
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
);
}
.slide[data-tagout="an"] .badge { margin-left: -20px; }
/* 74 px ist die Grundgroesse fuer alle Slides. Die Titel-Slide hebt sie
ueber den Projektstand auf 88 an, genau wie Post 1 es bei seiner Tagline
macht. Fuer alle sechs ginge 88 nicht: gemessen ist "Dein
Branchenprogramm" dann 1022 px breit, verfuegbar sind 896, und die
Ueberschriften von Slide 2, 4 und 5 wuerden dreizeilig. */
/* Groesser und enger als im Theme notebook: die Vorbilder setzen die
Ueberschrift als Blickfang ueber fast die ganze Breite. */
.headline {
font-size: 76px; font-weight: 800;
letter-spacing: -0.032em; line-height: 1.04;
color: var(--head-color);
}
/* Kein Marker. Der Akzent ist im Theme modern ein farbiges Wort mit einem
Balken darunter -- der Marker ist das Erkennungszeichen des Themes
notebook und hat hier nichts verloren.
Der Balken ist der Hover-Zustand der Ueberschrift auf der Landingpage
(LandingPageClient.tsx: h-[3px], -bottom-1, bg-current, im Ruhezustand
auf 22 % gestaucht, bei Hover auf volle Breite). Wir nehmen die volle
Breite, ein Standbild kennt kein Hover.
Masse in em statt in px: dort steht der Balken unter 62 px Schrift,
hier unter 76 -- und auf der Titel-Slide unter 88. 3/62 und 4/62
ergeben die Werte unten und wachsen damit von selbst mit. */
.headline .a, .title-head .a, .wabe-tagline .a {
color: var(--accent-yellow);
background: none;
padding: 0; margin: 0;
position: relative;
}
.headline .a::after, .title-head .a::after, .wabe-tagline .a::after {
content: ""; position: absolute; left: 0; right: 0;
bottom: -0.065em; height: 0.048em;
background: currentColor;
}
.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;
}
/* ─── Laptop mit Board ───────────────────────────────────────────────────
Fertiges Mockup: der Screen ist Teil des Bildes. Kein ausgeknocktes
Display, keine Projektion per matrix3d, kein zweiter Screenshot dahinter.
Damit fallen auch die gemessenen Vierecke und Eckradien weg, die das
vorherige Szenen-Mockup gebraucht hat. */
/* Breiter als das vorherige Mockup, weil dieses Bild links und rechts rund
ein Viertel freien Rand mitbringt: bei 880px ist das Geraet selbst wieder
etwa so gross wie vorher, und 880 bleibt innerhalb der 888px, die zwischen
den Innenraendern der Slide frei sind. */
.szene { position: relative; width: 880px; }
.szene-dev { width: 100%; height: auto; display: block; }
/* Das Mockup steht im DOM nach der Ueberschrift und lag damit darueber.
Ein gross gezogenes Geraet hat sie deshalb verdeckt und im
Bearbeiten-Modus die Klicks geschluckt, sodass man nicht mehr an sie
herankam. Die Zeile oben liegt jetzt darueber. */
.page.title[data-layout="szene"] .title-top { position: relative; z-index: 3; }
.page.title[data-layout="szene"] .szene { z-index: 1; }
.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;
}
/* ─── Variant: dunkel ────────────────────────────────────────────────── */
/* Theme modern, dunkle Variante. bg-dark aus der Website. */
.slide[data-variant="dunkel"] {
background: #0F1A2E;
--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);
--pat-color: #FFFFFF; --pat-strength: 0;
--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);
}
/* ─── Variant: hell ──────────────────────────────────────────────────── */
/* Theme modern, helle Variante. Die Werte stammen aus app/globals.css der
Website, nicht aus eigener Erfindung: surface, text-primary,
text-secondary, container-bg, border-neutral. Eine erfundene Palette war
der erste Fehler dieser Fassung. */
.slide[data-variant="hell"] {
background: var(--surface);
--head-color: #1A1A1A;
--body-color: #555555;
--accent-on: #C8901A;
--marker: var(--accent-yellow);
--frame-bg: #F6F8FA;
--shape-fill: #1A1A1A;
--shape-line: #E5E5E5;
--pat-color: #30313D; --pat-strength: 0;
--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);
}
/* ─── Uebersichts-Modus ──────────────────────────────────────────────────
Die Seite zeigt dann nur den Streifen, in natuerlicher Groesse und ohne
eigenen Rahmen: die Uebersicht setzt sie als iframe ein und skaliert
selbst. Alles Bedienbare faellt weg, auch die Hilfslinien. */
html[data-nur="streifen"] { overflow: hidden; }
html[data-nur="streifen"] .menubar,
html[data-nur="streifen"] .panel,
html[data-nur="streifen"] .hud,
html[data-nur="streifen"] .guides { display: none !important; }
/* Der Auswahlrahmen ist eine Bedienhilfe und hat in der Uebersicht nichts
verloren -- dort ist keine Slide "die aktuelle". */
html[data-nur="streifen"] .holder.sel::after { display: none; }
html[data-nur="streifen"] .stage { background: none; height: auto; }
html[data-nur="streifen"] .scroller { position: static; overflow: visible; }
html[data-nur="streifen"] .canvas { padding: 0; display: block;
width: max-content; min-width: 0; height: max-content; min-height: 0; }
html[data-nur="streifen"] body, html[data-nur="streifen"] .app {
height: auto; min-height: 0; background: none; overflow: visible;
}
</style>
</head>
<body>
<div class="menubar" id="menubar">
<span class="marke">PostGrid</span>
<div class="menu" data-menu="projekt">
<button type="button">Projekt</button>
<div class="liste">
<button type="button" data-gehe="./post1.html">Post 1</button>
<button type="button" data-gehe="./post2.html">Post 2</button>
<button type="button" data-gehe="./post2-alt.html">Post 2 · Alternative</button>
<hr>
<button type="button" data-gehe="./uebersicht.html">Alle Projekte im Streifen</button>
<hr>
<button type="button" data-klick="snap-save">Stand merken</button>
<button type="button" data-klick="snap-load">Zurück zum gemerkten Stand</button>
<hr>
<button type="button" data-klick="forget">Projekt zurücksetzen</button>
</div>
</div>
<div class="menu" data-menu="datei">
<button type="button">Datei</button>
<div class="liste">
<button type="button" data-klick="bild-waehlen">Bild importieren</button>
<hr>
<button type="button" data-klick="dl-png">Diese Slide laden<span class="kurz">D</span></button>
<button type="button" data-klick="dl-all">Alle Slides als ZIP<span class="kurz">A</span></button>
</div>
</div>
<div class="menu" data-menu="bearbeiten">
<button type="button">Bearbeiten</button>
<div class="liste">
<button type="button" data-tun="undo">Rückgängig<span class="kurz">⌘Z</span></button>
<button type="button" data-tun="redo">Wiederherstellen<span class="kurz">⇧⌘Z</span></button>
<hr>
<button type="button" data-klick="reset-one">Element auf Standard</button>
<button type="button" data-klick="reset-all">Alles auf Standard</button>
</div>
</div>
<div class="menu" data-menu="slide">
<button type="button">Slide</button>
<div class="liste">
<button type="button" data-klick="slide-neu">Neu dahinter</button>
<button type="button" data-klick="slide-kopie">Kopieren</button>
<button type="button" data-klick="slide-weg">Löschen</button>
<hr>
<button type="button" data-klick="slide-links">Nach vorn</button>
<button type="button" data-klick="slide-rechts">Nach hinten</button>
</div>
</div>
<div class="menu" data-menu="ansicht">
<button type="button">Ansicht</button>
<div class="liste">
<button type="button" data-tun="vorschau">Vorschau<span class="kurz">P</span></button>
<hr>
<button type="button" data-opt="views:w:streifen">Streifen<span class="kurz">W</span></button>
<button type="button" data-opt="views:w:einzeln">Einzeln</button>
<hr>
<button type="button" data-klick="zoom-fit">Einpassen<span class="kurz">0</span></button>
<button type="button" data-klick="zoom-in">Größer<span class="kurz">+</span></button>
<button type="button" data-klick="zoom-out">Kleiner<span class="kurz">−</span></button>
</div>
</div>
</div>
<div class="arbeitsflaeche">
<aside class="panel">
<h1>Post 2 · Alternative</h1>
<p class="sub"><span id="kopfzeile">Carousel · 1080 × 1350 px</span><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 class="opts" id="slide-ops" style="margin-top:8px">
<button id="slide-neu" title="Neue Slide dahinter einfügen">+ Neu</button>
<button id="slide-kopie" title="Diese Slide kopieren">Kopie</button>
<button id="slide-weg" title="Diese Slide löschen">Löschen</button>
<button id="slide-links" title="Eine Position nach vorn">←</button>
<button id="slide-rechts" title="Eine Position nach hinten">→</button>
</div>
</div>
<details class="group" data-grp="inhalt-nur-diese-slide">
<summary>Inhalt (nur diese Slide)</summary>
<div id="inhalt"></div>
</details>
<details class="group" data-grp="eigene-bilder">
<summary>Eigene Bilder</summary>
<input type="file" id="bild-datei" accept="image/png,image/jpeg,image/webp,image/gif,image/avif" multiple hidden>
<button class="btn secondary" id="bild-waehlen" style="width:100%">Bilder importieren …</button>
<div class="hint" id="bild-hinweis">Das Bild landet mittig auf der gewählten Slide.
Auf eine andere Slide ziehen legt es dort ab. Mit <b>E</b> lässt es sich schieben
und über <b>A−</b>/<b>A+</b> in der Größe ändern.<br>
Die Datei landet unverändert unter <b>assets/eigene/</b> und gehört mit in den Commit.</div>
</details>
<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>
<!-- Nicht mehr in der Seitenleiste: Layout und Perspektive sind
post-spezifisch, Vorschau steht oben unter Ansicht, der Tag ist
ausgeblendet. Die Schalter bleiben trotzdem im Dokument, weil die
Menueleiste, die Tastenkuerzel und die Abgleich-Funktionen sie
betaetigen. Sonst gaebe es zwei Wahrheiten ueber denselben Zustand. -->
<div hidden>
<div class="opts" id="layouts"></div>
<div class="opts" id="tagouts">
<button data-o="an" aria-pressed="true">An</button>
<button data-o="aus" aria-pressed="false">Aus</button>
</div>
<div class="opts" id="vorschau">
<button data-p="aus" aria-pressed="true">Aus</button>
<button data-p="an" aria-pressed="false">An</button>
</div>
<div class="opts" id="ints">
<button data-i="dezent" aria-pressed="true">Dezent</button>
<button data-i="kraeftig" aria-pressed="false">Kräftig</button>
</div>
<div class="opts" id="tilts">
<button data-t="an" aria-pressed="true">Gekippt</button>
<button data-t="aus" aria-pressed="false">Gerade</button>
</div>
<button id="reset-all">Alles auf Standard</button>
</div>
<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="versuch">
<summary>Versuch</summary>
<div class="opts" id="rasters">
<button data-r="aus" aria-pressed="true">Raster&nbsp;aus</button>
<button data-r="an" aria-pressed="false">Raster&nbsp;an</button>
</div>
<div class="opts" id="mitten">
<button data-m="aus" aria-pressed="true">Text&nbsp;links</button>
<button data-m="an" aria-pressed="false">Text&nbsp;mittig</button>
</div>
</details>
<details class="group" data-grp="band">
<summary>Band</summary>
<div class="opts" id="bands">
<button data-b="orange" aria-pressed="true">Orange</button>
<button data-b="teal" aria-pressed="false">HiveGrid&nbsp;Teal</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="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>
<!-- Der Export steht oben unter Datei. Die Knoepfe bleiben trotzdem im
Dokument: die Menueleiste betaetigt sie, und sie tragen den Zustand
"noch nicht bereit", solange die Schrift nicht geladen ist. Ohne sie
muesste beides doppelt gefuehrt werden. -->
<div hidden>
<button id="dl-png" disabled>Diese Slide</button>
<button id="dl-all" disabled>Alle 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>I</kbd> Intensität<br>
<kbd>W</kbd> Ansicht<br>
<kbd>R</kbd> Bildrahmen<br>
<kbd>T</kbd> Perspektive<br>
<kbd>P</kbd> Vorschau · 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>⌘/Strg</kbd>+<kbd>Z</kbd> Rückgängig (<kbd>⇧</kbd> = wiederherstellen)<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>
<aside class="panel panel-rechts" id="element-panel">
<div class="el-kopf">
<h1 id="el-titel">Element</h1>
<button class="el-zu" id="el-zu" title="Auswahl aufheben">✕</button>
</div>
<div id="el-inhalt"></div>
</aside>
</div>
<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. */
/* ─── Die Geschichte ──────────────────────────────────────────────────────
Post 2 erzaehlt die Herkunft, nicht die Funktionen. Statt Screenshots
traegt jede Slide eine gebaute Visualisierung: der Text nennt ein
Stichwort, das Bild zeigt es. Alle sind aus derselben Formensprache wie
die Slides selbst gebaut, also chamferte Kaesten, Sechsecke und der eine
gelbe Akzent. Kein Bildmaterial ausser Noahs Karte. */
/* ─── Die Geschichte ──────────────────────────────────────────────────────
Post 2 erzaehlt die Herkunft, nicht die Funktionen. Statt Screenshots
traegt jede Slide eine gebaute Visualisierung: der Text nennt ein
Stichwort, das Bild zeigt es.
Die Texte sind Zuspitzungen von Noahs Text, nicht dessen Absaetze: auf
einer Slide muss eine Zeile im Vorbeiscrollen sitzen. Formulierungen und
Stimme kommen aber von dort, nicht aus dem Nichts.
Der Beitrag ist Noahs Konzept, sagt das aber nirgends geradeheraus. Er
baut es auf: Slide 2 stellt im Fliesstext fest, dass hinter den
gewachsenen Ablaeufen keines steckt, Slide 5 liefert dann eines. Das Wort
faellt genau einmal und wird danach nicht wiederholt.
Slide 2 muss weich an Slide 1 anschliessen. Dort stellt sich Noah vor,
hier folgte frueher sofort die Diagnose -- ein harter Schnitt.
Die Ueberleitung steckt in Noahs Satzbau selbst: er raeumt die Erfahrung
erst ein und dreht dann ("hat nicht nur Erfahrung. Er hat auch Prozesse,
die seit Jahren so laufen"). Wer zuerst zustimmt, darf danach widersprechen.
Der Fliesstext behaelt seine Konstruktion mit dem nachgestellten "Nicht,
weil ..." -- das ist bei ihm ein Stilmittel, keine abgehackte Halbzeile.
Das Wort Zettelwirtschaft ist aus der Ueberschrift raus. Die Zeile sagt
"nicht nur Erfahrung", und was das andere ist, zeigt die Zeichnung
daneben. Es zweimal zu sagen waere einmal zu viel.
Wichtiger als die Kuerze ist die Satzform. In einer frueheren Fassung
hatten alle fuenf Ueberschriften dieselbe: zwei abgehackte Fragmente mit
Punkt dazwischen, fuenfmal hintereinander. Das liest sich maschinell,
egal wie gut der einzelne Satz ist. Jetzt wechseln sich Aussage mit
Wendung, Frage und Antwort, Paar und durchlaufender Satz ab.
Faustregel aus den gemessenen Breiten: bei 74 px und -0,03em passen rund
26 Zeichen in eine Zeile, mit Marker etwas weniger. Keine Zeile hier ist
laenger als 22. */
const VORLAGEN = [
{
key: "titel", type: "feature",
layouts: [{ id: "standard", label: "Standard" }],
eyebrow: "Die Geschichte",
head: 'Die Geschichte fängt<br><span class="a">auf der Baustelle an.</span>',
body: "Noah Geerkens, SHK-Betrieb in vierter Generation.",
vis: "karte", visW: 896, visLeft: 92, visBottom: 96,
},
{
key: "prozesse", type: "feature",
layouts: [{ id: "standard", label: "Standard" }],
eyebrow: "Der Alltag",
head: 'Wer lange dabei ist,<br><span class="a">hat nicht nur Erfahrung.</span>',
body: "Auch Abläufe, die laufen, weil die Software es vorgibt. Nicht, weil ein Konzept dahintersteckt.",
vis: "schiene", visW: 896, visLeft: 92, visBottom: 120,
},
{
key: "wechseln", type: "feature",
layouts: [{ id: "standard", label: "Standard" }],
eyebrow: "Der Wechsel",
head: 'Und wechseln?<br><span class="a">Kannst du vergessen.</span>',
body: "Da hängen Jahre an Stammdaten, Aufträgen und Buchhaltung drin. Ein Wechsel führt ins Chaos.",
vis: "ersetzen", visW: 896, visLeft: 92, visBottom: 130,
},
{
key: "mehr", type: "feature",
layouts: [{ id: "standard", label: "Standard" }],
eyebrow: "Was gefehlt hat",
head: 'Wir wollten nicht weg.<br><span class="a">Wir wollten mehr.</span>',
body: "KI-Lösungen, Projektmanagement, Automatisierungen. Unsere Tools sollen endlich miteinander reden.",
vis: "lose", visW: 988, visLeft: 92, visBottom: 120,
},
{
key: "verbinden", type: "feature",
layouts: [{ id: "standard", label: "Standard" }],
eyebrow: "Die Lösung",
head: 'Dein Branchenprogramm<br><span class="a">bleibt, wo es ist.</span>',
body: "Kein Ersatz, sondern der Aufbau drumherum. HiveGrid holt raus, was bisher gefehlt hat.",
vis: "verbinden", visW: 988, visLeft: 0, visBottom: 120,
},
{
key: "warteliste", type: "feature",
layouts: [{ id: "standard", label: "Standard" }],
eyebrow: "So geht's weiter",
/* Die Marken-Tagline. Sie wird genauso ausgezeichnet wie in Post 1 und
im Titel-Layout: der Marker sitzt auf dem zweiten "Handwerker", nicht
auf der ganzen Zeile. Eine zweite Schreibweise derselben Zeile waere
eine Abweichung, die niemandem auffaellt und trotzdem stoert. */
head: 'Vom Handwerker<br>für <span class="a">Handwerker</span>',
body: "Kein Startup-Projekt. Setz dich auf die Warteliste, dann melden wir uns.",
vis: "warteliste", visW: 896, visLeft: 92, visBottom: 240,
},
];
/* Vorlage für eine selbst angelegte Slide. Sie startet bewusst mit Text
drin: eine leere Slide sieht aus wie ein Fehler, und man sieht nicht,
welches Feld welche Zeile füllt. */
const kopie = x => JSON.parse(JSON.stringify(x));
const NEUE_SLIDE = {
type: "feature",
layouts: [{ id: "standard", label: "Standard" }],
eyebrow: "Neuer Tag",
head: 'Überschrift mit <span class="a">Akzent</span>',
body: "Beschreibung dieser Slide. Zwei bis drei Zeilen sitzen am besten.",
img: "./assets/screens/pipelines.png",
mockW: 1090, mockAR: 1.6,
imgScale: 1, imgX: "0%", imgY: "0%",
};
/* Alle Screens im Ordner. Eine selbst angelegte Slide kann jeden davon
wählen, deshalb werden sie vollständig geladen und nicht nur die, die
eine Vorlage zufällig gerade benutzt. */
/* Ausgangsliste. Der Server liefert beim Start die tatsaechlich vorhandenen
Dateien nach, damit importierte Bilder sofort auftauchen und die Liste
nicht im Code gepflegt werden muss. */
let BILDER = [
{ pfad: "./assets/screens/pipelines.png", name: "Pipelines · Board" },
{ pfad: "./assets/screens/pipelines-liste.png", name: "Pipelines · Liste" },
{ pfad: "./assets/screens/pipelines-detail.png", name: "Pipelines · Detail" },
{ pfad: "./assets/screens/workflow.png", name: "Workflow · Ablauf" },
{ pfad: "./assets/screens/workflow-bausteine.png", name: "Workflow · Bausteine" },
{ pfad: "./assets/screens/chat.png", name: "Chat" },
{ pfad: "./assets/screens/chat-tablet.png", name: "Chat · Tablet" },
{ pfad: "./assets/screens/chat-menue.png", name: "Chat · Menü" },
{ pfad: "./assets/screens/form-builder.png", name: "Form Builder" },
];
/* ─── Visualisierungen ───────────────────────────────────────────────────
Jede Slide nennt ueber "vis" ihr Bild. Gebaut wird es hier aus HTML, nicht
aus einer Aufnahme: so bleibt es beim Export scharf und laesst sich wie
jedes andere Element im Canvas verschieben. */
/* ─── Skizzen-Primitive ─────────────────────────────────────────────────────
Handzeichnung aus gesetztem Zufall. Der Keim ist fest: der Export rendert
bei fehlgeschlagener Pruefung bis zu fuenfmal neu, und jeder Durchgang muss
denselben Strich ergeben. Mit ungesetztem Math.random zittert jedes Bild
anders und die Kontrolle vergleicht gegen etwas, das es nie wieder gibt. */
let _s = 0;
function keim(n) { _s = n >>> 0; }
function rnd() {
_s = (_s + 0x6D2B79F5) >>> 0;
let t = Math.imul(_s ^ (_s >>> 15), 1 | _s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
const zit = () => 0; // keine Handschrift in dieser Fassung
const n1 = v => Math.round(v * 10) / 10;
/* Ein Strich, leicht gebogen und an den Enden verwackelt. Der Bogen waechst
mit der Laenge: eine kurze Kante zittert, eine lange schwingt. */
/* Gerade Linie. In der Skizzenfassung schwingt sie und schiesst an den
Enden ueber; hier nicht. Die Aufrufe bleiben identisch, damit beide
Fassungen dieselben Kompositionen benutzen koennen. */
function strich(x1, y1, x2, y2) {
return "M" + n1(x1) + "," + n1(y1) + "L" + n1(x2) + "," + n1(y2);
}
/* Punktfolge zu einer weichen Kurve (Catmull-Rom als Bezier). */
function glatt(pts, zu) {
const n = pts.length;
const at = i => pts[zu ? (i + n) % n : Math.max(0, Math.min(n - 1, i))];
let d = "M" + n1(pts[0][0]) + "," + n1(pts[0][1]);
const bis = zu ? n : n - 1;
for (let i = 0; i < bis; i++) {
const p0 = at(i - 1), p1 = at(i), p2 = at(i + 1), p3 = at(i + 2);
d += "C" + n1(p1[0] + (p2[0] - p0[0]) / 6) + "," + n1(p1[1] + (p2[1] - p0[1]) / 6) +
" " + n1(p2[0] - (p3[0] - p1[0]) / 6) + "," + n1(p2[1] - (p3[1] - p1[1]) / 6) +
" " + n1(p2[0]) + "," + n1(p2[1]);
}
return d;
}
/* Exakte Ellipse. Kein offener Zug, kein Ueberschuss. */
function kringel(cx, cy, rx, ry) {
ry = ry == null ? rx : ry;
return "M" + n1(cx - rx) + "," + n1(cy) +
"a" + n1(rx) + "," + n1(ry) + " 0 1 0 " + n1(rx * 2) + ",0" +
"a" + n1(rx) + "," + n1(ry) + " 0 1 0 " + n1(-rx * 2) + ",0Z";
}
/* Exaktes Rechteck. */
function rechteck(x, y, w, h) {
return "M" + n1(x) + "," + n1(y) + "h" + n1(w) + "v" + n1(h) + "h" + n1(-w) + "Z";
}
/* Der Chamfer, exakt. Die Markenform aus rules.md Paragraf 3. */
function chamfer(x, y, w, h, c) {
return "M" + n1(x + c) + "," + n1(y) +
"H" + n1(x + w - c) + "L" + n1(x + w) + "," + n1(y + c) +
"V" + n1(y + h - c) + "L" + n1(x + w - c) + "," + n1(y + h) +
"H" + n1(x + c) + "L" + n1(x) + "," + n1(y + h - c) +
"V" + n1(y + c) + "Z";
}
/* Schraffur als Schattierung: parallele Striche, an den Enden kuerzer, damit
das Feld nicht als Block steht. */
function schraff(x, y, w, h, abstand, neig) { return ; } // ohne Textur in dieser Fassung
/* Bodenschatten: ein paar kurze Striche unter dem Gegenstand. Sie sitzen eng
am Fuss, sonst schwebt der Gegenstand ueber seinem eigenen Schatten. */
function boden(cx, y, w) { return ; } // ohne Textur in dieser Fassung
/* ─── Bausteine ────────────────────────────────────────────────────────── */
/* Die Zeichenfarben folgen der Variante. Auf Dunkel traegt Tinte nicht, und
Gelb darf dort der volle Markenton sein statt des abgedunkelten. */
/* Drei Marken-Farben, dazu Tinte und eine Haarlinie. Teal war in rules.md
bisher als "ungenutzt" vermerkt und traegt hier den zweiten Akzent. */
/* Alle Werte aus app/globals.css der Website. GELB_DUNKEL ist die Textfarbe
auf gelbem Grund, GELB_HELL die aufgehellte Flaeche fuer Badges. */
let TINTE = "#1A1A1A", TEXT2 = "#555555", TEXT3 = "#888888",
GELB = "#F1B729", GELB_DUNKEL = "#3E2C0A", GELB_HELL = "#FFE082",
TEAL = "#0D6E6E", TEAL_HELL = "#E8F4F4",
ROT = "#E07B54", ROT_HELL = "#FBEAE4", // accent-orange der Website
LINIE = "#E5E5E5", KANTE = "#D5DBE1", KARTE = "#FFFFFF",
GRUND = "#F6F8FA"; // --container-bg, der Boden unter einem Zeichen
function farbenNachziehen() {
const d = variant === "dunkel";
TINTE = d ? "#FFFFFF" : "#1A1A1A";
TEXT2 = d ? "#C8D0DC" : "#555555";
TEXT3 = d ? "#8C97A8" : "#888888";
TEAL = d ? "#4FBEBE" : "#0D6E6E";
TEAL_HELL = d ? "rgba(79,190,190,.16)" : "#E8F4F4";
ROT = "#E07B54";
ROT_HELL = d ? "rgba(224,123,84,.18)" : "#FBEAE4";
LINIE = d ? "rgba(255,255,255,.14)" : "#E5E5E5";
KANTE = d ? "rgba(255,255,255,.22)" : "#D5DBE1";
KARTE = d ? "#1A2740" : "#FFFFFF";
GRUND = d ? "#222F49" : "#F6F8FA";
}
/* Der Chamfer der Marke: 16 px Schnitt, wie in lib/chamfer.ts der Website.
Dort steht auch, warum eine chamferte Flaeche keinen Rand bekommt --
clip-path beschneidet ihn mit. Also fuellen statt umranden. */
const CHAMFER_C = 16;
/* Weicher Schatten. Bis hierhin waren die Posts schattenfrei (rules.md 5),
fuer das Theme modern ist das aufgehoben: ohne ihn stehen die Kaesten
gestochen auf dem Grund, statt darueber zu schweben.
Als SVG-Filter und nicht als box-shadow -- die Kaesten sind gezeichnet
und nicht im DOM, und ein CSS-Schatten wuerde vom clip-path ohnehin
mit beschnitten (lib/chamfer.ts der Website). Die doppelte id in
mehreren Blaettern stoert nicht: alle Filter sind gleich, und url(#..)
nimmt den ersten. */
const SCHATTEN_DEFS =
'<defs><filter id="hgschatten" x="-35%" y="-35%" width="170%" height="170%">' +
'<feDropShadow dx="0" dy="7" stdDeviation="10" flood-color="#101828" ' +
'flood-opacity="0.14"/></filter></defs>';
// Eine Karte: Flaeche, Haarlinie, Schatten. Das ist die Grundform des Themes.
const karteFlaeche = (x, y, w, h, c, fuell) =>
'<g filter="url(#hgschatten)"><path d="' +
chamfer(x, y, w, h, c == null ? CHAMFER_C : c) + '" fill="' + (fuell || KARTE) +
'" stroke="' + KANTE + '" stroke-width="1.2"/></g>';
/* Die G-Marke, Punkte woertlich aus assets/brand/g-mark.svg. Als Pfad und
nicht als Bild: ein PNG im foreignObject kann beim Export verschwinden
(rules.md §7), ein Pfad nie. Der Massstab braucht mehr als eine
Nachkommastelle, sonst schrumpft die Marke sichtbar. */
const G_PUNKTE = "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";
function gMarke(cx, cy, hoehe, farbe) {
const s = hoehe / 332.9;
return '<g transform="translate(' + n1(cx - 384.5 * s / 2) + ',' + n1(cy - hoehe / 2) +
') scale(' + s.toFixed(5) + ')"><polygon points="' + G_PUNKTE + '" fill="' +
farbe + '"/></g>';
}
const chamferFlaeche = (x, y, w, h, fuell, c, rand) =>
'<path d="' + chamfer(x, y, w, h, c == null ? CHAMFER_C : c) + '" fill="' + fuell + '"' +
(rand ? ' stroke="' + KANTE + '" stroke-width="1.5"' : "") + '/>';
const zug = (d, o) => {
o = o || {};
return '<path d="' + d + '" fill="none" stroke="' + (o.f || TINTE) +
'" stroke-width="' + (o.w || 3.1) + '" stroke-linecap="round" stroke-linejoin="round"' +
(o.op ? ' stroke-opacity="' + o.op + '"' : '') +
(o.strichel ? ' stroke-dasharray="' + o.strichel + '"' : '') + '/>';
};
/* Gefuellte Flaeche aus Eckpunkten. In der Skizzenfassung wird ausgemalt,
hier gefuellt. */
const flaeche = (pts, farbe, deck) =>
'<path d="M' + pts.map(q => n1(q[0]) + "," + n1(q[1])).join("L") + 'Z" fill="' +
farbe + '"' + (deck ? ' fill-opacity="' + deck + '"' : "") + '/>';
/* Bodenschatten entfallen: rules.md Paragraf 5 sagt schattenfrei, und in der
Skizzenfassung waren sie nur als Strichlage vertretbar. */
const schatten = () => "";
/* ─── Die Satelliten ─────────────────────────────────────────────────────
Jedes Ikon meldet zurueck, wie weit es reicht (rad) und wo seine
Beschriftung hingehoert (unten). Sonst haengt der Knoten am kleinen Ikon
zu weit weg und am grossen klebt er drin. */
function ikonKi(cx, cy) {
const zack = (x, y, r) => {
const k = r * 0.28, p = [];
for (let i = 0; i < 8; i++) {
const a = -Math.PI / 2 + i * Math.PI / 4, rr = i % 2 ? k : r;
p.push([x + Math.cos(a) * rr + zit(1.4), y + Math.sin(a) * rr + zit(1.4)]);
}
return glatt(p, true);
};
return zug(zack(cx - 8, cy - 6, 46), { w: 3.4 }) +
zug(zack(cx + 38, cy + 30, 17), { w: 2.7 }) +
zug(zack(cx + 34, cy - 34, 12), { w: 2.5 }) +
schatten(boden(cx, cy + 54, 88));
}
ikonKi.rad = 52; ikonKi.unten = 92;
function ikonChat(cx, cy) {
const blase = (x, y, rx, ry, sx) =>
zug(kringel(x, y, rx, ry, { ueber: .12 })) +
zug(glatt([[x + sx * rx * .42, y + ry * .82], [x + sx * rx * .86, y + ry * 1.6],
[x + sx * rx * .08, y + ry * .98]], false));
return blase(cx + 20, cy - 4, 38, 30, 1) +
blase(cx - 22, cy - 16, 47, 37, -1) +
[-18, 0, 18].map(o => zug(kringel(cx - 22 + o, cy - 16, 4.6), { w: 2.5 })).join("") +
schatten(boden(cx, cy + 52, 100));
}
ikonChat.rad = 58; ikonChat.unten = 90;
function ikonCrm(cx, cy) {
const kopf = (x, y, r, w) => zug(kringel(x, y, r), { w: w }) +
zug(glatt([[x - r * 1.55, y + r * 2.7], [x - r * 1.5, y + r * 1.05],
[x, y + r * .55], [x + r * 1.5, y + r * 1.05], [x + r * 1.55, y + r * 2.7]], false), { w: w });
return kopf(cx - 42, cy - 16, 17, 2.8) + kopf(cx + 42, cy - 16, 17, 2.8) +
kopf(cx, cy + 2, 22, 3.3) +
schatten(boden(cx, cy + 62, 116));
}
ikonCrm.rad = 72; ikonCrm.unten = 92;
function ikonAuto(cx, cy) {
const r = 39, zaehne = 10;
let d = "";
for (let i = 0; i < zaehne; i++) {
const a = i * 6.283 / zaehne, br = 0.22;
const p = [];
[[-br, r], [-br * .8, r + 14], [br * .8, r + 14], [br, r]].forEach(v => {
const aa = a + v[0];
p.push([cx + Math.cos(aa) * v[1] + zit(.9), cy + Math.sin(aa) * v[1] + zit(.9)]);
});
d += glatt(p, false);
}
return zug(kringel(cx, cy, r)) + zug(d, { w: 2.9 }) + zug(kringel(cx, cy, 15), { w: 2.7 }) +
schatten(boden(cx, cy + 60, 96));
}
ikonAuto.rad = 56; ikonAuto.unten = 88;
function ikonBoard(cx, cy) {
const w = 116, h = 88, x = cx - w / 2, y = cy - h / 2;
let d = rechteck(x, y, w, h);
let k = "";
for (let i = 1; i < 3; i++) k += strich(x + w * i / 3, y + 7, x + w * i / 3, y + h - 7, { j: 1, b: 1 });
const karte = (sx, sy, sh) => rechteck(x + sx, y + sy, w / 3 - 16, sh, { ue: 2 });
k += karte(8, 11, 19) + karte(8, 36, 19) + karte(w / 3 + 8, 11, 19) + karte(2 * w / 3 + 8, 11, 31);
return zug(d) + zug(k, { w: 2.4 }) + schatten(boden(cx, cy + h / 2 + 7, 116));
}
ikonBoard.rad = 64; ikonBoard.unten = 84;
function ikonFormular(cx, cy) {
const w = 90, h = 116, x = cx - w / 2, y = cy - h / 2;
let d = rechteck(x, y, w, h);
d += rechteck(x + w / 2 - 19, y - 12, 38, 22, { ue: 2 });
let k = "";
[32, 50, 68].forEach(o => { k += strich(x + 15, y + o, x + w - 34, y + o, { j: 1, b: 1 }); });
k += rechteck(x + 15, y + 80, 23, 23, { ue: 2 });
const hak = [[x + 19, y + 91], [x + 25, y + 98], [x + 35, y + 81]];
return zug(d) + zug(k, { w: 2.4 }) + zug(glatt(hak, false), { w: 3.6, f: GELB }) +
schatten(boden(cx, cy + h / 2 + 7, 92));
}
ikonFormular.rad = 62; ikonFormular.unten = 96;
/* Ein Linienzug aus Einzelstrichen. Jede Kante bekommt ihren eigenen Zug
mit Ueberschuss an den Enden, dadurch bleiben die Ecken kantig. Eine
durchgehende weiche Kurve wuerde eine Pfeilspitze zum Tropfen runden. */
function linienzug(pts, ue) {
let d = "";
for (let i = 0; i < pts.length - 1; i++) {
d += strich(pts[i][0], pts[i][1], pts[i + 1][0], pts[i + 1][1],
{ ue: ue == null ? 3 : ue, j: 1.1 });
}
return d;
}
/* Kritzelfuellung: schnelle Schraegstriche quer durch die Form, jeder von
Kante zu Kante. halb(x) gibt die halbe Hoehe an dieser Stelle, dadurch
laeuft die Fuellung in einer Spitze von selbst aus.
Der erste Versuch war ein durchgehendes Zickzack die Laenge entlang. In
der Spitze schlug es ueber die Kanten hinaus und ergab ein Knaeuel statt
einer Flaeche. Einzelne Striche bleiben innerhalb der Form, und wo einer
ausgelassen wird, bleibt Weiss stehen -- genau so malt man schnell aus. */
function kritzel(x1, x2, my, halb, abstand) { return ; } // ohne Textur in dieser Fassung
/* Ein Strich mit Spitze am Ende. Die Spitze sind zwei kurze Zuege, keine
gefuellte Flaeche: gezeichnet wird sie so, gefuellt sieht sie gesetzt aus. */
function pfeil(x1, y1, x2, y2, gr) {
gr = gr == null ? 16 : gr;
const a = Math.atan2(y2 - y1, x2 - x1), oeff = 0.42;
return strich(x1, y1, x2, y2, { j: 1.1 }) +
strich(x2, y2, x2 - Math.cos(a - oeff) * gr, y2 - Math.sin(a - oeff) * gr, { j: .8, b: .8 }) +
strich(x2, y2, x2 - Math.cos(a + oeff) * gr, y2 - Math.sin(a + oeff) * gr, { j: .8, b: .8 });
}
/* Schraffur innerhalb einer Wabe. Eine gezeichnete Flaeche wird nicht
gefuellt, sie wird schraffiert: so bleibt der Raster-Grund sichtbar und
es sieht trotzdem nach "voll" aus wie auf der Website. */
function wabeSchraff(cx, cy, r, abstand) { return ; } // ohne Textur in dieser Fassung
function ikonBrief(cx, cy, f) {
const w = 46, h = 31, x = cx - w / 2, y = cy - h / 2;
return zug(rechteck(x, y, w, h, { ue: 2.5 }), { w: 2.9, f: f }) +
zug(glatt([[x + 3, y + 4], [cx, y + h * 0.66], [x + w - 3, y + 4]], false),
{ w: 2.7, f: f });
}
function ikonBlaseKlein(cx, cy, f) {
return zug(kringel(cx, cy - 5, 25, 19, { ueber: .1 }), { w: 2.9, f: f }) +
zug(glatt([[cx - 8, cy + 12], [cx - 13, cy + 23], [cx + 3, cy + 13]], false),
{ w: 2.7, f: f }) +
[-9, 0, 9].map(o => zug(kringel(cx + o, cy - 5, 2.8), { w: 2.2, f: f })).join("");
}
function ikonKonfetti(cx, cy, f) {
// Knallbonbon: Tuete unten links, Funken nach oben rechts.
const tuete = glatt([[cx - 28, cy + 24], [cx - 6, cy - 2],
[cx + 10, cy + 19], [cx - 28, cy + 24]], false) +
strich(cx - 20, cy + 15, cx - 2, cy + 19, { j: .9, b: .8 });
let funken = "";
[-1.42, -1.06, -0.68, -0.28].forEach((a, i) => {
const r0 = 16, r1 = 30 + (i % 2) * 7;
funken += strich(cx + 2 + Math.cos(a) * r0, cy + 3 + Math.sin(a) * r0,
cx + 2 + Math.cos(a) * r1, cy + 3 + Math.sin(a) * r1, { j: 1 });
});
let punkte = "";
[[20, -27], [31, -11], [26, -35], [8, -33]].forEach(v => {
punkte += kringel(cx + v[0], cy + v[1], 3.1);
});
return zug(tuete, { w: 2.9, f: f }) + zug(funken, { w: 2.4, f: f }) +
zug(punkte, { w: 2.2, f: f });
}
/* Die Wabe, flat-top, Seitenverhaeltnis 2/Wurzel3 wie in rules.md Paragraf 3.
Bei dieser Form liegen die sechs Ecken auf einem Kreis vom Radius r. Genau
deshalb ist sie hier richtig: jede Speiche setzt an einer Ecke an, nicht
irgendwo auf einer Kante. Gezogen wird sie aus sechs einzelnen Strichen
mit Ueberschuss, nicht als geschlossenes Polygon. */
function wabeEcken(cx, cy, r) {
const p = [];
for (let i = 0; i < 6; i++) {
const a = i * Math.PI / 3;
p.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]);
}
return p;
}
function wabe(cx, cy, r) {
const p = wabeEcken(cx, cy, r);
let d = "";
for (let i = 0; i < 6; i++) {
const q = p[i], w = p[(i + 1) % 6];
d += strich(q[0], q[1], w[0], w[1], { ue: 5, j: 1.8 });
}
return d;
}
/* ─── Die Mitte: das bestehende System ───────────────────────────────────
Bewusst als Liste gezeichnet, nicht als Kachel-Dashboard: so sieht eine
Warenwirtschaft aus. Und bewusst der schwerste Strich auf der Slide, denn
sie ist das, was bleibt. Das Fenster passt ganz in den Ring, sonst kreuzen
sich Ringbogen und Fensterecke, und weil der Grund durchscheinen soll gibt
es keine Fuellung, die das verdecken koennte. */
function nabe(cx, cy) {
const w = 216, h = 150, x = cx - w / 2, y = cy - h / 2, kopf = 40;
let d = rechteck(x, y, w, h, { ue: 4 });
d += strich(x, y + kopf, x + w, y + kopf, { ue: 3 });
const k = ""; // keine Fensterpunkte in dieser Fassung
let z = "";
for (let i = 0; i < 4; i++) {
const yy = y + kopf + 22 + i * 23;
z += strich(x + 17, yy, x + 59, yy, { j: 1, b: 1 });
z += strich(x + 72, yy, x + w - 48, yy, { j: 1, b: 1 });
z += strich(x + w - 38, yy, x + w - 17, yy, { j: 1, b: 1 });
}
return zug(d, { w: 3.6 }) + zug(k, { w: 2.4 }) + zug(z, { w: 2.2, op: .8 }) +
'<text x="' + cx + '" y="' + (y + kopf / 2 + 8) + '" text-anchor="middle" ' +
'font-family="Manrope, sans-serif" font-size="24" font-weight="800" ' +
'letter-spacing="-0.01em" fill="' + TINTE + '">Dein Programm</text>';
}
/* ─── Aufbau ───────────────────────────────────────────────────────────── */
/* ─── Slide 2: nur so gewachsen ──────────────────────────────────────────
Ein Knaeuel aus Kanaelen, durch das sich eine gestrichelte Leitung windet.
Bewusst ohne Aufloesung: das Gegenbild dazu ist Slide 5, und die Pointe
soll nicht zweimal vorkommen.
Die Namen sind allgemein gehalten, keine fremden Produkte. */
function glyBrief(cx, cy) {
const w = 30, h = 21, x = cx - w / 2, y = cy - h / 2;
return rechteck(x, y, w, h, { ue: 1.6 }) +
glatt([[x + 2, y + 3], [cx, y + h * 0.68], [x + w - 2, y + 3]], false);
}
function glyZettel(cx, cy) {
const w = 26, h = 32, x = cx - w / 2, y = cy - h / 2, e = 11;
return glatt([[x, y], [x + w - e, y], [x + w, y + e], [x + w, y + h],
[x, y + h], [x, y]], false) +
glatt([[x + w - e, y], [x + w - e, y + e], [x + w, y + e]], false);
}
function glyTabelle(cx, cy) {
const w = 30, h = 24, x = cx - w / 2, y = cy - h / 2;
let d = rechteck(x, y, w, h, { ue: 1.6 });
d += strich(x, y + h / 3, x + w, y + h / 3, { j: .7, b: .6 });
d += strich(x, y + h * 2 / 3, x + w, y + h * 2 / 3, { j: .7, b: .6 });
d += strich(x + w / 3, y, x + w / 3, y + h, { j: .7, b: .6 });
return d;
}
function glyHandy(cx, cy) {
const w = 20, h = 32, x = cx - w / 2, y = cy - h / 2;
return rechteck(x, y, w, h, { ue: 1.4 }) +
strich(x + 6, y + 4, x + w - 6, y + 4, { j: .6, b: .5 }) +
kringel(cx, y + h - 5, 1.9);
}
function glyOrdner(cx, cy) {
const w = 30, h = 26, x = cx - w / 2, y = cy - h / 2;
return rechteck(x, y + 6, w, h - 6, { ue: 1.4 }) +
glatt([[x + 1, y + 6], [x + 3, y], [x + 13, y], [x + 15, y + 6]], false);
}
/* Die kleinen Kacheln, die im Vorbild lose dazwischen schweben. */
function kachel(cx, cy, art, gr) {
const s = gr || 52, x = cx - s / 2, y = cy - s / 2;
let d = rechteck(x, y, s, s, { ue: 2.5 });
const f = s / 52;
if (art === "blatt") {
d += rechteck(x + 16 * f, y + 13 * f, 20 * f, 26 * f, { ue: 1.4 });
[20, 26, 32].forEach(o => {
d += strich(x + 20 * f, y + o * f, x + 32 * f, y + o * f, { j: .6, b: .5 });
});
} else if (art === "bild") {
d += rechteck(x + 13 * f, y + 15 * f, 26 * f, 22 * f, { ue: 1.4 });
d += glatt([[x + 16 * f, y + 33 * f], [x + 23 * f, y + 23 * f], [x + 29 * f, y + 30 * f],
[x + 32 * f, y + 26 * f], [x + 36 * f, y + 33 * f]], false);
} else {
d += kringel(cx, y + 19 * f, 6 * f);
d += glatt([[x + 14 * f, y + 40 * f], [x + 15 * f, y + 32 * f], [cx, y + 28 * f],
[x + s - 15 * f, y + 32 * f], [x + s - 14 * f, y + 40 * f]], false);
}
return d;
}
/* Eine ausholende Schlaufe zwischen zwei Ankern. Sie bauscht immer von der
Kachelspalte weg, damit die Leitung keine Beschriftung kreuzt: auf einem
durchscheinenden Grund kann kein Kasten sie verdecken. */
function schlaufe(ax, ay, bx, by, weite, seite) {
const dx = bx - ax, dy = by - ay, l = Math.hypot(dx, dy) || 1;
const nx = (dy / l) * seite, ny = (-dx / l) * seite;
return [
[ax, ay],
[ax + nx * weite * 0.62 + dx * 0.06, ay + ny * weite * 0.62 + dy * 0.06],
[(ax + bx) / 2 + nx * weite, (ay + by) / 2 + ny * weite],
[bx + nx * weite * 0.62 - dx * 0.06, by + ny * weite * 0.62 - dy * 0.06],
[bx, by],
];
}
/* Ein Stueck Leitung von Kasten zu Kasten.
Der erste Versuch legte die Stuetzpunkte direkt auf die Kastenkante und
liess die Bahn danach irgendwohin laufen. Das sah aus, als streife die
Linie den Kasten zufaellig, und wo zwei Punkte fast aufeinanderlagen,
warf die Kurve einen Zipfel. Beides zusammen war der Grund, warum die
Anbindung nicht stimmte.
Jetzt bekommt jedes Stueck vor dem Kasten einen Punkt auf gleicher Hoehe,
52 px daneben. Die Kurve laeuft dadurch waagerecht aus dem Kasten heraus
und waagerecht in den naechsten hinein: sie haengt sichtbar an ihm, statt
an ihm vorbeizukommen. */
function leitungsStueck(vonX, vonY, nachX, nachY, seite, abstand, zwischen) {
return [[vonX, vonY], [vonX + seite * abstand, vonY]]
.concat(zwischen)
.concat([[nachX + seite * abstand, nachY], [nachX, nachY]]);
}
function visProzesse() {
keim(20260813);
const W = 896, H = 730, HOCH = 62;
/* Die x-Werte sind so gewaehlt, dass die rechten Kanten dort landen, wo
die Leitung sie erwartet. Wer eine Beschriftung aendert, aendert die
Kastenbreite und muss die Ankerpunkte unten mitziehen. */
const chips = [
{ x: 34, y: 60, t: "E-Mails", i: "mail" },
{ x: 104, y: 200, t: "Zettel im Auto", i: "sticky-note" },
{ x: 48, y: 340, t: "Tabellen", i: "table" },
{ x: 128, y: 480, t: "Fotos auf dem Handy", i: "smartphone" },
{ x: 60, y: 620, t: "Ordner im Regal", i: "folder" },
];
chips.forEach(c => {
c.w = Math.round(84 + c.t.length * 14.2);
c.r = c.x + c.w;
});
let raus = "";
// Rechte Kette: von rechter Kante zu rechter Kante, mit weiten Bogen.
const rechts = [
[[470, 34], [590, 72], [624, 150], [540, 186]],
[[576, 244], [672, 312], [604, 392], [492, 372], [432, 308]],
[[352, 398], [452, 424], [600, 406], [688, 470]],
[[664, 540], [708, 624], [606, 684], [478, 660]],
];
let leitung = "";
rechts.forEach((zw, i) => {
leitung += glatt(leitungsStueck(chips[i].r, chips[i].y, chips[i + 1].r,
chips[i + 1].y, 1, 52, zw)
.map(q => [q[0] + zit(2.5), q[1] + zit(2.5)]), false);
});
// Linke Kette: dort ist wenig Platz, also zwei knappe Bogen.
[[1, 2, [[24, 244], [14, 302]]], [3, 4, [[22, 522], [16, 576]]]].forEach(v => {
leitung += glatt(leitungsStueck(chips[v[0]].x, chips[v[0]].y, chips[v[1]].x,
chips[v[1]].y, -1, 34, v[2])
.map(q => [q[0] + zit(2), q[1] + zit(2)]), false);
});
/* Die Leitung traegt den Akzent. Gestrichelt und gelb heisst hier
gewachsen, durchgezogen und gelb auf Slide 5 heisst verbunden: dieselbe
Farbe, aber die Strichart trennt die beiden Aussagen. Etwas kraeftiger
als in Grau, weil eine unterbrochene Linie weniger Flaeche hat. */
raus += zug(leitung, { f: GELB, w: 2.9, op: .85, strichel: "11 13" });
chips.forEach(c => {
const y = c.y - HOCH / 2;
raus += karteFlaeche(c.x, y, c.w, HOCH, 13);
raus += feather(c.i, c.x + 34, c.y, 30, TINTE, 2);
raus += '<text x="' + (c.x + 64) + '" y="' + (c.y + 10) + '" ' +
'font-family="Manrope, sans-serif" font-size="27" font-weight="600" ' +
'letter-spacing="-0.01em" fill="' + TINTE + '">' + c.t + '</text>';
});
return '<svg viewBox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H +
'" xmlns="http://www.w3.org/2000/svg">' + SCHATTEN_DEFS + raus + '</svg>';
}
/* ─── Slide 1: Foto im gezeichneten Rahmen ───────────────────────────────
Das Foto bleibt roh, gezeichnet wird nur drumherum. Der Helm sitzt wie
die Krone beim Zeichenspiel auf der oberen rechten Ecke, angeschraegt und
halb ueber der Kante.
Er ist ganz in Gelb gezogen, Umriss wie Schraffur. Das hat einen Grund:
er liegt zur Haelfte auf dem hellen Grund und zur Haelfte auf dem dunklen
Foto. Eine Tintenlinie waere auf dem Foto verschwunden. Gelb traegt auf
beidem, und ein Bauhelm ist ohnehin gelb. */
/* ─── Randnotizen ────────────────────────────────────────────────────────
Kleine Zeichen neben dem Foto, wie am Rand eines Blocks hingekritzelt.
Sie stehen ausserhalb des Rahmens, damit sie das Bild nicht stoeren, und
bleiben in Tinte -- nur was leuchtet, ist gelb. */
function gluehbirne(cx, cy, gr) {
const r = gr * 0.34, my = cy - gr * 0.12;
const kolben = kringel(cx, my, r, r * 1.06, { ueber: .12 });
const hals = strich(cx - r * 0.52, my + r * 0.9, cx - r * 0.46, my + r * 1.3, { j: .7 }) +
strich(cx + r * 0.52, my + r * 0.9, cx + r * 0.46, my + r * 1.3, { j: .7 });
const sockel = rechteck(cx - r * 0.46, my + r * 1.28, r * 0.92, r * 0.6, { ue: 1.5 }) +
strich(cx - r * 0.44, my + r * 1.5, cx + r * 0.44, my + r * 1.5, { j: .5, b: .5 });
const faden = glatt([[cx - r * 0.34, my - r * 0.08], [cx - r * 0.12, my + r * 0.3],
[cx, my - r * 0.14], [cx + r * 0.12, my + r * 0.3],
[cx + r * 0.34, my - r * 0.08]], false);
let strahlen = "";
[-2.55, -2.0, -1.45, -0.9, -0.35].forEach(a => {
strahlen += strich(cx + Math.cos(a) * r * 1.32, my + Math.sin(a) * r * 1.32,
cx + Math.cos(a) * r * 1.8, my + Math.sin(a) * r * 1.8, { j: .8 });
});
return zug(strahlen, { f: GELB, w: 3 }) + zug(kolben, { w: 3.2 }) +
zug(hals, { w: 2.6 }) + zug(sockel, { w: 2.6 }) + zug(faden, { w: 2, op: .65 });
}
/* Ein Zettel mit Eselsohr und zwei gekritzelten Zeilen. */
function zettel(cx, cy, w, h) {
const x = cx - w / 2, y = cy - h / 2, e = w * 0.32;
const blatt = glatt([[x, y], [x + w - e, y], [x + w, y + e], [x + w, y + h],
[x, y + h], [x, y]], false);
const knick = glatt([[x + w - e, y], [x + w - e + 4, y + e - 4], [x + w, y + e]], false);
let zeilen = "";
[0.44, 0.6, 0.76].forEach((f, i) => {
zeilen += strich(x + w * 0.15, y + h * f, x + w * (i === 2 ? 0.58 : 0.82), y + h * f,
{ j: .8, b: .8 });
});
return zug(blatt, { w: 2.8 }) + zug(knick, { w: 2.2, op: .7 }) +
zug(zeilen, { w: 2, op: .72 });
}
/* Ringmaulschluessel.
Zwei Anlaeufe liegen dahinter. Erst war das Maul mit glatt() gezogen, und
die weiche Kurve rundete die Kanten zu einem Knochen ab. Dann war es zwar
kantig, aber immer noch ein Umriss ohne Werkzeugcharakter.
Was ihn ausmacht, sind drei Dinge, die ein echter Schluessel hat: die
Backen stehen schraeg zur Schaftachse, der Ringkopf ist ein angestelltes
Oval mit Sechskant darin, und der Schaft ist zur Mitte hin schlanker. */
function schluessel(L) {
const t = L * 0.11;
// Schaft, zur Mitte verjuengt.
const schaft = glatt([[-L * 0.30, -t * 0.66], [-L * 0.05, -t * 0.46],
[L * 0.22, -t * 0.6]], false) +
glatt([[-L * 0.30, t * 0.66], [-L * 0.05, t * 0.46],
[L * 0.22, t * 0.6]], false);
// Ringkopf: Oval mit Sechskant, um 12 Grad angestellt.
const rx = -L * 0.42;
const kopf = kringel(rx, 0, t * 1.95, t * 1.62, { ueber: .12 });
const sechskant = wabe(rx, 0, t * 0.95);
// Maul: zwei Backen, um 16 Grad zur Schaftachse gedreht.
const eck = [[0.22, -0.6], [0.29, -1.95], [0.52, -1.95], [0.52, -1.0],
[0.35, -0.44], [0.35, 0.44], [0.52, 1.0], [0.52, 1.95],
[0.29, 1.95], [0.22, 0.6]];
let maul = "";
for (let i = 0; i < eck.length - 1; i++) {
maul += strich(L * eck[i][0], t * eck[i][1],
L * eck[i + 1][0], t * eck[i + 1][1], { j: .55, b: .5 });
}
// Zwei kurze Zuege am Schaft, sonst wirkt er wie ein flacher Streifen.
const glanz = strich(-L * 0.16, t * 0.24, L * 0.02, t * 0.16, { j: .5, b: .5 }) +
strich(L * 0.06, t * 0.2, L * 0.16, t * 0.26, { j: .5, b: .5 });
return zug(schaft, { w: 2.8 }) +
'<g transform="rotate(-12 ' + n1(rx) + ' 0)">' +
zug(kopf, { w: 2.8 }) + zug(sechskant, { w: 2.2, op: .75 }) +
'</g>' +
'<g transform="rotate(16 ' + n1(L * 0.22) + ' 0)">' + zug(maul, { w: 2.8 }) + '</g>' +
zug(glanz, { w: 1.8, op: .5 });
}
/* Der Helm. Drei Anlaeufe stecken drin, und die Zwischenschritte stehen
hier, weil sie lehrreich sind: erst war die Kuppel flach und die Krempe
eine geschlossene Scheibe darunter, zusammen eine Schirmmuetze. Dann kam
Schraffur dazu, und die Waagerechten kreuzten die Grate zu einem Gitter,
also einem Korb. Was traegt, sind viele Stuetzpunkte in der Kuppel, ein
First von vorn nach hinten, zwei Nebenrippen und eine Krempe mit Dicke.
Alles in Gelb, Umriss wie Rippen: der Helm liegt je nach Lage auf hellem
Grund oder auf dem dunklen Foto. Eine Tintenlinie waere dort verschwunden,
und ein Bauhelm ist ohnehin gelb. */
function bauhelm(breite) {
const b = breite / 2, kd = b * 0.76, h = b * 1.0;
const kuppel = glatt([
[-kd, 2], [-kd * 1.03, -h * 0.28], [-kd * 0.93, -h * 0.58],
[-kd * 0.62, -h * 0.86], [-kd * 0.22, -h * 0.99], [kd * 0.22, -h * 0.99],
[kd * 0.62, -h * 0.86], [kd * 0.93, -h * 0.58], [kd * 1.03, -h * 0.28], [kd, 2],
], false);
const first = glatt([[-kd * 0.04, -h * 0.99], [0, -h * 0.62],
[kd * 0.03, -h * 0.22], [kd * 0.04, 1]], false);
let rippen = "";
[-1, 1].forEach(v => {
rippen += glatt([[v * kd * 0.36, -h * 0.92], [v * kd * 0.58, -h * 0.56],
[v * kd * 0.68, -h * 0.17], [v * kd * 0.7, 1]], false);
});
// Krempe: unten und oben ein Zug, dadurch bekommt sie Dicke.
const krempeU = glatt([[-b, 4], [-b * 0.62, 16], [0, 21], [b * 0.62, 16], [b, 4]], false);
const krempeO = glatt([[-b, 4], [-b * 0.62, 7], [0, 10], [b * 0.62, 7], [b, 4]], false);
// Schattierung links: vier Boegen parallel zur Aussenkante.
let schatten = "";
for (let i = 0; i < 4; i++) {
const f = 0.94 - i * 0.1;
schatten += glatt([[-kd * f, -h * 0.16], [-kd * f * 0.94, -h * 0.46],
[-kd * f * 0.7, -h * 0.72]], false);
}
return zug(schatten, { f: GELB, w: 2.1, op: .45 }) +
zug(kuppel, { f: GELB, w: 5 }) +
zug(first, { f: GELB, w: 3, op: .8 }) +
zug(rippen, { f: GELB, w: 2.3, op: .55 }) +
zug(krempeU, { f: GELB, w: 5 }) +
zug(krempeO, { f: GELB, w: 3.4, op: .85 });
}
/* Zwei Lagen, umschaltbar ueber den Layout-Schalter der Slide.
"ecke": der Helm haengt an der abgeschnittenen Rahmenecke. Er kippt dafuer
nach rechts, damit die Oeffnung ueber der Ecke liegt und nicht von ihr
weg zeigt. Nach links gekippt sah er aus, als falle er herunter.
"kopf": er sitzt auf Noahs Stirn, groesser, damit er getragen wirkt und
nicht wie eine Kappe aufliegt. */
function visKarte(lage) {
keim(20260811);
const W = 950, H = 1095;
/* Das Foto liegt in dieser Ebene bei x 150..770 und y 180..955, der Rahmen
16 px aussen herum. Rundherum bleibt Platz: rechts fuer den Helm in der
Ecken-Lage, links und unten fuer die Randnotizen. */
/* Ein sauberer Chamfer-Rahmen, dazu Eckwinkel in Gelb. Die Skizzenfassung
hat hier einen versetzten Zweitzug mit schraffiertem Band -- ein
gezeichneter Schatten. Der passt nicht zu dieser Richtung; Eckwinkel
sind die Geste, die ein Bild fasst, ohne es zu umranden. */
const RX0 = 134, RY0 = 164, RW = 652, RH = 807, RC = 30, WL = 56;
let winkel = "";
[[RX0 + RC, RY0, WL, 0], [RX0, RY0 + RC, 0, WL],
[RX0 + RW - RC, RY0, -WL, 0], [RX0 + RW, RY0 + RC, 0, WL],
[RX0 + RC, RY0 + RH, WL, 0], [RX0, RY0 + RH - RC, 0, -WL],
[RX0 + RW - RC, RY0 + RH, -WL, 0], [RX0 + RW, RY0 + RH - RC, 0, -WL]]
.forEach(v => { winkel += strich(v[0], v[1], v[0] + v[2], v[1] + v[3]); });
const rahmen = zug(chamfer(RX0, RY0, RW, RH, RC), { w: 2.6, op: .55 }) +
zug(winkel, { f: GELB, w: 5 });
const rand = ""; // keine Randnotizen in dieser Fassung
const helm = lage === "kopf"
? '<g transform="translate(502 380) rotate(-4)">' + bauhelm(222) + '</g>'
: '<g transform="translate(794 198) rotate(28)">' + bauhelm(196) + '</g>';
return '<svg viewBox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H +
'" xmlns="http://www.w3.org/2000/svg" class="karte-skizze">' +
rand + rahmen + helm + '</svg>';
}
/* ─── Slide 3: wechseln kannst du vergessen ──────────────────────────────
Eine Leserichtung statt einer Anordnung: Wechsel-Symbol, Pfeil, Chaos.
Was drinhaengt, steht im Symbol; wohin es fuehrt, steht rechts.
Vier Fassungen liegen dahinter. Ein verwurzelter Kasten mit gerissener
Leine und eine beladene Sackkarre erzaehlten je eine kleine Szene. Eine
dritte reihte Block, Pfeile und eine schraffierte Wand nebeneinander, und
die Wand las niemand als Wand. Die vierte war ein durchgestrichenes
Symbol, aber der Strich lief quer durch die Woerter.
Was jede von ihnen nicht hatte: eine Richtung. Drei Stationen von links
nach rechts liest man in einer Sekunde, drei Teile nebeneinander nicht. */
/* ─── Slide 3 · Der Wechsel ─────────────────────────────────────────────
Kein Bild fuer Chaos, sondern der Vorgang selbst: eine Migration, die
nicht durchlaeuft. Fortschrittsbalken, drei Statuszeilen, geschaetzte
Dauer -- die Sprache, die unsere eigene Oberflaeche spricht (siehe die
Lauf-Liste im Pitch-Screen 1, wo fehlgeschlagene Laeufe genauso stehen).
Die Vorfassungen waren Metaphern: gerissene Leine, Sackkarre, Knaeuel.
Ein Betrieb, der ueber einen Wechsel nachdenkt, versteht eine
abgebrochene Uebernahme sofort und braucht dafuer kein Sinnbild. */
function visWechseln() {
const W = 896, H = 700, P = 56;
const zeilen = [
{ t: "Stammdaten", s: "übertragen", art: "ok" },
{ t: "Aufträge", s: "1.284 Konflikte", art: "warn" },
{ t: "Buchhaltung", s: "abgebrochen", art: "fehler" },
];
const ton = { ok: [TEAL_HELL, TEAL], warn: [GELB_HELL, GELB_DUNKEL], fehler: [ROT_HELL, ROT] };
let raus = karteFlaeche(0, 0, W, H);
raus += '<text x="' + P + '" y="86" font-family="Manrope, sans-serif" font-size="20" ' +
'font-weight="700" letter-spacing="0.16em" fill="' + TEXT3 + '">MIGRATION</text>';
raus += '<text x="' + P + '" y="152" font-family="Manrope, sans-serif" font-size="38" ' +
'font-weight="800" letter-spacing="-0.025em" fill="' + TINTE + '">Alles mitnehmen</text>';
// Fortschritt: er bleibt stehen, das ist die Aussage.
const BY = 248, BW = W - 2 * P;
raus += '<text x="' + P + '" y="' + (BY - 14) + '" font-family="Manrope, sans-serif" ' +
'font-size="24" font-weight="600" fill="' + TEXT2 + '">Fortschritt</text>';
raus += '<text x="' + (W - P) + '" y="' + (BY - 14) + '" text-anchor="end" ' +
'font-family="Manrope, sans-serif" font-size="24" font-weight="800" fill="' + TINTE +
'">34 %</text>';
raus += '<rect x="' + P + '" y="' + BY + '" width="' + BW + '" height="12" rx="6" fill="' +
LINIE + '"/>';
raus += '<rect x="' + P + '" y="' + BY + '" width="' + n1(BW * 0.34) + '" height="12" rx="6" fill="' +
GELB + '"/>';
zeilen.forEach((z, i) => {
const y = 366 + i * 96, f = ton[z.art];
raus += '<circle cx="' + (P + 17) + '" cy="' + (y - 9) + '" r="17" fill="' + f[0] + '"/>';
const cx = P + 17, cy = y - 9;
const zeichen = { ok: "check", warn: "alert-triangle", fehler: "x" }[z.art];
raus += feather(zeichen, cx, cy, 19, f[1], 2.6);
raus += '<text x="' + (P + 52) + '" y="' + y + '" font-family="Manrope, sans-serif" ' +
'font-size="30" font-weight="700" letter-spacing="-0.015em" fill="' + TINTE + '">' +
z.t + '</text>';
raus += '<text x="' + (W - P) + '" y="' + y + '" text-anchor="end" ' +
'font-family="Manrope, sans-serif" font-size="26" font-weight="600" fill="' +
(z.art === "fehler" ? ROT : TEXT2) + '">' + z.s + '</text>';
if (i < zeilen.length - 1) {
raus += '<rect x="' + P + '" y="' + (y + 38) + '" width="' + BW + '" height="1" fill="' +
LINIE + '"/>';
}
});
/* Noahs eigenes Argument als Fusszeile: kein Chef faehrt seinen Betrieb
ein halbes Jahr an die Wand. */
raus += '<text x="' + P + '" y="' + (H - 54) + '" font-family="Manrope, sans-serif" ' +
'font-size="24" font-weight="600" fill="' + TEXT3 + '">Geschätzte Dauer</text>';
raus += '<text x="' + (W - P) + '" y="' + (H - 54) + '" text-anchor="end" ' +
'font-family="Manrope, sans-serif" font-size="24" font-weight="800" fill="' + TINTE +
'">ein halbes Jahr</text>';
return '<svg viewBox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H +
'" xmlns="http://www.w3.org/2000/svg">' + SCHATTEN_DEFS + raus + '</svg>';
}
/* ─── Feather-Icons ──────────────────────────────────────────────────────
Die Pfade stammen aus @subframe/core, dem Icon-Satz, mit dem unsere
Oberflaeche gebaut ist (siehe hivegrid-pitch). 24x24-Raster, 2 px Strich,
runde Enden. Selbst gezeichnete Zeichen sahen daneben immer eine Spur
anders aus; das hier ist dieselbe Hand.
Die Strichstaerke wird durch den Massstab geteilt, damit sie bei jeder
Groesse gleich schwer aussieht -- sonst wird ein grosses Icon fett. */
const FEATHER = {
"alert-triangle": '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
"check": '<polyline points="20 6 9 17 4 12"/>',
"clipboard-list": '<rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/>',
"columns-3": '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/><path d="M15 3v18"/>',
"file-text": '<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" x2="8" y1="13" y2="13"/><line x1="16" x2="8" y1="17" y2="17"/><line x1="10" x2="8" y1="9" y2="9"/>',
"filter": '<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>',
"folder": '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
"image": '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
"mail": '<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
"megaphone": '<path d="m3 11 18-5v12L3 14v-3z"/><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/>',
"message-square": '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
"smartphone": '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>',
"sparkles": '<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/><path d="M5 3v4"/><path d="M19 17v4"/><path d="M3 5h4"/><path d="M17 19h4"/>',
"sticky-note": '<path d="M15.5 3H5a2 2 0 0 0-2 2v14c0 1.1.9 2 2 2h14a2 2 0 0 0 2-2V8.5L15.5 3Z"/><path d="M15 3v6h6"/>',
"table": '<path d="M12 3v18"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/>',
"user": '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
"users": '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
"workflow": '<rect width="8" height="8" x="3" y="3" rx="2"/><path d="M7 11v4a2 2 0 0 0 2 2h4"/><rect width="8" height="8" x="13" y="13" rx="2"/>',
"x": '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
"zap": '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
"rocket": '<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>'
};
function feather(name, cx, cy, groesse, farbe, staerke) {
const s = groesse / 24;
return '<g transform="translate(' + n1(cx - groesse / 2) + ',' + n1(cy - groesse / 2) +
') scale(' + n1(s) + ')" fill="none" stroke="' + farbe + '" stroke-width="' +
n1((staerke == null ? 2 : staerke) / s) +
'" stroke-linecap="round" stroke-linejoin="round">' + FEATHER[name] + '</g>';
}
/* ─── Formen fuer die zugespitzte Fassung ───────────────────────────────
Workflow-Engine, Vertrieb und Marketing brauchen eigene Zeichen: die
bestehenden zeigen Dinge, die ein Branchenprogramm schon kann.
Jede Form nimmt einen Massstab entgegen und taugt damit als kleines
Listenzeichen und als grosser Satellit, sonst braeuchte es sechs
Funktionen statt drei. */
function formWorkflow(cx, cy, s) {
const r = 7 * s, dx = 20 * s, dy = 14 * s;
return kringel(cx - dx, cy, r) + kringel(cx + dx, cy - dy, r) + kringel(cx + dx, cy + dy, r) +
strich(cx - dx + r, cy - r * 0.3, cx + dx - r, cy - dy + r * 0.5) +
strich(cx - dx + r, cy + r * 0.3, cx + dx - r, cy + dy - r * 0.5);
}
function formTrichter(cx, cy, s) {
const b = 20 * s, h = 15 * s;
return "M" + n1(cx - b) + "," + n1(cy - h) + "H" + n1(cx + b) +
"L" + n1(cx + b * 0.26) + "," + n1(cy + h * 0.2) +
"V" + n1(cy + h) + "L" + n1(cx - b * 0.26) + "," + n1(cy + h * 0.55) +
"V" + n1(cy + h * 0.2) + "Z";
}
function formMegafon(cx, cy, s) {
const b = 18 * s, h = 15 * s;
return "M" + n1(cx - b) + "," + n1(cy - h * 0.34) +
"L" + n1(cx + b * 0.28) + "," + n1(cy - h) +
"V" + n1(cy + h) + "L" + n1(cx - b) + "," + n1(cy + h * 0.34) + "Z" +
strich(cx + b * 0.56, cy - h * 0.46, cx + b * 0.94, cy - h * 0.72) +
strich(cx + b * 0.62, cy, cx + b * 1.02, cy) +
strich(cx + b * 0.56, cy + h * 0.46, cx + b * 0.94, cy + h * 0.72);
}
const glyWorkflow = (cx, cy) => formWorkflow(cx, cy, 0.95);
const glyTrichter = (cx, cy) => formTrichter(cx, cy, 1.05);
const glyMegafon = (cx, cy) => formMegafon(cx, cy, 1.05);
/* ─── Slide 4: wir wollten mehr ──────────────────────────────────────────
Die alte Fassung zeigte vier Kaesten mit "fehlt". Das ist der Mangel, die
Slide handelt aber vom Wunsch. Also ein Blatt mit einer Liste.
Die Punkte nennen nicht noch einmal KI und Automatisierung, das steht
schon im Fliesstext darueber. Sie sagen dasselbe in den Worten eines
Betriebs. Die Kaestchen bleiben leer: erfuellt wird der Wunsch erst auf
Slide 5, und genau davon lebt der Uebergang.
Gelb tragen die Kaestchen. Es ist der einzige Akzent hier und bleibt damit
bei seiner Rolle aus den anderen Slides: Struktur, keine Verzierung. */
/* Kleine Zeichen fuer die Liste auf Slide 4. Sie liegen in derselben
Groesse wie die Glyphen der Kanaele auf Slide 2, damit beide Listen
dieselbe Sprache sprechen. Alle geben Pfaddaten zurueck, keine Markup. */
function glyZahnrad(cx, cy) {
const r = 14, n = 8;
let d = "";
for (let i = 0; i < n; i++) {
const a = i * 6.283 / n, br = 0.24, pk = [];
[[-br, r], [-br * .8, r + 7], [br * .8, r + 7], [br, r]].forEach(v => {
const aa = a + v[0];
pk.push([cx + Math.cos(aa) * v[1] + zit(.6), cy + Math.sin(aa) * v[1] + zit(.6)]);
});
d += glatt(pk, false);
}
return kringel(cx, cy, r) + d + kringel(cx, cy, 5.5);
}
function glyBoard(cx, cy) {
const w = 44, h = 34, x = cx - w / 2, y = cy - h / 2;
let d = rechteck(x, y, w, h, { ue: 1.6 });
for (let i = 1; i < 3; i++) d += strich(x + w * i / 3, y + 5, x + w * i / 3, y + h - 5, { j: .6, b: .6 });
d += rechteck(x + 4, y + 6, w / 3 - 8, 8, { ue: 1 }) +
rechteck(x + 4, y + 19, w / 3 - 8, 8, { ue: 1 }) +
rechteck(x + w / 3 + 4, y + 6, w / 3 - 8, 8, { ue: 1 });
return d;
}
function glyBlase(cx, cy) {
return kringel(cx, cy - 3, 20, 15, { ueber: .1 }) +
glatt([[cx - 6, cy + 9], [cx - 10, cy + 19], [cx + 4, cy + 10]], false) +
[-7, 0, 7].map(o => kringel(cx + o, cy - 3, 2.4)).join("");
}
function glyLeute(cx, cy) {
const kopf = (x, y, r) => kringel(x, y, r) +
glatt([[x - r * 1.5, y + r * 2.6], [x - r * 1.45, y + r * 1.05], [x, y + r * .55],
[x + r * 1.45, y + r * 1.05], [x + r * 1.5, y + r * 2.6]], false);
return kopf(cx - 15, cy - 5, 6.5) + kopf(cx + 15, cy - 5, 6.5) + kopf(cx, cy + 1, 8.5);
}
function visMehr() {
/* Theme modern: kein Notizzettel. Die Aufzaehlung bleibt, wird aber zur
Kette aus Karten -- jede mit Zeichenkachel, Beschriftung und Haken,
dazwischen ein Verbinder mit Knoten. Das ist die Form, die Zapier fuer
Ablaeufe benutzt, und sie sagt hier zusaetzlich etwas: die vier Dinge
haengen zusammen, statt nur untereinander zu stehen. */
const W = 896, H = 730, KB = 636, KH = 116, LU = 44, KX = 130;
const punkte = [
{ t: "Workflow-Engine", i: "workflow" },
{ t: "Projektmanagement", i: "columns-3" },
{ t: "Chat", i: "message-square" },
{ t: "Mitarbeiter-Management", i: "users" },
];
const oben = (H - (punkte.length * KH + (punkte.length - 1) * LU)) / 2;
const kachelGrund = TEAL_HELL;
let raus = "";
punkte.forEach((p, i) => {
const y = oben + i * (KH + LU), mx = KX + 60, my = y + KH / 2;
// Verbinder zur naechsten Karte, mit Knoten in der Mitte.
if (i < punkte.length - 1) {
const vy = y + KH, vm = vy + LU / 2;
raus += zug(strich(mx, vy, mx, vy + LU), { f: TEAL, w: 2, op: .32 });
raus += '<circle cx="' + mx + '" cy="' + n1(vm) + '" r="13" fill="' + TEAL_HELL + '"/>';
raus += zug(strich(mx - 6, vm, mx + 6, vm) + strich(mx, vm - 6, mx, vm + 6),
{ f: TEAL, w: 2.4 });
}
raus += karteFlaeche(KX, y, KB, KH);
raus += chamferFlaeche(KX + 28, y + 26, 64, 64, kachelGrund, 10);
raus += feather(p.i, KX + 60, y + 58, 34, TEAL, 2.2);
raus += '<text x="' + (KX + 118) + '" y="' + n1(my + 12) + '" ' +
'font-family="Manrope, sans-serif" font-size="34" font-weight="700" ' +
'letter-spacing="-0.02em" fill="' + TINTE + '">' + p.t + '</text>';
// Haken rechts: gefuellter Kreis in Orange, wie bei Autarc.
const hx = KX + KB - 48;
raus += '<circle cx="' + hx + '" cy="' + n1(my) + '" r="18" fill="' + GELB + '"/>';
raus += zug(glatt([[hx - 9, my], [hx - 3, my + 7], [hx + 9, my - 8]], false),
{ f: "#3E2C0A", w: 3.6 });
});
return '<svg viewBox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H +
'" xmlns="http://www.w3.org/2000/svg">' + SCHATTEN_DEFS + raus + '</svg>';
}
/* ─── Slide 6: was nach der Anmeldung passiert ───────────────────────────
Uebernahme der Sektion "So geht\'s weiter" von der Website, in derselben
Handschrift wie Slide 5. Drei Schritte an einer Leitung, Wabe statt Kreis,
der dritte Schritt blass: er hat noch nicht stattgefunden. */
/* ─── Slide 6 · So geht es weiter ────────────────────────────────────────
Nachbau der Sektion WaitlistNext.tsx von der Website, nicht eine
entschraffierte Zeichnung. Alle Werte kommen von dort und sind mit 1,6
hochskaliert (die Liste ist dort 560 breit, hier 896):
Wabe 54 x 48 -> 86 x 77, flat-top wie im clip-path
Schiene links 26 -> 43, also die Wabenmitte, 2 px -> 3
Abstand 56 -> 90
Nummer 13 px -> 21, Sperrung 0,14em
Titel 20 px -> 32
Text 14,5 px -> 23
Auf der Website leuchten die Waben nacheinander auf, waehrend man
scrollt; der dritte Schritt ist dort blass, weil er noch nicht erreicht
ist. Ein Standbild kennt keinen Scrollstand, deshalb sind hier alle drei
aktiv. */
function visWarteliste() {
const W = 896, H = 600;
const HB = 86, HH = 77, HX = 0, TX = 120, ABSTAND = 84;
const schritte = [
{ t: "Du trägst dich ein", ikon: "brief",
z: ["Eine E-Mail genügt. Kein Vertrag,", "keine Kreditkarte, unverbindlich."] },
{ t: "Wir melden uns", ikon: "chat",
z: ["Sobald HiveGrid für deinen Betrieb", "bereit ist, persönlich und ohne Spam."] },
{ t: "Du bist von Anfang an dabei", ikon: "rakete",
z: ["Unter den Ersten, die HiveGrid", "nutzen und mitgestalten."] },
];
const zeilenHoehe = 37, blockHoehe = 142;
const oben = (H - (schritte.length * blockHoehe + (schritte.length - 1) * ABSTAND)) / 2;
// Flat-top-Wabe in den Proportionen des clip-path der Website.
const wabeForm = (x, y) =>
"M" + n1(x + HB * .25) + "," + y + "H" + n1(x + HB * .75) +
"L" + n1(x + HB) + "," + n1(y + HH / 2) +
"L" + n1(x + HB * .75) + "," + n1(y + HH) +
"H" + n1(x + HB * .25) + "L" + x + "," + n1(y + HH / 2) + "Z";
const zeichen = {
brief: (cx, cy) => {
const w = 36, h = 26, x = cx - w / 2, y = cy - h / 2;
return '<rect x="' + x + '" y="' + y + '" width="' + w + '" height="' + h +
'" rx="4" fill="none" stroke="#171106" stroke-width="2.6"/>' +
'<path d="M' + (x + 2) + ',' + (y + 4) + 'L' + cx + ',' + (y + h * .6) +
'L' + (x + w - 2) + ',' + (y + 4) + '" fill="none" stroke="#171106" ' +
'stroke-width="2.6" stroke-linejoin="round" stroke-linecap="round"/>';
},
chat: (cx, cy) => {
const w = 36, h = 28, x = cx - w / 2, y = cy - h / 2 - 3;
return '<rect x="' + x + '" y="' + y + '" width="' + w + '" height="' + h +
'" rx="9" fill="none" stroke="#171106" stroke-width="2.6"/>' +
'<path d="M' + (x + 9) + ',' + (y + h) + 'l-2,9l11,-9z" fill="#171106"/>' +
[-8, 0, 8].map(o => '<circle cx="' + (cx + o) + '" cy="' + (y + h / 2) +
'" r="2.4" fill="#171106"/>').join("");
},
/* Das selbst gezeichnete Konfetti war ein Kleckshaufen. Hier steht
dieselbe Aussage als Zeichen aus dem Satz der Software. */
rakete: (cx, cy) => feather("rocket", cx, cy, 34, "#171106", 2.4),
};
let raus = "";
const mitte = i => oben + i * (blockHoehe + ABSTAND) + HH / 2;
// Die Schiene liegt hinter den Waben und verbindet ihre Mitten.
raus += '<rect x="' + n1(HX + HB / 2 - 1.5) + '" y="' + n1(mitte(0)) + '" width="3" height="' +
n1(mitte(schritte.length - 1) - mitte(0)) + '" rx="1.5" fill="' + GELB + '"/>';
schritte.forEach((sc, i) => {
const y = oben + i * (blockHoehe + ABSTAND);
raus += '<path d="' + wabeForm(HX, y) + '" fill="' + GELB + '"/>';
raus += zeichen[sc.ikon](HX + HB / 2, y + HH / 2);
raus += '<text x="' + TX + '" y="' + n1(y + 24) + '" font-family="Manrope, sans-serif" ' +
'font-size="32" font-weight="700" letter-spacing="-0.015em" fill="' + TINTE + '">' +
sc.t + '</text>';
sc.z.forEach((z, k) => {
raus += '<text x="' + TX + '" y="' + n1(y + 76 + k * zeilenHoehe) + '" ' +
'font-family="Manrope, sans-serif" font-size="23" font-weight="500" fill="' +
TEXT2 + '">' + z + '</text>';
});
});
return '<svg viewBox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H +
'" xmlns="http://www.w3.org/2000/svg">' + SCHATTEN_DEFS + raus + '</svg>';
}
/* ─── Slide 5 · Die Loesung ──────────────────────────────────────────────
Nachbau des Architektur-Diagramms aus dem Pitch-Paket (Screen 0), nicht
eine entschraffierte Wabe. Dort sitzt der Kern als gefuellter Kreis in
der Mitte, ein farbiger Ring liegt darum und traegt oben eine Pille mit
dem Produktnamen, und die andockenden Module stehen als Kacheln aussen.
Das Diagramm sagt genau das, was die Ueberschrift sagt: der Kern bleibt,
der Ring kommt drumherum. Meine Wabe hat dieselbe Aussage transportiert,
aber in einer Form, die es in unserer Software nirgends gibt. */
function visVerbinden() {
const W = 988, H = 730, CX = 494, CY = 368;
const KERN = 104, RING = 202, SATX = 344, SATY = 262, KACHEL = 88;
const module = [
{ a: 240, t: "KI", i: "sparkles" },
{ a: 300, t: "Chat", i: "message-square" },
{ a: 0, t: "Mitarbeiter", i: "users" },
{ a: 60, t: "Automation", i: "zap" },
{ a: 120, t: "Projekte", i: "columns-3" },
{ a: 180, t: "Formulare", i: "clipboard-list" },
];
// KI hat kein eigenes Zeichen im Satz, deshalb der Funke von Slide 5 alt.
module[0].ikon = (x, y) => funkeForm(x, y, 20);
let raus = "";
// Der Ring: HiveGrid liegt um den Kern, nicht an seiner Stelle.
raus += '<circle cx="' + CX + '" cy="' + CY + '" r="' + RING + '" fill="none" stroke="' +
GELB + '" stroke-width="3"/>';
// Speichen und Kacheln.
module.forEach(m => {
const r = m.a * Math.PI / 180;
const sx = CX + Math.cos(r) * SATX, sy = CY + Math.sin(r) * SATY;
const vx = sx - CX, vy = sy - CY, vl = Math.hypot(vx, vy) || 1;
const ux = vx / vl, uy = vy / vl;
raus += '<line x1="' + n1(CX + ux * RING) + '" y1="' + n1(CY + uy * RING) +
'" x2="' + n1(sx - ux * (KACHEL / 2 + 10)) + '" y2="' + n1(sy - uy * (KACHEL / 2 + 10)) +
'" stroke="' + LINIE + '" stroke-width="2"/>';
raus += '<circle cx="' + n1(CX + ux * RING) + '" cy="' + n1(CY + uy * RING) +
'" r="7" fill="' + GELB + '"/>';
raus += karteFlaeche(sx - KACHEL / 2, sy - KACHEL / 2, KACHEL, KACHEL, 12, GRUND);
raus += feather(m.i, sx, sy, 42, GELB, 2.3);
const ty = m.a > 180 ? sy - KACHEL / 2 - 16 : sy + KACHEL / 2 + 34;
raus += '<text x="' + n1(sx) + '" y="' + n1(ty) + '" text-anchor="middle" ' +
'font-family="Manrope, sans-serif" font-size="24" font-weight="700" ' +
'letter-spacing="-0.01em" fill="' + TINTE + '">' + m.t + '</text>';
});
// Der Kern. Gefuellt und dunkel, damit er als das Bestehende liest.
raus += '<circle cx="' + CX + '" cy="' + CY + '" r="' + KERN + '" fill="' +
(variant === "dunkel" ? "#1A2740" : "#1A1A1A") + '"/>';
raus += '<text x="' + CX + '" y="' + (CY - 34) + '" text-anchor="middle" ' +
'font-family="Manrope, sans-serif" font-size="16" font-weight="700" ' +
'letter-spacing="0.18em" fill="rgba(255,255,255,.5)">KERN</text>';
["Dein", "Programm"].forEach((z, i) => {
raus += '<text x="' + CX + '" y="' + (CY + 6 + i * 34) + '" text-anchor="middle" ' +
'font-family="Manrope, sans-serif" font-size="30" font-weight="800" ' +
'letter-spacing="-0.02em" fill="#FFFFFF">' + z + '</text>';
});
/* Die Pille auf dem Ring, wie im Pitch-Diagramm. Sie sitzt auf der Bahn
und macht damit sichtbar, dass HiveGrid der Ring ist und nicht die
Mitte. */
const PB = 94, PH = 72, PX = CX - PB / 2, PY = CY - RING - PH / 2;
raus += karteFlaeche(PX, PY, PB, PH, 14);
raus += gMarke(CX, CY - RING, 42, GELB);
return '<svg viewBox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H +
'" xmlns="http://www.w3.org/2000/svg">' + SCHATTEN_DEFS + raus + '</svg>';
}
/* Vierzackiger Funke fuer KI, praezise gezogen. */
function funkeForm(cx, cy, r) {
const k = r * 0.3, p = [];
for (let i = 0; i < 8; i++) {
const a = -Math.PI / 2 + i * Math.PI / 4, rr = i % 2 ? k : r;
p.push([cx + Math.cos(a) * rr, cy + Math.sin(a) * rr]);
}
return glatt(p, true);
}
const VIS = {
karte: j =>
'<div class="karte-halter">' + visKarte(layoutState[j]) +
'<div class="karte-chip">Vierte Generation · SHK</div>' +
'<div class="karte">' +
'<div class="karte-grund"><img alt="" src="' + assets["./assets/team/noah-bg.webp"] + '">' +
'<span class="karte-wash"></span></div>' +
'<img class="karte-person" alt="Noah Geerkens" src="' +
assets["./assets/team/noah-person.webp"] + '">' +
'<div class="karte-name"><span class="marke">' +
'<svg viewBox="0 0 384.5 332.9" xmlns="http://www.w3.org/2000/svg">' +
'<polygon fill="#F1B729" points="' + G_PUNKTE + '"/></svg></span><span class="zeilen">' +
'<b>Noah Geerkens</b><small>Gründer</small></span></div>' +
'</div></div>',
schiene: () => visProzesse(),
ersetzen: () => visWechseln(),
lose: () => visMehr(),
verbinden: () => visVerbinden(),
warteliste: () => visWarteliste(),
};
/* ─── Das Deck ────────────────────────────────────────────────────────────
Welche Slides der Post hat, steht nicht mehr fest. Jeder Eintrag ist
entweder eine mitgelieferte Vorlage (ref) oder eine selbst angelegte, und
eigene Texte liegen als Überschreibung daneben. So bleibt eine Änderung an
einer Vorlage im Code für alle wirksam, die sie nicht überschrieben haben
— hätte man die Slides vollständig in den Stand geschrieben, wäre der dort
eingefroren. */
let slideListe = VORLAGEN.map(v => ({ ref: v.key }));
const SLIDES = [];
function slideAus(eintrag, i) {
const basis = eintrag.ref ? VORLAGEN.find(v => v.key === eintrag.ref) : null;
const s = kopie(basis || NEUE_SLIDE);
Object.assign(s, eintrag.werte || {});
// null heisst "weg", nicht "leeres Objekt": nur so laesst sich ein zweites
// Bild, das die Vorlage mitbringt, wieder abwaehlen.
if (s.media2 === null) delete s.media2;
// Der Schlüssel landet im Dateinamen und muss eindeutig bleiben.
s.key = (basis ? basis.key : (eintrag.key || "eigen")) + (eintrag.ref ? "" : "-" + (i + 1));
return s;
}
function slidesNeuAufbauen() {
SLIDES.length = 0;
slideListe.forEach((e, i) => SLIDES.push(slideAus(e, i)));
}
/* Im Eingabefeld tippt man Text, nicht Auszeichnung: *Wort* wird zum Akzent,
ein Zeilenumbruch zum Umbruch. Gespeichert wird trotzdem das fertige
Markup, weil die Vorlagen es schon so mitbringen. */
function textZuHtml(t) {
return String(t)
.replace(/[&<>]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]))
.replace(/\*([^*\n]+)\*/g, '<span class="a">$1</span>')
.replace(/\r?\n/g, "<br>");
}
function htmlZuText(h) {
return String(h == null ? "" : h)
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<span class="a">([\s\S]*?)<\/span>/gi, "*$1*")
.replace(/<[^>]+>/g, "")
.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
}
/* 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: 76, 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: "Laptop · Board", size: 880, prop: "width" },
"vis": { label: "Visualisierung", size: null, 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;
if (key === "vis") return SLIDES[i].visW || 900;
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));
}
slidesNeuAufbauen();
let edits = SLIDES.map(() => ({}));
let picked = null; // { slide, key }
// Bearbeiten ist immer an. "aus" heisst Vorschau: alles festgelegt.
let editMode = "an";
let current = 0;
let variant = "dunkel", tilt = "an", superSample = 2, view = "streifen", tagOut = "an", frameStyle = "chamfer";
let bandTon = "orange", rasterAn = "aus", textMitte = "aus";
/* ─── 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. */
/* ─── Theme ───────────────────────────────────────────────────────────────
Neben dem Projekt (welcher Beitrag) gibt es das Theme (wie er aussieht).
notebook Rasterhintergrund, Zeichenblock-Optik, Marker in der
Ueberschrift, handgezogene Formen. Liegt in post2.html.
modern Ruhiger Grund ohne Textur, sehr grosse Ueberschrift mit einem
farbigen Wort statt Marker, schwebende Karten mit Haarlinie,
praezise Formen. Diese Datei.
Beide teilen Aufbau, Texte und Kompositionen. Wer eine Visualisierung
aendert, muss an beide Dateien denken. */
const THEME = "modern";
const PROJECT = "post2-alt";
const STORE = "hivegrid.projekt." + PROJECT;
const STATE_URL = "./state/" + PROJECT + ".json";
const STATE_V = 7;
function currentState() {
return {
v: STATE_V, variant, tilt, superSample, view, tagOut, frameStyle, bandTon,
rasterAn, textMitte,
current, layoutState, bgState, edits,
// Welche Slides der Post ueberhaupt hat. Ohne das waere eine selbst
// angelegte Slide nach dem Neuladen weg.
deck: slideListe,
};
}
/* ─── 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) >= STATE_V) return false;
const alt = d.v || 1;
if (alt < 2) {
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 });
}
}
}
// Stand 3: Das Titel-Mockup ist ein anderes Bild mit anderem Format
// geworden. Breite und Versatz von vorher passen dazu nicht mehr und
// wuerden das Geraet ueber die Slide hinaus schieben.
if (alt < 3 && Array.isArray(d.edits)) {
for (const e of d.edits) { if (e) delete e.szene; }
}
// Stand 4: Automation & KI steht jetzt vor Vertrieb. Der Stand haengt an der
// Position, nicht am Slide-Schluessel — ohne diesen Tausch laegen die
// gezogenen Positionen, das Muster und das Layout an der falschen Slide.
if (alt < 4) {
const tausch = arr => {
if (Array.isArray(arr) && arr.length > 2) { const t = arr[1]; arr[1] = arr[2]; arr[2] = t; }
};
tausch(d.edits); tausch(d.bgState); tausch(d.layoutState);
if (d.current === 1) d.current = 2;
else if (d.current === 2) d.current = 1;
}
// Stand 5: Das Tag ist ausgeblendet, seine gespeicherte Stelle also ohne
// Wirkung. Der Eintrag wird geraeumt, damit keine Werte stehenbleiben,
// die niemand mehr nachvollziehen kann.
if (alt < 5 && Array.isArray(d.edits)) {
for (const e of d.edits) { if (e) delete e.badge; }
}
/* Stand 7: Die Visualisierungen haben andere Masse bekommen -- das Foto
auf Slide 1 ist quadratisch und fuellt die Inhaltsspalte, Slide 3 ist
120 px hoeher, Slide 5 hat wieder ihre volle Breite. Die gespeicherten
Verschiebungen und Groessen stammen aus der Zeit davor und wuerden die
Elemente aus dem Blatt schieben. Das gehoert in den Ladepfad und nicht
in die Datei: der offene Tab schreibt die Datei sonst zurueck
(POST-2.md 6). */
if (alt < 7 && Array.isArray(d.edits)) {
for (const e of d.edits) { if (e) delete e.vis; }
}
d.v = STATE_V;
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;
/* Ein vorhandener Stand, der sich nicht lesen laesst, ist ein Grund zum
Anhalten und nicht zum Weiterarbeiten: sonst laeuft das Blatt mit der
Voreinstellung an und schreibt die beim ersten Klick ueber genau die
Datei, die gerade nicht verstanden wurde. Solange die Sperre steht, geht
nichts mehr raus, weder in die Datei noch in den localStorage. */
/* Im Uebersichts-Modus wird nichts gespeichert: drei Kacheln, die beim
Schliessen ihren Stand zurueckschreiben, waeren genau die Falle aus
POST-2.md 6. Die Konstante steht hier oben, weil stateLocked sie
braucht -- weiter unten waere sie noch nicht initialisiert. */
const NUR_STREIFEN = document.documentElement.dataset.nur === "streifen";
let stateLocked = NUR_STREIFEN;
function lockState(grund) {
stateLocked = true;
clearTimeout(pushTimer); pushTimer = null;
setStatus("Stand nicht geladen (" + grund + "). Es wird nichts gespeichert, "
+ "damit die vorhandene Datei nicht ueberschrieben wird.", "err");
}
/* ─── Rueckgaengig ───────────────────────────────────────────────────────
Haengt an saveState statt an den 25 Stellen, die etwas aendern: was
gespeichert wird, ist genau das, was rueckgaengig gemacht werden soll.
Ziehen speichert erst beim Loslassen, eine Bewegung ist also bereits ein
Schritt und nicht hundert. */
const UNDO_MAX = 100;
let undoStack = [], redoStack = [], lastSnap = null, restoring = false;
// Tiefe Kopie: currentState() gibt die laufenden Arrays heraus, und beim
// Ziehen wird in edits[i][key] direkt hineingeschrieben. Ohne Kopie wuerde
// sich ein abgelegter Stand nachtraeglich mitveraendern.
// Vergleich mit sortierten Schluesseln, damit die Reihenfolge, in der dx und
// dy angelegt wurden, keinen Unterschied vortaeuscht.
function stabil(x) {
if (Array.isArray(x)) return x.map(stabil);
if (x && typeof x === "object") {
const o = {};
for (const k of Object.keys(x).sort()) o[k] = stabil(x[k]);
return o;
}
return x;
}
// Ein Slide-Wechsel allein ist nichts, was man rueckgaengig machen will.
// show() speichert bei jedem Wechsel; ohne diese Ausnahme bestuende der
// Stapel zur Haelfte aus Auswahlwechseln.
function nurAuswahlAnders(a, b) {
const ohne = s => { const c = Object.assign({}, s); delete c.current; return JSON.stringify(stabil(c)); };
return ohne(a) === ohne(b);
}
function merkeFuerUndo(state) {
const snap = kopie(state);
if (lastSnap && !nurAuswahlAnders(lastSnap, snap)) {
undoStack.push(lastSnap);
if (undoStack.length > UNDO_MAX) undoStack.shift();
redoStack.length = 0; // neuer Zweig: das Wiederherstellen verfaellt
}
lastSnap = snap;
}
/* Zurueckspielen laeuft ueber takeState, also denselben Weg wie das Laden,
und zieht danach die gesamte Oberflaeche nach. Von Hand jeden Schalter
einzeln zu setzen waere die Stelle, an der man einen vergisst. */
function restoreState(d) {
restoring = true;
try {
takeState(kopie(d));
deck.dataset.view = view;
press("variants", "v", variant);
press("tilts", "t", tilt);
press("quality", "q", String(superSample));
press("tagouts", "o", tagOut);
press("frames", "f", frameStyle);
press("bands", "b", bandTon);
press("rasters", "r", rasterAn);
press("mitten", "m", textMitte);
press("views", "w", view);
// Nach einem Schritt, der Slides hinzugefuegt, geloescht oder umsortiert
// hat, stimmt der Aufbau im Canvas nicht mehr mit dem Stand ueberein.
if (deck.querySelectorAll(".holder").length !== SLIDES.length) {
picked = null;
deckNeuBauen();
} else {
SLIDES.forEach((s, i) => rebuildPage(i));
applyAll();
applyEditsAll();
}
anzahlNachziehen();
show(current);
if (picked) pick(picked.slide, picked.key); else syncEditPanel();
} finally { restoring = false; }
// Der zurueckgespielte Stand ist ab jetzt der Bezugspunkt, sonst legt der
// naechste Speichervorgang ihn gleich wieder als Schritt ab.
lastSnap = kopie(currentState());
saveState();
}
function undo() {
if (stateLocked) return;
if (!undoStack.length) { setStatus("Nichts mehr rueckgaengig zu machen", "err"); return; }
redoStack.push(kopie(currentState()));
restoreState(undoStack.pop());
setStatus("Rueckgaengig · noch " + undoStack.length + " Schritte", "ok");
}
function redo() {
if (stateLocked) return;
if (!redoStack.length) { setStatus("Nichts wiederherzustellen", "err"); return; }
undoStack.push(kopie(currentState()));
restoreState(redoStack.pop());
setStatus("Wiederhergestellt · noch " + redoStack.length + " Schritte", "ok");
}
function saveState() {
if (stateLocked) return;
const state = currentState();
if (!restoring) merkeFuerUndo(state);
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;
if (stateLocked) return;
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 || stateLocked) return;
clearTimeout(pushTimer);
navigator.sendBeacon("/state?project=" + PROJECT,
new Blob([JSON.stringify(currentState())], { type: "application/json" }));
});
async function loadState() {
let d = null, fromFile = false, fileSeen = 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) { fileSeen = true; d = await res.json(); fromFile = true; }
} catch (_) {}
// Datei ist da, laesst sich aber nicht lesen: anhalten statt ueberschreiben.
if (fileSeen && !d) { lockState("Datei unlesbar"); return false; }
if (!d) {
try { d = JSON.parse(localStorage.getItem(STORE) || "null"); } catch (_) {}
}
if (!d) return false;
// Jeder Stand von 1 bis zum aktuellen ist gueltig; aeltere hebt migrateState
// an. Frueher standen hier nur 1 und 2 fest verdrahtet, dadurch wurde jeder
// frisch gespeicherte Stand beim Laden verworfen und von der Voreinstellung
// ueberschrieben.
if (!(d.v >= 1 && d.v <= STATE_V)) {
if (fromFile) { lockState("Stand " + d.v + ", dieses Blatt kennt bis " + STATE_V); }
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);
takeState(d);
return true;
}
/* Einen Stand in die laufenden Variablen uebernehmen. Steht bewusst als
eigene Funktion da: der Ladepfad und das Rueckgaengigmachen gehen beide
hier durch, sonst zieht der eine ein Feld nach, das der andere vergisst. */
function takeState(d) {
/* Zuerst das Deck: Anzahl und Art der Slides stehen darin, und alle
folgenden Felder haengen an dieser Anzahl. Ein Stand ohne Deck stammt
aus der Zeit fester fuenf Slides und behaelt die Vorlagen. */
if (Array.isArray(d.deck) && d.deck.length) {
slideListe = d.deck.map(e => ({
ref: e && e.ref, key: e && e.key, werte: (e && e.werte) || {},
}));
slidesNeuAufbauen();
passeFelderAn();
}
// 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;
/* Der Uebersichts-Modus zeigt immer den Streifen. Steht im Stand
"einzeln", waere sonst nur die zuletzt gewaehlte Slide zu sehen --
und stripWidth() meldete 1080 statt der vollen Breite. */
view = NUR_STREIFEN ? "streifen" : (d.view || view);
tagOut = d.tagOut || tagOut;
frameStyle = d.frameStyle || frameStyle;
bandTon = d.bandTon || bandTon;
rasterAn = d.rasterAn || rasterAn;
textMitte = d.textMitte || textMitte;
current = Number.isInteger(d.current) ? Math.min(d.current, SLIDES.length - 1) : 0;
}
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.
// Der Grund ist fest das Raster. Einstellbar bleibt nur seine Staerke.
const bgState = SLIDES.map(() => ({ bg: "raster", 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. */
// Im Uebersichts-Modus steht der Streifen fest auf 100 % -- skaliert wird aussen.
let zoom = NUR_STREIFEN ? 1 : 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);
}
/* Die Uebersicht muss wissen, wie breit dieser Streifen ist -- die Zahl
der Slides ist je Projekt anders. Sie kommt per postMessage statt ueber
einen Griff ins iframe-Dokument: so bleibt die Uebersicht von den
Interna der Post-Seite unabhaengig. */
function groesseMelden() {
if (!NUR_STREIFEN || parent === window) return;
parent.postMessage({ hg: "streifen", w: stripWidth(), h: 1350,
titel: document.title }, "*");
}
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";
groesseMelden();
}
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 : "");
};
// 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>';
/* ─── Das Band ──────────────────────────────────────────────────────────
Die Wellenlaenge ist mit Absicht kein Vielfaches von 1080. Waere sie es,
laege an jeder Naht derselbe Punkt der Welle -- meist ein Scheitel, und
ein waagerechtes Band an der Kante sagt dem Auge nichts. So trifft jede
Naht eine andere Stelle der Flanke.
Der Querschnitt ist ein Rohr, kein Farbband. Die Rampe unten ist das,
was einen Metallzylinder von einem Bonbon unterscheidet:
- Die Glanzstelle liegt NICHT an der Oberkante, sondern eingerueckt
bei etwa 20 %, und sie ist schmal und fast weiss. Ein breites,
weiches Hellgelb obenauf ist genau der Bonbon-Eindruck.
- Der dunkelste Punkt liegt NICHT an der Unterkante, sondern bei
etwa 78 %. Darunter kommt Licht vom weissen Grund zurueck.
- Der Tonumfang ist gross: von #6E4205 bis fast Weiss. Metall hat
Kontrast, lackierter Kunststoff nicht.
- Beide Silhouetten sind aufgehellt (Fresnel): streifendes Licht
wird staerker reflektiert.
- Ein paar Laengsstreifen als Schliff. Ohne die bleibt die Oberflaeche
zu glatt, um echt zu wirken.
Kein SVG-Verlauf: der laeuft immer geradlinig durch die Bounding-Box,
das Band wellt sich aber, er wuerde also quer statt laengs schattieren.
Versetzte Streifen folgen der Kurve von selbst.
Der Versatz ist senkrecht und nicht rechtwinklig zur Bahn. Dadurch wird
das Band an den Flanken um bis zu 14 % schmaler -- das liest sich als
Verkuerzung und ist erwuenscht, waehrend echte Parallelkurven an engen
Radien Selbstschnitte bekommen. */
/* ─── Das Band ──────────────────────────────────────────────────────────
Flach und deckend, wie die Formen bei Slack: eine kraeftige Flaeche,
dahinter eine blasse zweite als Tiefe. Kein Verlauf, keine Woelbung,
keine Glanzkante.
Davor stand hier ein metallisch schattierter Zylinder aus vierzig
Streifen. Der sah nach Foto aus und nach nichts, was zu einem flachen,
ruhigen Blatt passt. Wer ihn wiederhaben will, findet ihn im Commit
"Theme modern: Metallband, Icon-Satz und echte G-Marke".
Zwei Toene, umschaltbar. "teal" ist nicht --accent-teal, sondern
--dark-bg #30313D -- die Farbe des dunklen Abschnitts auf der Website. */
const BAND_TOENE = {
orange: { voll: "#F1B729", blass: "#FCE7B4" },
teal: { voll: "#30313D", blass: "#D3D5DE" },
};
/* Die Bahn steht als Stuetzpunkte da, nicht als Formel -- einer je halbe
Slide, in Streifen-Koordinaten. Eine Sinuskurve war kuerzer, liess sich
aber nicht steuern: auf Slide 5 lief sie quer durch die Kacheln. Jeder
Wert ist am gerenderten Bild geprueft, nicht gerechnet.
Slide 2 quer durch, die Kaesten liegen als Karten darueber
Slide 3 hinter der Karte, sichtbar in den Seitenraendern
Slide 4 zwischen den Kartenreihen hindurch
Slide 5 unter dem Diagramm, dessen Beschriftungen bei 1030 enden */
/* Von welcher Slide bis zu welcher das Band laeuft, jeweils als Anzahl
Slides ohne Band am Anfang und am Ende. Slide 1 und die letzte sollen
ruhig sein, Slide 2 traegt ihre eigene Leitung und wuerde sich mit dem
Band beissen. Bleibt Slide 3 bis 5. */
const BAND_VON = 2, BAND_BIS = 1;
/* Auf der ersten Slide mit Band faengt es nicht an der Kante an, sondern
unter der Karte: so kommt es dahinter hervor, statt links schon
dazuliegen. Der Wert ist ein Versatz in Slide-Koordinaten und liegt
hinter der Karte (die reicht bis 988). Die Kurve selbst bleibt an der
Kante verankert, sonst wuerden sich die Stuetzpunkte neu verteilen und
die Fuehrung auf den Folge-Slides verschieben. */
const BAND_ANSCHNITT = 540;
/* Am anderen Ende endet es an der Naht. Der Auslauf ist der Zeichenbereich
hinter dem Kurvenanker; die Kurve wird dort mit ihrer Endsteigung
geradlinig verlaengert, damit sie nicht abknickt. Ein Versuch mit 170
liess das Band in die letzte Slide hineinragen -- die soll ruhig sein. */
const BAND_AUSLAUF = 0;
const BAND_BAHN = [990, 900, 740, 690, 1130, 1275, 1215];
function bandSvg() {
/* Slide 1 und die letzte tragen kein Band: Anfang und Schluss sollen
ruhig sein. Es laeuft von der linken Kante der zweiten bis zur
rechten Kante der vorletzten Slide -- zwei Pixel innerhalb der Naht,
weil genau auf der Kante ein Antialiasing-Splitter auf der
Nachbarslide stehen bleibt. */
const H = 1350;
const A0 = BAND_VON * 1080 + 2; // Anker der Kurve
const A1 = Math.max(A0, (SLIDES.length - BAND_BIS) * 1080 - 2);
const X0 = Math.min(A1, BAND_VON * 1080 + BAND_ANSCHNITT); // ab hier gezeichnet
const X1 = A1 + BAND_AUSLAUF; // bis hierhin
const W = SLIDES.length * 1080;
const P = BAND_BAHN, SCHRITT = (A1 - A0) / (P.length - 1);
const ton = BAND_TOENE[bandTon] || BAND_TOENE.orange;
// Catmull-Rom: laeuft durch die Stuetzpunkte, nicht daran vorbei.
const kurve = x => {
const u = Math.max(0, Math.min(P.length - 1.0001, (x - A0) / SCHRITT));
const i = Math.floor(u), t = u - i;
const g = k => P[Math.max(0, Math.min(P.length - 1, k))];
const a0 = g(i - 1), a1 = g(i), a2 = g(i + 1), a3 = g(i + 2);
return 0.5 * (2 * a1 + (a2 - a0) * t +
(2 * a0 - 5 * a1 + 4 * a2 - a3) * t * t +
(-a0 + 3 * a1 - 3 * a2 + a3) * t * t * t);
};
// Hinter dem Anker geradlinig weiter, mit der Steigung am Endpunkt.
const steig = (kurve(A1) - kurve(A1 - 24)) / 24;
const y = x => x <= A1 ? kurve(x) : kurve(A1) + steig * (x - A1);
const xs = [];
for (let x = X0; x < X1; x += 45) xs.push(x);
xs.push(X1);
/* Gefuellte Flaeche statt dicker Linie: eine Linie bekommt an ihrem Ende
eine Kappe rechtwinklig zur Laufrichtung, und die steht bei schraeger
Bahn seitlich ueber die Naht hinaus -- auf der Nachbarslide blieb ein
Splitter stehen. Eine geschlossene Kontur endet exakt senkrecht. */
const kante = v => xs.map(x => x + "," + (y(x) + v).toFixed(1));
const flaeche = (v, h, f) => '<path d="M' + kante(v - h).join("L") + "L" +
kante(v + h).reverse().join("L") + '" fill="' + f + '"/>';
return '<svg class="layer-band" width="' + W + '" height="' + H + '" viewBox="0 0 ' +
W + ' ' + H + '" xmlns="http://www.w3.org/2000/svg">' +
flaeche(29, 53.5, ton.blass) + flaeche(0, 44, ton.voll) + '</svg>';
}
// Positionen im 5400er Raum. Die Nähte liegen auf 1080 / 2160 / 3240 / 4320.
const bgHtml = () =>
'<div class="bg">' +
'<div class="layer-raster"></div>' +
bandSvg() +
'</div>';
function contentHtml(s, i) {
if (s.type === "feature") {
return '<p class="badge">' + s.eyebrow + '</p>' +
'<h2 class="headline">' + s.head + '</h2>' +
'<p class="body">' + s.body + '</p>';
}
const logo = variant === "dunkel" ? assets["./assets/brand/logo-white.svg"] : assets["./assets/brand/logo.svg"];
if (layoutState[i] === "szene") {
// Die Wortmarke steckt in diesem Mockup schon im Bild. Ein zweites Logo
// auf der Slide waere eine Dopplung, deshalb faellt hier die wabe-logo
// weg und die Zeile oben traegt nur die Tagline.
const dev = assets["./assets/mockups/macbook-fels.png"]
? '<div class="szene"><img class="szene-dev" alt="HiveGrid" src="' +
assets["./assets/mockups/macbook-fels.png"] + '"></div>'
: '<div class="laptop-missing">Mockup fehlt.<br>Das freigestellte PNG als <b>assets/mockups/macbook-fels.png</b> ablegen.</div>';
// Nur Ueberschrift und Mockup: der Beschreibungstext faellt hier weg.
return '<div class="title-top">' +
'<p class="wabe-tagline">Vom Handwerker<br>für <span class="a">Handwerker</span></p>' +
'</div>' + dev;
}
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" : "";
}
// Entferntes Element: aus dem Fluss nehmen, nicht nur unsichtbar machen.
n.style.display = e.weg ? "none" : "";
/* Die Ebene wirkt nur auf positionierten Elementen. Text steht im Fluss,
deshalb kommt beim Setzen ein relative dazu -- am Layout aendert das
nichts, macht die Ebene aber erst wirksam. Ohne Wert bleibt alles,
wie es die Gestaltung vorsieht. */
if (e.z === undefined || e.z === null || e.z === "") {
n.style.zIndex = "";
if (n.dataset.zpos) { n.style.position = ""; delete n.dataset.zpos; }
} else {
n.style.zIndex = e.z;
if (getComputedStyle(n).position === "static") {
n.style.position = "relative";
n.dataset.zpos = "1";
}
}
});
}
// 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.vis && VIS[sl.vis]) {
const v = document.createElement("div");
v.className = "vis vis-" + sl.vis;
/* ?? statt ||: visLeft ist auf Slide 5 bewusst 0, damit die Zeichenflaeche
an der linken Slide-Kante beginnt und der Pfeil von Slide 4 dort
ankommt. Mit || waere die 0 als fehlender Wert durchgegangen und die
Flaeche haette weiter bei 92 gesessen. */
v.style.cssText =
"--vis-w:" + (sl.visW ?? 900) + "px;--vis-left:" + (sl.visLeft ?? 92) +
"px;--vis-bottom:" + (sl.visBottom ?? 96) + "px";
v.innerHTML = VIS[sl.vis](j);
page.appendChild(v);
}
if (sl.type === "feature" && assets[sl.img]) {
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.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);
}
}
/* Ein zweites Bild gibt es auf jeder Slide, nicht nur auf Feature-Slides:
ein importiertes Bild soll auf der gewaehlten Slide landen, egal
welche das ist. */
// Ein Bild, das es nicht mehr gibt (geloescht, nicht mitgepusht), darf die
// Slide nicht mit einem leeren <img> vergiften: das faellt sonst erst im
// Export auf, und zwar mit "1 Bild(er) nicht geladen".
if (sl.media2 && assets[sl.media2.img]) {
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);
}
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];
/* Der Layout-Schalter ruft rebuildPage. Das erneuerte bisher nur den
Textblock, nicht die Visualisierung -- ein Umschalten der Helmlage waere
dadurch wirkungslos geblieben. Der Zufallskeim ist gesetzt, das erneute
Zeichnen liefert also denselben Strich. */
const vv = page.querySelector(".vis");
if (vv && SLIDES[j].vis && VIS[SLIDES[j].vis]) vv.innerHTML = VIS[SLIDES[j].vis](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.tagout = tagOut;
el.dataset.raster = rasterAn;
el.dataset.mitte = textMitte;
el.dataset.frame = frameStyle;
el.dataset.editmode = editMode;
el.style.setProperty("--slide-i", i);
el.innerHTML = bgHtml();
/* Jede Slide traegt das vollstaendige Blatt, nicht nur ihre Nachbarn.
Vorher waren es i-1 bis i+1, und ein Element liess sich damit genau eine
Slide weit schieben, danach war es abgeschnitten. Der Post soll aber eine
durchgehende Flaeche sein. Der Preis sind n mal n Seiten statt n mal
drei -- bei einem Carousel dieser Groesse nicht spuerbar. */
const sheet = document.createElement("div");
sheet.className = "sheet";
for (let j = 0; j < SLIDES.length; 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));
// Noahs Karte von der Ueber-uns-Seite. Zwei Bilder, weil die Karte die
// Person freigestellt vor ihren eigenen Hintergrund legt.
paths.push("./assets/team/noah-bg.webp", "./assets/team/noah-person.webp");
/* Alle Screens, nicht nur die gerade benutzten: eine selbst angelegte
Slide kann jeden davon waehlen, und ein Bild, das erst beim Auswaehlen
geladen wuerde, fehlt im Export. */
BILDER.forEach(sc => paths.push(sc.pfad));
// 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-fels.png"]);
paths.push("./assets/mockups/laptop.png", "./assets/mockups/macbook-fels.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() {
/* Erst die Zeichenfarben, dann die Seiten: die Visualisierungen sind in
diese Farben gezeichnet und muessen bei einem Variantenwechsel neu
entstehen. In der Skizzenfassung reichte es, nur Titel-Seiten neu zu
bauen, weil dort alles in einer Farbe stand. */
farbenNachziehen();
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.tagout = tagOut;
el.dataset.raster = rasterAn;
el.dataset.mitte = textMitte;
el.dataset.frame = frameStyle;
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("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();
syncInhalt();
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-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:
ein gedrehtes oder perspektivisch gestelltes Bild hat ein Umrechteck, das
Raster, Hintergrund und Nachbarelemente mit einschliesst. Darin fand die
Prüfung immer genug Helligkeitsspanne und winkte eine leere Flä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");
});
/* ─── Slides verwalten ────────────────────────────────────────────────────
Die Felder pro Slide (Verschiebungen, Muster, Layout) haengen an der
Anzahl und muessen bei jeder Aenderung im Gleichschritt mitwandern. Das
laeuft ueber slideListe als Bezugsgroesse, nicht ueber SLIDES: SLIDES
wird daraus jedes Mal neu berechnet. */
function passeFelderAn() {
const n = SLIDES.length;
while (edits.length < n) edits.push({});
edits.length = n;
while (bgState.length < n) bgState.push({ bg: "formen", int: "dezent" });
bgState.length = n;
while (layoutState.length < n) layoutState.push(SLIDES[layoutState.length].layouts[0].id);
layoutState.length = n;
// Ein Layout, das es auf dieser Slide nicht gibt, faellt auf das erste
// zurueck: sonst zeigt eine Feature-Slide ein Titel-Layout.
layoutState.forEach((id, i) => {
if (!SLIDES[i].layouts.some(l => l.id === id)) layoutState[i] = SLIDES[i].layouts[0].id;
});
}
function deckNeuBauen() {
deck.innerHTML = "";
nav.innerHTML = "";
buildDeck();
applyAll();
applyEditsAll();
}
function anzahlNachziehen() {
const n = SLIDES.length;
const kopf = document.getElementById("kopfzeile");
if (kopf) kopf.textContent =
(n === 1 ? "Einzelbild" : "Carousel, " + n + " Slides") + " · 1080 × 1350 px";
const zip = document.getElementById("dl-all");
if (zip) {
zip.textContent = "Alle " + n + " Slides als ZIP";
// Bei einer einzigen Slide ist ein ZIP nur ein Umweg.
zip.style.display = n > 1 ? "" : "none";
}
// Das durchgehende Blatt ist so breit wie der Post lang ist.
deck.style.setProperty("--sheet-w", (n * 1080) + "px");
}
function nachDeckAenderung(an, meldung) {
slidesNeuAufbauen();
passeFelderAn();
picked = null;
deckNeuBauen();
current = Math.max(0, Math.min(an, SLIDES.length - 1));
show(current);
anzahlNachziehen();
syncInhalt();
syncEditPanel();
saveState();
if (meldung) setStatus(meldung, "ok");
}
function slideEinfuegen(eintrag, an) {
slideListe.splice(an, 0, eintrag);
edits.splice(an, 0, {});
bgState.splice(an, 0, kopie(bgState[current] || { bg: "formen", int: "dezent" }));
slidesNeuAufbauen();
layoutState.splice(an, 0, SLIDES[an].layouts[0].id);
}
function slideNeu() {
const an = current + 1;
slideEinfuegen({ werte: {} }, an);
nachDeckAenderung(an, "Slide " + (an + 1) + " angelegt");
}
function slideKopieren() {
const an = current + 1;
const q = slideListe[current];
slideEinfuegen({ ref: q.ref, key: q.key, werte: kopie(q.werte || {}) }, an);
// Auch die Feinarbeit mitnehmen, sonst ist die Kopie nur halb eine.
edits[an] = kopie(edits[current]);
bgState[an] = kopie(bgState[current]);
layoutState[an] = layoutState[current];
nachDeckAenderung(an, "Slide kopiert");
}
function slideLoeschen() {
if (SLIDES.length <= 1) { setStatus("Die letzte Slide bleibt stehen", "err"); return; }
const weg = current;
[slideListe, edits, bgState, layoutState].forEach(a => a.splice(weg, 1));
nachDeckAenderung(Math.min(weg, SLIDES.length - 2), "Slide gelöscht");
}
function slideSchieben(um) {
const ziel = current + um;
if (ziel < 0 || ziel >= SLIDES.length) return;
[slideListe, edits, bgState, layoutState].forEach(a => {
const [x] = a.splice(current, 1);
a.splice(ziel, 0, x);
});
nachDeckAenderung(ziel, "Slide verschoben");
}
/* ─── Inhalt der gewaehlten Slide ─────────────────────────────────────────
Geschrieben wird in slideListe[i].werte, nicht in SLIDES: SLIDES entsteht
aus der Vorlage plus diesen Ueberschreibungen. Wer nichts anfasst, bekommt
Aenderungen an der Vorlage im Code weiterhin mit. */
/* ─── Eigene Bilder ───────────────────────────────────────────────────────
Die Datei geht unveraendert an den Server und liegt danach unter
assets/eigene/. Der Umweg ist Absicht: als Base64 im Projektstand waere
ein einziges Foto groesser als der gesamte uebrige Stand und jeder Diff
unbrauchbar. So steht dort nur der Pfad. */
function bildOptionen() {
return BILDER.map(b =>
'<option value="' + b.pfad + '">' +
(b.ordner === "eigene" ? "★ " : "") + b.name + '</option>').join("");
}
async function bilderNachladen() {
try {
const res = await fetch("/bilder", { cache: "no-store" });
if (!res.ok) return;
const liste = await res.json();
if (Array.isArray(liste) && liste.length) BILDER = liste;
} catch (_) { /* ohne Server bleibt die eingebaute Liste */ }
}
// Eine Datei einlesen, damit sie sofort im Canvas und im Export vorliegt.
async function bildBereitstellen(pfad) {
if (assets[pfad]) return true;
const res = await fetch(pfad, { cache: "no-store" }).catch(() => null);
if (!res || !res.ok) return false;
const blob = await res.blob();
assets[pfad] = await new Promise((ok, fail) => {
const fr = new FileReader();
fr.onload = () => ok(fr.result);
fr.onerror = fail;
fr.readAsDataURL(blob);
});
return true;
}
async function bildImportieren(datei) {
const res = await fetch("/upload", {
method: "POST",
// Kodiert, damit Umlaute und Leerzeichen den Header nicht sprengen.
headers: { "X-Filename": encodeURIComponent(datei.name) },
body: datei,
});
if (!res.ok) {
const w = await res.json().catch(() => ({}));
/* 404 heisst hier fast immer: der Python-Server laeuft noch in einer
Fassung ohne /upload. Die Seite neu zu laden hilft dagegen nicht --
serve.py wird nur beim Start eingelesen. */
if (res.status === 404) throw new Error("Server kennt /upload nicht · ./serve.sh neu starten");
throw new Error(w.error || ("HTTP " + res.status));
}
const { pfad } = await res.json();
await bilderNachladen();
await bildBereitstellen(pfad);
return pfad;
}
// Masse aus dem Bild selbst: ein Hochformat soll nicht in einem
// Querformat-Rahmen stehen.
function seitenverhaeltnis(pfad) {
return new Promise(ok => {
const im = new Image();
im.onload = () => ok(im.naturalWidth / im.naturalHeight);
im.onerror = () => ok(1.6);
im.src = assets[pfad] || pfad;
});
}
/* Ein Element genau auf die Mitte der Slide setzen. Gerechnet wird nicht,
sondern gemessen: das Blatt ist gepolstert, die Mockups stehen schief im
Raum, und ein Dreisatz ueber left und bottom laege daneben. Der Versatz
wandert in die Bearbeitung, ist also nachtraeglich frei verschiebbar. */
function mittigSetzen(i, key) {
const node = deck.querySelector(
'.page[data-j="' + i + '"] [data-edit="' + key + '"]');
if (!node) return;
const slide = node.closest(".slide");
if (!slide) return;
const sr = slide.getBoundingClientRect(), nr = node.getBoundingClientRect();
if (!sr.width || !nr.width) return;
const k = sr.width / 1080;
const dx = ((nr.left + nr.width / 2) - (sr.left + sr.width / 2)) / k;
const dy = ((nr.top + nr.height / 2) - (sr.top + sr.height / 2)) / k;
const set = (edits[i][key] = edits[i][key] || {});
set.dx = Math.round((set.dx || 0) - dx);
set.dy = Math.round((set.dy || 0) - dy);
applyEditsAll();
}
async function alsZweitesBild(i, pfad, mittig) {
const ar = await seitenverhaeltnis(pfad);
const w = werteVon(i);
const vorher = w.media2 || SLIDES[i].media2 || {};
w.media2 = {
img: pfad, w: vorher.w || 480, ar: +ar.toFixed(4),
left: vorher.left != null ? vorher.left : 300,
bottom: vorher.bottom != null ? vorher.bottom : 400,
z: vorher.z || 2, rot: mittig ? 0 : (vorher.rot || 0),
};
slidesNeuAufbauen();
deckNeuBauen();
show(i);
// Ein frisch importiertes Bild soll man sehen, nicht suchen.
if (mittig) {
edits[i]["mock-2"] = {};
mittigSetzen(i, "mock-2");
pick(i, "mock-2");
}
saveState();
}
async function dateienAufnehmen(dateien, zielSlide) {
const bilder = [...dateien].filter(f => /^image\//.test(f.type));
if (!bilder.length) { setStatus("Keine Bilddatei dabei", "err"); return; }
setStatus("Importiere " + bilder.length + " Bild(er) …");
const pfade = [];
try {
for (const f of bilder) pfade.push(await bildImportieren(f));
} catch (err) {
setStatus("Import fehlgeschlagen: " + err.message, "err");
return;
}
// Beim Ablegen auf einer Slide wandert das erste Bild gleich dorthin. Ueber
// den Knopf importiert man nur; zugeordnet wird dann in den Feldern.
/* Ohne Ziel gilt die gewaehlte Slide. Frueher wurde nur importiert und
die Zuordnung blieb in einem Auswahlfeld liegen -- das Bild war damit
nirgends zu sehen und der Import wirkte wie ein Fehlschlag. */
const ziel = typeof zielSlide === "number" ? zielSlide : current;
await alsZweitesBild(ziel, pfade[0], true);
syncInhalt();
setStatus(pfade.length === 1
? "Bild auf Slide " + (ziel + 1) + " gelegt · mit E verschieben"
: pfade.length + " Bilder importiert, erstes auf Slide " + (ziel + 1) +
" gelegt · Rest unter Inhalt", "ok");
}
document.getElementById("bild-waehlen").addEventListener("click",
() => document.getElementById("bild-datei").click());
document.getElementById("bild-datei").addEventListener("change", ev => {
const f = ev.target.files;
if (f && f.length) dateienAufnehmen(f);
ev.target.value = ""; // dieselbe Datei soll erneut waehlbar bleiben
});
/* Ablegen auf einer Slide. Der Browser oeffnet eine fallengelassene Datei
sonst einfach im Tab und der Projektstand waere verloren. */
(function ablegen() {
const sc = document.getElementById("scroller");
const zielAus = e => {
const h = e.target.closest && e.target.closest(".holder");
return h ? Number(h.dataset.n) : null;
};
["dragenter", "dragover"].forEach(art => sc.addEventListener(art, e => {
if (!e.dataTransfer || [...e.dataTransfer.types].indexOf("Files") < 0) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
const n = zielAus(e);
deck.querySelectorAll(".holder").forEach((h, i) => h.classList.toggle("ablegen", i === n));
}));
sc.addEventListener("dragleave", e => {
if (e.relatedTarget && sc.contains(e.relatedTarget)) return;
deck.querySelectorAll(".holder").forEach(h => h.classList.remove("ablegen"));
});
sc.addEventListener("drop", e => {
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
e.preventDefault();
const n = zielAus(e);
deck.querySelectorAll(".holder").forEach(h => h.classList.remove("ablegen"));
dateienAufnehmen(e.dataTransfer.files, n == null ? undefined : n);
});
// Ausserhalb der Flaeche nichts oeffnen, sondern still ablehnen.
["dragover", "drop"].forEach(art => window.addEventListener(art, e => {
if (!sc.contains(e.target)) e.preventDefault();
}));
})();
function werteVon(i) {
const e = slideListe[i];
return (e.werte = e.werte || {});
}
function syncInhalt() {
const box = document.getElementById("inhalt");
if (!box) return;
const s = SLIDES[current];
const id = current + ":" + (s.type || "");
if (box.dataset.for !== id) {
box.dataset.for = id;
const felder = s.type === "feature"
? [["eyebrow", "Tag", "input"], ["head", "Überschrift", "area"], ["body", "Fließtext", "area"]]
: [["head", "Überschrift", "area"], ["sub", "Claim", "area"]];
box.innerHTML = felder.map(([k, label, art]) =>
'<label class="inh-label" for="in-' + k + '">' + label + '</label>' +
(art === "area"
? '<textarea id="in-' + k + '" data-feld="' + k + '" rows="2"></textarea>'
: '<input id="in-' + k + '" data-feld="' + k + '" type="text">')
).join("") +
(s.type === "feature"
? '<label class="inh-label" for="in-img">Screenshot</label>' +
'<select id="in-img" data-feld="img">' + bildOptionen() + '</select>' +
'<label class="inh-label" for="in-media2">Zweites Bild</label>' +
'<select id="in-media2" data-feld="media2">' +
'<option value="">— keins —</option>' + bildOptionen() + '</select>'
: "") +
'<div class="hint">*Wort* setzt den Akzent, ein Zeilenumbruch bricht die Zeile um.' +
(s.type === "title" ? ' Beim Titel wirkt das nur im Layout „Klassisch“.' : '') + '</div>';
}
box.querySelectorAll("[data-feld]").forEach(el => {
if (document.activeElement === el) return;
const k = el.dataset.feld;
if (k === "img") el.value = s.img || "";
else if (k === "media2") el.value = (s.media2 && s.media2.img) || "";
else el.value = htmlZuText(s[k]);
});
}
document.getElementById("inhalt").addEventListener("input", ev => {
const el = ev.target.closest("[data-feld]");
if (!el) return;
const k = el.dataset.feld;
const w = werteVon(current);
if (k === "media2") {
if (!el.value) {
// null statt weglassen: sonst kaeme das Bild der Vorlage zurueck.
w.media2 = null;
slidesNeuAufbauen(); deckNeuBauen(); show(current); saveState();
} else {
alsZweitesBild(current, el.value);
}
return;
}
if (k === "img") {
w.img = el.value;
// Das Rahmenformat folgt dem Bild, sonst steht ein Hochformat in einem
// Querformat-Rahmen und laesst links und rechts Flaechen frei.
const im = new Image();
im.onload = () => {
w.mockAR = +(im.naturalWidth / im.naturalHeight).toFixed(4);
w.imgScale = 1; w.imgX = "0%"; w.imgY = "0%";
slidesNeuAufbauen();
// Das Rahmenformat steckt im Aufbau der Seite, nicht in ihren Texten:
// dafuer reicht rebuildPage nicht, der Halter muss neu entstehen.
deckNeuBauen();
show(current);
saveState();
};
im.src = assets[el.value] || el.value;
return;
}
w[k] = textZuHtml(el.value);
slidesNeuAufbauen();
// Nur die betroffene Seite neu zeichnen: ein voller Neuaufbau wuerde das
// Eingabefeld unter den Fingern wegreissen.
rebuildPage(current);
applyEditsAll();
nav.querySelectorAll("button")[current].innerHTML =
'<span class="num">' + (current + 1) + '</span>' +
(SLIDES[current].type === "title" ? "Titel" : SLIDES[current].eyebrow);
saveState();
});
document.getElementById("slide-ops").addEventListener("click", ev => {
const b = ev.target.closest("button");
if (!b || stateLocked) return;
if (b.id === "slide-neu") slideNeu();
else if (b.id === "slide-kopie") slideKopieren();
else if (b.id === "slide-weg") slideLoeschen();
else if (b.id === "slide-links") slideSchieben(-1);
else if (b.id === "slide-rechts") slideSchieben(1);
});
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 aus dem lesen, was tatsächlich gezeichnet ist. Das Rechteck
zu messen geht nicht: .strip ist ein Flex-Kind der schon skalierten
Hülle, seine Border-Box misst Breite mal Maßstab hoch zwei. Und
autoFit() neu zu rechnen geht seit dem Element-Feld auch nicht mehr:
das fährt beim Anklicken ein, macht die Bühne schmaler und ändert den
Maßstab mitten im Klick. */
const mSc = /scale\(([-\d.]+)\)/.exec(deck.style.transform || "");
const k = mSc ? Number(mSc[1]) : (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;
}
/* Welches Textfeld der Slide haengt an welchem Element. Nur was hier steht,
laesst sich im Element-Feld direkt beschriften; der Rest (Logos, Bilder,
Geraete) traegt keinen Text. */
const TEXT_ZU_FELD = {
"badge": "eyebrow", "headline": "head", "body": "body",
"title-head": "head", "title-sub": "sub", "wabe-claim": "sub",
};
/* Ein geloeschtes Element laesst sich nicht mehr anklicken. Ohne diese
Liste bliebe nur "Alles auf Standard" oder Rueckgaengig -- es waere also
praktisch verloren. */
/* Der Index kommt von aussen: seit jede Slide das ganze Blatt traegt, ist
die angezeigte Slide nicht mehr zwingend die bearbeitete. */
function verstecktHtml(i) {
const versteckt = Object.keys(edits[i] || {})
.filter(k => (edits[i][k] || {}).weg);
if (!versteckt.length) return "";
return '<div class="abschnitt">Gelöscht auf Slide ' + (i + 1) + '</div>' +
'<div class="opts">' +
versteckt.map(k => '<button data-zurueck="' + k + '" data-slide="' + i + '">' +
((STANDARD[k] || {}).label || k) + ' zurück</button>').join("") + '</div>';
}
/* Ausrichten an der Slide. Gemessen statt gerechnet: die Mockups stehen
perspektivisch im Raum und das Blatt ist gepolstert, ein Dreisatz ueber
left und top laege daneben. 92 px ist der Rand, den die Gestaltung
ohnehin haelt (.content). */
const RAND = 92;
function ausrichten(i, key, waag, senk) {
const node = deck.querySelector(
'.page[data-j="' + i + '"] [data-edit="' + key + '"]');
const slide = node && node.closest(".slide");
if (!slide) return;
const sr = slide.getBoundingClientRect(), nr = node.getBoundingClientRect();
if (!sr.width || !nr.width) return;
const k = sr.width / 1080;
const l = (nr.left - sr.left) / k, o = (nr.top - sr.top) / k;
const b = nr.width / k, h = nr.height / k;
const set = (edits[i][key] = edits[i][key] || {});
if (waag) {
const ziel = waag === "links" ? RAND
: waag === "mitte" ? (1080 - b) / 2
: 1080 - RAND - b;
set.dx = Math.round((set.dx || 0) + (ziel - l));
}
if (senk) {
const ziel = senk === "oben" ? RAND
: senk === "mitte" ? (1350 - h) / 2
: 1350 - RAND - h;
set.dy = Math.round((set.dy || 0) + (ziel - o));
}
applyEditsAll();
syncEditPanel();
saveState();
}
function syncEditPanel() {
const koerper = document.getElementById("el-inhalt");
const titel = document.getElementById("el-titel");
// Nur beim Auf- und Zugehen neu einpassen: sonst springt der Streifen bei
// jedem Wechsel der Auswahl.
const vorher = document.body.dataset.elem;
document.body.dataset.elem = picked ? "an" : "aus";
const wechsel = vorher !== document.body.dataset.elem;
if (!picked) { koerper.dataset.for = ""; if (wechsel) fitStrip(); return; }
const std = STANDARD[picked.key] || {};
const e = (edits[picked.slide] || {})[picked.key] || {};
const base = stdSize(picked.key, picked.slide);
const feld = TEXT_ZU_FELD[picked.key];
const id = picked.slide + ":" + picked.key + ":" + (edits[picked.slide][picked.key] || {}).weg;
// Nur bei einer anderen Auswahl neu aufbauen. Beim Ziehen laeuft das hier
// pro Mausbewegung; ein Neuaufbau wuerde die Eingabe unter den Fingern
// wegreissen.
if (koerper.dataset.for !== id) {
koerper.dataset.for = id;
titel.textContent = (std.label || picked.key);
koerper.innerHTML =
'<p class="sub">Slide ' + (picked.slide + 1) + " von " + SLIDES.length + '</p>' +
(feld
? '<div class="abschnitt">Text</div>' +
'<textarea id="f-text" rows="3"></textarea>' +
'<div class="hint">*Wort* setzt den Akzent, Zeilenumbruch bricht um.</div>'
: "") +
'<div class="abschnitt">Maße</div>' +
'<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">' +
'<label for="f-z">Ebene</label>' +
'<input id="f-z" type="number" step="1" placeholder="Standard" style="grid-column:2/5">' +
'</div>' +
'<div class="hint">' + (std.prop ? "Standard " + base + " px · " : "") +
'Obere linke Ecke, 0 / 0 ist die Mitte der Slide. Ebene wie z-index, ' +
'leer heißt Standard.</div>' +
(std.prop
? '<div class="row" style="margin-top:8px">' +
'<button class="btn secondary" id="sz-minus">A−</button>' +
'<button class="btn secondary" id="sz-plus">A+</button></div>'
: "") +
'<div class="abschnitt">Ausrichten an der Slide</div>' +
'<div class="opts">' +
'<button data-waag="links">Links</button>' +
'<button data-waag="mitte">Mitte</button>' +
'<button data-waag="rechts">Rechts</button></div>' +
'<div class="opts" style="margin-top:4px">' +
'<button data-senk="oben">Oben</button>' +
'<button data-senk="mitte">Mitte</button>' +
'<button data-senk="unten">Unten</button></div>' +
'<div class="abschnitt">Auf allen Slides</div>' +
'<div class="row">' +
'<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="abschnitt">Element</div>' +
'<div class="row">' +
'<button class="btn secondary" id="reset-one">Zurücksetzen</button>' +
'<button class="btn secondary" id="f-weg">Löschen</button>' +
'</div>' +
verstecktHtml(picked.slide);
if (wechsel) fitStrip();
}
const put = (sel, v) => {
const el = koerper.querySelector(sel);
if (el && document.activeElement !== el) el.value = v;
};
if (feld) put("#f-text", htmlZuText(SLIDES[picked.slide][feld]));
if (std.prop) put("#f-size", e.size || base);
put("#f-z", e.z == null ? "" : e.z);
const c = centreOf(picked.slide, picked.key);
if (c) { put("#f-dx", c.x); put("#f-dy", c.y); }
}
document.getElementById("el-zu").addEventListener("click", () => {
picked = null;
deck.querySelectorAll("[data-edit]").forEach(n => n.classList.remove("picked"));
syncEditPanel();
});
document.getElementById("el-inhalt").addEventListener("click", ev => {
const zurueck = ev.target.closest("[data-zurueck]");
if (zurueck) {
const k = zurueck.dataset.zurueck;
const i = Number(zurueck.dataset.slide);
const set = (edits[i] || {})[k];
if (set) delete set.weg;
applyEditsAll();
document.getElementById("el-inhalt").dataset.for = "";
pick(i, k);
saveState();
setStatus(((STANDARD[k] || {}).label || k) + " ist wieder da", "ok");
return;
}
const b = ev.target.closest("button");
if (!b || !picked) return;
if (b.id === "sz-plus") { resize(1); return; }
if (b.id === "sz-minus") { resize(-1); return; }
if (b.id === "reset-one") { resetOne(); return; }
if (b.id === "align-pos") { alignAll("pos"); return; }
if (b.id === "align-size") { alignAll("size"); return; }
if (b.dataset.waag) { ausrichten(picked.slide, picked.key, b.dataset.waag, null); return; }
if (b.dataset.senk) { ausrichten(picked.slide, picked.key, null, b.dataset.senk); return; }
if (b.id !== "f-weg") return;
const i = picked.slide, key = picked.key;
if (key === "mock-2") {
// Beim zweiten Bild wirklich entfernen, nicht nur ausblenden: sonst
// stuende es weiter im Stand und im Auswahlfeld.
werteVon(i).media2 = null;
slidesNeuAufbauen();
picked = null;
deckNeuBauen();
show(i);
syncInhalt();
} else {
(edits[i][key] = edits[i][key] || {}).weg = true;
applyEditsAll();
// Ausgewaehlt lassen: dann steht "Element zurueck" gleich bereit. Der
// Aufbau muss aber erzwungen werden, sonst fehlt die Rueckhol-Liste.
document.getElementById("el-inhalt").dataset.for = "";
syncEditPanel();
}
saveState();
setStatus("Element entfernt", "ok");
});
/* Eingabe wirkt sofort. Kein pick() danach: das baut das Feld neu auf und
der Fokus waere weg. */
document.getElementById("el-inhalt").addEventListener("input", ev => {
const el = ev.target;
// textarea zaehlt nicht als input: ohne das faellt das Textfeld durch.
if (!picked || !el.matches("input, textarea")) return;
/* Der Text gehoert der Slide, nicht der Bearbeitung: er wandert in
dieselbe Ueberschreibung, die auch das Inhalt-Feld links beschreibt. */
if (el.id === "f-text") {
const feld = TEXT_ZU_FELD[picked.key];
if (!feld) return;
werteVon(picked.slide)[feld] = textZuHtml(el.value);
slidesNeuAufbauen();
rebuildPage(picked.slide);
applyEditsAll();
pick(picked.slide, picked.key);
syncInhalt();
saveState();
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);
}
else if (el.id === "f-z") {
if (raw === "") delete set.z;
else set.z = Math.round(n);
}
// 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("vorschau").addEventListener("click", e => {
const b = e.target.closest("button");
if (!b) return;
setVorschau(b.dataset.p === "an");
});
/* In der Vorschau ist alles festgelegt: keine Auswahl, kein Ziehen, keine
Rahmen. Blaettern und Zoomen bleiben, das ist der Sinn der Sache. */
function setVorschau(an) {
editMode = an ? "aus" : "an";
press("vorschau", "p", an ? "an" : "aus");
deck.querySelectorAll(".slide").forEach(el => { el.dataset.editmode = editMode; });
if (an) {
picked = null;
deck.querySelectorAll("[data-edit]").forEach(n => n.classList.remove("picked"));
syncEditPanel();
}
}
/* ─── 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");
}
/* ─── 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");
// Was aus der Merkdatei kommt, wird geprueft, bevor es den laufenden Stand
// ersetzt: Slide-Anzahl und Version muessen zu diesem Blatt passen.
function snapUsable(d) {
return !!d && typeof d.v === "number" && d.v >= 1 && d.v <= STATE_V
&& Array.isArray(d.edits) && d.edits.length === SLIDES.length
&& Array.isArray(d.layoutState) && d.layoutState.length === SLIDES.length
&& Array.isArray(d.bgState) && d.bgState.length === SLIDES.length;
}
// Immer frisch: der Merkzettel wird waehrend der Arbeit ueberschrieben, ein
// gecachter Treffer wuerde den Stand von vorhin zurueckholen.
function snapUrl() {
return "./state/" + SNAP_PROJECT + ".json?t=" + Date.now();
}
async function readSnap() {
const res = await fetch(snapUrl(), { cache: "no-store" });
if (!res.ok) throw new Error("kein gemerkter Stand");
return await res.json();
}
async function snapExists() {
try { return snapUsable(await readSnap()); }
catch (_) { return false; }
}
snapExists().then(ok => { snapLoad.disabled = !ok; });
snapSave.addEventListener("click", async () => {
snapSave.disabled = true;
try {
const mine = currentState();
const res = await fetch("/state?project=" + SNAP_PROJECT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(mine),
});
if (!res.ok) throw new Error("HTTP " + res.status);
// Gegengelesen: gemerkt ist erst, was auch wirklich in der Datei steht.
// Ein "ok" vom Server allein hat hier schon einmal zu wenig bewiesen.
const back = await readSnap();
if (!snapUsable(back)) throw new Error("Datei unbrauchbar zurueckgelesen");
if (JSON.stringify(back.edits) !== JSON.stringify(mine.edits)
|| JSON.stringify(back.bgState) !== JSON.stringify(mine.bgState)
|| JSON.stringify(back.layoutState) !== JSON.stringify(mine.layoutState)) {
throw new Error("Datei weicht vom Stand ab");
}
snapLoad.disabled = false;
setStatus("Stand gemerkt und gegengelesen", "ok");
} catch (err) {
snapLoad.disabled = !(await snapExists());
setStatus("Merken fehlgeschlagen: " + err.message, "err");
} finally { snapSave.disabled = false; }
});
snapLoad.addEventListener("click", async () => {
snapLoad.disabled = true;
try {
const d = await readSnap();
// Erst pruefen, dann ersetzen. Ein Merkzettel, der nicht passt, darf
// nicht den Stand kosten, der gerade auf dem Schirm steht.
if (!snapUsable(d)) throw new Error("gemerkter Stand passt nicht zu diesem Blatt");
// Rueckfahrkarte: der Stand von jetzt wird als <projekt>-vorher abgelegt,
// bevor er ueberschrieben wird. Ein Fehlklick ist damit umkehrbar.
await fetch("/state?project=" + PROJECT + "-vorher", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(currentState()),
}).catch(() => {});
// Den laufenden Timer entschaerfen und dichtmachen, sonst schreibt der
// Reload den alten Stand ueber den gerade zurueckgeholten.
clearTimeout(pushTimer); pushTimer = null;
stateLocked = true;
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);
// Gegenlesen, bevor neu geladen wird: erst wenn die Datei steht, ist der
// Reload sicher. Sonst startet das Blatt ohne Stand.
const back = await fetch(STATE_URL + "?t=" + Date.now(), { cache: "no-store" });
if (!back.ok || !snapUsable(await back.json())) throw new Error("Stand nicht geschrieben");
try { localStorage.setItem(STORE, JSON.stringify(d)); } catch (_) {}
location.reload();
} catch (err) {
stateLocked = false;
snapLoad.disabled = false;
setStatus("Zurueckholen fehlgeschlagen: " + err.message, "err");
}
});
/* ─── Menueleiste ────────────────────────────────────────────────────────
Die Eintraege fuehren nichts selbst aus, sondern betaetigen die Schalter
im Bedienfeld. Damit gibt es weiter genau eine Stelle, an der ein
Verhalten steht, und die Leiste kann nicht auseinanderlaufen.
data-klick Knopf mit dieser id anklicken
data-opt Knopf einer Optionsgruppe: gruppe:attribut:wert
data-tun benannte Aktion
data-gehe anderes Projekt oeffnen */
(function menueleiste() {
const bar = document.getElementById("menubar");
if (!bar) return;
const menues = [...bar.querySelectorAll(".menu")];
const zu = () => menues.forEach(m => delete m.dataset.offen);
const AKTION = { undo, redo, vorschau: () => setVorschau(editMode === "an") };
// Haken und graue Eintraege erst beim Oeffnen setzen: vorher weiss die
// Leiste nicht, was gerade gilt.
function stand(m) {
m.querySelectorAll("button[data-opt]").forEach(b => {
const [gruppe, attr, wert] = b.dataset.opt.split(":");
const q = document.querySelector("#" + gruppe + " button[data-" + attr + '="' + wert + '"]');
const an = q && q.getAttribute("aria-pressed") === "true";
b.querySelector(".haken")?.remove();
b.insertAdjacentHTML("afterbegin",
'<span class="haken">' + (an ? "✓" : "") + "</span>");
});
m.querySelectorAll('button[data-tun="vorschau"]').forEach(b => {
b.querySelector(".haken")?.remove();
b.insertAdjacentHTML("afterbegin",
'<span class="haken">' + (editMode === "aus" ? "✓" : "") + "</span>");
});
m.querySelectorAll("button[data-klick]").forEach(b => {
const q = document.getElementById(b.dataset.klick);
b.disabled = !q || q.disabled;
});
m.querySelectorAll("button[data-gehe]").forEach(b => {
const hier = b.dataset.gehe.endsWith(location.pathname.split("/").pop() || "post1.html");
b.querySelector(".haken")?.remove();
b.insertAdjacentHTML("afterbegin",
'<span class="haken">' + (hier ? "✓" : "") + "</span>");
});
}
bar.addEventListener("click", e => {
const titel = e.target.closest(".menu > button");
if (titel) {
const m = titel.parentElement;
const war = m.dataset.offen === "ja";
zu();
if (!war) { m.dataset.offen = "ja"; stand(m); }
return;
}
const el = e.target.closest(".liste button");
if (!el || el.disabled) return;
zu();
if (el.dataset.gehe) { location.href = el.dataset.gehe; return; }
if (el.dataset.tun) { (AKTION[el.dataset.tun] || (() => {}))(); return; }
if (el.dataset.klick) { document.getElementById(el.dataset.klick)?.click(); return; }
if (el.dataset.opt) {
const [gruppe, attr, wert] = el.dataset.opt.split(":");
document.querySelector("#" + gruppe + " button[data-" + attr + '="' + wert + '"]')?.click();
}
});
// Ueberfahren wechselt das offene Menue, wie gewohnt.
bar.addEventListener("mouseover", e => {
const titel = e.target.closest(".menu > button");
if (!titel || !menues.some(m => m.dataset.offen === "ja")) return;
const m = titel.parentElement;
if (m.dataset.offen === "ja") return;
zu(); m.dataset.offen = "ja"; stand(m);
});
document.addEventListener("mousedown", e => { if (!bar.contains(e.target)) zu(); });
document.addEventListener("keydown", e => { if (e.key === "Escape") zu(); });
})();
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("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("ints").addEventListener("click", e => {
const b = e.target.closest("button");
if (b) { bgState[current].int = b.dataset.i; syncBgButtons(); applyAll(); saveState(); }
});
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("frames").addEventListener("click", e => {
const b = e.target.closest("button");
if (b) { frameStyle = b.dataset.f; press("frames", "f", frameStyle); applyAll(); saveState(); }
});
/* Das Band haengt in .bg und wird von buildSlide gesetzt, nicht von
applyAll. Beim Umschalten wird es deshalb hier direkt getauscht. */
document.getElementById("rasters").addEventListener("click", e => {
const b = e.target.closest("button");
if (b) { rasterAn = b.dataset.r; press("rasters", "r", rasterAn); applyAll(); saveState(); }
});
document.getElementById("mitten").addEventListener("click", e => {
const b = e.target.closest("button");
if (b) { textMitte = b.dataset.m; press("mitten", "m", textMitte); applyAll(); saveState(); }
});
document.getElementById("bands").addEventListener("click", e => {
const b = e.target.closest("button");
if (!b) return;
bandTon = b.dataset.b; press("bands", "b", bandTon);
document.querySelectorAll(".bg .layer-band").forEach(el => el.outerHTML = bandSvg());
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));
/* ⌘/Strg + Z. Eigener Zuhoerer, weil der allgemeine unten alles mit
Sondertaste durchlaesst. In Eingabefeldern hat das Zurueckgehen des
Browsers Vorrang, sonst kaeme man aus einem Tippfehler nicht mehr heraus. */
document.addEventListener("keydown", e => {
if (!(e.metaKey || e.ctrlKey) || e.altKey) return;
const k = e.key.toLowerCase();
if (k !== "z" && k !== "y") return;
const t = e.target;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
e.preventDefault();
if (k === "y" || e.shiftKey) redo(); else undo();
});
document.addEventListener("keydown", e => {
if (e.metaKey || e.ctrlKey || e.altKey) return;
// In einem Eingabefeld gehoeren die Tasten dem Feld, nicht den Kuerzeln.
// Ist nichts fokussiert, ist das Ziel das Dokument selbst — das kennt
// kein matches. Seit Slides hinzukommen und wegfallen koennen, ist genau
// das erreichbar: der Neuaufbau entfernt den zuletzt fokussierten Knopf.
if (e.target && e.target.matches && 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 === "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 === "p") { setVorschau(editMode === "an"); }
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 === "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 bilderNachladen();
await preloadAssets();
const restored = await loadState();
/* Verweise auf Bilder, die es nicht mehr gibt, hier aufloesen statt sie
bis in den Export zu schleppen. Sie entstehen, wenn eine importierte
Datei geloescht oder nicht mitgepusht wurde. */
const verloren = [];
slideListe.forEach((e, i) => {
const w = e.werte || {};
if (w.media2 && w.media2.img && !assets[w.media2.img]) {
verloren.push(w.media2.img.split("/").pop());
w.media2 = null;
}
if (w.img && !assets[w.img]) { verloren.push(w.img.split("/").pop()); delete w.img; }
});
if (verloren.length) {
slidesNeuAufbauen();
saveState();
}
buildDeck();
deck.dataset.view = view;
press("variants", "v", variant);
press("tilts", "t", tilt);
press("quality", "q", String(superSample));
press("tagouts", "o", tagOut);
press("frames", "f", frameStyle);
press("views", "w", view);
anzahlNachziehen();
show(current);
if (verloren.length)
setStatus("Fehlende Bilder entfernt: " + verloren.join(", ") +
" · bitte neu importieren", "err");
else 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. " + SLIDES.length +
(SLIDES.length === 1 ? " Slide" : " Slides") + ", 1080 × 1350 px", "ok");
} catch (err) {
console.error(err);
setStatus("Fehler beim Laden: " + err.message, "err");
}
})();
})();
</script>
</body>
</html>