Compare commits

..
10 Commits
Author SHA1 Message Date
dejanandClaude Opus 5.5 07deacde41 Post 9: Varianten überarbeitet, Variante 6 und 7 nach Noahs Arbeitsheft
- Variante 2/3: Webinar statt HiveGrid, Noahs Satz zum Kopf des Chefs,
  vorher und heute am Beispiel „Angebot nachfassen“ als Schema
- Variante 4/5: drei verschiedene Grafiken, Wabenanimation gelb dann rot,
  Titel mit Wabenwelle animiert
- Variante 6/7: Aussagen, Zeichnungen und Schriften aus „Jede Frage lief
  über meinen Tisch“, Titel dunkel, Pfeile werden gezeichnet (Test)
- post9-hub.mjs legt alle Varianten als Bilder und Videos ins Marketing-Hub
- Menüs aller Posts und Übersicht mit Variante 6 und 7

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 21:51:51 +01:00
dejanandClaude Opus 5.5 f9f48609dd Bildunterschriften für Post 7 und 8
Post 7: Form Builder, auf Instagram erschienen, Facebook offen.
Post 8: Text freigegeben, Post vorerst nicht veröffentlicht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 21:51:50 +01:00
dejanandClaude Opus 5.5 466b094c0b Post 9 Variante 4/5: Noah gespiegelt, Wabengrafiken animiert
- Titelslide: Noah an x 800 gespiegelt, er schaut zum Text
- Slide 2 und 3 als Video (6 s, 1:1): die gelben Waben ploppen
  nacheinander auf und ziehen sich zum Schleifenende zurück
- Im Streifen laufen die Animationen mit, Standbild bleibt der Endstand

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 19:22:27 +01:00
dejanandClaude Opus 5.5 5b768537a2 Post 9: Format 1:1 für die Anzeige und vier neue Varianten
- Format-Schalter 4:5 / 1:1 in Post 9, Stand auf 1:1 (Meta nimmt ein
  Carousel mit Video als Anzeige nur quadratisch), Video in 1:1
- animation.mjs baut das Format der Seite, Datei mit -1x1
- Variante 2/3: Kundengeschichte nach dem weclapp-Karussell, hell und dunkel
- Variante 4/5: Zahlen-Karussell nach HERO Insights mit KfW- und
  Bitkom-Zahlen, Noah als Teaser, hell und dunkel
