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

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

562 lines
23 KiB
Markdown
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.
# Post-Generator — Regeln & Konventionen
Arbeitsgrundlage für alles, was in diesem Repo an Social-Grafiken entsteht.
**Stand:** 31.07.2026
Der Generator läuft lokal: `./serve.sh` → <http://127.0.0.1:4321/post1.html>
(Post 1, Carousel) bzw. `/index.html` (Post 2, Statement).
---
## 1. Wo die Wahrheit steht
Alle Marken-Werte stammen aus der Website, nicht aus diesem Repo. Bei Zweifel
**dort nachschauen, nicht hier raten**:
| Was | Quelle |
|---|---|
| Farben, Radien, Abstände | `HiveGrid-Website/app/globals.css` (`:root`) |
| Chamfer-Form | `HiveGrid-Website/lib/chamfer.ts` |
| Schrift | `HiveGrid-Website/app/layout.tsx` |
| Badge-Styling | `HiveGrid-Website/components/sections/ScreenshotAccordion.tsx` |
| Feature-Texte | `HiveGrid-Website/app/playground/PlaygroundClient.tsx` → `solutionModules` |
Die Werte sind hier gespiegelt, weil der Export keine Tailwind-/oklch-Farben
verträgt (siehe §7). Ändert sich die Website, müssen sie **hier nachgezogen
werden** — das passiert nicht automatisch.
---
## 2. Farben
### Marke
| Token | Wert | Verwendung |
|---|---|---|
| `--accent-yellow` | `#F1B729` | Primärer Akzent, Marker, G-Marke |
| `--accent-yellow-light` | `#FFE082` | Badge-Hintergrund |
| `--accent-yellow-dark` | `#3E2C0A` | Badge-Text |
| `--accent-teal` | `#0D6E6E` | Sekundär, aktuell ungenutzt |
| `--dark-bg` | `#30313D` | Dunkle Variante + Formen-Ebene |
### Flächen & Text
| Token | Wert |
|---|---|
| `--surface` | `#FFFFFF` |
| `--bg-primary` | `#FAFAFA` |
| `--text-primary` | `#1A1A1A` |
| `--text-secondary` | `#555555` |
| `--text-on-dark` | `#C8D0DC` |
### Pro Variante
**Hell** (Grund `#FFFFFF`)
```
--head-color #1A1A1A --frame-bg #E8EBF0
--body-color #555555 --shape-fill #30313D (deckend!)
--accent-on #C8901A --shape-line rgba(48,49,61,.13)
--marker #F1B729 --shape-accent #F1B729
--pat-color #30313D --gmark-color rgba(48,49,61,.16)
```
**Dunkel** (Grund `#30313D`)
```
--head-color #FFFFFF --frame-bg rgba(255,255,255,.11)
--body-color #B9BDC9 --shape-fill #414252
--accent-on #F1B729 --shape-line rgba(255,255,255,.10)
--marker #F1B729 --shape-accent #F1B729
--pat-color #FFFFFF --gmark-color rgba(255,255,255,.13)
```
> **`--accent-on` ist nicht `#F1B729`, sondern `#C8901A` auf Hell.** Reines
> Markengelb trägt auf Weiß bei kleinem Text nicht. Der abgedunkelte Ton ist
> Absicht, kein Fehler.
> **Der Block oben rechts ist auf Hell deckendes `#30313D`.** Das ist derselbe
> Wert wie die dunkle Story-Sektion der Website. Er darf **nicht** über die
> Intensitätsstufe aufgehellt werden, sonst ist es nicht mehr die Markenfarbe
> (siehe §5).
---
## 3. Geometrie
### Chamfer
Die Marken-Form: abgeschnittene Ecken, Echo der Honigwabe. Auf der Website
16 px, hier hochskaliert, weil die Fläche viermal so groß ist.
```css
clip-path: polygon(
Xpx 0, calc(100% - Xpx) 0, 100% Xpx, 100% calc(100% - Xpx),
calc(100% - Xpx) 100%, Xpx 100%, 0 calc(100% - Xpx), 0 Xpx
);
```
| Element | X |
|---|---|
| Badge | 11 px |
| Mockup-Rahmen außen | 22 px |
| Mockup-Rahmen innen | 14 px |
**Drei Fallstricke** (stehen auch in `chamfer.ts`):
- `clip-path` beschneidet **auch den Schatten** → Tiefe über `filter: drop-shadow()`, nie `box-shadow`
- `clip-path` beschneidet **auch den Rahmen** → Rand über eine Hintergrundfläche lösen, nicht über `border`
- Kinder, die aus der Box ragen (Popups, Tooltips), werden abgeschnitten → solche Elemente nicht chamfern
### Hexagon
Flat-Top, also flache Ober- und Unterkante mit Spitzen links und rechts:
```css
clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
```
**Seitenverhältnis 384,5 : 332,9 = 1,155 (= 2/√3).** Aus der Logomarke
abgeleitet, nicht geschätzt. Wer davon abweicht, staucht das Sechseck.
### G-Marke
Ein einziges Polygon, aus `logo.svg` extrahiert (dort die einzige Fläche mit
`class="st3"`), auf 0,0 normalisiert:
```
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
```
Immer **inline** als SVG einsetzen, nie als `<img>` — nur so lässt sie sich per
`currentColor` pro Variante umfärben, und nur so überlebt sie den Export
zuverlässig (§7).
### Wabenraster
Nahtlose Kachel aus derselben G-Form. Bei Flat-Top-Hexagonen springen die
Spalten um ¾ der Breite, benachbarte Spalten sitzen eine halbe Reihe versetzt.
Eine Kachel = zwei Spalten breit, eine Reihe hoch. **Die über den Kachelrand
hinausragenden Kopien sind Pflicht** — ohne sie sieht man die Nähte.
---
## 4. Typografie
### Die Schrift
**Manrope** — dieselbe wie auf der Website. Bewusst nicht Inter: Der Kommentar in
`app/layout.tsx` nennt Inter „the default AI/website-builder font"; Manrope ist
eine Spur wärmer und geometrischer.
```css
font-family: 'Manrope', system-ui, sans-serif;
```
| | |
|---|---|
| Schnitte | 400, 500, 600, 700, 800 |
| Ablage | `assets/fonts/manrope.css` + 6 `.woff2` |
| Subsets | latin, latin-ext, cyrillic, cyrillic-ext, greek, vietnamese |
| Herkunft | Google Fonts, einmalig heruntergeladen |
**Warum lokal und nicht per CDN:** Der Export darf zur Laufzeit an keiner
fremden Origin hängen. Ein Font von `fonts.gstatic.com` wäre cross-origin, würde
den Export vom Netz abhängig machen und im Zweifel den Canvas verunreinigen. Die
URLs in `manrope.css` sind deshalb auf relative Pfade umgeschrieben.
> **Vor jedem Export auf `document.fonts.ready` warten.** Sonst rendert der erste
> Durchgang still in einer System-Fallback-Schrift, und man sieht es erst in der
> fertigen Datei. Der Download-Button bleibt bis dahin deaktiviert.
### Größen
| Element | Größe | Weight | Sonstiges |
|---|---|---|---|
| Headline | 74 px | 800 | `line-height: 1.06`, `letter-spacing: -0.03em` |
| Titel-Tagline | 74 px | 800 | identisch zur Headline, bewusst |
| Fließtext | 30 px | 500 | `line-height: 1.48`, `max-width: 830px` |
| Badge | 26 px | 600 | `letter-spacing: 0.1em` |
| Claim (Titel) | 31 px | 500 | `line-height: 1.44` |
### Akzent-Wörter
Zwei Behandlungen, **nie beide im selben Textblock**:
- **Marker** — handgezeichneter SVG-Strich als `background-image`, wellige
Kanten, angeschrägtes linkes Ende. Für Headlines und Payoffs.
Ein CSS-Verlauf ist hier **verboten**: er kann geometrisch nur ein perfektes
Rechteck erzeugen und sieht gedruckt statt gemalt aus.
- **Farbe** (`--accent-on`) — für kleinere Zeilen.
Pro Slide höchstens **ein** markiertes Wort. Zwei Marker in einem Block waren
schon zweimal zu viel.
### Sprache
- Alles Sichtbare auf **Deutsch**, Code-Kommentare auf **Englisch**
- **Keine Em-Dashes (—)** in sichtbaren Texten. Komma, Punkt oder `·`
- Feature-Texte wortgleich von der Website. Headlines dürfen gekürzt und
umgestellt werden, aber **keine neuen Wörter erfinden**
---
## 5. Aufbau der Slides
### Formate
**1080 × 1350 px (4:5)** — das hochkantigste offiziell unterstützte
Feed-Format. Belegt: Instagram unterstützt 1.91:1 bis 4:5, Breiten 320–1080
behalten die Originalauflösung, alles außerhalb wird beschnitten.
- **Alle Slides eines Carousels müssen dasselbe Seitenverhältnis haben** — die
erste Slide setzt es, der Rest wird hart darauf beschnitten
- Safe Zone: nichts Lesbares näher als ~92 px an den Rand
- Der **Profil-Grid** zeigt seit 2025 Hochformat und beschneidet auf 3:4 —
im Grid fehlen links und rechts je ~33 px. Für die drei angepinnten Cover
relevant, im Feed nicht
### Layout-Template: Tag, Überschrift, Beschreibung, Bild
Jede Feature-Slide besteht aus denselben vier Bausteinen in derselben Reihenfolge.
Das ist keine Geschmacksfrage — Wiedererkennung über fünf Slides entsteht genau
dadurch, dass sich das Gerüst nicht bewegt.
```
92 ┌──────────────────────────────────────┐
│ ▌ TAG │ ← ausgerückt (siehe unten)
│ │
│ Überschrift, zwei Zeilen │ ← dominiert die Slide
│ mit einem markierten Wort │
│ │
│ Beschreibung, zwei bis drei │ ← kürzer als man denkt
│ Zeilen Fließtext │
│ │
│ ┌────────────────────────────────── │
│ │ Bild │ ← ein dominanter Blickfang
│ │ │
└──┴───────────────────────────────────┘
```
**Größenverhältnisse.** Die Hierarchie muss ohne Nachdenken lesbar sein — was
zuerst, was zweitens, was drittens. Bei uns: Überschrift 74 px, Fließtext 30 px,
Tag 26 px. Das Verhältnis Überschrift zu Fließtext liegt bei rund **2,5 : 1**;
gängige Empfehlungen für Carousels nennen Überschrift 48–72 pt gegen Fließtext
24–36 pt, also dieselbe Größenordnung. Weniger Abstand als Faktor 2 und die
Hierarchie kippt.
**Ein Blickfang pro Slide.** Genau ein Element, auf das das Auge sofort springt —
bei uns die Überschrift, danach das Bild. Zwei gleich starke Elemente lassen den
Betrachter raten und wegwischen.
**Weißraum ist kein verschenkter Platz**, sondern das, was die Blöcke trennt.
Der Abstand zwischen Tag und Überschrift ist deshalb größer als der zwischen
Überschrift und Beschreibung: getrennte Gedanken brauchen mehr Luft als
zusammengehörige.
#### Das Tag wird ausgerückt
Der Tag hat 20 px Innenabstand. Steht sein **Kasten** bündig zur Inhaltskante,
beginnt sein **Text** 20 px weiter rechts als die Überschrift — und genau das
sieht man:
```
vorher nachher
92 │▌ WORKFLOW-ENGINE 72 │▌ WORKFLOW-ENGINE
92 │Wenn-dann 92 │Wenn-dann
└ Kasten bündig, └ Text bündig,
Text 20 px versetzt Kasten ragt aus
```
Gelöst über `margin-left: -20px`, also genau den Innenabstand. Das ist dasselbe
Prinzip wie hängende Interpunktion im Satz: Ausgerichtet wird nach der
**wahrgenommenen** Kante, nicht nach der geometrischen. Der Kasten steht dann bei
x 72 — immer noch weit innerhalb der Safe Zone.
> Wer die Innenabstände des Tags ändert, muss das negative Margin mitziehen.
> Die beiden Werte gehören zusammen.
### Ebenen (von hinten nach vorn)
| z-index | Ebene |
|---|---|
| 0 | Hintergrund: Formen, Wabenraster, Punkte, Raster, G-Marke |
| 1 | Mockup |
| 2 | Inhalt: Badge, Headline, Fließtext |
| 3 | Swipe-Hinweis |
### Was global gilt und was pro Slide
| Einstellung | Geltung | Warum |
|---|---|---|
| Variante (Hell/Dunkel) | **global** | gemischt fällt das Carousel auseinander |
| Mockup-Perspektive | **global** | dito |
| Renderqualität | **global** | Exportstufe, kein Gestaltungsmittel (§7) |
| Layout | pro Slide | jede Slide hat einen eigenen Katalog |
| Muster | pro Slide | |
| Intensität | pro Slide | |
### Durchgehendes Carousel (Panorama)
Der Post ist dann kein Stapel von fünf Bildern mehr, sondern **ein einziges
Blatt von 5400 × 1350 px**, das in fünf 1080er Fenster geschnitten wird. Wer
swipet, sieht Elemente über die Kante hinauslaufen und auf der nächsten Slide
weitergehen.
```
Slide 1 │ Slide 2 │ Slide 3 │ Slide 4 │ Slide 5
0 ─────── 1080 ────── 2160 ────── 3240 ────── 4320 ────── 5400
↑ ↑ ↑ ↑
die vier Nähte
```
**Technisch:** Eine Ebene von 5400 px Breite liegt in jeder Slide, verschoben um
`index × -1080 px`. Jede Slide zeigt dadurch ihr Fenster desselben Blatts. Kein
Zuschneiden von Hand, kein Splitter-Tool — die Slides *sind* die Schnitte.
**Regeln, die dabei zählen:**
1. **Slide 1 muss allein funktionieren.** Sie ist das Cover im Profil-Grid. Sie
darf nicht wie „das linke Fünftel von etwas" wirken
2. **Die Naht ist nie unsichtbar.** Instagram zeigt beim Swipen ein paar Pixel
Abstand zwischen den Slides. Das lässt sich nicht abstellen und ist auch nicht
schlimm — der Effekt trägt trotzdem
3. **Nichts Wichtiges auf die Naht legen.** Keine Schrift, kein Gesicht, kein
Logo. Der Schnitt gehört in ruhige Zonen
4. **Aber etwas Auffälliges über die Naht laufen lassen.** Eine halbierte Form
zieht das Auge weiter und lenkt vom Spalt ab. Genau dafür sitzen unsere
Sechsecke **mittig auf 1080 / 2160 / 3240 / 4320** — je zur Hälfte links und
rechts der Naht
5. **Nur Standbilder.** Ein Video mitten im Carousel bricht den Fluss
**Was bei uns auf dem Blatt liegt:** vier Sechsecke auf den vier Nähten
(abwechselnd massiv und Kontur, unterschiedliche Höhen), die G-Marke auf der Naht
2|3, und ein durchgehendes Band über die volle Breite als Klammer.
Das ist ein **globaler** Schalter, kein Muster pro Slide — er wirkt naturgemäß
auf alle fünf. Die Muster pro Slide bleiben zusätzlich verfügbar; für den reinen
Panorama-Look überall auf „Aus" stellen.
> Gängige Empfehlung sind 2–4 Slides fürs Panorama. Wir haben fünf. Das
> funktioniert, verlangt aber eine ruhigere Gestaltung — bei fünf Fenstern wird
> jede zusätzliche Form schnell unruhig.
### Intensität
Die Stufe „Dezent" (Faktor 0,55) wirkt **nur auf Nebenelemente** — Hexagon-Kontur,
Waben, Punkt- und Linienraster, G-Marke. Der große Block oben rechts bleibt
immer deckend, sonst wäre er nicht mehr exakt `#30313D`.
---
## 6. Mockups
Kein fotorealistischer Laptop-Rahmen für die Feature-Slides: Die Website rahmt
jeden Screenshot mit dem Chamfer (`.screenshot-frame` in `globals.css`). Das ist
die markeneigene Mockup-Form und verbindet die Posts mit der Seite.
Der Laptop auf der Titel-Slide ist die Ausnahme und bewusst ein Blickfang.
### Geometrie pro Slide
Breite, Seitenverhältnis und Position sind CSS-Variablen mit Defaults:
```
--mock-w 1090px (ragt ~100 px über die rechte Kante)
--mock-ar 1.6 (16:10)
--mock-left 92px
```
**Regel: Das Format des Rahmens folgt dem Inhalt des Screenshots, nicht
umgekehrt.** Der Form Builder ist eine schmale, zentrierte Spalte mit breiten
leeren Rändern — in einem 16:10-Rahmen zeigt er vor allem Leere. Er bekommt
deshalb 540 × 720 (3:4), mittig gesetzt, und einen Zoom von 3,8 direkt auf die
Spalte.
### Screenshots zuschneiden
Quellen sind ~2000 px breit und im Vollbild unlesbar. Jeder Screen wird auf
seinen aussagekräftigen Bereich gezoomt. Die Werte sind gerechnet, nicht geraten:
```
Rahmenbreite = 1 Einheit, Bild bei scale 1 = 1 Einheit breit
Bildhöhe = 1/BildAR Rahmenhöhe = 1/RahmenAR
Punkt fx horizontal mittig: tx = 0.5/s − fx
Punkt fy vertikal mittig: ty = (RahmenH/2)/(s · BildH) − fy
```
Mindest-Zoom zum Füllen: `RahmenH / BildH`. Darunter entstehen Lücken.
**Ausnahme Titel-Slide:** Der Laptop zeigt den Screen **1:1**, ohne Zoom. Dort
soll ein Bildschirm zeigen, was ein Bildschirm zeigt. Die Displayfläche misst
739 × 489 (AR 1,511), `workflow.png` hat 1,504 — passt praktisch deckungsgleich.
### Neue Geräte-Mockups einbauen
1. Braucht **echte Transparenz**. Ein Screenshot eines transparenten PNGs ist
keine Transparenz — das Karomuster steckt dann als Pixel drin und lässt sich
nicht sauber entfernen (einmal versucht, Ergebnis unbrauchbar)
2. Ist die Displayfläche deckend, muss sie **ausgeknockt** werden, damit der
Screenshot dahinter liegen kann. Nur so bleiben Notch und Rundungen oben
3. Displayfläche ausmessen und als Prozentwerte eintragen — sie hängt am
konkreten Bild
4. Schatten über `filter: drop-shadow()` auf dem Container, nicht im PNG
---
## 7. Export & Bildqualität
### Die zwei Regeln, aus denen alles folgt
**1. Genau 1080 px breit hochladen.** Instagram behält bei Breiten von 320 bis
1080 die Originalauflösung. Alles darüber wird **von Instagram selbst**
verkleinert und danach neu komprimiert — Qualitätsverlust in zwei Runden statt
einer. Ein 4000-px-Upload ist deshalb schlechter als ein 1080-px-Upload, nicht
besser.
**2. Trotzdem größer rendern.** Wir rastern intern bei **2×** (2160 × 2700) und
skalieren selbst auf exakt 1080 × 1350 herunter. Das ist kein Widerspruch zu
Regel 1, sondern der eigentliche Trick: Wir kontrollieren das Downsampling,
statt es Instagram zu überlassen.
```
2160 × 2700 → Halbierung → 1080 × 1350 → Upload
render exakt
```
### Warum das Supersampling hier hilft
Nicht wegen des Textes — den rastert der Browser bei nativer Größe ohnehin
sauber, teils sogar schärfer als supersampled. Der Gewinn liegt woanders:
- **Transformationen** — der gekippte Laptop, gedrehte Formen, Chamfer-Kanten
und Hexagon-Clips werden bei doppelter Auflösung gerastert und danach
gemittelt. Das ist echtes Anti-Aliasing statt Treppenkanten
- **Screenshots** — unsere Quellen sind ~2000 px breit und werden auf ~1090 px
gezeigt. Bei 2× zeichnet der Browser sie näher an ihrer nativen Auflösung, das
anschließende Downsampling ist unser eigenes und damit besser
- **Marker und Wabenraster** — SVG-Kanten profitieren identisch
> Bei reinen Textslides ohne Mockup ist der Unterschied gering bis null. Deshalb
> ist die Stufe umschaltbar (1× / 2× / 3×), nicht fest verdrahtet. **2× ist der
> Standard**, 3× kostet spürbar Zeit und Speicher bei kaum sichtbarem Gewinn.
### Warum stufenweises Halbieren
Browser skalieren Canvas standardmäßig schnell statt gut: Pixel werden direkt
übernommen, Nachbarinformationen gehen verloren, das Ergebnis rauscht. Zwei
Gegenmaßnahmen, beide nötig:
```js
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high"; // Default ist "low"
```
Und: **in Halbierungsschritten** verkleinern statt in einem Sprung. Ein direkter
Sprung von 3240 auf 1080 überspringt Zwischenpixel; zweimal halbieren mittelt
jeweils über 2 × 2 und kommt einer echten Flächenmittelung nahe.
`createImageBitmap()` mit `resizeQuality` wäre der elegantere Weg, ist aber in
Browsern uneinheitlich umgesetzt — teils wird die Option ignoriert. `drawImage`
mit `imageSmoothingQuality` ist der verlässliche Pfad.
### Format
**PNG, nicht JPEG.** Instagram konvertiert am Ende ohnehin zu JPEG — aber unsere
Slides sind Text, flache Flächen und UI-Screenshots mit harten Kanten. Genau die
zerlegt JPEG-Kompression zuerst. Wer als PNG hochlädt, gibt Instagram ein
verlustfreies Original und zahlt nur **einmal** Kompression statt zweimal.
Für Fotoinhalte wäre JPEG bei 85–95 % die bessere Wahl. Haben wir hier nicht.
### Farbraum
**sRGB, ausschließlich.** Instagram wirft andere Profile weg und konvertiert
nach sRGB. Wer in Adobe RGB oder P3 exportiert, bekommt Farbverschiebungen bei
dieser Konvertierung. Canvas-Exporte aus dem Browser sind standardmäßig sRGB —
also nichts zu tun, solange niemand ein Farbprofil anfasst.
### Weiteres
- Auf `document.fonts.ready` warten, sonst rendert der erste Export in einer
Fallback-Schrift
- Dateinamen mit `01-`…`05-` präfixen: Instagram übernimmt beim Hochladen die
Dateisortierung
- Dateigröße im Auge behalten. Für JPEG gilt die Faustregel „unter 1 MB", sonst
komprimiert Instagram härter. Für unsere PNGs mit Screenshots ist das nicht zu
halten und auch nicht nötig
- Vom Desktop hochladen, wenn möglich — gilt als schonender als der mobile Upload
### Quellenlage, ehrlich
**Von Meta dokumentiert:** die unterstützten Seitenverhältnisse (1.91:1 bis 4:5),
die Auflösungsgrenze 320–1080, der Beschnitt außerhalb, und dass alle
Carousel-Slides auf die erste beschnitten werden.
**Community-Konsens, nicht offiziell:** die konkreten Kompressionsprozente, die
1-MB-Faustregel, Desktop-vs-Mobile, und PNG-vs-JPEG-Empfehlungen. Plausibel und
breit reproduziert, aber von Meta nirgends bestätigt. Im Zweifel selbst
gegentesten: dieselbe Slide in zwei Varianten hochladen und vergleichen.
### Vier Dinge, die den Export brechen
**1. Tailwind- und `oklch()`-Farben.** Die Export-Bibliothek kann sie nicht
serialisieren. Der Slide-Baum benutzt deshalb ausschließlich Hex und `rgba()`.
**2. PNGs im `foreignObject`.** Der Export serialisiert die Slide in eine
`data:image/svg+xml`-URL und malt sie als **ein** Bild. Raster-Kinder dekodieren
asynchron und verpassen diesen Paint regelmäßig — der Screenshot fehlt dann,
während der Rest stimmt. SVGs dekodieren synchron und sind immer da.
> Deshalb ist der Export **verifiziert statt gehofft**: Jede Screenshot-Fläche
> wird nach dem Rendern abgetastet. Eine solche Fläche ist nie glatt, sie trägt
> UI und Farbe. Liegt die Luminanz-Spanne unter 12, fehlt das Bild und es wird
> neu gerendert, bis zu fünf Versuche.
**3. Browser-Downloads.** `data:`-URLs öffneten einen leeren Tab, `blob:`-URLs
das Bild im Tab — das `download`-Attribut wurde beide Male ignoriert. Der
Generator schickt die Bytes deshalb per POST an **`/save`**, und `serve.py`
schreibt direkt nach `~/Downloads`.
**4. Stille Skriptfehler.** Ein Fehler auf oberster Ebene (Syntax, Temporal Dead
Zone) tötet das ganze Skript: Das Panel rendert aus statischem HTML weiter, aber
nichts wird gebaut. Beide Seiten haben einen globalen `error`-Listener, der so
etwas ins Statusfeld schreibt. **Nicht entfernen.**
---
## 8. Fallen, in die wir schon getappt sind
| Symptom | Ursache |
|---|---|
| Logo unsichtbar auf Weiß | Sichtbarkeitsregel vertauscht — auf Hell wurde `logo-white.svg` gezeigt |
| Logo bildschirmbreit statt zentriert | `.laptop-screen img` galt auch fürs Logo und hat es überschrieben. Solche Regeln **immer auf eine Klasse eingrenzen** |
| Element eine Zeile zu tief | `max-width` erzwang einen zusätzlichen Umbruch. Bei gesetzten `<br>` gehört `white-space: nowrap` dazu |
| Farbe „stimmt nicht" | Deckkraft der Ebene lag darüber. Eine Farbe ist nur exakt, wenn nichts sie multipliziert |
| Muster wirkt zufällig hingeworfen | Formen liefen quer durch den Text. Dekoration bleibt in den Rändern, verankert und angeschnitten |
| Nichts lädt, Bühne leer | Konstante vor ihrer Deklaration benutzt (TDZ) |
---
## 9. Ordner
```
posts/
├── serve.py / serve.sh Statischer Server + /save-Endpunkt (Port 4321)
├── post1.html Post 1 · Lösung (Carousel, 5 Slides)
├── index.html Post 2 · Statement (Einzelbild)
├── rules.md diese Datei
├── assets/
│ ├── logo.svg, logo-white.svg, cta-honeycomb.svg
│ ├── laptop.png Mockup, Display ausgeknockt
│ ├── fonts/ Manrope, lokal
│ └── screens/ Software-Screenshots
└── vendor/ html-to-image, JSZip (lokal, kein CDN)
```
Alles läuft **offline**. Keine CDN-Verweise, keine Google-Fonts zur Laufzeit —
sonst hängt der Export an einer Netzwerkverbindung und an fremden Origins.