- post9-varianten.html zeigt alle Varianten untereinander
- Menüs und Übersicht um die Varianten ergänzt, Regel zu 1:1 in rules.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 18:55:03 +01:00
dejanandClaude Opus 5.5 cafefc9f1b komponenten.md: Form Builder und gleiche Hoehe im Carousel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:26:21 +02:00
dejanandClaude Opus 5.5 fc69bebe96 Post 7: neu gebaut nach dem echten Form Builder
Kopf, Reiter, Fragekarten, offene Frage mit Streifen, "Nur anzeigen,
wenn", Werkzeugleiste und "Felder zuweisen" aus screens/forms mit den
Komponenten aus komponenten.md. Raus, weil es sie im Produkt nicht gibt:
die Pipeline am Lead, die Fusszeile mit "Zuletzt gespeichert" und
"Bereit zum Veroeffentlichen", die alten Feldtypen-Namen. Slide-Texte
angepasst, Video neu exportiert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:26:21 +02:00
dejanandClaude Opus 5.5 4dd2ea1d18 Regel: Oberflaechen immer aus den Produkt-Komponenten
rules.md §0 macht es zur Pflicht, komponenten.md ist das Nachschlagewerk
mit Tokens, Komponenten, Bausteinen und Vorgehen, CLAUDE.md weist jeden
Agenten darauf hin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:03:33 +02:00
dejanandClaude Opus 5.5 35e68db15b Post 8: neu gebaut aus den Komponenten von hivegrid-cloud
Jede Zeichnung bildet jetzt einen echten Screen nach: Kampagnenbericht,
Zielgruppe bearbeiten, Vorlagen, Bereit zum Versand, Gestaltung. Schrift
Instrument Sans, Icons Lucide, Masse und Farben aus ui/theme.css und den
Subframe-Komponenten. Raus, weil es sie im Produkt nicht gibt: Reiter,
Haekchen-Chips, Produktkacheln. Instrument Sans wird im PNG-Export
eingebettet. Video neu exportiert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:03:33 +02:00
dejanandClaude Opus 5.5 cd74224880 Post 9: Folien im Look der Webinar-Slides, ruhigerer Sprecherwechsel
Teil 1 dunkel mit G-Waben wie die Kapitel-Slides, die drei danach hell mit
dem zweiten Wabenmuster. Die Zeichen sitzen als grosse gelbe Wabe im Muster,
je ein Wort im Titel in Gelb. Die Live-Marke entfaellt, der Titel sagt es
schon. Noah spricht bei Teil 1 und 2, David bei 3 und 4. Video neu exportiert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:04:53 +02:00
dejanandClaude Opus 5.5 78a6fcba78 Export: klare Meldung, wenn PostGrid nicht auf 4321 laeuft
Vorher hiess das nur "kennt keine Animationen".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:04:53 +02:00
53 changed files with 54609 additions and 894 deletions

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
# posts (PostGrid)
Hier entstehen die Bild- und Animationsposts von HiveGrid. Regeln in `rules.md`.
**PFLICHT, ohne Ausnahme:** Jede Oberfläche in einem Post wird aus den echten Komponenten von `hivegrid-cloud`
nachgebaut, mit deren Maßen, Farben, Schrift, Icons und Beschriftungen, und bildet einen Screen nach, den es im Produkt
gibt. Keine ausgedachten, „vibecoded“ Grafiken. Vor jedem neuen Post `komponenten.md` lesen und die Bausteine aus
`post8.html` übernehmen (rules.md §0).
- Code von hivegrid-cloud immer von `origin/main` lesen (`git show origin/main:<pfad>`), die Arbeitskopie ist veraltet
und wird nicht angefasst.
- PostGrid starten: `./serve.sh`, dann http://127.0.0.1:4321/. Der Export (`node animation.mjs`) lädt immer von Port 4321.
- Texte: schlichtes Deutsch, keine Geviertstriche.
+37
View File
@@ -0,0 +1,37 @@
# Post 7 · Form Builder
Erster Beitrag nach der Pause, **erschienen auf Instagram am Fr 23.10.2026**
(Redaktionsplan, Slot s02). Datei `post7.html`, Stand in `state/post7.json`.
**Facebook:** noch offen. Dejan stellt den Beitrag dort selbst ein (Stand
02.11.2026), mit demselben Text.
---
## Die Bildunterschrift
So am 22.10.2026 geliefert und für Instagram verwendet. Ob beim Posten noch
etwas geändert wurde, ist hier nicht vermerkt.
```
Eine Anfrage kommt per Mail. Keine Adresse, keine Fotos, keine Telefonnummer. Dann geht das Hinterhertelefonieren los.
Im Form Builder von HiveGrid baust du dein Anfrageformular selbst: Text, Auswahl, Datei, Datum und mehr. Du bestimmst, was gefragt wird und in welcher Reihenfolge.
Fragen, die nicht für jeden gelten, tauchen nur auf, wenn die Antwort davor sie verlangt. So füllt keiner Felder aus, die ihn nichts angehen.
Jede neue Antwort wird als Lead gespeichert, mit den Feldern, die du zuweist. Abgetippt wird nichts mehr.
Das Formular bindest du auf deiner Website ein oder teilst es per QR-Code.
Was fehlt bei deinen Anfragen am häufigsten?
#handwerk #shk #handwerksbetrieb #digitalisierungimhandwerk #kundenanfragen #onlineformular #formbuilder #hivegrid
```
| Vorgabe | Ist |
|---|---|
| Haken in den ersten 125 Zeichen | erster Absatz, 118 Zeichen |
| 300 bis 800 Zeichen | 655 ohne Hashtags |
| 5 bis 10 Hashtags, unten abgesetzt | 8 |
| Suchwörter zusätzlich im Fließtext | Anfrage, Formular, Fotos, Lead, Website, QR-Code |
+45
View File
@@ -0,0 +1,45 @@
# Post 8 · E-Mail-Marketing
Datei `post8.html`, Stand in `state/post8.json`. Slides und Bildunterschrift
sind fertig. Geplant war Mo 26.10.2026 auf Instagram (Redaktionsplan, Slot
s14).
**Status: vorerst nicht gepostet** (Dejan, 02.11.2026). Der Text ist
freigegeben („der Text ist gut“) und wird so verwendet, wenn der Beitrag
kommt, auf Instagram wie auf Facebook.
---
## Die Bildunterschrift
```
Du hast keine Zeit, dich bei jedem Kunden zu melden. HiveGrid schon.
Die richtige E-Mail zur richtigen Zeit: Wartungserinnerung, Nachfassen, Angebot. Direkt aus deiner Kundenliste, ohne zweite Adressliste.
Nach Gewerk, Region oder Projektstand filtern, und die Mail landet bei den Richtigen. Nicht bei allen.
Vorlagen für Angebot, Wartungserinnerung und Newsletter sind dabei. Den Rest baust du aus Bausteinen: Überschrift, Text, Bild, Button.
HiveGrid hält jede Kampagne an, bis sie DSGVO-konform ist. Was das Gesetz verlangt, musst du nicht auswendig können.
Danach siehst du auf einen Blick, wer geöffnet und wer geklickt hat.
#handwerk #shk #handwerksbetrieb #emailmarketing #kundenbindung #wartung #digitalisierungimhandwerk #hivegrid
```
| Vorgabe | Ist |
|---|---|
| Haken in den ersten 125 Zeichen | erster Absatz, 68 Zeichen |
| 300 bis 800 Zeichen | 634 ohne Hashtags, 745 mit |
| 5 bis 10 Hashtags, unten abgesetzt | 8 |
| Suchwörter zusätzlich im Fließtext | E-Mail, Wartungserinnerung, Angebot, Kundenliste, Newsletter, DSGVO |
**Woher die Sätze kommen:**
- Der Haken und „Wartungserinnerung, Nachfassen, Angebot … direkt aus deiner
Kundenliste“: Website, `lib/produkt-daten.ts`, Seite `email-marketing`
(Headline und Subtext).
- Absatz 3 bis 5 folgen den Slides Zielgruppe, Vorlagen, Baukasten,
Rechtssicher in `post8.html`.
- Der letzte Satz gehört zum Kampagnenbericht im Bewegtbild; auf der Website
„Wer hat geöffnet, wer geklickt, wer reagiert? Alles auf einen Blick.“
+30 -7
View File
@@ -3,6 +3,7 @@
* *
* node animation.mjs post6.html alle animierten Slides * node animation.mjs post6.html alle animierten Slides
* node animation.mjs post6.html 5 nur Slide 6 (nullbasiert: 5) * node animation.mjs post6.html 5 nur Slide 6 (nullbasiert: 5)
* node animation.mjs post9.html animWebinar 1x1 im Format 1:1
* *
* WARUM MP4 UND NICHT GIF * WARUM MP4 UND NICHT GIF
* Instagram nimmt kein GIF an. Was im Feed als "kurzer animierter Beitrag" * Instagram nimmt kein GIF an. Was im Feed als "kurzer animierter Beitrag"
@@ -14,7 +15,11 @@
* DIE WERTE * DIE WERTE
* Behaelter MP4, H.264 High, yuv420p * Behaelter MP4, H.264 High, yuv420p
* Bild 1080 x 1350, also 4:5 wie unsere Standbilder -- ein Carousel * Bild 1080 x 1350, also 4:5 wie unsere Standbilder -- ein Carousel
* schneidet alle Elemente auf das Format des ersten zu * schneidet alle Elemente auf das Format des ersten zu.
* Kennt die Seite ein zweites Format (window.SLIDE_FORMAT, bisher
* nur Post 9 mit 1:1 fuer die Anzeige), gilt dessen Hoehe. Das
* Format kommt aus dem Projektstand oder als drittes Argument,
* die Datei traegt dann ein Kennzeichen (-1x1).
* Bildrate 30 fps * Bildrate 30 fps
* Laenge 3 s ist das Minimum, 60 s je Element das Maximum; wir liegen * Laenge 3 s ist das Minimum, 60 s je Element das Maximum; wir liegen
* bei 5 * bei 5
@@ -40,12 +45,16 @@ const SEITE = process.argv[2] || "post6.html";
Slide mehr und hat deshalb auch keine Position im Streifen, die sich Slide mehr und hat deshalb auch keine Position im Streifen, die sich
verschieben koennte. Ohne Angabe werden alle des Projekts gebaut. */ verschieben koennte. Ohne Angabe werden alle des Projekts gebaut. */
const NUR = process.argv[3] || null; const NUR = process.argv[3] || null;
/* Format, wenn die Seite mehrere kennt. Ohne Angabe gilt der Projektstand. */
const FORMAT = process.argv[4] || null;
const ZUSATZ = FORMAT ? "&format=" + encodeURIComponent(FORMAT) : "";
const PROJEKT = SEITE.replace(/\.html$/, ""); const PROJEKT = SEITE.replace(/\.html$/, "");
const CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; const CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
const PORT = 9530; const PORT = 9530;
const FPS = 30; const FPS = 30;
const BREITE = 1080, HOEHE = 1350; const BREITE = 1080;
let HOEHE = 1350, DATEI_ZUSATZ = "";
const DATENRATE = "12M"; const DATENRATE = "12M";
const ZIEL = path.join(process.cwd(), "animation"); const ZIEL = path.join(process.cwd(), "animation");
@@ -126,14 +135,28 @@ try {
{ width: BREITE, height: HOEHE, deviceScaleFactor: 1, mobile: false }); { width: BREITE, height: HOEHE, deviceScaleFactor: 1, mobile: false });
const laden = async adresse => { const laden = async adresse => {
await cdp.ruf("Page.navigate", { url: "http://127.0.0.1:4321/" + adresse }); const antwort = await cdp.ruf("Page.navigate", { url: "http://127.0.0.1:4321/" + adresse });
/* Laeuft PostGrid nicht auf 4321, laedt die Seite gar nicht. Ohne diese
Pruefung hiess das spaeter nur "kennt keine Animationen". */
if (antwort && antwort.errorText) {
throw new Error("PostGrid ist unter http://127.0.0.1:4321/ nicht erreichbar (" +
antwort.errorText + "). PostGrid mit ./serve.sh starten und den Post dort oeffnen.");
}
await new Promise(r => setTimeout(r, 4000)); await new Promise(r => setTimeout(r, 4000));
}; };
// Erst die Liste holen. Ohne k rendert der Modus nichts, das Skript laeuft // Erst die Liste holen. Ohne k rendert der Modus nichts, das Skript laeuft
// aber vollstaendig durch und window.ANIM_LISTE steht bereit. // aber vollstaendig durch und window.ANIM_LISTE steht bereit.
await laden(SEITE + "?nur=anim"); await laden(SEITE + "?nur=anim" + ZUSATZ);
const alle = await cdp.werte("window.ANIM_LISTE ? window.ANIM_LISTE() : null"); const alle = await cdp.werte("window.ANIM_LISTE ? window.ANIM_LISTE() : null");
/* Das Format der Seite. Posts ohne zweites Format melden nichts und
bleiben bei 4:5. */
const fmt = await cdp.werte("window.SLIDE_FORMAT ? window.SLIDE_FORMAT() : null");
if (fmt) {
HOEHE = fmt.hoehe; DATEI_ZUSATZ = fmt.datei || "";
await cdp.ruf("Emulation.setDeviceMetricsOverride",
{ width: BREITE, height: HOEHE, deviceScaleFactor: 1, mobile: false });
}
if (!alle) throw new Error(SEITE + " kennt keine Animationen (window.ANIM_LISTE fehlt)"); if (!alle) throw new Error(SEITE + " kennt keine Animationen (window.ANIM_LISTE fehlt)");
const liste = alle.filter(a => NUR == null || a.key === NUR); const liste = alle.filter(a => NUR == null || a.key === NUR);
if (!liste.length) throw new Error("keine Animation" + if (!liste.length) throw new Error("keine Animation" +
@@ -144,7 +167,7 @@ try {
for (const a of liste) { for (const a of liste) {
const anzahl = Math.round(a.dauer * FPS); const anzahl = Math.round(a.dauer * FPS);
await laden(SEITE + "?nur=anim&k=" + encodeURIComponent(a.key)); await laden(SEITE + "?nur=anim&k=" + encodeURIComponent(a.key) + ZUSATZ);
/* Nachsehen, dass wirklich ein Blatt dasteht und es die richtigen Masse /* Nachsehen, dass wirklich ein Blatt dasteht und es die richtigen Masse
hat. Ein leeres Fenster faellt sonst erst am fertigen Video auf. */ hat. Ein leeres Fenster faellt sonst erst am fertigen Video auf. */
@@ -182,7 +205,7 @@ try {
"UMLAUT_ERLAUBT. Einen Geviertstrich ersetzt ein Punkt oder ein Komma."); "UMLAUT_ERLAUBT. Einen Geviertstrich ersetzt ein Punkt oder ein Komma.");
const ordner = await mkdtemp(path.join(tmpdir(), "postgrid-bilder-")); const ordner = await mkdtemp(path.join(tmpdir(), "postgrid-bilder-"));
process.stdout.write(` ${a.key}: ${anzahl} Bilder `); process.stdout.write(` ${a.key}: ${anzahl} Bilder, ${BREITE} x ${HOEHE} `);
for (let n = 0; n < anzahl; n++) { for (let n = 0; n < anzahl; n++) {
/* Das letzte Bild ist nicht t = 1, sondern eins davor: bei einer /* Das letzte Bild ist nicht t = 1, sondern eins davor: bei einer
Schleife waeren t = 0 und t = 1 dasselbe Bild und es haekelte kurz. */ Schleife waeren t = 0 und t = 1 dasselbe Bild und es haekelte kurz. */
@@ -192,7 +215,7 @@ try {
Buffer.from(r.data, "base64")); Buffer.from(r.data, "base64"));
if (n % 15 === 0) process.stdout.write("."); if (n % 15 === 0) process.stdout.write(".");
} }
const datei = path.join(ZIEL, PROJEKT + "-" + a.key + ".mp4"); const datei = path.join(ZIEL, PROJEKT + "-" + a.key + DATEI_ZUSATZ + ".mp4");
process.stdout.write(" kodiere "); process.stdout.write(" kodiere ");
await kodieren(ordner, datei, anzahl); await kodieren(ordner, datei, anzahl);
await rm(ordner, { recursive: true, force: true }); await rm(ordner, { recursive: true, force: true });
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 883 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+250
View File
@@ -0,0 +1,250 @@
# Produkt-Komponenten für Posts
**Stand:** 20.10.2026. Quelle: `hivegrid-cloud`, Stand `origin/main` vom 19.10.2026.
**Referenz-Umsetzung:** `post8.html`, Abschnitt „Post 8 · E-Mail-Marketing“.
Diese Datei ist das Nachschlagewerk für jede Zeichnung, die in einem Post die Software zeigt. Die Regel dazu steht in
`rules.md` §0 und gilt ohne Ausnahme.
---
## 1. Die Regel
**Jede Oberfläche in einem Post wird aus den echten Komponenten von hivegrid-cloud nachgebaut, mit ihren Maßen,
Farben, Schriften, Icons und Beschriftungen. Keine eigenen, ausgedachten Grafiken.**
- Jede Zeichnung bildet einen Screen nach, den es im Produkt gibt. Im Kommentar steht die Datei, aus der er stammt.
- Beschriftungen stehen wörtlich in `apps/web/public/locales/de/translations.json`.
- Was im Produkt nicht existiert, wird nicht gezeichnet. Auch nicht, wenn es gut aussähe.
- Was nicht auf die Slide passt, wird weggelassen, nicht umgebaut. Im Kommentar steht, was fehlt und warum.
- Erfunden werden darf nur Demo-Inhalt: Namen, Betreffzeilen, Texte, Daten, Zahlen. Die Zahlen müssen aufgehen.
**Warum:** Die erste Fassung von Post 8 hatte ein eigenes Design (Pillen mit Rand, Häkchen-Chips, Reiter und Kacheln, die
es im Produkt nicht gibt). Dejan: „sieht total vibecoded aus“. Nach dem Umbau auf die Komponenten: „sieht jetzt genau so
aus wie die Software“ (20.10.2026).
---
## 2. So gehst du vor
1. **Screens finden.** Für das Thema des Posts das Modul in hivegrid-cloud suchen:
`apps/web/app/components/screens/<modul>/`, `apps/web/app/modules/<modul>/`, die Routen unter
`apps/web/app/routes/app.$tenant/`, die Texte in `translations.json`.
2. **Immer `origin/main` lesen, nie die Arbeitskopie.** Die lokale Kopie von hivegrid-cloud ist veraltet und wird nicht
angefasst (kein pull, kein checkout ohne Dejan).
- Einzelne Datei: `git -C ../hivegrid-cloud show origin/main:<pfad>`
- Für viele Dateien ein Schnappschuss ins Scratchpad:
`git -C ../hivegrid-cloud archive origin/main apps/web/app apps/web/public/locales/de | tar -x -C <scratchpad>/hg-main`
- Vorher `git -C ../hivegrid-cloud fetch` (holt nur, ändert nichts).
3. **Für jede Slide einen echten Screen wählen.** Aufbau von oben nach unten aus dem Code lesen: Kopf, Knöpfe, Tabs,
Felder, Tabellenspalten, welche Badge für welchen Status.
4. **Mit den Bausteinen zeichnen** (Abschnitt 3). Sie stehen in `post8.html` und werden in den neuen Post kopiert, weil
jeder Post eine einzelne Datei ist.
5. **Prüfen:**
- Streifen-Ansicht `?nur=streifen`: Die Karte darf nicht in den Text der Slide ragen.
- PNG-Export: Die Oberfläche muss in Instrument Sans stehen, nicht in einer Ersatzschrift (siehe Fallen).
- MP4 mit `node animation.mjs postN.html <key>`.
---
## 3. Bausteine in `post8.html`
Alle Werte sind Produkt-Pixel und werden mit `PZ = 1.15` vergrößert, wie ein Browserzoom. Koordinaten sind Pixel der
Zeichnung. Nur Schriften, Icons und Komponentenhöhen laufen über `pz()`.
| Baustein | Bildet nach | Aufruf |
|---|---|---|
| `P`, `P_TON`, `P_KNOPF`, `P_KARTE` | Rollen aus `ui/theme.css` | Farbkonstanten |
| `pText(x, y, t, gr, gew, farbe, o)` | Text in Instrument Sans | `o.anker` für Ausrichtung |
| `pMess(t, gr, gew)` | Textbreite in Instrument Sans | |
| `pMitte(ym, gr)` | Grundlinie für Text mittig in einem Kasten | |
| `pIkon(name, cx, cy, gr, farbe)` | Feather/Lucide-Icon, Strich 2 auf 24 | Namen aus `LUCIDE` |
| `pKarte(x, y, b, h, art, radius)` | `Card`, `InlineMessage` | `art`: std, weiss, neutral, info, warnung |
| `pBadge(x, ym, t, ton, klein)` | `BadgeFeedback` subtle | `ton`: positiv, warnung, info, neutral |
| `pKnopf(x, y, t, art, o)` | `Button` | `art`: primaer, sekundaer, tertiaer, geist, aus; `o.ikon`, `o.ikonRechts`, `o.klein` |
| `pFeld(x, y, b, o)` | `TextInput`, `Select`, `TextArea` | `o.label`, `o.wert`, `o.platzhalter`, `o.auswahl`, `o.hoch`, `o.hilfe` |
| `pTabs(x, y, b, reiter, aktiv, innen)` | `Tabs` | |
| `pUmschalter(xRechts, y, eintraege, aktiv)` | `ToggleGroup` klein | Einträge `[icon, text]` |
| `pTabelle(x, y, spalten, zeilen, zh, zelle)` | `Table` | Spalten `[titel, icon, breite]` |
| `pRahmen(h, inhalt, kopf)` | Seite mit `PageHeader` | `kopf.ikon`, `kopf.titel`, `kopf.badge`, `kopf.ikonKasten`, `kopf.rechts` |
| `pKopfKnopf(t, art, o)` | Knopf rechts im Kopf | |
| `emKpi(...)` | `WidgetRahmen` mit `KPICard` | |
**Icons:** `LUCIDE` enthält die Formen aus `lucide-static@0.460.0`. Subframes `Feather*`-Icons haben dieselben Formen.
Fehlt ein Icon, die SVG von `https://cdn.jsdelivr.net/npm/lucide-static@0.460.0/icons/<name>.svg` holen und das Innere
der `<svg>` in die Tabelle eintragen. Phosphor (`ikon()`) gehört nur noch in ältere Posts, nicht in Oberflächen.
**Schrift:** Die Dateien liegen in `assets/fonts/instrument-sans-*.woff2`, dieselben wie in
`hivegrid-cloud/apps/web/public/fonts/`. Der Post braucht dafür drei Dinge:
1. den `@font-face`-Block im `<head>`
2. beim Start `document.fonts.load(...)` für 400, 500 und 600, bevor zum ersten Mal gemessen wird
3. `instrumentSansCss()`, angehängt an `fontEmbedCss`, sonst fehlt die Schrift im PNG
Alles drei steht in `post8.html`.
---
## 4. Tokens, aufgelöst
### Schrift
Instrument Sans, variabel 400 bis 700. Laufweite überall 0.
| Token | px / Zeilenhöhe / Gewicht | Wofür |
|---|---|---|
| `subtitle` | 32/40/600 | Seitentitel im `PageHeader`, Wert in `StatsCard` |
| `heading-` | 24/32/500 | Dialogtitel |
| `body-large` | 20/24 (‑0: 26)/500 | Abschnitte wie „E-Mail gestalten“, EmptyState-Titel |
| `body-default-regular` | 16/24 (‑0: 22)/400 | **Knopfbeschriftung**, Eingaben, Fließtext |
| `body-default-medium` | 16/24/500 | Feldbeschriftungen |
| `body-default-semibold` | 16/24/600 | Kartentitel |
| `body-small-regular` | 14/21 (‑0: 18)/400 | Hilfetexte, Tabs, Seitenleiste |
| `body-small-medium` | 14/21/500 | Badges, Menüeinträge, Checkbox-Beschriftung |
| `body-extra-small` | 12/18 (‑0: 16)/400 bis 600 | Tabellenkopf (500), Tooltip |
`text-heading-2` und `text-heading-3` werden im Code benutzt, sind aber nirgends definiert. Sie rendern als 16/24/400.
### Farben
**Neutral:** 0 `#FAFAFA` · 50 `#F5F4F4` · 100 `#F0F0F0` · 200 `#E5E5E5` · 300 `#CECECF` · 400 `#B5B5B7` · 500 `#9D9DA0` ·
600 `#858589` · 700 `#6D6D71` · 800 `#505057` · 850 `#37373E` · 900 `#24242B` · 950 `#111114`
| Rolle | Wert |
|---|---|
| Seite (`surface-neutral-primary`) | `#F5F4F4` |
| Karte, Kopfzeile, Tabellenzeile (`secondary`) | `#FAFAFA` |
| Tabellenkopf, Hover (`tertiary`) | `#F0F0F0` |
| Rand, Rand stark | `#E5E5E5`, `#B5B5B7` (Eingabefelder) |
| Text primär, sekundär, tertiär | `#24242B`, `#505057`, `#6D6D71` |
| Platzhalter, deaktiviert | `#CECECF` |
| Link | `#08739C` |
| Icons primär, sekundär, tertiär | `#24242B`, `#505057`, `#858589` |
| Akzent für aktiv und ausgewählt | `#E1F4FD` (information subtle) |
| Sekundärknopf | `#E3F1FF` |
| Ton | 100 | 200 | 500 | 600 | 700 |
|---|---|---|---|---|---|
| Erfolg (grün) | `#DCFCE4` | `#BAF8CB` | `#20C54F` | `#15A43E` | `#148134` |
| Warnung (gelb, nicht Marke) | `#FCF6C5` | `#FAEB8E` | `#E1AB11` | `#C2840C` | `#9B5E0D` |
| Fehler (rot) | `#FEE2E2` | `#FDCBCB` | `#ED4646` | `#DA2828` | `#C52020` |
| Information (ocean) | `#E1F4FD` | `#BCEAFB` | `#15B1E2` | `#0890C1` | `#08739C` |
**Kein Markengelb in der Oberfläche.** Die Rolle „brand“ ist im Produkt auf Grau gemappt, Primärknöpfe sind `#24242B`.
Gelb gehört auf der Slide nur zur Überschrift und zu Marken-Elementen außerhalb der Oberfläche.
### Radien und Schatten
- `rounded-sm` 4 px (Eingabefelder) · `rounded-md` **6 px** (Knöpfe, Karten, Tabs) · `rounded-lg` 8 px (Widgets) ·
`rounded-xl` 12 px. In `theme.css` steht md = 8, `globals.css` überschreibt das danach mit 6.
- Schatten im Produkt: sm `0 1px 2px rgb(0 0 0 / .08)`, md und lg größer. In den Zeichnungen lassen wir sie weg
(rules.md §5). Der Stapel um die Karte hat seinen eigenen Schatten und bleibt.
---
## 5. Komponenten
| Komponente | Maße und Farben |
|---|---|
| **Button** | klein h32, px12, Abstand 8 · mittel h36, px16, Abstand 12 · groß h48. Radius md. Beschriftung 16/400, Icon 16. primär `#24242B`/`#FAFAFA` · sekundär `#E3F1FF`/`#24242B` · tertiär `#F5F4F4` mit Rand `#E5E5E5` · ghost transparent · information `#0890C1` · destructive `#DA2828` · deaktiviert `#F0F0F0`/`#CECECF` |
| **IconButton** | rund, quadratisch mit `square` (md). 24/32/36/48 Kasten, Icon 16/20/20/24 |
| **BadgeFeedback** (alle Status) | Pille **ohne Rand**, h24 (klein 20), px10, Abstand 8, Punkt 4 px, Schrift 14/500. positiv `#DCFCE4`, Text und Punkt `#148134` · Warnung `#FCF6C5`, Text `#24242B`, Punkt `#9B5E0D` · information `#E1F4FD`, Text `#24242B`, Punkt `#08739C` · neutral `#F0F0F0`, Text und Punkt `#24242B` · negativ `#FEE2E2`, `#C52020` |
| **Badge** (Farbtöne) | Pille h24 mit 1-px-Rand, sonst wie oben. Standard `#F0F0F0`, Rand `#E5E5E5` |
| **Tabs** | Reiter px16, py10 (rund 40 hoch), oben rund, `#FAFAFA`, unten 2 px `#E5E5E5`, Schrift 14/400 `#505057`. **Aktiv:** Fläche `#E1F4FD`, unten 2 px `#24242B`, Schrift 14/500 `#24242B` |
| **TextInput / Select** | h36, px12, Radius 4, Rand `#B5B5B7`, Fläche `#F5F4F4`. Beschriftung 16/500 `#505057`, 6 px darüber. Wert 16 `#505057`, Platzhalter `#CECECF`, Hilfe 14 `#6D6D71`. Pfeil 16 `#858589` |
| **TextArea** | wie oben, mindestens 96 hoch, Innenabstand 12×8 |
| **Checkbox** | 16, Radius 4, Rand 2 px `#B5B5B7`. An: `#24242B` mit Haken `#FAFAFA`. Beschriftung 14/500 |
| **Switch** | 36×22, rund. Aus: `#F5F4F4` mit dunklem Knopf. An: `#24242B` mit hellem Knopf |
| **Table** | Außenrand `#E5E5E5`. Kopf h32, `#F0F0F0`, Schrift 12/500 `#505057`, Icon 12 `#858589`. Zeilen `#FAFAFA` mit Trennlinien `#E5E5E5`, Höhe aus dem Inhalt (rund 31) |
| **Avatar** | rund, 24/32/40/48/64. Ohne Bild `#F0F0F0`, Kürzel 16/500 `#505057` |
| **IconWithBackground** | Radius 4. Kasten 22/24/28/32/40, Icon 14/16/20/24/32. information `#E1F4FD` mit `#08739C` |
| **Card** | Radius md, Rand `#E5E5E5`, Fläche `#FAFAFA`, Innenabstand 16 (lg 24). Varianten information, warning, negative, positive, neutral |
| **InlineMessage** | Card in Ton-Variante, px16, py12, Text 16 `#24242B`. Standard information |
| **Alert** | Radius md, Rand, px16, py20, Icon 24. Titel 16/500 im Ton, Text 16 `#505057` |
| **Widget** (`WidgetRahmen`) | Radius lg, Rand, weiß. Kopf h44 mit Titel 13/600. KPI: Wert halbfett, Fußzeile 11 `#6D6D71` über einer Linie |
| **ToggleGroup** | h36 (klein 32), Innenabstand 2, Rand, `#F0F0F0`. Aktiv: `#24242B` mit Schrift `#FAFAFA` |
| **DropdownMenu** | `#FAFAFA`, Rand, Radius md, Innenabstand 4. Einträge 14/500 `#6D6D71`, Hover `#F0F0F0` |
| **Tooltip** | `#505057`, Schrift 12/500 `#FAFAFA`, Radius 4 |
| **Progress** | 6 px, rund, Spur `#F0F0F0`, Füllung `#24242B` |
| **EmptyState** | Titel 20/500, Text 16 `#505057`. Umrandet: Rand und `#F5F4F4` |
| **Dialog** | weiß, Rand, Radius md, Overlay `rgb(0 0 0 / .4)`. DecisionDialog 420 oder 560 breit, Titel 24/500 |
### App-Rahmen
- **Seite:** Hintergrund `#F5F4F4`. Kopfleiste h64, `#FAFAFA`, Linie unten.
- **PageHeader:** `#FAFAFA`, px24, py20, Linie unten. Icon 24 in `#858589`, Abstand 8, Titel 32/600 `#24242B`.
Knöpfe rechts mit Abstand 8. In den Zeichnungen Titel 24, weil die Karte schmaler ist als ein Bildschirm.
- **Filterleiste:** `#F5F4F4`, px24, py10. Suchfeld `#FAFAFA`, Facetten-Chips h24 mit Rand `#B5B5B7`. Aktiv `#BCEAFB`.
- **Seitenleiste:** 240 breit, `#FAFAFA`. Einträge h40, Icon 20 mit Strich 1.5, Schrift 14. Gewählt: `#E1F4FD`.
---
## 6. Fallen
1. **Schrift im PNG.** html-to-image bettet nur die Schrift ein, die im HTML vorkommt, also Manrope. Instrument Sans
steckt nur im SVG und fehlte im Export. Lösung: `instrumentSansCss()` an `fontEmbedCss` hängen. Prüfen, indem man
Export und Vorschau übereinanderlegt.
2. **Messen vor dem Laden.** `pMess()` speichert Breiten zwischen. Wird vor dem Laden der Schrift gemessen, bleibt die
Breite der Ersatzschrift hängen. Darum `document.fonts.load` am Anfang von `boot()`.
3. **Was in den Übersetzungen steht, ist nicht automatisch zu sehen.** Beispiel: „Pre-Send-Prüfung“ und die sechs
Prüfpunkte stehen in `translations.json`, angezeigt werden sie nirgends. Immer im Screen-Code nachsehen, ob ein Text
wirklich gerendert wird.
4. **Website und Produkt unterscheiden.** Die Website zeigt manches, was es im Produkt nicht gibt (in Post 8 die
Produktkacheln im Mail-Baukasten). Maßgeblich ist das Produkt.
5. **Höhe der Karte.** Zu hohe Karten ragen in den Text der Slide. Lieber Schritte oder Hilfetexte weglassen als die
Komponenten zu verkleinern.
6. **Feste Höhe im Bewegtbild.** Die Höhe der Zeichnung darf nicht vom Zeitpunkt abhängen, sonst ändert sich der
viewBox und alles springt.
---
## 7. Beispiel: E-Mail-Marketing (Post 8)
| Slide | Screen | Datei in `apps/web/app/components/screens/email-marketing/` |
|---|---|---|
| Bewegtbild | Kampagnenbericht | `EmailMarketingDetailScreens.tsx` |
| Zielgruppe | Dialog „Zielgruppe bearbeiten“ | `SegmentEditorDialog.tsx`, `SegmentFilterBuilder.tsx` |
| Vorlagen | Vorlagenseite | `EmailMarketingTemplateScreens.tsx` |
| Rechtssicher | „Empfänger & Versand“ mit „Bereit zum Versand?“ | `EmailMarketingCampaignEditorPage.tsx` |
| Baukasten | Gestaltung | `CampaignContentBuilder.tsx`, `CampaignBuilderSidebar.tsx`, `campaign-mail-look.ts` |
Status-Badges dort: „Gesendet“ positiv · „Geplant“, „Pausiert“ Warnung · „Wird gesendet“ information · „Entwurf“,
„Archiviert“ neutral. Empfänger: „Zugestellt“, „Geöffnet“, „Geklickt“ positiv · „Ausstehend“ Warnung · „Gebounced“
negativ. Vorlagen: „Standard“ information · „Eigen“ neutral. Versandbereitschaft: „Erledigt“ positiv · „Offen“ neutral.
---
## 8. Beispiel: Form Builder (Post 7)
Seit 20.10.2026 nach dem echten Editor gebaut. Vorher kam Post 7 aus dem Klickdummy des Pitch-Pakets
(`S02EinfacherFragenEditor.tsx`) und zeigte Dinge, die es nicht gibt.
| Slide | Screen | Datei in `apps/web/app/components/screens/forms/` |
|---|---|---|
| Bewegtbild | Reiter „Fragen“, neue Frage klappt auf | `editor/questions-tab/QuestionsTabView.tsx`, `SortableQuestionCard.tsx` |
| Die Fragen | zugeklappte Fragekarten, Werkzeugleiste | `SortableQuestionCard.tsx:121`, `QuestionsTabView.tsx` |
| Bedingt sichtbar | offene Frage mit „Nur anzeigen, wenn“ | `editor/questions-tab/QuestionEditors.tsx:123` |
| Lead | Ansicht „Felder zuweisen“ | `editor/LeadBanner.tsx`, `editor/LeadMappingView.tsx` |
- **Kopf** (`FormEditorPage.tsx:411`): kein PageHeader, eine eigene Zeile mit `clipboard-list`, Name 20/500, Badge
„Entwurf“ (neutral), „Veröffentlicht“ (positiv) oder „Änderungen nicht live“ (Warnung). Rechts klein: „Einbetten & QR“
(sekundär, nur extern), „Vorschau“ (tertiär), „Veröffentlichen“ (primär), ⋯. Autosave „Automatisch gespeichert“ steht
dort und blendet nach 3 s aus.
- **Reiter:** „Fragen“ (`list-checks`), „Design“ (`palette`), „Antworten“ (`inbox`), gleich breit.
- **Fragekarte zu:** Radius 8, px20 py16, Griff, Typ-Icon, Titel 16/500 mit rotem `*` bei Pflicht, darunter der Typ
und bei optionalen „ · optional“. Kopieren grau, Löschen rot `#C52020`. Bedingte Fragen: Badge „Bedingt sichtbar“
klein neutral.
- **Fragekarte offen:** kein farbiger Rand, sondern 6-px-Streifen links in `#0791F0`. Optionen nur hier.
- **Feldtypen:** Text, Mehrzeiliger Text, Auswahl, Mehrfachauswahl, Checkbox, E-Mail, Telefon, Zahl, Datum, Uhrzeit,
Datum und Uhrzeit, Datei (`paperclip`), Foto (Kamera, nur intern), Unterschrift. **Nicht:** „Kurzantwort“, „Absatz“,
„Multiple-Choice-Frage“.
- **„Frage hinzufügen“:** gestrichelte Fläche, 2 px `#B5B5B7`, Icon 24, Schrift 20/500. Neue Frage: Typ „Text“, Name
„Unbenannte Frage“, neue Optionen „Auswahl A“, „Auswahl B“.
- **Lead:** Banner „Lead-Verknüpfung“ mit „Aktiv“, Text „Neue Antworten werden als Lead gespeichert.“, Knöpfe „Felder
zuweisen“ (primär) und „Deaktivieren“ (tertiär). **Eine Pipeline gibt es im Form Builder nicht.**
- **Gibt es nicht:** eine Fußzeile, „Zuletzt gespeichert vor wenigen Sekunden“, „Bereit zum Veröffentlichen“.
Bausteine dazu in `post7.html`: `fbRahmen`, `fbFrage`, `fbLeiste`, `fbNeu`, `fbFeld`, `fbOffenKopf`, `fbOffenKarte`,
`fbOption`, `fbOptionNeu`, `fbSchalter`, `pKurz` (Text mit „…“ kürzen).
## 9. PostGrid-Eigenheit: gleiche Höhe im Carousel
Alle Grafiken eines Posts bekommen dieselbe SVG-Höhe, damit die Karten auf allen Slides oben auf einer Linie stehen.
Eine kürzere Karte lässt deshalb unten Platz frei. Das ist gewollt. Zu hoch darf keine werden: Sie ragt sonst in den
Text. Das Bewegtbild ohne Fließtext hat ein eigenes `visBottom`.
+8 -1
View File
@@ -805,7 +805,14 @@ if (location.search.indexOf("nur=streifen") >= 0)
<button type="button" data-gehe="./post6.html">Post 6 · Team-Chat</button> <button type="button" data-gehe="./post6.html">Post 6 · Team-Chat</button>
<button type="button" data-gehe="./post7.html">Post 7 · Form Builder</button> <button type="button" data-gehe="./post7.html">Post 7 · Form Builder</button>
<button type="button" data-gehe="./post8.html">Post 8 · E-Mail-Marketing</button> <button type="button" data-gehe="./post8.html">Post 8 · E-Mail-Marketing</button>
<button type="button" data-gehe="./post9.html">Post 9 · Webinar</button> <button type="button" data-gehe="./post9.html">Post 9 · Variante 1 · Webinar-Fenster</button>
<button type="button" data-gehe="./post9-v2.html">Post 9 · Variante 2 · Kundengeschichte</button>
<button type="button" data-gehe="./post9-v3.html">Post 9 · Variante 3 · Kundengeschichte dunkel</button>
<button type="button" data-gehe="./post9-v4.html">Post 9 · Variante 4 · Zahlen</button>
<button type="button" data-gehe="./post9-v5.html">Post 9 · Variante 5 · Zahlen dunkel</button>
<button type="button" data-gehe="./post9-v6.html">Post 9 · Variante 6 · Arbeitsheft</button>
<button type="button" data-gehe="./post9-v7.html">Post 9 · Variante 7 · Arbeitsheft dunkel</button>
<button type="button" data-gehe="./post9-varianten.html">Post 9 · Varianten im Vergleich</button>
<hr> <hr>
<button type="button" data-gehe="./uebersicht.html">Alle Projekte im Streifen</button> <button type="button" data-gehe="./uebersicht.html">Alle Projekte im Streifen</button>
<hr> <hr>
+8 -1
View File
@@ -1108,7 +1108,14 @@ if (location.search.indexOf("nur=anim") >= 0)
<button type="button" data-gehe="./post6.html">Post 6 · Team-Chat</button> <button type="button" data-gehe="./post6.html">Post 6 · Team-Chat</button>
<button type="button" data-gehe="./post7.html">Post 7 · Form Builder</button> <button type="button" data-gehe="./post7.html">Post 7 · Form Builder</button>
<button type="button" data-gehe="./post8.html">Post 8 · E-Mail-Marketing</button> <button type="button" data-gehe="./post8.html">Post 8 · E-Mail-Marketing</button>
<button type="button" data-gehe="./post9.html">Post 9 · Webinar</button> <button type="button" data-gehe="./post9.html">Post 9 · Variante 1 · Webinar-Fenster</button>
<button type="button" data-gehe="./post9-v2.html">Post 9 · Variante 2 · Kundengeschichte</button>
<button type="button" data-gehe="./post9-v3.html">Post 9 · Variante 3 · Kundengeschichte dunkel</button>
<button type="button" data-gehe="./post9-v4.html">Post 9 · Variante 4 · Zahlen</button>
<button type="button" data-gehe="./post9-v5.html">Post 9 · Variante 5 · Zahlen dunkel</button>
<button type="button" data-gehe="./post9-v6.html">Post 9 · Variante 6 · Arbeitsheft</button>
<button type="button" data-gehe="./post9-v7.html">Post 9 · Variante 7 · Arbeitsheft dunkel</button>
<button type="button" data-gehe="./post9-varianten.html">Post 9 · Varianten im Vergleich</button>
<hr> <hr>
<button type="button" data-gehe="./uebersicht.html">Alle Projekte im Streifen</button> <button type="button" data-gehe="./uebersicht.html">Alle Projekte im Streifen</button>
<hr> <hr>
+8 -1
View File
@@ -1001,7 +1001,14 @@ if (location.search.indexOf("nur=anim") >= 0)
<button type="button" data-gehe="./post6.html">Post 6 · Team-Chat</button> <button type="button" data-gehe="./post6.html">Post 6 · Team-Chat</button>
<button type="button" data-gehe="./post7.html">Post 7 · Form Builder</button> <button type="button" data-gehe="./post7.html">Post 7 · Form Builder</button>
<button type="button" data-gehe="./post8.html">Post 8 · E-Mail-Marketing</button> <button type="button" data-gehe="./post8.html">Post 8 · E-Mail-Marketing</button>
<button type="button" data-gehe="./post9.html">Post 9 · Webinar</button> <button type="button" data-gehe="./post9.html">Post 9 · Variante 1 · Webinar-Fenster</button>
<button type="button" data-gehe="./post9-v2.html">Post 9 · Variante 2 · Kundengeschichte</button>
<button type="button" data-gehe="./post9-v3.html">Post 9 · Variante 3 · Kundengeschichte dunkel</button>
<button type="button" data-gehe="./post9-v4.html">Post 9 · Variante 4 · Zahlen</button>
<button type="button" data-gehe="./post9-v5.html">Post 9 · Variante 5 · Zahlen dunkel</button>
<button type="button" data-gehe="./post9-v6.html">Post 9 · Variante 6 · Arbeitsheft</button>
<button type="button" data-gehe="./post9-v7.html">Post 9 · Variante 7 · Arbeitsheft dunkel</button>
<button type="button" data-gehe="./post9-varianten.html">Post 9 · Varianten im Vergleich</button>
<hr> <hr>
<button type="button" data-gehe="./uebersicht.html">Alle Projekte im Streifen</button> <button type="button" data-gehe="./uebersicht.html">Alle Projekte im Streifen</button>
<hr> <hr>
+8 -1
View File
@@ -1021,7 +1021,14 @@ if (location.search.indexOf("nur=anim") >= 0)
<button type="button" data-gehe="./post6.html">Post 6 · Team-Chat</button> <button type="button" data-gehe="./post6.html">Post 6 · Team-Chat</button>
<button type="button" data-gehe="./post7.html">Post 7 · Form Builder</button> <button type="button" data-gehe="./post7.html">Post 7 · Form Builder</button>
<button type="button" data-gehe="./post8.html">Post 8 · E-Mail-Marketing</button> <button type="button" data-gehe="./post8.html">Post 8 · E-Mail-Marketing</button>
<button type="button" data-gehe="./post9.html">Post 9 · Webinar</button> <button type="button" data-gehe="./post9.html">Post 9 · Variante 1 · Webinar-Fenster</button>
<button type="button" data-gehe="./post9-v2.html">Post 9 · Variante 2 · Kundengeschichte</button>
<button type="button" data-gehe="./post9-v3.html">Post 9 · Variante 3 · Kundengeschichte dunkel</button>
<button type="button" data-gehe="./post9-v4.html">Post 9 · Variante 4 · Zahlen</button>
<button type="button" data-gehe="./post9-v5.html">Post 9 · Variante 5 · Zahlen dunkel</button>
<button type="button" data-gehe="./post9-v6.html">Post 9 · Variante 6 · Arbeitsheft</button>
<button type="button" data-gehe="./post9-v7.html">Post 9 · Variante 7 · Arbeitsheft dunkel</button>
<button type="button" data-gehe="./post9-varianten.html">Post 9 · Varianten im Vergleich</button>
<hr> <hr>
<button type="button" data-gehe="./uebersicht.html">Alle Projekte im Streifen</button> <button type="button" data-gehe="./uebersicht.html">Alle Projekte im Streifen</button>
<hr> <hr>
+8 -1
View File
@@ -1049,7 +1049,14 @@ if (location.search.indexOf("nur=anim") >= 0)
<button type="button" data-gehe="./post6.html">Post 6 · Team-Chat</button> <button type="button" data-gehe="./post6.html">Post 6 · Team-Chat</button>
<button type="button" data-gehe="./post7.html">Post 7 · Form Builder</button> <button type="button" data-gehe="./post7.html">Post 7 · Form Builder</button>
<button type="button" data-gehe="./post8.html">Post 8 · E-Mail-Marketing</button> <button type="button" data-gehe="./post8.html">Post 8 · E-Mail-Marketing</button>
<button type="button" data-gehe="./post9.html">Post 9 · Webinar</button> <button type="button" data-gehe="./post9.html">Post 9 · Variante 1 · Webinar-Fenster</button>
<button type="button" data-gehe="./post9-v2.html">Post 9 · Variante 2 · Kundengeschichte</button>
<button type="button" data-gehe="./post9-v3.html">Post 9 · Variante 3 · Kundengeschichte dunkel</button>
<button type="button" data-gehe="./post9-v4.html">Post 9 · Variante 4 · Zahlen</button>
<button type="button" data-gehe="./post9-v5.html">Post 9 · Variante 5 · Zahlen dunkel</button>
<button type="button" data-gehe="./post9-v6.html">Post 9 · Variante 6 · Arbeitsheft</button>
<button type="button" data-gehe="./post9-v7.html">Post 9 · Variante 7 · Arbeitsheft dunkel</button>
<button type="button" data-gehe="./post9-varianten.html">Post 9 · Varianten im Vergleich</button>
<hr> <hr>
<button type="button" data-gehe="./uebersicht.html">Alle Projekte im Streifen</button> <button type="button" data-gehe="./uebersicht.html">Alle Projekte im Streifen</button>
<hr> <hr>
+8 -1
View File
@@ -1049,7 +1049,14 @@ if (location.search.indexOf("nur=anim") >= 0)
<button type="button" data-gehe="./post6.html">Post 6 · Team-Chat</button> <button type="button" data-gehe="./post6.html">Post 6 · Team-Chat</button>
<button type="button" data-gehe="./post7.html">Post 7 · Form Builder</button> <button type="button" data-gehe="./post7.html">Post 7 · Form Builder</button>
<button type="button" data-gehe="./post8.html">Post 8 · E-Mail-Marketing</button> <button type="button" data-gehe="./post8.html">Post 8 · E-Mail-Marketing</button>
<button type="button" data-gehe="./post9.html">Post 9 · Webinar</button> <button type="button" data-gehe="./post9.html">Post 9 · Variante 1 · Webinar-Fenster</button>
<button type="button" data-gehe="./post9-v2.html">Post 9 · Variante 2 · Kundengeschichte</button>
<button type="button" data-gehe="./post9-v3.html">Post 9 · Variante 3 · Kundengeschichte dunkel</button>
<button type="button" data-gehe="./post9-v4.html">Post 9 · Variante 4 · Zahlen</button>
<button type="button" data-gehe="./post9-v5.html">Post 9 · Variante 5 · Zahlen dunkel</button>
<button type="button" data-gehe="./post9-v6.html">Post 9 · Variante 6 · Arbeitsheft</button>
<button type="button" data-gehe="./post9-v7.html">Post 9 · Variante 7 · Arbeitsheft dunkel</button>
<button type="button" data-gehe="./post9-varianten.html">Post 9 · Varianten im Vergleich</button>
<hr> <hr>
<button type="button" data-gehe="./uebersicht.html">Alle Projekte im Streifen</button> <button type="button" data-gehe="./uebersicht.html">Alle Projekte im Streifen</button>
<hr> <hr>
+8 -1
View File
@@ -1053,7 +1053,14 @@ if (location.search.indexOf("nur=anim") >= 0)
<button type="button" data-gehe="./post6.html">Post 6 · Team-Chat</button> <button type="button" data-gehe="./post6.html">Post 6 · Team-Chat</button>
<button type="button" data-gehe="./post7.html">Post 7 · Form Builder</button> <button type="button" data-gehe="./post7.html">Post 7 · Form Builder</button>
<button type="button" data-gehe="./post8.html">Post 8 · E-Mail-Marketing</button> <button type="button" data-gehe="./post8.html">Post 8 · E-Mail-Marketing</button>
<button type="button" data-gehe="./post9.html">Post 9 · Webinar</button> <button type="button" data-gehe="./post9.html">Post 9 · Variante 1 · Webinar-Fenster</button>
<button type="button" data-gehe="./post9-v2.html">Post 9 · Variante 2 · Kundengeschichte</button>
<button type="button" data-gehe="./post9-v3.html">Post 9 · Variante 3 · Kundengeschichte dunkel</button>
<button type="button" data-gehe="./post9-v4.html">Post 9 · Variante 4 · Zahlen</button>
<button type="button" data-gehe="./post9-v5.html">Post 9 · Variante 5 · Zahlen dunkel</button>
<button type="button" data-gehe="./post9-v6.html">Post 9 · Variante 6 · Arbeitsheft</button>
<button type="button" data-gehe="./post9-v7.html">Post 9 · Variante 7 · Arbeitsheft dunkel</button>
<button type="button" data-gehe="./post9-varianten.html">Post 9 · Varianten im Vergleich</button>
<hr> <hr>
<button type="button" data-gehe="./uebersicht.html">Alle Projekte im Streifen</button> <button type="button" data-gehe="./uebersicht.html">Alle Projekte im Streifen</button>
<hr> <hr>
+704 -240
View File
File diff suppressed because it is too large. Load diff
+670 -535
View File
File diff suppressed because it is too large. Load diff
+111
View File
@@ -0,0 +1,111 @@
// Legt alle Varianten von Post 9 ins Marketing-Hub, damit Noah sie auf
// hivegrid-marketing.pages.dev sieht (Dejan, 04.11.2026). Die Seiten in
// PostGrid laufen nur lokal, deshalb kommen dort Bilder und Videos an:
//
// jede Slide als PNG, 1080 x 1080, so wie der Streifen sie zeigt
// die animierten Slides als MP4 aus animation/
// varianten.json mit Reihenfolge und Texten fuer die Webinar-Seite
//
// Aufruf, wenn PostGrid laeuft (./serve.sh): node post9-hub.mjs
// Danach im Hub committen, pushen und ./veroeffentlichen.sh.
//
// Vorher die Animationen neu bauen, wenn sich an ihnen etwas geaendert hat:
// node animation.mjs post9.html animWebinar 1x1
// node animation.mjs post9-v4.html animBuerokratie 1x1 (ebenso animKi, v5)
import { spawn } from "node:child_process";
import { writeFile, mkdir, copyFile, rm, readdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
const HUB = path.resolve("../HiveGrid Marketing /webinar/anzeigen/post9");
const POSTGRID = "http://127.0.0.1:4321/";
/* Reihenfolge im Carousel. "bild:n" ist Slide n des Streifens, "video:x"
eine Datei aus animation/. In Variante 1 steht das Video vorn, in 4 und 5
ersetzen die Videos Slide 2 und 3 des Streifens (dort steht ihr Endstand). */
const VARIANTEN = [
{ nr: 1, datei: "post9.html", name: "Webinar-Fenster",
text: "Das Bewegtbild zeigt das Webinar mit seinen Teilen und beiden Sprechern, danach die Anmeldung.",
folge: ["video:post9-animWebinar-1x1.mp4", "bild:0"] },
{ nr: 2, datei: "post9-v2.html", name: "Kundengeschichte",
text: "Angelehnt an ein Karussell von weclapp. Noah als Gesicht, dann vorher und heute am Beispiel „Angebot nachfassen“, dann die Anmeldung.",
folge: ["bild:0", "bild:1", "bild:2", "bild:3", "bild:4"] },
{ nr: 3, datei: "post9-v3.html", name: "Kundengeschichte dunkel",
text: "Wie Variante 2, auf dunklem Grund.",
folge: ["bild:0", "bild:1", "bild:2", "bild:3", "bild:4"] },
{ nr: 4, datei: "post9-v4.html", name: "Zahlen",
text: "Angelehnt an HERO Insights. Noah als Teaser, dann drei Zahlen aus Studien zur Zeit im Handwerk, dann die Anmeldung. Slide 1 bis 3 sind kurze Videos.",
folge: ["video:post9-v4-animTitel-1x1.mp4", "video:post9-v4-animBuerokratie-1x1.mp4", "video:post9-v4-animKi-1x1.mp4", "bild:3", "bild:4"] },
{ nr: 5, datei: "post9-v5.html", name: "Zahlen dunkel",
text: "Wie Variante 4, auf dunklem Grund.",
folge: ["video:post9-v5-animTitel-1x1.mp4", "video:post9-v5-animBuerokratie-1x1.mp4", "video:post9-v5-animKi-1x1.mp4", "bild:3", "bild:4"] },
{ nr: 6, datei: "post9-v6.html", name: "Arbeitsheft",
text: "Aussagen und Zeichnungen aus Noahs Arbeitsheft „Jede Frage lief über meinen Tisch“, im Look des Hefts, der Titel dunkel wie der Umschlag. Vom Engpass über die Inseln und den Bewegungsmelder bis zu den zehn Handgriffen, dann die Anmeldung. Auf dem Titel werden die Pfeile gezeichnet (Test).",
folge: ["video:post9-v6-animTitel-1x1.mp4", "bild:1", "bild:2", "bild:3", "bild:4", "bild:5"] },
{ nr: 7, datei: "post9-v7.html", name: "Arbeitsheft dunkel",
text: "Wie Variante 6, auf dunklem Grund.",
folge: ["video:post9-v7-animTitel-1x1.mp4", "bild:1", "bild:2", "bild:3", "bild:4", "bild:5"] },
];
const PORT = 9542;
const profil = path.join(tmpdir(), "pg-hub");
const chrome = spawn("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
["--headless", "--disable-gpu", "--hide-scrollbars", "--remote-debugging-port=" + PORT,
"--user-data-dir=" + profil, "about:blank"], { stdio: "ignore" });
let ziele;
for (let i = 0; i < 60; i++) {
ziele = await fetch(`http://127.0.0.1:${PORT}/json/list`).then(r => r.json()).catch(() => null);
if (ziele && ziele.some(t => t.type === "page")) break;
await new Promise(r => setTimeout(r, 200));
}
const ws = new WebSocket(ziele.find(t => t.type === "page").webSocketDebuggerUrl);
const offen = new Map(); let n = 0;
ws.onmessage = e => { const m = JSON.parse(e.data); if (m.id && offen.has(m.id)) { offen.get(m.id)(m); offen.delete(m.id); } };
await new Promise(ok => ws.onopen = ok);
const ruf = (method, params = {}) => new Promise((ok, weg) => { const id = ++n;
offen.set(id, m => m.error ? weg(new Error(m.error.message)) : ok(m.result)); ws.send(JSON.stringify({ id, method, params })); });
const warte = ms => new Promise(r => setTimeout(r, ms));
await ruf("Page.enable"); await ruf("Runtime.enable");
await rm(HUB, { recursive: true, force: true });
await mkdir(HUB, { recursive: true });
const liste = [];
for (const v of VARIANTEN) {
/* Breit genug fuer jeden Streifen, danach auf die echte Breite stellen. */
await ruf("Emulation.setDeviceMetricsOverride", { width: 7560, height: 1080, deviceScaleFactor: 1, mobile: false });
await ruf("Page.navigate", { url: POSTGRID + v.datei + "?nur=streifen&format=1x1" });
await warte(4500);
const breite = (await ruf("Runtime.evaluate", { expression: "document.documentElement.scrollWidth", returnByValue: true })).result.value;
const anzahl = Math.round(breite / 1080);
const slides = [];
for (const [i, teil] of v.folge.entries()) {
const [art, wert] = teil.split(":");
const name = `v${v.nr}-${i + 1}` + (art === "video" ? ".mp4" : ".png");
if (art === "video") {
await copyFile(path.join("animation", wert), path.join(HUB, name));
} else {
const k = Number(wert);
if (k >= anzahl) throw new Error(`${v.datei}: Slide ${k + 1} fehlt im Streifen (${anzahl} Slides)`);
const bild = await ruf("Page.captureScreenshot", { format: "png",
clip: { x: k * 1080, y: 0, width: 1080, height: 1080, scale: 1 } });
await writeFile(path.join(HUB, name), Buffer.from(bild.data, "base64"));
}
slides.push({ art: art === "video" ? "video" : "bild", datei: name });
}
liste.push({ nr: v.nr, name: v.name, text: v.text, slides });
console.log(`Variante ${v.nr}: ${slides.length} Elemente`);
}
const heute = new Date().toISOString().slice(0, 10);
await writeFile(path.join(HUB, "varianten.json"), JSON.stringify({
titel: "Post 9 · Webinar-Anzeige",
stand: heute,
hinweis: "Format 1:1 für die Anzeige. Datum und Uhrzeit des Webinars sind noch Platzhalter.",
varianten: liste,
}, null, 1) + "\n");
console.log("Fertig:", (await readdir(HUB)).length, "Dateien in", HUB);
ws.close();
if (chrome.exitCode === null) { const zu = new Promise(ok => chrome.once("exit", ok)); chrome.kill(); await zu; }
await rm(profil, { recursive: true, force: true }).catch(() => {});
+8486
View File
File diff suppressed because it is too large. Load diff
+8490
View File
File diff suppressed because it is too large. Load diff
+8714
View File
File diff suppressed because it is too large. Load diff
+8717
View File
File diff suppressed because it is too large. Load diff
+8876
View File
File diff suppressed because it is too large. Load diff
+8879
View File
File diff suppressed because it is too large. Load diff
+208
View File
@@ -0,0 +1,208 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PostGrid · Post 9 · Varianten</title>
<link rel="stylesheet" href="./assets/fonts/manrope.css">
<style>
/* ─── Post 9 im Vergleich ─────────────────────────────────────────────────
Die Varianten der Webinar-Anzeige untereinander, wie die Uebersicht aller
Projekte: jede Variante kommt als iframe im Modus ?nur=streifen und zeigt
genau, was der Generator zeigt. Variante 1 beginnt mit dem Bewegtbild.
Das steht nicht im Streifen (Animationen liegen ausserhalb), deshalb
laeuft davor das exportierte MP4 im passenden Format. */
* { box-sizing: border-box; }
:root { color-scheme: dark; }
html, body { height: 100%; }
body {
margin: 0; font-family: Manrope, system-ui, sans-serif;
background: #101116; color: #C7CCD6; overflow: hidden;
}
.menubar {
position: relative; z-index: 10; display: flex; align-items: center; gap: 2px;
height: 34px; padding: 0 10px; background: #16181F;
border-bottom: 1px solid #23262F; font-size: 13px; user-select: none;
}
.menubar .marke { font-weight: 800; color: #FFFFFF; margin-right: 14px; letter-spacing: -.01em; }
.menubar a {
color: #C7CCD6; text-decoration: none; padding: 5px 10px; border-radius: 6px;
}
.menubar a:hover { background: #23262F; color: #FFFFFF; }
.stage { position: absolute; inset: 34px 0 0; overflow-y: auto; overflow-x: hidden;
background-color: #101116;
background-image: radial-gradient(circle, #282B35 1.6px, transparent 1.6px);
background-size: 26px 26px;
}
.flaeche { padding: 48px 54px 80px; display: flex; flex-direction: column; gap: 56px; }
.kopf h1 { margin: 0; font-size: 26px; font-weight: 800; color: #FFFFFF; letter-spacing: -.01em; }
.kopf p { margin: 6px 0 0; font-size: 14px; color: #6E7480; }
.variante { display: flex; flex-direction: column; gap: 14px; }
.variante h2 {
margin: 0; font-size: 13px; font-weight: 800; letter-spacing: .14em;
text-transform: uppercase; color: #6E7480;
}
.variante h2 b { color: #F1B729; font-weight: 800; }
.variante h2 span { color: #FFFFFF; }
.variante p { margin: 0; max-width: 820px; font-size: 14px; line-height: 1.55; color: #9AA0AC; }
.reihe { display: flex; gap: 0; align-items: flex-start; }
.rahmen { overflow: hidden; background: #FFFFFF; border: 1px solid #2E313B; flex: none; }
.rahmen + .rahmen { border-left: 0; }
.rahmen iframe {
display: block; border: 0; transform-origin: 0 0; background: #FFFFFF; pointer-events: none;
}
.rahmen video { display: block; width: 100%; height: 100%; object-fit: cover; }
.marke-video {
font-size: 11px; color: #6E7480; letter-spacing: .06em; text-transform: uppercase; margin-top: 8px;
}
</style>
</head>
<body>
<div class="menubar">
<span class="marke">PostGrid</span>
<a href="./uebersicht.html">Alle Projekte</a>
<a href="./post9.html">Variante 1 bearbeiten</a>
<a href="./post9-v2.html">Variante 2 bearbeiten</a>
<a href="./post9-v3.html">Variante 3 bearbeiten</a>
<a href="./post9-v4.html">Variante 4 bearbeiten</a>
<a href="./post9-v5.html">Variante 5 bearbeiten</a>
<a href="./post9-v6.html">Variante 6 bearbeiten</a>
<a href="./post9-v7.html">Variante 7 bearbeiten</a>
</div>
<main class="stage" id="stage">
<div class="flaeche">
<div class="kopf">
<h1>Post 9 · Webinar-Anzeige</h1>
<p id="meta">Sieben Varianten derselben Anzeige. Lade Streifen ...</p>
</div>
<section class="variante">
<h2><b>Variante 1</b> · <span>Webinar-Fenster</span> · fertig seit 17.10.</h2>
<p>Das Bewegtbild zeigt das Webinar mit seinen vier Teilen und beiden Sprechern, danach die Anmeldung.
Zwei Elemente, das erste ist ein Video. Als Anzeige deshalb in 1:1.</p>
<div class="reihe" id="reihe1">
<div class="rahmen" id="video1"><video id="anim1" autoplay muted loop playsinline></video></div>
<div class="rahmen"><iframe id="f1" src="./post9.html?nur=streifen" loading="eager"></iframe></div>
</div>
</section>
<section class="variante">
<h2><b>Variante 2</b> · <span>Kundengeschichte</span> · angelehnt an weclapp</h2>
<p>Komposition wie das Karussell von weclapp zu PV Green (Werbebibliothek, ID 792638160291622): Etikett
„Live-Webinar“ und Foto, Zitat mit freigestellter Person, Vorher, Heute, Anmeldung. Seit 04.11. ohne HiveGrid
im Text und ohne Fachwörter. Die Geschichte: Noah kriegt nicht in die Ausführung, was in seinem Kopf ist.
Vorher hing das Nachfassen am Chef (Karte wie bei weclapp), heute laufen dieselben drei Schritte als Kette von selbst
(Schema wie bei Zapier), wie der Ablauf „Angebot nachfassen“ im Webinar Teil 2. Farben, Schrift und Formen von uns. Fünf Bilder, kein Video, als Anzeige also auch in
4:5 möglich.</p>
<div class="reihe">
<div class="rahmen"><iframe id="f2" src="./post9-v2.html?nur=streifen" loading="eager"></iframe></div>
</div>
</section>
<section class="variante">
<h2><b>Variante 3</b> · <span>Kundengeschichte dunkel</span> · wie Variante 2</h2>
<p>Dieselben fünf Slides wie Variante 2 auf dem dunklen Grund des Posts. Texte in Weiß, Formen und Wabenband in
einem helleren Blau, Gelb bleibt. Was auf Gelb und im Geerkens-Chip steht, bleibt dunkel.</p>
<div class="reihe">
<div class="rahmen"><iframe id="f3" src="./post9-v3.html?nur=streifen" loading="eager"></iframe></div>
</div>
</section>
<section class="variante">
<h2><b>Variante 4</b> · <span>Zahlen</span> · angelehnt an HERO Insights</h2>
<p>Komposition wie das Karussell „HERO Insights“ (Werbebibliothek, ID 3818182885142025): dunkles Titelbild,
dann Karten mit Titel, schwarzem und gelbem Balken und einer Grafik. Titelbild mit Noah als Teaser fürs Webinar,
dann 100 Waben als Band für 8 Stunden Bürokratie, eine große 4 mit den 100 Waben als Block für KI, zuletzt 72 %
„zu ausgelastet“ groß im gelben Kasten. Jede Karte sieht anders aus. Titel, Bürokratie und KI laufen als Video:
eine gelbe Welle durchs Wabenmuster, dann die Waben erst gelb, die wichtigen zuletzt rot.
Zahlen aus KfW Research (April 2025) und Bitkom (504 Betriebe, 2025), Quelle auf jeder Karte.</p>
<div class="reihe">
<div class="rahmen"><iframe id="f4" src="./post9-v4.html?nur=streifen" loading="eager"></iframe></div>
</div>
</section>
<section class="variante">
<h2><b>Variante 5</b> · <span>Zahlen dunkel</span> · wie Variante 4</h2>
<p>Dieselben fünf Karten wie Variante 4 auf dem dunklen Grund des Posts.</p>
<div class="reihe">
<div class="rahmen"><iframe id="f5" src="./post9-v5.html?nur=streifen" loading="eager"></iframe></div>
</div>
</section>
<section class="variante">
<h2><b>Variante 6</b> · <span>Arbeitsheft</span> · angelehnt an Noahs E-Book</h2>
<p>Aussagen und Zeichnungen aus Noahs Arbeitsheft „Jede Frage lief über meinen Tisch“: Titel dunkel wie der
Umschlag, mit den drei Fragen in Handschrift, der Rest weiß, dann Engpass, Inseln, Bewegungsmelder, zehn Handgriffe bei einer Wärmepumpe
und die Anmeldung. Manrope, Literata und Caveat wie im Heft, Skizzen-Kästen und gelbe Pfeile nach der Website.
Der Titel läuft als Video, die Pfeile werden wie mit dem Stift gezogen (Test).</p>
<div class="reihe">
<div class="rahmen"><iframe id="f6" src="./post9-v6.html?nur=streifen" loading="eager"></iframe></div>
</div>
</section>
<section class="variante">
<h2><b>Variante 7</b> · <span>Arbeitsheft dunkel</span> · wie Variante 6</h2>
<p>Dieselben sechs Slides auf dem dunklen Grund. Die Zeichnungen liegen auf weißen Karten, wie im Heft auf den
dunklen Seiten.</p>
<div class="reihe">
<div class="rahmen"><iframe id="f7" src="./post9-v7.html?nur=streifen" loading="eager"></iframe></div>
</div>
</section>
</div>
</main>
<script>
(() => {
"use strict";
const stage = document.getElementById("stage");
const meta = document.getElementById("meta");
/* Breite und Hoehe melden die Streifen selbst, sobald sie stehen. */
const st = {
f1: { el: document.getElementById("f1"), w: 1080, h: 1080, da: false },
f2: { el: document.getElementById("f2"), w: 5400, h: 1080, da: false },
f3: { el: document.getElementById("f3"), w: 5400, h: 1080, da: false },
f4: { el: document.getElementById("f4"), w: 5400, h: 1080, da: false },
f5: { el: document.getElementById("f5"), w: 5400, h: 1080, da: false },
f6: { el: document.getElementById("f6"), w: 6480, h: 1080, da: false },
f7: { el: document.getElementById("f7"), w: 6480, h: 1080, da: false },
};
const video = document.getElementById("anim1"), videoRahmen = document.getElementById("video1");
window.addEventListener("message", e => {
const d = e.data;
if (!d || d.hg !== "streifen") return;
const r = Object.values(st).find(x => x.el.contentWindow === e.source);
if (!r) return;
r.w = d.w; r.h = d.h; r.da = true;
legen();
});
/* Alle Slides im selben Massstab, eingepasst an die laengste Reihe. */
function legen() {
const breit = Math.max(st.f1.w + 1080, st.f2.w, st.f3.w, st.f4.w, st.f5.w, st.f6.w, st.f7.w);
const k = Math.min((stage.clientWidth - 108) / breit, 0.42);
Object.values(st).forEach(r => {
r.el.style.width = r.w + "px";
r.el.style.height = r.h + "px";
r.el.style.transform = "scale(" + k + ")";
r.el.parentElement.style.width = Math.round(r.w * k) + "px";
r.el.parentElement.style.height = Math.round(r.h * k) + "px";
});
/* Das Video im Format der Variante 1: 1:1 oder 4:5. */
videoRahmen.style.width = Math.round(1080 * k) + "px";
videoRahmen.style.height = Math.round(st.f1.h * k) + "px";
const quelle = "./animation/post9-animWebinar" + (st.f1.h === 1080 ? "-1x1" : "") + ".mp4";
if (!video.src.endsWith(quelle.slice(1))) { video.src = quelle; video.play().catch(() => {}); }
const fertig = Object.values(st).every(r => r.da);
meta.textContent = fertig
? "Sieben Varianten derselben Anzeige · Variante 1: 1 Video, 1 Bild · Varianten 2 bis 5: je 5 Elemente · 6 und 7: je 6 Bilder · Format " +
Object.values(st).map(r => r.h === 1080 ? "1:1" : "4:5").join(", ")
: "Sieben Varianten derselben Anzeige. Lade Streifen ...";
}
window.addEventListener("resize", legen);
legen();
})();
</script>
</body>
</html>
+262 -96
View File
@@ -15,7 +15,7 @@ if (location.search.indexOf("nur=anim") >= 0)
</script> </script>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>PostGrid Post 9 · Webinar</title> <title>PostGrid Post 9 · Variante 1 · Webinar-Fenster</title>
<link rel="stylesheet" href="./assets/fonts/manrope.css"> <link rel="stylesheet" href="./assets/fonts/manrope.css">
<style> <style>
/* ─── HiveGrid design tokens (mirrored from app/globals.css) ───────────── /* ─── HiveGrid design tokens (mirrored from app/globals.css) ─────────────
@@ -247,6 +247,9 @@ if (location.search.indexOf("nur=anim") >= 0)
#el-inhalt input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } #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 input:focus { outline: none; border-color: var(--accent-yellow); }
#el-inhalt .hint { color: #6E7480; margin-top: 6px; } #el-inhalt .hint { color: #6E7480; margin-top: 6px; }
.group[data-grp="format"] .hint {
font-size: 11.5px; line-height: 1.5; color: #6E7480; margin-top: 8px;
}
#el-inhalt .row { margin-top: 0; } #el-inhalt .row { margin-top: 0; }
.std { margin-bottom: 22px; } .std { margin-bottom: 22px; }
.std summary { .std summary {
@@ -345,9 +348,11 @@ if (location.search.indexOf("nur=anim") >= 0)
.strip[data-view="einzeln"] .holder { display: none; } .strip[data-view="einzeln"] .holder { display: none; }
.strip[data-view="einzeln"] .holder.sel { display: block; } .strip[data-view="einzeln"] .holder.sel { display: block; }
/* ─── Slide base — exactly Instagram 4:5 (1080 × 1350) ───────────────── */ /* ─── Slide base — Instagram 4:5 (1080 × 1350) oder 1:1 (1080 × 1080) ──
Die Hoehe kommt aus --slide-h, das formatAnwenden() auf <html> setzt.
Ohne Wert bleibt es 4:5 wie in allen anderen Posts. */
.slide { .slide {
width: 1080px; height: 1350px; width: 1080px; height: var(--slide-h, 1350px);
position: relative; overflow: hidden; position: relative; overflow: hidden;
font-family: 'Manrope', system-ui, sans-serif; font-family: 'Manrope', system-ui, sans-serif;
} }
@@ -533,7 +538,7 @@ if (location.search.indexOf("nur=anim") >= 0)
--animskala: .215; --animskala: .215;
flex: none; position: relative; overflow: hidden; border-radius: 6px; flex: none; position: relative; overflow: hidden; border-radius: 6px;
width: calc(1080px * var(--animskala)); width: calc(1080px * var(--animskala));
height: calc(1350px * var(--animskala)); height: calc(var(--slide-h, 1350px) * var(--animskala));
box-shadow: 0 0 0 1px #2E313B; box-shadow: 0 0 0 1px #2E313B;
} }
.animbuehne > .slide { .animbuehne > .slide {
@@ -626,7 +631,7 @@ if (location.search.indexOf("nur=anim") >= 0)
left: calc(var(--slide-i, 0) * -1080px); left: calc(var(--slide-i, 0) * -1080px);
width: var(--sheet-w, 5400px); z-index: 1; width: var(--sheet-w, 5400px); z-index: 1;
} }
.page { position: absolute; top: 0; width: 1080px; height: 1350px; } .page { position: absolute; top: 0; width: 1080px; height: var(--slide-h, 1350px); }
/* Im Theme modern traegt das Badge wieder Platz, deshalb beginnt der Block /* 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. */ bei 92 -- dem Rand der Safe Zone -- statt bei den 59 des Themes notebook. */
@@ -1053,7 +1058,14 @@ if (location.search.indexOf("nur=anim") >= 0)
<button type="button" data-gehe="./post6.html">Post 6 · Team-Chat</button> <button type="button" data-gehe="./post6.html">Post 6 · Team-Chat</button>
<button type="button" data-gehe="./post7.html">Post 7 · Form Builder</button> <button type="button" data-gehe="./post7.html">Post 7 · Form Builder</button>
<button type="button" data-gehe="./post8.html">Post 8 · E-Mail-Marketing</button> <button type="button" data-gehe="./post8.html">Post 8 · E-Mail-Marketing</button>
<button type="button" data-gehe="./post9.html">Post 9 · Webinar</button> <button type="button" data-gehe="./post9.html">Post 9 · Variante 1 · Webinar-Fenster</button>
<button type="button" data-gehe="./post9-v2.html">Post 9 · Variante 2 · Kundengeschichte</button>
<button type="button" data-gehe="./post9-v3.html">Post 9 · Variante 3 · Kundengeschichte dunkel</button>
<button type="button" data-gehe="./post9-v4.html">Post 9 · Variante 4 · Zahlen</button>
<button type="button" data-gehe="./post9-v5.html">Post 9 · Variante 5 · Zahlen dunkel</button>
<button type="button" data-gehe="./post9-v6.html">Post 9 · Variante 6 · Arbeitsheft</button>
<button type="button" data-gehe="./post9-v7.html">Post 9 · Variante 7 · Arbeitsheft dunkel</button>
<button type="button" data-gehe="./post9-varianten.html">Post 9 · Varianten im Vergleich</button>
<hr> <hr>
<button type="button" data-gehe="./uebersicht.html">Alle Projekte im Streifen</button> <button type="button" data-gehe="./uebersicht.html">Alle Projekte im Streifen</button>
<hr> <hr>
@@ -1115,7 +1127,7 @@ if (location.search.indexOf("nur=anim") >= 0)
<div class="arbeitsflaeche"> <div class="arbeitsflaeche">
<aside class="panel"> <aside class="panel">
<h1>Post 9 · Webinar</h1> <h1>Post 9 · Variante 1</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> <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"> <div class="group">
@@ -1145,6 +1157,17 @@ if (location.search.indexOf("nur=anim") >= 0)
Die Datei landet unverändert unter <b>assets/eigene/</b> und gehört mit in den Commit.</div> Die Datei landet unverändert unter <b>assets/eigene/</b> und gehört mit in den Commit.</div>
</details> </details>
<details class="group" data-grp="format" open>
<summary>Format</summary>
<div class="opts" id="formate">
<button data-g="4x5" aria-pressed="true">4:5&nbsp;·&nbsp;Beitrag</button>
<button data-g="1x1" aria-pressed="false">1:1&nbsp;·&nbsp;Anzeige</button>
</div>
<div class="hint">Meta nimmt ein Carousel mit Video als Anzeige nur in 1:1.
Beide Formate teilen Texte und Slides. In 1:1 sind die Karten kleiner, und
„Link in der Bio“ fehlt, weil die Anzeige einen eigenen Knopf hat.</div>
</details>
<details class="group" data-grp="variante"> <details class="group" data-grp="variante">
<summary>Variante</summary> <summary>Variante</summary>
<div class="opts" id="variants"> <div class="opts" id="variants">
@@ -1434,6 +1457,11 @@ if (location.search.indexOf("nur=anim") >= 0)
head: 'Live zuschauen,<br><span class="a">live fragen.</span>', head: 'Live zuschauen,<br><span class="a">live fragen.</span>',
body: "Was Automatisierung in deinem Betrieb wirklich macht. Mit dem Branchenprogramm, das du schon hast.", body: "Was Automatisierung in deinem Betrieb wirklich macht. Mit dem Branchenprogramm, das du schon hast.",
vis: "anmeldung", visW: 896, visLeft: 92, visBottom: 168, vis: "anmeldung", visW: 896, visLeft: 92, visBottom: 168,
/* 1:1 fuer die Anzeige: 270 px weniger Hoehe. Ueberschrift und Text
bleiben, die Karte wird kleiner und steht mittig, unten wie oben
92 px Rand. Gemessen am 02.11.: Text endet bei y 369, die Karte
beginnt 50 px darunter. */
quadrat: { visW: 736, visLeft: 172, visBottom: 92 },
}, },
]; ];
@@ -1456,6 +1484,10 @@ if (location.search.indexOf("nur=anim") >= 0)
/* 244 statt 226 wie in Post 8: die Karte ist hier niedriger, und der /* 244 statt 226 wie in Post 8: die Karte ist hier niedriger, und der
Abstand zur Ueberschrift soll derselbe bleiben, rund 118 px. */ Abstand zur Ueberschrift soll derselbe bleiben, rund 118 px. */
vis: "animWebinar", visW: 896, visLeft: 92, visBottom: 244, vis: "animWebinar", visW: 896, visLeft: 92, visBottom: 244,
/* 1:1: die Karte so breit wie auf der Anmelde-Slide, damit beim
Wischen nichts springt, und rund 90 px unter der Ueberschrift wie
in 4:5 (dort 88). */
quadrat: { visW: 736, visLeft: 172, visBottom: 106 },
}, },
]; ];
@@ -4552,52 +4584,34 @@ if (location.search.indexOf("nur=anim") >= 0)
{ kuerzel: "DB", name: "David Bertram", rolle: "Produkt & Design" }, { kuerzel: "DB", name: "David Bertram", rolle: "Produkt & Design" },
]; ];
/* Die vier Teile, Wort fuer Wort von „Was heute kommt“. Die Zeichen sind /* Die vier Teile, Wort fuer Wort von „Was heute kommt“. akzent ist das
die Phosphor-Gegenstuecke zu den Lucide-Zeichen auf derselben Slide: Wort in Gelb, wie auf den Kapitel-Slides des Webinars. Die Zeichen sind
die Phosphor-Gegenstuecke zu den Lucide-Zeichen auf „Was heute kommt“:
Kanban fuer die Pipelines, ein Ablauf fuer die Automation, zwei Blasen Kanban fuer die Pipelines, ein Ablauf fuer die Automation, zwei Blasen
fuer den Chat, das Geschenk fuer die Ueberraschung. */ fuer den Chat, das Geschenk fuer die Ueberraschung. */
const WB_TEILE = [ const WB_TEILE = [
{ rubrik: "Teil 1", zeichen: "kanban", zeilen: ["Von der Anfrage", "zum Auftrag"] }, { rubrik: "Teil 1", zeichen: "kanban", akzent: "Auftrag", zeilen: ["Von der Anfrage", "zum Auftrag"] },
{ rubrik: "Teil 2", zeichen: "tree-structure", zeilen: ["Das Herzstück:", "Automatisierung"] }, { rubrik: "Teil 2", zeichen: "tree-structure", akzent: "Automatisierung", zeilen: ["Das Herzstück:", "Automatisierung"] },
{ rubrik: "Teil 3", zeichen: "chats-circle", zeilen: ["Alles drumherum: Chat,", "Personal, Passwörter"] }, { rubrik: "Teil 3", zeichen: "chats-circle", akzent: "drumherum:", zeilen: ["Alles drumherum: Chat,", "Personal, Passwörter"] },
{ rubrik: "Zum Schluss", zeichen: "gift", zeilen: ["Eine Überraschung"] }, { rubrik: "Zum Schluss", zeichen: "gift", akzent: "Überraschung", zeilen: ["Eine Überraschung"] },
]; ];
/* Rot nur fuer die Live-Marke. Aus ui/theme.css von hivegrid-cloud: /* Markengelb fuer die Wabe in der Live-Marke, wie --accent-yellow. */
red-100, red-200, red-700 nach dem Muster der Marken in Post 8, der const WB_GELB = "#F1B729";
Punkt ist red-500. */ /* Die Farben der Kapitel-Slides im Webinar (quelle/slides/teil1.html):
const WB_ROT = { flaeche: "#FEE2E2", rand: "#FDCBCB", zeichen: "#C52020", punkt: "#ED4646" }; dunkler Grund, Titel fast weiss, die Rubrik grau. */
/* Das Sechseck der Webinar-Slides: Gelb #FCE48A, Tinte als Rand. */ const WB_DUNKEL = "#24242B", WB_HELL = "#FAFAFA", WB_GRAU = "#9D9DA0";
const WB_WABE = "#FCE48A";
const WB_B = 720, WB_RAND = 34, WB_KOPF = 92; const WB_B = 720, WB_RAND = 34, WB_KOPF = 92;
let wbNr = 0; let wbNr = 0;
/* Die Live-Marke: Punkt, Wort, und um den Punkt ein Ring, der einmal pro /* Der Rahmen: Zeichen, Titel, Trennlinie. Wie emRahmen in Post 8, nur
Sekunde aufgeht. puls laeuft von 0 bis 1; ohne puls steht der Punkt ohne Reiter. Eine eigene Live-Marke gibt es nicht mehr: Der Titel sagt
still. */ schon „Live-Webinar“ (Dejan, 20.10.). */
function wbLive(x, ym, puls) {
const gr = 14, h = gr + 13, b = messen("Live", gr, 700) + gr * 1.9 + 14;
let g = '<rect x="' + n1(x) + '" y="' + n1(ym - h / 2) + '" width="' + n1(b) +
'" height="' + h + '" rx="' + n1(h / 2) + '" fill="' + WB_ROT.flaeche +
'" stroke="' + WB_ROT.rand + '" stroke-width="1.2"/>';
const px = x + 15;
if (puls !== undefined && puls > 0 && puls < 1) {
g += '<circle cx="' + n1(px) + '" cy="' + n1(ym) + '" r="' + n1(4 + 6 * raus(puls)) +
'" fill="' + WB_ROT.punkt + '" opacity="' + n1(.35 * (1 - puls)) + '"/>';
}
g += '<circle cx="' + n1(px) + '" cy="' + n1(ym) + '" r="4" fill="' + WB_ROT.punkt + '"/>';
g += uiText(px + 11, ym + gr * .36, "Live", gr, 700, WB_ROT.zeichen);
return g;
}
/* Der Rahmen: Zeichen, Titel, Live-Marke, Trennlinie. Wie emRahmen in
Post 8, nur ohne Reiter. */
function wbRahmen(h, inhalt, o) { function wbRahmen(h, inhalt, o) {
const B = WB_B + 2 * WB_RAND; const B = WB_B + 2 * WB_RAND;
let g = ikon(o.zeichen, WB_RAND + 14, 33, 20, CHAT_TEXT); let g = ikon(o.zeichen, WB_RAND + 14, 33, 20, CHAT_TEXT);
g += uiText(WB_RAND + 32, 40, o.titel, 20, 800, CHAT_TEXT); g += uiText(WB_RAND + 32, 40, o.titel, 20, 800, CHAT_TEXT);
g += wbLive(WB_RAND + 46 + messen(o.titel, 20, 800), 33, o.puls);
/* Rechts der Termin. Ohne ihn sagt das erste Element nur, worum es geht, /* Rechts der Termin. Ohne ihn sagt das erste Element nur, worum es geht,
und nicht, wann. */ und nicht, wann. */
if (o.rechts) { if (o.rechts) {
@@ -4610,28 +4624,105 @@ if (location.search.indexOf("nur=anim") >= 0)
return stapelKasten(B, h, 22, g); return stapelKasten(B, h, 22, g);
} }
/* Das Sechseck mit Zeichen, wie auf „Was heute kommt“. */ /* Das Wabenmuster der Kapitel-Slides (bilder/wabe-ecke.png im Webinar):
function wbWabe(x, y, s, zeichen) { gelbe G-Waben, oben rechts am kraeftigsten, nach links unten auslaufend.
const k = s / 56; Hier als Vektoren nachgebaut, damit jedes Videobild gleich scharf ist
const p = [[3, 28], [15.5, 6.4], [40.5, 6.4], [53, 28], [40.5, 49.6], [15.5, 49.6]] und kein Bild nachladen muss. Die Waben stehen einmal fest; das Auslaufen
.map(q => n1(x + q[0] * k) + "," + n1(y + q[1] * k)).join(" "); macht eine Maske mit Verlauf, die maske() je Bild neu benennt. */
return '<polygon points="' + p + '" fill="' + WB_WABE + '" stroke="' + CHAT_TEXT + const WB_MUSTER = (() => {
'" stroke-width="1.6" stroke-linejoin="round"/>' + const G = "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";
ikon(zeichen, x + s / 2, y + s / 2, s * .42, CHAT_TEXT); const B = 50, S = B / 384.5, H = 332.9 * S, L = 7;
const DX = 0.75 * B + L, DY = H + L * 1.1;
let g = "";
for (let sp = 0; sp * DX < 720 + B; sp++) {
for (let z = -1; z * DY < 430; z++) {
const x = 720 - B - sp * DX, y = z * DY + (sp % 2 ? DY / 2 : 0);
if (x < 200 - B || y > 320) continue;
g += '<polygon points="' + G + '" transform="translate(' + n1(x) + ' ' + n1(y) + ') scale(' + S.toFixed(5) + ')"/>';
}
}
return g;
})();
/* Das zweite Muster der Webinar-Slides (waben.py, Slides „noah“ und
„problem“): weisse Waben mit grauem Rand auf hellem Grund, dazwischen
einzelne gelbe. Fuer die Folien nach Teil 1, damit die Titelfolie
dunkel bleibt und die anderen sich davon abheben. Spitze Waben wie
dort, die gelben stehen ausserhalb der Maske und bleiben voll. */
const WB_WABEN_HELL = (() => {
const R = 26, DX = Math.sqrt(3) * R, DY = 1.5 * R;
const ecken = (x, y) => [-90, -30, 30, 90, 150, 210]
.map(w => n1(x + R * Math.cos(w * Math.PI / 180)) + "," + n1(y + R * Math.sin(w * Math.PI / 180))).join(" ");
const zellen = [];
for (let j = 0; j * DY < 330; j++) {
for (let i = 0; i * DX < 720; i++) {
const x = 720 - i * DX - (j % 2 ? DX / 2 : 0), y = j * DY;
if (x > 230) zellen.push([x, y]);
}
}
const naechste = (zx, zy) => zellen.reduce((a, z) => Math.hypot(z[0] - zx, z[1] - zy) < Math.hypot(a[0] - zx, a[1] - zy) ? z : a);
const gelb = [naechste(470, 40), naechste(400, 140), naechste(690, 315)];
return {
waben: zellen.filter(z => !gelb.includes(z)).map(z => '<polygon points="' + ecken(z[0], z[1]) + '"/>').join(""),
gelb: gelb.map(z => '<polygon points="' + ecken(z[0], z[1]) + '"/>').join(""),
};
})();
function wbMaske(id) {
const verlauf = (vid, staerke) => '<linearGradient id="' + vid + '" gradientUnits="userSpaceOnUse" x1="720" y1="0" x2="400" y2="300">' +
'<stop offset="0" stop-color="#FFFFFF" stop-opacity="' + staerke + '"/><stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>' +
'</linearGradient>';
const maske = (mid, vid) => '<mask id="' + mid + '" maskUnits="userSpaceOnUse" x="0" y="0" width="720" height="' + WB_BUEHNE_H + '">' +
'<rect width="720" height="' + WB_BUEHNE_H + '" fill="url(#' + vid + ')"/></mask>';
return verlauf(id + "v", ".45") + maske(id, id + "v") + verlauf(id + "hv", "1") + maske(id + "h", id + "hv");
} }
/* Eine Folie auf der Buehne. Sechseck oben rechts, Rubrik oben links, /* Das Zeichen des Teils in einer grossen gelben Wabe oben rechts. Sie
der Titel unten links: dieselbe Diagonale wie auf den Kapitel-Slides sitzt im Muster wie eine hervorgehobene Zelle: Ein Rand in der Farbe
des Webinars. */ des Grunds raeumt die Waben darunter frei. Auf der dunklen Folie flach
wie die G-Waben, auf den hellen spitz wie die Zellen dort. */
function wbIkonWabe(zeichen, hell) {
const cx = 620, cy = 118, R = 58, rand = 9;
const winkel = hell ? [-90, -30, 30, 90, 150, 210] : [0, 60, 120, 180, 240, 300];
const ecken = r => winkel.map(w => n1(cx + r * Math.cos(w * Math.PI / 180)) + "," +
n1(cy + r * Math.sin(w * Math.PI / 180))).join(" ");
return '<polygon points="' + ecken(R + rand) + '" fill="' + (hell ? FL_GRUND : WB_DUNKEL) + '"/>' +
'<polygon points="' + ecken(R) + '" fill="' + WB_GELB + '"/>' +
ikon(zeichen, cx, cy, R * .86, CHAT_TEXT);
}
/* Eine Titelzeile, das Akzentwort in Gelb. */
function wbZeile(x, y, text, akzent, gr, farbe) {
const i = akzent ? text.indexOf(akzent) : -1;
const stueck = (s, farbe) => s ? '<tspan fill="' + farbe + '">' + wfEsc(s) + '</tspan>' : "";
const inhalt = i < 0 ? wfEsc(text) : stueck(text.slice(0, i), farbe) + stueck(akzent, WB_GELB) +
stueck(text.slice(i + akzent.length), farbe);
return '<text x="' + n1(x) + '" y="' + n1(y) + '" font-family="Manrope, sans-serif" font-size="' + gr +
'" font-weight="800" fill="' + farbe + '">' + inhalt + '</text>';
}
/* Eine Folie auf der Buehne, im Look der Webinar-Slides: oben rechts das
Wabenmuster, oben links die Rubrik, unten links der Titel mit einem Wort
in Gelb. Teil 1 ist die Titelfolie, dunkel mit G-Waben wie die
Kapitel-Slides. Die drei danach sind hell mit dem zweiten Muster
(Dejan, 20.10.). */
const WB_BUEHNE_H = 430; const WB_BUEHNE_H = 430;
function wbFolie(x0, teil) { function wbFolie(x0, teil, maske, hell) {
let g = wbWabe(x0 + WB_B - 44 - 124, 36, 124, teil.zeichen); let g = '<g transform="translate(' + n1(x0) + ',0)">';
g += uiText(x0 + 48, 78, teil.rubrik.toUpperCase(), 17, 800, CHAT_TEXT3, { sperr: "0.13em" }); if (hell) {
g += '<rect width="' + WB_B + '" height="' + WB_BUEHNE_H + '" fill="' + FL_GRUND + '"/>';
g += '<g mask="url(#' + maske + 'h)" fill="#FFFFFF" stroke="#D6D6D9" stroke-width="1.5">' + WB_WABEN_HELL.waben + '</g>';
g += '<g fill="' + WB_GELB + '">' + WB_WABEN_HELL.gelb + '</g>';
} else {
g += '<rect width="' + WB_B + '" height="' + WB_BUEHNE_H + '" fill="' + WB_DUNKEL + '"/>';
g += '<g mask="url(#' + maske + ')" fill="' + WB_GELB + '">' + WB_MUSTER + '</g>';
}
g += wbIkonWabe(teil.zeichen, hell);
g += uiText(48, 78, teil.rubrik.toUpperCase(), 17, 800, hell ? CHAT_TEXT3 : WB_GRAU, { sperr: "0.13em" });
const zh = 56, unten = WB_BUEHNE_H - 94; const zh = 56, unten = WB_BUEHNE_H - 94;
teil.zeilen.forEach((z, i) => { teil.zeilen.forEach((z, i) => {
g += uiText(x0 + 48, unten - (teil.zeilen.length - 1 - i) * zh, z, 48, 800, CHAT_TEXT); g += wbZeile(48, unten - (teil.zeilen.length - 1 - i) * zh, z, teil.akzent, 48, hell ? CHAT_TEXT : WB_HELL);
}); });
return g; return g + '</g>';
} }
/* Eine Kachel fuer einen Sprecher: Foto, Name, Rolle. spricht blendet den /* Eine Kachel fuer einen Sprecher: Foto, Name, Rolle. spricht blendet den
@@ -4668,22 +4759,19 @@ if (location.search.indexOf("nur=anim") >= 0)
sich je Teil ein Abschnitt der Leiste. sich je Teil ein Abschnitt der Leiste.
Unter der Buehne die beiden Sprecher. Wer redet, hat den dunklen Rand. Unter der Buehne die beiden Sprecher. Wer redet, hat den dunklen Rand.
Die Reihenfolge ist die aus dem Skript: Jedes Kapitel eroeffnet Noah, Noah die erste Haelfte, David die zweite.
dann uebernimmt David.
Alles, was sich wiederholt, laeuft in ganzen Perioden ueber die acht Alles, was sich wiederholt, laeuft in ganzen Perioden ueber die acht
Sekunden: der Puls der Live-Marke achtmal, die Balken drei-, vier- und Sekunden, die Balken drei-, vier- und fuenfmal je Teil. Ein Video, das
fuenfmal je Teil. Ein Video, das sich auf Instagram sich auf Instagram wiederholt, springt dann nur bei der Folie. */
wiederholt, springt dann nur bei der Folie und nicht beim Puls. */
function animWebinar(j, t) { function animWebinar(j, t) {
const n = WB_TEILE.length, abschnitt = 1 / n, schub = .045; const n = WB_TEILE.length, abschnitt = 1 / n, schub = .045;
/* Welche Folie steht, und wie weit der Schub zur naechsten ist. */ /* Welche Folie steht, und wie weit der Schub zur naechsten ist. */
let g = ""; let g = "";
const id = "wbbuehne" + (++wbNr); const id = "wbbuehne" + (++wbNr), maske = id + "m";
g += '<clipPath id="' + id + '"><path d="' + rundRechteck(0, 0, WB_B, WB_BUEHNE_H, 14) + g += '<clipPath id="' + id + '"><path d="' + rundRechteck(0, 0, WB_B, WB_BUEHNE_H, 14) +
'"/></clipPath>'; '"/></clipPath>' + wbMaske(maske);
g += '<path d="' + rundRechteck(0, 0, WB_B, WB_BUEHNE_H, 14) + '" fill="' + FL_GRUND + g += '<path d="' + rundRechteck(0, 0, WB_B, WB_BUEHNE_H, 14) + '" fill="' + WB_DUNKEL + '"/>';
'" stroke="' + CHAT_RAND + '" stroke-width="1.2"/>';
let folien = ""; let folien = "";
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
@@ -4693,27 +4781,33 @@ if (location.search.indexOf("nur=anim") >= 0)
const raus_ = i === n - 1 ? 0 : daempfen(stufe(t, (i + 1) * abschnitt - schub, (i + 1) * abschnitt + schub)); const raus_ = i === n - 1 ? 0 : daempfen(stufe(t, (i + 1) * abschnitt - schub, (i + 1) * abschnitt + schub));
const x0 = (1 - rein) * WB_B - raus_ * WB_B; const x0 = (1 - rein) * WB_B - raus_ * WB_B;
if (x0 >= WB_B || x0 <= -WB_B) continue; if (x0 >= WB_B || x0 <= -WB_B) continue;
folien += wbFolie(x0, WB_TEILE[i]); folien += wbFolie(x0, WB_TEILE[i], maske, i > 0);
} }
g += '<g clip-path="url(#' + id + ')">' + folien + '</g>'; g += '<g clip-path="url(#' + id + ')">' + folien + '</g>';
/* Die Leiste: vier Abschnitte, jeder fuellt sich, solange sein Teil /* Die Leiste: vier Abschnitte, jeder fuellt sich, solange sein Teil
steht. */ steht. */
const LX = 48, LB = WB_B - 96, LL = 8, sb = (LB - (n - 1) * LL) / n, ly = WB_BUEHNE_H - 40; const LX = 48, LB = WB_B - 96, LL = 8, sb = (LB - (n - 1) * LL) / n, ly = WB_BUEHNE_H - 40;
/* Die Spur ist auf der dunklen Titelfolie weiss und durchscheinend, auf
den hellen grau. Sie wechselt mit dem Schub zu Teil 2. */
const hellAnteil = daempfen(stufe(t, abschnitt - schub, abschnitt + schub));
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const sx = LX + i * (sb + LL); const sx = LX + i * (sb + LL);
g += '<rect x="' + n1(sx) + '" y="' + ly + '" width="' + n1(sb) + g += '<rect x="' + n1(sx) + '" y="' + ly + '" width="' + n1(sb) +
'" height="6" rx="3" fill="#DADADC"/>'; '" height="6" rx="3" fill="#FFFFFF" fill-opacity="' + (.16 * (1 - hellAnteil)).toFixed(3) + '"/>';
if (hellAnteil > 0) g += '<rect x="' + n1(sx) + '" y="' + ly + '" width="' + n1(sb) +
'" height="6" rx="3" fill="#DADADC" fill-opacity="' + hellAnteil.toFixed(3) + '"/>';
const p = stufe(t, i * abschnitt, (i + 1) * abschnitt); const p = stufe(t, i * abschnitt, (i + 1) * abschnitt);
if (p > 0) g += '<rect x="' + n1(sx) + '" y="' + ly + '" width="' + n1(Math.max(6, sb * p)) + if (p > 0) g += '<rect x="' + n1(sx) + '" y="' + ly + '" width="' + n1(Math.max(6, sb * p)) +
'" height="6" rx="3" fill="' + CHAT_TEXT + '"/>'; '" height="6" rx="3" fill="' + WB_GELB + '"/>';
} }
/* Die Sprecher. Im Abschnitt eines Teils redet erst Noah, ab etwas /* Die Sprecher. Noah redet bei Teil 1 und 2, David bei Teil 3 und 4.
ueber der Haelfte David. Der Wechsel blendet ueber, damit nicht beide Gewechselt wird nur einmal, mit dem Schub zur dritten Folie, und am
Ende des Loops zurueck. Vorher wechselte es in jedem Teil, das war zu
unruhig (Dejan, 20.10.). Der Wechsel blendet ueber, damit nicht beide
Raender gleichzeitig hart umspringen. */ Raender gleichzeitig hart umspringen. */
const imTeil = (t % abschnitt) / abschnitt; const david = daempfen(stufe(t, .5 - schub, .5 + schub));
const david = stufe(imTeil, .52, .62);
const noah = 1 - david; const noah = 1 - david;
const sy = WB_BUEHNE_H + 20, kb = (WB_B - 16) / 2; const sy = WB_BUEHNE_H + 20, kb = (WB_B - 16) / 2;
const phase = t * n; const phase = t * n;
@@ -4721,7 +4815,7 @@ if (location.search.indexOf("nur=anim") >= 0)
g += wbSprecher(kb + 16, sy, kb, WB_SPRECHER[1], david, phase); g += wbSprecher(kb + 16, sy, kb, WB_SPRECHER[1], david, phase);
const h = WB_KOPF + WB_BUEHNE_H + 20 + 84 + WB_RAND; const h = WB_KOPF + WB_BUEHNE_H + 20 + 84 + WB_RAND;
return wbRahmen(h, g, { zeichen: "video-camera", titel: "Live-Webinar", puls: (t * 8) % 1, return wbRahmen(h, g, { zeichen: "video-camera", titel: "Live-Webinar",
rechts: WEBINAR.datum + ", " + WEBINAR.uhrzeit + " Uhr" }); rechts: WEBINAR.datum + ", " + WEBINAR.uhrzeit + " Uhr" });
} }
@@ -4764,9 +4858,14 @@ if (location.search.indexOf("nur=anim") >= 0)
g += '<path d="' + rundRechteck(0, y, WB_B, 68, 12) + '" fill="#24242B"/>'; g += '<path d="' + rundRechteck(0, y, WB_B, 68, 12) + '" fill="#24242B"/>';
g += uiText(WB_B / 2, y + 42, "Jetzt anmelden", 22, 800, "#FFFFFF", { anker: "middle" }); g += uiText(WB_B / 2, y + 42, "Jetzt anmelden", 22, 800, "#FFFFFF", { anker: "middle" });
y += 68 + 36; y += 68;
/* „Link in der Bio“ nur im Beitrag. Die Anzeige (1:1) traegt ihren
eigenen Knopf zur Anmeldeseite, dort fuehrte die Zeile in die Irre. */
if (format !== "1x1") {
y += 36;
g += uiText(WB_B / 2, y, "Link in der Bio", 18, 600, CHAT_TEXT3, { anker: "middle" }); g += uiText(WB_B / 2, y, "Link in der Bio", 18, 600, CHAT_TEXT3, { anker: "middle" });
y += 6; y += 6;
}
return wbRahmen(WB_KOPF + y + WB_RAND, g, { zeichen: "calendar-check", titel: "Live-Webinar" }); return wbRahmen(WB_KOPF + y + WB_RAND, g, { zeichen: "calendar-check", titel: "Live-Webinar" });
} }
@@ -4899,7 +4998,7 @@ if (location.search.indexOf("nur=anim") >= 0)
if (key === "mock") return SLIDES[i].mockW || 1090; if (key === "mock") return SLIDES[i].mockW || 1090;
if (key === "mock-2") return (SLIDES[i].media2 || {}).w || 400; if (key === "mock-2") return (SLIDES[i].media2 || {}).w || 400;
if (key === "note") return (SLIDES[i].note || {}).w || 320; if (key === "note") return (SLIDES[i].note || {}).w || 320;
if (key === "vis") return SLIDES[i].visW || 900; if (key === "vis") return visMass(SLIDES[i]).visW || 900;
return (STANDARD[key] || {}).size; return (STANDARD[key] || {}).size;
} }
@@ -4934,6 +5033,36 @@ if (location.search.indexOf("nur=anim") >= 0)
Textblock darueber steht dann sichtbar aus der Achse. */ Textblock darueber steht dann sichtbar aus der Achse. */
let bandTon = "orange", rasterAn = "punkte", textMitte = "an"; let bandTon = "orange", rasterAn = "punkte", textMitte = "an";
/* ─── Format ──────────────────────────────────────────────────────────────
4:5 ist das Format der Beitraege. 1:1 ist das Format der Anzeige: Meta
nimmt ein Carousel, in dem ein Video steckt, im Instagram-Feed nur
quadratisch. Wortlaut im Ads Guide (abgerufen 02.11.2026): „Derzeit wird
bei Carousels, die Videos enthalten, nur das Format 1:1 unterstützt.“
Reine Bild-Carousels duerften 4:5 bleiben.
Die Breite bleibt 1080, nur die Hoehe wechselt. Texte, Slides und
Verschiebungen teilen sich beide Formate; was sich je Format
unterscheidet, steht an der Slide unter `quadrat` (Groesse und Lage der
Karte). Die URL kann das Format vorgeben (?format=1x1), damit
animation.mjs jedes Format bauen kann, ohne den Stand umzustellen. */
const FORMATE = {
"4x5": { h: 1350, name: "4:5", datei: "" },
"1x1": { h: 1080, name: "1:1", datei: "-1x1" },
};
let format = "4x5";
let SLIDE_H = FORMATE[format].h;
const FORMAT_URL = (f => FORMATE[f] ? f : null)(new URLSearchParams(location.search).get("format"));
function formatAnwenden() {
if (!FORMATE[format]) format = "4x5";
SLIDE_H = FORMATE[format].h;
document.documentElement.style.setProperty("--slide-h", SLIDE_H + "px");
document.documentElement.dataset.format = format;
}
/* Die Karte je Format. Fuer 4:5 gilt, was an der Slide steht; fuer 1:1
ueberschreibt `quadrat` Breite, Abstand links und Abstand unten. */
const visMass = sl => (format === "1x1" && sl.quadrat) ? Object.assign({}, sl, sl.quadrat) : sl;
window.SLIDE_FORMAT = () => ({ format, breite: 1080, hoehe: SLIDE_H, datei: FORMATE[format].datei });
/* ─── Projektstand ──────────────────────────────────────────────────────── /* ─── Projektstand ────────────────────────────────────────────────────────
Alles, was am Projekt hängt, wird gespeichert: Einstellungen, Layouts, Alles, was am Projekt hängt, wird gespeichert: Einstellungen, Layouts,
Muster und die Verschiebungen im Canvas. Das Werkzeug soll den Stand Muster und die Verschiebungen im Canvas. Das Werkzeug soll den Stand
@@ -4959,7 +5088,7 @@ if (location.search.indexOf("nur=anim") >= 0)
function currentState() { function currentState() {
return { return {
v: STATE_V, variant, tilt, superSample, view, tagOut, frameStyle, bandTon, v: STATE_V, variant, tilt, superSample, view, tagOut, frameStyle, bandTon,
rasterAn, textMitte, rasterAn, textMitte, format,
current, layoutState, bgState, edits, current, layoutState, bgState, edits,
// Welche Slides der Post ueberhaupt hat. Ohne das waere eine selbst // Welche Slides der Post ueberhaupt hat. Ohne das waere eine selbst
// angelegte Slide nach dem Neuladen weg. // angelegte Slide nach dem Neuladen weg.
@@ -5107,6 +5236,7 @@ if (location.search.indexOf("nur=anim") >= 0)
press("rasters", "r", rasterAn); press("rasters", "r", rasterAn);
press("mitten", "m", textMitte); press("mitten", "m", textMitte);
press("views", "w", view); press("views", "w", view);
press("formate", "g", format);
// Nach einem Schritt, der Slides hinzugefuegt, geloescht oder umsortiert // Nach einem Schritt, der Slides hinzugefuegt, geloescht oder umsortiert
// hat, stimmt der Aufbau im Canvas nicht mehr mit dem Stand ueberein. // hat, stimmt der Aufbau im Canvas nicht mehr mit dem Stand ueberein.
if (deck.querySelectorAll(".holder").length !== SLIDES.length) { if (deck.querySelectorAll(".holder").length !== SLIDES.length) {
@@ -5118,6 +5248,8 @@ if (location.search.indexOf("nur=anim") >= 0)
applyEditsAll(); applyEditsAll();
} }
anzahlNachziehen(); anzahlNachziehen();
animLeisteBauen();
fitStrip();
show(current); show(current);
if (picked) pick(picked.slide, picked.key); else syncEditPanel(); if (picked) pick(picked.slide, picked.key); else syncEditPanel();
} finally { restoring = false; } } finally { restoring = false; }
@@ -5243,6 +5375,8 @@ if (location.search.indexOf("nur=anim") >= 0)
bandTon = d.bandTon || bandTon; bandTon = d.bandTon || bandTon;
rasterAn = d.rasterAn || rasterAn; rasterAn = d.rasterAn || rasterAn;
textMitte = d.textMitte || textMitte; textMitte = d.textMitte || textMitte;
format = FORMAT_URL || (FORMATE[d.format] ? d.format : format);
formatAnwenden();
current = Number.isInteger(d.current) ? Math.min(d.current, SLIDES.length - 1) : 0; current = Number.isInteger(d.current) ? Math.min(d.current, SLIDES.length - 1) : 0;
} }
@@ -5288,7 +5422,7 @@ if (location.search.indexOf("nur=anim") >= 0)
const scroller = document.getElementById("scroller"); const scroller = document.getElementById("scroller");
const availW = Math.max(320, scroller.clientWidth - 120); const availW = Math.max(320, scroller.clientWidth - 120);
const availH = Math.max(240, scroller.clientHeight - 120); const availH = Math.max(240, scroller.clientHeight - 120);
return Math.min(availW / stripWidth(), availH / 1350, 0.45); return Math.min(availW / stripWidth(), availH / SLIDE_H, 0.45);
} }
/* Die Uebersicht muss wissen, wie breit dieser Streifen ist -- die Zahl /* Die Uebersicht muss wissen, wie breit dieser Streifen ist -- die Zahl
@@ -5297,7 +5431,7 @@ if (location.search.indexOf("nur=anim") >= 0)
Interna der Post-Seite unabhaengig. */ Interna der Post-Seite unabhaengig. */
function groesseMelden() { function groesseMelden() {
if (!NUR_STREIFEN || parent === window) return; if (!NUR_STREIFEN || parent === window) return;
parent.postMessage({ hg: "streifen", w: stripWidth(), h: 1350, parent.postMessage({ hg: "streifen", w: stripWidth(), h: SLIDE_H,
titel: document.title }, "*"); titel: document.title }, "*");
} }
@@ -5306,7 +5440,7 @@ if (location.search.indexOf("nur=anim") >= 0)
const k = zoom || autoFit(); const k = zoom || autoFit();
deck.style.transform = "scale(" + k + ")"; deck.style.transform = "scale(" + k + ")";
wrap.style.width = Math.round(stripWidth() * k) + "px"; wrap.style.width = Math.round(stripWidth() * k) + "px";
wrap.style.height = Math.round(1350 * k) + "px"; wrap.style.height = Math.round(SLIDE_H * k) + "px";
const lvl = document.getElementById("zoom-fit"); const lvl = document.getElementById("zoom-fit");
if (lvl) lvl.textContent = zoom ? Math.round(k * 100) + " %" : "Fit"; if (lvl) lvl.textContent = zoom ? Math.round(k * 100) + " %" : "Fit";
groesseMelden(); groesseMelden();
@@ -5428,7 +5562,7 @@ if (location.search.indexOf("nur=anim") >= 0)
rechten Kante der vorletzten Slide -- zwei Pixel innerhalb der Naht, rechten Kante der vorletzten Slide -- zwei Pixel innerhalb der Naht,
weil genau auf der Kante ein Antialiasing-Splitter auf der weil genau auf der Kante ein Antialiasing-Splitter auf der
Nachbarslide stehen bleibt. */ Nachbarslide stehen bleibt. */
const H = 1350; const H = SLIDE_H;
const A0 = BAND_VON * 1080 + 2; // Anker der Kurve const A0 = BAND_VON * 1080 + 2; // Anker der Kurve
const A1 = Math.max(A0, (SLIDES.length - BAND_BIS) * 1080 - 2); 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 X0 = Math.min(A1, BAND_VON * 1080 + BAND_ANSCHNITT); // ab hier gezeichnet
@@ -5622,9 +5756,10 @@ if (location.search.indexOf("nur=anim") >= 0)
an der linken Slide-Kante beginnt und der Pfeil von Slide 4 dort an der linken Slide-Kante beginnt und der Pfeil von Slide 4 dort
ankommt. Mit || waere die 0 als fehlender Wert durchgegangen und die ankommt. Mit || waere die 0 als fehlender Wert durchgegangen und die
Flaeche haette weiter bei 92 gesessen. */ Flaeche haette weiter bei 92 gesessen. */
const m = visMass(sl);
v.style.cssText = v.style.cssText =
"--vis-w:" + (sl.visW ?? 900) + "px;--vis-left:" + (sl.visLeft ?? 92) + "--vis-w:" + (m.visW ?? 900) + "px;--vis-left:" + (m.visLeft ?? 92) +
"px;--vis-bottom:" + (sl.visBottom ?? 96) + "px"; "px;--vis-bottom:" + (m.visBottom ?? 96) + "px";
v.innerHTML = VIS[sl.vis](j, animT(j)); v.innerHTML = VIS[sl.vis](j, animT(j));
page.appendChild(v); page.appendChild(v);
} }
@@ -5755,9 +5890,10 @@ if (location.search.indexOf("nur=anim") >= 0)
const v = document.createElement("div"); const v = document.createElement("div");
v.className = "vis vis-" + a.vis; v.className = "vis vis-" + a.vis;
const m = visMass(a);
v.style.cssText = v.style.cssText =
"--vis-w:" + (a.visW ?? 900) + "px;--vis-left:" + (a.visLeft ?? 92) + "--vis-w:" + (m.visW ?? 900) + "px;--vis-left:" + (m.visLeft ?? 92) +
"px;--vis-bottom:" + (a.visBottom ?? 96) + "px"; "px;--vis-bottom:" + (m.visBottom ?? 96) + "px";
v.innerHTML = VIS[a.vis](i, animT(i)); v.innerHTML = VIS[a.vis](i, animT(i));
page.appendChild(v); page.appendChild(v);
@@ -5824,6 +5960,9 @@ if (location.search.indexOf("nur=anim") >= 0)
zeile.textContent = "Chrome startet …"; zeile.textContent = "Chrome startet …";
zeile.dataset.ton = ""; zeile.dataset.ton = "";
try { try {
/* animation.mjs liest das Format aus der Datei im Repo. Ein gerade
umgeschaltetes Format haengt womoeglich noch im Timer: erst schreiben. */
if (pushTimer) { clearTimeout(pushTimer); await pushState(currentState()); }
const an = await fetch("/anim", { const an = await fetch("/anim", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -5891,7 +6030,7 @@ if (location.search.indexOf("nur=anim") >= 0)
kopf.innerHTML = ANIM_PFEIL + "<b>Animation</b><span>" + kopf.innerHTML = ANIM_PFEIL + "<b>Animation</b><span>" +
(liste.length (liste.length
? liste.length + (liste.length === 1 ? " Element" : " Elemente") + ? liste.length + (liste.length === 1 ? " Element" : " Elemente") +
" &middot; 1080 &times; 1350 &middot; 30 fps &middot; MP4, getrennt vom Bildexport" " &middot; 1080 &times; " + SLIDE_H + " &middot; 30 fps &middot; MP4, getrennt vom Bildexport"
: "noch keine in diesem Projekt") + "</span>"; : "noch keine in diesem Projekt") + "</span>";
leiste.innerHTML = ""; leiste.innerHTML = "";
leiste.appendChild(kopf); leiste.appendChild(kopf);
@@ -5922,7 +6061,7 @@ if (location.search.indexOf("nur=anim") >= 0)
rechts.innerHTML = rechts.innerHTML =
'<div class="animtitel">' + a.titel + '</div>' + '<div class="animtitel">' + a.titel + '</div>' +
'<div class="animwerte">' + a.dauer.toFixed(1) + " s &middot; " + '<div class="animwerte">' + a.dauer.toFixed(1) + " s &middot; " +
Math.round(a.dauer * 30) + " Bilder &middot; 1080 &times; 1350</div>" + Math.round(a.dauer * 30) + " Bilder &middot; 1080 &times; " + SLIDE_H + "</div>" +
'<input class="animschieber" type="range" min="0" max="1000" value="0" ' + '<input class="animschieber" type="range" min="0" max="1000" value="0" ' +
'data-animschieber="' + a.i + '" aria-label="Zeitpunkt">' + 'data-animschieber="' + a.i + '" aria-label="Zeitpunkt">' +
'<div class="animknoepfe">' + '<div class="animknoepfe">' +
@@ -5930,7 +6069,7 @@ if (location.search.indexOf("nur=anim") >= 0)
'<button type="button" class="animexport">MP4 exportieren</button>' + '<button type="button" class="animexport">MP4 exportieren</button>' +
"</div>" + "</div>" +
'<p class="animzeile"><code>node animation.mjs ' + PROJECT + ".html " + '<p class="animzeile"><code>node animation.mjs ' + PROJECT + ".html " +
a.key + "</code></p>"; a.key + (format === "4x5" ? "" : " " + format) + "</code></p>";
karte.appendChild(rechts); karte.appendChild(rechts);
reihe.appendChild(karte); reihe.appendChild(karte);
@@ -6194,12 +6333,12 @@ if (location.search.indexOf("nur=anim") >= 0)
const k = superSample; const k = superSample;
return { return {
width: 1080 * k, width: 1080 * k,
height: 1350 * k, height: SLIDE_H * k,
pixelRatio: 1, pixelRatio: 1,
fontEmbedCSS: fontEmbedCss, fontEmbedCSS: fontEmbedCss,
style: { style: {
width: "1080px", width: "1080px",
height: "1350px", height: SLIDE_H + "px",
transform: k === 1 ? "none" : "scale(" + k + ")", transform: k === 1 ? "none" : "scale(" + k + ")",
transformOrigin: "top left", transformOrigin: "top left",
}, },
@@ -6221,7 +6360,8 @@ if (location.search.indexOf("nur=anim") >= 0)
} }
// Numeric prefix keeps the upload order intact in the file picker. // 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"; const fileName = i => String(i + 1).padStart(2, "0") + "-" + SLIDES[i].key + "-" + variant +
FORMATE[format].datei + ".png";
/* html-to-image serialises the slide into a data:image/svg+xml URL and paints /* 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 that as a single image. Raster children of a foreignObject decode
@@ -6590,7 +6730,7 @@ if (location.search.indexOf("nur=anim") >= 0)
// Die Ausgabegröße hängt an der Renderqualität und wird mitgesagt, damit // Die Ausgabegröße hängt an der Renderqualität und wird mitgesagt, damit
// sichtbar ist, was tatsächlich auf der Platte landet. // sichtbar ist, was tatsächlich auf der Platte landet.
const ausgabeGroesse = () => (1080 * superSample) + " × " + (1350 * superSample) + " px"; const ausgabeGroesse = () => (1080 * superSample) + " × " + (SLIDE_H * superSample) + " px";
async function downloadOne() { async function downloadOne() {
setStatus("Rendere Slide " + (current + 1) + " …"); setStatus("Rendere Slide " + (current + 1) + " …");
@@ -6653,7 +6793,8 @@ if (location.search.indexOf("nur=anim") >= 0)
const n = SLIDES.length; const n = SLIDES.length;
const kopf = document.getElementById("kopfzeile"); const kopf = document.getElementById("kopfzeile");
if (kopf) kopf.textContent = if (kopf) kopf.textContent =
(n === 1 ? "Einzelbild" : "Carousel, " + n + " Slides") + " · 1080 × 1350 px"; (n === 1 ? "Einzelbild" : "Carousel, " + n + " Slides") + " · 1080 × " + SLIDE_H +
" px · " + FORMATE[format].name;
const zip = document.getElementById("dl-all"); const zip = document.getElementById("dl-all");
if (zip) { if (zip) {
zip.textContent = "Alle " + n + " Slides als ZIP"; zip.textContent = "Alle " + n + " Slides als ZIP";
@@ -7202,8 +7343,8 @@ if (location.search.indexOf("nur=anim") >= 0)
} }
if (senk) { if (senk) {
const ziel = senk === "oben" ? RAND const ziel = senk === "oben" ? RAND
: senk === "mitte" ? (1350 - h) / 2 : senk === "mitte" ? (SLIDE_H - h) / 2
: 1350 - RAND - h; : SLIDE_H - RAND - h;
set.dy = Math.round((set.dy || 0) + (ziel - o)); set.dy = Math.round((set.dy || 0) + (ziel - o));
} }
applyEditsAll(); applyEditsAll();
@@ -7678,7 +7819,7 @@ if (location.search.indexOf("nur=anim") >= 0)
html += "</table>"; html += "</table>";
} }
html += '<h4>Fest</h4><table>' + html += '<h4>Fest</h4><table>' +
'<tr><td>Slide</td><td>1080 × 1350</td></tr>' + '<tr><td>Slide</td><td>1080 × 1350 (4:5) oder 1080 × 1080 (1:1)</td></tr>' +
'<tr><td>Innenabstand</td><td>92 px</td></tr>' + '<tr><td>Innenabstand</td><td>92 px</td></tr>' +
'<tr><td>Tag ausgerückt</td><td>−20 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>' + '<tr><td>Mockup</td><td>1090 px · 16:10</td></tr>' +
@@ -7820,6 +7961,25 @@ if (location.search.indexOf("nur=anim") >= 0)
if (b) { tilt = b.dataset.t; press("tilts", "t", tilt); applyAll(); saveState(); } if (b) { tilt = b.dataset.t; press("tilts", "t", tilt); applyAll(); saveState(); }
}); });
/* Formatwechsel: Hoehe setzen, alle Slides und das Bewegtbild neu bauen.
Die Karten bekommen dabei ihre Masse fuer das Format (visMass). */
document.getElementById("formate").addEventListener("click", e => {
const b = e.target.closest("button");
if (!b || !FORMATE[b.dataset.g] || b.dataset.g === format) return;
format = b.dataset.g;
formatAnwenden();
press("formate", "g", format);
picked = null;
deckNeuBauen();
anzahlNachziehen();
animLeisteBauen();
zoom = null;
show(current);
syncEditPanel();
saveState();
setStatus("Format " + FORMATE[format].name + " · 1080 × " + SLIDE_H + " px", "ok");
});
document.getElementById("quality").addEventListener("click", e => { document.getElementById("quality").addEventListener("click", e => {
const b = e.target.closest("button"); const b = e.target.closest("button");
if (b) { superSample = Number(b.dataset.q); press("quality", "q", b.dataset.q); saveState(); } if (b) { superSample = Number(b.dataset.q); press("quality", "q", b.dataset.q); saveState(); }
@@ -7896,6 +8056,11 @@ if (location.search.indexOf("nur=anim") >= 0)
await bilderNachladen(); await bilderNachladen();
await preloadAssets(); await preloadAssets();
const restored = await loadState(); const restored = await loadState();
format = FORMAT_URL || format;
formatAnwenden();
/* Der Rendermodus fuer animation.mjs darf den Stand nicht anfassen:
Mit ?format=1x1 wuerde er sonst das Format in die Datei schreiben. */
if (document.documentElement.dataset.nur === "anim") stateLocked = true;
/* Verweise auf Bilder, die es nicht mehr gibt, hier aufloesen statt sie /* Verweise auf Bilder, die es nicht mehr gibt, hier aufloesen statt sie
bis in den Export zu schleppen. Sie entstehen, wenn eine importierte bis in den Export zu schleppen. Sie entstehen, wenn eine importierte
Datei geloescht oder nicht mitgepusht wurde. */ Datei geloescht oder nicht mitgepusht wurde. */
@@ -7920,6 +8085,7 @@ if (location.search.indexOf("nur=anim") >= 0)
press("tagouts", "o", tagOut); press("tagouts", "o", tagOut);
press("frames", "f", frameStyle); press("frames", "f", frameStyle);
press("views", "w", view); press("views", "w", view);
press("formate", "g", format);
anzahlNachziehen(); anzahlNachziehen();
show(current); show(current);
animLeisteBauen(); animLeisteBauen();
@@ -7931,7 +8097,7 @@ if (location.search.indexOf("nur=anim") >= 0)
fontEmbedCss = await htmlToImage.getFontEmbedCSS(deck.querySelector(".slide")); fontEmbedCss = await htmlToImage.getFontEmbedCSS(deck.querySelector(".slide"));
["dl-png", "dl-all"].forEach(id => document.getElementById(id).disabled = false); ["dl-png", "dl-all"].forEach(id => document.getElementById(id).disabled = false);
if (checkCollisions()) setStatus("Bereit. " + SLIDES.length + if (checkCollisions()) setStatus("Bereit. " + SLIDES.length +
(SLIDES.length === 1 ? " Slide" : " Slides") + ", 1080 × 1350 px", "ok"); (SLIDES.length === 1 ? " Slide" : " Slides") + ", 1080 × " + SLIDE_H + " px", "ok");
/* Ganz zum Schluss: der Rendermodus raeumt die Seite leer, und alles, /* Ganz zum Schluss: der Rendermodus raeumt die Seite leer, und alles,
was danach noch ein Element sucht, faende keines mehr. Ein Aufruf was danach noch ein Element sucht, faende keines mehr. Ein Aufruf
weiter oben hat genau das ausgeloest -- "Cannot set properties of weiter oben hat genau das ausgeloest -- "Cannot set properties of
+45 -7
View File
@@ -1,13 +1,41 @@
# Post-Generator — Regeln & Konventionen # Post-Generator — Regeln & Konventionen
Arbeitsgrundlage für alles, was in diesem Repo an Social-Grafiken entsteht. Arbeitsgrundlage für alles, was in diesem Repo an Social-Grafiken entsteht.
**Stand:** 15.08.2026 **Stand:** 20.10.2026
Der Generator läuft lokal: `./serve.sh` → <http://127.0.0.1:4321/post1.html> Der Generator läuft lokal: `./serve.sh` → <http://127.0.0.1:4321/post1.html>
(Post 1, Carousel). (Post 1, Carousel).
--- ---
## 0. PFLICHT: Oberflächen aus den Produkt-Komponenten, nie eigene Grafik
> **Diese Regel gilt für jeden Post und hat Vorrang vor allem anderen in dieser Datei.**
>
> Jede Zeichnung, die die Software zeigt, wird aus den echten Komponenten von
> `hivegrid-cloud` nachgebaut: mit deren Maßen, Farben, Schrift (Instrument Sans),
> Icons (Feather/Lucide) und Beschriftungen aus `translations.json`. Jede Zeichnung
> bildet einen Screen nach, den es im Produkt wirklich gibt.
>
> **Verboten:** ausgedachte Oberflächen, eigene Marken-Pillen, Häkchen-Chips,
> Reiter, Kacheln oder Effekte, die es im Produkt nicht gibt. Kurz: keine
> „vibecoded“ Grafiken.
>
> - Nachschlagewerk mit allen Tokens, Komponenten, Bausteinen und dem Vorgehen:
> **`komponenten.md`**. Vor jedem neuen Post lesen.
> - Referenz-Umsetzung: `post8.html` (seit 20.10.2026). Die Bausteine `pText`,
> `pBadge`, `pKnopf`, `pFeld`, `pTabs`, `pTabelle`, `pRahmen` und so weiter von
> dort übernehmen.
> - Code immer von `origin/main` lesen (`git show origin/main:<pfad>`), nicht aus
> der veralteten Arbeitskopie.
> - Was nicht auf die Slide passt, weglassen und im Kommentar vermerken. Nicht
> umbauen, nicht erfinden. Erfunden werden darf nur Demo-Inhalt.
>
> **Warum:** Post 8 sah mit eigenem Design „total vibecoded“ aus. Nachgebaut aus
> den Komponenten „sieht jetzt genau so aus wie die Software“ (Dejan, 20.10.2026).
---
## 1. Wo die Wahrheit steht ## 1. Wo die Wahrheit steht
> **Neue Basis ab 12.10.2026: `hivegrid-cloud`.** Das ist die aktuelle Version > **Neue Basis ab 12.10.2026: `hivegrid-cloud`.** Das ist die aktuelle Version
@@ -27,7 +55,7 @@ Der Generator läuft lokal: `./serve.sh` → <http://127.0.0.1:4321/post1.html>
> >
> | Was | Wert | > | Was | Wert |
> |---|---| > |---|---|
> | Schrift | Instrument Sans (heute hier: Manrope) | > | Schrift | Instrument Sans: in allen Oberflächen-Zeichnungen (§0), die Slide-Überschriften bleiben Manrope |
> | Text, Knöpfe | `#24242B` (grey-900), Knöpfe sind fast schwarz | > | Text, Knöpfe | `#24242B` (grey-900), Knöpfe sind fast schwarz |
> | Text zweitrangig | `#505057`, drittrangig `#6D6D71` | > | Text zweitrangig | `#505057`, drittrangig `#6D6D71` |
> | Markengelb | `#F1B729` (brand-600), unverändert | > | Markengelb | `#F1B729` (brand-600), unverändert |
@@ -36,12 +64,12 @@ Der Generator läuft lokal: `./serve.sh` → <http://127.0.0.1:4321/post1.html>
> | Rahmen | `#E5E5E5` (neutral-200) | > | Rahmen | `#E5E5E5` (neutral-200) |
> | Radien | 4, 8, 12 px | > | Radien | 4, 8, 12 px |
> | Größen | Hero 72/72, Seite 48/56, Untertitel 32/40, alle 600 | > | Größen | Hero 72/72, Seite 48/56, Untertitel 32/40, alle 600 |
> | Symbole | Feather über `@subframe/core` (heute hier: Phosphor) | > | Symbole | Feather über `@subframe/core`: in Oberflächen als Lucide-Formen (§0), Phosphor nur noch in älteren Posts |
> >
> Drei Punkte widersprechen den bisherigen Regeln und sind noch nicht > Entschieden am 20.10.2026: Oberflächen nehmen Rahmen `#E5E5E5` und
> entschieden: die Schatten (`--shadow-md`, `--shadow-lg`) gegen §5 „Keine > Feather/Lucide aus dem Produkt (§0). Offen bleiben die Schatten
> Schatten“, der Rahmen `#E5E5E5` gegen `#D5DBE1` aus §1a, Feather gegen > (`--shadow-md`, `--shadow-lg`) gegen §5 „Keine Schatten“: In den Zeichnungen
> Phosphor. Die Werte sind hier noch nicht gespiegelt. > lassen wir sie weg, siehe `komponenten.md`.
Alle Marken-Werte stammen aus der Website, nicht aus diesem Repo. Bei Zweifel Alle Marken-Werte stammen aus der Website, nicht aus diesem Repo. Bei Zweifel
**dort nachschauen, nicht hier raten**: **dort nachschauen, nicht hier raten**:
@@ -454,6 +482,16 @@ behalten die Originalauflösung, alles außerhalb wird beschnitten.
im Grid fehlen links und rechts je ~33 px. Für die drei angepinnten Cover im Grid fehlen links und rechts je ~33 px. Für die drei angepinnten Cover
relevant, im Feed nicht relevant, im Feed nicht
**1080 × 1080 px (1:1) für Anzeigen mit Video im Carousel.** Ein normaler
Beitrag darf ein Video im 4:5-Carousel haben. Als Anzeige nicht: Meta schreibt
im Ads Guide für den Instagram-Feed „Derzeit wird bei Carousels, die Videos
enthalten, nur das Format 1:1 unterstützt.“ Reine Bild-Carousels dürfen 4:5
bleiben. In Reels laufen nur Bild-Carousels (9:16). Abgerufen 02.11.2026.
Post 9 hat dafür den Schalter „Format“ (4:5 · Beitrag, 1:1 · Anzeige), die
Karten tragen ihre 1:1-Maße unter `quadrat`, und
`node animation.mjs post9.html animWebinar 1x1` baut das Video in 1:1
(Datei mit `-1x1` im Namen).
### Layout-Template: Tag, Überschrift, Beschreibung, Bild ### Layout-Template: Tag, Überschrift, Beschreibung, Bild
Jede Feature-Slide besteht aus denselben vier Bausteinen in derselben Reihenfolge. Jede Feature-Slide besteht aus denselben vier Bausteinen in derselben Reihenfolge.
+1
View File
@@ -16,6 +16,7 @@
"edits": [ "edits": [
{} {}
], ],
"format": "1x1",
"frameStyle": "chamfer", "frameStyle": "chamfer",
"layoutState": [ "layoutState": [
"standard" "standard"
+15 -2
View File
@@ -112,7 +112,14 @@
<button type="button" data-gehe="./post6.html">Post 6 · Team-Chat</button> <button type="button" data-gehe="./post6.html">Post 6 · Team-Chat</button>
<button type="button" data-gehe="./post7.html">Post 7 · Form Builder</button> <button type="button" data-gehe="./post7.html">Post 7 · Form Builder</button>
<button type="button" data-gehe="./post8.html">Post 8 · E-Mail-Marketing</button> <button type="button" data-gehe="./post8.html">Post 8 · E-Mail-Marketing</button>
<button type="button" data-gehe="./post9.html">Post 9 · Webinar</button> <button type="button" data-gehe="./post9.html">Post 9 · Variante 1 · Webinar-Fenster</button>
<button type="button" data-gehe="./post9-v2.html">Post 9 · Variante 2 · Kundengeschichte</button>
<button type="button" data-gehe="./post9-v3.html">Post 9 · Variante 3 · Kundengeschichte dunkel</button>
<button type="button" data-gehe="./post9-v4.html">Post 9 · Variante 4 · Zahlen</button>
<button type="button" data-gehe="./post9-v5.html">Post 9 · Variante 5 · Zahlen dunkel</button>
<button type="button" data-gehe="./post9-v6.html">Post 9 · Variante 6 · Arbeitsheft</button>
<button type="button" data-gehe="./post9-v7.html">Post 9 · Variante 7 · Arbeitsheft dunkel</button>
<button type="button" data-gehe="./post9-varianten.html">Post 9 · Varianten im Vergleich</button>
<hr> <hr>
<button type="button" data-klick="neu">Streifen neu laden</button> <button type="button" data-klick="neu">Streifen neu laden</button>
</div> </div>
@@ -142,7 +149,13 @@
{ datei: "./post6.html", name: "Post 6 · Team-Chat", theme: "modern" }, { datei: "./post6.html", name: "Post 6 · Team-Chat", theme: "modern" },
{ datei: "./post7.html", name: "Post 7 · Form Builder", theme: "modern" }, { datei: "./post7.html", name: "Post 7 · Form Builder", theme: "modern" },
{ datei: "./post8.html", name: "Post 8 · E-Mail-Marketing", theme: "modern" }, { datei: "./post8.html", name: "Post 8 · E-Mail-Marketing", theme: "modern" },
{ datei: "./post9.html", name: "Post 9 · Webinar", theme: "modern" }, { datei: "./post9.html", name: "Post 9 · Variante 1 · Webinar-Fenster", theme: "modern" },
{ datei: "./post9-v2.html", name: "Post 9 · Variante 2 · Kundengeschichte", theme: "modern" },
{ datei: "./post9-v3.html", name: "Post 9 · Variante 3 · Kundengeschichte dunkel", theme: "modern" },
{ datei: "./post9-v4.html", name: "Post 9 · Variante 4 · Zahlen", theme: "modern" },
{ datei: "./post9-v5.html", name: "Post 9 · Variante 5 · Zahlen dunkel", theme: "modern" },
{ datei: "./post9-v6.html", name: "Post 9 · Variante 6 · Arbeitsheft", theme: "modern" },
{ datei: "./post9-v7.html", name: "Post 9 · Variante 7 · Arbeitsheft dunkel", theme: "modern" },
]; ];
const flaeche = document.getElementById("flaeche"); const flaeche = document.getElementById("flaeche");