Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07deacde41 | ||
|
|
f9f48609dd | ||
|
|
466b094c0b | ||
|
|
5b768537a2 | ||
|
|
cafefc9f1b | ||
|
|
fc69bebe96 | ||
|
|
4dd2ea1d18 | ||
|
|
35e68db15b | ||
|
|
cd74224880 | ||
|
|
78a6fcba78 | ||
|
|
f38cbfe12c | ||
|
|
b61f029a36 | ||
|
|
bc2a05012d | ||
|
|
ffa01fefcd | ||
|
|
76ac3e4cce | ||
|
|
5a65b5444c | ||
|
|
c93702e87c | ||
|
|
4a08cc361f | ||
|
|
2b91d2b177 | ||
|
|
e2f44d61f2 | ||
|
|
96dcd080e1 | ||
|
|
841408c6fa | ||
|
|
4d227a01c9 | ||
|
|
344645cb77 | ||
|
|
6b9f7a4ccf | ||
|
|
dfbf2ce2f3 | ||
|
|
b422b492e6 | ||
|
|
9c1d900da9 | ||
|
|
16503edfa2 | ||
|
|
77734b6da2 | ||
|
|
e8f5686f30 | ||
|
|
7804587ca0 | ||
|
|
d8b010fbdc | ||
|
|
bfe6b5cb4b | ||
|
|
1836a90a1a | ||
|
|
20d26d3383 | ||
|
|
a94c1985fc | ||
|
|
b250b34b07 | ||
|
|
35ad9cc104 | ||
|
|
6a7cdc1580 | ||
|
|
a2cb7cbf6c | ||
|
|
efcb864db1 | ||
|
|
03d137a667 | ||
|
|
be2f93a1fa | ||
|
|
28095f667d | ||
|
|
732e890989 | ||
|
|
668f4036df | ||
|
|
ac702c239b | ||
|
|
64675688b2 | ||
|
|
9ae27488d6 | ||
|
|
7fb1c3dfcb | ||
|
|
9fe7b848ee | ||
|
|
a884df4cda | ||
|
|
35c998b7e1 | ||
|
|
82913d78b8 | ||
|
|
a8f79c1224 | ||
|
|
d4feaa175b | ||
|
|
9e8b2ea607 | ||
|
|
c7c05a85cc | ||
|
|
ce87a7fb57 | ||
|
|
7723e5bf5a | ||
|
|
fcf720a1ee | ||
|
|
c26efa3f0c | ||
|
|
32206e7352 | ||
|
|
23a9ffc4cf | ||
|
|
bae57e64ce | ||
|
|
e02e87b7e2 | ||
|
|
ceb63fec38 | ||
|
|
a5ec0c565e | ||
|
|
06479c54b3 | ||
|
|
35f05cf213 | ||
|
|
79821775b0 | ||
|
|
16b85a285a | ||
|
|
0c4933d6b5 | ||
|
|
d578d12991 | ||
|
|
5fe1d2945f | ||
|
|
af6afe8e6e | ||
|
|
43a4a394f2 | ||
|
|
9b405ce931 | ||
|
|
993dc3e5ef | ||
|
|
84b3905d71 | ||
|
|
e88afc61a4 | ||
|
|
f465749c8f | ||
|
|
a7c347070e | ||
|
|
3781cc95d9 | ||
|
|
8590fa7e6e | ||
|
|
9cc82e59ee | ||
|
|
87834304fa | ||
|
|
6f58896f12 | ||
|
|
45342df1bd | ||
|
|
0c08b18dd9 | ||
|
|
ce56735077 | ||
|
|
71a7ac79a9 | ||
|
|
3f72d892c5 |
No files matched your search
@@ -1 +1,10 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Zwischenschritt beim PDF-Bau, entsteht neu aus bauen.py
|
||||||
|
redaktionsplan/*.html
|
||||||
|
|
||||||
|
# Wegwerf-Profil des Browsers beim Bau der Video-Komponenten
|
||||||
|
video/.chrome/
|
||||||
|
|
||||||
|
# Python-Zwischendateien von serve.py
|
||||||
|
__pycache__/
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# Post 2 · Die Geschichte
|
||||||
|
|
||||||
|
**Erscheint Mi 12.08.2026, 12:00 Uhr.** Carousel, 6 Slides, 1080 × 1350 px.
|
||||||
|
Zweiter Beitrag des Kontos `hivegrid.de` (nach „Vom Handwerker für Handwerker").
|
||||||
|
|
||||||
|
Diese Datei ist die Übergabe: alles, was zum Beitrag bekannt ist, damit man ohne
|
||||||
|
den Gesprächsverlauf weiterarbeiten kann. Der Redaktionsplan drumherum steht in
|
||||||
|
[`redaktionsplan/`](./redaktionsplan/).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Worum es geht
|
||||||
|
|
||||||
|
Die Herkunft, nicht die Funktionen. Warum es HiveGrid gibt: ein SHK-Betrieb in
|
||||||
|
vierter Generation, der sein Branchenprogramm nicht wechseln konnte und deshalb
|
||||||
|
drumherum gebaut hat.
|
||||||
|
|
||||||
|
**Vorlage ist der Text von der Website** (`HiveGrid-Website`,
|
||||||
|
`app/playground/PlaygroundClient.tsx`, Abschnitt „Warum es HiveGrid gibt").
|
||||||
|
Der vollständige Text gehört **unter** den Beitrag, nicht auf die Slides.
|
||||||
|
|
||||||
|
> Auf den Slides steht die Zuspitzung, in der Bildunterschrift der ganze Text.
|
||||||
|
> Ein früherer Stand hatte die Website-Absätze wörtlich auf den Slides. Das war
|
||||||
|
> falsch: Auf einer Slide muss eine Zeile im Vorbeiscrollen sitzen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Die sechs Slides
|
||||||
|
|
||||||
|
| # | Schlüssel | Überschrift | Fließtext | Visualisierung |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | `titel` | Die Geschichte fängt **auf der Baustelle an.** | Noah Geerkens, SHK-Betrieb in vierter Generation. | `karte` |
|
||||||
|
| 2 | `prozesse` | Nicht sinnvoll. **Nur so gewachsen.** | Die Software gibt den Takt vor. Nicht der Betrieb. | `schiene` |
|
||||||
|
| 3 | `wechseln` | Wechseln? **Kannst du vergessen.** | Jahre an Daten und Wissen hängen drin. Am Ende dasselbe, nur woanders. | `ersetzen` |
|
||||||
|
| 4 | `mehr` | Wir wollten nicht weg. **Wir wollten mehr.** | KI, CRM, Automatisierung. Und Tools, die miteinander reden. | `lose` |
|
||||||
|
| 5 | `verbinden` | Dein ERP bleibt. **Der Rest kommt dazu.** | Nichts wird ersetzt. Alles wird verbunden. | `verbinden` |
|
||||||
|
| 6 | `warteliste` | Gebaut im Betrieb. **Für den Betrieb.** | Setz dich auf die Warteliste. | `warteliste` |
|
||||||
|
|
||||||
|
Fett = das mit dem Marker hervorgehobene Akzentwort (`<span class="a">`).
|
||||||
|
|
||||||
|
**Zeilenlängen sind geprüft.** Bei 74 px und `-0.03em` passen rund 26 Zeichen in
|
||||||
|
eine Zeile, mit Marker etwas weniger. Keine Zeile hier ist länger als 22, es
|
||||||
|
bricht also nichts um und alle Überschriften bleiben zweizeilig. Gemessen wurde
|
||||||
|
mit der echten Manrope (variables Latin-Subset, Gewicht 800) über die
|
||||||
|
Vorschubbreiten, nicht geschätzt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Die Visualisierungen
|
||||||
|
|
||||||
|
**Kein Bildmaterial außer Noahs Karte.** Alles andere ist HTML und CSS. Damit
|
||||||
|
bleibt es beim 3-fachen Export so scharf wie die Schrift, und jede
|
||||||
|
Visualisierung lässt sich im Canvas verschieben und in der Breite ändern wie
|
||||||
|
jedes andere Element. Sie heißt dort **„Visualisierung"**.
|
||||||
|
|
||||||
|
Gebaut aus derselben Formensprache wie die Slides: chamferte Kästen, Sechsecke,
|
||||||
|
der eine gelbe Akzent. Durchgezogene Balken heißen verbunden, gestrichelte offen.
|
||||||
|
|
||||||
|
| Name | Was zu sehen ist | Gedanke dahinter |
|
||||||
|
|---|---|---|
|
||||||
|
| `karte` | Noahs Teamkarte von der Über-uns-Seite | Ein Gesicht, kein Symbol |
|
||||||
|
| `schiene` | Drei Schritte an einer Schiene mit der Beschriftung „seit Jahren so" | Die Schiene gibt die Reihenfolge vor, nicht der Betrieb |
|
||||||
|
| `ersetzen` | Schwerer Bestand, durchgestrichener Pfeil, leeres Ziel | Das Kreuz liegt auf dem **Pfeil**, nicht auf dem Ziel: der Wechsel scheitert, nicht das neue System |
|
||||||
|
| `lose` | Vier Kacheln, deren gestrichelte Stummel ins Leere laufen | KI, CRM, Automation, ERP wissen nichts voneinander |
|
||||||
|
| `verbinden` | ERP als Sechseck in der Mitte, das Neue drumherum | Der Kern bleibt, außen kommt dazu |
|
||||||
|
| `warteliste` | Eingabefeld, Knopf, Adresse | Der Abschluss ist ein Feld, kein Bild |
|
||||||
|
|
||||||
|
### Noahs Karte
|
||||||
|
|
||||||
|
Nachbau der Karte von der Über-uns-Seite **im aktiven Zustand**. Werte direkt aus
|
||||||
|
`HiveGrid-Website/app/ueber-uns/TeamCards.tsx`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Hintergrund grayscale(.04) saturate(.74) brightness(.73)
|
||||||
|
Person saturate(1) contrast(1.01)
|
||||||
|
Kartengrund #0f1a2e Radius außen 38 px, innen 27 px
|
||||||
|
Namensschild gelb, links unten, "Noah" + "CEO"
|
||||||
|
```
|
||||||
|
|
||||||
|
Bilder liegen unter `assets/team/noah-bg.webp` und `noah-person.webp`.
|
||||||
|
|
||||||
|
> **Ohne die weichgezeichnete Ellipse des Originals.** Die hängt an
|
||||||
|
> `backdrop-filter`, und was `html-to-image` daraus macht, ist nicht verlässlich.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Maße, gemessen
|
||||||
|
|
||||||
|
Der Inhaltsblock (Überschrift plus Fließtext) endet je nach Textlänge bei
|
||||||
|
**y 415 bis 460**. Unten bleiben 92 px Rand. Für die Visualisierung stehen also
|
||||||
|
rund **800 px Höhe** zur Verfügung, und die soll sie auch füllen.
|
||||||
|
|
||||||
|
| Slide | Visualisierung: oben | Höhe | Luft zum Text | Abstand nach unten |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | 499 | 775 | 84 | 76 |
|
||||||
|
| 2 | 548 | 682 | 133 | 120 |
|
||||||
|
| 3 | 566 | 664 | 106 | 120 |
|
||||||
|
| 4 | 502 | 700 | 70 | 120 |
|
||||||
|
| 5 | 470 | 732 | 83 | 120 |
|
||||||
|
| 6 | 603 | 466 | 188 | 281 |
|
||||||
|
|
||||||
|
Slide 6 ist bewusst kleiner und steht dafür mittig in der freien Fläche. Ein
|
||||||
|
Formularfeld auf 700 px aufzublasen sieht aufgeblasen aus.
|
||||||
|
|
||||||
|
**Wie nachmessen:** Eine Kopie von `post2.html` anlegen, vor `</body>` ein
|
||||||
|
Skript hängen, das `getBoundingClientRect` der Elemente in `document.title`
|
||||||
|
schreibt, und mit
|
||||||
|
`Google\ Chrome --headless --dump-dom` auslesen. Auf `.holder .slide` warten,
|
||||||
|
die Seite baut asynchron auf.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Stand der Dinge
|
||||||
|
|
||||||
|
**Fertig:** Alle sechs Slides sind gebaut, die Texte auf Slide-Länge, die
|
||||||
|
Visualisierungen füllen das Format, Noahs Karte sitzt.
|
||||||
|
|
||||||
|
**Offen:**
|
||||||
|
|
||||||
|
1. **Export und Kontrolle.** Über `Datei → Alle Slides als ZIP`, 3-fach.
|
||||||
|
Danach jede Slide ansehen: der Export hat schon zweimal Bilder verschluckt,
|
||||||
|
die Prüfung meldet das inzwischen, aber ein Blick schadet nicht.
|
||||||
|
2. **Mitgestalter.** Falls Noahs Betriebsprofil mit drauf soll, **beim Posten**
|
||||||
|
einladen. Nachträglich geht es zwar, aber der Beitrag erscheint dann erst
|
||||||
|
nach der Annahme bei dessen Followern und die Ausspielung ist vorbei.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5a. Die Bildunterschrift
|
||||||
|
|
||||||
|
Grundlage ist Noahs Absatz von der Website, gekürzt und **aus der Ich-Form
|
||||||
|
geholt**: das Konto ist `hivegrid.de`, nicht Noah. Er wird deshalb in der
|
||||||
|
dritten Person eingeführt, danach trägt „wir" den Text — das sind die Gründer,
|
||||||
|
und Noah ist einer davon.
|
||||||
|
|
||||||
|
```
|
||||||
|
Ein Betrieb in vierter Generation hat nicht nur Erfahrung. Er hat auch Abläufe, die laufen, weil die Software es vorgibt.
|
||||||
|
|
||||||
|
Noah Geerkens führt so einen Betrieb, SHK, mit seiner Schwester. Da fängt die Geschichte von HiveGrid an.
|
||||||
|
|
||||||
|
Und wechseln? Kannst du vergessen. Da hängen Jahre an Stammdaten, Aufträgen und Buchhaltung drin. Kein Chef fährt seinen Betrieb ein halbes Jahr an die Wand, um am Ende dasselbe zu haben, nur woanders.
|
||||||
|
|
||||||
|
Wollten wir auch nicht. Wir wollten mehr: KI, Projektmanagement, Automatisierungen. Dass die Tools im Handwerksbetrieb endlich miteinander reden.
|
||||||
|
|
||||||
|
Genau dafür gibt es HiveGrid: kein Ersatz für deine Branchensoftware, sondern der Aufbau drumherum. Dein ERP bleibt, wo es ist.
|
||||||
|
|
||||||
|
Kein Startup-Projekt. Entstanden im Handwerksalltag, gebaut für den Handwerksalltag.
|
||||||
|
|
||||||
|
#handwerk #shk #handwerksbetrieb #sanitärheizungklima #branchensoftware #digitalisierungimhandwerk #erp #ki #hivegrid
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gegen die Vorgaben aus dem Redaktionsplan geprüft:**
|
||||||
|
|
||||||
|
| Vorgabe | Ist |
|
||||||
|
|---|---|
|
||||||
|
| Haken in den ersten 125 Zeichen | erster Absatz, 121 Zeichen |
|
||||||
|
| 300 bis 800 Zeichen | 793 |
|
||||||
|
| 5 bis 10 Hashtags, unten abgesetzt | 9 |
|
||||||
|
| Suchwörter zusätzlich im Fließtext | Handwerk, SHK, Branchensoftware, ERP, KI, Automatisierungen |
|
||||||
|
|
||||||
|
Zwei Dinge, die nicht drinstehen dürfen und geprüft sind: **„Verbinden statt
|
||||||
|
ersetzen"** ist für den Beitrag am 14.08. reserviert (§7), und **„Was kaum
|
||||||
|
einer sagt"** aus dem Website-Text ist raus — die Wendung war schon auf den
|
||||||
|
Slides gestrichen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Fallen, die schon zugeschnappt sind
|
||||||
|
|
||||||
|
- **Der offene Tab überschreibt die Datei.** `state/post2.json` gewinnt nur
|
||||||
|
**beim Laden**. Wer die Datei von außen ändert und dann im offenen Tab klickt,
|
||||||
|
bekommt den alten Stand zurückgeschrieben. Also erst neu laden, dann klicken.
|
||||||
|
Änderungen, die sicher ankommen müssen, gehören in den Ladepfad (`migrateState`).
|
||||||
|
- **Probier-Verschiebungen bleiben im Stand.** Slide 1 war unten abgeschnitten,
|
||||||
|
weil die Karte auf 700 px vergrößert und 250 px nach unten geschoben war. Wenn
|
||||||
|
eine Vorgabe im Code nicht wirkt, zuerst `edits` im Stand prüfen.
|
||||||
|
- **Der Server verschluckt Fehler.** Ein Filter in `log_message` ist über
|
||||||
|
`HTTPStatus` gestolpert und hat den Thread mitgerissen; die Seite blieb dann
|
||||||
|
ohne Meldung bei „Lade Screens ..." stehen. Behoben, aber wenn die Seite
|
||||||
|
wieder hängt: zuerst `serve.py` neu starten und in die Ausgabe schauen.
|
||||||
|
- **Texte von der Website gehören nicht auf Slides.** Siehe oben.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Was danach kommt
|
||||||
|
|
||||||
|
Der Redaktionsplan sieht als nächsten Beitrag **Fr 14.08. „Verbinden statt
|
||||||
|
ersetzen"** vor, ein Einzelbild mit der Kernaussage. Der Satz „Verbinden statt
|
||||||
|
ersetzen" ist dafür reserviert und taucht deshalb in keiner Bildunterschrift
|
||||||
|
vorher wörtlich auf.
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Post 4 · Workflow-Engine
|
||||||
|
|
||||||
|
**Erscheint Mi 19.08.2026, 12:00 Uhr.** Carousel, 4 Slides, 1080 × 1350 px.
|
||||||
|
Nummer 3 im Redaktionsplan, im Generator das vierte Projekt.
|
||||||
|
|
||||||
|
Diese Datei ist die Übergabe: alles, was zum Beitrag bekannt ist, damit man ohne
|
||||||
|
den Gesprächsverlauf weiterarbeiten kann. Der Redaktionsplan drumherum steht in
|
||||||
|
[`redaktionsplan/`](./redaktionsplan/), die Gestaltungsregeln in
|
||||||
|
[`rules.md`](./rules.md), der Aufbau der Datei in [`README.md`](./README.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Die vier Slides
|
||||||
|
|
||||||
|
| # | Schlüssel | Überschrift | Fließtext | Bild |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | `anfrage` | Automatisiere **deinen Betrieb.** | — | ein Ablauf auf dem Canvas |
|
||||||
|
| 2 | `ausloeser` | Erst der **Auslöser.** | Was den Ablauf startet. | Auslöser-Auswahl |
|
||||||
|
| 3 | `aktionen` | Dann die **Aktionen.** | Was danach passiert. | Aktionen-Auswahl |
|
||||||
|
| 4 | `vorlagen` | Fang nicht bei **null an.** | Starte mit einer fertigen Vorlage. | drei Vorlagen |
|
||||||
|
|
||||||
|
Fett = das Akzentwort. Die Titel-Slide trägt keinen Fließtext: der Ablauf
|
||||||
|
darunter sagt, was gemeint ist.
|
||||||
|
|
||||||
|
**Vier statt fünf bis sieben.** Der Redaktionsplan nennt eine Spanne, kein Soll.
|
||||||
|
Eine Slide, die nichts hinzufügt, kostet einen Wisch und gibt nichts zurück.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Die Bildunterschrift
|
||||||
|
|
||||||
|
```
|
||||||
|
In den meisten Handwerksbetrieben sitzt zwischen der Anfrage und der Aufgabe ein Mensch, der klicken muss.
|
||||||
|
|
||||||
|
Genau den nimmt die Workflow-Engine aus der Kette. Du wählst einen Auslöser: neue Anfrage, Kontakt geändert, Termin in Kürze, Zahlungsziel überschritten. Dann hängst du Aktionen dran: Aufgabe anlegen, Person zuweisen, Benachrichtigung senden. Ab da läuft die Automatisierung von selbst.
|
||||||
|
|
||||||
|
Und wenn die Angaben mal nicht reichen, geht es den anderen Weg: nachfragen statt liegenlassen. Kein Vorgang fällt hinten runter.
|
||||||
|
|
||||||
|
Zusammengeklickt, Kästchen für Kästchen. Ohne eine Zeile Code. Und wer nicht bei null anfangen will, nimmt eine fertige Vorlage und baut sie um.
|
||||||
|
|
||||||
|
#handwerk #shk #handwerksbetrieb #digitalisierungimhandwerk #automatisierung #workflow #prozesse #hivegrid
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gegen die Vorgaben aus dem Redaktionsplan geprüft:**
|
||||||
|
|
||||||
|
| Vorgabe | Ist |
|
||||||
|
|---|---|
|
||||||
|
| Haken in den ersten 125 Zeichen | erster Absatz, 106 Zeichen |
|
||||||
|
| 300 bis 800 Zeichen | 671 |
|
||||||
|
| 5 bis 10 Hashtags, unten abgesetzt | 8 |
|
||||||
|
| Suchwörter zusätzlich im Fließtext | Handwerk, Automatisierung, Workflow, Auslöser, Aktionen, Vorlage, Anfrage, Aufgabe |
|
||||||
|
|
||||||
|
Drei Wendungen kommen bewusst **nicht** vor: „Verbinden statt ersetzen" gehört
|
||||||
|
Post 3, „Wenn-dann statt Handarbeit" ist die Überschrift von Post 1, und
|
||||||
|
Em-Dashes verbietet `rules.md` §4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Woher die Texte kommen
|
||||||
|
|
||||||
|
Alle Produktbegriffe stehen wörtlich in der Quelle:
|
||||||
|
|
||||||
|
| Was | Quelle |
|
||||||
|
|---|---|
|
||||||
|
| Knotennamen und ihre Farben | `assets/screens/workflow.png` |
|
||||||
|
| Bausteinliste, Rubriken, Beschreibungen | `assets/screens/workflow-bausteine.png` |
|
||||||
|
| Artnamen, Zeichen, Farbzuordnung | `NodeWorkflowEngine.tsx` im Pitch-Paket |
|
||||||
|
| Vorlagennamen und Startbausteine | `GENERAL_WORKFLOW_PRESETS` im Click-Dummy |
|
||||||
|
| „Auslöser wählen, Aktionen dranhängen, fertig" | Website, `LandingPageClient.tsx` |
|
||||||
|
| „Starte mit einer fertigen Vorlage …" | Website, `lib/produkt-daten.ts` |
|
||||||
|
|
||||||
|
Überschriften und Fließtexte sind Zuspitzung. Sie behaupten nichts, was nicht in
|
||||||
|
einer dieser Quellen steht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Drei Stellen, an denen bewusst abgewichen wird
|
||||||
|
|
||||||
|
1. **Slide 1 lässt einen Schritt aus.** Zwischen „Formular eingegangen" und
|
||||||
|
„Passender Lead?" steht im Screenshot noch „Daten vereinheitlichen". Alle
|
||||||
|
drei echten Abläufe verzweigen erst an Position 3 oder 4, und das füllt ein
|
||||||
|
hochkantes Format schlecht. Ein Ablauf, den es genau so gibt, ist es damit
|
||||||
|
nicht mehr; einer, den es geben könnte, schon.
|
||||||
|
2. **Slide 4 nennt Demo-Abläufe „Vorlagen".** Dass es eine Vorlagen-Bibliothek
|
||||||
|
gibt, steht als Merkmal auf der Website; dass sie diese drei enthält, steht
|
||||||
|
nirgends. Im Werkzeug werden sie über `workflowId` geladen, nicht als
|
||||||
|
Galerie gezeigt.
|
||||||
|
3. **Kein PDS im Beitrag.** Von den sechs Vorlagen nennen drei das
|
||||||
|
Branchenprogramm. Übrig bleibt genau `GENERAL_WORKFLOW_PRESETS` — keine
|
||||||
|
Auswahl nach Geschmack, sondern die vollständige zweite Gruppe. Gekürzt
|
||||||
|
wurde nichts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Stand der Dinge
|
||||||
|
|
||||||
|
**Fertig:** Alle vier Slides sind gebaut und exportieren sauber, je
|
||||||
|
2160 × 2700 px (`01-anfrage-hell.png` bis `04-vorlagen-hell.png`).
|
||||||
|
|
||||||
|
**Offen:**
|
||||||
|
|
||||||
|
1. **Export und Kontrolle.** Über *Datei → Alle Slides als ZIP*. Der Export
|
||||||
|
prüft Screenshot-Flächen nach; dieser Beitrag hat keine, die Prüfung läuft
|
||||||
|
also leer durch. Ein Blick auf die Dateien schadet trotzdem nicht.
|
||||||
|
2. **Mitgestalter.** Falls jemand mit drauf soll, **beim Posten** einladen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Eine Falle, die schon zugeschnappt ist
|
||||||
|
|
||||||
|
**Regeln aus dem CSS nimmt man als ganzen Block.** Beim Entfernen der
|
||||||
|
Karten-Regeln von Post 2 blieben rund neunzig Zeilen Deklarationen ohne
|
||||||
|
Selektor stehen, weil nur die Selektorzeilen weggeschnitten waren. CSS fängt
|
||||||
|
sich davon nicht wieder: der Parser überspringt bis zur nächsten Marke, und
|
||||||
|
dabei verschwand `[data-edit]`. Damit war das Verschieben von Elementen im
|
||||||
|
Canvas tot, ohne dass die Seite einen Fehler zeigte.
|
||||||
|
|
||||||
|
Sichtbar wurde es erst, als ein gespeicherter Versatz von 3 px nicht mehr
|
||||||
|
ankam. Wer hier Regeln entfernt, sieht danach im Browser nach, ob
|
||||||
|
`document.styleSheets` sie noch alle kennt.
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
# Post 5 · Projektmanagement
|
||||||
|
|
||||||
|
Vierter Beitrag der Produktreihe, Termin **Fr 21.08.** laut Redaktionsplan,
|
||||||
|
Stichwort dort: „Der Überblick, den jeder Betrieb vermisst."
|
||||||
|
|
||||||
|
Hell, Theme `modern`, Punktgrund, Text zentriert — dieselbe Anlage wie Post 4,
|
||||||
|
damit die Reihe als Reihe erkennbar bleibt. Datei `post5.html`, Stand in
|
||||||
|
`state/post5.json`, erreichbar über das Projektmenü jeder Seite und über
|
||||||
|
`uebersicht.html`.
|
||||||
|
|
||||||
|
**Fünf Slides, und jede zeigt einen anderen Bildschirm.** Das Demo-Projekt hat
|
||||||
|
für die Pipeline vier davon — Schablone, Board, Liste, Detail — und die tragen
|
||||||
|
zusammen mehr als ein einzelner. Die fünfte Slide ist die Karte aus der Nähe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Die fünf Slides
|
||||||
|
|
||||||
|
| # | Tag | Überschrift | Screen | Was zu sehen ist |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | Projektmanagement | Von der Anfrage **bis zur Abnahme.** | 4A | Das Board: drei Phasen mit Anzahl, Summe und Karten |
|
||||||
|
| 2 | Die Karte | Alles Wichtige **auf einer Karte.** | 4A | Eine Karte groß |
|
||||||
|
| 3 | Zwei Pipelines | Vom Verkauf **in die Abwicklung.** | 4B | Ein Projekt in „Auftragsabwicklung SHK", mit dem Knopf für den Pipelinewechsel |
|
||||||
|
| 4 | Die Liste | Board oder **Liste.** | 4C | Dieselben Projekte als Tabelle, mit gesperrten Auftragswerten |
|
||||||
|
| 5 | Die Schablone | Deine Felder, **deine Regeln.** | 3 | Der Feldeditor, offen auf „Aufmaß erledigt" |
|
||||||
|
|
||||||
|
**Slide 3 ist die Pointe.** Das Demo hat nicht eine Pipeline, sondern zwei:
|
||||||
|
`Vertrieb: Anlagen-Verkauf` und `Auftragsabwicklung SHK`. Ein Projekt liegt in
|
||||||
|
einer davon und wechselt über den Knopf `Pipeline` in die andere. Das ist der
|
||||||
|
Unterschied zwischen Vertriebs- und Abwicklungs-Projektmanagement, und er ist
|
||||||
|
keine Auslegung: die beiden Namen stehen in
|
||||||
|
`PipelineBoardInteractive.tsx` und `S02PipelinesDetailansicht.tsx`.
|
||||||
|
|
||||||
|
**Slide 5 erklärt, warum das eine Software sein kann.** Die Felder hängen an
|
||||||
|
der Pipeline, nicht am Programm. Siebzehn Feldtypen, dazu drei Stufen unter
|
||||||
|
`Pflicht ab Board-Spalte`. Ein SHK-Betrieb legt sich „Aufmaß erledigt" an,
|
||||||
|
ein anderer etwas anderes.
|
||||||
|
|
||||||
|
Slide 1 trägt das Board, weil sie im Profilraster eine Kachel von rund 150 px
|
||||||
|
ist. Dort ist kein Wort lesbar; was ankommt, sind Säulen aus Kärtchen.
|
||||||
|
|
||||||
|
Sie trägt **keinen Fließtext**, wie die Titelbilder von Post 3 und Post 4.
|
||||||
|
„Jedes Projekt im Griff" stand zuerst als Überschrift da: zwei Zeilen von
|
||||||
|
dreizehn und neun Zeichen liegen als schmaler Block in einer breiten Fläche,
|
||||||
|
und die Slide wirkte dadurch leer. Jetzt trägt sie die zweite Hälfte derselben
|
||||||
|
Überschrift aus `lib/produkt-daten.ts`. Die Lücke über dem Board schließt
|
||||||
|
`visBottom: 215` statt eines Satzes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Die Bildunterschrift
|
||||||
|
|
||||||
|
```
|
||||||
|
Die häufigste Frage im Handwerksbüro ist keine fachliche. Sie lautet: wo steht das eigentlich gerade?
|
||||||
|
|
||||||
|
In HiveGrid steht jedes Projekt in genau einer Phase. Auf der Karte siehst du Gewerk, Auftragswert, Priorität, wer zuständig ist und was als Nächstes ansteht, ohne sie zu öffnen.
|
||||||
|
|
||||||
|
Sobald der Auftrag steht, wechselt das Projekt die Pipeline. Aus dem Verkauf wird die Abwicklung, mit denselben Daten. Verkauft ist nicht fertig.
|
||||||
|
|
||||||
|
Ob Board oder Liste, entscheidest du. Welche Felder eine Pipeline trägt, ebenfalls: ein SHK-Betrieb legt sich ein Häkchen fürs Aufmaß an, ein anderer Betrieb etwas anderes.
|
||||||
|
|
||||||
|
Kein Suchen, kein Nachfragen, kein Zettelchaos.
|
||||||
|
|
||||||
|
#handwerk #shk #handwerksbetrieb #digitalisierungimhandwerk #projektmanagement #auftragsabwicklung #vertrieb #hivegrid
|
||||||
|
```
|
||||||
|
|
||||||
|
| Vorgabe | Ist |
|
||||||
|
|---|---|
|
||||||
|
| Haken in den ersten 125 Zeichen | erster Absatz, 101 Zeichen |
|
||||||
|
| 300 bis 800 Zeichen | 651 |
|
||||||
|
| 5 bis 10 Hashtags, unten abgesetzt | 8 |
|
||||||
|
| Suchwörter zusätzlich im Fließtext | Handwerk, Projekt, Phase, Auftrag, Vertrieb, Abwicklung, Pipeline, Aufmaß |
|
||||||
|
|
||||||
|
„Kein Suchen, kein Nachfragen, kein Zettelchaos" steht wörtlich so schon im
|
||||||
|
Fließtext der Projektmanagement-Slide von Post 1. Dieselbe Reihe darf denselben
|
||||||
|
Satz zweimal benutzen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Woher die Angaben kommen
|
||||||
|
|
||||||
|
Quelle ist durchgehend das Pitch-Paket
|
||||||
|
(*fertig bombo · Kundentermin 30.07.2026*), Ordner `hivegrid-pitch`.
|
||||||
|
|
||||||
|
| Was | Woher |
|
||||||
|
|---|---|
|
||||||
|
| Die zehn Phasennamen und ihre Reihenfolge | `PipelineBoardInteractive.tsx`, `STAGES` |
|
||||||
|
| Board / Liste | die `ToggleGroup` in der Kopfzeile, sie kennt genau diese zwei |
|
||||||
|
| „Vertrieb: Anlagen-Verkauf" | Seitentitel desselben Screens |
|
||||||
|
| Alle Projekte auf Slide 1 und 2 | `INITIAL_PROJECTS`, wörtlich |
|
||||||
|
| Anzahl und Summe je Spalte | aus denselben 41 Datensätzen gezählt und addiert |
|
||||||
|
| Gewerk zu Farbton | `CATEGORY_TONES` |
|
||||||
|
| Hoch / Mittel / Niedrig, Balkenzahl, Farbrollen | `ProjectCard.tsx`, `PriorityBadge` und `PriorityIndicator` |
|
||||||
|
| Aufbau der Karte, Umbruch auf zwei Zeilen, getönter Fuß | `ProjectCardRoot`, Klasse für Klasse |
|
||||||
|
| Farbe des Kürzels | `avatarColor` je Projekt, aufgelöst über `Avatar.tsx` |
|
||||||
|
| Projekt, Pipeline, Nummer und Rubriken auf Slide 3 | `S02PipelinesDetailansicht.tsx` |
|
||||||
|
| Zeilen und Spaltenköpfe auf Slide 4 | `S03ProjektListe.tsx` |
|
||||||
|
| Feldtypen und `Pflicht ab Board-Spalte` auf Slide 5 | `S05DetailSchabloneEditMode.tsx` |
|
||||||
|
| Plättchen-, Rand- und Textfarben | `ui/theme.css` |
|
||||||
|
| Alle Symbole | `@subframe/core`, Pfade aus dem Paket kopiert |
|
||||||
|
| „Jedes Projekt im Griff" | Überschrift in `lib/produkt-daten.ts` der Website |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Wo bewusst abgewichen wird
|
||||||
|
|
||||||
|
**Slide 1 zeigt nur das Board selbst.** Seitentitel, der Umschalter
|
||||||
|
Board/Liste und die Filterleiste standen einmal darüber und sind alle drei
|
||||||
|
wieder entfallen: ein Titelbild soll in einem Blick sagen, worum es geht, und
|
||||||
|
was im Profilraster ankommt, sind Säulen aus Kärtchen. Drei Reihen
|
||||||
|
Bedienoberfläche darüber liest niemand. Wie die Pipeline heißt, sagt Slide 3;
|
||||||
|
dass es auch eine Liste gibt, sagt Slide 4.
|
||||||
|
|
||||||
|
**Zwei bis drei Karten je Spalte, der Kopf zählt vier.** Außen zwei, in der
|
||||||
|
Mitte drei. So ist es im Werkzeug auch: eine Spalte zeigt, was aufs
|
||||||
|
Bild passt, der Rest kommt beim Scrollen. Gleich hohe Säulen sahen aus wie eine
|
||||||
|
Tabelle, ungleiche sehen aus wie ein Board, auf dem gearbeitet wird.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Slide 4 zeigt vier von neun Tabellenspalten und sieben von elf Zeilen.** Die
|
||||||
|
Tabelle hat Projekt-Nr., Projektname, Board, Spalte, Auftragswert,
|
||||||
|
Kunde / Kontaktrolle, Termin / Hinweis, Tags und Sachbearbeiter. Neun
|
||||||
|
nebeneinander wären auf 1080 px unter zehn Pixel Schrift.
|
||||||
|
|
||||||
|
Die Spalte Sachbearbeiter ist zuletzt noch entfallen, damit
|
||||||
|
„Auftragswert gesperrt" ungekürzt hineinpasst. Der Wortlaut ist der Grund,
|
||||||
|
warum diese Spalte überhaupt auf der Slide steht — ihn auf „gesperrt" zu
|
||||||
|
stutzen wäre der bequeme Weg gewesen und kein Zitat mehr. Weggelassen ist nur
|
||||||
|
Vorhandenes, dazugedichtet nichts.
|
||||||
|
|
||||||
|
**Slide 3 stellt die Rubriken untereinander, das Werkzeug stellt sie
|
||||||
|
nebeneinander.** Hochkant ist untereinander lesbar, die Reihenfolge bleibt.
|
||||||
|
|
||||||
|
**Zwei Rubriken fehlen auf Slide 3.** `PDS-INTEGRATION`, weil der Beitrag sich
|
||||||
|
an Betriebe allgemein richtet und nicht an die, die zufällig dieses eine
|
||||||
|
Branchenprogramm fahren — dieselbe Entscheidung wie bei Post 4. Und
|
||||||
|
`ADRESSDATEN · TEST A (ZUSATZADRESSEN AN)`, weil das ein Arbeitstitel aus der
|
||||||
|
Entwicklung ist und kein Bildschirmtext.
|
||||||
|
|
||||||
|
**Die Karte ist 320 breit wie im Original (`w-80`), der Betrag steht auf 26
|
||||||
|
statt größer.** Bei „Niedrig" ist das Prioritätsplättchen breiter als bei
|
||||||
|
„Hoch", und der Betrag stieß dagegen.
|
||||||
|
|
||||||
|
**Die Maßstäbe sind aufeinander abgestimmt.** Slide 1, 3, 4 und 5 zeigen ihre
|
||||||
|
Schrift zwischen 16 und 23 px; nur Slide 2 ist als Nahaufnahme gedacht und
|
||||||
|
liegt bei 47 px. Vorher stand sie auf 59 bis 73 px — eine einzelne Karte wirkte
|
||||||
|
damit größer als das ganze Board davor, und die Reihe kippte. Wer an einer
|
||||||
|
Zeichnung dreht, sollte die Tabelle in §6 danach nachrechnen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Eine Richtigstellung
|
||||||
|
|
||||||
|
**Ein früherer Stand dieses Beitrags behauptete, die Detailansicht sei nur ein
|
||||||
|
Entwurfs-PNG.** Das war falsch. Geprüft worden waren die Beschriftungen des
|
||||||
|
Bildes `07_Screen-4b_Karten-Detail.png` — `KUNDENDATEN`, `OBJEKTDATEN`,
|
||||||
|
`AUFTRAG & FÖRDERUNG`, `Wahrscheinlichkeit`, `Heizlast`, `Lead-Quelle` —, und
|
||||||
|
die kommen im Code tatsächlich nirgends vor. Daraus wurde der falsche Schluss
|
||||||
|
gezogen, es gebe den Bildschirm nicht.
|
||||||
|
|
||||||
|
Es gibt ihn: `app/(screens)/4b/page.tsx` rendert
|
||||||
|
`S02PipelinesDetailansicht.tsx`, 3150 Zeilen, mit den Rubriken `GRUNDDATEN`,
|
||||||
|
`PLANUNG & VERANTWORTLICHE`, `PROJEKTKONTAKTDATEN`, `KONTAKTE`, `FIRMENDATEN`,
|
||||||
|
`ANSPRECHPARTNER`, `PROJEKTADRESSE` und `ZUSATZADRESSEN`. Das PNG ist ein
|
||||||
|
älterer Entwurf desselben Bildschirms, nicht sein Beleg.
|
||||||
|
|
||||||
|
**Die Lehre ist nicht „Screenshots lügen", sondern: die Abwesenheit einer
|
||||||
|
Zeichenkette beweist die Abwesenheit eines Bildschirms nicht.** Für die zweite
|
||||||
|
Frage muss man die Routen ansehen, nicht die Wörter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Stand der Dinge
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Slides | 5 |
|
||||||
|
| Format | 1080 × 1350, Ausgabe zweifach: 2160 × 2700 |
|
||||||
|
| Luft zwischen Text und Zeichnung | 224 / 239 / 123 / 187 / 133 px (Grenze 40) |
|
||||||
|
| Zeichnung ragt über die Seite | nein, alle innerhalb der 92-px-Schutzzone |
|
||||||
|
| Stylesheet vollständig | 214 Regeln, `[data-edit]` vorhanden |
|
||||||
|
| Konsole | keine Fehler |
|
||||||
|
| Ausgabeweg | geprüft, `01-board-hell.png` bis `05-schablone-hell.png` |
|
||||||
|
|
||||||
|
Geprüft wird mit den Hilfsskripten aus dem Sitzungsordner: `flaeche.mjs` misst
|
||||||
|
über `getBBox()` die tatsächlich gezeichnete Fläche je Slide, `kopf5.mjs` die
|
||||||
|
Breite jeder Überschriftzeile (sie ist `nowrap`, eine zu lange Zeile bricht
|
||||||
|
nicht um, sondern läuft über den Rand), `css5.mjs` zählt die Regeln im
|
||||||
|
Stylesheet und liest die Konsole mit, `export.mjs` fährt den echten Ausgabeweg
|
||||||
|
und fängt `/save` ab, damit beim Prüfen nichts in *Downloads* landet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Zwei Fallen aus diesem Bau
|
||||||
|
|
||||||
|
**Ein Screenshot ist keine Quelle.** Die Karte war zuerst aus `pipelines.png`
|
||||||
|
abgemalt. Drei Sachen lagen daneben, und keine davon war im Bild zu erkennen:
|
||||||
|
Priorität, Balken und Kürzel stehen im Original als Gruppe rechts und hängen
|
||||||
|
über die Kante des Fußes, im Fuß steht über dem Termin die Zeitspanne des
|
||||||
|
Projekts, und die Balken sitzen in einem eigenen umrandeten Kasten. Erst
|
||||||
|
`ProjectCard.tsx` hat das gezeigt.
|
||||||
|
|
||||||
|
**Ein Prüfskript hat den Projektstand verstellt.** `export.mjs` schaltet die
|
||||||
|
Slides mit der Pfeiltaste weiter. Die Seite startet aber im Bearbeiten-Modus
|
||||||
|
(`editMode = "an"`), und dort verschieben Pfeiltasten das gewählte Element,
|
||||||
|
statt die Slide zu wechseln. Jeder Prüflauf hat die Zeichnungen also ein Stück
|
||||||
|
gerückt und das in `state/post5.json` geschrieben: am Ende stand dort
|
||||||
|
`vis: {dx: 3, dy: -69}` auf Slide 1 und auf Slide 2 sogar ein `size: 762`, das
|
||||||
|
die im Code eingestellten 720 überschrieb.
|
||||||
|
|
||||||
|
Gemerkt habe ich es an Messwerten, die im Code nirgends standen — eine Slide
|
||||||
|
meldete 89 px Rand rechts bei eingestellten 92, eine andere 177 px unten bei
|
||||||
|
eingestellten 120. **Zahlen, die man sich nicht erklären kann, sind ein Befund
|
||||||
|
und keine Messungenauigkeit.** `export.mjs` verlässt jetzt vor dem Blättern den
|
||||||
|
Bearbeiten-Modus und schreibt den Stand am Ende zurück, falls er sich doch
|
||||||
|
verändert hat.
|
||||||
|
|
||||||
|
**SVG bricht keinen Text um, und Breite schätzen geht schief.** Die
|
||||||
|
Plättchenbreite war anfangs aus der Zeichenzahl mal 0,52 em gerechnet. Das
|
||||||
|
stimmt für „Heizung" und geht bei „Wärmepumpe" daneben; das Wort stand über dem
|
||||||
|
Rand seines Plättchens. Gemessen wird jetzt mit einem Canvas in derselben
|
||||||
|
Schrift, die das SVG später zeichnet. Aus derselben Messung kommt der
|
||||||
|
Zeilenumbruch der Titel, der im Original `line-clamp-2` heißt.
|
||||||
@@ -0,0 +1,938 @@
|
|||||||
|
# Post 6 · Team-Chat
|
||||||
|
|
||||||
|
Fünfter Beitrag der Produktreihe, Termin **Mi 26.08.** laut Redaktionsplan
|
||||||
|
(von Mo 24.08. verschoben). Stichwort dort: „Ein Kanal pro Projekt, Foto rein,
|
||||||
|
Aufgabe raus."
|
||||||
|
|
||||||
|
Hell, Theme `modern`, Punktgrund, Text zentriert — dieselbe Anlage wie Post 4
|
||||||
|
und 5. Datei `post6.html`, Stand in `state/post6.json`, erreichbar über das
|
||||||
|
Projektmenü jeder Seite und über `uebersicht.html`.
|
||||||
|
|
||||||
|
**Fünf Slides, dazu ein Bewegtbild.** DSGVO und deutsche Server hätten einen
|
||||||
|
weiteren Punkt ergeben, dafür gibt es aber keinen Bildschirm. Der steht in der
|
||||||
|
Bildunterschrift, wo eine Aussage ohne Bild hingehört.
|
||||||
|
|
||||||
|
Dazu kommt die erste Animation im Repo. Sie ist **keine Slide**: sie steht in
|
||||||
|
`ANIMS` statt in `SLIDES`, liegt nicht im Streifen und wird vom Bildexport
|
||||||
|
nicht angefasst. Im Carousel ist sie ein eigenes Element, das mitkommen kann
|
||||||
|
oder eben nicht. Bedient wird sie im Bereich unter dem Canvas, gebaut mit
|
||||||
|
einem eigenen Knopf. Wie das funktioniert, steht im
|
||||||
|
[README unter „Animation"](./README.md#animation); was diese eine zeigt, in
|
||||||
|
§7 weiter unten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Die fünf Slides und das Bewegtbild
|
||||||
|
|
||||||
|
| # | Tag | Überschrift | Was zu sehen ist |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Team-Chat | Büro und Baustelle, **endlich im selben Chat.** | Kanalliste und Verlauf nebeneinander, mit Filterzeile und Eingabefeld |
|
||||||
|
| 2 | Die Kanäle | Ein Kanal **pro Projekt.** | Links das Durcheinander als Zeichnung, rechts die Kanalliste |
|
||||||
|
| 3 | Die Aufgabe | Foto rein, **Aufgabe raus.** | Baustellenfotos, die Aufgabe daraus, die Bestätigung im Projekt |
|
||||||
|
| 4 | Das Pluszeichen | Nicht nur **Text.** | Das Menü über der Eingabezeile, aus der es aufklappt |
|
||||||
|
| 5 | Bald verfügbar | Der Chat für unterwegs **wird gerade gebaut.** | Zwei Ausschnitte der Oberfläche, versetzt übereinander, mit dem Absperrband darüber |
|
||||||
|
|
||||||
|
Dazu, außerhalb des Streifens:
|
||||||
|
|
||||||
|
| Schlüssel | Tag | Überschrift | Was zu sehen ist |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `animChat` | Bewegtbild | Chatnachrichten werden **Aufgaben im Projekt.** | 6 s Animation: eine Nachricht wird angeklickt, im Menü „Aufgabe erstellen" gewählt, und die Aufgabe klappt in der Nachricht auf |
|
||||||
|
|
||||||
|
**Slide 3 ist der Kern.** Der Redaktionsplan verlangt „Foto rein, Aufgabe
|
||||||
|
raus", und genau das liegt im Werkzeug als Abfolge vor: Tobias schickt die
|
||||||
|
Baustellenfotos, darauf folgt eine Nachricht mit angehängter Aufgabe, darauf
|
||||||
|
eine Systemzeile „Aufgabe „Materialliste prüfen" im Projekt erstellt". Die
|
||||||
|
letzte Zeile macht den Unterschied: die Aufgabe liegt danach im Projekt und
|
||||||
|
nicht nur im Verlauf.
|
||||||
|
|
||||||
|
Slide 1 trägt den ganzen Chat, weil sie im Profilraster eine Kachel von rund
|
||||||
|
150 px ist. Dort ist kein Wort lesbar; was ankommt, sind zwei Spalten mit
|
||||||
|
Blasen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Die Bildunterschrift
|
||||||
|
|
||||||
|
```
|
||||||
|
Im Handwerk läuft die halbe Baustelle über WhatsApp. Das Foto von letzter Woche findet trotzdem keiner wieder.
|
||||||
|
|
||||||
|
In HiveGrid hat jedes Projekt seinen eigenen Kanal, mit Projektnummer. Daneben stehen Direktnachrichten und Gruppen, aber eben nicht alles in einem Verlauf.
|
||||||
|
|
||||||
|
Ein Foto von der Baustelle geht rein, alle sehen es sofort. Aus „mach das mal“ wird mit einem Klick eine echte Aufgabe: mit Frist, mit Namen, im Projekt.
|
||||||
|
|
||||||
|
Dokument, Sprachnachricht, Kontakt, Termin. Was sonst im Chat-Verlauf verschwindet, hängt hier am Projekt.
|
||||||
|
|
||||||
|
Kundendaten gehören nicht in eine WhatsApp-Gruppe. Bei HiveGrid liegen sie verschlüsselt auf deutschen Servern.
|
||||||
|
|
||||||
|
#handwerk #shk #handwerksbetrieb #digitalisierungimhandwerk #teamchat #baustelle #kommunikation #hivegrid
|
||||||
|
```
|
||||||
|
|
||||||
|
| Vorgabe | Ist |
|
||||||
|
|---|---|
|
||||||
|
| Haken in den ersten 125 Zeichen | erster Absatz, 110 |
|
||||||
|
| 300 bis 800 Zeichen | 644 |
|
||||||
|
| 5 bis 10 Hashtags, unten abgesetzt | 8 |
|
||||||
|
| Suchwörter zusätzlich im Fließtext | Handwerk, Baustelle, Projekt, Kanal, Aufgabe, Chat, Kontakt, Termin |
|
||||||
|
|
||||||
|
Der letzte Absatz steht **wörtlich** so als Merkmal „DSGVO-konform" in
|
||||||
|
`lib/produkt-daten.ts` der Website. „Aus „mach das mal" wird mit einem Klick
|
||||||
|
eine echte Aufgabe" ebenfalls, als Merkmal „Aufgaben-Integration".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Woher die Angaben kommen
|
||||||
|
|
||||||
|
Quelle ist `components/general/GeneralTeamChatPage.tsx` aus dem Pitch-Paket
|
||||||
|
(*fertig bombo · Kundentermin 30.07.2026*).
|
||||||
|
|
||||||
|
| Was | Woher |
|
||||||
|
|---|---|
|
||||||
|
| Alle acht Kanäle: Titel, Vorschau, Zeit, Zuordnung, Nadel, Ungelesen | `CHATS`, wörtlich |
|
||||||
|
| Der Verlauf: Autoren, Texte, Zeiten, Anhänge | `INITIAL_MESSAGES.waermepumpe`, wörtlich |
|
||||||
|
| Aufbau einer Listenzeile (drei Zeilen) | die Zeilen 170 bis 172 der Quelle |
|
||||||
|
| Aufbau einer Nachricht, Anhangkarte, Systemzeile | `MessageBubble` und `AttachmentCard` |
|
||||||
|
| Sinnbild je Kanalsorte (Koffer, Personen, Kürzel) | `ChatAvatar` |
|
||||||
|
| Kopfzeile „P-2026-0042 · 8 Mitglieder · aktiv" | `ChatHeader` |
|
||||||
|
| Filterzeile Alle / Projekte / Direkt / Ungelesen | der Typ `ChatFilter` |
|
||||||
|
| Das Menü hinter dem Plus, samt Trenner und Reihenfolge | `Composer` (Beschriftungen oben geändert, §4) |
|
||||||
|
| Eingabefeld „Nachricht schreiben …" | derselbe Ort |
|
||||||
|
| Farben, Ränder, Plättchentöne | `ui/theme.css` |
|
||||||
|
| Alle Symbole | `@subframe/core`, Pfade aus dem Paket kopiert |
|
||||||
|
| „Büro und Baustelle, endlich im selben Chat." | Überschrift in `lib/produkt-daten.ts` |
|
||||||
|
|
||||||
|
**Vom Chat gibt es nur eine Fassung.** Anders als bei der Pipeline, die mit
|
||||||
|
Board, Liste, Detail und Schablone vier eigene SHK-Bildschirme hat, liegt der
|
||||||
|
Chat allein unter `/allgemein/team-chat`. Die Inhalte darin sind trotzdem aus
|
||||||
|
dem Gewerk: Wärmepumpe Müller, Montagetermin, Baustellenfotos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Wo bewusst abgewichen wird
|
||||||
|
|
||||||
|
**Alles, was einen Chat kennzeichnet, ist grün.** Projektkanal, Gruppe und
|
||||||
|
Direktnachricht tragen dieselbe Marke (`MARKE`, Fläche `#DCFCE4`, Rand
|
||||||
|
`#B7EDC6`, Zeichen `#148134`) — in der Liste wie im Verlauf. Vorher trug der
|
||||||
|
Projektkanal Lila, die Gruppe Grau und jede Person ihre eigene Farbe: vier
|
||||||
|
Familien in einer Liste, und keine davon sagte etwas, das die Zeile nicht schon
|
||||||
|
sagt. Die Kürzel unterscheiden die Personen, dafür braucht es keine vier
|
||||||
|
Farbtöne.
|
||||||
|
|
||||||
|
**Die vier Profilbilder zeigen niemanden.** `avatarKreis()` nimmt ein Foto,
|
||||||
|
sobald eines unter `assets/eigene/portrait-<kürzel>.<endung>` liegt (also
|
||||||
|
`portrait-sk`, `portrait-mb`, `portrait-tw`, `portrait-ms`), rund beschnitten
|
||||||
|
mit demselben Rand wie der Kürzelkreis; sonst steht dort weiter das Kürzel.
|
||||||
|
`preloadAssets()` holt alles aus `assets/eigene` ohnehin mit, und der Export
|
||||||
|
bekommt sie damit als Daten-URL statt als Verweis. Nur Menschen bekommen eins
|
||||||
|
— Projektkanal und Gruppe behalten ihr Sinnbild.
|
||||||
|
|
||||||
|
**Es sind Aufnahmen von Unsplash, keine erzeugten Gesichter.** Vier Portraits,
|
||||||
|
über die Suche nach `professional headshot` gefunden:
|
||||||
|
|
||||||
|
| Datei | Rolle | Quelle |
|
||||||
|
|---|---|---|
|
||||||
|
| `portrait-sk` | Sandra Koch, Projektleitung | [SJvDxw0azqw](https://unsplash.com/photos/SJvDxw0azqw) |
|
||||||
|
| `portrait-mb` | Maike Berger | [ZAo0cKz_IKM](https://unsplash.com/photos/ZAo0cKz_IKM) |
|
||||||
|
| `portrait-tw` | Tobias Wagner, Montage | [TTdJV-K1IUg](https://unsplash.com/photos/TTdJV-K1IUg) |
|
||||||
|
| `portrait-ms` | Michael Schuster, Kundendienst | [7YVZYZeITc8](https://unsplash.com/photos/7YVZYZeITc8) |
|
||||||
|
|
||||||
|
Ausgewählt wurde nicht nach Gesicht, sondern nach **Unterscheidbarkeit im
|
||||||
|
Kreis von 42 px**: dort zählt der Gesamtwert und die Haarkontur, nicht die
|
||||||
|
Züge. Die vier decken hell, mittelhell, dunkel und mittel ab, dazu vier
|
||||||
|
verschiedene Silhouetten — kurze Locken, schulterlang, Schiebermütze mit
|
||||||
|
Bart, kurz und glatt. Bei Tobias trägt die Mütze die Erkennbarkeit fast
|
||||||
|
allein; sie ist auf dieser Größe deutlicher als jedes Gesichtsmerkmal.
|
||||||
|
|
||||||
|
> **Was dabei offen bleibt, und zwar wissentlich.** Die Unsplash-Lizenz deckt
|
||||||
|
> das Urheberrecht des Fotografen — **kein Model Release**. Nachgelesen an der
|
||||||
|
> Quelle: Unsplash sagt zu erkennbaren Personen nichts, Pexels schreibt
|
||||||
|
> „Identifiable people may not appear in a bad light or in a way that is
|
||||||
|
> offensive", was eine Einschränkung ist und keine Erlaubnis. Ein fremdes
|
||||||
|
> Gesicht als „Sandra Koch, Projektleitung" auf dem Firmenkanal berührt §22
|
||||||
|
> KUG und die DSGVO, nicht das Urheberrecht. Ein Zwischenstand wich deshalb
|
||||||
|
> auf erzeugte Gesichter aus (StyleGAN2 über `thispersondoesnotexist.com`);
|
||||||
|
> das ist zugunsten echter Aufnahmen zurückgenommen worden. Wer es
|
||||||
|
> rechtssicher will, braucht **model-released Stock** (Adobe Stock, iStock,
|
||||||
|
> kostenpflichtig) — das ist der einzige Weg, der die Frage beseitigt statt
|
||||||
|
> sie zu tragen.
|
||||||
|
|
||||||
|
**Der Zuschnitt braucht keinen Füllton mehr.** Ein ordentliches
|
||||||
|
Business-Portrait hat Luft über dem Kopf; ein quadratischer Ausschnitt, auf
|
||||||
|
das Gesicht gesetzt, füllt den Kreis von selbst. Die erzeugten Gesichter
|
||||||
|
davor waren bis an den Rand beschnitten und brauchten deshalb erst ein
|
||||||
|
Herauszoomen und dann etwas, womit der freiwerdende Rand zu füllen war — zwei
|
||||||
|
Anläufe, die beide nur Folgen der Quelle waren:
|
||||||
|
|
||||||
|
> **Erst hineingezoomt, dann weichgezeichnet.** Der erste Anlauf schnitt
|
||||||
|
> 800 × 800 aus dem 1024er, also **enger** — die Köpfe waren an Haaransatz
|
||||||
|
> und Schläfen abgeschnitten, was bei 42 px nicht als Zuschnitt liest,
|
||||||
|
> sondern als Fehler. Der zweite zoomte richtig heraus und füllte den Rand
|
||||||
|
> mit einer weichgezeichneten Kopie des Bildes. Das sah aus, als sei das
|
||||||
|
> ganze Bild unscharf: ein Schleier, dessen Machart sich deutlich vom eigenen
|
||||||
|
> Bokeh der Aufnahme unterschied. Mit richtigen Vorlagen fällt beides weg.
|
||||||
|
|
||||||
|
**Geschärft wird beim Verkleinern, nicht danach am Ergebnis.** Skaliert mit
|
||||||
|
`lanczos` statt bikubisch, dazu ein leichtes `unsharp=5:5:0.7`. Jedes
|
||||||
|
Verkleinern kostet Kanten, und der Avatar misst am Ende 42 px — was dort an
|
||||||
|
Schärfe fehlt, holt keine Renderqualität zurück.
|
||||||
|
|
||||||
|
Abgelegt als **320 × 320 JPEG** bei Qualität 2. Der Avatar misst 42 px auf der
|
||||||
|
Slide, bei Renderqualität 3 also 126 — 320 lässt Luft, und JPEG ist für Fotos
|
||||||
|
das richtige Format; als PNG wäre es das Fünffache. Zusammen 100 KB.
|
||||||
|
|
||||||
|
**Im Menü hinter dem Plus fällt oben das Verb weg.** Im Werkzeug heißen die
|
||||||
|
vier oberen Einträge „Dokument senden", „Fotos und Videos senden", „Audio
|
||||||
|
senden" und „Kontakt teilen" — viermal ein Wort, das der Ort schon sagt: wer
|
||||||
|
das Menü über das Pluszeichen geöffnet hat, weiß, dass er etwas schickt. Hier
|
||||||
|
stehen nur die Dinge: **Dokumente, Medien, Audio, Kontakt.** Unten bleibt das
|
||||||
|
Verb, weil es dort etwas hinzufügt — „Termin" allein wäre ein Anhang, „Termin
|
||||||
|
erstellen" ist eine Handlung.
|
||||||
|
|
||||||
|
„Dokument" steht dabei in der Mehrzahl, weil man mehrere anhängen kann; Audio
|
||||||
|
und Kontakt bleiben einzeln, man schickt eine Aufnahme und einen Kontakt. Aus
|
||||||
|
„Fotos und Videos" wird **„Medien"** — dasselbe in einem Wort.
|
||||||
|
|
||||||
|
**Bei „Kontakt" steht jetzt das Teilen-Zeichen statt des Telefonhörers.** Ein
|
||||||
|
Hörer sagt „anrufen"; hier wird eine Karte weitergegeben. `ShareNetwork` aus
|
||||||
|
demselben Phosphor-bold-Satz wie alle anderen Zeichen, entnommen aus
|
||||||
|
`@phosphor-icons/react` im Website-Repo statt abgemalt. `phone` stand danach
|
||||||
|
nirgends mehr und ist aus der Tabelle heraus — sie zählt jetzt 19 Zeichen, und
|
||||||
|
jedes davon wird benutzt.
|
||||||
|
|
||||||
|
**Und die Eingabezeile ist zurück.** Sie stand einmal unter dem Menü und war
|
||||||
|
weggenommen worden mit der Begründung, die Slide zeige, was man anhängen kann,
|
||||||
|
und dafür brauche es das Feld nicht. Das stimmte nur so lange, wie man wusste,
|
||||||
|
worauf man schaut: ohne das Feld ist die Karte eine Liste im Nichts, und dass
|
||||||
|
sie aufklappt, wenn man im Chat auf das Pluszeichen tippt, stand nirgends.
|
||||||
|
|
||||||
|
Das Pluszeichen trägt jetzt die grüne Marke wie ein gedrücktes Element, und
|
||||||
|
der Zeiger steht darauf — derselbe, der im Bewegtbild die Nachricht anklickt.
|
||||||
|
Beides zusammen sagt den Zusammenhang, ohne dass ein Wort danebenstehen muss.
|
||||||
|
Das Menü öffnet nach oben, weil es das im Werkzeug auch tut.
|
||||||
|
|
||||||
|
Mit den kürzeren Beschriftungen kommt die Breite der Karte jetzt aus dem
|
||||||
|
längsten Eintrag statt aus einer festen Zahl. Vorher standen dort 470 Einheiten
|
||||||
|
— gemessen für Beschriftungen mit „senden" —, und die Karte lief mit einem
|
||||||
|
Drittel Leerraum quer über die Slide.
|
||||||
|
|
||||||
|
> **Ein hochkantes Bild richtet sich nach der Höhe, nicht nach der Breite.**
|
||||||
|
> Der Kasten misst 460 × 535. Bei der alten Breite von 940 px wurde er 1093 px
|
||||||
|
> hoch und lief in die Überschrift. Gerechnet wird die neue Breite außerdem am
|
||||||
|
> **sichtbaren Blatt** und nicht am Kasten: der ist um den Stapelrand größer
|
||||||
|
> als das, was man sieht, und mit seiner Oberkante gerechnet stand die
|
||||||
|
> Zeichnung noch einmal 60 px zu hoch und berührte den Fließtext. Bei 680 px
|
||||||
|
> bleiben rund 115 px Luft.
|
||||||
|
|
||||||
|
**Die Plakette für Ungelesenes trägt eine Zahl und ist grün.** In der Quelle
|
||||||
|
ist `unread` ein Wahrheitswert und der Punkt violett. Beides ist geändert:
|
||||||
|
violett ist im Produkt die Farbe der Projekte, das Kanalsymbol in derselben
|
||||||
|
Zeile trägt sie schon, und ein zweites violettes Zeichen daneben konkurriert
|
||||||
|
damit, statt etwas anderes zu sagen. Ein farbloser Punkt sagt außerdem nur
|
||||||
|
„da war was", eine Zahl sagt, wie viel — so macht es jeder Messenger, den ein
|
||||||
|
Betrieb kennt.
|
||||||
|
|
||||||
|
Die Zahlen (3, 2, 1) stehen **nicht** in der Quelle; sie ist an dieser Stelle
|
||||||
|
binär. Sie sind die einzigen erfundenen Werte im ganzen Post.
|
||||||
|
|
||||||
|
**Die Symbole kommen aus Phosphor, nicht aus Feather.** Der Feather-Satz
|
||||||
|
steckt in so ziemlich jeder Oberfläche der letzten Jahre; man erkennt ihn
|
||||||
|
sofort wieder, und damit sieht eine Zeichnung aus wie jede andere. Am
|
||||||
|
deutlichsten war das am Gruppensymbol — zwei Köpfe nebeneinander, das
|
||||||
|
Piktogramm schlechthin.
|
||||||
|
|
||||||
|
Phosphor ist dieselbe Familie von Strichsymbolen, nur mit eigener Handschrift:
|
||||||
|
runder, gleichmäßiger, andere Metaphern (`UsersThree` statt `Users`,
|
||||||
|
`PaperPlaneRight` statt `Send`, `CaretDown` statt `ChevronDown`). Es ist
|
||||||
|
dasselbe Paket, das die Website benutzt — `@phosphor-icons/react` steht neben
|
||||||
|
`lucide-react` in ihrer `package.json` — also keine dritte Handschrift im Haus.
|
||||||
|
|
||||||
|
Die Pfade sind **direkt aus dem installierten Paket gezogen**, nicht
|
||||||
|
abgeschrieben: Gewichtung `bold`, Zeichenfläche 256 × 256. Anders als bei
|
||||||
|
Feather sind es gefüllte Formen und keine Striche, `ikon()` färbt deshalb über
|
||||||
|
`fill` statt über `stroke`.
|
||||||
|
|
||||||
|
**Die Gewichtung ist gemessen, nicht geschätzt.** Kein Farbwert hat sich beim
|
||||||
|
Tausch geändert — trotzdem sah es anders aus, und das zu Recht: ein Symbol aus
|
||||||
|
dünneren Formen hat weniger voll gedeckte Pixel und mehr angeschnittene, es
|
||||||
|
liest sich blasser, obwohl derselbe Hexwert darinsteht. Im Kanalsymbol,
|
||||||
|
Ausschnitt 44 × 44:
|
||||||
|
|
||||||
|
| | volle Pixel | Deckung |
|
||||||
|
|---|---|---|
|
||||||
|
| Feather, wie es vorher war | 59 | 180 |
|
||||||
|
| Phosphor `regular` | 23 | 151 (16 % zu blass) |
|
||||||
|
| Phosphor `bold` | 62 | 192 (6 % darüber) |
|
||||||
|
|
||||||
|
Ein Zwischenstand hatte `regular`, weil es „im Menü leichter aussah". Geschätzt
|
||||||
|
statt gemessen, und daneben. Wer eine optische Stärke beurteilt, zählt Pixel.
|
||||||
|
|
||||||
|
Ein Zwischenstand ersetzte nur das eine Gruppensymbol durch zwei überlappende
|
||||||
|
Scheiben. Das war keine Antwort auf die Frage: die Frage war der Satz, nicht
|
||||||
|
das einzelne Zeichen.
|
||||||
|
|
||||||
|
**Slide 4 nennt drei der sechs Einträge, nicht alle.** „Versende mehr als nur
|
||||||
|
Textnachrichten: Dokumente, Fotos, Sprachnachrichten." Drei Beispiele sagen,
|
||||||
|
worum es geht; die vollständige Liste steht im Bild daneben.
|
||||||
|
|
||||||
|
Vorher stand dort die ganze Liste **mit Erklärung** („Dokument, Fotos,
|
||||||
|
Sprachnachricht, Kontakt. Soll daraus etwas werden: Termin oder Aufgabe."). Zu
|
||||||
|
spezifisch war nicht das Aufzählen, sondern das Durchdeklinieren. Auf dem Weg
|
||||||
|
zur jetzigen Fassung habe ich zweimal in die andere Richtung übersteuert und
|
||||||
|
gar nichts mehr genannt — ein Fließtext auf einer Feature-Slide soll das
|
||||||
|
Feature erklären, nicht umschreiben und auch nicht in vier Wörtern abhandeln.
|
||||||
|
|
||||||
|
**Slide 4 zeigt das Menü ohne das Eingabefeld, an dem es hängt.** Das Feld
|
||||||
|
stand einmal darunter, damit man sieht, woher das Menü kommt. Es ist weg: die
|
||||||
|
Slide zeigt, was man anhängen kann, und dafür braucht es das Feld nicht. Der
|
||||||
|
Stapel sagt ohnehin, dass hier ein Bildschirm liegt und kein Kasten im Nichts.
|
||||||
|
|
||||||
|
**Slide 1 zeigt drei von sechs Nachrichten, Slide 2 fünf von acht Kanälen.**
|
||||||
|
Was nicht ins Bild passt, fällt weg; ein Chat und eine Liste scrollen beide.
|
||||||
|
Slide 2 behält alle drei Sorten: Projektkanal, Direktnachricht, Gruppe.
|
||||||
|
|
||||||
|
**Die Bildergalerie ist eine Anhangkarte, kein Bild.** Im Werkzeug zeigt
|
||||||
|
`GalleryPreview` zwei Fotos, geladen von Unsplash. Fremde Fotos in einen
|
||||||
|
Beitrag zu setzen, der von der eigenen Baustelle spricht, wäre eine Behauptung
|
||||||
|
über Material, das wir nicht haben. Stattdessen steht dort die Karte
|
||||||
|
„2 Baustellenfotos · Galerie · heute" — der Text ist der der Quelle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Stand der Dinge
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Slides | 5, dazu 1 Animation außerhalb des Streifens |
|
||||||
|
| Format | 1080 × 1350, Ausgabe zweifach: 2160 × 2700 |
|
||||||
|
| Luft zwischen Text und Zeichnung | 118 / 170 / 153 / 155 / 55 px, Animation 84 (Grenze 40) |
|
||||||
|
| Zeichnung ragt über die Seite | nein, alle innerhalb der 92-px-Schutzzone |
|
||||||
|
| Breiteste Überschriftzeile | 818 px von 896 |
|
||||||
|
| Konsole | keine Fehler |
|
||||||
|
| Überschneidungen | keine, `kollision.mjs` über alle Slides |
|
||||||
|
| Ausgabeweg | geprüft, `01-chat-hell.png` bis `05-mobil-hell.png` |
|
||||||
|
| Bewegtbild | `animation/post6-animChat.mp4`, Ränder 177/176/123, Luft 118, ffprobe siehe §7 |
|
||||||
|
|
||||||
|
Geprüft wird mit den Hilfsskripten aus dem Sitzungsordner: `pruef.mjs` misst
|
||||||
|
über `getBBox()` die tatsächlich gezeichnete Fläche je Slide und die Breite
|
||||||
|
jeder Überschriftzeile, zählt die Regeln im Stylesheet und liest die Konsole
|
||||||
|
mit; `schuss.mjs` rendert einzelne Slides; `export.mjs` fährt den echten
|
||||||
|
Ausgabeweg und fängt `/save` ab; `leiste.mjs` prüft in jedem Projekt, ob die
|
||||||
|
Animationsleiste an der richtigen Stelle steht und ob die Seite fehlerfrei
|
||||||
|
anläuft.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5a. Slide 5 kommt von der Website, nicht von mir
|
||||||
|
|
||||||
|
**Kein Gerät.** Vorbild ist die Machart, die Signal für genau solche Slides
|
||||||
|
benutzt: zwei Ausschnitte der Oberfläche, versetzt übereinander, jeder
|
||||||
|
mit eigenem Schatten. Man sieht zwei Zustände nebeneinander, ohne dass ein
|
||||||
|
Telefonrahmen die halbe Fläche frisst.
|
||||||
|
|
||||||
|
Ein Zwischenstand hatte hier ein nachgebautes Handy samt Insel und Tasten,
|
||||||
|
ganz verwischt. Das kostet viel Platz für etwas, das nichts über das Produkt
|
||||||
|
sagt, und der Rahmen war ohnehin frei erfunden. Die Unschärfe ist damit auch
|
||||||
|
weg: das Band sagt deutlich genug, dass es die App noch nicht gibt, und die
|
||||||
|
Ausschnitte sollen lesbar sein.
|
||||||
|
|
||||||
|
Links die Kanalliste von Slide 2, rechts der Verlauf von Slide 1, beide schmal
|
||||||
|
gesetzt und am Rand angeschnitten. Gezeigt wird also nichts, was nicht vorher
|
||||||
|
schon zu sehen war. Beide sind **leicht verwischt** (`stdDeviation 2.2`):
|
||||||
|
erkennbar, aber nicht zu lesen. Scharf bleibt allein das Band, und genau das
|
||||||
|
soll es auch sein.
|
||||||
|
|
||||||
|
**Das Absperrband kommt von der Website.** In
|
||||||
|
`components/sections/ChatDemoSection.tsx` steht unter der Fahne `comingSoon`
|
||||||
|
genau dieses Element: ein **Absperrband** aus dem Bau, gedreht um **-13°**, in
|
||||||
|
`--accent-yellow` `#F1B729` mit Kanten in `#171106`, darauf immer wieder
|
||||||
|
Schutzhelm und **BALD VERFÜGBAR**. Auf der Website ist es an den Rand des
|
||||||
|
Telefons beschnitten — „*clipped to the phone's rounded bounds so the ends cut
|
||||||
|
off like real tape*" steht als Kommentar daneben.
|
||||||
|
|
||||||
|
Ein Zwischenstand hatte hier einen selbst erfundenen gelben Aufkleber
|
||||||
|
„In Arbeit". Das war eine eigene Bildsprache neben einer vorhandenen.
|
||||||
|
Übernommen ist jetzt die der Website, samt Winkel, Farben und Wortlaut.
|
||||||
|
|
||||||
|
**Das Band läuft hinter die Ausschnitte.** Es liegt über beiden und
|
||||||
|
verschwindet an beiden Enden hinter ihnen: links hinter der linken Kante der
|
||||||
|
hinteren Platte, rechts hinter der rechten der vorderen. Beschnitten wird also
|
||||||
|
an der Silhouette **beider** Ausschnitte, und die ist die ganze Sache — der
|
||||||
|
Gegensatz „absperren oder beschneiden", den ein früherer Stand hier
|
||||||
|
behauptete, gibt es nicht.
|
||||||
|
|
||||||
|
Vorher lief das Band über die ganze Zeichnung hinaus und endete an der
|
||||||
|
Bildkante, quer zur eigenen Laufrichtung abgeschnitten. Genau das ist das
|
||||||
|
Zeichen für *ein Ende*: so liest sich ein Aufkleber, der zufällig dort aufhört,
|
||||||
|
wo das Bild aufhört. Ein Ende, das an einer **senkrechten Plattenkante**
|
||||||
|
abbricht, während das Band mit 13° ankommt, ist keines.
|
||||||
|
|
||||||
|
**Drei Dinge tragen das, und keines verbiegt die Form.**
|
||||||
|
|
||||||
|
*Die Schnittkante ist die Plattenkante.* Getroffen wird die Silhouette an genau
|
||||||
|
zwei Stellen, beide dort gerade — die Rundungen liegen weit weg — und beide
|
||||||
|
senkrecht.
|
||||||
|
|
||||||
|
*Sie ist weicher als eine gezogene Linie und härter als die Platten.* Mit deren
|
||||||
|
`stdDeviation 2.2` lief das Band über vier Einheiten ins Weiße aus und sah nach
|
||||||
|
Ausblenden aus statt nach Verdecken; ganz scharf stand eine gestochene Kante an
|
||||||
|
einer unscharfen Platte. `1.1` liegt dazwischen.
|
||||||
|
|
||||||
|
*Die letzten Einheiten verlieren Licht,* weil die Fläche sich wegdreht: von
|
||||||
|
`#F1B82A` auf `#815F11` über 56 Einheiten, mit dem Höhepunkt als Plateau dicht
|
||||||
|
vor der Kante. Der Ton ist ein warmes Dunkelbraun und kein Schwarz — ein Band,
|
||||||
|
das sich wegdreht, wird nicht farblos.
|
||||||
|
|
||||||
|
**Die Wölbung.** Quer über die Höhe der vorderen Fläche liegt ein Verlauf von
|
||||||
|
`#F8C64A` über `#F1B729` nach `#D89F1B`, knapp unter der Oberkante ein
|
||||||
|
Glanzlicht. Die untere Längskante ist einen Pixel dicker als die obere, weil
|
||||||
|
sie im Schatten der Wölbung liegt.
|
||||||
|
|
||||||
|
**Und es ist kein Rechteck mehr, sondern ein Spruchband, das sich um den
|
||||||
|
Stapel legt.** Das ist die größte Abweichung von der Website in diesem
|
||||||
|
Beitrag. Dort ist die Form ein durchgehendes Rechteck; hier knickt sie
|
||||||
|
**genau an der Plattenkante** um, läuft dahinter weiter und kommt an der
|
||||||
|
Kante wieder hervor — tiefer, weil sie dabei umgeschlagen ist, niedriger,
|
||||||
|
weil sie vom Betrachter wegführt, und flacher getönt, weil man von dort ihre
|
||||||
|
Unterseite sieht.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `HERVOR` 78 | wie weit das Stück hinter der Platte hervorsteht |
|
||||||
|
| `VERSATZ` 40 | wie tief es dabei hängt |
|
||||||
|
| `SCHMAL` 0,72 | seine Höhe: verkürzt, weil es wegführt |
|
||||||
|
| `NEIG` 10 | zusätzlicher Fall nach außen |
|
||||||
|
| `#DFA81E` | sein Ton — eine Fläche, die wegführt, fängt weniger Licht |
|
||||||
|
|
||||||
|
**Die Stelle des Knicks wird nicht gerechnet.** Beide Stücke reichen
|
||||||
|
absichtlich weit in die Platte hinein, und **zwei Masken** schneiden sie
|
||||||
|
exakt an deren Kante ab: `bandSilhouette` lässt die vordere Fläche innerhalb
|
||||||
|
der Silhouette stehen, `bandAussen` die Stücke außerhalb. Der Knick sitzt
|
||||||
|
damit immer auf der Kante, auch wenn sich die Platten einmal verschieben.
|
||||||
|
|
||||||
|
Beide kommen aus **derselben Kontur, ohne Strich darauf** — so ergänzen sich
|
||||||
|
ihre Deckungen an jeder Stelle zu genau eins. Darüber liegt die **Falzkante**
|
||||||
|
als eigener Strich (`bandGesamt`, 4,5 breit, `#171106`): senkrecht auf der
|
||||||
|
Plattenkante, während das Band mit 13° ankommt, und über Fläche *und* Stück
|
||||||
|
hinweg, weil beide dort zusammenstoßen. Ohne sie brechen die dunklen
|
||||||
|
Längskanten an der Naht einfach ab, und der Falz sieht zerschnitten aus statt
|
||||||
|
geknickt.
|
||||||
|
|
||||||
|
Weggelassen sind die zwei Festlichkeiten des klassischen Spruchbands: **keine
|
||||||
|
Zacken** an den Enden und **kein Bogen** über die Länge. Ein gewölbtes Band
|
||||||
|
über einer flachen Oberfläche sieht nach Urkunde aus.
|
||||||
|
|
||||||
|
> **Drei Anläufe, und woran die ersten beiden lagen.** Erst waren die Enden
|
||||||
|
> zur Spitze verjüngt, dann war das Band um eine Knickstelle gekippt — das
|
||||||
|
> eine sah aus wie eine Schleife, das andere wie eine Welle. Beide verbogen
|
||||||
|
> das Band **als Ganzes** und ließen es nirgendwo hin.
|
||||||
|
>
|
||||||
|
> Der dritte Anlauf hatte den Knick schon richtig, saß aber **70 Einheiten
|
||||||
|
> vor** der Kante. Damit lag er mitten auf dem Bildschirm, und das Ganze sah
|
||||||
|
> aus wie ein Spruchband, das jemand daraufgeklebt und in der Mitte geknickt
|
||||||
|
> hat. Ein Band knickt nicht um nichts: der Knick gehört an die Kante, weil
|
||||||
|
> dort etwas ist, worum es sich legen kann.
|
||||||
|
|
||||||
|
Der Lichtabfall vor der Kante ist dabei auf die Hälfte zurückgenommen worden
|
||||||
|
(`0,80` → `0,42` an der Kante). Zusammen mit dem ohnehin dunkleren
|
||||||
|
umgeschlagenen Stück wurde daraus sonst ein brauner Schatten statt eines
|
||||||
|
Bandes. Er gilt **beiden**: nachgemessen liegt das Stück an der Naht bei
|
||||||
|
Helligkeit 125, das Band daneben bei 132 — das Stück ist also eine Spur
|
||||||
|
dunkler, und das ist die richtige Richtung, denn es liegt weiter hinten.
|
||||||
|
|
||||||
|
*Helm und Schrift stehen einmal, mittig auf der geraden Fläche.* Wiederholt und
|
||||||
|
am linken Rand beginnend passte der Satz zu einem endlosen Band. Er sitzt in
|
||||||
|
der Mitte des sichtbaren Laufs, weil dessen Mitte und die Drehmitte des Bandes
|
||||||
|
dieselbe Stelle sind: beide Kanten liegen gleich weit von ihr weg.
|
||||||
|
|
||||||
|
**Vom Absperrband abgesehen kommt der Schirm aus dem Pitch-Paket.** Eine
|
||||||
|
Zwischenfassung zeigte dort den Chat der Website (Heizungsprojekt Schulz,
|
||||||
|
`seedMessages`, Blasen in `#0D6E6E`). Der ist echt, gehört aber zu einer
|
||||||
|
anderen Oberfläche, und der Beitrag zeigt sonst durchgehend das Werkzeug.
|
||||||
|
Jetzt steht auf dem Schirm derselbe Verlauf wie auf Slide 1 und 3, gezeichnet
|
||||||
|
mit derselben `blase()`, nur schmaler. Man sieht es kaum, weil das Gerät
|
||||||
|
verwischt ist — das ist kein Grund, etwas anderes hinzulegen: was unter der
|
||||||
|
Unschärfe liegt, soll stimmen.
|
||||||
|
|
||||||
|
Überschrift und Fließtext sind ebenfalls die der Website: „Der Chat für
|
||||||
|
unterwegs wird gerade gebaut." und „Die mobile App legen wir gerade auf die
|
||||||
|
Baustelle. Am Desktop bist du schon voll dabei, die Version fürs Handy folgt
|
||||||
|
in Kürze." Der Em-Dash der Vorlage ist durch ein Komma ersetzt, `rules.md` §4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5d. Slide 2 stellt gegenüber statt zu zeigen
|
||||||
|
|
||||||
|
Links das Durcheinander als Zeichnung, rechts die Oberfläche als Ausschnitt.
|
||||||
|
Die Aussage steckt in der Gegenüberstellung und nicht in einem der beiden
|
||||||
|
Teile — der Satz der Website ist genau diese Zweiteilung:
|
||||||
|
„Schluss mit dem einen Verlauf, in dem alles durcheinandergeht. Ein Kanal pro
|
||||||
|
Projekt."
|
||||||
|
|
||||||
|
**Die linke Hälfte gibt es in diesem Repo schon**, in `post2.html` auf Slide 2
|
||||||
|
(`visProzesse`): beschriftete Plättchen, die eine geschlängelte gestrichelte
|
||||||
|
Linie verbindet. Dort ist sie im Zeichenblock-Stil mit schiefen Kanten, hier
|
||||||
|
glatt — das Theme ist ein anderes, und dieselbe Form zweimal in zwei
|
||||||
|
Anmutungen wäre schlechter als zwei Formen mit derselben Idee.
|
||||||
|
|
||||||
|
Die fünf Plättchen (Angebot, Baustellenfoto, Termin, Rückfrage, Materialliste)
|
||||||
|
sind **Redaktionstext und keine Produktstrings**. Sie beschreiben die
|
||||||
|
Wirklichkeit im Betrieb, nicht die Oberfläche; dieselbe Lage wie bei
|
||||||
|
„E-Mails" und „Zettel im Auto" in Post 2. Gewählt sind Wörter, die im Beitrag
|
||||||
|
ohnehin vorkommen.
|
||||||
|
|
||||||
|
**Die Zeichnung steht ganz auf ihrer eigenen Seite.** Ein Zwischenstand zog
|
||||||
|
sie über die Kante nach links, sodass zwei Plättchen auf Slide 1 lagen und die
|
||||||
|
Leitung hindurchlief. Das ist zurückgenommen, und mit ihm der negative
|
||||||
|
`visLeft` und die Sonderregel für die Slide-Kante. `visLeft` ist wieder 92 wie
|
||||||
|
überall.
|
||||||
|
|
||||||
|
**Die Leitung fängt am ersten Plättchen an.** Sie hatte einen Anlauf, der links
|
||||||
|
vor dem Nullpunkt begann — ein Rest aus der Fassung über zwei Slides. Ohne die
|
||||||
|
Nachbarslide kam er aus dem Nichts und versprach einen Zusammenhang, den es
|
||||||
|
nicht gibt. Jetzt beginnt sie bei „Angebot" und endet bei „Materialliste", also
|
||||||
|
an beiden Enden an etwas.
|
||||||
|
|
||||||
|
**Kein Pfeil zwischen den Hälften.** Er stand einmal dort und machte die Sache
|
||||||
|
zu ordentlich — eine Zeichnung, die Durcheinander zeigt, braucht keinen
|
||||||
|
Wegweiser, und die Leitung führt ohnehin von oben nach unten. Die Plättchen
|
||||||
|
sind aus demselben Grund gestreut und leicht gekippt statt in einer geraden Spalte
|
||||||
|
gesetzt. Die Werte dafür stehen fest im Code und sind nicht gewürfelt: ein
|
||||||
|
Zufallsgenerator gäbe bei jedem Neubau ein anderes Bild, und die Slide soll
|
||||||
|
zweimal gleich aussehen.
|
||||||
|
|
||||||
|
**Der Ausschnitt steht größer da als die Zeichnung daneben** (`LISTE_SKALA`,
|
||||||
|
1,28). Er trägt das Produkt, die Plättchen tragen nur die Ausgangslage; gleich
|
||||||
|
groß gesetzt lasen sich beide als gleichwertig, und das sind sie nicht. Platz
|
||||||
|
dafür war da: gemessen endet die linke Hälfte samt Leitung bei x = 344, der
|
||||||
|
Kasten begann erst bei 464.
|
||||||
|
|
||||||
|
**Die Projektnummer misst dadurch wieder 15,8 px** statt 12,4 in der halben
|
||||||
|
Breite. Sie ist der Beleg für den Fließtext („Jeder Kanal hängt an seiner
|
||||||
|
Projektnummer") und muss lesbar bleiben — wer sie verkleinert, nimmt der Slide
|
||||||
|
ihre eigene Aussage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5c. Slide 3 kommt ohne Hervorhebung aus
|
||||||
|
|
||||||
|
Um die Anhangkarte der Aufgabe lag einmal ein gelber Rahmen, geborgt aus den
|
||||||
|
Slack-Beiträgen: rund, über die Blase hinausragend, auf genau eine Sache
|
||||||
|
zeigend. Er ist weg.
|
||||||
|
|
||||||
|
Bei sechs Pixeln war er plump — er trug mehr Farbe als die Karte, auf die er
|
||||||
|
zeigen sollte. Der Versuch, ihn zu retten, ging über vier Varianten (ganz weg,
|
||||||
|
weicher Schein, gelbe Kante links, dünner Ring), jeweils gerendert und auf
|
||||||
|
Feed-Breite heruntergerechnet; gewählt war am Ende derselbe Ring mit drei
|
||||||
|
statt sechs Pixeln. Im Beitrag sah das aus wie vorher.
|
||||||
|
|
||||||
|
**Die Slide braucht ihn nicht.** Sie besteht aus drei Nachrichten, die
|
||||||
|
aufeinander folgen, und die Aufgabenkarte ist das einzige Element mit einer
|
||||||
|
gelben Marke („Offen") — der Blick geht ohnehin dorthin. Ein Rahmen sagt
|
||||||
|
nochmal, was die Anordnung schon sagt, und kostet dafür die Ruhe der Fläche.
|
||||||
|
|
||||||
|
Mit ihm ist `letzterAnhang` weggefallen: `blase()` merkte sich die Lage der
|
||||||
|
zuletzt gezeichneten Anhangkarte, damit der Rahmen nicht aus einer zweiten
|
||||||
|
Rechnung kommt. Ohne Rahmen liest das niemand mehr.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5b. Woher die Tiefe kommt
|
||||||
|
|
||||||
|
Zwei Anläufe waren falsch, bevor es saß.
|
||||||
|
|
||||||
|
**Erster Versuch: graue Flächen hinter die Elemente.** Das nimmt den
|
||||||
|
Zeichnungen ihre Durchsichtigkeit und setzt einen Kasten auf den Punktgrund,
|
||||||
|
der dort nicht hingehört. Wieder raus.
|
||||||
|
|
||||||
|
**Zweiter Versuch: nur bessere Schatten.** Richtig, aber zu wenig. Die
|
||||||
|
Schatten des Produkts sind in `ui/theme.css` **gestaffelt** und nicht
|
||||||
|
einlagig, und das war tatsächlich ein Fehler im ersten Nachbau:
|
||||||
|
|
||||||
|
```
|
||||||
|
--shadow-sm 0 1px 2px rgb(0 0 0 / .08)
|
||||||
|
--shadow-md 0 4px 16px -2px + 0 2px 4px -1px
|
||||||
|
--shadow-lg 0 12px 32px -4px + 0 4px 8px -2px
|
||||||
|
```
|
||||||
|
|
||||||
|
Zwei Lagen, weil eine allein nicht beides kann: die enge dunkle Kante setzt
|
||||||
|
das Element auf die Fläche, die weite helle hebt es davon ab. In SVG zwei
|
||||||
|
`feDropShadow` im selben Filter. Vorher stand dort einer mit `dy 7` und
|
||||||
|
`stdDeviation 10`.
|
||||||
|
|
||||||
|
**Was es gebracht hat: der Stapel.** `post1.html` hat ihn als Bildrahmen
|
||||||
|
`stapel` im CSS, für Screenshots. Hier ist er in SVG nachgebaut, mit genau
|
||||||
|
denselben Werten, damit die Reihe zusammenpasst:
|
||||||
|
|
||||||
|
```
|
||||||
|
.sheet-mid rotate(-2.4deg) translate(-2.2%, 3%) scale(.975)
|
||||||
|
.sheet-back rotate( 3.2deg) translate( 3%, 5.5%) scale(.95)
|
||||||
|
```
|
||||||
|
|
||||||
|
Er trägt zwei Aufgaben: er gibt Tiefe, die ein Schatten allein nicht schafft,
|
||||||
|
und er sagt, dass hinter der einen Ansicht weitere liegen. Die hinteren
|
||||||
|
Blätter sind deshalb **leer** — Andeutung, keine Kopie.
|
||||||
|
|
||||||
|
Auf Slide 1, 2 und 4 liegt er, weil dort ein Bildschirm oder ein Menü zu sehen
|
||||||
|
ist. Auf Slide 3 nicht: ein Ausschnitt aus einer Unterhaltung ist kein Blatt,
|
||||||
|
dort tragen die Blasen ihre eigene Erhebung.
|
||||||
|
|
||||||
|
Dazu zwei Dinge aus der Quelle, die der Unterhaltung Struktur geben, ohne eine
|
||||||
|
Fläche zu behaupten: die Datumsmarke **Heute · 30.07.2026** über dem ersten
|
||||||
|
Beitrag und die Zeile **„Maike Berger schreibt gerade …"** über dem
|
||||||
|
Eingabefeld. Beides steht so in `GeneralTeamChatPage.tsx`.
|
||||||
|
|
||||||
|
Nebenbei richtiggestellt: **der offene Kanal ist grau hinterlegt, nicht
|
||||||
|
violett.** Im Original steht dort `bg-surface-neutral-primary` mit Rand und
|
||||||
|
`shadow-sm`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Vierundzwanzig Fallen aus diesem Bau
|
||||||
|
|
||||||
|
**Die Listenzeile hat drei Zeilen, nicht zwei.** Der erste Anlauf setzte Titel
|
||||||
|
und Vorschau untereinander und schob Nadel und Ungelesen-Punkt rechts neben
|
||||||
|
die Vorschau. Dort stießen sie an den Text — und die Zuordnung fehlte ganz.
|
||||||
|
Im Original steht sie als dritte Zeile: `P-2026-0042` links, die Nadel rechts.
|
||||||
|
Das war nicht nur ein Layoutfehler: der Fließtext behauptete „Jeder Kanal hängt
|
||||||
|
an seiner Projektnummer", und die Nummer war nirgends zu sehen.
|
||||||
|
|
||||||
|
**Eine Anhangkarte auf eine Zeile zu zwingen kürzt Zitate.** In der schmalen
|
||||||
|
Spalte auf Slide 5 stand „Materialliste prüfen · fälli…". Der Titel darf jetzt
|
||||||
|
auf zwei Zeilen, und die Karte wächst mit; die Blasenhöhe rechnet das mit.
|
||||||
|
Ebenso bricht die Systemzeile um, statt aus der Fläche zu laufen.
|
||||||
|
|
||||||
|
**Eine feste Obergrenze für die Blasenbreite hat ein Zitat gekürzt.** Für die
|
||||||
|
Titelslide musste sie klein sein, damit die Unterhaltung neben die Liste passt.
|
||||||
|
Auf Slide 3 stand deshalb „Materialliste prüfen · fälli…" statt
|
||||||
|
„… · fällig 31.07.2026". Die Grenze hängt jetzt an der Spalte, in der die Blase
|
||||||
|
steht.
|
||||||
|
|
||||||
|
**Beinahe hätte ich ein Zitat ohne Grund gekürzt.** Die Website-Überschrift
|
||||||
|
lautet „Büro und Baustelle, endlich im selben Chat." Ich hatte das „endlich"
|
||||||
|
gestrichen mit der Begründung, die Zeile laufe sonst über die Schutzzone, und
|
||||||
|
diese Begründung sogar mit einer Zahl versehen, die ich nicht gemessen hatte.
|
||||||
|
Nachgemessen sind es 818 px von 896 erlaubten: sie passt. Das Wort ist wieder
|
||||||
|
drin. Dafür gibt es jetzt `breite.mjs`, das eine Formulierung misst, bevor sie
|
||||||
|
in die Datei kommt.
|
||||||
|
|
||||||
|
**Eine Verschiebung mitten in der Zeichenfunktion erfasst nur die Hälfte.**
|
||||||
|
Die Zeile, die den Inhalt um `PAD` einrückt, stand mitten in `visChat()`.
|
||||||
|
Alles, was danach gezeichnet wurde — Datumsmarke, Blasen, Tippzeile,
|
||||||
|
Eingabefeld — lag um 26 Einheiten daneben, und die Kürzel schnitten die
|
||||||
|
Trennlinie zwischen den Spalten. Auf der ganzen Slide sieht man das nicht, im
|
||||||
|
Ausschnitt sofort. Die Verschiebung steht jetzt unmittelbar vor der Rückgabe.
|
||||||
|
|
||||||
|
Dafür gibt es `kollision.mjs`: es nimmt jedes gezeichnete Element, rechnet
|
||||||
|
seinen Kasten in Nutzereinheiten und meldet, was über den viewBox hinausragt
|
||||||
|
oder die senkrechte Trennlinie schneidet. Die Stapelblätter sind ausgenommen,
|
||||||
|
sie dürfen alles schneiden.
|
||||||
|
|
||||||
|
**Ein Prüfskript, das in den falschen Einheiten rechnet, ist schlimmer als
|
||||||
|
keines.** `kollision.mjs` benutzte zuerst `getCTM()`. Das enthält die
|
||||||
|
Skalierung der viewBox auf die Anzeigebreite — die Maße kamen also in
|
||||||
|
Bildschirmpixeln heraus und wurden gegen die viewBox in Nutzereinheiten
|
||||||
|
geprüft. Auf Slide 1 fiel es nicht auf, weil der Faktor dort zufällig bei 0,93
|
||||||
|
lag; auf Slide 2 meldete es 26 Fehler, die keine waren. Gerechnet wird jetzt
|
||||||
|
über `svg.getScreenCTM().inverse().multiply(el.getScreenCTM())`.
|
||||||
|
|
||||||
|
**Gedrehte Blätter ragen aus dem Kasten.** Der Stapel dreht das hinterste
|
||||||
|
Blatt um 3,2° und verschiebt es um 3 % und 5,5 % der eigenen Größe. Die Ecken
|
||||||
|
stehen damit über das vordere Blatt hinaus, und die Zeichnung lag bis zu 24 px
|
||||||
|
außerhalb der 92-px-Schutzzone. Der Überstand wird jetzt gerechnet statt
|
||||||
|
geschätzt: `stapelRand()` schickt die vier Ecken durch dieselbe Kette wie das
|
||||||
|
Blatt und nimmt den größten Wert. `stapelKasten()` macht den Kasten
|
||||||
|
entsprechend größer.
|
||||||
|
|
||||||
|
**Ein Maß, das aus einer Konstante kommt statt aus dem Gezeichneten, veraltet
|
||||||
|
still.** Der Pfeil auf Slide 2 rechnete seine Lage aus einer Blockbreite. Als
|
||||||
|
die Plättchen weiter nach rechts rückten, stimmte sie nicht mehr, und der Pfeil
|
||||||
|
zerfiel in zwei Striche. Er leitet seine Lage jetzt aus den Plättchen selbst
|
||||||
|
ab, so wie der Rahmen auf Slide 3 seine aus `blase()` bekommt.
|
||||||
|
|
||||||
|
**Eine Sicherung, die zu spät greift, sichert den Fehler mit.** `export.mjs`
|
||||||
|
schaltete die Slides mit der Pfeiltaste weiter und schrieb dabei im
|
||||||
|
Bearbeiten-Modus `vis`-Versätze in `state/post6.json`. Die Gegenmaßnahme war,
|
||||||
|
den Stand vor dem Lauf zu lesen und danach zurückzuschreiben — nur stand die
|
||||||
|
Drift beim nächsten Lauf schon in der Datei und wurde mitgesichert. Jetzt
|
||||||
|
werden die Slides über `#slides-nav` angeklickt statt durchgetastet; ohne
|
||||||
|
Tasten gibt es das Problem gar nicht erst.
|
||||||
|
|
||||||
|
**Ein gedrehtes Element wandert seitlich aus, wenn der Drehpunkt woanders
|
||||||
|
liegt.** Das Band sitzt 24 Einheiten unter der Mitte der Zeichnung, gedreht
|
||||||
|
wurde aber um die Mitte. Dadurch verschiebt sich sein Kasten um
|
||||||
|
24·sin(13°) ≈ 5 Einheiten zur Seite und stand über dem Rand. Gedreht wird
|
||||||
|
jetzt um die eigene Mitte des Bandes.
|
||||||
|
|
||||||
|
**`getBBox()` kennt keine Beschneidung.** Das Absperrband war zuerst auf die
|
||||||
|
175 % der Website gezogen und per `clip-path` am Gerät abgeschnitten. Im Bild
|
||||||
|
stimmte das, die Randmessung meldete aber einen Überstand von 146 px, den es
|
||||||
|
gar nicht gab: `getBBox()` liefert die Geometrie, nicht das Sichtbare. Das
|
||||||
|
Band ist jetzt nur so lang, wie es bei -13° quer über das Gerät sein muss,
|
||||||
|
also `B / cos(13°)` plus Zugabe. Messung und Bild sagen wieder dasselbe.
|
||||||
|
|
||||||
|
**Das Prüfskript nahm eine leere Slide auf.** `schuss.mjs` stellte für eine
|
||||||
|
einzelne Slide ein Fenster von 1400 px. Im durchgehenden Blatt liegt Seite 3
|
||||||
|
aber bei x = 2160, und `captureBeyondViewport` fing das nicht zuverlässig ab.
|
||||||
|
Die Bühne steht jetzt immer auf 7000 px.
|
||||||
|
|
||||||
|
**Eine ungültige `var()` fällt lautlos durch.** Die Animationsleiste war in
|
||||||
|
Farben gesetzt, die es in diesen Dateien nie gab — `var(--line)`,
|
||||||
|
`var(--panel)`, `var(--ink)`. Eine Variable ohne Wert und ohne Ersatzwert macht
|
||||||
|
nicht etwa die Farbe kaputt, sondern lässt **die ganze Angabe** fallen: kein
|
||||||
|
Rahmen, kein Grund, keine Meldung in der Konsole, kein Fehler in irgendeinem
|
||||||
|
Prüfskript. Aufgefallen ist es erst auf dem Bildschirmfoto. Die Leiste trägt
|
||||||
|
jetzt dieselben festen Werte wie der Rest der Oberfläche (`#17181F`, `#24262F`,
|
||||||
|
`#1D1F26`, `#2E313B`).
|
||||||
|
|
||||||
|
**Ein Geschwister hinter einem absolut liegenden Element landet nicht
|
||||||
|
darunter.** Die Leiste wurde als nächstes Element nach `#scroller` eingehängt
|
||||||
|
und lag prompt oben über dem Streifen: `.scroller` ist `position: absolute;
|
||||||
|
inset: 0` und nimmt keinen Platz im Fluss ein. Die Bühne ist deshalb jetzt eine
|
||||||
|
Spalte aus zwei Zeilen — oben eine neu eingezogene `#stageflaeche` mit Scroller
|
||||||
|
und Fußleiste, darunter die Leiste. Eingezogen wird sie beim Start per Skript,
|
||||||
|
damit der HTML-Rumpf jeder Datei unverändert bleibt.
|
||||||
|
|
||||||
|
**Eine Animation deckt auf, was ein Standbild verdeckt.** Auf Slide 3 steht
|
||||||
|
die Nachricht „Danke. Ich habe dazu direkt eine Aufgabe erstellt." mit
|
||||||
|
angehängter Aufgabenkarte, und das liest sich einwandfrei: ein Zustand, eine
|
||||||
|
Momentaufnahme. Sobald derselbe Inhalt einen Ablauf bekommt, fällt auf, dass
|
||||||
|
Text und Anhang einander bedingen — die Nachricht meldete eine Aufgabe, die im
|
||||||
|
selben Zug erst entstand. Der Fehler lag nicht in der Quelle, sondern darin,
|
||||||
|
eine Momentaufnahme für eine Reihenfolge zu halten. Mehr dazu in §7.
|
||||||
|
|
||||||
|
**Das Pitch-Paket ist ein Stand, nicht das Produkt.** Der zweite Anlauf für die
|
||||||
|
Animation nahm das Menü hinter dem Pluszeichen, weil `GeneralTeamChatPage.tsx`
|
||||||
|
den einzigen dort ausgezeichneten Weg zu einer Aufgabe so zeigt. Belegt war das
|
||||||
|
sauber — Datei, Zeile, Grep über beide Repos und über die Staging-API, die eine
|
||||||
|
Aufgabe nur mit `contact | company | lead | project` verknüpft und eine
|
||||||
|
Nachricht als Bezug gar nicht kennt. Trotzdem falsch: im laufenden Team-Chat
|
||||||
|
hängt an jeder Nachricht ein Menü mit „Aufgabe erstellen".
|
||||||
|
|
||||||
|
Die Lehre ist nicht „schlechter prüfen", sondern **wissen, was eine Quelle
|
||||||
|
belegt.** Ein Pitch-Paket vom Juli belegt, was im Juli gebaut war. Es belegt
|
||||||
|
nicht, was das Produkt heute kann. Wo eine Aussage der Website (hier:
|
||||||
|
„Aus „mach das mal" wird mit einem Klick eine echte Aufgabe") keine Entsprechung
|
||||||
|
im vorliegenden Code hat, ist das ein Grund nachzufragen und nicht, die Aussage
|
||||||
|
auf den vorhandenen Code zu biegen.
|
||||||
|
|
||||||
|
**Ich habe die falsche Slide geprüft.** Nach dem Vergrößern des Ausschnitts auf
|
||||||
|
Slide 2 lief `kollision.mjs` mit dem Argument `1` — das ist Slide 1. Auf Slide 2
|
||||||
|
ragte das hinterste, gedrehte Blatt des Stapels fünf Einheiten unter den Kasten:
|
||||||
|
`stapelRand()` rechnet den Überstand für die unskalierte Größe, und die
|
||||||
|
Skalierung vergrößert ihn mit. Der Kasten wird jetzt aus dem skalierten Stapel
|
||||||
|
gerechnet. Gefunden wurde es erst zwei Commits später, bei einer Prüfung, die
|
||||||
|
alle fünf Slides durchging.
|
||||||
|
|
||||||
|
**Ein `|| 0` an der falschen Stelle verdeckt ein NaN.** Der Abspielen-Knopf tat
|
||||||
|
nichts, sobald man ihn nach dem Laden als Erstes anfasste. `animAbspielen`
|
||||||
|
rechnete `performance.now() - ANIM_T[i] * dauer`, damit die Vorschau dort
|
||||||
|
weiterläuft, wo der Regler steht — nur ist `ANIM_T[i]` undefined, solange der
|
||||||
|
Regler nie bewegt wurde. Daraus wird NaN, der Fortschritt wird NaN, und
|
||||||
|
`animT()` liefert wegen seines eigenen `|| 0` brav wieder 0: die Vorschau stand
|
||||||
|
still, ohne dass in der Konsole etwas auftauchte.
|
||||||
|
|
||||||
|
Der Fehler war vier Commits alt und wurde nicht bemerkt, weil jede Prüfung
|
||||||
|
vorher `animSetzen()` aufgerufen hatte, um irgendetwas zu messen — und damit
|
||||||
|
genau den Zustand herstellte, in dem es funktioniert. **Ein Prüfskript, das den
|
||||||
|
Anfangszustand anfasst, prüft ihn nicht mehr.** `animSetzen` weist NaN jetzt
|
||||||
|
zusätzlich mit `Number.isFinite` ab, damit ein solcher Wert gar nicht erst in
|
||||||
|
den Stand kommt.
|
||||||
|
|
||||||
|
**Ein weicher Schnitt und ein Schatten an derselben Stelle heben sich auf.**
|
||||||
|
Die Falte des Absperrbands hatte ihren dunkelsten Punkt genau auf der
|
||||||
|
Plattenkante. Dort blendet die Maske das Band aber schon aus — das Dunkelste
|
||||||
|
lag also im Durchsichtigen und mischte sich mit dem Weiß der Platte zu einem
|
||||||
|
blassen Streifen, der die Falte gerade wieder aufhob. Der Höhepunkt sitzt jetzt
|
||||||
|
als Plateau ein Stück davor, wo das Band noch voll deckt.
|
||||||
|
|
||||||
|
**Ein Verlauf gehört in die Koordinaten dessen, woran er endet.** Das Abdunkeln
|
||||||
|
der Bandenden stand zuerst im gedrehten Bandkasten und lief damit 13° schräg
|
||||||
|
zur senkrechten Plattenkante, an der es aufhören soll. Über die Bandhöhe
|
||||||
|
verschob sich der Höhepunkt dadurch um 21 Einheiten. Es steht jetzt in den
|
||||||
|
Koordinaten der Zeichnung, waagerecht, parallel zur Kante.
|
||||||
|
|
||||||
|
**Mehr als die Hälfte der „Fotos" von einer Bildersuche waren Profilbilder
|
||||||
|
der Fotografen.** Beim Einsammeln der Unsplash-Treffer greift ein
|
||||||
|
`figure img[src*='images.unsplash.com']` auch das Avatarbild des Urhebers ab,
|
||||||
|
das mit im `<figure>` steht. Von 56 Kandidaten waren **32** solche
|
||||||
|
Profilbilder — und sie sehen aus wie Portraits, weil sie welche sind. Zwei
|
||||||
|
davon waren schon eingesetzt und gerendert, bevor es beim Aufschreiben der
|
||||||
|
Herkunft auffiel: ihre URL beginnt mit `profile-` statt mit `photo-`. Ein
|
||||||
|
Profilbild ist nicht von der Unsplash-Lizenz gedeckt. Gefiltert wird jetzt
|
||||||
|
auf `/photo-` in der Bild-URL.
|
||||||
|
|
||||||
|
**Zwei gegenläufige Teilstücke in einem Pfad löschen ihren Überlapp weg.**
|
||||||
|
Der Beschnitt für die Falzkante war ein einziger `<path>` aus Bandrechteck und
|
||||||
|
umgeschlagenem Stück. Das Rechteck läuft im Uhrzeigersinn, das Stück dagegen —
|
||||||
|
und die Füllregel `nonzero` macht daraus ein **Loch**, genau dort, wo beide
|
||||||
|
sich überlappen. Und genau dort sollte die Falzkante hin. Sie fehlte, ohne dass
|
||||||
|
irgendetwas einen Fehler meldete. Die Kinder einer `clipPath` werden dagegen
|
||||||
|
vereinigt: jetzt stehen dort zwei `<path>`-Elemente statt eines.
|
||||||
|
|
||||||
|
**Ein Rechteck in Zeichnungsgröße trifft nicht, was über die Zeichnung
|
||||||
|
hinaussteht.** Die Abdunklung der umgeschlagenen Stücke lag auf einem Rechteck
|
||||||
|
`0 0 W H`. Die Stücke stehen aber außerhalb von `0..W` — sie ragen ja über die
|
||||||
|
Plattenkante hinaus. Das Rechteck traf sie nie, und das Stück blieb heller als
|
||||||
|
das Band daneben: verkehrt herum für etwas, das weiter hinten liegt. Aufgefallen
|
||||||
|
ist es erst beim Auszählen der Pixel quer über die Naht, nicht am Bild.
|
||||||
|
|
||||||
|
**Ein Kommentar, der einen zurückgenommenen Stand beschreibt, ist schlimmer als
|
||||||
|
keiner.** Über dem Band standen zwei Kommentarblöcke übereinander: der eine
|
||||||
|
sagte, es rage beidseitig hinaus, der andere, es knicke an den Enden weg. Das
|
||||||
|
erste stimmte noch, das zweite war einen Commit vorher zurückgenommen worden.
|
||||||
|
Wer danach etwas ändert, glaubt dem Kommentar. Mit ihnen weg sind auch
|
||||||
|
`WEB.bandKnick` und `WEB.bandSpitze`, die Farben des Knicks, die niemand mehr
|
||||||
|
las.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Das Bewegtbild
|
||||||
|
|
||||||
|
Sechs Sekunden, 180 Bilder, `animation/post6-animChat.mp4`. Gezeichnet wird es
|
||||||
|
von `animChat(i, t)` in `post6.html`, mit denselben Bausteinen wie jede Slide —
|
||||||
|
nur bekommt die Funktion neben der Nummer den Fortschritt `t` und leitet daraus
|
||||||
|
alles ab. Der Eintrag steht in `ANIMS`, nicht in `SLIDES`.
|
||||||
|
|
||||||
|
**Gezeigt wird der Weg, den das laufende Werkzeug hat:** Nachricht anfassen,
|
||||||
|
Menü öffnet sich, „Aufgabe erstellen", die Aufgabe klappt in der Nachricht auf,
|
||||||
|
das Projekt bestätigt sie. Das ist die Funktion, die die Website meint mit
|
||||||
|
„Aus „mach das mal" wird mit einem Klick eine echte Aufgabe."
|
||||||
|
|
||||||
|
| Zeit | Was passiert |
|
||||||
|
|---|---|
|
||||||
|
| 0,0 – 0,2 s | Ruhe. Der Verlauf steht: Sandra, Maike, Tobias |
|
||||||
|
| 0,2 – 1,1 s | Der Zeiger fährt auf Tobias' Nachricht, der Pfeil erscheint an ihr |
|
||||||
|
| 1,3 – 1,6 s | Klick |
|
||||||
|
| 1,5 – 2,0 s | Das Menü schnappt nach oben auf: Reagieren, Antworten, Im Thread antworten, Anpinnen, Aufgabe erstellen, Bearbeiten, Löschen |
|
||||||
|
| 2,4 – 3,4 s | Der Zeiger geht auf „Aufgabe erstellen", die Zeile hebt sich, Klick |
|
||||||
|
| 3,6 – 4,8 s | Das Menü schließt, die Aufgabenkarte klappt in Tobias' Nachricht auf, der Verlauf rutscht nach |
|
||||||
|
| 4,8 – 5,4 s | Die Systemzeile „Aufgabe „Materialliste prüfen" im Projekt erstellt" erscheint |
|
||||||
|
| 5,4 – 6,0 s | Halt auf dem Ergebnis |
|
||||||
|
|
||||||
|
### Die Kurven sind die von GSAP, die Bibliothek ist es nicht
|
||||||
|
|
||||||
|
Die Website macht ihre Bewegungen mit **GSAP** (`gsap@3.14.2`, Standard-Lizenz;
|
||||||
|
`HeroSection`, `Navbar`, `HoneycombSection`, `FAQSection` und weitere). Die
|
||||||
|
Frage lag also nahe, es hier auch zu tun. Die Antwort ist: **die Kurven ja, die
|
||||||
|
Bibliothek nein.**
|
||||||
|
|
||||||
|
Was GSAP über eine Kurve hinaus kann — Zeitleisten, ScrollTrigger,
|
||||||
|
MotionPathPlugin —, setzt voraus, dass etwas *läuft*. Hier läuft nichts:
|
||||||
|
`animChat(i, t)` bekommt seinen Fortschritt von außen und zeichnet daraus den
|
||||||
|
Stand. Die Funktion **ist** schon die Zeitleiste, und `animation.mjs` sampelt
|
||||||
|
sie Bild für Bild. Ein `gsap.timeline({paused: true})` mit `.progress(t)` wäre
|
||||||
|
genau dieselbe Konstruktion, nur mit 70 KB darunter — und das Repo ist autark,
|
||||||
|
jede Datei in `vendor/` liegt dort, weil sie etwas kann, das man nicht in fünf
|
||||||
|
Zeilen schreibt (DOM nach Canvas, ZIP). Ein Easing ist eine Zeile.
|
||||||
|
|
||||||
|
Dazu kommt: **zwei der Kurven waren längst GSAPs.** `daempfen` ist Wort für
|
||||||
|
Wort `power2.inOut`, `rueck` ist `back.out(1.70158)`. Die Datei sprach die
|
||||||
|
Sprache schon, sie sagte es nur nicht. Jetzt stehen die Namen daneben, und der
|
||||||
|
Satz ist vollständig:
|
||||||
|
|
||||||
|
| in der Datei | GSAP | wofür |
|
||||||
|
|---|---|---|
|
||||||
|
| `daempfen` | `power2.inOut` | Wege mit Anfang und Ende: das Wandern zur Menüzeile, das Aufklappen der Karte |
|
||||||
|
| `rueck` | `back.out(1.7)` | das Menü, das aufschnappt und einmal leicht überschwingt |
|
||||||
|
| `raus` | `power2.out` | alles, was ankommt und liegen bleibt: der Zeiger, die Systemzeile |
|
||||||
|
| `knall` | `expo.out` | der Klickring |
|
||||||
|
|
||||||
|
**Der Zeiger fährt jetzt wie eine Hand und nicht wie ein Schlitten.** Er lag
|
||||||
|
auf `power2.inOut` — langsam an, in der Mitte schnell, wieder abbremsend. So
|
||||||
|
bewegt sich ein Schlitten auf einer Schiene. Eine Hand ist sofort schnell und
|
||||||
|
wird zum Ziel hin langsam, also `power2.out`.
|
||||||
|
|
||||||
|
**Und er fährt einen Bogen.** Auf der Geraden zwischen Start und Ziel liegt kein
|
||||||
|
Zeiger, den je ein Mensch geführt hat. Der Stützpunkt einer quadratischen
|
||||||
|
Bézierkurve sitzt quer zur Strecke, 13 % ihrer Länge nach außen — vom Verlauf
|
||||||
|
weg, damit der Zeiger nicht über die Blasen streicht. Das sind rund 27 px
|
||||||
|
Ausbuchtung auf 410 px Weg; es fällt nicht auf, und genau das ist der Punkt.
|
||||||
|
Bei `p = 1` liegt die Bahn exakt auf dem Ziel, das anschließende Wandern zur
|
||||||
|
Menüzeile setzt also sauber daran an.
|
||||||
|
|
||||||
|
**Der Klickring geht auf zwei Kurven.** Der Radius auf `expo.out`, also fast
|
||||||
|
sofort draußen, die Deckung linear hinterher. Beides auf derselben Kurve sah
|
||||||
|
aus wie eine Blase, die sich aufbläst — ein Klick ist ein Stoß und kein
|
||||||
|
Aufpumpen.
|
||||||
|
|
||||||
|
**Der Anlauf ist von 0,6 s auf 0,18 s geschrumpft.** Am Ende steht das Bild
|
||||||
|
eine halbe Sekunde still, damit man das Ergebnis liest. Lief vorne noch einmal
|
||||||
|
eine halbe Sekunde nichts, waren das über den Schleifenstoß hinweg 1,2 von
|
||||||
|
6 Sekunden Stillstand am Stück, und die Schleife sah aus, als hänge sie. Der
|
||||||
|
Halt am Ende bleibt, der am Anfang ist auf ein Achtel geschrumpft.
|
||||||
|
|
||||||
|
> **Nachgeprüft ist das an Einzelbildern, nicht am Lauf.** Kontaktabzüge über
|
||||||
|
> die 180 Bilder und eine Nahaufnahme des Klickrings zeigen, was die Kurven
|
||||||
|
> rechnen. Ob die Bewegung *läuft*, sieht man erst im Abspielen — dieser eine
|
||||||
|
> Schritt steht noch aus.
|
||||||
|
|
||||||
|
### Warum Tobias' Nachricht
|
||||||
|
|
||||||
|
Nicht irgendeine: die Aufgabenkarte aus der Quelle trägt **„Tobias Wagner ·
|
||||||
|
offen"** als Zuständigkeit. Wer sie an einer fremden Nachricht entstehen lässt,
|
||||||
|
muss den Namen erfinden. So bleiben alle vier Felder der Karte
|
||||||
|
(`square-check`, „Materialliste prüfen · fällig 31.07.2026", „Tobias Wagner ·
|
||||||
|
offen", „Offen") genau die aus `GeneralTeamChatPage.tsx`.
|
||||||
|
|
||||||
|
### Vier Entscheidungen
|
||||||
|
|
||||||
|
- **Der Verlauf hängt am unteren Rand, nicht am oberen.** Damit erzeugt jedes
|
||||||
|
Wachstum das Hochschieben von selbst, ohne eine zweite Rechnung für das
|
||||||
|
Scrollen. Wenn die Aufgabenkarte aufklappt, rutscht der Verlauf nach.
|
||||||
|
- **Die Karte wird aufgedeckt, nicht gestaucht.** Sie liegt in voller Größe da
|
||||||
|
und wird über eine `clipPath` von oben freigegeben. Eine gestauchte Karte
|
||||||
|
sieht kaputt aus, eine aufgedeckte sieht nach Aufklappen aus.
|
||||||
|
- **Der Mauszeiger ist erzählerisches Mittel, kein Bestandteil des Werkzeugs.**
|
||||||
|
Ohne ihn öffnet sich ein Menü und man weiß nicht, warum. Weiß mit dunkler
|
||||||
|
Kante, damit er auf Verlauf wie Menü liest, und nach dem zweiten Klick zügig
|
||||||
|
weg — sonst zieht er den Blick von der aufklappenden Aufgabe ab.
|
||||||
|
- **Am Ende wird nichts zurückgenommen.** Ein früherer Stand blendete die neue
|
||||||
|
Nachricht wieder aus, damit der Schleifenstoß unsichtbar wird. Das sah aus,
|
||||||
|
als lösche sich eine abgeschickte Nachricht von selbst. Eine Schleife darf
|
||||||
|
springen.
|
||||||
|
|
||||||
|
### Ohne Fließtext
|
||||||
|
|
||||||
|
Die Animation trägt als einzige keine Beschreibung. Die Überschrift sagt es,
|
||||||
|
die Bewegung zeigt es, und die Systemzeile im Bild bestätigt es — ein Satz
|
||||||
|
darunter wäre die dritte Stelle für dieselbe Aussage. Ein leerer `body` lässt
|
||||||
|
`contentHtml` den Absatz ganz weg, sonst schöbe sein Zeilenabstand die
|
||||||
|
Zeichnung nach unten, ohne dass etwas dasteht.
|
||||||
|
|
||||||
|
Die gut hundert Pixel, die dadurch frei werden, gehen an die Zeichnung:
|
||||||
|
`visW` von 700 auf 776, mittig gesetzt bei `visLeft: 152`. Luft zum Text 118,
|
||||||
|
also derselbe Wert wie auf Slide 1.
|
||||||
|
|
||||||
|
Die Überschrift „Chatnachrichten werden / **Aufgaben im Projekt.**" misst
|
||||||
|
879 px in der ersten und 733 in der zweiten Zeile, bei 896 erlaubten. Gemessen
|
||||||
|
mit `breite.mjs`, bevor sie in die Datei kam, und nach dem Einsetzen noch
|
||||||
|
einmal am gerenderten Blatt nachgezählt: zwei Zeilen, kein Umbruch.
|
||||||
|
|
||||||
|
**„Chatnachrichten" zusammengeschrieben, nicht mit Bindestrich.** Beides ist
|
||||||
|
korrekt, also entscheidet die Breite: „Chat-Nachrichten werden" misst 916 px
|
||||||
|
und läuft über die Schutzzone, zusammengeschrieben sind es 879. Verworfen
|
||||||
|
wurden außerdem „Aufgaben im richtigen Projekt." (1067 px) und „Aus
|
||||||
|
Chat-Nachrichten werden" (1063 px).
|
||||||
|
|
||||||
|
**Das Menü schnappt auf, es kriecht nicht.** Ein Zwischenstand nahm dafür
|
||||||
|
dieselbe gedämpfte Kurve wie alles andere, über 0,8 Sekunden. Die Deckung war
|
||||||
|
darin nach fünf Bildern fertig, während die Skalierung noch eine halbe Sekunde
|
||||||
|
von 0,96 auf 1,00 weiterlief. Zu sehen war davon fast nichts, zu spüren war es
|
||||||
|
als Hänger genau in dem Moment, in dem geklickt wird. Jetzt öffnet es in einer
|
||||||
|
Drittelsekunde mit `rueck`, schwingt einmal ganz leicht über und steht: drei
|
||||||
|
Bilder Aufblenden, dann Ruhe.
|
||||||
|
|
||||||
|
### Zur Länge
|
||||||
|
|
||||||
|
Instagram nimmt je Carousel-Element **3 bis 60 Sekunden** an. Sechs liegt weit
|
||||||
|
von beiden Grenzen weg; die Länge richtet sich also allein nach dem Ablauf.
|
||||||
|
Vier Schritte — anfassen, wählen, aufklappen, bestätigen — brauchen etwa so
|
||||||
|
lange. Eine kurze Schleife wiederholt sich außerdem öfter, während jemand liest.
|
||||||
|
Eine Zwischenfassung lief acht Sekunden, weil dort zusätzlich eine Nachricht
|
||||||
|
getippt wurde; das fällt mit dem richtigen Weg weg.
|
||||||
|
|
||||||
|
Die Symbole im Menü sind unsere Strichsymbole. Im Werkzeug steht bei
|
||||||
|
„Reagieren" ein Emoji; ein Emoji zwischen sechs Strichsymbolen bricht die
|
||||||
|
Bildsprache, und die Slides tragen durchgehend Feather-Symbole.
|
||||||
|
|
||||||
|
Nachgeprüft mit `ffprobe`: H.264 High, `yuv420p`, 1080 × 1350, 30 fps, 180
|
||||||
|
Bilder, Dauer exakt 6,000000 s, stumme AAC-Spur, `moov` am Dateianfang, 1,1 MB.
|
||||||
@@ -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 |
|
||||||
@@ -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.“
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Webinar-Post: Outline
|
||||||
|
|
||||||
|
**Stand:** 17.10.2026. Gebaut als `post9.html`, Termin und Uhrzeit sind noch Platzhalter.
|
||||||
|
|
||||||
|
## Gebaut (17.10.)
|
||||||
|
|
||||||
|
Aufbau nach der Ad-Recherche im Hub (`webinar/Ad-Recherche.md`): zuerst das Bewegtbild, dann eine Slide zum Anmelden. Als Anzeige laufen beide hintereinander, die zweite ist das Endbild.
|
||||||
|
|
||||||
|
| # | Element | Inhalt |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **Bewegtbild** `animWebinar`, 8 s, `animation/post9-animWebinar.mp4` | Tag „Live-Webinar“, Überschrift „Automatisierung im Handwerk.“ Darunter ein Webinar-Fenster: Live-Marke, Termin oben rechts, die vier Teile von „Was heute kommt“ laufen als Folien durch. Unten Noah und David. Wer gerade spricht, hat einen dunklen Rand, wie im Skript: Noah eröffnet jedes Kapitel, dann David. |
|
||||||
|
| 2 | **Anmeldung** | Tag „Jetzt anmelden“, Überschrift „Live zuschauen, live fragen.“, Untertitel des Webinars als Text. Karte mit Titel, Datum, Uhrzeit, Ort, beiden Sprechern, Knopf „Jetzt anmelden“ und „Link in der Bio“. |
|
||||||
|
|
||||||
|
- **Platzhalter** stehen an einer Stelle, in `WEBINAR` in `post9.html`: Datum, Wochentag, Uhrzeit, Dauer.
|
||||||
|
- **Nicht im Bild,** weil offen: „kostenlos“ und „mit Aufzeichnung“.
|
||||||
|
- **David Bertram:** Nachname aus `app/ueber-uns/team.ts` der Website. Noch bestätigen, dass er spricht.
|
||||||
|
- **Fotos:** `assets/eigene/portrait-ng.jpg` und `portrait-db.jpg`, ausgeschnitten aus den Teamkarten der Website.
|
||||||
|
- **Für die Anzeige** muss „Link in der Bio“ raus, die Anzeige hat ihren eigenen Knopf. Außerdem fehlen 9:16 und das zusammengesetzte Video (Bewegtbild plus Endbild).
|
||||||
|
- **Später:** Noah spricht rund 30 s in die Kamera (Variante 4 der Ad-Recherche).
|
||||||
|
|
||||||
|
## Ursprüngliche Outline (12.10.)
|
||||||
|
|
||||||
|
## Worum es geht
|
||||||
|
|
||||||
|
- HiveGrid hostet ein Webinar auf WebinarGeek. Thema, Termin und Inhalt stehen
|
||||||
|
noch nicht fest.
|
||||||
|
- Dejan schreibt zuerst Slides und Skript des Webinars. Danach bauen wir
|
||||||
|
diesen Post.
|
||||||
|
- Der Post lädt zur Anmeldung ein. Er ist eine Ankündigung, wie F15 in der
|
||||||
|
Formatbibliothek des Hubs.
|
||||||
|
- Er entsteht im neuen Look aus `hivegrid-cloud` (rules.md §1).
|
||||||
|
|
||||||
|
## Leitlinien
|
||||||
|
|
||||||
|
- **Der Titel nennt ein Problem aus dem Betrieb, keine Funktion.** So steht es
|
||||||
|
im Webinar-Konzept (`HiveGrid-Website/webinar.md` §1.2).
|
||||||
|
- **Nur Fakten, die feststehen.** „Kostenlos“ oder „mit Aufzeichnung“ nur, wenn
|
||||||
|
es stimmt. Kein Countdown, keine erfundenen Zahlen.
|
||||||
|
- **Ein Ziel: die Anmeldung.** Auf Instagram über den Link in der Bio, auf
|
||||||
|
LinkedIn direkt im Text.
|
||||||
|
- **Format und Aufbau** wie in rules.md §5: 1080 × 1350, Tag, Überschrift,
|
||||||
|
Beschreibung, Bild.
|
||||||
|
|
||||||
|
## Slide 1: die Einladung
|
||||||
|
|
||||||
|
Sie muss allein tragen. Wer nicht weiterwischt, weiß trotzdem, was es ist,
|
||||||
|
wann es ist und wo man sich anmeldet.
|
||||||
|
|
||||||
|
| Baustein | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| Tag | WEBINAR |
|
||||||
|
| Überschrift | der Titel des Webinars, ein Wort markiert |
|
||||||
|
| Beschreibung | ein Satz: für wen und worum |
|
||||||
|
| Bild | der Termin als Karte im Look der App. Datum groß, Uhrzeit, Dauer, „Online“. Unten ein schwarzer Knopf „Anmelden“ mit dem Hinweis auf den Link in der Bio. Gebaut aus Card, Badge und Button von `hivegrid-cloud`. |
|
||||||
|
|
||||||
|
## Slide 2: was du mitnimmst
|
||||||
|
|
||||||
|
| Baustein | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| Tag | DAS ERWARTET DICH |
|
||||||
|
| Überschrift | der Nutzen in einem Satz |
|
||||||
|
| Bild | drei Punkte aus dem Ablauf untereinander, als StepperVertical oder Liste in einer Card. Darunter, wer spricht: Foto, Name, Rolle (Avatar). Am Ende derselbe Knopf wie auf Slide 1. |
|
||||||
|
|
||||||
|
## Als Einzelbild
|
||||||
|
|
||||||
|
Reicht eine Slide, bekommt Slide 1 drei Zeilen Ablauf in die Karte. Der Rest
|
||||||
|
kommt in die Bildunterschrift.
|
||||||
|
|
||||||
|
## Kanäle und Zeitpunkt
|
||||||
|
|
||||||
|
- Instagram und Facebook als Carousel oder Bild.
|
||||||
|
- LinkedIn als L4 Anlass, mit dem Anmeldelink im Text.
|
||||||
|
- Erscheinen kann der Post, sobald der Termin steht. Das Webinar-Konzept rät,
|
||||||
|
rund drei Wochen vorher zu werben. Die Hälfte der Anmeldungen kommt dort in
|
||||||
|
der letzten Woche (§1.4).
|
||||||
|
|
||||||
|
## Was noch fehlt
|
||||||
|
|
||||||
|
Kommt aus Dejans Slides und Skript:
|
||||||
|
|
||||||
|
- Titel und das Problem dahinter
|
||||||
|
- Datum, Uhrzeit, Dauer
|
||||||
|
- wer spricht
|
||||||
|
- drei Punkte aus dem Ablauf
|
||||||
|
- der Anmeldelink bei WebinarGeek
|
||||||
|
- ob es kostenlos ist und ob es eine Aufzeichnung gibt
|
||||||
@@ -18,9 +18,309 @@ Downloads landen direkt in `~/Downloads`, ohne Browser-Dialog.
|
|||||||
|
|
||||||
## Projekte
|
## Projekte
|
||||||
|
|
||||||
| Projekt | Datei | Inhalt |
|
| Projekt | Datei | Inhalt | Übergabe |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Post 1 · Lösung | `post1.html` | Carousel, Anzahl frei | |
|
||||||
|
| Post 2 · Die Geschichte | `post2.html` | Carousel, 6 Slides, erscheint 12.08. | [`POST-2.md`](./POST-2.md) |
|
||||||
|
| Post 2 · Alternative | `post2-alt.html` | Derselbe Beitrag im Theme `modern` | |
|
||||||
|
| Post 3 · Verbinden statt ersetzen | `post3.html` | Einzelbild, Theme `modern`, erscheint 17.08. | |
|
||||||
|
| Post 4 · Workflow-Engine | `post4.html` | Carousel, 4 Slides, Theme `modern`, erscheint 19.08. | [`POST-4.md`](./POST-4.md) |
|
||||||
|
| Post 5 · Projektmanagement | `post5.html` | Carousel, 5 Slides, Theme `modern`, erscheint 21.08. | [`POST-5.md`](./POST-5.md) |
|
||||||
|
| Post 6 · Team-Chat | `post6.html` | Carousel, 5 Slides, Theme `modern`, erscheint 26.08. | [`POST-6.md`](./POST-6.md) |
|
||||||
|
|
||||||
|
**Alle Streifen nebeneinander** zeigt `uebersicht.html`, erreichbar über
|
||||||
|
*Projekt → Alle Projekte im Streifen*. Die Seite baut die Slides nicht selbst
|
||||||
|
nach: jedes Projekt kommt als `iframe` mit `?nur=streifen`, und in diesem Modus
|
||||||
|
blendet die Post-Seite ihre Bedienoberfläche aus, zeigt den Streifen in
|
||||||
|
natürlicher Größe und **speichert nichts** — drei Kacheln, die beim Schließen
|
||||||
|
ihren Stand zurückschreiben, wären genau die Falle aus [`POST-2.md`](./POST-2.md) §6.
|
||||||
|
Jeder Streifen ist auf die Fensterbreite eingepasst, gescrollt wird nur
|
||||||
|
hoch und runter — kein Zoom, weder außen noch im Streifen.
|
||||||
|
|
||||||
|
**Post 2 gibt es zweimal, in zwei Themes.** Projekt und Theme sind zwei
|
||||||
|
verschiedene Dinge: das Projekt sagt, welcher Beitrag, das Theme, wie er
|
||||||
|
aussieht. `notebook` ist die Zeichenblock-Optik mit Raster und Marker,
|
||||||
|
`modern` der ruhige Grund mit farbigem Akzentwort und schwebenden Karten.
|
||||||
|
Beide zeigen denselben Beitrag mit denselben Texten.
|
||||||
|
|
||||||
|
Die Themes stehen in [`rules.md`](./rules.md) §1a mit ihren Unterschieden.
|
||||||
|
Wer eine Visualisierung ändert, muss an beide Dateien denken.
|
||||||
|
|
||||||
|
**Post 3 ist ein Einzelbild**, keine Slidefolge: eine Haltung, die man
|
||||||
|
durchwischen muss, ist keine. Sein Bild ist das **Wabenfeld der Startseite**,
|
||||||
|
nachgebaut aus `HoneycombSection.tsx` — dieselbe Achsrechnung, dieselben
|
||||||
|
Zellnummern, dieselbe Belegung, damit es wiedererkennbar bleibt und keine
|
||||||
|
Anmutung davon ist. Die vier Waben „In Arbeit" fehlen: auf der Startseite sind
|
||||||
|
sie ein Versprechen, im Beitrag wären sie ein Eingeständnis.
|
||||||
|
|
||||||
|
**Post 4 zeigt einen einzigen echten Ablauf**, nicht sechs Behauptungen über
|
||||||
|
Automatisierung: den Vorgang aus `assets/screens/workflow.png`. Die Bausteine
|
||||||
|
sind nachgebaut aus `NodeWorkflowEngine` und `WorkflowEngineClickDummy` des
|
||||||
|
Pitch-Pakets, in Markenfarben statt in den UI-Farben des Produkts — Teal trägt
|
||||||
|
die Arbeit, Gelb den einen Akzent, Anthrazit die Entscheidung.
|
||||||
|
|
||||||
|
**Vier Slides.** Der Redaktionsplan nennt fünf bis sieben, aber das ist eine
|
||||||
|
Spanne und kein Soll: eine Slide, die nichts hinzufügt, kostet einen Wisch und
|
||||||
|
gibt nichts zurück.
|
||||||
|
|
||||||
|
| Slide | Was sie leistet | Ansicht | Quelle der Texte |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | was es ist | ein Ablauf auf dem Canvas | Knoten aus `assets/screens/workflow.png`, Notiz aus `WORKFLOW_PRESETS`, Anordnung eigen |
|
||||||
|
| 2 | was ihn startet | Auslöser-Auswahl | `assets/screens/workflow-bausteine.png` |
|
||||||
|
| 3 | was dann passiert | Aktionen-Auswahl | `NodeWorkflowEngine`, Rubriken des `Katalog` |
|
||||||
|
| 4 | dass man nicht bei null anfängt | drei Vorlagen | `GENERAL_WORKFLOW_PRESETS` |
|
||||||
|
|
||||||
|
Slide 2 und 3 sind **eine Funktion mit zwei Rubriken**, weil es im Produkt eine
|
||||||
|
Auswahl ist: „Auslöser" und „Aktionen" stehen dort untereinander im selben
|
||||||
|
Feld. Getrennt gezeichnet wären es zwei Bilder, die zufällig gleich aussehen.
|
||||||
|
Die Kachelfarbe kommt dabei aus der **Kategorie**, nicht aus dem Knotentyp —
|
||||||
|
im Screenshot tragen alle Auslöser eine gelbe Kachel und die Aktion eine
|
||||||
|
blaue, samt Punkt vor der Rubrik. Auf dem Canvas ist es anders, dort färbt die
|
||||||
|
Wurzelkomponente je Art. Beides stimmt, es sind zwei Ansichten.
|
||||||
|
|
||||||
|
**Das Farbsystem stammt aus der Wurzelkomponente** und ist keine Dekoration:
|
||||||
|
grün fängt an, rot ändert Daten, blau meldet, amber macht eine Aufgabe daraus,
|
||||||
|
violett entscheidet, grau ist ein Fremdsystem. Deshalb sind auf Slide 3 alle
|
||||||
|
Kacheln grün — jede Vorlage startet mit einem Auslöser.
|
||||||
|
|
||||||
|
> **Die Kartenform auf Slide 3 ist nicht aus dem Projekt.** Im Werkzeug werden
|
||||||
|
> Vorlagen nur geladen, nicht als Galerie gezeigt; es gibt dort keine Ansicht,
|
||||||
|
> an die man sich halten könnte. Wörtlich sind die Namen, Beschreibungen und
|
||||||
|
> Startbausteine, gebaut ist die Form. Der Unterschied gehört benannt.
|
||||||
|
|
||||||
|
**Text mittig, Titel ohne Fließtext.** Die Visualisierungen sitzen 896 px breit
|
||||||
|
bei x 92, also links wie rechts 92 px Rand — ein linksbündiger Textblock
|
||||||
|
darüber steht sichtbar aus der Achse. Die Titel-Slide trägt nur die
|
||||||
|
Überschrift: der Ablauf darunter sagt, was gemeint ist, und ein Fließtext
|
||||||
|
wiederholte es in Prosa. Dafür fiel hier dieselbe Korrektur an wie in
|
||||||
|
`post3.html` — ein leerer Fließtext darf keinen leeren Absatz erzeugen, sonst
|
||||||
|
schiebt sein Zeilenabstand die Zeichnung nach unten.
|
||||||
|
|
||||||
|
> **Die Gabel auf Slide 1 kommt früher als im Screenshot, und ein Schritt
|
||||||
|
> fehlt.** Alle drei echten Abläufe verzweigen erst an Position 3 oder 4 —
|
||||||
|
> das füllt ein hochkantes Format schlecht, weil über und unter dem Stamm
|
||||||
|
> nichts steht, solange er läuft. Hier gabelt es nach dem zweiten Knoten, und
|
||||||
|
> zwischen „Formular eingegangen" und „Passender Lead?" fehlt dadurch „Daten
|
||||||
|
> vereinheitlichen". Alle sechs Knoten heißen wörtlich wie in `workflow.png`
|
||||||
|
> und tragen dessen Farben, aber diese eine Kante ist zusammengezogen: ein
|
||||||
|
> Ablauf, den es genau so gibt, ist es nicht mehr — einer, den es geben
|
||||||
|
> könnte, schon. Die Anordnung ist ohnehin eigen, im Werkzeug zieht man
|
||||||
|
> Knoten mit der Maus.
|
||||||
|
|
||||||
|
**Kein PDS im Beitrag.** Von den sechs Vorlagen nennen drei das
|
||||||
|
Branchenprogramm — zwei im Namen, eine in der Beschreibung. Der Beitrag
|
||||||
|
richtet sich an Betriebe allgemein, nicht an die, die zufällig dieses eine
|
||||||
|
Programm fahren. Übrig bleibt genau die Gruppe `GENERAL_WORKFLOW_PRESETS`, in
|
||||||
|
der das Wort kein einziges Mal vorkommt — also keine Auswahl nach Geschmack,
|
||||||
|
sondern die vollständige zweite Gruppe. Gekürzt wurde nichts: aus „Offene
|
||||||
|
Angebote aus PDS erkennen" ein „Offene Angebote erkennen" zu machen wäre die
|
||||||
|
bequeme Lösung und kein Zitat mehr. Lieber eine Karte weniger.
|
||||||
|
|
||||||
|
**Alle Produkttexte auf den Slides sind wortgleich aus der Quelle**, Titel wie
|
||||||
|
Beschreibungen. Wo ein Name nicht in eine Zeile passte, steht er zweizeilig —
|
||||||
|
gekürzt wird nicht, denn aus „Daten vereinheitlichen" ein „Prüfen" zu machen
|
||||||
|
ist keine Kürzung, sondern ein anderer Name.
|
||||||
|
|
||||||
|
**Der Knoten ist die echte Komponente.** `NodeItem` aus
|
||||||
|
`NodeWorkflowEngine.tsx` des Pitch-Pakets: quadratisch 128 × 128, `rounded-lg`,
|
||||||
|
6 px Rand, Titel und Beschreibung **unter** dem Feld, beim Auslöser die linke
|
||||||
|
Flanke voll gerundet. Genau so rendert es auch `assets/screens/workflow.png`.
|
||||||
|
Ein Zwischenstand zeichnete stattdessen breite Karten mit farbiger Kante links
|
||||||
|
und einem Block „BESCHREIBUNG" — abgemalt vom Pitch-Bild `02_Präsentation/03`.
|
||||||
|
Im Code des Projekts gibt es das nirgends, und der Kommentar behauptete
|
||||||
|
trotzdem, es sei ein Nachbau der Komponente.
|
||||||
|
|
||||||
|
**Die Farben sind die des Produkts**, nicht die der Marke. Aufgelöst aus
|
||||||
|
`ui/theme.css` über die ganze Token-Kette (`surface-ui-subtle-green-default`
|
||||||
|
→ `--color-ui-green-100` → `--color-green-100`), nicht aus dem Bild geschätzt:
|
||||||
|
|
||||||
|
| Rolle | Fläche | Zeichen |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Post 1 · Lösung | `post1.html` | Carousel, Anzahl frei |
|
| Auslöser | `#DCFCE4` | `#148134` |
|
||||||
|
| Daten | `#FEE2E2` | `#C52020` |
|
||||||
|
| Entscheidung | `#F0E7FF` | `#691CD4` |
|
||||||
|
| Meldung | `#D5EFFF` | `#003BEB` |
|
||||||
|
| Abschluss | `#FCF6C5` | `#9B5E0D` |
|
||||||
|
|
||||||
|
Ein Zwischenstand hatte sie durch Markenfarben ersetzt, weil `rules.md` §2 diese
|
||||||
|
Töne nicht kennt. Das ist zurückgenommen: der Beitrag zeigt die Oberfläche, und
|
||||||
|
ihre Farbe trägt Bedeutung — grün fängt an, rot arbeitet an Daten, violett
|
||||||
|
entscheidet, blau meldet, gelb schließt ab. Umgefärbt wäre es eine
|
||||||
|
Nacherzählung. Der Rand ist `border-default` `#E5E5E5`, also der Wert, vor dem
|
||||||
|
`rules.md` §5 warnt; hier trägt er, weil die Felder farbig gefüllt sind.
|
||||||
|
|
||||||
|
**Der Notizzettel auf Slide 1** ist `StickyNote.tsx` aus dem Pitch-Paket
|
||||||
|
(`rounded-md`, Kopfleiste `h-8`, Text `#424242`, sechs Farbpaare) mit dem
|
||||||
|
grünen Zettel aus dem Preset `form-to-pds`, Text unverändert. Zettel sind das,
|
||||||
|
was einen Ablauf im Werkzeug von einem Diagramm unterscheidet.
|
||||||
|
|
||||||
|
**Was hier bewusst nicht steht: ein erfundener Vorgang.** Ein Zwischenstand
|
||||||
|
hatte eine Slide mit einem Aktivitätenverlauf samt Uhrzeiten. Nichts davon war
|
||||||
|
gemessen. Bei einem Produkt vor dem Start ist eine erfundene Uhrzeit keine
|
||||||
|
Veranschaulichung, sondern ein Beleg, den es nicht gibt.
|
||||||
|
|
||||||
|
**Slide 1 trägt einen Ablauf, kein Stichwort.** Im Profilraster ist sie eine
|
||||||
|
Kachel von rund 150 px, auf 3:4 beschnitten — dort ist kein Wort lesbar. Was
|
||||||
|
ankommt, ist die Form: eine Kette, die sich gabelt.
|
||||||
|
|
||||||
|
Was über die 1080 px einer Seite hinausragt, landet auf der Nachbarslide — das
|
||||||
|
durchgehende Blatt aus `rules.md` §5 liegt in jeder Slide. Ein Prüfskript misst
|
||||||
|
das nach; alle sechs Zeichnungen bleiben auf der Sicherheitszone.
|
||||||
|
|
||||||
|
Sein Grund ist **gepunktet statt leer**: die Arbeitsfläche des Workflow-Editors,
|
||||||
|
mit den Werten aus `WorkflowEditor.tsx` der Website. Der Punktgrund steht als
|
||||||
|
dritte Stellung unter *Versuch → Punkte* und ist die Voreinstellung dieses
|
||||||
|
Projekts. Warum das Theme `modern` hier von seinem ruhigen Grund abweicht, steht
|
||||||
|
im CSS bei `.layer-raster`.
|
||||||
|
|
||||||
|
**Post 5 zeigt die Pipeline von vier Seiten.** Das Demo-Projekt hat dafür vier
|
||||||
|
Bildschirme — Schablone, Board, Liste, Detail — und jeder bekommt eine Slide,
|
||||||
|
dazu die Karte aus der Nähe. Anders als bei Post 4 wird **nichts umgefärbt**:
|
||||||
|
eine Pipeline lebt davon, dass man das Gewerk an der Farbe erkennt, ohne zu
|
||||||
|
lesen, und dass der Fuß einer Karte sich mit ihrer Priorität färbt. Die
|
||||||
|
Zuordnung Gewerk zu Ton ist `CATEGORY_TONES`, die Plättchenfarben sind
|
||||||
|
`ui/theme.css`.
|
||||||
|
|
||||||
|
| Slide | Screen | Was sie leistet |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | 4A | das Board selbst, drei Phasen mit Anzahl, Summe und Karten |
|
||||||
|
| 2 | 4A | eine Karte groß |
|
||||||
|
| 3 | 4B | „Vom Verkauf in die Abwicklung": dasselbe Projekt in `Auftragsabwicklung SHK`, mit dem Knopf für den Pipelinewechsel |
|
||||||
|
| 4 | 4C | dieselben Projekte als Tabelle |
|
||||||
|
| 5 | 3 | der Feldeditor, offen auf „Aufmaß erledigt" |
|
||||||
|
|
||||||
|
**Es sind zwei Pipelines, nicht eine.** Das Board gehört zu
|
||||||
|
`Vertrieb: Anlagen-Verkauf`, das Projekt in der Detailansicht liegt in
|
||||||
|
`Auftragsabwicklung SHK`, und ein Knopf schiebt es von der einen in die andere.
|
||||||
|
Das ist der Unterschied zwischen Vertriebs- und Abwicklungs-Projektmanagement,
|
||||||
|
und Slide 3 ist deshalb die Pointe des Beitrags. Möglich ist beides in einer
|
||||||
|
Software, weil die Felder an der Pipeline hängen und nicht am Programm — das
|
||||||
|
zeigt Slide 5.
|
||||||
|
|
||||||
|
**Alle Zahlen sind gerechnet, keine ist gesetzt.** Anzahl und Summe je Spalte
|
||||||
|
kommen aus denselben Datensätzen wie die Karten.
|
||||||
|
|
||||||
|
**Die Karte ist aus dem Code nachgebaut, nicht aus dem Screenshot.** Der erste
|
||||||
|
Anlauf war von `assets/screens/pipelines.png` abgemalt und lag an drei Stellen
|
||||||
|
daneben, die im Bild nicht zu erkennen waren. Der Rest steht in
|
||||||
|
[`POST-5.md`](./POST-5.md) §7.
|
||||||
|
|
||||||
|
> **Eine Richtigstellung.** Ein früherer Stand behauptete hier, die
|
||||||
|
> Detailansicht sei nur ein Entwurfs-PNG. Falsch: geprüft worden waren die
|
||||||
|
> Beschriftungen des Bildes `07_Screen-4b_Karten-Detail.png`, und die kommen im
|
||||||
|
> Code wirklich nicht vor — den Bildschirm gibt es trotzdem, unter
|
||||||
|
> `app/(screens)/4b/`, nur anders beschriftet. Die Abwesenheit einer
|
||||||
|
> Zeichenkette beweist die Abwesenheit eines Bildschirms nicht.
|
||||||
|
|
||||||
|
**Post 6 zeigt den Team-Chat**, nachgebaut aus `GeneralTeamChatPage` des
|
||||||
|
Pitch-Pakets. Anders als die Pipeline mit ihren vier eigenen Bildschirmen gibt
|
||||||
|
es vom Chat nur eine Fassung, unter `/allgemein/team-chat`. Die Inhalte darin
|
||||||
|
sind trotzdem aus dem Gewerk: Wärmepumpe Müller, Montagetermin,
|
||||||
|
Baustellenfotos.
|
||||||
|
|
||||||
|
| Slide | Was sie leistet |
|
||||||
|
|---|---|
|
||||||
|
| 1 | Kanalliste und Verlauf nebeneinander, mit Filterzeile und Eingabefeld |
|
||||||
|
| 2 | links das Durcheinander als Zeichnung, rechts die Kanalliste |
|
||||||
|
| 3 | Fotos, die Aufgabe daraus, die Bestätigung im Projekt |
|
||||||
|
| 4 | das Menü hinter dem Plus im Eingabefeld |
|
||||||
|
| 5 | zwei Ausschnitte der Oberfläche, versetzt übereinander, mit Absperrband |
|
||||||
|
|
||||||
|
**Slide 3 ist der Kern**, und sie ist keine Konstruktion: im Werkzeug folgen
|
||||||
|
die drei Nachrichten genau so aufeinander. Tobias schickt die Baustellenfotos,
|
||||||
|
darauf eine Nachricht mit angehängter Aufgabe, darauf die Systemzeile
|
||||||
|
„Aufgabe „Materialliste prüfen" im Projekt erstellt". Die letzte Zeile macht
|
||||||
|
den Unterschied: die Aufgabe liegt danach im Projekt, nicht nur im Verlauf.
|
||||||
|
|
||||||
|
**Slide 5 zeigt zwei Ausschnitte statt eines Geräts.** Die Machart ist die,
|
||||||
|
die Signal für solche Slides benutzt: zwei Teile der Oberfläche,
|
||||||
|
versetzt übereinander, jeder mit eigenem Schatten. Ein Zwischenstand hatte hier
|
||||||
|
ein nachgebautes Handy samt Insel und Tasten; das kostet viel Platz für etwas,
|
||||||
|
das nichts über das Produkt sagt, und der Rahmen war frei erfunden.
|
||||||
|
|
||||||
|
**Slide 2 stellt gegenüber statt zu zeigen:** links das Durcheinander als
|
||||||
|
Zeichnung, rechts die Oberfläche als Ausschnitt. Die linke Hälfte gibt es in
|
||||||
|
diesem Repo schon, in `post2.html` auf Slide 2 (`visProzesse`) — dort im
|
||||||
|
Zeichenblock-Stil, hier glatt. Die Plättchen sind Redaktionstext und keine
|
||||||
|
Produktstrings; sie beschreiben die Wirklichkeit im Betrieb.
|
||||||
|
|
||||||
|
**Die Leitung läuft über den Wisch hinweg:** sie beginnt links außerhalb der
|
||||||
|
Slide, und mit `visLeft: -102` landet dieses Stück auf Slide 1. Das ist der
|
||||||
|
Überstand, vor dem `rules.md` §5 sonst warnt — `.page` hat kein `overflow` —
|
||||||
|
hier aber gewollt. `pruef.mjs` liest einen negativen `--vis-left` als Ansage
|
||||||
|
und meldet ihn nicht mehr als Fehler.
|
||||||
|
|
||||||
|
Beide Ausschnitte auf Slide 5 sind leicht verwischt: erkennbar, aber nicht zu lesen.
|
||||||
|
Scharf bleibt allein das Band. Ein zweites Mittel von Slack, ein dicker gelber
|
||||||
|
Rahmen um die Anhangkarte auf Slide 3, ist wieder weg: die Karte trägt als
|
||||||
|
einzige eine gelbe Marke, der Blick geht ohnehin dorthin, und ein Rahmen sagt
|
||||||
|
nochmal, was die Anordnung schon sagt — [`POST-6.md`](./POST-6.md) §5c.
|
||||||
|
|
||||||
|
Dass die App noch nicht da ist, sagt das **Absperrband** von der Website:
|
||||||
|
`ChatDemoSection.tsx` hat es unter der Fahne `comingSoon`, -13° gedreht, in
|
||||||
|
`#F1B729` mit Kanten in `#171106`, darauf Schutzhelm und „BALD VERFÜGBAR". Auch
|
||||||
|
Überschrift und Fließtext sind die der Website; der Inhalt der Ausschnitte
|
||||||
|
kommt aus dem Pitch-Paket wie auf allen anderen Slides.
|
||||||
|
|
||||||
|
**Das Band läuft hinter die Ausschnitte** und ist an ihrer Silhouette
|
||||||
|
beschnitten — so hält es die Website auch, dort am Rand des Telefons. Es endet
|
||||||
|
damit nicht mehr quer zur eigenen Laufrichtung an der Bildkante, wie ein
|
||||||
|
Aufkleber, sondern an einer senkrechten Plattenkante, hinter der es
|
||||||
|
verschwindet. Die Schnittkante ist weicher als eine gezogene Linie und härter
|
||||||
|
als die unscharfen Platten.
|
||||||
|
|
||||||
|
**Und es legt sich um den Stapel, statt darauf zu liegen** — die größte
|
||||||
|
Abweichung von der Website in diesem Beitrag. Genau an der Plattenkante knickt
|
||||||
|
es um, läuft dahinter weiter und kommt an der Kante wieder hervor: tiefer,
|
||||||
|
weil es umgeschlagen ist, niedriger, weil es wegführt, flacher getönt, weil
|
||||||
|
man von dort seine Unterseite sieht. Ohne die zwei Festlichkeiten des
|
||||||
|
klassischen Spruchbands: keine Zacken, kein Bogen.
|
||||||
|
|
||||||
|
Die Stelle des Knicks wird dabei nicht gerechnet. Beide Stücke reichen weit in
|
||||||
|
die Platte hinein, und zwei Masken aus derselben Kontur schneiden sie an deren
|
||||||
|
Kante ab — die eine lässt die Fläche innen stehen, die andere die Stücke
|
||||||
|
außen, und ihre Deckungen ergänzen sich zu eins. Darüber liegt die Falzkante
|
||||||
|
als eigener Strich, senkrecht auf der Plattenkante und über beide hinweg. Drei
|
||||||
|
Anläufe hat es bis dahin gebraucht; woran die ersten beiden lagen, steht in
|
||||||
|
[`POST-6.md`](./POST-6.md) §5a.
|
||||||
|
|
||||||
|
**Die Tiefe kommt aus dem Stapel und gestaffelten Schatten, nicht aus
|
||||||
|
Flächen.** Ein Zwischenstand legte graue Flächen hinter die Elemente; das nimmt
|
||||||
|
den Zeichnungen ihre Durchsichtigkeit und setzt einen Kasten auf den
|
||||||
|
Punktgrund. Stattdessen zweierlei: die echten Schatten aus `ui/theme.css`, die
|
||||||
|
dort **gestaffelt** sind (enge dunkle Kante plus weite helle, in SVG zwei
|
||||||
|
`feDropShadow` im selben Filter), und der **Stapel** aus `post1.html`, in SVG
|
||||||
|
nachgebaut mit denselben Winkeln, die dort im CSS stehen. Die hinteren Blätter
|
||||||
|
sind leer: Andeutung, keine Kopie. Auf Slide 1 und 2, wo ein Bildschirm zu
|
||||||
|
sehen ist; nicht auf Slide 3, wo ein Ausschnitt einer Unterhaltung steht.
|
||||||
|
|
||||||
|
Dazu die Datumsmarke und die Zeile „Maike Berger schreibt gerade …" aus der
|
||||||
|
Quelle, die der Unterhaltung Struktur geben, ohne eine Fläche zu behaupten.
|
||||||
|
|
||||||
|
**Die Bildergalerie ist eine Anhangkarte, kein Bild.** Im Werkzeug lädt
|
||||||
|
`GalleryPreview` zwei Fotos von Unsplash. Fremde Fotos in einen Beitrag zu
|
||||||
|
setzen, der von der eigenen Baustelle spricht, wäre eine Behauptung über
|
||||||
|
Material, das wir nicht haben.
|
||||||
|
|
||||||
|
**Die vier Profilbilder sind Unsplash-Aufnahmen.** Sie liegen unter
|
||||||
|
`assets/eigene/` als `portrait-sk`, `-mb`, `-tw`, `-ms`, ausgewählt nicht nach
|
||||||
|
Gesicht, sondern nach Unterscheidbarkeit im Kreis von 42 px — dort zählt der
|
||||||
|
Gesamtwert und die Haarkontur, nicht die Züge.
|
||||||
|
|
||||||
|
Zum Absatz darüber steht das in einer Spannung, die benannt gehört: die
|
||||||
|
Unsplash-Lizenz deckt das Urheberrecht des Fotografen, **kein Model Release**.
|
||||||
|
Ein Zwischenstand wich deshalb auf erzeugte Gesichter aus und ist zugunsten
|
||||||
|
echter Aufnahmen zurückgenommen worden. Herkunft, Rechtslage und der einzige
|
||||||
|
Weg, der die Frage wirklich beseitigt, stehen in
|
||||||
|
[`POST-6.md`](./POST-6.md) §4.
|
||||||
|
|
||||||
|
Die vierzehn Partner-Marken liegen unter `assets/partner/` und sind, wie alles
|
||||||
|
andere hier, aus der Website **gespiegelt statt verlinkt**. Sechs davon sind
|
||||||
|
PNG und damit dieselbe Wette wie ein Screenshot (§7 in `rules.md`), deshalb
|
||||||
|
prüft der Export sie einzeln nach — `wabenZonen()` in `post3.html`.
|
||||||
|
|
||||||
|
Der Redaktionsplan für alle weiteren Beiträge liegt in
|
||||||
|
[`redaktionsplan/`](./redaktionsplan/), gebaut mit `python3 redaktionsplan/bauen.py`.
|
||||||
|
|
||||||
### Slides anlegen und wegnehmen
|
### Slides anlegen und wegnehmen
|
||||||
|
|
||||||
@@ -94,6 +394,102 @@ Server nicht läuft. Wer den Stand ändert, muss also committen und pushen —
|
|||||||
sonst sieht ihn niemand sonst. Gibt es die Datei noch nicht, wird der Stand
|
sonst sieht ihn niemand sonst. Gibt es die Datei noch nicht, wird der Stand
|
||||||
aus dem Browser einmalig hineingeschrieben.
|
aus dem Browser einmalig hineingeschrieben.
|
||||||
|
|
||||||
|
|
||||||
|
## Animation
|
||||||
|
|
||||||
|
Unter dem Canvas liegt in **jedem** Projekt, im selben Fenster, ein Bereich
|
||||||
|
„Animation". Er zeigt jede Animation als eigenes Blatt in Vorschau, mit
|
||||||
|
Zeitregler, `Abspielen` und `MP4 exportieren`.
|
||||||
|
|
||||||
|
**Ein Klick auf den Kopf klappt ihn zu**, dann bleibt eine Zeile von 40 px und
|
||||||
|
der Canvas hat den Platz. Ob er offen ist, merkt sich der Browser je Projekt
|
||||||
|
(`localStorage`) und nicht der Projektstand: es ist eine Vorliebe und keine
|
||||||
|
Gestaltung, sie hat in `state/<projekt>.json` nichts verloren, wo sie bei jedem
|
||||||
|
einen Diff machen würde.
|
||||||
|
|
||||||
|
**Eine Animation ist keine Slide.** Sie steht in `ANIMS` und nicht in
|
||||||
|
`SLIDES`, liegt also nicht im Streifen, taucht in der Slide-Auswahl nicht auf
|
||||||
|
und wird vom Bildexport nicht angefasst. Das ist der ganze Punkt: im Carousel
|
||||||
|
ist sie ein eigenes Element, und ob sie mitkommt, entscheidest du beim Posten.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const ANIMS = [
|
||||||
|
{ key: "animChat", type: "feature", dauer: 6,
|
||||||
|
eyebrow: "Bewegtbild",
|
||||||
|
head: 'Getippt,<br><span class="a">und schon im Projekt.</span>',
|
||||||
|
body: "Eine Nachricht, ein Klick, und die Aufgabe hängt am richtigen Projekt.",
|
||||||
|
vis: "animChat", visW: 700, visLeft: 190, visBottom: 120 },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
Dieselben Felder wie bei einer Slide, dazu `dauer` in Sekunden. Die
|
||||||
|
Zeichenfunktion aus `VIS` bekommt einen zweiten Wert `t` zwischen 0 und 1 und
|
||||||
|
zeichnet daraus den Stand zu diesem Zeitpunkt. Blatt, Überschrift,
|
||||||
|
Punktgrund und Schutzzone sind dieselben wie überall.
|
||||||
|
|
||||||
|
### Exportieren
|
||||||
|
|
||||||
|
**`MP4 exportieren`** im Bereich unter dem Canvas. Der Browser kann kein MP4
|
||||||
|
schreiben, also stößt die Seite den Bau auf dem Server an (`POST /anim`) und
|
||||||
|
fragt den Stand ab; die Zeile unter den Knöpfen zeigt den Fortschritt und am
|
||||||
|
Ende den Pfad. Getrennt vom Bildexport, in beide Richtungen: „Alle Slides als
|
||||||
|
ZIP" enthält kein Video, und dieser Knopf rendert kein Bild.
|
||||||
|
|
||||||
|
**Die Datei landet zweimal.** In `~/Downloads`, wie die Bilder, zum Hochladen.
|
||||||
|
Und in `animation/` im Repo, wo sie versioniert ist und nach einem Pull auf
|
||||||
|
der anderen Maschine liegt. In Downloads wird nichts überschrieben: eine
|
||||||
|
zweite Ausgabe heißt `…-1.mp4`, genau wie beim Bildexport.
|
||||||
|
|
||||||
|
Auf der Kommandozeile geht dasselbe:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./serve.sh # muss laufen
|
||||||
|
node animation.mjs post6.html # alle Animationen des Projekts
|
||||||
|
node animation.mjs post6.html animChat # nur diese
|
||||||
|
```
|
||||||
|
|
||||||
|
Heraus kommt `animation/post6-animChat.mp4` (auf der Kommandozeile nur dort,
|
||||||
|
ohne die Kopie in Downloads). Angesprochen wird die Animation
|
||||||
|
über ihren Schlüssel, nicht über eine Nummer: sie hat keine Position im
|
||||||
|
Streifen, die sich verschieben könnte.
|
||||||
|
|
||||||
|
Für die Aufnahme lädt das Skript die Seite als `?nur=anim&k=<key>`. In diesem
|
||||||
|
Modus zeigt sie allein das Blatt der Animation, in natürlicher Größe an 0,0 —
|
||||||
|
damit entfällt jede Ausschnittsrechnung.
|
||||||
|
|
||||||
|
### Was Instagram bei so einem Beitrag tatsächlich annimmt
|
||||||
|
|
||||||
|
Kurz: **ein MP4, kein GIF.**
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Format | MP4, H.264 High, `yuv420p`, `+faststart` |
|
||||||
|
| Maße | 1080 × 1350 (4:5), wie die Standbilder |
|
||||||
|
| Bildrate | 30 fps, Schlüsselbild jede Sekunde |
|
||||||
|
| Datenrate | rund 12 Mbit/s |
|
||||||
|
| Länge | mindestens 3 s, höchstens 60 s je Carousel-Element |
|
||||||
|
| Ton | eine stumme AAC-Spur |
|
||||||
|
|
||||||
|
Vier Dinge, die den Aufbau bestimmen:
|
||||||
|
|
||||||
|
- **GIF geht nicht.** Instagram nimmt keine GIF-Uploads an; die Datei landet
|
||||||
|
als Standbild. Was in der App wie ein animierter Beitrag aussieht, ist ein
|
||||||
|
kurzes Video.
|
||||||
|
- **Ein Video im Carousel wird kein Reel.** Seit 2022 wandert nur ein
|
||||||
|
*einzeln* geposteter Video-Beitrag automatisch in die Reels. Liegt das Video
|
||||||
|
in einem Carousel, bleibt der Beitrag ein Beitrag.
|
||||||
|
- **Der erste Beitrag im Carousel bestimmt den Ausschnitt für alle.** Ein
|
||||||
|
Element in einem anderen Seitenverhältnis wird beschnitten, nicht
|
||||||
|
eingepasst. Deshalb rendert die Animation in genau denselben 1080 × 1350 wie
|
||||||
|
jedes Standbild.
|
||||||
|
- **Eine stumme Tonspur muss mit.** Manche Upload-Wege lehnen ein Video ohne
|
||||||
|
Audiostrom ab. Die Spur kostet 128 kbit/s und erspart die Fehlersuche.
|
||||||
|
|
||||||
|
Die Bewegung läuft in Schleife. Das letzte Bild ist deshalb **nicht** dasselbe
|
||||||
|
wie das erste (`t = n / anzahl`, nicht `n / (anzahl − 1)`) — sonst steht der
|
||||||
|
Stoß eine Dreißigstelsekunde still.
|
||||||
|
|
||||||
|
|
||||||
## Bedienung
|
## Bedienung
|
||||||
|
|
||||||
| Taste | Wirkung |
|
| Taste | Wirkung |
|
||||||
@@ -146,17 +542,48 @@ Maßstab zurück.
|
|||||||
```
|
```
|
||||||
assets/
|
assets/
|
||||||
├── brand/ Wortmarke hell/dunkel, G-Marke (SVG + PNG), Wabe, Geerkens
|
├── brand/ Wortmarke hell/dunkel, G-Marke (SVG + PNG), Wabe, Geerkens
|
||||||
├── partner/ DATEV, KWP, Google Calendar, Google Drive, MS Outlook, MS Teams
|
├── partner/ die 14 Marken des Wabenfelds (DATEV, KWP, pds, Bauhow, Aircall,
|
||||||
|
│ Onepage, PandaDoc, Hallo Petra, Claude, OpenAI, Google Drive,
|
||||||
|
│ Google Calendar, MS Outlook, MS Teams)
|
||||||
├── mockups/ Geräte-Mockups (Displays ausgeknockt, Screenshot liegt dahinter)
|
├── mockups/ Geräte-Mockups (Displays ausgeknockt, Screenshot liegt dahinter)
|
||||||
├── screens/ Software-Screenshots
|
├── screens/ Software-Screenshots
|
||||||
├── eigene/ selbst importierte Bilder
|
├── eigene/ selbst importierte Bilder
|
||||||
└── fonts/ Manrope 400–800, lokal
|
└── fonts/ Manrope 400–800, lokal
|
||||||
vendor/ html-to-image, JSZip
|
vendor/ html-to-image, JSZip
|
||||||
state/ Projektstände, gehören ins Repo
|
state/ Projektstände, gehören ins Repo
|
||||||
serve.py Statischer Server + /save, /state, /upload, /bilder
|
animation.mjs baut aus einem ANIMS-Eintrag ein MP4 (braucht ffmpeg)
|
||||||
|
animation/ die fertigen MP4s, ein Carousel-Element je Datei
|
||||||
|
serve.py Statischer Server + /save, /state, /upload, /bilder, /anim
|
||||||
rules.md Gestaltungsregeln, Farbwerte, Export-Wissen
|
rules.md Gestaltungsregeln, Farbwerte, Export-Wissen
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Assets werden nachgefragt, nicht neu geladen
|
||||||
|
|
||||||
|
Ein Bild, das unter demselben Namen ausgetauscht wird, muss sofort ankommen —
|
||||||
|
sonst arbeitet man gegen eine alte Kopie. Dafür stand auf allem `no-store`:
|
||||||
|
im Server und noch einmal in `preloadAssets()`. Der Browser durfte nichts
|
||||||
|
behalten.
|
||||||
|
|
||||||
|
Das Ziel war richtig, der Preis zu hoch. Jeder Reload lud alles neu, und weil
|
||||||
|
`uebersicht.html` die sechs Projekte als iframes lädt und jeder iframe für
|
||||||
|
sich vorlud, kam die Seite auf **38 MB in 435 Anfragen**.
|
||||||
|
|
||||||
|
`no-cache` leistet dasselbe billiger: der Browser behält seine Kopie, fragt
|
||||||
|
aber vor jeder Benutzung nach. Eine ausgetauschte Datei kommt genauso sofort
|
||||||
|
an, eine unveränderte kostet ein 304 ohne Rumpf — und die sechs iframes teilen
|
||||||
|
sich eine Kopie statt sechs zu ziehen.
|
||||||
|
|
||||||
|
| | vorher | jetzt |
|
||||||
|
|---|---|---|
|
||||||
|
| `uebersicht.html`, erster Aufruf | 38,1 MB | 2,2 MB |
|
||||||
|
| `uebersicht.html`, Reload | 32,8 MB | 1,9 MB |
|
||||||
|
| `post6.html`, Reload | 5,4 MB | 0,3 MB |
|
||||||
|
|
||||||
|
Nachgeprüft ist beides: unverändert antwortet der Server mit `304` und
|
||||||
|
0 Bytes, nach einem `touch` mit `200` und der vollen Datei. **Seite, Stand und
|
||||||
|
Auskünfte (`/bilder`, `/state`) bleiben bei `no-store`** — die sind klein und
|
||||||
|
dürfen nie aus einer Kopie kommen.
|
||||||
|
|
||||||
## Regeln
|
## Regeln
|
||||||
|
|
||||||
**`rules.md` ist die Arbeitsgrundlage.** Farbwerte, Geometrie, Typografie,
|
**`rules.md` ist die Arbeitsgrundlage.** Farbwerte, Geometrie, Typografie,
|
||||||
|
|||||||
@@ -0,0 +1,975 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<meta charset="utf-8"><title>Varianten Titel-Slide</title>
|
||||||
|
<link rel="stylesheet" href="./assets/fonts/manrope.css">
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{background:#fff;font-family:'Manrope',sans-serif;padding:44px 40px 50px}
|
||||||
|
h1{font-size:30px;font-weight:800;letter-spacing:-.025em;margin-bottom:6px}
|
||||||
|
h1+.hin{margin-bottom:32px}
|
||||||
|
.hin{font-size:14px;color:#666;line-height:1.45;max-width:920px}
|
||||||
|
hr{border:0;border-top:1px solid #E2E6EA;margin:50px 0 40px}
|
||||||
|
.gitter{display:grid;grid-template-columns:repeat(3,1fr);gap:36px 26px}
|
||||||
|
figure{display:flex;flex-direction:column;gap:12px}
|
||||||
|
.buehne{height:452px;position:relative}
|
||||||
|
.karte-halter{position:absolute;left:20px;top:70px;width:620px;
|
||||||
|
transform:scale(.46);transform-origin:top left}
|
||||||
|
.karte-halter.leer{height:775px}
|
||||||
|
svg.karte-skizze{position:absolute;z-index:6;left:-7.42%;width:114.84%;top:-19.35%;height:125.29%;
|
||||||
|
overflow:visible}
|
||||||
|
.karte{position:relative;aspect-ratio:4/5;overflow:hidden;border-radius:8px;background:#0f1a2e;isolation:isolate}
|
||||||
|
.karte-grund{position:absolute;inset:0;z-index:1;overflow:hidden;border-radius:8px;background:#2b2d2a;
|
||||||
|
filter:grayscale(.04) saturate(.74) brightness(.73)}
|
||||||
|
.karte-grund img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
|
||||||
|
.karte-wash{position:absolute;inset:0;background:linear-gradient(180deg,rgba(15,26,46,.01),rgba(15,26,46,.12))}
|
||||||
|
.karte-person{position:absolute;inset:0;z-index:4;width:100%;height:100%;object-fit:cover;
|
||||||
|
filter:saturate(1) contrast(1.01)}
|
||||||
|
.karte-name{position:absolute;left:6%;bottom:5.7%;z-index:5;display:flex;align-items:baseline;gap:13px;
|
||||||
|
padding:13px 17px;background:#F1B729;color:#101010}
|
||||||
|
.karte-name span{font-size:27px;font-weight:700;letter-spacing:-.03em;line-height:1.15}
|
||||||
|
.karte-name small{font-size:12px;font-weight:700;letter-spacing:.14em;text-transform:uppercase}
|
||||||
|
/* Halb hell, halb dunkel: der Helm liegt im Post auf beidem. */
|
||||||
|
.probe{width:300px;height:250px;background:linear-gradient(90deg,#fff 50%,#14161A 50%);
|
||||||
|
border:1px solid #E2E6EA}
|
||||||
|
.probe svg{display:block}
|
||||||
|
figcaption{display:flex;flex-direction:column;gap:3px}
|
||||||
|
figcaption b{font-size:15px;font-weight:800;letter-spacing:-.01em;line-height:1.25}
|
||||||
|
figcaption span{font-size:12.5px;color:#666;line-height:1.35}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<h1>1 · Rahmen</h1>
|
||||||
|
<p class="hin">Ohne Foto, damit nur die Zeichnung zu sehen ist. In Originalgroesse gezogen und fuer die
|
||||||
|
Ansicht auf 46 % verkleinert, damit die Strichstaerken zueinander stimmen. Nummer 7 ist die gewaehlte
|
||||||
|
Mischung und steckt bereits in post2.html.</p>
|
||||||
|
<div class="gitter"><figure><div class="buehne"><div class="karte-halter leer"><svg viewBox="0 0 712 971" width="712" height="971" class="karte-skizze" id="r0"></svg></div></div><figcaption><b>1 · Fein</b><span>Zwei Zuege, der zweite als leichter Nachzieher.</span></figcaption></figure><figure><div class="buehne"><div class="karte-halter leer"><svg viewBox="0 0 712 971" width="712" height="971" class="karte-skizze" id="r1"></svg></div></div><figcaption><b>2 · Kraeftig</b><span>Ein einziger fetter Zug, enger am Bild.</span></figcaption></figure><figure><div class="buehne"><div class="karte-halter leer"><svg viewBox="0 0 712 971" width="712" height="971" class="karte-skizze" id="r2"></svg></div></div><figcaption><b>3 · Versetzt</b><span>Zweiter Rahmen nach unten rechts.</span></figcaption></figure><figure><div class="buehne"><div class="karte-halter leer"><svg viewBox="0 0 712 971" width="712" height="971" class="karte-skizze" id="r3"></svg></div></div><figcaption><b>4 · Passepartout</b><span>Duenner Aussenrahmen mit Abstand, fetter Innenrahmen.</span></figcaption></figure><figure><div class="buehne"><div class="karte-halter leer"><svg viewBox="0 0 712 971" width="712" height="971" class="karte-skizze" id="r4"></svg></div></div><figcaption><b>5 · Chamfer</b><span>Die Markenform, von Hand gezogen.</span></figcaption></figure><figure><div class="buehne"><div class="karte-halter leer"><svg viewBox="0 0 712 971" width="712" height="971" class="karte-skizze" id="r5"></svg></div></div><figcaption><b>6 · Schattenkante</b><span>Fetter Rahmen, aussen an zwei Seiten schraffiert.</span></figcaption></figure><figure><div class="buehne"><div class="karte-halter leer"><svg viewBox="0 0 712 971" width="712" height="971" class="karte-skizze" id="r6"></svg></div></div><figcaption><b>7 · Gewaehlt: Chamfer + Versetzt + Schattenkante</b><span>Der versetzte Zug ist die Aussenkante des Schattens, die Schraffur fuellt das Band dazwischen.</span></figcaption></figure></div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h1>2 · Helme</h1>
|
||||||
|
<p class="hin">Jeder Helm auf halb hellem, halb dunklem Grund: im Post liegt er auf beidem, deshalb ist
|
||||||
|
er ganz in Gelb gezogen. Alle in derselben Breite, damit die Entwuerfe vergleichbar sind.</p>
|
||||||
|
<div class="gitter"><figure><div class="probe"><svg viewBox="0 0 300 250" width="300" height="250" id="k0"></svg></div><figcaption><b>H1 · Einfach</b><span>Kuppel, Krempe, drei Grate. Der Helm, der im Post steckt.</span></figcaption></figure><figure><div class="probe"><svg viewBox="0 0 300 250" width="300" height="250" id="k1"></svg></div><figcaption><b>H2 · Fein</b><span>Mehr Stuetzpunkte, First, Nebenrippen, Krempendicke, Schattierung.</span></figcaption></figure><figure><div class="probe"><svg viewBox="0 0 300 250" width="300" height="250" id="k2"></svg></div><figcaption><b>H3 · Schraffiert</b><span>Gefuellt statt schattiert, dafuer ohne Rippen.</span></figcaption></figure><figure><div class="probe"><svg viewBox="0 0 300 250" width="300" height="250" id="k3"></svg></div><figcaption><b>H4 · Dreiviertel</b><span>Krempe als Ellipse, dadurch raeumlich statt flach.</span></figcaption></figure><figure><div class="probe"><svg viewBox="0 0 300 250" width="300" height="250" id="k4"></svg></div><figcaption><b>H5 · Mit Kinnriemen</b><span>Wie Fein, dazu Riemen und Schnalle.</span></figcaption></figure><figure><div class="probe"><svg viewBox="0 0 300 250" width="300" height="250" id="k5"></svg></div><figcaption><b>H6 · Kontur</b><span>Nur die Silhouette, zwei kraeftige Zuege.</span></figcaption></figure></div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h1>3 · Fertiges Konzept</h1>
|
||||||
|
<p class="hin">Rahmen, Foto und Helm 2 zusammen. Nur Lage und Groesse des Helms unterscheiden sich.</p>
|
||||||
|
<div class="gitter"><figure><div class="buehne"><div class="karte-halter"><svg viewBox="0 0 712 971" width="712" height="971" class="karte-skizze" id="f0"></svg><div class="karte"><div class="karte-grund"><img alt="" src="./assets/team/noah-bg.webp"><span class="karte-wash"></span></div><img class="karte-person" alt="" src="./assets/team/noah-person.webp"><div class="karte-name"><span>Noah</span><small>CEO</small></div></div></div></div><figcaption><b>A · Ueber der Ecke</b><span>Helm 2, ueber der oberen rechten Ecke.</span></figcaption></figure><figure><div class="buehne"><div class="karte-halter"><svg viewBox="0 0 712 971" width="712" height="971" class="karte-skizze" id="f1"></svg><div class="karte"><div class="karte-grund"><img alt="" src="./assets/team/noah-bg.webp"><span class="karte-wash"></span></div><img class="karte-person" alt="" src="./assets/team/noah-person.webp"><div class="karte-name"><span>Noah</span><small>CEO</small></div></div></div></div><figcaption><b>B · Auf dem Kopf, einfach</b><span>Helm 2 auf dem Kopf, kleinere Breite (186).</span></figcaption></figure><figure><div class="buehne"><div class="karte-halter"><svg viewBox="0 0 712 971" width="712" height="971" class="karte-skizze" id="f2"></svg><div class="karte"><div class="karte-grund"><img alt="" src="./assets/team/noah-bg.webp"><span class="karte-wash"></span></div><img class="karte-person" alt="" src="./assets/team/noah-person.webp"><div class="karte-name"><span>Noah</span><small>CEO</small></div></div></div></div><figcaption><b>C · Auf der Ecke</b><span>Helm 2, nach rechts gekippt, Oeffnung ueber der Ecke.</span></figcaption></figure><figure><div class="buehne"><div class="karte-halter"><svg viewBox="0 0 712 971" width="712" height="971" class="karte-skizze" id="f3"></svg><div class="karte"><div class="karte-grund"><img alt="" src="./assets/team/noah-bg.webp"><span class="karte-wash"></span></div><img class="karte-person" alt="" src="./assets/team/noah-person.webp"><div class="karte-name"><span>Noah</span><small>CEO</small></div></div></div></div><figcaption><b>D · Auf dem Kopf, fein</b><span>Helm 2 auf dem Kopf, groessere Breite (222).</span></figcaption></figure></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* ─── Skizzen-Primitive ─────────────────────────────────────────────────────
|
||||||
|
Handzeichnung aus gesetztem Zufall. Der Keim ist fest: der Export rendert
|
||||||
|
bei fehlgeschlagener Pruefung bis zu fuenfmal neu, und jeder Durchgang muss
|
||||||
|
denselben Strich ergeben. Mit ungesetztem Math.random zittert jedes Bild
|
||||||
|
anders und die Kontrolle vergleicht gegen etwas, das es nie wieder gibt. */
|
||||||
|
let _s = 0;
|
||||||
|
function keim(n) { _s = n >>> 0; }
|
||||||
|
function rnd() {
|
||||||
|
_s = (_s + 0x6D2B79F5) >>> 0;
|
||||||
|
let t = Math.imul(_s ^ (_s >>> 15), 1 | _s);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
}
|
||||||
|
const zit = m => (rnd() * 2 - 1) * m;
|
||||||
|
const n1 = v => Math.round(v * 10) / 10;
|
||||||
|
|
||||||
|
/* Ein Strich, leicht gebogen und an den Enden verwackelt. Der Bogen waechst
|
||||||
|
mit der Laenge: eine kurze Kante zittert, eine lange schwingt. */
|
||||||
|
function strich(x1, y1, x2, y2, o) {
|
||||||
|
o = o || {};
|
||||||
|
const j = o.j == null ? 1.5 : o.j;
|
||||||
|
const ue = o.ue == null ? 0 : o.ue; // Ueberschuss an den Enden
|
||||||
|
let dx = x2 - x1, dy = y2 - y1;
|
||||||
|
const len = Math.hypot(dx, dy) || 1, ux = dx / len, uy = dy / len;
|
||||||
|
const ax = x1 - ux * rnd() * ue + zit(j), ay = y1 - uy * rnd() * ue + zit(j);
|
||||||
|
const bx = x2 + ux * rnd() * ue + zit(j), by = y2 + uy * rnd() * ue + zit(j);
|
||||||
|
dx = bx - ax; dy = by - ay;
|
||||||
|
const nx = -dy / len, ny = dx / len;
|
||||||
|
const b = o.b == null ? len * 0.008 + 1 : o.b;
|
||||||
|
const o1 = zit(b), o2 = zit(b);
|
||||||
|
return "M" + n1(ax) + "," + n1(ay) + "C" +
|
||||||
|
n1(ax + dx / 3 + nx * o1) + "," + n1(ay + dy / 3 + ny * o1) + " " +
|
||||||
|
n1(ax + dx * 2 / 3 + nx * o2) + "," + n1(ay + dy * 2 / 3 + ny * o2) + " " +
|
||||||
|
n1(bx) + "," + n1(by);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Punktfolge zu einer weichen Kurve (Catmull-Rom als Bezier). */
|
||||||
|
function glatt(pts, zu) {
|
||||||
|
const n = pts.length;
|
||||||
|
const at = i => pts[zu ? (i + n) % n : Math.max(0, Math.min(n - 1, i))];
|
||||||
|
let d = "M" + n1(pts[0][0]) + "," + n1(pts[0][1]);
|
||||||
|
const bis = zu ? n : n - 1;
|
||||||
|
for (let i = 0; i < bis; i++) {
|
||||||
|
const p0 = at(i - 1), p1 = at(i), p2 = at(i + 1), p3 = at(i + 2);
|
||||||
|
d += "C" + n1(p1[0] + (p2[0] - p0[0]) / 6) + "," + n1(p1[1] + (p2[1] - p0[1]) / 6) +
|
||||||
|
" " + n1(p2[0] - (p3[0] - p1[0]) / 6) + "," + n1(p2[1] - (p3[1] - p1[1]) / 6) +
|
||||||
|
" " + n1(p2[0]) + "," + n1(p2[1]);
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kringel statt Kreis: der Zug faengt irgendwo an, laeuft einmal herum und
|
||||||
|
schiesst ein Stueck ueber den Anfang hinaus. Genau das macht die Hand. */
|
||||||
|
function kringel(cx, cy, rx, ry, o) {
|
||||||
|
o = o || {};
|
||||||
|
ry = ry == null ? rx : ry;
|
||||||
|
const wack = o.wack == null ? Math.min(rx, ry) * 0.02 + 1 : o.wack;
|
||||||
|
const a0 = o.a0 == null ? rnd() * 6.283 : o.a0;
|
||||||
|
const ueber = o.ueber == null ? 0.16 + rnd() * 0.2 : o.ueber;
|
||||||
|
const schritte = 18, pts = [];
|
||||||
|
const spanne = 6.283 + ueber;
|
||||||
|
for (let i = 0; i <= schritte; i++) {
|
||||||
|
const a = a0 + spanne * (i / schritte);
|
||||||
|
pts.push([cx + Math.cos(a) * (rx + zit(wack)), cy + Math.sin(a) * (ry + zit(wack))]);
|
||||||
|
}
|
||||||
|
return glatt(pts, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vier einzelne Kanten mit Ueberschuss an den Ecken. Der Ueberschuss ist das
|
||||||
|
eigentliche Erkennungsmerkmal von Handzeichnung, nicht das Zittern. */
|
||||||
|
function rechteck(x, y, w, h, o) {
|
||||||
|
o = o || {};
|
||||||
|
const ue = o.ue == null ? 3.5 : o.ue;
|
||||||
|
return [
|
||||||
|
strich(x, y, x + w, y, { ue: ue }),
|
||||||
|
strich(x + w, y, x + w, y + h, { ue: ue }),
|
||||||
|
strich(x + w, y + h, x, y + h, { ue: ue }),
|
||||||
|
strich(x, y + h, x, y, { ue: ue }),
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der Chamfer als gezeichneter Kasten: acht Kanten statt vier. Das ist die
|
||||||
|
Markenform aus rules.md Paragraf 3, hier von Hand gezogen. */
|
||||||
|
function chamfer(x, y, w, h, c, o) {
|
||||||
|
o = o || {};
|
||||||
|
const ue = o.ue == null ? 2.5 : o.ue;
|
||||||
|
const p = [[x + c, y], [x + w - c, y], [x + w, y + c], [x + w, y + h - c],
|
||||||
|
[x + w - c, y + h], [x + c, y + h], [x, y + h - c], [x, y + c]];
|
||||||
|
let d = "";
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const a = p[i], b = p[(i + 1) % 8];
|
||||||
|
d += strich(a[0], a[1], b[0], b[1], { ue: i % 2 ? 1 : ue });
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Schraffur als Schattierung: parallele Striche, an den Enden kuerzer, damit
|
||||||
|
das Feld nicht als Block steht. */
|
||||||
|
function schraff(x, y, w, h, abstand, neig) {
|
||||||
|
neig = neig == null ? 0.55 : neig;
|
||||||
|
let d = "";
|
||||||
|
for (let t = -h * neig; t < w; t += abstand) {
|
||||||
|
const x1 = x + t, x2 = x + t + h * neig;
|
||||||
|
const a = Math.max(x, Math.min(x + w, x1)), b = Math.max(x, Math.min(x + w, x2));
|
||||||
|
if (b - a < 3) continue;
|
||||||
|
const y1 = y + h - (a - x1) / neig, y2 = y + h - (b - x1) / neig;
|
||||||
|
d += strich(a, Math.min(y + h, Math.max(y, y1)), b, Math.min(y + h, Math.max(y, y2)), { j: .8, b: .8 });
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bodenschatten: ein paar kurze Striche unter dem Gegenstand. Sie sitzen eng
|
||||||
|
am Fuss, sonst schwebt der Gegenstand ueber seinem eigenen Schatten. */
|
||||||
|
function boden(cx, y, w) {
|
||||||
|
let d = "";
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const f = 1 - Math.abs(i - 1.5) * 0.26;
|
||||||
|
const b = w * f * (0.62 + rnd() * 0.26);
|
||||||
|
d += strich(cx - b / 2 + zit(3), y + i * 3.6, cx + b / 2 + zit(3), y + i * 3.6 - 4, { j: .9, b: 1.2 });
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Bausteine ────────────────────────────────────────────────────────── */
|
||||||
|
const TINTE = "#2A2C33", GELB = "#E9A81C";
|
||||||
|
const zug = (d, o) => {
|
||||||
|
o = o || {};
|
||||||
|
return '<path d="' + d + '" fill="none" stroke="' + (o.f || TINTE) +
|
||||||
|
'" stroke-width="' + (o.w || 3.1) + '" stroke-linecap="round" stroke-linejoin="round"' +
|
||||||
|
(o.op ? ' stroke-opacity="' + o.op + '"' : '') +
|
||||||
|
(o.strichel ? ' stroke-dasharray="' + o.strichel + '"' : '') + '/>';
|
||||||
|
};
|
||||||
|
const schatten = d => zug(d, { w: 1.9, op: .34 });
|
||||||
|
|
||||||
|
/* ─── Die Satelliten ─────────────────────────────────────────────────────
|
||||||
|
Jedes Ikon meldet zurueck, wie weit es reicht (rad) und wo seine
|
||||||
|
Beschriftung hingehoert (unten). Sonst haengt der Knoten am kleinen Ikon
|
||||||
|
zu weit weg und am grossen klebt er drin. */
|
||||||
|
function ikonKi(cx, cy) {
|
||||||
|
const zack = (x, y, r) => {
|
||||||
|
const k = r * 0.28, p = [];
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const a = -Math.PI / 2 + i * Math.PI / 4, rr = i % 2 ? k : r;
|
||||||
|
p.push([x + Math.cos(a) * rr + zit(1.4), y + Math.sin(a) * rr + zit(1.4)]);
|
||||||
|
}
|
||||||
|
return glatt(p, true);
|
||||||
|
};
|
||||||
|
return zug(zack(cx - 8, cy - 6, 46), { w: 3.4 }) +
|
||||||
|
zug(zack(cx + 38, cy + 30, 17), { w: 2.7 }) +
|
||||||
|
zug(zack(cx + 34, cy - 34, 12), { w: 2.5 }) +
|
||||||
|
schatten(boden(cx, cy + 54, 88));
|
||||||
|
}
|
||||||
|
ikonKi.rad = 52; ikonKi.unten = 92;
|
||||||
|
|
||||||
|
function ikonChat(cx, cy) {
|
||||||
|
const blase = (x, y, rx, ry, sx) =>
|
||||||
|
zug(kringel(x, y, rx, ry, { ueber: .12 })) +
|
||||||
|
zug(glatt([[x + sx * rx * .42, y + ry * .82], [x + sx * rx * .86, y + ry * 1.6],
|
||||||
|
[x + sx * rx * .08, y + ry * .98]], false));
|
||||||
|
return blase(cx + 20, cy - 4, 38, 30, 1) +
|
||||||
|
blase(cx - 22, cy - 16, 47, 37, -1) +
|
||||||
|
[-18, 0, 18].map(o => zug(kringel(cx - 22 + o, cy - 16, 4.6), { w: 2.5 })).join("") +
|
||||||
|
schatten(boden(cx, cy + 52, 100));
|
||||||
|
}
|
||||||
|
ikonChat.rad = 58; ikonChat.unten = 90;
|
||||||
|
|
||||||
|
function ikonCrm(cx, cy) {
|
||||||
|
const kopf = (x, y, r, w) => zug(kringel(x, y, r), { w: w }) +
|
||||||
|
zug(glatt([[x - r * 1.55, y + r * 2.7], [x - r * 1.5, y + r * 1.05],
|
||||||
|
[x, y + r * .55], [x + r * 1.5, y + r * 1.05], [x + r * 1.55, y + r * 2.7]], false), { w: w });
|
||||||
|
return kopf(cx - 42, cy - 16, 17, 2.8) + kopf(cx + 42, cy - 16, 17, 2.8) +
|
||||||
|
kopf(cx, cy + 2, 22, 3.3) +
|
||||||
|
schatten(boden(cx, cy + 62, 116));
|
||||||
|
}
|
||||||
|
ikonCrm.rad = 72; ikonCrm.unten = 92;
|
||||||
|
|
||||||
|
function ikonAuto(cx, cy) {
|
||||||
|
const r = 39, zaehne = 10;
|
||||||
|
let d = "";
|
||||||
|
for (let i = 0; i < zaehne; i++) {
|
||||||
|
const a = i * 6.283 / zaehne, br = 0.22;
|
||||||
|
const p = [];
|
||||||
|
[[-br, r], [-br * .8, r + 14], [br * .8, r + 14], [br, r]].forEach(v => {
|
||||||
|
const aa = a + v[0];
|
||||||
|
p.push([cx + Math.cos(aa) * v[1] + zit(.9), cy + Math.sin(aa) * v[1] + zit(.9)]);
|
||||||
|
});
|
||||||
|
d += glatt(p, false);
|
||||||
|
}
|
||||||
|
return zug(kringel(cx, cy, r)) + zug(d, { w: 2.9 }) + zug(kringel(cx, cy, 15), { w: 2.7 }) +
|
||||||
|
schatten(boden(cx, cy + 60, 96));
|
||||||
|
}
|
||||||
|
ikonAuto.rad = 56; ikonAuto.unten = 88;
|
||||||
|
|
||||||
|
function ikonBoard(cx, cy) {
|
||||||
|
const w = 116, h = 88, x = cx - w / 2, y = cy - h / 2;
|
||||||
|
let d = rechteck(x, y, w, h);
|
||||||
|
let k = "";
|
||||||
|
for (let i = 1; i < 3; i++) k += strich(x + w * i / 3, y + 7, x + w * i / 3, y + h - 7, { j: 1, b: 1 });
|
||||||
|
const karte = (sx, sy, sh) => rechteck(x + sx, y + sy, w / 3 - 16, sh, { ue: 2 });
|
||||||
|
k += karte(8, 11, 19) + karte(8, 36, 19) + karte(w / 3 + 8, 11, 19) + karte(2 * w / 3 + 8, 11, 31);
|
||||||
|
return zug(d) + zug(k, { w: 2.4 }) + schatten(boden(cx, cy + h / 2 + 7, 116));
|
||||||
|
}
|
||||||
|
ikonBoard.rad = 64; ikonBoard.unten = 84;
|
||||||
|
|
||||||
|
function ikonFormular(cx, cy) {
|
||||||
|
const w = 90, h = 116, x = cx - w / 2, y = cy - h / 2;
|
||||||
|
let d = rechteck(x, y, w, h);
|
||||||
|
d += rechteck(x + w / 2 - 19, y - 12, 38, 22, { ue: 2 });
|
||||||
|
let k = "";
|
||||||
|
[32, 50, 68].forEach(o => { k += strich(x + 15, y + o, x + w - 34, y + o, { j: 1, b: 1 }); });
|
||||||
|
k += rechteck(x + 15, y + 80, 23, 23, { ue: 2 });
|
||||||
|
const hak = [[x + 19, y + 91], [x + 25, y + 98], [x + 35, y + 81]];
|
||||||
|
return zug(d) + zug(k, { w: 2.4 }) + zug(glatt(hak, false), { w: 3.6, f: GELB }) +
|
||||||
|
schatten(boden(cx, cy + h / 2 + 7, 92));
|
||||||
|
}
|
||||||
|
ikonFormular.rad = 62; ikonFormular.unten = 96;
|
||||||
|
|
||||||
|
/* Ein Strich mit Spitze am Ende. Die Spitze sind zwei kurze Zuege, keine
|
||||||
|
gefuellte Flaeche: gezeichnet wird sie so, gefuellt sieht sie gesetzt aus. */
|
||||||
|
function pfeil(x1, y1, x2, y2, gr) {
|
||||||
|
gr = gr == null ? 16 : gr;
|
||||||
|
const a = Math.atan2(y2 - y1, x2 - x1), oeff = 0.42;
|
||||||
|
return strich(x1, y1, x2, y2, { j: 1.1 }) +
|
||||||
|
strich(x2, y2, x2 - Math.cos(a - oeff) * gr, y2 - Math.sin(a - oeff) * gr, { j: .8, b: .8 }) +
|
||||||
|
strich(x2, y2, x2 - Math.cos(a + oeff) * gr, y2 - Math.sin(a + oeff) * gr, { j: .8, b: .8 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Schraffur innerhalb einer Wabe. Eine gezeichnete Flaeche wird nicht
|
||||||
|
gefuellt, sie wird schraffiert: so bleibt der Raster-Grund sichtbar und
|
||||||
|
es sieht trotzdem nach "voll" aus wie auf der Website. */
|
||||||
|
function wabeSchraff(cx, cy, r, abstand) {
|
||||||
|
const h = r * 0.866;
|
||||||
|
let d = "";
|
||||||
|
for (let y = -h + abstand; y < h - abstand * 0.5; y += abstand) {
|
||||||
|
const halb = r - (r / 2) * (Math.abs(y) / h) - 8;
|
||||||
|
if (halb < 7) continue;
|
||||||
|
d += strich(cx - halb, cy + y - 3, cx + halb, cy + y + 3, { j: .9, b: 1 });
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ikonBrief(cx, cy, f) {
|
||||||
|
const w = 46, h = 31, x = cx - w / 2, y = cy - h / 2;
|
||||||
|
return zug(rechteck(x, y, w, h, { ue: 2.5 }), { w: 2.9, f: f }) +
|
||||||
|
zug(glatt([[x + 3, y + 4], [cx, y + h * 0.66], [x + w - 3, y + 4]], false),
|
||||||
|
{ w: 2.7, f: f });
|
||||||
|
}
|
||||||
|
|
||||||
|
function ikonBlaseKlein(cx, cy, f) {
|
||||||
|
return zug(kringel(cx, cy - 5, 25, 19, { ueber: .1 }), { w: 2.9, f: f }) +
|
||||||
|
zug(glatt([[cx - 8, cy + 12], [cx - 13, cy + 23], [cx + 3, cy + 13]], false),
|
||||||
|
{ w: 2.7, f: f }) +
|
||||||
|
[-9, 0, 9].map(o => zug(kringel(cx + o, cy - 5, 2.8), { w: 2.2, f: f })).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function ikonKonfetti(cx, cy, f) {
|
||||||
|
// Knallbonbon: Tuete unten links, Funken nach oben rechts.
|
||||||
|
const tuete = glatt([[cx - 28, cy + 24], [cx - 6, cy - 2],
|
||||||
|
[cx + 10, cy + 19], [cx - 28, cy + 24]], false) +
|
||||||
|
strich(cx - 20, cy + 15, cx - 2, cy + 19, { j: .9, b: .8 });
|
||||||
|
let funken = "";
|
||||||
|
[-1.42, -1.06, -0.68, -0.28].forEach((a, i) => {
|
||||||
|
const r0 = 16, r1 = 30 + (i % 2) * 7;
|
||||||
|
funken += strich(cx + 2 + Math.cos(a) * r0, cy + 3 + Math.sin(a) * r0,
|
||||||
|
cx + 2 + Math.cos(a) * r1, cy + 3 + Math.sin(a) * r1, { j: 1 });
|
||||||
|
});
|
||||||
|
let punkte = "";
|
||||||
|
[[20, -27], [31, -11], [26, -35], [8, -33]].forEach(v => {
|
||||||
|
punkte += kringel(cx + v[0], cy + v[1], 3.1);
|
||||||
|
});
|
||||||
|
return zug(tuete, { w: 2.9, f: f }) + zug(funken, { w: 2.4, f: f }) +
|
||||||
|
zug(punkte, { w: 2.2, f: f });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die Wabe, flat-top, Seitenverhaeltnis 2/Wurzel3 wie in rules.md Paragraf 3.
|
||||||
|
Bei dieser Form liegen die sechs Ecken auf einem Kreis vom Radius r. Genau
|
||||||
|
deshalb ist sie hier richtig: jede Speiche setzt an einer Ecke an, nicht
|
||||||
|
irgendwo auf einer Kante. Gezogen wird sie aus sechs einzelnen Strichen
|
||||||
|
mit Ueberschuss, nicht als geschlossenes Polygon. */
|
||||||
|
function wabeEcken(cx, cy, r) {
|
||||||
|
const p = [];
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const a = i * Math.PI / 3;
|
||||||
|
p.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wabe(cx, cy, r) {
|
||||||
|
const p = wabeEcken(cx, cy, r);
|
||||||
|
let d = "";
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const q = p[i], w = p[(i + 1) % 6];
|
||||||
|
d += strich(q[0], q[1], w[0], w[1], { ue: 5, j: 1.8 });
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Die Mitte: das bestehende System ───────────────────────────────────
|
||||||
|
Bewusst als Liste gezeichnet, nicht als Kachel-Dashboard: so sieht eine
|
||||||
|
Warenwirtschaft aus. Und bewusst der schwerste Strich auf der Slide, denn
|
||||||
|
sie ist das, was bleibt. Das Fenster passt ganz in den Ring, sonst kreuzen
|
||||||
|
sich Ringbogen und Fensterecke, und weil der Grund durchscheinen soll gibt
|
||||||
|
es keine Fuellung, die das verdecken koennte. */
|
||||||
|
function nabe(cx, cy) {
|
||||||
|
const w = 216, h = 150, x = cx - w / 2, y = cy - h / 2, kopf = 40;
|
||||||
|
let d = rechteck(x, y, w, h, { ue: 4 });
|
||||||
|
d += strich(x, y + kopf, x + w, y + kopf, { ue: 3 });
|
||||||
|
let k = "";
|
||||||
|
[0, 1, 2].forEach(i => { k += kringel(x + 18 + i * 15, y + kopf / 2, 4.4, 4.4, { ueber: .1 }); });
|
||||||
|
let z = "";
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const yy = y + kopf + 22 + i * 23;
|
||||||
|
z += strich(x + 17, yy, x + 59, yy, { j: 1, b: 1 });
|
||||||
|
z += strich(x + 72, yy, x + w - 48, yy, { j: 1, b: 1 });
|
||||||
|
z += strich(x + w - 38, yy, x + w - 17, yy, { j: 1, b: 1 });
|
||||||
|
}
|
||||||
|
return zug(d, { w: 3.6 }) + zug(k, { w: 2.4 }) + zug(z, { w: 2.2, op: .8 }) +
|
||||||
|
'<text x="' + cx + '" y="' + (y + kopf / 2 + 8) + '" text-anchor="middle" ' +
|
||||||
|
'font-family="Manrope, sans-serif" font-size="24" font-weight="800" ' +
|
||||||
|
'letter-spacing="-0.01em" fill="' + TINTE + '">HiveGrid</text>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Aufbau ───────────────────────────────────────────────────────────── */
|
||||||
|
/* ─── Slide 2: nur so gewachsen ──────────────────────────────────────────
|
||||||
|
Ein Knaeuel aus Kanaelen, durch das sich eine gestrichelte Leitung windet.
|
||||||
|
Bewusst ohne Aufloesung: das Gegenbild dazu ist Slide 5, und die Pointe
|
||||||
|
soll nicht zweimal vorkommen.
|
||||||
|
Die Namen sind allgemein gehalten, keine fremden Produkte. */
|
||||||
|
function glyBrief(cx, cy) {
|
||||||
|
const w = 30, h = 21, x = cx - w / 2, y = cy - h / 2;
|
||||||
|
return rechteck(x, y, w, h, { ue: 1.6 }) +
|
||||||
|
glatt([[x + 2, y + 3], [cx, y + h * 0.68], [x + w - 2, y + 3]], false);
|
||||||
|
}
|
||||||
|
function glyZettel(cx, cy) {
|
||||||
|
const w = 26, h = 32, x = cx - w / 2, y = cy - h / 2, e = 11;
|
||||||
|
return glatt([[x, y], [x + w - e, y], [x + w, y + e], [x + w, y + h],
|
||||||
|
[x, y + h], [x, y]], false) +
|
||||||
|
glatt([[x + w - e, y], [x + w - e, y + e], [x + w, y + e]], false);
|
||||||
|
}
|
||||||
|
function glyTabelle(cx, cy) {
|
||||||
|
const w = 30, h = 24, x = cx - w / 2, y = cy - h / 2;
|
||||||
|
let d = rechteck(x, y, w, h, { ue: 1.6 });
|
||||||
|
d += strich(x, y + h / 3, x + w, y + h / 3, { j: .7, b: .6 });
|
||||||
|
d += strich(x, y + h * 2 / 3, x + w, y + h * 2 / 3, { j: .7, b: .6 });
|
||||||
|
d += strich(x + w / 3, y, x + w / 3, y + h, { j: .7, b: .6 });
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
function glyHandy(cx, cy) {
|
||||||
|
const w = 20, h = 32, x = cx - w / 2, y = cy - h / 2;
|
||||||
|
return rechteck(x, y, w, h, { ue: 1.4 }) +
|
||||||
|
strich(x + 6, y + 4, x + w - 6, y + 4, { j: .6, b: .5 }) +
|
||||||
|
kringel(cx, y + h - 5, 1.9);
|
||||||
|
}
|
||||||
|
function glyOrdner(cx, cy) {
|
||||||
|
const w = 30, h = 26, x = cx - w / 2, y = cy - h / 2;
|
||||||
|
return rechteck(x, y + 6, w, h - 6, { ue: 1.4 }) +
|
||||||
|
glatt([[x + 1, y + 6], [x + 3, y], [x + 13, y], [x + 15, y + 6]], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die kleinen Kacheln, die im Vorbild lose dazwischen schweben. */
|
||||||
|
function kachel(cx, cy, art, gr) {
|
||||||
|
const s = gr || 52, x = cx - s / 2, y = cy - s / 2;
|
||||||
|
let d = rechteck(x, y, s, s, { ue: 2.5 });
|
||||||
|
const f = s / 52;
|
||||||
|
if (art === "blatt") {
|
||||||
|
d += rechteck(x + 16 * f, y + 13 * f, 20 * f, 26 * f, { ue: 1.4 });
|
||||||
|
[20, 26, 32].forEach(o => {
|
||||||
|
d += strich(x + 20 * f, y + o * f, x + 32 * f, y + o * f, { j: .6, b: .5 });
|
||||||
|
});
|
||||||
|
} else if (art === "bild") {
|
||||||
|
d += rechteck(x + 13 * f, y + 15 * f, 26 * f, 22 * f, { ue: 1.4 });
|
||||||
|
d += glatt([[x + 16 * f, y + 33 * f], [x + 23 * f, y + 23 * f], [x + 29 * f, y + 30 * f],
|
||||||
|
[x + 32 * f, y + 26 * f], [x + 36 * f, y + 33 * f]], false);
|
||||||
|
} else {
|
||||||
|
d += kringel(cx, y + 19 * f, 6 * f);
|
||||||
|
d += glatt([[x + 14 * f, y + 40 * f], [x + 15 * f, y + 32 * f], [cx, y + 28 * f],
|
||||||
|
[x + s - 15 * f, y + 32 * f], [x + s - 14 * f, y + 40 * f]], false);
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Eine ausholende Schlaufe zwischen zwei Ankern. Sie bauscht immer von der
|
||||||
|
Kachelspalte weg, damit die Leitung keine Beschriftung kreuzt: auf einem
|
||||||
|
durchscheinenden Grund kann kein Kasten sie verdecken. */
|
||||||
|
function schlaufe(ax, ay, bx, by, weite, seite) {
|
||||||
|
const dx = bx - ax, dy = by - ay, l = Math.hypot(dx, dy) || 1;
|
||||||
|
const nx = (dy / l) * seite, ny = (-dx / l) * seite;
|
||||||
|
return [
|
||||||
|
[ax, ay],
|
||||||
|
[ax + nx * weite * 0.62 + dx * 0.06, ay + ny * weite * 0.62 + dy * 0.06],
|
||||||
|
[(ax + bx) / 2 + nx * weite, (ay + by) / 2 + ny * weite],
|
||||||
|
[bx + nx * weite * 0.62 - dx * 0.06, by + ny * weite * 0.62 - dy * 0.06],
|
||||||
|
[bx, by],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ein Stueck Leitung von Kasten zu Kasten.
|
||||||
|
|
||||||
|
Der erste Versuch legte die Stuetzpunkte direkt auf die Kastenkante und
|
||||||
|
liess die Bahn danach irgendwohin laufen. Das sah aus, als streife die
|
||||||
|
Linie den Kasten zufaellig, und wo zwei Punkte fast aufeinanderlagen,
|
||||||
|
warf die Kurve einen Zipfel. Beides zusammen war der Grund, warum die
|
||||||
|
Anbindung nicht stimmte.
|
||||||
|
|
||||||
|
Jetzt bekommt jedes Stueck vor dem Kasten einen Punkt auf gleicher Hoehe,
|
||||||
|
52 px daneben. Die Kurve laeuft dadurch waagerecht aus dem Kasten heraus
|
||||||
|
und waagerecht in den naechsten hinein: sie haengt sichtbar an ihm, statt
|
||||||
|
an ihm vorbeizukommen. */
|
||||||
|
function leitungsStueck(vonX, vonY, nachX, nachY, seite, abstand, zwischen) {
|
||||||
|
return [[vonX, vonY], [vonX + seite * abstand, vonY]]
|
||||||
|
.concat(zwischen)
|
||||||
|
.concat([[nachX + seite * abstand, nachY], [nachX, nachY]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function visProzesse() {
|
||||||
|
keim(20260813);
|
||||||
|
const W = 896, H = 730, HOCH = 62;
|
||||||
|
|
||||||
|
/* Die x-Werte sind so gewaehlt, dass die rechten Kanten dort landen, wo
|
||||||
|
die Leitung sie erwartet. Wer eine Beschriftung aendert, aendert die
|
||||||
|
Kastenbreite und muss die Ankerpunkte unten mitziehen. */
|
||||||
|
const chips = [
|
||||||
|
{ x: 34, y: 60, t: "E-Mails", g: glyBrief },
|
||||||
|
{ x: 104, y: 200, t: "Zettel im Auto", g: glyZettel },
|
||||||
|
{ x: 48, y: 340, t: "Tabellen", g: glyTabelle },
|
||||||
|
{ x: 128, y: 480, t: "Fotos auf dem Handy", g: glyHandy },
|
||||||
|
{ x: 60, y: 620, t: "Ordner im Regal", g: glyOrdner },
|
||||||
|
];
|
||||||
|
chips.forEach(c => {
|
||||||
|
c.w = Math.round(84 + c.t.length * 14.2);
|
||||||
|
c.r = c.x + c.w;
|
||||||
|
});
|
||||||
|
|
||||||
|
let raus = "";
|
||||||
|
|
||||||
|
// Rechte Kette: von rechter Kante zu rechter Kante, mit weiten Bogen.
|
||||||
|
const rechts = [
|
||||||
|
[[470, 34], [590, 72], [624, 150], [540, 186]],
|
||||||
|
[[576, 244], [672, 312], [604, 392], [492, 372], [432, 308]],
|
||||||
|
[[352, 398], [452, 424], [600, 406], [688, 470]],
|
||||||
|
[[664, 540], [708, 624], [606, 684], [478, 660]],
|
||||||
|
];
|
||||||
|
let leitung = "";
|
||||||
|
rechts.forEach((zw, i) => {
|
||||||
|
leitung += glatt(leitungsStueck(chips[i].r, chips[i].y, chips[i + 1].r,
|
||||||
|
chips[i + 1].y, 1, 52, zw)
|
||||||
|
.map(q => [q[0] + zit(2.5), q[1] + zit(2.5)]), false);
|
||||||
|
});
|
||||||
|
// Linke Kette: dort ist wenig Platz, also zwei knappe Bogen.
|
||||||
|
[[1, 2, [[24, 244], [14, 302]]], [3, 4, [[22, 522], [16, 576]]]].forEach(v => {
|
||||||
|
leitung += glatt(leitungsStueck(chips[v[0]].x, chips[v[0]].y, chips[v[1]].x,
|
||||||
|
chips[v[1]].y, -1, 34, v[2])
|
||||||
|
.map(q => [q[0] + zit(2), q[1] + zit(2)]), false);
|
||||||
|
});
|
||||||
|
/* Die Leitung traegt den Akzent. Gestrichelt und gelb heisst hier
|
||||||
|
gewachsen, durchgezogen und gelb auf Slide 5 heisst verbunden: dieselbe
|
||||||
|
Farbe, aber die Strichart trennt die beiden Aussagen. Etwas kraeftiger
|
||||||
|
als in Grau, weil eine unterbrochene Linie weniger Flaeche hat. */
|
||||||
|
raus += zug(leitung, { f: GELB, w: 2.9, op: .78, strichel: "11 13" });
|
||||||
|
|
||||||
|
[[700, 96, "blatt", 52], [770, 236, "bild", 46], [760, 430, "person", 50],
|
||||||
|
[250, 690, "blatt", 44]]
|
||||||
|
.forEach(k => { raus += zug(kachel(k[0], k[1], k[2], k[3]), { w: 2.5, op: .8 }); });
|
||||||
|
|
||||||
|
chips.forEach(c => {
|
||||||
|
const y = c.y - HOCH / 2;
|
||||||
|
raus += zug(chamfer(c.x, y, c.w, HOCH, 13), { w: 3.4 });
|
||||||
|
raus += zug(c.g(c.x + 34, c.y), { w: 2.6 });
|
||||||
|
raus += '<text x="' + (c.x + 64) + '" y="' + (c.y + 10) + '" ' +
|
||||||
|
'font-family="Manrope, sans-serif" font-size="27" font-weight="600" ' +
|
||||||
|
'letter-spacing="-0.01em" fill="' + TINTE + '">' + c.t + '</text>';
|
||||||
|
});
|
||||||
|
|
||||||
|
return '<svg viewBox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H +
|
||||||
|
'" xmlns="http://www.w3.org/2000/svg">' + raus + '</svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Slide 1: Foto im gezeichneten Rahmen ───────────────────────────────
|
||||||
|
Das Foto bleibt roh, gezeichnet wird nur drumherum. Der Helm sitzt wie
|
||||||
|
die Krone beim Zeichenspiel auf der oberen rechten Ecke, angeschraegt und
|
||||||
|
halb ueber der Kante.
|
||||||
|
|
||||||
|
Er ist ganz in Gelb gezogen, Umriss wie Schraffur. Das hat einen Grund:
|
||||||
|
er liegt zur Haelfte auf dem hellen Grund und zur Haelfte auf dem dunklen
|
||||||
|
Foto. Eine Tintenlinie waere auf dem Foto verschwunden. Gelb traegt auf
|
||||||
|
beidem, und ein Bauhelm ist ohnehin gelb. */
|
||||||
|
function bauhelm(breite) {
|
||||||
|
const b = breite / 2, kd = b * 0.70, h = b * 1.12;
|
||||||
|
|
||||||
|
/* Zwei Anlaeufe waren noetig. Erst war die Kuppel flach und die Krempe
|
||||||
|
eine geschlossene Scheibe darunter: zusammen eine Schirmmuetze. Dann
|
||||||
|
kam Schraffur dazu, und die Waagerechten kreuzten die Grate zu einem
|
||||||
|
Gitter: ein Korb. Ein Bauhelm braucht wenige Striche, hoch und leer. */
|
||||||
|
const kuppel = glatt([[-kd, 2], [-kd * 1.0, -h * 0.4], [-kd * 0.6, -h * 0.85],
|
||||||
|
[0, -h], [kd * 0.6, -h * 0.85], [kd * 1.0, -h * 0.4],
|
||||||
|
[kd, 2]], false);
|
||||||
|
// Krempe: zwei Zuege, unten und oben, also eine Lippe mit Dicke.
|
||||||
|
const krempe = glatt([[-b, 1], [-b * 0.52, 13], [0, 16], [b * 0.52, 13], [b, 1]], false) +
|
||||||
|
glatt([[-b, 1], [-b * 0.52, 4], [0, 6], [b * 0.52, 4], [b, 1]], false);
|
||||||
|
// Der mittlere Grat traegt die Form, die beiden aeusseren geben Rundung.
|
||||||
|
let grate = glatt([[0, -h + 6], [0, -h * 0.45], [0, -1]], false);
|
||||||
|
[-1, 1].forEach(v => {
|
||||||
|
grate += glatt([[v * kd * 0.44, -h * 0.82], [v * kd * 0.62, -h * 0.42],
|
||||||
|
[v * kd * 0.68, -1]], false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return zug(kuppel, { f: GELB, w: 5 }) +
|
||||||
|
zug(grate, { f: GELB, w: 2.4, op: .6 }) +
|
||||||
|
zug(krempe, { f: GELB, w: 5 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function visKarte() {
|
||||||
|
keim(20260811);
|
||||||
|
const W = 712, H = 971;
|
||||||
|
// Das Foto liegt bei x 46..666 und y 150..925. Der Rahmen laeuft 16 px
|
||||||
|
// aussen herum, zweimal gezogen: einmal fest, einmal als leichter
|
||||||
|
// Nachzieher, wie beim Nachfahren einer Kante.
|
||||||
|
/* Drei Sachen zusammen: die Markenform, ein versetzter Zweitzug und eine
|
||||||
|
schraffierte Kante. Sie greifen ineinander statt nebeneinanderher zu
|
||||||
|
laufen, denn der versetzte Rahmen ist die Aussenkante des Schattens und
|
||||||
|
die Schraffur fuellt das Band dazwischen. So ist es eine Geste und
|
||||||
|
nicht dreimal dasselbe uebereinander.
|
||||||
|
|
||||||
|
Zur Schattenfrage: rules.md Paragraf 5 verbietet Schatten, meint damit
|
||||||
|
aber weichgezeichnete. Die dort notierte Ausnahme sagt, eine Linie ist
|
||||||
|
kein Schatten. Eine Schraffur besteht aus Linien. */
|
||||||
|
let band = "";
|
||||||
|
for (let y = 178; y < 928; y += 13) band += strich(683, y, 699, y + 16, { j: .7, b: .7 });
|
||||||
|
for (let x = 72; x < 666; x += 13) band += strich(x, 942, x + 16, 958, { j: .7, b: .7 });
|
||||||
|
|
||||||
|
const raus =
|
||||||
|
zug(chamfer(48, 152, 652, 807, 30, { ue: 5 }), { w: 3, op: .38 }) +
|
||||||
|
zug(band, { w: 1.9, op: .34 }) +
|
||||||
|
zug(chamfer(30, 134, 652, 807, 30, { ue: 9 }), { w: 6 }) +
|
||||||
|
'<g transform="translate(562 154) rotate(-14)">' + bauhelm(196) + '</g>';
|
||||||
|
return '<svg viewBox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H +
|
||||||
|
'" xmlns="http://www.w3.org/2000/svg" class="karte-skizze">' + raus + '</svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Slide 3: wechseln kannst du vergessen ──────────────────────────────
|
||||||
|
Der Fliesstext hat zwei Haelften, und beide brauchen ein Bild: "Jahre an
|
||||||
|
Daten und Wissen haengen drin" wird zu Wurzeln im Boden, "am Ende
|
||||||
|
dasselbe, nur woanders" zu demselben Kasten noch einmal, nur gestrichelt.
|
||||||
|
Dazwischen eine gerissene Leine.
|
||||||
|
|
||||||
|
Das Kreuz der alten Fassung lag auf dem Pfeil und nicht auf dem Ziel, aus
|
||||||
|
gutem Grund: nicht das neue System scheitert, sondern der Wechsel. Das
|
||||||
|
bleibt so. Aus dem Kreuz ist eine gerissene Leine geworden und aus dem
|
||||||
|
leeren Ziel ein gleiches, denn leer war die falsche Aussage. */
|
||||||
|
function wurzeln(x, w, oben, tief) {
|
||||||
|
let raus = "";
|
||||||
|
const n = 8;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const t = (i + 0.5) / n;
|
||||||
|
const sx = x + w * (0.07 + 0.86 * t);
|
||||||
|
const laenge = tief * (0.5 + rnd() * 0.5);
|
||||||
|
const drift = (t - 0.5) * 165 + zit(26);
|
||||||
|
raus += zug(glatt([[sx, oben],
|
||||||
|
[sx + drift * 0.18 + zit(9), oben + laenge * 0.3],
|
||||||
|
[sx + drift * 0.62 + zit(11), oben + laenge * 0.66],
|
||||||
|
[sx + drift, oben + laenge]], false),
|
||||||
|
{ w: 3.1 - Math.abs(t - 0.5) * 1.7 });
|
||||||
|
const az = oben + laenge * 0.5, ax = sx + drift * 0.28;
|
||||||
|
const seite = t < 0.5 ? -1 : 1;
|
||||||
|
raus += zug(glatt([[ax, az],
|
||||||
|
[ax + seite * 20 + zit(7), az + laenge * 0.2],
|
||||||
|
[ax + seite * 33 + zit(9), az + laenge * 0.33]], false), { w: 1.8 });
|
||||||
|
}
|
||||||
|
return raus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Erdreich: ungleich lange Schraegstriche. Die gleichmaessige Schraffur aus
|
||||||
|
schraff() sah nach technischer Zeichnung aus, nicht nach Boden. */
|
||||||
|
function erdreich(x1, x2, y) {
|
||||||
|
let d = "";
|
||||||
|
for (let x = x1; x < x2; x += 13 + rnd() * 6) {
|
||||||
|
const l = 11 + rnd() * 13;
|
||||||
|
d += strich(x, y + 2, x - l * 0.55, y + 2 + l, { j: .8, b: .8 });
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visWechseln() {
|
||||||
|
keim(20260815);
|
||||||
|
const W = 896, H = 730, GRAU = "#A9AFB7";
|
||||||
|
const BY = 100, BH = 360, BW = 320, LX = 30, RX = 555;
|
||||||
|
const zeilen = ["Stammdaten", "Aufträge", "Historie", "Wissen im Kopf"];
|
||||||
|
const mitte = BY + BH / 2;
|
||||||
|
|
||||||
|
const inhalt = (x, farbe) => zeilen.map((z, i) =>
|
||||||
|
'<text x="' + (x + 60) + '" y="' + (BY + 96 + i * 66) + '" ' +
|
||||||
|
'font-family="Manrope, sans-serif" font-size="28" font-weight="600" ' +
|
||||||
|
'letter-spacing="-0.01em" fill="' + farbe + '">' + z + '</text>').join("");
|
||||||
|
const striche = x => zeilen.map((z, i) =>
|
||||||
|
strich(x + 28, BY + 87 + i * 66, x + 48, BY + 87 + i * 66, { j: .8, b: .8 })).join("");
|
||||||
|
|
||||||
|
let raus = "";
|
||||||
|
|
||||||
|
// Der Bestand: schwerer Strich, doppelte Unterkante.
|
||||||
|
raus += zug(rechteck(LX, BY, BW, BH, { ue: 4.5 }), { w: 3.8 });
|
||||||
|
raus += zug(strich(LX + 6, BY + BH + 6, LX + BW - 6, BY + BH + 6, { ue: 2 }), { w: 3 });
|
||||||
|
raus += zug(striche(LX), { w: 2.6 });
|
||||||
|
|
||||||
|
/* Die Erdoberflaeche liegt dicht unter dem Kasten, nicht am unteren Rand.
|
||||||
|
Im ersten Wurf lag sie 120 px tiefer, dadurch endeten die Wurzeln in der
|
||||||
|
Luft und sahen aus wie Beine. Sie muessen die Linie kreuzen, sonst
|
||||||
|
stecken sie nicht drin. */
|
||||||
|
const boden = BY + BH + 18;
|
||||||
|
raus += zug(strich(0, boden, 500, boden, { ue: 4 }), { w: 3 });
|
||||||
|
raus += zug(erdreich(6, 494, boden), { w: 1.9, op: .45 });
|
||||||
|
raus += wurzeln(LX, BW, BY + BH + 6, 142);
|
||||||
|
|
||||||
|
/* Die Leine reisst, und der Akzent sitzt genau dort, wo die Aussage sitzt.
|
||||||
|
Entscheidend ist, dass beide Enden nach unten haengen und die Fasern in
|
||||||
|
dieselbe Richtung weiter ausfransen. Im ersten Wurf zeigten sie
|
||||||
|
aufeinander zu: das ergab zwei Pfeilspitzen, also die gegenteilige
|
||||||
|
Aussage. Eine gerissene Leine faellt, sie zielt nicht. */
|
||||||
|
const endL = [418, mitte + 62], endR = [496, mitte + 48];
|
||||||
|
raus += zug(glatt([[LX + BW, mitte - 8], [388, mitte + 2], [412, mitte + 28],
|
||||||
|
endL], false), { f: GELB, w: 4.4 });
|
||||||
|
raus += zug(glatt([[RX, mitte - 8], [528, mitte + 0], [502, mitte + 22],
|
||||||
|
endR], false), { f: GELB, w: 4.4 });
|
||||||
|
let fransen = "";
|
||||||
|
[endL, endR].forEach(e => {
|
||||||
|
[-0.42, -0.05, 0.36].forEach(o => {
|
||||||
|
fransen += strich(e[0], e[1] - 4,
|
||||||
|
e[0] + Math.sin(o) * 26, e[1] + Math.cos(o) * 26, { j: .9, b: 1.1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
raus += zug(fransen, { f: GELB, w: 2.8 });
|
||||||
|
|
||||||
|
// Dasselbe noch einmal, nur woanders: gleiche Groesse, gleiche Zeilen.
|
||||||
|
raus += zug(rechteck(RX, BY, BW, BH, { ue: 3 }), { w: 3, op: .6, strichel: "14 13", f: GRAU });
|
||||||
|
raus += zug(striche(RX), { w: 2.4, f: GRAU });
|
||||||
|
raus += inhalt(LX, TINTE) + inhalt(RX, GRAU);
|
||||||
|
|
||||||
|
return '<svg viewBox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H +
|
||||||
|
'" xmlns="http://www.w3.org/2000/svg">' + raus + '</svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Slide 4: wir wollten mehr ──────────────────────────────────────────
|
||||||
|
Die alte Fassung zeigte vier Kaesten mit "fehlt". Das ist der Mangel, die
|
||||||
|
Slide handelt aber vom Wunsch. Also ein Blatt mit einer Liste.
|
||||||
|
|
||||||
|
Die Punkte nennen nicht noch einmal KI, CRM und Automatisierung, das steht
|
||||||
|
schon im Fliesstext darueber. Sie sagen dasselbe in den Worten eines
|
||||||
|
Betriebs. Die Kaestchen bleiben leer: erfuellt wird der Wunsch erst auf
|
||||||
|
Slide 5, und genau davon lebt der Uebergang.
|
||||||
|
|
||||||
|
Gelb tragen die Kaestchen. Es ist der einzige Akzent hier und bleibt damit
|
||||||
|
bei seiner Rolle aus den anderen Slides: Struktur, keine Verzierung. */
|
||||||
|
function visMehr() {
|
||||||
|
keim(20260816);
|
||||||
|
const W = 896, H = 730;
|
||||||
|
const LX = 60, RX = 800, OY = 70, UY = 650, KNICK = 58;
|
||||||
|
|
||||||
|
const wuensche = [
|
||||||
|
"Angebot schreibt sich selbst",
|
||||||
|
"Kunde sieht seinen Stand",
|
||||||
|
"Nichts doppelt eintippen",
|
||||||
|
"Alles redet miteinander",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Blatt mit abgeknickter Ecke unten rechts.
|
||||||
|
const blatt = strich(LX, OY, RX, OY, { ue: 4 }) +
|
||||||
|
strich(RX, OY, RX, UY - KNICK, { ue: 3 }) +
|
||||||
|
strich(RX, UY - KNICK, RX - KNICK, UY, { ue: 2 }) +
|
||||||
|
strich(RX - KNICK, UY, LX, UY, { ue: 4 }) +
|
||||||
|
strich(LX, UY, LX, OY, { ue: 4 });
|
||||||
|
const knick = glatt([[RX, UY - KNICK], [RX - KNICK + 8, UY - KNICK + 12],
|
||||||
|
[RX - KNICK, UY]], false);
|
||||||
|
|
||||||
|
let raus = zug(blatt, { w: 3.4 }) + zug(knick, { w: 2.6, op: .75 });
|
||||||
|
|
||||||
|
wuensche.forEach((t, i) => {
|
||||||
|
const y = 180 + i * 120;
|
||||||
|
raus += zug(strich(LX + 60, y + 26, RX - 60, y + 26, { j: 1, b: 1 }), { w: 2, op: .22 });
|
||||||
|
raus += zug(rechteck(LX + 60, y - 30, 38, 38, { ue: 3 }), { f: GELB, w: 3.4 });
|
||||||
|
raus += '<text x="' + (LX + 126) + '" y="' + y + '" font-family="Manrope, sans-serif" ' +
|
||||||
|
'font-size="32" font-weight="600" letter-spacing="-0.015em" fill="' + TINTE + '">' +
|
||||||
|
t + '</text>';
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Leicht schief aufs Blatt gelegt. Genau senkrecht wirkt gesetzt, und ein
|
||||||
|
Zettel liegt nie gerade. */
|
||||||
|
return '<svg viewBox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H +
|
||||||
|
'" xmlns="http://www.w3.org/2000/svg">' +
|
||||||
|
'<g transform="rotate(-1.3 448 360)">' + raus + '</g></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Slide 6: was nach der Anmeldung passiert ───────────────────────────
|
||||||
|
Uebernahme der Sektion "So geht\'s weiter" von der Website, in derselben
|
||||||
|
Handschrift wie Slide 5. Drei Schritte an einer Leitung, Wabe statt Kreis,
|
||||||
|
der dritte Schritt blass: er hat noch nicht stattgefunden. */
|
||||||
|
function visWarteliste() {
|
||||||
|
keim(20260814);
|
||||||
|
const W = 896, H = 730, HX = 66, R = 54, TX = 176, HOEH = R * 0.866;
|
||||||
|
|
||||||
|
const schritte = [
|
||||||
|
{ y: 112, titel: "Du trägst dich ein", ikon: ikonBrief,
|
||||||
|
zeilen: ["Eine E-Mail genügt. Kein Vertrag,", "keine Kreditkarte, unverbindlich."] },
|
||||||
|
{ y: 352, titel: "Wir melden uns", ikon: ikonBlaseKlein,
|
||||||
|
zeilen: ["Sobald HiveGrid für deinen Betrieb", "bereit ist, persönlich und ohne Spam."] },
|
||||||
|
{ y: 592, titel: "Du bist von Anfang an dabei", ikon: ikonKonfetti,
|
||||||
|
zeilen: ["Unter den Ersten, die HiveGrid", "nutzen und mitgestalten."] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const txt = (x, y, groesse, gewicht, farbe, sperr, inhalt) =>
|
||||||
|
'<text x="' + x + '" y="' + y + '" font-family="Manrope, sans-serif" font-size="' +
|
||||||
|
groesse + '" font-weight="' + gewicht + '" letter-spacing="' + sperr +
|
||||||
|
'" fill="' + farbe + '">' + inhalt + '</text>';
|
||||||
|
|
||||||
|
let raus = "";
|
||||||
|
|
||||||
|
// Von Wabe zu Wabe ein Pfeil. Er endet kurz vor der naechsten Wabe, sonst
|
||||||
|
// laeuft die Spitze in die Kante und ist nicht mehr als Spitze zu lesen.
|
||||||
|
let pfeile = "";
|
||||||
|
for (let i = 0; i < schritte.length - 1; i++) {
|
||||||
|
pfeile += pfeil(HX, schritte[i].y + HOEH + 8, HX, schritte[i + 1].y - HOEH - 9);
|
||||||
|
}
|
||||||
|
raus += zug(pfeile, { f: GELB, w: 3.8 });
|
||||||
|
|
||||||
|
schritte.forEach(sc => {
|
||||||
|
raus += zug(wabeSchraff(HX, sc.y, R, 11), { f: GELB, w: 2, op: .5 });
|
||||||
|
raus += zug(wabe(HX, sc.y, R), { f: GELB, w: 4 });
|
||||||
|
raus += sc.ikon(HX, sc.y, TINTE);
|
||||||
|
raus += txt(TX, sc.y - 26, 40, 800, TINTE, "-0.02em", sc.titel);
|
||||||
|
sc.zeilen.forEach((z, k) => {
|
||||||
|
raus += txt(TX, sc.y + 24 + k * 34, 26, 500, "#555555", "0", z);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return '<svg viewBox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H +
|
||||||
|
'" xmlns="http://www.w3.org/2000/svg">' + raus + '</svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function visVerbinden() {
|
||||||
|
keim(20260812);
|
||||||
|
const W = 896, H = 730, cx = 448, cy = 350, R = 160;
|
||||||
|
const RX = 352, RY = 300;
|
||||||
|
|
||||||
|
// Die Winkel sind nicht frei gewaehlt: sie sind die sechs Eckwinkel der
|
||||||
|
// Wabe. Dadurch sitzt jeder Knoten auf einer Ecke.
|
||||||
|
const sats = [
|
||||||
|
{ a: 240, name: "KI", ikon: ikonKi },
|
||||||
|
{ a: 300, name: "Chat", ikon: ikonChat },
|
||||||
|
{ a: 0, name: "Mitarbeiter", ikon: ikonCrm },
|
||||||
|
{ a: 60, name: "Automation", ikon: ikonAuto },
|
||||||
|
{ a: 120, name: "Projekte", ikon: ikonBoard },
|
||||||
|
{ a: 180, name: "Formulare", ikon: ikonFormular },
|
||||||
|
];
|
||||||
|
|
||||||
|
let speichen = "", knoten = "", bilder = "", schrift = "";
|
||||||
|
|
||||||
|
sats.forEach(s => {
|
||||||
|
const r = s.a * Math.PI / 180;
|
||||||
|
const sx = cx + Math.cos(r) * RX, sy = cy + Math.sin(r) * RY;
|
||||||
|
const ix = cx + Math.cos(r) * R, iy = cy + Math.sin(r) * R; // Ecke der Wabe
|
||||||
|
const vx = sx - ix, vy = sy - iy, vl = Math.hypot(vx, vy) || 1;
|
||||||
|
const ux = vx / vl, uy = vy / vl;
|
||||||
|
const ax = sx - ux * (s.ikon.rad + 20), ay = sy - uy * (s.ikon.rad + 20);
|
||||||
|
speichen += strich(ix + ux * 12, iy + uy * 12, ax - ux * 11, ay - uy * 11, { j: 1.2 });
|
||||||
|
knoten += kringel(ix, iy, 10.5, 10.5, { ueber: .1 }) + kringel(ax, ay, 10.5, 10.5, { ueber: .1 });
|
||||||
|
bilder += s.ikon(sx, sy);
|
||||||
|
schrift += '<text x="' + n1(sx) + '" y="' + n1(sy + s.ikon.unten) + '" text-anchor="middle" ' +
|
||||||
|
'font-family="Manrope, sans-serif" font-size="28" font-weight="700" ' +
|
||||||
|
'letter-spacing="-0.01em" fill="' + TINTE + '">' + s.name + '</text>';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Die Wabe wird zweimal gezogen. Ein zweiter Umriss auf groesserem Radius
|
||||||
|
// sah nach Versehen aus, derselbe Radius nach zweitem Zug.
|
||||||
|
// Breite und Hoehe stehen als Attribute mit dran, nicht nur im viewBox.
|
||||||
|
// Ohne sie hat Chrome die Hoehe nicht aus dem Seitenverhaeltnis abgeleitet,
|
||||||
|
// das SVG wurde zu hoch und die Zeichnung lief in den Fliesstext.
|
||||||
|
return '<svg viewBox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H +
|
||||||
|
'" xmlns="http://www.w3.org/2000/svg">' +
|
||||||
|
bilder + schrift +
|
||||||
|
zug(speichen, { f: GELB, w: 4.4 }) +
|
||||||
|
zug(wabe(cx, cy, R), { f: GELB, w: 4.8 }) +
|
||||||
|
zug(wabe(cx, cy, R - 2), { f: GELB, w: 3.2, op: .72 }) +
|
||||||
|
zug(knoten, { f: GELB, w: 3.8 }) +
|
||||||
|
nabe(cx, cy) +
|
||||||
|
'</svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ─── Helm-Entwuerfe ──────────────────────────────────────────────────────
|
||||||
|
Alle in Gelb, weil der Helm im Post zur Haelfte auf hellem Grund und zur
|
||||||
|
Haelfte auf dem dunklen Foto liegt. Eine Tintenlinie waere dort weg. */
|
||||||
|
|
||||||
|
/* H2: mehr Stuetzpunkte, First, Nebenrippen, Krempe mit Dicke, Schattierung. */
|
||||||
|
function bauhelmFein(breite) {
|
||||||
|
const b = breite / 2, kd = b * 0.76, h = b * 1.0;
|
||||||
|
const kuppel = glatt([
|
||||||
|
[-kd, 2], [-kd * 1.03, -h * 0.28], [-kd * 0.93, -h * 0.58],
|
||||||
|
[-kd * 0.62, -h * 0.86], [-kd * 0.22, -h * 0.99], [kd * 0.22, -h * 0.99],
|
||||||
|
[kd * 0.62, -h * 0.86], [kd * 0.93, -h * 0.58], [kd * 1.03, -h * 0.28], [kd, 2],
|
||||||
|
], false);
|
||||||
|
const first = glatt([[-kd * 0.04, -h * 0.99], [0, -h * 0.62],
|
||||||
|
[kd * 0.03, -h * 0.22], [kd * 0.04, 1]], false);
|
||||||
|
let rippen = "";
|
||||||
|
[-1, 1].forEach(v => {
|
||||||
|
rippen += glatt([[v * kd * 0.36, -h * 0.92], [v * kd * 0.58, -h * 0.56],
|
||||||
|
[v * kd * 0.68, -h * 0.17], [v * kd * 0.7, 1]], false);
|
||||||
|
});
|
||||||
|
const krempeU = glatt([[-b, 4], [-b * 0.62, 16], [0, 21], [b * 0.62, 16], [b, 4]], false);
|
||||||
|
const krempeO = glatt([[-b, 4], [-b * 0.62, 7], [0, 10], [b * 0.62, 7], [b, 4]], false);
|
||||||
|
let schatten = "";
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const f = 0.94 - i * 0.1;
|
||||||
|
schatten += glatt([[-kd * f, -h * 0.16], [-kd * f * 0.94, -h * 0.46],
|
||||||
|
[-kd * f * 0.7, -h * 0.72]], false);
|
||||||
|
}
|
||||||
|
return zug(schatten, { f: GELB, w: 2.1, op: .45 }) + zug(kuppel, { f: GELB, w: 5 }) +
|
||||||
|
zug(first, { f: GELB, w: 3, op: .8 }) + zug(rippen, { f: GELB, w: 2.3, op: .55 }) +
|
||||||
|
zug(krempeU, { f: GELB, w: 5 }) + zug(krempeO, { f: GELB, w: 3.4, op: .85 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* H3: gefuellt ueber Schraffur, dafuer ohne Rippen. Schraffur und Rippen
|
||||||
|
zusammen ergaben in einem frueheren Versuch ein Gitter, also einen Korb. */
|
||||||
|
function bauhelmSchraffiert(breite) {
|
||||||
|
const b = breite / 2, kd = b * 0.76, h = b * 1.0;
|
||||||
|
const kuppel = glatt([
|
||||||
|
[-kd, 2], [-kd * 1.03, -h * 0.28], [-kd * 0.93, -h * 0.58],
|
||||||
|
[-kd * 0.62, -h * 0.86], [-kd * 0.22, -h * 0.99], [kd * 0.22, -h * 0.99],
|
||||||
|
[kd * 0.62, -h * 0.86], [kd * 0.93, -h * 0.58], [kd * 1.03, -h * 0.28], [kd, 2],
|
||||||
|
], false);
|
||||||
|
const krempeU = glatt([[-b, 4], [-b * 0.62, 16], [0, 21], [b * 0.62, 16], [b, 4]], false);
|
||||||
|
const krempeO = glatt([[-b, 4], [-b * 0.62, 7], [0, 10], [b * 0.62, 7], [b, 4]], false);
|
||||||
|
let fuellung = "";
|
||||||
|
for (let y = -h + 15; y < -8; y += 13) {
|
||||||
|
const halb = kd * Math.sqrt(Math.max(0, 1 - (y / h) * (y / h))) - 9;
|
||||||
|
if (halb < 8) continue;
|
||||||
|
fuellung += strich(-halb, y - 2, halb, y + 2, { j: .8, b: .9 });
|
||||||
|
}
|
||||||
|
return zug(fuellung, { f: GELB, w: 2.1, op: .5 }) + zug(kuppel, { f: GELB, w: 5 }) +
|
||||||
|
zug(krempeU, { f: GELB, w: 5 }) + zug(krempeO, { f: GELB, w: 3.4, op: .85 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* H4: Dreiviertelansicht. Die Krempe ist eine Ellipse statt einer Linie,
|
||||||
|
dadurch sieht man ihre Unterseite und der Helm bekommt Tiefe. Der hintere
|
||||||
|
Bogen wird nur dort gezogen, wo ihn die Kuppel nicht verdeckt. */
|
||||||
|
function bauhelmDreiviertel(breite) {
|
||||||
|
const b = breite / 2, ry = b * 0.23, kd = b * 0.74, h = b * 0.98;
|
||||||
|
const bogen = (von, bis) => {
|
||||||
|
const p = [];
|
||||||
|
for (let i = 0; i <= 10; i++) {
|
||||||
|
const a = von + (bis - von) * (i / 10);
|
||||||
|
p.push([Math.cos(a) * b, Math.sin(a) * ry]);
|
||||||
|
}
|
||||||
|
return glatt(p, false);
|
||||||
|
};
|
||||||
|
const vorn = bogen(0, Math.PI); // untere Haelfte
|
||||||
|
const hinten = bogen(Math.PI + 0.28, Math.PI + 0.72) +
|
||||||
|
bogen(-0.72, -0.28); // nur die freien Stuecke
|
||||||
|
const kuppel = glatt([
|
||||||
|
[-kd, -ry * 0.5], [-kd * 1.0, -h * 0.34], [-kd * 0.84, -h * 0.68],
|
||||||
|
[-kd * 0.42, -h * 0.95], [0, -h], [kd * 0.46, -h * 0.93],
|
||||||
|
[kd * 0.86, -h * 0.64], [kd * 1.0, -h * 0.3], [kd, -ry * 0.5],
|
||||||
|
], false);
|
||||||
|
const first = glatt([[-kd * 0.5, -h * 0.78], [0, -h * 0.98],
|
||||||
|
[kd * 0.5, -h * 0.76], [kd * 0.86, -h * 0.4]], false);
|
||||||
|
return zug(kuppel, { f: GELB, w: 5 }) + zug(first, { f: GELB, w: 2.6, op: .7 }) +
|
||||||
|
zug(hinten, { f: GELB, w: 2.6, op: .55 }) + zug(vorn, { f: GELB, w: 5 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* H5: wie H2, dazu ein Kinnriemen mit Schnalle. */
|
||||||
|
function bauhelmRiemen(breite) {
|
||||||
|
const b = breite / 2;
|
||||||
|
const riemen = glatt([[-b * 0.82, 12], [-b * 0.68, b * 0.52], [-b * 0.2, b * 0.78],
|
||||||
|
[b * 0.3, b * 0.7], [b * 0.76, b * 0.3], [b * 0.84, 12]], false);
|
||||||
|
const schnalle = rechteck(-b * 0.16, b * 0.62, b * 0.3, b * 0.17, { ue: 1.5 });
|
||||||
|
return bauhelmFein(breite) + zug(riemen, { f: GELB, w: 2.8, op: .8 }) +
|
||||||
|
zug(schnalle, { f: GELB, w: 2.4, op: .8 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* H6: nur die Silhouette, zwei kraeftige Zuege, sonst nichts. */
|
||||||
|
function bauhelmKontur(breite) {
|
||||||
|
const b = breite / 2, kd = b * 0.76, h = b * 1.02;
|
||||||
|
const kuppel = glatt([
|
||||||
|
[-kd, 2], [-kd * 1.02, -h * 0.3], [-kd * 0.9, -h * 0.62],
|
||||||
|
[-kd * 0.5, -h * 0.92], [0, -h], [kd * 0.5, -h * 0.92],
|
||||||
|
[kd * 0.9, -h * 0.62], [kd * 1.02, -h * 0.3], [kd, 2],
|
||||||
|
], false);
|
||||||
|
const krempe = glatt([[-b, 3], [-b * 0.6, 15], [0, 20], [b * 0.6, 15], [b, 3]], false);
|
||||||
|
return zug(kuppel, { f: GELB, w: 6.5 }) + zug(krempe, { f: GELB, w: 6.5 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der gewaehlte Rahmen, gleich aufgebaut wie in post2.html. */
|
||||||
|
function rahmenMix() {
|
||||||
|
let band = "";
|
||||||
|
for (let y = 178; y < 928; y += 13) band += strich(683, y, 699, y + 16, { j: .7, b: .7 });
|
||||||
|
for (let x = 72; x < 666; x += 13) band += strich(x, 942, x + 16, 958, { j: .7, b: .7 });
|
||||||
|
return zug(chamfer(48, 152, 652, 807, 30, { ue: 5 }), { w: 3, op: .38 }) +
|
||||||
|
zug(band, { w: 1.9, op: .34 }) +
|
||||||
|
zug(chamfer(30, 134, 652, 807, 30, { ue: 9 }), { w: 6 });
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("r0").innerHTML = (function(){ keim(20260811); return zug(rechteck(30,134,652,807,{ue:7}),{w:3.8}) + zug(rechteck(31,136,650,804,{ue:5}),{w:2.4,op:.4}); })();
|
||||||
|
document.getElementById("r1").innerHTML = (function(){ keim(20260811); return zug(rechteck(34,138,644,799,{ue:10}),{w:7}); })();
|
||||||
|
document.getElementById("r2").innerHTML = (function(){ keim(20260811); return zug(rechteck(44,148,652,807,{ue:6}),{w:3.2,op:.34}) + zug(rechteck(30,134,652,807,{ue:9}),{w:6}); })();
|
||||||
|
document.getElementById("r3").innerHTML = (function(){ keim(20260811); return zug(rechteck(12,116,688,843,{ue:6}),{w:2.6,op:.5}) + zug(rechteck(34,138,644,799,{ue:9}),{w:6}); })();
|
||||||
|
document.getElementById("r4").innerHTML = (function(){ keim(20260811); return zug(chamfer(30,134,652,807,30,{ue:8}),{w:6}); })();
|
||||||
|
document.getElementById("r5").innerHTML = (function(){ keim(20260811); return zug(rechteck(30,134,652,807,{ue:9}),{w:6}) + zug(schraff(684,152,26,796,11),{w:1.9,op:.34}) + zug(schraff(52,943,650,26,11),{w:1.9,op:.34}); })();
|
||||||
|
document.getElementById("r6").innerHTML = (function(){ keim(20260811); return rahmenMix(); })();
|
||||||
|
document.getElementById("k0").innerHTML = (function(){ keim(20260811); return '<g transform="translate(150 168)">' + bauhelm(200) + '</g>'; })();
|
||||||
|
document.getElementById("k1").innerHTML = (function(){ keim(20260811); return '<g transform="translate(150 168)">' + bauhelmFein(200) + '</g>'; })();
|
||||||
|
document.getElementById("k2").innerHTML = (function(){ keim(20260811); return '<g transform="translate(150 168)">' + bauhelmSchraffiert(200) + '</g>'; })();
|
||||||
|
document.getElementById("k3").innerHTML = (function(){ keim(20260811); return '<g transform="translate(150 168)">' + bauhelmDreiviertel(200) + '</g>'; })();
|
||||||
|
document.getElementById("k4").innerHTML = (function(){ keim(20260811); return '<g transform="translate(150 168)">' + bauhelmRiemen(200) + '</g>'; })();
|
||||||
|
document.getElementById("k5").innerHTML = (function(){ keim(20260811); return '<g transform="translate(150 168)">' + bauhelmKontur(200) + '</g>'; })();
|
||||||
|
document.getElementById("f0").innerHTML = (function(){ keim(20260811); return rahmenMix() + '<g transform="translate(562 154) rotate(-14)">' + bauhelmFein(196) + '</g>'; })();
|
||||||
|
document.getElementById("f1").innerHTML = (function(){ keim(20260811); return rahmenMix() + '<g transform="translate(391 322) rotate(-6)">' + bauhelmFein(186) + '</g>'; })();
|
||||||
|
document.getElementById("f2").innerHTML = (function(){ keim(20260811); return rahmenMix() + '<g transform="translate(690 168) rotate(28)">' + bauhelmFein(196) + '</g>'; })();
|
||||||
|
document.getElementById("f3").innerHTML = (function(){ keim(20260811); return rahmenMix() + '<g transform="translate(386 350) rotate(-4)">' + bauhelmFein(222) + '</g>'; })();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/* Baut aus einer animierten Slide ein MP4 fuers Carousel.
|
||||||
|
*
|
||||||
|
* node animation.mjs post6.html alle animierten Slides
|
||||||
|
* 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
|
||||||
|
* Instagram nimmt kein GIF an. Was im Feed als "kurzer animierter Beitrag"
|
||||||
|
* laeuft und trotzdem kein Reel ist, ist ein Video in einem Carousel: Meta
|
||||||
|
* schreibt "Carousels are limited to 10 images, videos, or a mix of the two",
|
||||||
|
* und ein Video darin bleibt ein Carousel-Element. Nur ein einzeln gepostetes
|
||||||
|
* Video wird seit 2022 zum Reel.
|
||||||
|
*
|
||||||
|
* DIE WERTE
|
||||||
|
* Behaelter MP4, H.264 High, yuv420p
|
||||||
|
* Bild 1080 x 1350, also 4:5 wie unsere Standbilder -- ein Carousel
|
||||||
|
* 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
|
||||||
|
* Laenge 3 s ist das Minimum, 60 s je Element das Maximum; wir liegen
|
||||||
|
* bei 5
|
||||||
|
* Datenrate 12 Mbit/s. Instagram rechnet ohnehin neu; was man hineingibt,
|
||||||
|
* ist die Obergrenze dessen, was herauskommen kann
|
||||||
|
* Ton eine stille AAC-Spur. Ein Video ganz ohne Tonspur wird von
|
||||||
|
* manchen Uploadwegen abgelehnt, und still ist es so oder so
|
||||||
|
* faststart der moov-Atom nach vorn, sonst laedt das Video erst, wenn es
|
||||||
|
* ganz da ist
|
||||||
|
*
|
||||||
|
* Die Bilder kommen aus derselben Seite wie die Standbilder, ueber dieselbe
|
||||||
|
* Tuer wie die Vorschau (window.animSetzen). Was im Browser laeuft, ist also
|
||||||
|
* das, was im Video landet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const SEITE = process.argv[2] || "post6.html";
|
||||||
|
/* Der Schluessel der Animation, nicht ihre Nummer. Eine Animation ist keine
|
||||||
|
Slide mehr und hat deshalb auch keine Position im Streifen, die sich
|
||||||
|
verschieben koennte. Ohne Angabe werden alle des Projekts gebaut. */
|
||||||
|
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 CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
||||||
|
const PORT = 9530;
|
||||||
|
const FPS = 30;
|
||||||
|
const BREITE = 1080;
|
||||||
|
let HOEHE = 1350, DATEI_ZUSATZ = "";
|
||||||
|
const DATENRATE = "12M";
|
||||||
|
const ZIEL = path.join(process.cwd(), "animation");
|
||||||
|
|
||||||
|
/* ── CDP, das Noetigste ──────────────────────────────────────────────────── */
|
||||||
|
async function verbinden(port) {
|
||||||
|
let ziele = null;
|
||||||
|
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 seite = ziele && ziele.find(t => t.type === "page");
|
||||||
|
if (!seite) throw new Error("Chrome antwortet nicht auf " + port);
|
||||||
|
const ws = new WebSocket(seite.webSocketDebuggerUrl);
|
||||||
|
const offen = new Map(), horcher = [];
|
||||||
|
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); }
|
||||||
|
else if (m.method) horcher.forEach(h => h(m));
|
||||||
|
};
|
||||||
|
await new Promise((ok, weg) => { ws.onopen = ok; ws.onerror = weg; });
|
||||||
|
let n = 0;
|
||||||
|
const ruf = (method, params = {}) => new Promise((ok, weg) => {
|
||||||
|
const id = ++n;
|
||||||
|
offen.set(id, m => m.error ? weg(new Error(method + ": " + m.error.message)) : ok(m.result));
|
||||||
|
ws.send(JSON.stringify({ id, method, params }));
|
||||||
|
});
|
||||||
|
const werte = async ausdruck => {
|
||||||
|
const r = await ruf("Runtime.evaluate", { expression: ausdruck, awaitPromise: true, returnByValue: true });
|
||||||
|
if (r.exceptionDetails) throw new Error("in der Seite: " +
|
||||||
|
(r.exceptionDetails.exception?.description || r.exceptionDetails.text));
|
||||||
|
return r.result.value;
|
||||||
|
};
|
||||||
|
return { ruf, werte, zu: () => ws.close() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── ffmpeg ──────────────────────────────────────────────────────────────── */
|
||||||
|
function kodieren(ordner, datei, bilder) {
|
||||||
|
return new Promise((ok, weg) => {
|
||||||
|
const f = spawn("ffmpeg", [
|
||||||
|
"-y", "-hide_banner", "-loglevel", "error",
|
||||||
|
"-framerate", String(FPS),
|
||||||
|
"-i", path.join(ordner, "%05d.png"),
|
||||||
|
// Eine stille Tonspur dazu, sonst mag mancher Uploadweg die Datei nicht.
|
||||||
|
"-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=44100",
|
||||||
|
"-shortest",
|
||||||
|
"-c:v", "libx264", "-profile:v", "high", "-level", "4.1",
|
||||||
|
"-pix_fmt", "yuv420p",
|
||||||
|
"-b:v", DATENRATE, "-maxrate", DATENRATE, "-bufsize", "24M",
|
||||||
|
// Ein Schluesselbild je Sekunde: die Schleife setzt sauber wieder an.
|
||||||
|
"-g", String(FPS), "-keyint_min", String(FPS), "-sc_threshold", "0",
|
||||||
|
"-c:a", "aac", "-b:a", "128k", "-ar", "44100",
|
||||||
|
"-movflags", "+faststart",
|
||||||
|
datei,
|
||||||
|
], { stdio: ["ignore", "inherit", "inherit"] });
|
||||||
|
f.on("exit", c => c === 0 ? ok() : weg(new Error("ffmpeg endete mit " + c)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Ablauf ──────────────────────────────────────────────────────────────── */
|
||||||
|
const chrome = spawn(CHROME, [
|
||||||
|
"--headless", "--disable-gpu", "--hide-scrollbars",
|
||||||
|
"--remote-debugging-port=" + PORT,
|
||||||
|
"--user-data-dir=" + path.join(tmpdir(), "postgrid-anim"),
|
||||||
|
"about:blank",
|
||||||
|
], { stdio: "ignore" });
|
||||||
|
|
||||||
|
let cdp;
|
||||||
|
try {
|
||||||
|
cdp = await verbinden(PORT);
|
||||||
|
await cdp.ruf("Page.enable");
|
||||||
|
await cdp.ruf("Runtime.enable");
|
||||||
|
/* Das Fenster hat genau die Groesse eines Blattes. Im Modus ?nur=anim
|
||||||
|
zeigt die Seite allein die Animation, an 0,0 -- damit entfaellt jede
|
||||||
|
Ausschnittsrechnung, und mit ihr die Falle, dass ein zu schmales Fenster
|
||||||
|
fuer eine hintere Seite ein leeres Bild liefert. */
|
||||||
|
await cdp.ruf("Emulation.setDeviceMetricsOverride",
|
||||||
|
{ width: BREITE, height: HOEHE, deviceScaleFactor: 1, mobile: false });
|
||||||
|
|
||||||
|
const laden = async 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));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Erst die Liste holen. Ohne k rendert der Modus nichts, das Skript laeuft
|
||||||
|
// aber vollstaendig durch und window.ANIM_LISTE steht bereit.
|
||||||
|
await laden(SEITE + "?nur=anim" + ZUSATZ);
|
||||||
|
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)");
|
||||||
|
const liste = alle.filter(a => NUR == null || a.key === NUR);
|
||||||
|
if (!liste.length) throw new Error("keine Animation" +
|
||||||
|
(NUR ? ' "' + NUR + '"' : "") + " in " + SEITE +
|
||||||
|
(alle.length ? " — vorhanden: " + alle.map(a => a.key).join(", ") : ""));
|
||||||
|
|
||||||
|
await mkdir(ZIEL, { recursive: true });
|
||||||
|
|
||||||
|
for (const a of liste) {
|
||||||
|
const anzahl = Math.round(a.dauer * FPS);
|
||||||
|
await laden(SEITE + "?nur=anim&k=" + encodeURIComponent(a.key) + ZUSATZ);
|
||||||
|
|
||||||
|
/* Nachsehen, dass wirklich ein Blatt dasteht und es die richtigen Masse
|
||||||
|
hat. Ein leeres Fenster faellt sonst erst am fertigen Video auf. */
|
||||||
|
const kasten = await cdp.werte(`(() => {
|
||||||
|
const el = document.querySelector(".slide[data-animi]");
|
||||||
|
if (!el) return null;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return { x: Math.round(r.left), y: Math.round(r.top),
|
||||||
|
w: Math.round(r.width), h: Math.round(r.height) };
|
||||||
|
})()`);
|
||||||
|
if (!kasten) throw new Error(`${a.key}: kein Blatt im Modus ?nur=anim`);
|
||||||
|
if (kasten.w !== BREITE || kasten.h !== HOEHE)
|
||||||
|
throw new Error(`${a.key} misst ${kasten.w}x${kasten.h}, erwartet ${BREITE}x${HOEHE}`);
|
||||||
|
if (kasten.x !== 0 || kasten.y !== 0)
|
||||||
|
throw new Error(`${a.key} sitzt bei ${kasten.x},${kasten.y} statt 0,0`);
|
||||||
|
|
||||||
|
/* Dieselbe Umlautpruefung wie der Bildexport. Das Bewegtbild laeuft nicht
|
||||||
|
durch renderSlide und waere sonst die Luecke: auf einer Slide faellt
|
||||||
|
"Sanitaer" beim Export auf, in 180 Bildern niemandem.
|
||||||
|
|
||||||
|
Ueber den ganzen Ablauf und nicht nur einmal: der Text einer Animation
|
||||||
|
aendert sich mit t. Beim ersten Versuch lief die Pruefung nur auf dem
|
||||||
|
Anfangsbild und fand nichts -- die fehlerhaften Optionen erscheinen
|
||||||
|
erst ab t = 0,4. Vierundzwanzig Stellen decken jeden Zustand ab und
|
||||||
|
kosten zusammen weniger als eine Sekunde. */
|
||||||
|
const gesehen = new Set();
|
||||||
|
for (let p = 0; p < 24; p++) {
|
||||||
|
await cdp.werte(`window.animSetzen(${a.i}, ${p / 24})`);
|
||||||
|
const f = await cdp.werte("window.TEXT_PRUEFUNG ? window.TEXT_PRUEFUNG() : []");
|
||||||
|
(f || []).forEach(w => gesehen.add(w));
|
||||||
|
}
|
||||||
|
if (gesehen.size)
|
||||||
|
throw new Error(`${a.key}: ${[...gesehen].join(", ")}. ` +
|
||||||
|
"Fehlt ein Umlaut, schreib ihn; ist das Wort so richtig, gehoert es in " +
|
||||||
|
"UMLAUT_ERLAUBT. Einen Geviertstrich ersetzt ein Punkt oder ein Komma.");
|
||||||
|
|
||||||
|
const ordner = await mkdtemp(path.join(tmpdir(), "postgrid-bilder-"));
|
||||||
|
process.stdout.write(` ${a.key}: ${anzahl} Bilder, ${BREITE} x ${HOEHE} `);
|
||||||
|
for (let n = 0; n < anzahl; n++) {
|
||||||
|
/* 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. */
|
||||||
|
await cdp.werte(`window.animSetzen(${a.i}, ${n / anzahl})`);
|
||||||
|
const r = await cdp.ruf("Page.captureScreenshot", { format: "png" });
|
||||||
|
await writeFile(path.join(ordner, String(n + 1).padStart(5, "0") + ".png"),
|
||||||
|
Buffer.from(r.data, "base64"));
|
||||||
|
if (n % 15 === 0) process.stdout.write(".");
|
||||||
|
}
|
||||||
|
const datei = path.join(ZIEL, PROJEKT + "-" + a.key + DATEI_ZUSATZ + ".mp4");
|
||||||
|
process.stdout.write(" kodiere ");
|
||||||
|
await kodieren(ordner, datei, anzahl);
|
||||||
|
await rm(ordner, { recursive: true, force: true });
|
||||||
|
console.log("→ " + path.relative(process.cwd(), datei));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (cdp) cdp.zu();
|
||||||
|
chrome.kill();
|
||||||
|
await new Promise(ok => chrome.once("exit", ok));
|
||||||
|
await rm(path.join(tmpdir(), "postgrid-anim"), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 883 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 350 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 23.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 302 302" style="enable-background:new 0 0 302 302;" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#00B388;}
|
||||||
|
.st1{fill:#FFFFFF;}
|
||||||
|
</style>
|
||||||
|
<title>grid</title>
|
||||||
|
<desc>Created with Sketch.</desc>
|
||||||
|
<g id="book">
|
||||||
|
<g id="slide_07" transform="translate(-230.000000, -233.000000)">
|
||||||
|
<g id="logo" transform="translate(230.000000, 233.000000)">
|
||||||
|
<g id="icon" transform="translate(51.000000, 51.000000)">
|
||||||
|
<g id="bg">
|
||||||
|
<path class="st0" d="M150.8,4.3C139.5,1.7,121,0,100,0S60.5,1.7,49.2,4.3C26.7,9,9,26.7,4.3,49.2C1.7,60.5,0,79,0,100
|
||||||
|
s1.7,39.5,4.3,50.8C9,173.3,26.7,191,49.2,195.7C60.5,198.3,79,200,100,200s39.5-1.7,50.8-4.3c22.5-4.7,40.2-22.4,44.9-44.9
|
||||||
|
c2.6-11.3,4.3-29.8,4.3-50.8s-1.7-39.5-4.3-50.8C191,26.7,173.3,9,150.8,4.3z"/>
|
||||||
|
</g>
|
||||||
|
<path id="Path" class="st1" d="M124,153.5c-1.9-4.5-5.9-7.9-10.8-8.9c-2.9-0.7-7.8-1.1-13.2-1.1s-10.3,0.4-13.2,1.1
|
||||||
|
c-4.9,1-8.9,4.4-10.8,8.9l0,0l0,0c-0.7,1.6-2.3,2.5-3.9,2.3c-0.4,0-0.8-0.1-1.1-0.1c-7.8-0.8-14-1.9-17.6-3.1l0,0
|
||||||
|
c-2.4-0.9-4.1-3.2-4.1-5.9c0-0.1,0-0.1,0-0.2c0-0.1,0-0.2,0-0.3v-0.1c0-0.1,0-0.1,0-0.2c0.6-8.2,5.6-27.3,13-49.1
|
||||||
|
c8.5-24.9,17.3-45.1,21.3-49.1l0,0c0.6-0.7,1.4-1.2,2.2-1.6c0.3-0.2,0.7-0.4,1.2-0.5l0,0c2.6-0.9,7.5-1.5,13-1.5l0,0
|
||||||
|
c5.5,0,10.4,0.6,13,1.5l0,0l0,0c0.5,0.2,0.9,0.3,1.2,0.5c0.8,0.4,1.5,1,2.2,1.6l0,0c4,3.9,12.8,24.1,21.3,49.1
|
||||||
|
c7.4,21.8,12.4,40.9,13,49.1c0,0.1,0,0.1,0,0.2v0.1c0,0.1,0,0.2,0,0.3s0,0.1,0,0.2c0,2.7-1.7,5-4.1,5.9l0,0
|
||||||
|
c-3.6,1.3-9.7,2.4-17.6,3.1c-0.3,0-0.8,0.1-1.2,0.1l0,0C126.2,155.9,124.7,155,124,153.5L124,153.5z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,16 @@
|
|||||||
|
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_2124_86)">
|
||||||
|
<rect width="128" height="128" rx="30" fill="url(#paint0_linear_2124_86)"/>
|
||||||
|
<rect opacity="0.35" width="128" height="128" fill="#D97757"/>
|
||||||
|
<path d="M35.0157 79.6937L53.4322 69.3667L53.7357 68.4622L53.4322 67.9585H52.5215L49.4352 67.7708L38.9115 67.4891L29.8045 67.1136L20.9504 66.6442L18.7243 66.1748L16.6499 63.4053L16.8523 62.044L18.7243 60.7766L21.4058 61.0113L27.3254 61.4337L36.23 62.044L42.6555 62.4195L52.2179 63.4053H53.7357L53.9381 62.795L53.4322 62.4195L53.0274 62.044L43.8192 55.8008L33.8521 49.2291L28.6408 45.4269L25.8581 43.5024L24.4415 41.7186L23.8343 37.7756L26.3641 34.9591L29.8045 35.1938L30.6646 35.4285L34.1556 38.1042L41.593 43.8779L51.3072 51.0129L52.7239 52.1864L53.2943 51.8014L53.3816 51.5292L52.7239 50.4496L47.462 40.9206L41.846 31.2039L39.3163 27.1669L38.6586 24.773C38.4038 23.7683 38.2538 22.9371 38.2538 21.9096L41.1377 17.9665L42.7567 17.4502L46.6525 17.9665L48.2715 19.3748L50.7001 24.9138L54.5958 33.5978L60.6672 45.4269L62.438 48.9475L63.3993 52.1864L63.7535 53.1722H64.3606V52.6089L64.8666 45.9433L65.7773 37.7756L66.688 27.2608L66.9915 24.3036L68.4588 20.7361L71.3933 18.8115L73.67 19.8911L75.542 22.5667L75.289 24.3036L74.176 31.5324L72.0004 42.8452L70.5837 50.4496H71.3933L72.3546 49.4638L76.1997 44.3942L82.6252 36.3204L85.4585 33.1284L88.7978 29.6079L90.9228 27.918H94.9703L97.9048 32.3304L96.5894 36.8837L92.4406 42.1411L89.0002 46.6004L84.0672 53.2095L81.0062 58.5234L81.2807 58.9618L82.0181 58.8989L93.1489 56.505L99.1697 55.4253L106.354 54.2049L109.592 55.707L109.946 57.256L108.681 60.401L100.991 62.2787L91.9853 64.1094L78.5744 67.2664L78.4259 67.3858L78.6012 67.6458L84.649 68.1932L87.2294 68.334H93.5537L105.342 69.2259L108.428 71.2444L110.25 73.7322L109.946 75.6568L105.19 78.0508L98.8155 76.5487L83.8901 72.9812L78.7801 71.7138H78.0717V72.1362L82.3217 76.314L90.1639 83.3551L99.9286 92.4616L100.435 94.7148L99.1697 96.4985L97.8542 96.3108L89.2532 89.8329L85.9139 86.9226L78.4259 80.5856H77.92V81.2428L79.6402 83.7776L88.7978 97.5312L89.2532 101.756L88.5954 103.117L86.2175 103.962L83.6371 103.493L78.2235 95.9352L72.7087 87.4859L68.2564 79.8815L67.7187 80.221L65.0689 108.515L63.8547 109.971L61.0214 111.05L58.6434 109.266L57.3786 106.356L58.6434 100.582L60.1613 93.0719L61.3755 87.1104L62.4886 79.6937L63.1684 77.2161L63.1083 77.0503L62.5653 77.1416L56.9738 84.8103L48.4739 96.3108L41.7448 103.493L40.1258 104.15L37.3431 102.695L37.5961 100.113L39.1645 97.8129L48.4739 85.9838L54.0899 78.6141L57.7094 74.3837L57.6742 73.7718L57.4744 73.7548L32.739 89.8799L28.3373 90.4432L26.4147 88.6594L26.6676 85.7491L27.5783 84.8103L35.0157 79.6937Z" fill="#FAF9F5"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="paint0_linear_2124_86" x1="52.5" y1="128" x2="52.5" y2="0" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#DC6038"/>
|
||||||
|
<stop offset="1" stop-color="#D97757"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="clip0_2124_86">
|
||||||
|
<rect width="128" height="128" rx="30" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="146 226 269 267" role="img" aria-label="OpenAI">
|
||||||
|
<rect x="146" y="226" width="269" height="267" rx="62" fill="#111111"/>
|
||||||
|
<g transform="translate(280.5 359.5) scale(.82) translate(-280.5 -359.5)">
|
||||||
|
<path d="M249.176 323.434V298.276C249.176 296.158 249.971 294.569 251.825 293.509L302.406 264.381C309.29 260.409 317.5 258.555 325.973 258.555C357.75 258.555 377.877 283.185 377.877 309.399C377.877 311.253 377.877 313.371 377.611 315.49L325.178 284.771C322.001 282.919 318.822 282.919 315.645 284.771L249.176 323.434ZM367.283 421.415V361.301C367.283 357.592 365.694 354.945 362.516 353.092L296.048 314.43L317.763 301.982C319.617 300.925 321.206 300.925 323.058 301.982L373.639 331.112C388.205 339.586 398.003 357.592 398.003 375.069C398.003 395.195 386.087 413.733 367.283 421.412V421.415ZM233.553 368.452L211.838 355.742C209.986 354.684 209.19 353.095 209.19 350.975V292.718C209.19 264.383 230.905 242.932 260.301 242.932C271.423 242.932 281.748 246.641 290.49 253.26L238.321 283.449C235.146 285.303 233.555 287.951 233.555 291.659V368.455L233.553 368.452ZM280.292 395.462L249.176 377.985V340.913L280.292 323.436L311.407 340.913V377.985L280.292 395.462ZM300.286 475.968C289.163 475.968 278.837 472.259 270.097 465.64L322.264 435.449C325.441 433.597 327.03 430.949 327.03 427.239V350.445L349.011 363.155C350.865 364.213 351.66 365.802 351.66 367.922V426.179C351.66 454.514 329.679 475.965 300.286 475.965V475.968ZM237.525 416.915L186.944 387.785C172.378 379.31 162.582 361.305 162.582 343.827C162.582 323.436 174.763 305.164 193.563 297.485V357.861C193.563 361.571 195.154 364.217 198.33 366.071L264.535 404.467L242.82 416.915C240.967 417.972 239.377 417.972 237.525 416.915ZM234.614 460.343C204.689 460.343 182.71 437.833 182.71 410.028C182.71 407.91 182.976 405.792 183.238 403.672L235.405 433.863C238.582 435.715 241.763 435.715 244.938 433.863L311.407 395.466V420.622C311.407 422.742 310.612 424.331 308.758 425.389L258.179 454.519C251.293 458.491 243.083 460.343 234.611 460.343H234.614ZM300.286 491.854C332.329 491.854 359.073 469.082 365.167 438.892C394.825 431.211 413.892 403.406 413.892 375.073C413.892 356.535 405.948 338.529 391.648 325.552C392.972 319.991 393.766 314.43 393.766 308.87C393.766 271.003 363.048 242.666 327.562 242.666C320.413 242.666 313.528 243.723 306.644 246.109C294.725 234.457 278.307 227.042 260.301 227.042C228.258 227.042 201.513 249.815 195.42 280.004C165.761 287.685 146.694 315.49 146.694 343.824C146.694 362.362 154.638 380.368 168.938 393.344C167.613 398.906 166.819 404.467 166.819 410.027C166.819 447.894 197.538 476.231 233.024 476.231C240.172 476.231 247.058 475.173 253.943 472.788C265.859 484.441 282.278 491.854 300.286 491.854Z" fill="#FFFFFF"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,30 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1181.705 585.818" role="img" aria-labelledby="pds-title">
|
||||||
|
<title id="pds-title">pds</title>
|
||||||
|
<g>
|
||||||
|
<defs>
|
||||||
|
<path id="pds-swoosh-left" d="M772.086 206.642l-105.035 8.411c45.016 41.834 87.543 75.766 127.371 102.59 27.018 8.992 62.736 12.163 104.491-4.697-30.703-25.227-50.565-48.661-86.763-88.302-16.24-17.811-39.056-18.002-40.03-18.002h-.034z"/>
|
||||||
|
<clipPath id="pds-swoosh-left-clip">
|
||||||
|
<use xlink:href="#pds-swoosh-left"/>
|
||||||
|
</clipPath>
|
||||||
|
<linearGradient id="pds-swoosh-left-gradient" x1="452.9609" x2="458.5785" y1="234.8403" y2="234.8403" gradientTransform="matrix(38.4954 22.2253 22.2253 -38.4954 -21983.3574 -822.2328)" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#d50a3b"/>
|
||||||
|
<stop offset=".2" stop-color="#d50a3b"/>
|
||||||
|
<stop offset=".8" stop-color="#94190e"/>
|
||||||
|
<stop offset="1" stop-color="#94190e"/>
|
||||||
|
</linearGradient>
|
||||||
|
<path id="pds-swoosh-right" d="M982.978 254.984c-78.122 76.53-144.787 77.232-188.556 62.659 44.006 27.159 103.202 56.553 159.283 60.762.938.077 1.883.125 2.826.158h5.46c80.146-2.82 188.527-127.453 213.695-229.967.229-.959.471-1.854.691-2.751 0-.051 0-.095.017-.219 5.311-21.627-2.354-38.337-6.048-44.743C1150.984 70.19 1109.181 0 1109.181 0c-7.671 61.885-53.602 183.874-126.203 254.984z"/>
|
||||||
|
<clipPath id="pds-swoosh-right-clip">
|
||||||
|
<use xlink:href="#pds-swoosh-right"/>
|
||||||
|
</clipPath>
|
||||||
|
<linearGradient id="pds-swoosh-right-gradient" x1="453.6846" x2="459.3051" y1="233.9976" y2="233.9976" gradientTransform="matrix(68.3072 0 0 -68.3072 -30195.5273 16172.9473)" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#d50a3b"/>
|
||||||
|
<stop offset=".3" stop-color="#d50a3b"/>
|
||||||
|
<stop offset=".9" stop-color="#ea9893"/>
|
||||||
|
<stop offset="1" stop-color="#ea9893"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<polygon fill="url(#pds-swoosh-left-gradient)" points="725.021 106.244 952.239 237.433 840.945 430.206 613.72 299.015" clip-path="url(#pds-swoosh-left-clip)"/>
|
||||||
|
<rect width="387.283" height="378.563" x="794.422" fill="url(#pds-swoosh-right-gradient)" clip-path="url(#pds-swoosh-right-clip)"/>
|
||||||
|
</g>
|
||||||
|
<path fill="#575656" d="M586.085 451.803c0-19.771-4.644-39.134-37.172-58.887l-50.756-30.985c-9.664-5.829-15.493-9.682-15.493-15.886 0-11.999 8.147-16.276 26.733-16.276 22.063 0 53.058 3.085 63.914 4.652h1.551c3.878 0 6.196-2.709 6.196-7.38v-22.438c0-4.662-2.318-7.764-6.979-8.914-19.754-4.262-37.556-7.772-68.942-7.772-49.197 0-73.595 19.396-73.595 58.128 0 22.464 13.558 41.825 32.537 53.45l53.066 32.536c12.399 7.737 17.053 12.408 17.053 19.771 0 10.447-3.486 18.577-27.5 18.577-11.641 0-46.496-1.926-65.099-4.262h-1.543c-3.869 0-6.596 3.103-6.596 6.205v23.247c0 4.652 2.727 7.755 7.372 8.896 21.688 5.813 54.225 7.772 69.334 7.772 50.756 0 75.939-26.742 75.939-60.437M341.66 467.67c-7.363 1.551-23.248 2.71-37.189 2.71-33.321 0-41.433-23.623-41.433-55.393v-29.826c0-31.753 8.112-55.393 41.433-55.393 9.297 0 26.35 1.177 37.189 2.727zm51.132 24.032V222.069c0-4.261-3.485-7.729-7.746-7.729h-35.639c-4.261 0-7.747 3.469-7.747 7.729v68.576c-14.334-1.942-27.892-2.728-37.189-2.728-62.363 0-92.59 36.807-92.59 97.244v29.826c0 60.421 30.227 97.252 92.59 97.252 24.798 0 53.858-3.886 77.873-10.09 8.897-2.334 10.448-4.651 10.448-10.447M129.763 414.988c0 31.77-8.13 55.393-41.451 55.393-9.297 0-26.333-1.159-37.189-2.71V332.496c7.363-1.55 23.248-2.727 37.189-2.727 33.321 0 41.451 23.64 41.451 55.393zm51.14 0v-29.826c0-60.438-30.21-97.244-92.591-97.244-24.782 0-53.841 3.895-77.856 10.09C1.534 300.326 0 302.653 0 308.463V578.08c0 4.244 3.477 7.738 7.729 7.738h35.665c4.252 0 7.729-3.494 7.729-7.738v-68.566c14.326 1.926 27.892 2.727 37.189 2.727 62.381 0 92.591-36.831 92.591-97.252"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Rendert das YouTube-Banner als PNG in 2560 x 1440, mit Chrome ohne Fenster.
|
||||||
|
# ./rendern.sh youtube-banner.png
|
||||||
|
# ./rendern.sh zonen youtube-banner-zonen.png, mit den Bereichen je Gerät
|
||||||
|
cd "$(dirname "$0")" || exit 1
|
||||||
|
CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||||
|
NAME=youtube-banner
|
||||||
|
SUCHE=""
|
||||||
|
if [ "$1" = "zonen" ]; then NAME=youtube-banner-zonen; SUCHE="?zonen"; fi
|
||||||
|
"$CHROME" --headless=new --disable-gpu --hide-scrollbars --allow-file-access-from-files \
|
||||||
|
--force-device-scale-factor=1 --window-size=2560,1440 --virtual-time-budget=4000 \
|
||||||
|
--screenshot="$PWD/$NAME.png" "file://$PWD/youtube-banner.html$SUCHE" 2>/dev/null
|
||||||
|
ls -la "$NAME.png"
|
||||||
|
After Width: | Height: | Size: 383 KiB |
@@ -0,0 +1,105 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>YouTube-Banner HiveGrid</title>
|
||||||
|
<link rel="stylesheet" href="../assets/fonts/manrope.css">
|
||||||
|
<style>
|
||||||
|
/* YouTube-Banner, 2560 x 1440 (Empfehlung von Google, Stand 10/2026).
|
||||||
|
Was jedes Gerät sieht:
|
||||||
|
Fernseher das ganze Bild
|
||||||
|
Desktop der Streifen in der Mitte, 2560 x 423
|
||||||
|
Handy nur 1546 x 423 in der Mitte. Darin steht alles Wichtige.
|
||||||
|
Keine Schatten und Rahmen, das verbietet Google ausdrücklich.
|
||||||
|
Gerendert mit ./rendern.sh. Mit ?zonen sieht man die Bereiche. */
|
||||||
|
:root {
|
||||||
|
--navy: #0F1A2E;
|
||||||
|
--gelb: #F1B729;
|
||||||
|
--weiss: #FFFFFF;
|
||||||
|
--grau: #B9BDC9;
|
||||||
|
}
|
||||||
|
html, body { margin: 0; background: var(--navy); }
|
||||||
|
.banner {
|
||||||
|
position: relative; width: 2560px; height: 1440px; overflow: hidden;
|
||||||
|
background: var(--navy); font-family: Manrope, sans-serif;
|
||||||
|
}
|
||||||
|
#waben { position: absolute; inset: 0; }
|
||||||
|
.mitte {
|
||||||
|
position: absolute; left: 507px; top: 508px; width: 1546px; height: 423px;
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.wortmarke { height: 76px; display: block; margin-bottom: 34px; }
|
||||||
|
h1 {
|
||||||
|
margin: 0; color: var(--weiss); font-weight: 800; font-size: 80px;
|
||||||
|
line-height: 1.08; letter-spacing: -0.025em;
|
||||||
|
}
|
||||||
|
/* Akzent wie im Theme modern: das Wort in Gelb mit einem Balken darunter. */
|
||||||
|
h1 .a { color: var(--gelb); position: relative; white-space: nowrap; }
|
||||||
|
h1 .a::after {
|
||||||
|
content: ""; position: absolute; left: 0; right: 0.12em; bottom: -0.04em;
|
||||||
|
height: 0.09em; background: var(--gelb);
|
||||||
|
}
|
||||||
|
.unter {
|
||||||
|
margin: 34px 0 0; color: var(--grau); font-weight: 600; font-size: 38px;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hilfslinien, nur mit ?zonen */
|
||||||
|
.zone { position: absolute; border: 3px dashed; pointer-events: none; display: none; }
|
||||||
|
.zonen .zone { display: block; }
|
||||||
|
.zone span { position: absolute; left: 12px; top: 8px; font: 700 26px Manrope, sans-serif; }
|
||||||
|
.z-handy { left: 507px; top: 508px; width: 1546px; height: 423px; border-color: #FF4D4D; color: #FF4D4D; }
|
||||||
|
.z-tablet { left: 352px; top: 508px; width: 1855px; height: 423px; border-color: #4DD2FF; color: #4DD2FF; }
|
||||||
|
.z-desktop { left: 0; top: 508px; width: 2560px; height: 423px; border-color: #7CFF4D; color: #7CFF4D; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="banner" id="banner">
|
||||||
|
<svg id="waben" width="2560" height="1440" viewBox="0 0 2560 1440" aria-hidden="true"></svg>
|
||||||
|
<div class="mitte">
|
||||||
|
<img class="wortmarke" src="../assets/brand/logo-white.svg" alt="HiveGrid">
|
||||||
|
<h1>Dein Branchenprogramm bleibt.<br>HiveGrid kommt <span class="a">drumherum.</span></h1>
|
||||||
|
<p class="unter">Software für Handwerksbetriebe, entstanden in einem SHK-Betrieb</p>
|
||||||
|
</div>
|
||||||
|
<div class="zone z-desktop"><span>Desktop 2560 × 423</span></div>
|
||||||
|
<div class="zone z-tablet"><span>Tablet 1855 × 423</span></div>
|
||||||
|
<div class="zone z-handy"><span>Handy, alle Geräte 1546 × 423</span></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
// Das Wabenfeld der Website (cta-honeycomb.svg): gelbe G-Waben auf Dunkel.
|
||||||
|
// Außen volle G-Waben, zur Mitte hin nur noch der Umriss der Wabe, immer
|
||||||
|
// dünner, dann nichts mehr, damit die Schrift frei steht. Kein halbdurch-
|
||||||
|
// sichtiges Gelb: Das wird auf Navy trüb.
|
||||||
|
// Fester Zufall, damit jedes Rendern gleich aussieht.
|
||||||
|
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";
|
||||||
|
const WABE = "384.5,166.5 288.3,332.9 96.1,332.9 0,166.5 96.1,0 288.3,0";
|
||||||
|
const B = 168, S = B / 384.5, H = 332.9 * S, LUECKE = 22;
|
||||||
|
const DX = 0.75 * B + LUECKE, DY = H + LUECKE * 1.1;
|
||||||
|
let saat = 7;
|
||||||
|
const zufall = () => ((saat = (saat * 16807) % 2147483647) / 2147483647);
|
||||||
|
const svg = document.getElementById("waben");
|
||||||
|
let inhalt = "";
|
||||||
|
for (let spalte = -1; spalte * DX < 2560 + B; spalte++) {
|
||||||
|
for (let zeile = -1; zeile * DY < 1440 + H; zeile++) {
|
||||||
|
const x = spalte * DX, y = zeile * DY + (spalte % 2 ? DY / 2 : 0);
|
||||||
|
const mx = x + B / 2, my = y + H / 2;
|
||||||
|
// 0 in der Mitte, 1 am Rand. Waagerecht ab 640 px vom Zentrum, senkrecht ab 300.
|
||||||
|
const quer = Math.max(Math.abs(mx - 1280) - 640, 0) / 600;
|
||||||
|
const hoch = Math.max(Math.abs(my - 720) - 300, 0) / 420;
|
||||||
|
const staerke = Math.min(1, Math.max(quer, hoch * 0.85)) + 0.06 * (zufall() - 0.5);
|
||||||
|
const lage = `transform="translate(${x.toFixed(1)} ${y.toFixed(1)}) scale(${S.toFixed(5)})"`;
|
||||||
|
if (staerke >= 0.5) {
|
||||||
|
inhalt += `<polygon points="${G}" fill="#F1B729" ${lage}/>`;
|
||||||
|
} else if (staerke >= 0.12) {
|
||||||
|
// Strichstärke in Pixeln, über die Skalierung zurückgerechnet.
|
||||||
|
const strich = (1.5 + 5 * (staerke - 0.12) / 0.38) / S;
|
||||||
|
inhalt += `<polygon points="${WABE}" fill="none" stroke="#F1B729" stroke-width="${strich.toFixed(1)}" stroke-linejoin="miter" ${lage}/>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
svg.innerHTML = inhalt;
|
||||||
|
if (location.search.includes("zonen")) document.getElementById("banner").classList.add("zonen");
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
After Width: | Height: | Size: 356 KiB |
@@ -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`.
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
|
<script>
|
||||||
|
/* Uebersichts-Modus: die Seite laeuft dann als Kachel in uebersicht.html.
|
||||||
|
Das Attribut muss vor dem ersten Zeichnen stehen, sonst blitzt die
|
||||||
|
Bedienoberflaeche kurz auf. Deshalb hier oben und nicht im Hauptskript. */
|
||||||
|
if (location.search.indexOf("nur=streifen") >= 0)
|
||||||
|
document.documentElement.dataset.nur = "streifen";
|
||||||
|
</script>
|
||||||
<meta charset="utf-8">
|
<meta 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 1 · Lösung</title>
|
<title>PostGrid Post 1 · Lösung</title>
|
||||||
@@ -139,8 +146,14 @@
|
|||||||
.forget:hover { color: #EB7C6B; }
|
.forget:hover { color: #EB7C6B; }
|
||||||
|
|
||||||
/* ─── Control panel ──────────────────────────────────────────────────── */
|
/* ─── Control panel ──────────────────────────────────────────────────── */
|
||||||
|
/* max-width statt nur width: bei einem schmalen Fenster kaempfen linkes
|
||||||
|
Feld, Buehne und rechtes Feld um denselben Platz, und ohne Obergrenze
|
||||||
|
schiebt sich das rechte aus dem Fenster. Dann ist es nicht mehr
|
||||||
|
zuzumachen, weil sein Knopf mit hinausrutscht.
|
||||||
|
overflow statt overflow-y aus demselben Grund: wird das Feld schmal,
|
||||||
|
muss sein Inhalt waagerecht erreichbar bleiben. */
|
||||||
.panel {
|
.panel {
|
||||||
width: 306px; flex: none; height: 100%; overflow-y: auto;
|
width: 306px; max-width: 42vw; flex: none; height: 100%; overflow: auto;
|
||||||
padding: 28px 22px 40px 28px;
|
padding: 28px 22px 40px 28px;
|
||||||
border-right: 1px solid #24262F; background: #17181F;
|
border-right: 1px solid #24262F; background: #17181F;
|
||||||
}
|
}
|
||||||
@@ -753,6 +766,26 @@
|
|||||||
--guide-axis: rgba(48,49,61,.45);
|
--guide-axis: rgba(48,49,61,.45);
|
||||||
--guide-mid: rgba(200,144,26,.75);
|
--guide-mid: rgba(200,144,26,.75);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Uebersichts-Modus ──────────────────────────────────────────────────
|
||||||
|
Die Seite zeigt dann nur den Streifen, in natuerlicher Groesse und ohne
|
||||||
|
eigenen Rahmen: die Uebersicht setzt sie als iframe ein und skaliert
|
||||||
|
selbst. Alles Bedienbare faellt weg, auch die Hilfslinien. */
|
||||||
|
html[data-nur="streifen"] { overflow: hidden; }
|
||||||
|
html[data-nur="streifen"] .menubar,
|
||||||
|
html[data-nur="streifen"] .panel,
|
||||||
|
html[data-nur="streifen"] .hud,
|
||||||
|
html[data-nur="streifen"] .guides { display: none !important; }
|
||||||
|
/* Der Auswahlrahmen ist eine Bedienhilfe und hat in der Uebersicht nichts
|
||||||
|
verloren -- dort ist keine Slide "die aktuelle". */
|
||||||
|
html[data-nur="streifen"] .holder.sel::after { display: none; }
|
||||||
|
html[data-nur="streifen"] .stage { background: none; height: auto; }
|
||||||
|
html[data-nur="streifen"] .scroller { position: static; overflow: visible; }
|
||||||
|
html[data-nur="streifen"] .canvas { padding: 0; display: block;
|
||||||
|
width: max-content; min-width: 0; height: max-content; min-height: 0; }
|
||||||
|
html[data-nur="streifen"] body, html[data-nur="streifen"] .app {
|
||||||
|
height: auto; min-height: 0; background: none; overflow: visible;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -765,6 +798,23 @@
|
|||||||
<div class="liste">
|
<div class="liste">
|
||||||
<button type="button" data-gehe="./post1.html">Post 1</button>
|
<button type="button" data-gehe="./post1.html">Post 1</button>
|
||||||
<button type="button" data-gehe="./post2.html">Post 2</button>
|
<button type="button" data-gehe="./post2.html">Post 2</button>
|
||||||
|
<button type="button" data-gehe="./post2-alt.html">Post 2 · Alternative</button>
|
||||||
|
<button type="button" data-gehe="./post3.html">Post 3 · Verbinden statt ersetzen</button>
|
||||||
|
<button type="button" data-gehe="./post4.html">Post 4 · Workflow-Engine</button>
|
||||||
|
<button type="button" data-gehe="./post5.html">Post 5 · Projektmanagement</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="./post8.html">Post 8 · E-Mail-Marketing</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>
|
||||||
|
<button type="button" data-gehe="./uebersicht.html">Alle Projekte im Streifen</button>
|
||||||
<hr>
|
<hr>
|
||||||
<button type="button" data-klick="snap-save">Stand merken</button>
|
<button type="button" data-klick="snap-save">Stand merken</button>
|
||||||
<button type="button" data-klick="snap-load">Zurück zum gemerkten Stand</button>
|
<button type="button" data-klick="snap-load">Zurück zum gemerkten Stand</button>
|
||||||
@@ -971,9 +1021,13 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<aside class="panel panel-rechts" id="element-panel">
|
<aside class="panel panel-rechts" id="element-panel">
|
||||||
|
<!-- Der Schliessknopf steht vor der Ueberschrift und nicht dahinter. Am
|
||||||
|
rechten Rand war er der erste, der verschwand, sobald das Feld nicht
|
||||||
|
ganz ins Fenster passte -- und damit war das Feld nicht mehr
|
||||||
|
zuzumachen. Vorn ist er immer da. -->
|
||||||
<div class="el-kopf">
|
<div class="el-kopf">
|
||||||
|
<button class="el-zu" id="el-zu" title="Auswahl aufheben (Esc)">✕</button>
|
||||||
<h1 id="el-titel">Element</h1>
|
<h1 id="el-titel">Element</h1>
|
||||||
<button class="el-zu" id="el-zu" title="Auswahl aufheben">✕</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="el-inhalt"></div>
|
<div id="el-inhalt"></div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -1295,7 +1349,12 @@
|
|||||||
Voreinstellung an und schreibt die beim ersten Klick ueber genau die
|
Voreinstellung an und schreibt die beim ersten Klick ueber genau die
|
||||||
Datei, die gerade nicht verstanden wurde. Solange die Sperre steht, geht
|
Datei, die gerade nicht verstanden wurde. Solange die Sperre steht, geht
|
||||||
nichts mehr raus, weder in die Datei noch in den localStorage. */
|
nichts mehr raus, weder in die Datei noch in den localStorage. */
|
||||||
let stateLocked = false;
|
/* Im Uebersichts-Modus wird nichts gespeichert: drei Kacheln, die beim
|
||||||
|
Schliessen ihren Stand zurueckschreiben, waeren genau die Falle aus
|
||||||
|
POST-2.md 6. Die Konstante steht hier oben, weil stateLocked sie
|
||||||
|
braucht -- weiter unten waere sie noch nicht initialisiert. */
|
||||||
|
const NUR_STREIFEN = document.documentElement.dataset.nur === "streifen";
|
||||||
|
let stateLocked = NUR_STREIFEN;
|
||||||
function lockState(grund) {
|
function lockState(grund) {
|
||||||
stateLocked = true;
|
stateLocked = true;
|
||||||
clearTimeout(pushTimer); pushTimer = null;
|
clearTimeout(pushTimer); pushTimer = null;
|
||||||
@@ -1486,7 +1545,10 @@
|
|||||||
variant = d.variant || variant;
|
variant = d.variant || variant;
|
||||||
tilt = d.tilt || tilt;
|
tilt = d.tilt || tilt;
|
||||||
superSample = d.superSample || superSample;
|
superSample = d.superSample || superSample;
|
||||||
view = d.view || view;
|
/* Der Uebersichts-Modus zeigt immer den Streifen. Steht im Stand
|
||||||
|
"einzeln", waere sonst nur die zuletzt gewaehlte Slide zu sehen --
|
||||||
|
und stripWidth() meldete 1080 statt der vollen Breite. */
|
||||||
|
view = NUR_STREIFEN ? "streifen" : (d.view || view);
|
||||||
tagOut = d.tagOut || tagOut;
|
tagOut = d.tagOut || tagOut;
|
||||||
frameStyle = d.frameStyle || frameStyle;
|
frameStyle = d.frameStyle || frameStyle;
|
||||||
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;
|
||||||
@@ -1525,7 +1587,8 @@
|
|||||||
springt. */
|
springt. */
|
||||||
/* zoom === null bedeutet: automatisch einpassen. Sobald der Nutzer zoomt,
|
/* zoom === null bedeutet: automatisch einpassen. Sobald der Nutzer zoomt,
|
||||||
hält der Wert, bis er auf "Fit" zurückgeht. */
|
hält der Wert, bis er auf "Fit" zurückgeht. */
|
||||||
let zoom = null;
|
// Im Uebersichts-Modus steht der Streifen fest auf 100 % -- skaliert wird aussen.
|
||||||
|
let zoom = NUR_STREIFEN ? 1 : null;
|
||||||
|
|
||||||
function stripWidth() { return view === "streifen" ? 1080 * SLIDES.length : 1080; }
|
function stripWidth() { return view === "streifen" ? 1080 * SLIDES.length : 1080; }
|
||||||
|
|
||||||
@@ -1536,6 +1599,16 @@
|
|||||||
return Math.min(availW / stripWidth(), availH / 1350, 0.45);
|
return Math.min(availW / stripWidth(), availH / 1350, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Die Uebersicht muss wissen, wie breit dieser Streifen ist -- die Zahl
|
||||||
|
der Slides ist je Projekt anders. Sie kommt per postMessage statt ueber
|
||||||
|
einen Griff ins iframe-Dokument: so bleibt die Uebersicht von den
|
||||||
|
Interna der Post-Seite unabhaengig. */
|
||||||
|
function groesseMelden() {
|
||||||
|
if (!NUR_STREIFEN || parent === window) return;
|
||||||
|
parent.postMessage({ hg: "streifen", w: stripWidth(), h: 1350,
|
||||||
|
titel: document.title }, "*");
|
||||||
|
}
|
||||||
|
|
||||||
function fitStrip() {
|
function fitStrip() {
|
||||||
const wrap = document.getElementById("strip-wrap");
|
const wrap = document.getElementById("strip-wrap");
|
||||||
const k = zoom || autoFit();
|
const k = zoom || autoFit();
|
||||||
@@ -1544,6 +1617,7 @@
|
|||||||
wrap.style.height = Math.round(1350 * k) + "px";
|
wrap.style.height = Math.round(1350 * 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setZoom(k, anchor) {
|
function setZoom(k, anchor) {
|
||||||
@@ -1881,7 +1955,13 @@
|
|||||||
const optional = new Set(["./assets/mockups/laptop.png", "./assets/mockups/macbook-fels.png"]);
|
const optional = new Set(["./assets/mockups/laptop.png", "./assets/mockups/macbook-fels.png"]);
|
||||||
paths.push("./assets/mockups/laptop.png", "./assets/mockups/macbook-fels.png");
|
paths.push("./assets/mockups/laptop.png", "./assets/mockups/macbook-fels.png");
|
||||||
for (const path of [...new Set(paths)]) {
|
for (const path of [...new Set(paths)]) {
|
||||||
const res = await fetch(path, { cache: "no-store" }).catch(() => null);
|
/* no-cache statt no-store: der Browser behaelt seine Kopie und fragt
|
||||||
|
nur nach, ob sie noch stimmt. Ein ausgetauschtes Bild kommt genauso
|
||||||
|
sofort an, ein unveraendertes kostet ein 304 statt seiner vollen
|
||||||
|
Groesse. Mit no-store lud jeder Reload alle Assets neu -- ueber
|
||||||
|
uebersicht.html, die sechs Projekte als iframes laedt, waren das
|
||||||
|
38 MB und 21 Sekunden. */
|
||||||
|
const res = await fetch(path, { cache: "no-cache" }).catch(() => null);
|
||||||
if (!res || !res.ok) {
|
if (!res || !res.ok) {
|
||||||
if (optional.has(path)) continue;
|
if (optional.has(path)) continue;
|
||||||
throw new Error(path + " -> HTTP " + (res ? res.status : "kein Zugriff"));
|
throw new Error(path + " -> HTTP " + (res ? res.status : "kein Zugriff"));
|
||||||
@@ -2193,6 +2273,92 @@
|
|||||||
return n < 20 ? 1 : dunkel / n;
|
return n < 20 ? 1 : dunkel / n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ─── Umlaute im gezeichneten Text ───────────────────────────────────────
|
||||||
|
Auf einer Slide stand einmal "Sanitaer" und "Klima / Lueftung". Beides
|
||||||
|
kam woertlich aus der Quelle -- im Klickdummy war es dort schludrig
|
||||||
|
eingetippt --, und beides las sich im fertigen Beitrag wie ein Fehler,
|
||||||
|
weil es einer ist. Gefunden hat es niemand beim Bauen, sondern erst
|
||||||
|
jemand, der auf den fertigen Post geschaut hat.
|
||||||
|
|
||||||
|
Deshalb prueft es jetzt der Export. Ein reines Muster genuegt dafuer
|
||||||
|
nicht: Deutsch schreibt "neue", "Dauer", "Michael" voellig zu Recht mit
|
||||||
|
ae/oe/ue. Nachgezaehlt ueber den gezeichneten Text aller Projekte sind es
|
||||||
|
aber nur eine Handvoll solcher Woerter -- eine Ausnahmeliste ist also
|
||||||
|
kurz und trifft trotzdem alles andere.
|
||||||
|
|
||||||
|
ss ist der umgekehrte Fall: fast immer richtig (dass, Schluss, Adresse,
|
||||||
|
Netzanschluss), nur ein paar Woerter brauchen ein scharfes s. Die stehen
|
||||||
|
deshalb einzeln als Liste und nicht als Muster.
|
||||||
|
|
||||||
|
Kanalnamen sind Absicht: "# waermepumpe-mueller" ist ein Handle, und die
|
||||||
|
schreiben Chatwerkzeuge klein und ohne Umlaute. */
|
||||||
|
/* Woerter, in denen ae/oe/ue kein verkruepppelter Umlaut ist, sondern zwei
|
||||||
|
Laute an einer Silbengrenze. Die Eintraege wirken als Wortanfang.
|
||||||
|
|
||||||
|
Die Liste ist kurz geworden, weil die Regel darunter den groessten Teil
|
||||||
|
der Ausnahmen von selbst erledigt: ein ersatzweise geschriebener Umlaut
|
||||||
|
steht nie hinter einem Vokal. Vorher standen hier dauer, neue, steuer,
|
||||||
|
feuer, bauer -- alle nach demselben Muster, und die Liste waere mit
|
||||||
|
jedem Beitrag weitergewachsen. Was bleibt, sind die echten Sonderfaelle:
|
||||||
|
"Michael" (ae hinter h), "aktuell" (ue hinter t). */
|
||||||
|
const UMLAUT_ERLAUBT = new Set([
|
||||||
|
"michael", "aktuell", "museum",
|
||||||
|
"waermepumpe-mueller", "waermepumpe-abnahme",
|
||||||
|
]);
|
||||||
|
const BRAUCHT_SCHARFES_S = new Set([
|
||||||
|
"aufmass", "strasse", "groesse", "groesser", "fuss", "fussboden",
|
||||||
|
"mass", "masse", "weiss", "heisst", "aussen", "gross", "grosse", "spass",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function umlautFehler(wurzel) {
|
||||||
|
const raus = [];
|
||||||
|
wurzel.querySelectorAll("text, .headline, .body, .badge").forEach(e => {
|
||||||
|
/* Nicht textContent, sondern die Textknoten mit Leerzeichen dazwischen.
|
||||||
|
Eine Ueberschrift ist "Formulare selbst bauen.<br><span>Per
|
||||||
|
Drag-and-drop.</span>" -- textContent klebt daraus "bauen.Per"
|
||||||
|
zusammen, ein Wort, das es nicht gibt. Ein Umbruch und eine
|
||||||
|
Elementgrenze sind Wortgrenzen. */
|
||||||
|
const stuecke = [];
|
||||||
|
const lauf = document.createTreeWalker(e, NodeFilter.SHOW_TEXT);
|
||||||
|
for (let k = lauf.nextNode(); k; k = lauf.nextNode()) stuecke.push(k.nodeValue);
|
||||||
|
const ganz = stuecke.join(" ");
|
||||||
|
/* Geviertstriche gehoeren nicht in unsere Texte. Der Halbgeviertstrich
|
||||||
|
als Gedankenstrich ist richtiges Deutsch und bleibt erlaubt; der
|
||||||
|
lange Strich ist eine englische Angewohnheit und liest sich auf einer
|
||||||
|
deutschen Slide fremd. Er kam nicht aus einer Quelle, sondern beim
|
||||||
|
Schreiben mit hinein. */
|
||||||
|
if (ganz.indexOf("\u2014") >= 0) {
|
||||||
|
const stelle = "Geviertstrich in „" + ganz.trim().slice(0, 40) + "“";
|
||||||
|
if (!raus.includes(stelle)) raus.push(stelle);
|
||||||
|
}
|
||||||
|
ganz.split(/[^A-Za-zÄÖÜäöüß0-9./#@-]+/).forEach(w => {
|
||||||
|
if (!w) return;
|
||||||
|
const k = w.toLowerCase().replace(/^[@#]+/, "").replace(/[.,;:!?)(]+$/, "");
|
||||||
|
if (!k || raus.includes(w)) return;
|
||||||
|
/* Mailadressen und Domains sind ausgenommen. Dort ist die
|
||||||
|
umlautfreie Schreibung keine Schludrigkeit, sondern Pflicht:
|
||||||
|
"buero@muster-shk.de" kann gar kein ue ersetzen. Ohne diese
|
||||||
|
Ausnahme meldete Post 8 drei Adressen als Fehler. */
|
||||||
|
if (/@/.test(k) || /\.(de|com|net|org|eu|at|ch|io)$/.test(k)) return;
|
||||||
|
if ([...UMLAUT_ERLAUBT].some(a => k.startsWith(a))) return;
|
||||||
|
/* ae/oe/ue nur dann als verunglueckter Umlaut, wenn kein Vokal
|
||||||
|
davorsteht. Ein ae, oe oder ue, das fuer ae, oe oder ue steht,
|
||||||
|
folgt immer einem Konsonanten oder steht am Wortanfang: "fuer",
|
||||||
|
"ueber", "gruen", "Sanitaer", "Lueftung". Hinter einem Vokal sind
|
||||||
|
es dagegen immer zwei eigene Laute: bauen, Bauer, neue, Dauer,
|
||||||
|
Steuer, Feuer, Mauer, genauer. Die Regel ersetzt sechs Eintraege
|
||||||
|
in UMLAUT_ERLAUBT und alle, die dort noch gelandet waeren. */
|
||||||
|
if (/(^|[^aeiou])(ae|oe|ue)/.test(k) || BRAUCHT_SCHARFES_S.has(k)) raus.push(w);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return raus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fuer animation.mjs: das Bewegtbild laeuft nicht durch renderSlide und
|
||||||
|
braucht deshalb eine eigene Tuer zu derselben Pruefung. */
|
||||||
|
window.TEXT_PRUEFUNG = () => umlautFehler(document);
|
||||||
|
|
||||||
async function renderSlide(i) {
|
async function renderSlide(i) {
|
||||||
// Hilfslinien sind Werkzeug: koennten sie aus einem Tastendruck noch
|
// Hilfslinien sind Werkzeug: koennten sie aus einem Tastendruck noch
|
||||||
// stehen, landeten sie im Bild.
|
// stehen, landeten sie im Bild.
|
||||||
@@ -2209,6 +2375,12 @@
|
|||||||
el.dataset.editmode = "aus";
|
el.dataset.editmode = "aus";
|
||||||
try {
|
try {
|
||||||
await document.fonts.ready;
|
await document.fonts.ready;
|
||||||
|
/* Den Text pruefen, bevor gerendert wird. Lieber ein Abbruch mit
|
||||||
|
klarer Meldung als ein fertiges PNG mit "Sanitaer" darauf. */
|
||||||
|
const uf = umlautFehler(el);
|
||||||
|
if (uf.length) throw new Error("Slide " + (i + 1) + ": " + uf.join(", ") +
|
||||||
|
". Fehlt ein Umlaut, schreib ihn; ist das Wort so richtig, gehoert es " +
|
||||||
|
"in UMLAUT_ERLAUBT. Einen Geviertstrich ersetzt ein Punkt oder ein Komma.");
|
||||||
const imgs = [...el.querySelectorAll("img")];
|
const imgs = [...el.querySelectorAll("img")];
|
||||||
await Promise.all(imgs.map(im =>
|
await Promise.all(imgs.map(im =>
|
||||||
im.complete ? im.decode().catch(() => {})
|
im.complete ? im.decode().catch(() => {})
|
||||||
@@ -2447,7 +2619,7 @@
|
|||||||
// Eine Datei einlesen, damit sie sofort im Canvas und im Export vorliegt.
|
// Eine Datei einlesen, damit sie sofort im Canvas und im Export vorliegt.
|
||||||
async function bildBereitstellen(pfad) {
|
async function bildBereitstellen(pfad) {
|
||||||
if (assets[pfad]) return true;
|
if (assets[pfad]) return true;
|
||||||
const res = await fetch(pfad, { cache: "no-store" }).catch(() => null);
|
const res = await fetch(pfad, { cache: "no-cache" }).catch(() => null);
|
||||||
if (!res || !res.ok) return false;
|
if (!res || !res.ok) return false;
|
||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
assets[pfad] = await new Promise((ok, fail) => {
|
assets[pfad] = await new Promise((ok, fail) => {
|
||||||
@@ -2991,10 +3163,24 @@
|
|||||||
if (c) { put("#f-dx", c.x); put("#f-dy", c.y); }
|
if (c) { put("#f-dx", c.x); put("#f-dy", c.y); }
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("el-zu").addEventListener("click", () => {
|
/* Auswahl aufheben, und damit das rechte Feld zu. Zwei Wege, weil einer
|
||||||
|
zu wenig war: der Knopf setzt voraus, dass man ihn sieht, und genau das
|
||||||
|
war nicht immer der Fall. Escape geht immer.
|
||||||
|
|
||||||
|
Nicht wenn gerade in einem Feld getippt wird -- dort soll Escape die
|
||||||
|
Eingabe verlassen und nicht die Auswahl wegnehmen. */
|
||||||
|
function auswahlAufheben() {
|
||||||
|
if (!picked) return;
|
||||||
picked = null;
|
picked = null;
|
||||||
deck.querySelectorAll("[data-edit]").forEach(n => n.classList.remove("picked"));
|
deck.querySelectorAll("[data-edit]").forEach(n => n.classList.remove("picked"));
|
||||||
syncEditPanel();
|
syncEditPanel();
|
||||||
|
}
|
||||||
|
document.getElementById("el-zu").addEventListener("click", auswahlAufheben);
|
||||||
|
document.addEventListener("keydown", e => {
|
||||||
|
if (e.key !== "Escape") return;
|
||||||
|
const z = document.activeElement;
|
||||||
|
if (z && /^(INPUT|TEXTAREA|SELECT)$/.test(z.tagName)) return;
|
||||||
|
auswahlAufheben();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("el-inhalt").addEventListener("click", ev => {
|
document.getElementById("el-inhalt").addEventListener("click", ev => {
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Baut den Redaktionsplan als A4-PDF.
|
||||||
|
|
||||||
|
Aufbau wie beim Webinar-Vergleich: eine HTML-Datei mit eingebettetem Manrope
|
||||||
|
und eingebettetem Logo, die von Chrome nach PDF gedruckt wird. Eingebettet
|
||||||
|
heißt hier wörtlich, die Datei läuft ohne Netz und ohne Nachbarordner.
|
||||||
|
|
||||||
|
python3 redaktionsplan/bauen.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import subprocess
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HIER = Path(__file__).resolve().parent
|
||||||
|
REPO = HIER.parent
|
||||||
|
CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||||
|
|
||||||
|
# Schrift und Logo kommen aus dem Repo selbst, nicht aus einem Nachbarordner:
|
||||||
|
# das PDF soll sich auf jedem Rechner bauen lassen, der nur dieses Repo hat.
|
||||||
|
# Manrope liegt als variables Latin-Subset vor, 200 bis 800.
|
||||||
|
FONT = base64.b64encode(
|
||||||
|
(REPO / "assets/fonts/xn7gYHE41ni1AdIRggexSvfedN4.woff2").read_bytes()).decode()
|
||||||
|
LOGO = base64.b64encode(
|
||||||
|
(REPO / "assets/brand/logo.svg").read_bytes()).decode()
|
||||||
|
|
||||||
|
TAGE = {0: "Mo", 2: "Mi", 4: "Fr"}
|
||||||
|
|
||||||
|
|
||||||
|
# Der erste Termin. Am 12.08. war der Start geplant, erschienen ist "Die
|
||||||
|
# Geschichte" aber erst am Freitag darauf -- der ganze Plan ist damit um
|
||||||
|
# einen Termin nach hinten gerutscht.
|
||||||
|
START = date(2026, 8, 14)
|
||||||
|
|
||||||
|
# Termine, die ausfallen. Der Plan rutscht ab dort um einen Slot nach hinten,
|
||||||
|
# die Beitraege davor behalten ihr Datum -- sie sind schon erschienen.
|
||||||
|
#
|
||||||
|
# Warum nicht einfach START verschieben: das haette auch die vier Beitraege
|
||||||
|
# mitgenommen, die am 14., 17., 19. und 21.08. tatsaechlich draussen waren.
|
||||||
|
# Ein Plan, der rueckwirkend andere Termine behauptet, ist keiner.
|
||||||
|
#
|
||||||
|
# 24.08. Team-Chat war nicht fertig, rueckt auf Mi 26.08.
|
||||||
|
# 26.08. und weiter geschoben: der Beitrag lag noch beim Feinschliff
|
||||||
|
# 28.08. erschienen ist er am Mo 31.08.
|
||||||
|
# 02.09. Form Builder ruecken wir bewusst auf Mo 07.09., statt ihn zwei
|
||||||
|
# 04.09. Tage nach dem Team-Chat hinterherzuschicken
|
||||||
|
AUSGELASSEN = {"24.08.", "26.08.", "28.08.", "02.09.", "04.09."}
|
||||||
|
|
||||||
|
|
||||||
|
def slots(start: date, anzahl: int, auslassen=frozenset()):
|
||||||
|
"""Die Mo/Mi/Fr-Termine ab dem Starttag, ohne die ausgelassenen."""
|
||||||
|
raus, d = [], start
|
||||||
|
while len(raus) < anzahl:
|
||||||
|
if d.weekday() in TAGE:
|
||||||
|
stempel = d.strftime("%d.%m.")
|
||||||
|
if stempel not in auslassen:
|
||||||
|
raus.append((TAGE[d.weekday()], stempel))
|
||||||
|
d += timedelta(days=1)
|
||||||
|
return raus
|
||||||
|
|
||||||
|
|
||||||
|
# Thema, Format, Ziel. Reihenfolge ist nicht beliebig: erst warum es uns gibt,
|
||||||
|
# dann die Haltung, dann die Funktionen von der täglichen Last nach hinten zu
|
||||||
|
# dem, was Vorwissen braucht.
|
||||||
|
# E-Mail-Marketing und Zettelwirtschaft sind am 11.09.2026 getauscht worden.
|
||||||
|
# Die Reihenfolge geht sonst von der taeglichen Last nach hinten zu dem, was
|
||||||
|
# Vorwissen braucht -- Zettelwirtschaft ist aber der einzige Beitrag ohne
|
||||||
|
# Produkt dahinter und muss deshalb erst gebaut werden, waehrend E-Mail-
|
||||||
|
# Marketing eine fertige Oberflaeche zum Zeigen hat.
|
||||||
|
PLAN = [
|
||||||
|
("Die Geschichte", "Carousel, 6", "Herkunft. Warum es HiveGrid gibt."),
|
||||||
|
("Verbinden statt ersetzen", "Einzelbild", "Haltung. Die Kernaussage in einem Satz."),
|
||||||
|
("Workflow-Engine", "Carousel", "Wenn-dann statt Handarbeit."),
|
||||||
|
("Projektmanagement", "Carousel", "Der Überblick, den jeder Betrieb vermisst."),
|
||||||
|
("Team-Chat", "Carousel", "Ein Kanal pro Projekt, Foto rein, Aufgabe raus."),
|
||||||
|
("Form Builder", "Carousel", "Vom Kontaktformular bis zum Aufmaß."),
|
||||||
|
("E-Mail-Marketing", "Carousel", "Aus Anfragen werden Kontakte, aus Kontakten Aufträge."),
|
||||||
|
("Zettelwirtschaft", "Carousel", "Wissen ohne Produkt. Wo im Betrieb Zeit verloren geht."),
|
||||||
|
("Mitarbeiter-Management", "Carousel", "Wer kann was, wer ist wo, wer braucht was."),
|
||||||
|
("Der Betrieb dahinter", "Carousel", "Vierte Generation SHK. Keine Studio-Bilder."),
|
||||||
|
("KI-Anbindung, MCP", "Carousel", "Externe KI darf mitlesen, ohne Datenabfluss."),
|
||||||
|
("Dein ERP bleibt", "Einzelbild", "Der Einwand Nummer eins, direkt beantwortet."),
|
||||||
|
("Tempo und Übersicht", "Reel", "Die Oberfläche selbst. Wenige Klicks, kein Suchen."),
|
||||||
|
("Warteliste", "Carousel", "Aufruf. Was die ersten Betriebe bekommen."),
|
||||||
|
("Häufige Fragen", "Carousel", "Die fünf Einwände, die immer kommen."),
|
||||||
|
]
|
||||||
|
|
||||||
|
TERMINE = slots(START, len(PLAN), AUSGELASSEN)
|
||||||
|
|
||||||
|
# Wie viele Beitraege fertig im Generator liegen. Sie sind in der Tabelle
|
||||||
|
# gelb hinterlegt, und der Satz darueber nennt die Zahl -- beides kommt aus
|
||||||
|
# derselben Konstante, damit es nicht auseinanderlaeuft.
|
||||||
|
FERTIG = 7
|
||||||
|
ZAHLWORT = {1: "Der eine markierte Termin ist", 2: "Die beiden markierten Termine sind",
|
||||||
|
3: "Die drei markierten Termine sind", 4: "Die vier markierten Termine sind",
|
||||||
|
5: "Die fünf markierten Termine sind", 6: "Die sechs markierten Termine sind",
|
||||||
|
7: "Die sieben markierten Termine sind",
|
||||||
|
8: "Die acht markierten Termine sind"}[FERTIG]
|
||||||
|
|
||||||
|
# Der erste Reel-Termin, gerechnet statt abgeschrieben. Im Text stand er
|
||||||
|
# einmal fest als "09.09." und stimmte schon damals nicht. Auch der Kommentar,
|
||||||
|
# der ihn danach auf dem 11.09. verortete, war nach der naechsten Verschiebung
|
||||||
|
# wieder falsch -- ein Datum, das sich aus AUSGELASSEN ergibt, gehoert
|
||||||
|
# nirgendwo als Zahl hingeschrieben, auch nicht in einen Kommentar.
|
||||||
|
REEL = next(d for p, (t, d) in zip(PLAN, TERMINE) if p[1] == "Reel")
|
||||||
|
SPANNE = TERMINE[0][1] + " bis " + TERMINE[-1][1] + START.strftime("%Y")
|
||||||
|
|
||||||
|
ZEILEN = "".join(
|
||||||
|
'<tr{hi}><td class="dt"><b>{tag}</b> {datum}</td><td><b>{thema}</b></td>'
|
||||||
|
'<td class="fm">{format}</td><td class="zl">{ziel}</td></tr>'.format(
|
||||||
|
hi=' class="hi"' if i < FERTIG else "",
|
||||||
|
tag=t, datum=d, thema=p[0], format=p[1], ziel=p[2],
|
||||||
|
)
|
||||||
|
for i, (p, (t, d)) in enumerate(zip(PLAN, TERMINE))
|
||||||
|
)
|
||||||
|
|
||||||
|
HTML = f"""<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Instagram: Redaktionsplan und Vorgehen</title>
|
||||||
|
<style>
|
||||||
|
@font-face{{font-family:'Manrope';font-style:normal;font-weight:200 800;
|
||||||
|
src:url(data:font/woff2;base64,{FONT}) format('woff2');font-display:block}}
|
||||||
|
|
||||||
|
:root{{
|
||||||
|
--ink:#14161A; --ink2:#4A5158; --ink3:#868D95; --ink4:#AAB1B8;
|
||||||
|
--line:#E2E6EA; --line-soft:#EFF2F4;
|
||||||
|
--paper2:#F6F8FA; --yellow:#F1B729; --yellow-soft:#FDF3DC;
|
||||||
|
--navy:#0F1A2E; --teal:#0D6E6E; --rot:#C0554B;
|
||||||
|
}}
|
||||||
|
*{{box-sizing:border-box;margin:0;padding:0}}
|
||||||
|
html{{-webkit-font-smoothing:antialiased}}
|
||||||
|
body{{font-family:'Manrope',system-ui,sans-serif;color:var(--ink);background:#8A9099;
|
||||||
|
font-size:12.4px;line-height:1.5;font-variant-numeric:tabular-nums;letter-spacing:-.003em}}
|
||||||
|
@page{{size:A4;margin:0}}
|
||||||
|
.page{{width:210mm;height:296.6mm;background:#fff;position:relative;overflow:hidden;
|
||||||
|
padding:16mm 16mm 13mm;page-break-after:always;margin:0 auto 8mm;display:flex;flex-direction:column}}
|
||||||
|
.page:last-child{{page-break-after:auto;margin-bottom:0}}
|
||||||
|
@media print{{body{{background:#fff}}.page{{margin:0}}}}
|
||||||
|
|
||||||
|
h1{{font-size:29px;font-weight:800;line-height:1.08;letter-spacing:-.03em}}
|
||||||
|
h2{{font-size:16px;font-weight:800;letter-spacing:-.018em;line-height:1.2}}
|
||||||
|
h3{{font-size:12.4px;font-weight:800;letter-spacing:-.01em}}
|
||||||
|
p{{margin-bottom:7px}}p:last-child{{margin-bottom:0}}
|
||||||
|
strong{{font-weight:700}}
|
||||||
|
.lead{{font-size:13.6px;line-height:1.48;color:var(--ink2);font-weight:500;letter-spacing:-.01em}}
|
||||||
|
.small{{font-size:10.8px;line-height:1.45;color:var(--ink2)}}
|
||||||
|
.micro{{font-size:9.4px;line-height:1.4;color:var(--ink3)}}
|
||||||
|
.eyebrow{{font-size:8.4px;font-weight:700;letter-spacing:.16em;text-transform:uppercase;color:var(--ink3)}}
|
||||||
|
|
||||||
|
.top{{display:flex;justify-content:space-between;align-items:center;
|
||||||
|
border-bottom:1px solid var(--line);padding-bottom:8px;margin-bottom:16px;flex:0 0 auto}}
|
||||||
|
.foot{{position:absolute;left:16mm;right:16mm;bottom:7mm;display:flex;justify-content:space-between;
|
||||||
|
font-size:8.4px;letter-spacing:.05em;color:var(--ink4);border-top:1px solid var(--line-soft);padding-top:5px}}
|
||||||
|
.body{{flex:1 1 auto;min-height:0}}
|
||||||
|
|
||||||
|
.chamfer{{clip-path:polygon(10px 0,calc(100% - 10px) 0,100% 10px,100% calc(100% - 10px),
|
||||||
|
calc(100% - 10px) 100%,10px 100%,0 calc(100% - 10px),0 10px)}}
|
||||||
|
.grid2{{display:grid;grid-template-columns:1fr 1fr;gap:13px}}
|
||||||
|
.grid3{{display:grid;grid-template-columns:1fr 1fr 1fr;gap:13px}}
|
||||||
|
.card{{background:var(--paper2);padding:13px 15px}}
|
||||||
|
.card--line{{background:#fff;border:1px solid var(--line);padding:13px 15px}}
|
||||||
|
|
||||||
|
.reco{{background:var(--navy);color:#fff;padding:14px 18px;display:flex;align-items:center;gap:22px}}
|
||||||
|
.reco .lbl{{font-size:8.4px;font-weight:700;letter-spacing:.16em;text-transform:uppercase;color:var(--yellow);margin-bottom:4px}}
|
||||||
|
.reco .big{{font-size:19px;font-weight:800;letter-spacing:-.025em;line-height:1}}
|
||||||
|
.reco .sep{{width:1px;height:34px;background:rgba(255,255,255,.2)}}
|
||||||
|
|
||||||
|
.stat{{padding:12px 14px;background:var(--paper2)}}
|
||||||
|
.stat .k{{font-size:8.2px;font-weight:700;letter-spacing:.13em;text-transform:uppercase;color:var(--ink3);margin-bottom:6px}}
|
||||||
|
.stat .v{{font-size:27px;font-weight:800;letter-spacing:-.035em;line-height:1}}
|
||||||
|
.stat .v .u{{font-size:14px;font-weight:700}}
|
||||||
|
.stat .s{{font-size:9.4px;color:var(--ink3);margin-top:5px;line-height:1.3}}
|
||||||
|
.stat--acc{{background:var(--navy);color:#fff}}
|
||||||
|
.stat--acc .k{{color:#7E8CA0}}.stat--acc .v{{color:var(--yellow)}}.stat--acc .s{{color:#9AA6B6}}
|
||||||
|
|
||||||
|
table{{width:100%;border-collapse:collapse;font-size:11px}}
|
||||||
|
th{{text-align:left;font-size:8.4px;font-weight:800;letter-spacing:.11em;text-transform:uppercase;
|
||||||
|
color:var(--ink3);padding:0 8px 5px;border-bottom:1.5px solid var(--ink);vertical-align:bottom}}
|
||||||
|
td{{padding:6px 8px;border-bottom:1px solid var(--line-soft);vertical-align:top;line-height:1.35}}
|
||||||
|
tr:last-child td{{border-bottom:none}}
|
||||||
|
th:first-child,td:first-child{{padding-left:0}}
|
||||||
|
th:last-child,td:last-child{{padding-right:0}}
|
||||||
|
tr.hi td{{background:var(--yellow-soft)}}
|
||||||
|
tr.hi td:first-child{{box-shadow:inset 3px 0 0 var(--yellow);padding-left:9px}}
|
||||||
|
td.dt{{white-space:nowrap;color:var(--ink2)}}
|
||||||
|
td.fm{{white-space:nowrap;color:var(--ink2);font-size:10.4px}}
|
||||||
|
td.zl{{color:var(--ink2)}}
|
||||||
|
|
||||||
|
ul{{list-style:none}}
|
||||||
|
li{{position:relative;padding-left:14px;margin-bottom:5px;font-size:11px;line-height:1.42;color:var(--ink2)}}
|
||||||
|
li:last-child{{margin-bottom:0}}
|
||||||
|
li::before{{content:"";position:absolute;left:0;top:6px;width:5px;height:5px;background:var(--yellow)}}
|
||||||
|
li b{{color:var(--ink)}}
|
||||||
|
ul.num{{counter-reset:n}}
|
||||||
|
ul.num li{{padding-left:20px;counter-increment:n}}
|
||||||
|
ul.num li::before{{content:counter(n);background:none;color:var(--ink3);font-size:9.4px;
|
||||||
|
font-weight:800;top:0;left:0;width:auto;height:auto}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- SEITE 1 -->
|
||||||
|
<section class="page">
|
||||||
|
<div class="top">
|
||||||
|
<img src="data:image/svg+xml;base64,{LOGO}" style="height:17px" alt="HiveGrid">
|
||||||
|
<div class="eyebrow">Instagram · Redaktionsplan & Vorgehen · Stand 23.08.2026</div>
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<h1 style="margin-bottom:9px">Rahmen und Rhythmus</h1>
|
||||||
|
<p class="lead" style="margin-bottom:14px;max-width:660px">
|
||||||
|
In dieser Phase erwarten wir über den Kanal keine nennenswerte Reichweite. Er dient
|
||||||
|
zuerst der <strong style="color:var(--ink)">Darstellung der Software</strong> für Leute,
|
||||||
|
die ohnehin auf uns stoßen: mögliche Kunden, Partner, Investoren. Dieses Dokument legt
|
||||||
|
fest, was wann erscheint, wie es aussieht und was wir nach vier Wochen ansehen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="reco chamfer" style="margin-bottom:15px">
|
||||||
|
<div>
|
||||||
|
<div class="lbl">Rhythmus</div>
|
||||||
|
<div class="big">Mo · Mi · Fr</div>
|
||||||
|
</div>
|
||||||
|
<div class="sep"></div>
|
||||||
|
<div>
|
||||||
|
<div class="lbl" style="color:#7A879B">Uhrzeit</div>
|
||||||
|
<div class="big" style="color:var(--yellow)">12:00</div>
|
||||||
|
</div>
|
||||||
|
<div class="sep"></div>
|
||||||
|
<div style="flex:1">
|
||||||
|
<div class="lbl" style="color:#7A879B">Worauf es ankommt</div>
|
||||||
|
<div style="font-size:11px;color:#C8D0DC;line-height:1.4">
|
||||||
|
Auf dem Eindruck der ersten neun Kacheln. Die sieht jeder, der zum ersten Mal
|
||||||
|
auf dem Profil landet, bevor er einen einzelnen Beitrag öffnet.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid3" style="margin-bottom:16px">
|
||||||
|
<div class="stat chamfer">
|
||||||
|
<div class="k">Beiträge</div>
|
||||||
|
<div class="v">3<span class="u"> / Woche</span></div>
|
||||||
|
<div class="s">12 in vier Wochen. Mehr als fünf ginge auf die Qualität.</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat chamfer">
|
||||||
|
<div class="k">Format zuerst</div>
|
||||||
|
<div class="v">Carousel</div>
|
||||||
|
<div class="s">Trägt Erklärung und lässt sich im Generator wiederholbar bauen.</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat stat--acc chamfer">
|
||||||
|
<div class="k">Durchgängig</div>
|
||||||
|
<div class="v">Ein Raster</div>
|
||||||
|
<div class="s">Gleiche Schrift, gleicher Grund, gleiche Bildsprache über alle Beiträge.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style="margin-bottom:8px">Wofür der Kanal da ist</h2>
|
||||||
|
<div class="grid2" style="margin-bottom:15px">
|
||||||
|
<div class="card chamfer">
|
||||||
|
<h3 style="margin-bottom:5px">Zeigen statt behaupten</h3>
|
||||||
|
<p class="small">Jeder Beitrag zeigt etwas Konkretes aus der Software oder aus dem
|
||||||
|
Betrieb: einen Screen, einen Ablauf, eine Aufnahme von der Baustelle. Keine
|
||||||
|
Behauptungen ohne Bild dazu.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card chamfer">
|
||||||
|
<h3 style="margin-bottom:5px">Titelbilder tragen allein</h3>
|
||||||
|
<p class="small">Im Raster ist von einem Carousel nur die erste Slide zu sehen.
|
||||||
|
Die trägt deshalb einen vollständigen Gedanken, nicht den Anfang von einem. Der Rest
|
||||||
|
steht innen und in der Bildunterschrift.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style="margin-bottom:8px">Warum drei Tage und warum 12:00</h2>
|
||||||
|
<div class="grid3">
|
||||||
|
<div class="card chamfer">
|
||||||
|
<h3 style="margin-bottom:5px">Drei ist die untere Grenze</h3>
|
||||||
|
<p class="small">Unter drei Beiträgen pro Woche wirkt ein Profil inaktiv. Über
|
||||||
|
fünf leidet die Qualität.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card chamfer">
|
||||||
|
<h3 style="margin-bottom:5px">12:00 ist eine Annahme</h3>
|
||||||
|
<p class="small">Für handwerklich geprägte Betriebe sind drei Fenster belegt: 6 bis 8,
|
||||||
|
12 bis 13 und 19 bis 21 Uhr. Die Mittagspause passt am besten.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card chamfer">
|
||||||
|
<h3 style="margin-bottom:5px">Montag ist der schwächste</h3>
|
||||||
|
<p class="small">Die Studienlage nennt Dienstag bis Donnerstag. Wir starten trotzdem
|
||||||
|
mit Mo, Mi, Fr und schauen es uns in vier Wochen an.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="foot"><span>HiveGrid · Social Media</span><span>Seite 1 von 4</span></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SEITE 2: REDAKTIONSPLAN -->
|
||||||
|
<section class="page">
|
||||||
|
<div class="top">
|
||||||
|
<img src="data:image/svg+xml;base64,{LOGO}" style="height:17px" alt="HiveGrid">
|
||||||
|
<div class="eyebrow">Redaktionsplan · {SPANNE}</div>
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<h1 style="margin-bottom:9px">Was wann erscheint</h1>
|
||||||
|
<p class="lead" style="margin-bottom:13px;max-width:660px">
|
||||||
|
Fünfzehn Termine, alle um 12:00 Uhr. Die Reihenfolge ist nicht beliebig: erst warum
|
||||||
|
es uns gibt, dann die Haltung, dann die Funktionen. Innerhalb der Funktionen von der
|
||||||
|
täglichen Last nach hinten zu dem, was Vorwissen braucht. {ZAHLWORT} gesetzt und
|
||||||
|
liegen fertig im Generator, alles danach darf sich verschieben.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table style="margin-bottom:14px">
|
||||||
|
<thead><tr>
|
||||||
|
<th style="width:76px">Termin</th>
|
||||||
|
<th style="width:186px">Thema</th>
|
||||||
|
<th style="width:92px">Format</th>
|
||||||
|
<th>Wofür der Beitrag da ist</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>{ZEILEN}</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2 style="margin-bottom:8px">Sechs Sorten im Wechsel</h2>
|
||||||
|
<table style="margin-bottom:15px">
|
||||||
|
<thead><tr>
|
||||||
|
<th style="width:150px">Sorte</th>
|
||||||
|
<th style="width:64px" class="t-num">Anteil</th>
|
||||||
|
<th>Beispiel aus dem Plan</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><b>Funktion erklärt</b></td><td class="t-num">8</td>
|
||||||
|
<td>Workflow-Engine, Form Builder, Team-Chat</td></tr>
|
||||||
|
<tr><td><b>Menschen und Betrieb</b></td><td class="t-num">2</td>
|
||||||
|
<td>Die Geschichte, Der Betrieb dahinter</td></tr>
|
||||||
|
<tr><td><b>Haltung</b></td><td class="t-num">1</td>
|
||||||
|
<td>Verbinden statt ersetzen</td></tr>
|
||||||
|
<tr><td><b>Einwand beantwortet</b></td><td class="t-num">2</td>
|
||||||
|
<td>Dein ERP bleibt, Häufige Fragen</td></tr>
|
||||||
|
<tr><td><b>Wissen ohne Produkt</b></td><td class="t-num">1</td>
|
||||||
|
<td>Zettelwirtschaft</td></tr>
|
||||||
|
<tr><td><b>Aufruf</b></td><td class="t-num">1</td>
|
||||||
|
<td>Warteliste</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2 style="margin-bottom:8px">Was unter jedem Beitrag steht</h2>
|
||||||
|
<div class="grid3">
|
||||||
|
<div class="card chamfer">
|
||||||
|
<h3 style="margin-bottom:5px">Die ersten 125 Zeichen</h3>
|
||||||
|
<p class="small">Mehr sieht niemand, bevor er auf "mehr" tippt. Der Haken gehört
|
||||||
|
dorthin, nicht in den letzten Absatz.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card chamfer">
|
||||||
|
<h3 style="margin-bottom:5px">300 bis 800 Zeichen</h3>
|
||||||
|
<p class="small">Der Bereich, in dem erklärende Beiträge am besten laufen. Darunter
|
||||||
|
wirkt es dünn, darüber liest es niemand zu Ende.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card chamfer">
|
||||||
|
<h3 style="margin-bottom:5px">5 bis 10 Hashtags</h3>
|
||||||
|
<p class="small">Ganz unten, durch Leerzeilen abgesetzt. Schlägt sowohl drei als auch
|
||||||
|
dreißig. Die Suchwörter gehören zusätzlich in den Fließtext.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="foot"><span>HiveGrid · Social Media</span><span>Seite 2 von 4</span></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SEITE 3: BILDSPRACHE -->
|
||||||
|
<section class="page">
|
||||||
|
<div class="top">
|
||||||
|
<img src="data:image/svg+xml;base64,{LOGO}" style="height:17px" alt="HiveGrid">
|
||||||
|
<div class="eyebrow">Bildsprache & Format</div>
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<h1 style="margin-bottom:9px">Bildsprache</h1>
|
||||||
|
<p class="lead" style="margin-bottom:14px;max-width:660px">
|
||||||
|
Das Profil zeigt neun Kacheln auf einmal. Was gleich bleibt und was wechselt, ist
|
||||||
|
deshalb festgelegt und nicht von Beitrag zu Beitrag neu entschieden.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid2" style="margin-bottom:16px">
|
||||||
|
<div class="card chamfer">
|
||||||
|
<h3 style="margin-bottom:6px">Was gleich bleibt</h3>
|
||||||
|
<ul>
|
||||||
|
<li><b>1080 × 1350 px</b>, das hochkantigste Feed-Format</li>
|
||||||
|
<li><b>Manrope</b>, dieselbe Schrift wie auf der Website</li>
|
||||||
|
<li><b>Ein Grund</b>, das Raster, sonst nichts</li>
|
||||||
|
<li><b>Ein Akzentwort</b> je Überschrift, per Marker</li>
|
||||||
|
<li><b>Chamfer</b> an Rahmen und Tags, die Markenform</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="card chamfer">
|
||||||
|
<h3 style="margin-bottom:6px">Was von Beitrag zu Beitrag wechselt</h3>
|
||||||
|
<ul>
|
||||||
|
<li><b>Hell und dunkel</b> im Wechsel, damit das Raster atmet</li>
|
||||||
|
<li><b>Screens oder Visualisierung</b>, je nach Thema</li>
|
||||||
|
<li><b>Die Anzahl der Slides</b>, fünf bis sieben</li>
|
||||||
|
</ul>
|
||||||
|
<p class="micro" style="margin-top:7px">Im Profil-Raster wird auf 3:4 beschnitten,
|
||||||
|
nicht auf 4:5. Was mittig sitzt, überlebt.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style="margin-bottom:8px">Statt behaupten, zeigen</h2>
|
||||||
|
<table style="margin-bottom:16px">
|
||||||
|
<thead><tr>
|
||||||
|
<th style="width:210px">Statt</th>
|
||||||
|
<th>Zeigen wir</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>"Alles auf einen Blick"</td>
|
||||||
|
<td>Das Board mit echten Aufträgen, Summen und Terminen.</td></tr>
|
||||||
|
<tr><td>"Spart Zeit"</td>
|
||||||
|
<td>Den Ablauf: Anfrage rein, Aufgabe raus, Bestätigung an den Kunden.</td></tr>
|
||||||
|
<tr><td>"Einfach zu bedienen"</td>
|
||||||
|
<td>Das Formular, das per Drag-and-drop entsteht.</td></tr>
|
||||||
|
<tr><td>"Aus dem Handwerk"</td>
|
||||||
|
<td>Den Betrieb, die Baustelle, die Menschen dahinter.</td></tr>
|
||||||
|
<tr><td>"Verbindet deine Tools"</td>
|
||||||
|
<td>Die Visualisierung: ERP in der Mitte, das Neue drumherum.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2 style="margin-bottom:8px">Carousel jetzt, Reels später</h2>
|
||||||
|
<div class="card--line chamfer">
|
||||||
|
<p class="small" style="margin-bottom:6px">Für Konten unter 50.000 Followern bringen
|
||||||
|
Reels mehr Reichweite als Carousels. Das ist die Datenlage. Für uns ist sie zweitrangig,
|
||||||
|
weil Reichweite in dieser Phase nicht das Ziel ist.</p>
|
||||||
|
<p class="small">Carousels passen besser zu dem, was der Kanal leisten soll: Sie
|
||||||
|
erklären, sie lassen sich im Generator sauber und wiederholbar bauen, und sie werden
|
||||||
|
gespeichert. Das erste Reel steht am {REEL} und ist eine Bildschirmaufnahme, dafür
|
||||||
|
braucht es keine Kamera. Danach kommt Material aus dem Betrieb dazu. Aus drei Beiträgen
|
||||||
|
werden dann zwei Carousels und ein Reel, nicht vier Beiträge.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="foot"><span>HiveGrid · Social Media</span><span>Seite 3 von 4</span></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SEITE 4: INTERAKTION -->
|
||||||
|
<section class="page">
|
||||||
|
<div class="top">
|
||||||
|
<img src="data:image/svg+xml;base64,{LOGO}" style="height:17px" alt="HiveGrid">
|
||||||
|
<div class="eyebrow">Tägliche Arbeit & Auswertung</div>
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<h1 style="margin-bottom:9px">Tägliche Interaktion</h1>
|
||||||
|
<p class="lead" style="margin-bottom:14px;max-width:660px">
|
||||||
|
Instagram sortiert ein Konto auch danach ein, womit es sich selbst beschäftigt.
|
||||||
|
Rund zehn Minuten am Tag, aufgeteilt auf die drei Posting-Tage.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid2" style="margin-bottom:16px">
|
||||||
|
<div class="card chamfer">
|
||||||
|
<h3 style="margin-bottom:6px">Die Routine pro Beitrag</h3>
|
||||||
|
<ul>
|
||||||
|
<li><b>5 Beiträge liken</b> aus dem eigenen Umfeld</li>
|
||||||
|
<li><b>3 echte Kommentare</b> schreiben, keine Emojis</li>
|
||||||
|
<li><b>1 Konto folgen</b>, das wirklich passt</li>
|
||||||
|
</ul>
|
||||||
|
<p class="micro" style="margin-top:7px">Zehn gute Kommentare bei den richtigen Konten
|
||||||
|
wirken mehr als fünfzig verstreute. In den Zahlen sichtbar wird das nach vier bis
|
||||||
|
acht Wochen.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card chamfer">
|
||||||
|
<h3 style="margin-bottom:6px">Wem wir folgen</h3>
|
||||||
|
<ul>
|
||||||
|
<li><b>SHK-, Elektro- und Dachbetriebe</b>, die selbst posten</li>
|
||||||
|
<li><b>Innungen, Kreishandwerkerschaften, Kammern</b></li>
|
||||||
|
<li><b>Handwerksmessen und Fachmedien</b></li>
|
||||||
|
<li><b>Hersteller</b>, deren Produkte im Betrieb verbaut werden</li>
|
||||||
|
<li><b>Andere Handwerkssoftware</b>, deren Kommentarspalten voll sind</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card--line chamfer" style="margin-bottom:16px">
|
||||||
|
<h3 style="margin-bottom:6px">Wie ein Kommentar aussieht</h3>
|
||||||
|
<p class="small" style="margin-bottom:6px">Am meisten bringt ein Kommentar in den ersten
|
||||||
|
Minuten nach einem Beitrag. Deshalb folgen wir gezielt Konten, die regelmäßig posten,
|
||||||
|
und schauen einmal täglich zur Mittagszeit rein.</p>
|
||||||
|
<p class="small">Ein Kommentar ist gut, wenn er ohne unseren Namen funktioniert. Also
|
||||||
|
eine Erfahrung, eine Nachfrage, ein Widerspruch. Nicht "Toller Beitrag", und nichts,
|
||||||
|
was auf uns verweist.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style="margin-bottom:8px">Was wir nach vier Wochen ansehen</h2>
|
||||||
|
<table style="margin-bottom:14px">
|
||||||
|
<thead><tr>
|
||||||
|
<th style="width:150px">Kennzahl</th>
|
||||||
|
<th style="width:150px">Wo sie steht</th>
|
||||||
|
<th>Was sie uns sagt</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="hi"><td><b>Profilaufrufe je Beitrag</b></td><td>Insights, Interaktionen</td>
|
||||||
|
<td>Ob ein Beitrag jemanden dazu bringt, sich das Profil anzusehen. Das ist unser Zweck.</td></tr>
|
||||||
|
<tr><td><b>Sendungen</b></td><td>Beitragsinsights</td>
|
||||||
|
<td>Welches Thema jemand weiterschickt. Wiegt beim Ausspielen mehr als ein Like.</td></tr>
|
||||||
|
<tr><td><b>Speicherungen</b></td><td>Beitragsinsights</td>
|
||||||
|
<td>Ob ein Carousel als Nachschlagewerk taugt.</td></tr>
|
||||||
|
<tr><td><b>Konten erreicht,<br>davon keine Follower</b></td><td>Insights, Reichweite</td>
|
||||||
|
<td>Ob wir aus der eigenen Blase herauskommen.</td></tr>
|
||||||
|
<tr><td><b>Aktivste Zeiten</b></td><td>Insights, Zielgruppe</td>
|
||||||
|
<td>Ob 12:00 stimmt. Erst ab etwa 100 Followern belastbar.</td></tr>
|
||||||
|
<tr><td><b>Anmeldungen Warteliste</b></td><td>Backend</td>
|
||||||
|
<td>Ob aus dem Eindruck ein Kontakt wird.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="card chamfer">
|
||||||
|
<h3 style="margin-bottom:5px">Nach vier Wochen</h3>
|
||||||
|
<p class="small">Bleibt der Montag oder wird es Dienstag? Bleibt 12:00? Und wie viele
|
||||||
|
Reels ersetzen ab dann Carousels? Bis dahin lassen wir den Rhythmus laufen, sonst
|
||||||
|
lässt sich nichts vergleichen.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="micro" style="margin-top:12px">Quellen zu den Ausspielungssignalen und
|
||||||
|
Posting-Zeiten: Sprout Social, Buffer, Later und Hootsuite (Instagram-Algorithmus 2026),
|
||||||
|
adconsulting.de und socialmediaakademie.de (Posting-Zeiten B2B und Handwerk, Deutschland).
|
||||||
|
Zahlen zu Sendungen und Reichweite stammen aus Auswertungen Dritter, nicht von Meta selbst,
|
||||||
|
und sind Größenordnungen, keine Garantien.</p>
|
||||||
|
</div>
|
||||||
|
<div class="foot"><span>HiveGrid · Social Media</span><span>Seite 4 von 4</span></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
ZIEL_HTML = HIER / "Instagram-Redaktionsplan.html"
|
||||||
|
ZIEL_PDF = HIER / "Instagram-Redaktionsplan.pdf"
|
||||||
|
ZIEL_HTML.write_text(HTML, encoding="utf-8")
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
[CHROME, "--headless", "--disable-gpu", "--no-pdf-header-footer",
|
||||||
|
f"--print-to-pdf={ZIEL_PDF}", ZIEL_HTML.as_uri()],
|
||||||
|
check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
print(f"{ZIEL_PDF.name} {ZIEL_PDF.stat().st_size / 1024:.0f} KB")
|
||||||
@@ -1,15 +1,76 @@
|
|||||||
# 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:** 31.07.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
|
||||||
|
> der Software, mit den neuen Komponenten. Neue Posts bauen auf ihrem Look auf,
|
||||||
|
> nicht mehr auf der Website. Bei Zweifel dort nachschauen:
|
||||||
|
>
|
||||||
|
> | Was | Quelle in `hivegrid-cloud/` |
|
||||||
|
> |---|---|
|
||||||
|
> | Farben, Schriftgrößen, Radien, Schatten | `apps/web/app/ui/theme.css` |
|
||||||
|
> | Komponenten (109, aus Subframe) | `apps/web/app/ui/components/` |
|
||||||
|
> | Subframe-Projekt | `f023064a5ca0`, siehe `.subframe/sync.json` |
|
||||||
|
> | Schrift-Dateien | `apps/web/public/fonts/instrument-sans-*.woff2` |
|
||||||
|
> | Module und Screens | `apps/web/app/modules/` |
|
||||||
|
> | Logo | `apps/web/app/ui/components/Logo/` |
|
||||||
|
>
|
||||||
|
> Die wichtigsten Werte aus `theme.css`:
|
||||||
|
>
|
||||||
|
> | Was | Wert |
|
||||||
|
> |---|---|
|
||||||
|
> | 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 zweitrangig | `#505057`, drittrangig `#6D6D71` |
|
||||||
|
> | Markengelb | `#F1B729` (brand-600), unverändert |
|
||||||
|
> | Zweitfarbe | Sky `#0791F0` (brand-secondary-500) |
|
||||||
|
> | Flächen | `#FAFAFA` und `#F5F4F4` |
|
||||||
|
> | Rahmen | `#E5E5E5` (neutral-200) |
|
||||||
|
> | Radien | 4, 8, 12 px |
|
||||||
|
> | Größen | Hero 72/72, Seite 48/56, Untertitel 32/40, alle 600 |
|
||||||
|
> | Symbole | Feather über `@subframe/core`: in Oberflächen als Lucide-Formen (§0), Phosphor nur noch in älteren Posts |
|
||||||
|
>
|
||||||
|
> Entschieden am 20.10.2026: Oberflächen nehmen Rahmen `#E5E5E5` und
|
||||||
|
> Feather/Lucide aus dem Produkt (§0). Offen bleiben die Schatten
|
||||||
|
> (`--shadow-md`, `--shadow-lg`) gegen §5 „Keine Schatten“: In den Zeichnungen
|
||||||
|
> 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**:
|
||||||
|
|
||||||
@@ -20,6 +81,7 @@ Alle Marken-Werte stammen aus der Website, nicht aus diesem Repo. Bei Zweifel
|
|||||||
| Schrift | `HiveGrid-Website/app/layout.tsx` |
|
| Schrift | `HiveGrid-Website/app/layout.tsx` |
|
||||||
| Badge-Styling | `HiveGrid-Website/components/sections/ScreenshotAccordion.tsx` |
|
| Badge-Styling | `HiveGrid-Website/components/sections/ScreenshotAccordion.tsx` |
|
||||||
| Feature-Texte | `HiveGrid-Website/app/playground/PlaygroundClient.tsx` → `solutionModules` |
|
| Feature-Texte | `HiveGrid-Website/app/playground/PlaygroundClient.tsx` → `solutionModules` |
|
||||||
|
| Wabenfeld: Geometrie, Zellnummern, Belegung | `HiveGrid-Website/components/landing/HoneycombSection.tsx` |
|
||||||
|
|
||||||
Die Werte sind hier gespiegelt, weil der Export keine Tailwind-/oklch-Farben
|
Die Werte sind hier gespiegelt, weil der Export keine Tailwind-/oklch-Farben
|
||||||
verträgt (siehe §7). Ändert sich die Website, müssen sie **hier nachgezogen
|
verträgt (siehe §7). Ändert sich die Website, müssen sie **hier nachgezogen
|
||||||
@@ -27,6 +89,120 @@ werden** — das passiert nicht automatisch.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 1a. Themes
|
||||||
|
|
||||||
|
**Projekt und Theme sind zwei verschiedene Dinge.** Das Projekt sagt, *welcher*
|
||||||
|
Beitrag; das Theme sagt, *wie er aussieht*. Beide Themes teilen Aufbau, Texte
|
||||||
|
und Kompositionen der Visualisierungen.
|
||||||
|
|
||||||
|
> **Der Grund ist über alle Beiträge hinweg derselbe: `--surface` `#FFFFFF`.**
|
||||||
|
> Kein Theme darf ihn ändern. Im Profilraster stehen die Beiträge nebeneinander,
|
||||||
|
> und schon ein leicht getönter Grund fällt dort als Fehler auf. Ein Theme, das
|
||||||
|
> mehr Kontrast für seine Karten braucht, holt ihn sich über die Kante
|
||||||
|
> (`--border-neutral` `#D5DBE1`), nicht über den Grund.
|
||||||
|
|
||||||
|
| Theme | Erkennungszeichen | Datei |
|
||||||
|
|---|---|---|
|
||||||
|
| `notebook` | Rasterhintergrund, Zeichenblock-Optik, Marker in der Überschrift, handgezogene Formen | `post2.html` |
|
||||||
|
| `modern` | Ruhiger Grund ohne Textur, sehr große Überschrift mit einem farbigen Wort, schwebende Karten mit Haarlinie, präzise Formen | `post2-alt.html`, `post3.html` |
|
||||||
|
|
||||||
|
Jede Datei trägt oben eine Konstante `THEME`. Sie steuert nichts, sie benennt
|
||||||
|
nur — die Unterschiede stecken in den CSS-Werten und in den Zeichen-Primitiven.
|
||||||
|
|
||||||
|
**Was beide Themes trennt, ist genau festgelegt:**
|
||||||
|
|
||||||
|
| | notebook | modern |
|
||||||
|
|---|---|---|
|
||||||
|
| Grund | Raster, 78 px | weiß, keine Textur |
|
||||||
|
| Über alle Slides | — | ein durchgehendes Band |
|
||||||
|
| Akzent in der Überschrift | Marker-Fläche | farbiges Wort |
|
||||||
|
| Formen | `strich` schwingt, Ecken schießen über | exakt, keine Abweichung |
|
||||||
|
| Flächen | Schraffur | Füllung |
|
||||||
|
| Zweitfarbe | keine | Teal `#0D6E6E` |
|
||||||
|
| Zeichen | selbst gezeichnet | Feather aus `@subframe/core` |
|
||||||
|
|
||||||
|
**Karten stehen weiß auf weiß und tragen über Kante und Schatten.** Kante
|
||||||
|
`#D5DBE1`, nicht `#E5E5E5` — die hellere Linie verschwindet auf Weiß. Den
|
||||||
|
Grund zu tönen wäre der einfachere Weg und ist verboten, siehe oben. Der
|
||||||
|
Schatten ist die Ausnahme von §5, die dieses Theme sich nimmt; dort steht,
|
||||||
|
warum und mit welchen Werten.
|
||||||
|
|
||||||
|
**Das Band ist ein einziges Bild über den ganzen Streifen.** Jede Slide zeigt
|
||||||
|
ihren Ausschnitt, verschoben über `--slide-i`. An der Naht läuft es deshalb
|
||||||
|
weiter, statt neu anzusetzen — das ist der Übergang, den ein Carousel sonst
|
||||||
|
nicht hat, weil jede Slide für sich steht. Drei Punkte, die daran hängen:
|
||||||
|
|
||||||
|
- **Die Wellenlänge darf kein Vielfaches von 1080 sein.** Sonst liegt an jeder
|
||||||
|
Naht derselbe Punkt der Welle, meist ein Scheitel, und ein waagerechtes Band
|
||||||
|
an der Kante sagt dem Auge nichts.
|
||||||
|
- **Flach und deckend, wie die Formen bei Slack.** Eine kräftige Fläche,
|
||||||
|
dahinter eine blasse zweite als Tiefe. Kein Verlauf, keine Wölbung, keine
|
||||||
|
Glanzkante. Ein Zwischenstand hatte hier einen metallisch schattierten
|
||||||
|
Zylinder aus vierzig Streifen — fotorealistisch und damit ein Fremdkörper
|
||||||
|
auf einem flachen, ruhigen Blatt. Er steht im Commit *„Theme modern:
|
||||||
|
Metallband, Icon-Satz und echte G-Marke"*, falls ihn jemand wiederhaben will.
|
||||||
|
- **Zwei Töne, umschaltbar** unter *Band* im Bedienfeld (`BAND_TOENE`):
|
||||||
|
|
||||||
|
| Ton | Fläche | Blasse zweite |
|
||||||
|
|---|---|---|
|
||||||
|
| `orange` | `--accent-yellow` `#F1B729` | `#FCE7B4` |
|
||||||
|
| `teal` | `--dark-bg` `#30313D` | `#D3D5DE` |
|
||||||
|
|
||||||
|
> **„HiveGrid Teal" ist `--dark-bg` `#30313D`, nicht `--accent-teal`.** Im
|
||||||
|
> Haus heißt der dunkle Ton des Story-Abschnitts auf der Website so. Er ist
|
||||||
|
> ein blaustichiges Anthrazit; `--accent-teal` `#0D6E6E` ist ein anderes
|
||||||
|
> Grün und trägt im Theme `modern` die Zeichenkacheln. Beim Reden über
|
||||||
|
> Farben lohnt die Nachfrage, welches gemeint ist.
|
||||||
|
- **Der Umschalter greift an `.bg`, nicht über `applyAll`.** Das Band setzt
|
||||||
|
`buildSlide`, nicht der Neuaufbau der Seiten; der Handler tauscht deshalb
|
||||||
|
`.bg .layer-band` direkt aus.
|
||||||
|
- **Die Bahn steht als Stützpunkte da, nicht als Formel** (`BAND_BAHN`, einer
|
||||||
|
je halbe Slide). Eine Sinuskurve war kürzer, ließ sich aber nicht steuern:
|
||||||
|
auf Slide 5 lief sie quer durch die Kacheln, und dort verschwinden die
|
||||||
|
orangenen Zeichen. Jeder Wert ist am gerenderten Bild geprüft, nicht
|
||||||
|
gerechnet. Wer `BAND_VON` oder `BAND_BIS` ändert, muss die Liste mitziehen:
|
||||||
|
die Punkte verteilen sich gleichmäßig über die neue Strecke, und alle
|
||||||
|
Werte verschieben sich.
|
||||||
|
- **Wo das Band läuft, steht in `BAND_VON` / `BAND_BIS`** — Anzahl Slides ohne
|
||||||
|
Band am Anfang und am Ende. Bei Post 2 sind das 2 und 1, das Band trägt also
|
||||||
|
Slide 3 bis 5. Slide 1 und die letzte sollen ruhig sein, Slide 2 trägt ihre
|
||||||
|
eigene orangene Leitung und würde sich damit beißen.
|
||||||
|
- **Als gefüllte Fläche gezeichnet, nicht als dicke Linie.** Eine Linie bekommt
|
||||||
|
am Ende eine Kappe rechtwinklig zur Laufrichtung; bei schräger Bahn steht die
|
||||||
|
seitlich über die Naht hinaus, und auf der Nachbarslide bleibt ein Splitter
|
||||||
|
stehen. Eine geschlossene Kontur endet exakt senkrecht — dann genügen zwei
|
||||||
|
Pixel Abstand zur Naht.
|
||||||
|
|
||||||
|
**Was über dem Band liegt, ist eine Karte mit Schatten.** Nicht randlos: der
|
||||||
|
Versuch mit reinen Konturen sah gestochen aus. Kleine Karten über einem Band
|
||||||
|
sind die Vorlage; eine randlose Karte über die ganze Fläche war der Fehler
|
||||||
|
davor, weil das Band dahinter komplett verschwand.
|
||||||
|
|
||||||
|
> **Eine Ausnahme bleibt: Slide 3.** Deren Karte *ist* die Visualisierung, ein
|
||||||
|
> Oberflächen-Panel mit Fortschrittsbalken und Statuszeilen. Die kann nicht
|
||||||
|
> randlos sein, also zeigt sich das Band dort nur in den Seitenrändern.
|
||||||
|
|
||||||
|
**Die Zeichen kommen aus dem Icon-Satz der Software, nicht aus der Hand.**
|
||||||
|
`@subframe/core`, dieselben Feather-Icons, mit denen die Oberfläche gebaut ist
|
||||||
|
(im Pitch-Paket unter `node_modules/@subframe/core/…/icons/final/`). Sie liegen
|
||||||
|
als `FEATHER`-Tabelle in der Datei, gezogen mit dem Skript aus dem
|
||||||
|
Gesprächsverlauf. Zwei Fallen beim Ziehen: die Props stehen teils als Zahl ohne
|
||||||
|
Anführungszeichen (`x: 3`), und bei Icons mit nur einem Kind muss der
|
||||||
|
`<svg>`-Rahmen weg, sonst rendert nichts.
|
||||||
|
|
||||||
|
Ausnahme ist Slide 6 in `post2-alt.html`: deren drei Zeichen sind Nachbauten
|
||||||
|
aus `WaitlistNext.tsx` und bleiben es.
|
||||||
|
|
||||||
|
**Teal ist die Zweitfarbe des Themes `modern`.** In `notebook` kommt sie nicht
|
||||||
|
vor. Damit hat der bisher ungenutzte Marken-Ton eine Aufgabe: er trägt
|
||||||
|
Zeichen und Kacheln, während Orange den einen Akzent setzt.
|
||||||
|
|
||||||
|
**Eine Änderung an einer Visualisierung betrifft beide Dateien.** Das ist der
|
||||||
|
Preis dafür, dass die Themes echte Alternativen sind und nicht Varianten
|
||||||
|
derselben Datei.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2. Farben
|
## 2. Farben
|
||||||
|
|
||||||
### Marke
|
### Marke
|
||||||
@@ -36,7 +212,7 @@ werden** — das passiert nicht automatisch.
|
|||||||
| `--accent-yellow` | `#F1B729` | Primärer Akzent, Marker, G-Marke |
|
| `--accent-yellow` | `#F1B729` | Primärer Akzent, Marker, G-Marke |
|
||||||
| `--accent-yellow-light` | `#FFE082` | Badge-Hintergrund |
|
| `--accent-yellow-light` | `#FFE082` | Badge-Hintergrund |
|
||||||
| `--accent-yellow-dark` | `#3E2C0A` | Badge-Text |
|
| `--accent-yellow-dark` | `#3E2C0A` | Badge-Text |
|
||||||
| `--accent-teal` | `#0D6E6E` | Sekundär, aktuell ungenutzt |
|
| `--accent-teal` | `#0D6E6E` | Sekundär; im Theme `modern` die Zeichenkacheln |
|
||||||
| `--dark-bg` | `#30313D` | Dunkle Variante + Formen-Ebene |
|
| `--dark-bg` | `#30313D` | Dunkle Variante + Formen-Ebene |
|
||||||
|
|
||||||
### Flächen & Text
|
### Flächen & Text
|
||||||
@@ -141,6 +317,38 @@ Spalten um ¾ der Breite, benachbarte Spalten sitzen eine halbe Reihe versetzt.
|
|||||||
Eine Kachel = zwei Spalten breit, eine Reihe hoch. **Die über den Kachelrand
|
Eine Kachel = zwei Spalten breit, eine Reihe hoch. **Die über den Kachelrand
|
||||||
hinausragenden Kopien sind Pflicht** — ohne sie sieht man die Nähte.
|
hinausragenden Kopien sind Pflicht** — ohne sie sieht man die Nähte.
|
||||||
|
|
||||||
|
### Wabenfeld
|
||||||
|
|
||||||
|
Nicht zu verwechseln mit dem Wabenraster: das ist die Textur im Hintergrund,
|
||||||
|
das Wabenfeld ist die **Grafik von der Startseite** — die Marke als Nabe, die
|
||||||
|
fremden Programme als Waben darum, verbunden über Stränge. Sie steht in
|
||||||
|
`post3.html` als `visWabenfeld()` und ist aus `HoneycombSection.tsx`
|
||||||
|
übernommen, nicht nachempfunden:
|
||||||
|
|
||||||
|
| | Wert | Warum genau der |
|
||||||
|
|---|---|---|
|
||||||
|
| Umkreisradius | 62 | alles andere leitet sich davon ab |
|
||||||
|
| Flächenfaktor | 0,84 | lässt die Fuge zwischen zwei Waben offen |
|
||||||
|
| Achsrechnung | `x = 1,5·R·q`, `y = √3·R·(r + q/2)` | flat-top |
|
||||||
|
| Zellnummern | 1 Nabe, 2–7 innerer Ring, 8–19 Deko-Ring, dann das Feld | die Belegung hängt daran |
|
||||||
|
| Marken-Größe | sichtbar 56,5 px | Claude und OpenAI sind die Eichmarken |
|
||||||
|
|
||||||
|
**Die Zellnummern sind die Schnittstelle.** Welche Marke wo sitzt, steht auf
|
||||||
|
der Website in `HONEYCOMB_DEFAULT_LOGO_PLACEMENTS` und hier in `WABE_MARKEN`,
|
||||||
|
beide über dieselben Nummern. Wer den Ringgang in `wabeRing()` umstellt,
|
||||||
|
verschiebt damit stillschweigend jede Marke.
|
||||||
|
|
||||||
|
**Eine Marke wird über zwei Zahlen eingepasst**, nicht über eine: die
|
||||||
|
Grundfläche ihres Renderpfads (Feld 59,52 px, Deko 50,44 px) mal ihre eigene
|
||||||
|
Skalierung. Das Verhältnis 1,18 zwischen beiden Pfaden stammt aus der Website
|
||||||
|
und ist der Grund, warum dieselbe sichtbare Größe zwei verschiedene Faktoren
|
||||||
|
braucht. Für eine neue Marke gilt: `gr = Grundfläche / Anteil, den die Datei in
|
||||||
|
ihrer eigenen Fläche ausfüllt` — eine Datei mit Luft ringsum braucht mehr.
|
||||||
|
|
||||||
|
**Die vier Waben „In Arbeit" gehören nicht in einen Beitrag.** Auf der
|
||||||
|
Startseite sind sie ein Versprechen, im Feed wären sie ein Eingeständnis. Es
|
||||||
|
sind die Zellen 29, 41, 46 und 59, und sie bleiben gewöhnliche leere Waben.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Typografie
|
## 4. Typografie
|
||||||
@@ -200,6 +408,56 @@ schon zweimal zu viel.
|
|||||||
- **Keine Em-Dashes (—)** in sichtbaren Texten. Komma, Punkt oder `·`
|
- **Keine Em-Dashes (—)** in sichtbaren Texten. Komma, Punkt oder `·`
|
||||||
- Feature-Texte wortgleich von der Website. Headlines dürfen gekürzt und
|
- Feature-Texte wortgleich von der Website. Headlines dürfen gekürzt und
|
||||||
umgestellt werden, aber **keine neuen Wörter erfinden**
|
umgestellt werden, aber **keine neuen Wörter erfinden**
|
||||||
|
- **Umlaute sind Umlaute.** Kein `Sanitaer`, kein `Aufmass`. Das gilt auch
|
||||||
|
dann, wenn die Quelle es so schreibt: eine ASCII-Ersatzschreibung im
|
||||||
|
Klickdummy ist ein Tippfehler und kein Produktbegriff, den man zitiert
|
||||||
|
|
||||||
|
#### Der Export prüft das
|
||||||
|
|
||||||
|
Aufgefallen ist es an „Sanitaer“ und „Klima / Lueftung“ auf einer fertigen
|
||||||
|
Slide — beim Bauen hatte es niemand gesehen, weil beides wörtlich aus der
|
||||||
|
Quelle kam. Seitdem prüft es die Maschine, an zwei Stellen:
|
||||||
|
|
||||||
|
| Weg | Wo |
|
||||||
|
|---|---|
|
||||||
|
| Bildexport | `renderSlide()` bricht ab, bevor ein PNG entsteht |
|
||||||
|
| Bewegtbild | `animation.mjs` prüft an 24 Stellen über den Ablauf |
|
||||||
|
|
||||||
|
Geprüft wird beides: fehlende Umlaute **und** Em-Dashes. Die Em-Dash-Regel
|
||||||
|
stand von Anfang an hier und wurde in Post 8 trotzdem gebrochen — eine Regel,
|
||||||
|
die nur im Dokument steht, hält nur so lange, wie jemand daran denkt.
|
||||||
|
|
||||||
|
**Bei den Umlauten genügt ein reines Muster nicht**, aber fast. Ein
|
||||||
|
ersatzweise geschriebenes `ae`/`oe`/`ue` steht nie hinter einem Vokal: „für“,
|
||||||
|
„über“, „grün“, „Sanitär“, „Lüftung“ folgen alle auf einen Konsonanten oder
|
||||||
|
stehen am Wortanfang. Hinter einem Vokal sind es dagegen immer zwei eigene
|
||||||
|
Laute — bauen, Bauer, neue, Dauer, Steuer, Mauer, genauer. Das Muster prüft
|
||||||
|
deshalb `(^|[^aeiou])(ae|oe|ue)`, und die Ausnahmeliste `UMLAUT_ERLAUBT` ist
|
||||||
|
auf die echten Sonderfälle geschrumpft: „Michael“ (ae hinter h), „aktuell“
|
||||||
|
(ue hinter t). Vorher standen dort sechs Wörter nach demselben Muster, und
|
||||||
|
die Liste wäre mit jedem Beitrag weitergewachsen.
|
||||||
|
|
||||||
|
Bei `ss` ist es umgekehrt: fast immer richtig (dass, Schluss, Netzanschluss),
|
||||||
|
nur wenige Wörter brauchen ein scharfes s. Die stehen einzeln in
|
||||||
|
`BRAUCHT_SCHARFES_S`.
|
||||||
|
|
||||||
|
**Mailadressen und Domains sind ausgenommen.** In `buero@muster-shk.de` kann
|
||||||
|
gar kein ü stehen; die umlautfreie Schreibung ist dort keine Schludrigkeit,
|
||||||
|
sondern Pflicht.
|
||||||
|
|
||||||
|
**Ein Zeilenumbruch ist eine Wortgrenze.** Die Prüfung liest nicht
|
||||||
|
`textContent`, sondern die einzelnen Textknoten mit Leerzeichen dazwischen.
|
||||||
|
Sonst klebt eine Überschrift wie „Formulare selbst bauen.`<br>`Per
|
||||||
|
Drag-and-drop.“ zu `bauen.Per` zusammen — ein Wort, das es nicht gibt, das
|
||||||
|
aber `ue` enthält und die Prüfung auslöst.
|
||||||
|
|
||||||
|
Schlägt die Prüfung bei einem Wort an, das so richtig ist, gehört es in die
|
||||||
|
Ausnahmeliste — nicht die Prüfung abgeschaltet.
|
||||||
|
|
||||||
|
> **Beim Bewegtbild reicht ein Blick auf das erste Bild nicht.** Der erste
|
||||||
|
> Anlauf prüfte nur den Anfangszustand und fand nichts: die fehlerhaften
|
||||||
|
> Wörter erscheinen dort erst ab `t = 0,4`. Der Text einer Animation ändert
|
||||||
|
> sich mit der Zeit, die Prüfung muss es auch.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -224,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.
|
||||||
@@ -314,6 +582,21 @@ aussehen lässt. Flächig gesetzt wirkt es ruhiger und passt zur Website, die
|
|||||||
denselben Weg geht. Nebenbei ist es beim Export billiger — jeder unscharfe
|
denselben Weg geht. Nebenbei ist es beim Export billiger — jeder unscharfe
|
||||||
Filter muss beim 3-fachen Rendern dreifach berechnet werden.
|
Filter muss beim 3-fachen Rendern dreifach berechnet werden.
|
||||||
|
|
||||||
|
> **Für das Theme `modern` ist diese Regel aufgehoben.** Dort schweben Karten
|
||||||
|
> über einem durchlaufenden Band, und ohne Schatten stehen sie gestochen auf
|
||||||
|
> dem Grund statt darüber. Der Schatten ist ein SVG-`feDropShadow`
|
||||||
|
> (`dy 7`, `stdDeviation 10`, `#101828` bei 14 %), kein `box-shadow`: die
|
||||||
|
> Karten sind gezeichnet und nicht im DOM, und ein CSS-Schatten würde vom
|
||||||
|
> `clip-path` ohnehin mit beschnitten. Der Kostenpunkt aus dem Absatz oben
|
||||||
|
> bleibt — **nach jeder Änderung daran einmal 3-fach exportieren und
|
||||||
|
> hinsehen.** Im Theme `notebook` gilt die Regel unverändert.
|
||||||
|
>
|
||||||
|
> **`.vis svg` braucht dafür `overflow: visible`.** Ein SVG beschneidet an
|
||||||
|
> seiner viewBox, und die Karten füllen ihre Zeichnung randlos aus. Ohne die
|
||||||
|
> Regel wird der Schatten genau an der Kartenkante gekappt: außen ein grauer
|
||||||
|
> Streifen mit hartem Schnitt, in den Chamfer-Kerben graue Keile. Am Layout
|
||||||
|
> ändert sich nichts, gezeichnet wird weiterhin nur, was in der viewBox steht.
|
||||||
|
|
||||||
### Was global gilt und was pro Slide
|
### Was global gilt und was pro Slide
|
||||||
|
|
||||||
| Einstellung | Geltung | Warum |
|
| Einstellung | Geltung | Warum |
|
||||||
@@ -711,6 +994,7 @@ etwas ins Statusfeld schreibt. **Nicht entfernen.**
|
|||||||
| Farbe „stimmt nicht" | Deckkraft der Ebene lag darüber. Eine Farbe ist nur exakt, wenn nichts sie multipliziert |
|
| Farbe „stimmt nicht" | Deckkraft der Ebene lag darüber. Eine Farbe ist nur exakt, wenn nichts sie multipliziert |
|
||||||
| Muster wirkt zufällig hingeworfen | Formen liefen quer durch den Text. Dekoration bleibt in den Rändern, verankert und angeschnitten |
|
| Muster wirkt zufällig hingeworfen | Formen liefen quer durch den Text. Dekoration bleibt in den Rändern, verankert und angeschnitten |
|
||||||
| Nichts lädt, Bühne leer | Konstante vor ihrer Deklaration benutzt (TDZ) |
|
| Nichts lädt, Bühne leer | Konstante vor ihrer Deklaration benutzt (TDZ) |
|
||||||
|
| „Sanitaer“ auf dem fertigen Beitrag | Wörtlich aus der Quelle übernommen, die es selbst falsch schreibt. §4 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,12 @@ import http.server
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import socketserver
|
import socketserver
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
PORT = 4321
|
PORT = 4321
|
||||||
@@ -40,6 +44,15 @@ MAX_BILD = 40 * 1024 * 1024
|
|||||||
# angefasst, deshalb bleibt ein PNG genau das PNG, das der Finder geliefert hat.
|
# angefasst, deshalb bleibt ein PNG genau das PNG, das der Finder geliefert hat.
|
||||||
BILD_ENDUNGEN = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif"}
|
BILD_ENDUNGEN = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif"}
|
||||||
BILD_ORDNER = ("assets/screens", "assets/eigene")
|
BILD_ORDNER = ("assets/screens", "assets/eigene")
|
||||||
|
ANIM_DIR = ROOT / "animation"
|
||||||
|
SAFE_KEY = re.compile(r"^[A-Za-z][A-Za-z0-9-]{0,39}$")
|
||||||
|
ANIM_FEHLER = re.compile(r"^[A-Za-z]*Error: ")
|
||||||
|
# Laufende und abgeschlossene Videobauten, nach Kennung. Der Bau dauert je
|
||||||
|
# nach Laenge ein bis drei Minuten -- zu lang fuer eine offene Anfrage, und
|
||||||
|
# animation.mjs holt sich waehrenddessen selbst Seiten von diesem Server.
|
||||||
|
# Deshalb laeuft er in einem eigenen Faden und die Seite fragt den Stand ab.
|
||||||
|
ANIM_LAEUFE = {}
|
||||||
|
ANIM_SPERRE = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
class Handler(http.server.SimpleHTTPRequestHandler):
|
class Handler(http.server.SimpleHTTPRequestHandler):
|
||||||
@@ -104,11 +117,61 @@ class Handler(http.server.SimpleHTTPRequestHandler):
|
|||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
"""Vor der Dateiauslieferung nur die Bilderliste abfangen."""
|
"""Vor der Dateiauslieferung nur die Bilderliste abfangen."""
|
||||||
if urllib.parse.urlparse(self.path).path == "/bilder":
|
zerlegt = urllib.parse.urlparse(self.path)
|
||||||
|
if zerlegt.path == "/":
|
||||||
|
# Die Wurzel fuehrt zur Uebersicht aller Projekte statt zur
|
||||||
|
# Dateiliste. So reicht ein Link auf http://127.0.0.1:4321/,
|
||||||
|
# zum Beispiel aus dem Marketing-Dashboard.
|
||||||
|
self.send_response(302)
|
||||||
|
self.send_header("Location", "/uebersicht.html")
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
if zerlegt.path == "/bilder":
|
||||||
self._liste_bilder()
|
self._liste_bilder()
|
||||||
return
|
return
|
||||||
|
if zerlegt.path == "/anim":
|
||||||
|
self._anim_stand(urllib.parse.parse_qs(zerlegt.query).get("id", [""])[0])
|
||||||
|
return
|
||||||
super().do_GET()
|
super().do_GET()
|
||||||
|
|
||||||
|
def _anim_stand(self, kennung):
|
||||||
|
"""Stand eines Videobaus. Unbekannte Kennung ist kein Serverfehler,
|
||||||
|
sondern eine Antwort: der Lauf ist weg, mehr steht dazu nicht an."""
|
||||||
|
with ANIM_SPERRE:
|
||||||
|
lauf = ANIM_LAEUFE.get(kennung)
|
||||||
|
if lauf is None:
|
||||||
|
self._json(404, json.dumps({"laeuft": False, "fehler": "Lauf unbekannt"}))
|
||||||
|
return
|
||||||
|
self._json(200, json.dumps(lauf, ensure_ascii=False))
|
||||||
|
|
||||||
|
def _anim_bauen(self):
|
||||||
|
"""Ein einzelnes MP4 bauen lassen. Getrennt vom Bildexport: die
|
||||||
|
Animation ist ein eigenes Element im Carousel und soll auch einzeln
|
||||||
|
herauskommen."""
|
||||||
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
|
if length <= 0 or length > 4096:
|
||||||
|
self._json(400, '{"error":"ungueltige Anfrage"}')
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
wunsch = json.loads(self.rfile.read(length))
|
||||||
|
projekt, key = wunsch["projekt"], wunsch["key"]
|
||||||
|
except Exception:
|
||||||
|
self._json(400, '{"error":"kein gueltiges JSON"}')
|
||||||
|
return
|
||||||
|
if not SAFE_PROJECT.match(str(projekt)) or not SAFE_KEY.match(str(key)):
|
||||||
|
self._json(400, '{"error":"unzulaessiger Name"}')
|
||||||
|
return
|
||||||
|
if not (ROOT / (projekt + ".html")).is_file():
|
||||||
|
self._json(404, '{"error":"Projekt nicht gefunden"}')
|
||||||
|
return
|
||||||
|
|
||||||
|
kennung = uuid.uuid4().hex[:12]
|
||||||
|
with ANIM_SPERRE:
|
||||||
|
ANIM_LAEUFE[kennung] = {"laeuft": True, "zeile": "startet …"}
|
||||||
|
threading.Thread(target=_anim_lauf, args=(kennung, projekt, key),
|
||||||
|
daemon=True).start()
|
||||||
|
self._json(200, json.dumps({"id": kennung}))
|
||||||
|
|
||||||
def _liste_bilder(self):
|
def _liste_bilder(self):
|
||||||
"""Alle verwendbaren Bilder, damit die Auswahl im Generator nicht im
|
"""Alle verwendbaren Bilder, damit die Auswahl im Generator nicht im
|
||||||
Code gepflegt werden muss und importierte sofort auftauchen."""
|
Code gepflegt werden muss und importierte sofort auftauchen."""
|
||||||
@@ -171,6 +234,9 @@ class Handler(http.server.SimpleHTTPRequestHandler):
|
|||||||
if path == "/upload":
|
if path == "/upload":
|
||||||
self._upload()
|
self._upload()
|
||||||
return
|
return
|
||||||
|
if path == "/anim":
|
||||||
|
self._anim_bauen()
|
||||||
|
return
|
||||||
if path != "/save":
|
if path != "/save":
|
||||||
self.send_error(404)
|
self.send_error(404)
|
||||||
return
|
return
|
||||||
@@ -209,18 +275,99 @@ class Handler(http.server.SimpleHTTPRequestHandler):
|
|||||||
self.wfile.write(payload)
|
self.wfile.write(payload)
|
||||||
|
|
||||||
def end_headers(self):
|
def end_headers(self):
|
||||||
# Werkzeug, kein Auslieferungsserver: Assets werden hier laufend
|
# Werkzeug, kein Auslieferungsserver: ein Asset, das unter demselben
|
||||||
# ausgetauscht, und ein gecachter Screenshot unter gleichem Dateinamen
|
# Namen ausgetauscht wird, muss sofort ankommen.
|
||||||
# kostet mehr Zeit als jeder erneute Ladevorgang.
|
#
|
||||||
self.send_header("Cache-Control", "no-store, must-revalidate")
|
# Dafuer stand hier no-store auf allem. Das Ziel war richtig, der
|
||||||
self.send_header("Pragma", "no-cache")
|
# Preis zu hoch: no-store verbietet dem Browser die Kopie ueberhaupt,
|
||||||
self.send_header("Expires", "0")
|
# und damit lud jeder Reload die vollen rund 6 MB neu -- 3 MB
|
||||||
|
# Screenshots, 3,3 MB Mockups, die Schriften, die Bibliotheken. Am
|
||||||
|
# Ende wartete man beim Neuladen auf Dateien, die sich seit Wochen
|
||||||
|
# nicht geaendert hatten.
|
||||||
|
#
|
||||||
|
# no-cache leistet dasselbe billiger: der Browser darf die Kopie
|
||||||
|
# behalten, muss aber vor jeder Benutzung nachfragen. Eine
|
||||||
|
# ausgetauschte Datei kommt damit genauso sofort an, eine
|
||||||
|
# unveraenderte kostet ein 304 ohne Rumpf. Geprueft: der Handler
|
||||||
|
# beantwortet If-Modified-Since bereits mit 304 und 0 Bytes.
|
||||||
|
#
|
||||||
|
# Seite, Stand und Auskuenfte bleiben bei no-store. Die sind klein,
|
||||||
|
# und sie duerfen nie aus einer Kopie kommen.
|
||||||
|
pfad = urllib.parse.urlparse(self.path).path
|
||||||
|
if pfad.startswith(("/assets/", "/vendor/", "/animation/")):
|
||||||
|
self.send_header("Cache-Control", "no-cache")
|
||||||
|
else:
|
||||||
|
self.send_header("Cache-Control", "no-store, must-revalidate")
|
||||||
|
self.send_header("Pragma", "no-cache")
|
||||||
|
self.send_header("Expires", "0")
|
||||||
super().end_headers()
|
super().end_headers()
|
||||||
|
|
||||||
def log_message(self, fmt, *args):
|
def log_message(self, fmt, *args):
|
||||||
first = args[0] if args else ""
|
# args[0] ist normalerweise die Anfragezeile, bei send_error aber ein
|
||||||
|
# HTTPStatus. Ohne str() flog hier ein TypeError, der den Handler-Thread
|
||||||
|
# abbrach: der Browser wartete dann ewig auf eine Antwort, die nie kam,
|
||||||
|
# und die Seite blieb bei "Lade Screens ..." stehen.
|
||||||
|
first = str(args[0]) if args else ""
|
||||||
if "POST /save" in first or "DELETE" in first:
|
if "POST /save" in first or "DELETE" in first:
|
||||||
super().log_message(fmt, *args)
|
super().log_message(fmt, *args)
|
||||||
|
elif not first.startswith(("GET", "POST", "HEAD")):
|
||||||
|
# Fehler und Warnungen gehoeren gesehen, sonst sucht man lange.
|
||||||
|
super().log_message(fmt, *args)
|
||||||
|
|
||||||
|
|
||||||
|
def _anim_lauf(kennung, projekt, key):
|
||||||
|
"""animation.mjs in einem eigenen Faden. Seine Ausgabe wandert zeilenweise
|
||||||
|
in den Stand, damit die Seite den Fortschritt zeigen kann statt zwei
|
||||||
|
Minuten lang nichts."""
|
||||||
|
def melden(**neu):
|
||||||
|
with ANIM_SPERRE:
|
||||||
|
ANIM_LAEUFE.setdefault(kennung, {}).update(neu)
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
["node", "animation.mjs", projekt + ".html", key],
|
||||||
|
cwd=str(ROOT), stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||||
|
text=True, bufsize=1)
|
||||||
|
ausgabe = []
|
||||||
|
for zeile in proc.stdout:
|
||||||
|
zeile = zeile.strip()
|
||||||
|
if zeile:
|
||||||
|
ausgabe.append(zeile)
|
||||||
|
melden(zeile=zeile)
|
||||||
|
code = proc.wait()
|
||||||
|
if code != 0:
|
||||||
|
# Die letzte Zeile ist bei node die Versionsangabe und sagt nichts,
|
||||||
|
# und der Auszug aus dem Quelltext steht vor der eigentlichen
|
||||||
|
# Meldung. Gesucht ist die Zeile, die mit dem Fehlernamen anfaengt.
|
||||||
|
grund = next((z for z in ausgabe if ANIM_FEHLER.match(z)), "")
|
||||||
|
melden(laeuft=False,
|
||||||
|
fehler=grund or (ausgabe[-1] if ausgabe else "node endete mit " + str(code)))
|
||||||
|
return
|
||||||
|
ziel = ANIM_DIR / (projekt + "-" + key + ".mp4")
|
||||||
|
if not ziel.is_file():
|
||||||
|
melden(laeuft=False, fehler="kein MP4 entstanden")
|
||||||
|
return
|
||||||
|
groesse = ziel.stat().st_size
|
||||||
|
# Zum Hochladen liegt die Datei am selben Ort wie die Bilder. Im Repo
|
||||||
|
# bleibt sie trotzdem: dort ist sie versioniert und hat auf der anderen
|
||||||
|
# Maschine nach einem Pull denselben Stand. Ueberschrieben wird in
|
||||||
|
# Downloads nichts, genau wie beim Bildexport -- eine Datei, die
|
||||||
|
# vielleicht schon hochgeladen wurde, gehoert nicht ersetzt.
|
||||||
|
TARGET.mkdir(parents=True, exist_ok=True)
|
||||||
|
abgabe = TARGET / ziel.name
|
||||||
|
n = 1
|
||||||
|
while abgabe.exists():
|
||||||
|
abgabe = TARGET / f"{ziel.stem}-{n}{ziel.suffix}"
|
||||||
|
n += 1
|
||||||
|
shutil.copy2(ziel, abgabe)
|
||||||
|
melden(laeuft=False, pfad="~/Downloads/" + abgabe.name,
|
||||||
|
groesse=f"{groesse/1024/1024:.1f} MB · auch animation/{ziel.name}",
|
||||||
|
zeile="fertig")
|
||||||
|
print(f"gespeichert: {abgabe} ({groesse/1024/1024:.1f} MB)")
|
||||||
|
except FileNotFoundError:
|
||||||
|
melden(laeuft=False, fehler="node nicht gefunden")
|
||||||
|
except Exception as e: # der Faden darf den Server nicht mitnehmen
|
||||||
|
melden(laeuft=False, fehler=str(e))
|
||||||
|
|
||||||
|
|
||||||
class Server(socketserver.ThreadingTCPServer):
|
class Server(socketserver.ThreadingTCPServer):
|
||||||
@@ -228,7 +375,14 @@ class Server(socketserver.ThreadingTCPServer):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
print(f"Generator: http://127.0.0.1:{PORT}/post1.html")
|
# Der Port ist fest, damit Links von aussen (Marketing-Dashboard,
|
||||||
|
# Lesezeichen) immer stimmen. Ist er belegt, laeuft PostGrid meist schon.
|
||||||
|
try:
|
||||||
|
httpd = Server(("127.0.0.1", PORT), Handler)
|
||||||
|
except OSError:
|
||||||
|
print(f"Port {PORT} ist belegt. Laeuft PostGrid schon? Dann einfach http://127.0.0.1:{PORT}/ oeffnen.")
|
||||||
|
raise SystemExit(1)
|
||||||
|
print(f"PostGrid: http://127.0.0.1:{PORT}/")
|
||||||
print(f"Speichert nach: {TARGET}")
|
print(f"Speichert nach: {TARGET}")
|
||||||
with Server(("127.0.0.1", PORT), Handler) as httpd:
|
with httpd:
|
||||||
httpd.serve_forever()
|
httpd.serve_forever()
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Serve the generator on a free port, with the /save endpoint.
|
# Startet PostGrid immer auf dem festen Port 4321: http://127.0.0.1:4321/
|
||||||
cd "$(dirname "$0")" && exec python3 serve.py
|
cd "$(dirname "$0")" && exec python3 serve.py
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
{
|
||||||
|
"bandTon": "teal",
|
||||||
|
"bgState": [
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "kraeftig"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"current": 4,
|
||||||
|
"deck": [
|
||||||
|
{
|
||||||
|
"ref": "titel",
|
||||||
|
"werte": {
|
||||||
|
"body": "<br>"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ref": "prozesse",
|
||||||
|
"werte": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ref": "wechseln",
|
||||||
|
"werte": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ref": "mehr",
|
||||||
|
"werte": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ref": "verbinden",
|
||||||
|
"werte": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ref": "warteliste",
|
||||||
|
"werte": {
|
||||||
|
"body": "Setz dich auf die Warteliste, dann melden wir uns."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edits": [
|
||||||
|
{
|
||||||
|
"body": {
|
||||||
|
"dx": 102,
|
||||||
|
"dy": 0
|
||||||
|
},
|
||||||
|
"headline": {
|
||||||
|
"dx": 37,
|
||||||
|
"dy": 3,
|
||||||
|
"size": 88
|
||||||
|
},
|
||||||
|
"vis": {
|
||||||
|
"dx": 3,
|
||||||
|
"dy": -9
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"body": {
|
||||||
|
"dx": 0,
|
||||||
|
"dy": 20
|
||||||
|
},
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
},
|
||||||
|
"vis": {
|
||||||
|
"dx": 113,
|
||||||
|
"dy": -59
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"body": {
|
||||||
|
"dx": 0,
|
||||||
|
"dy": 20
|
||||||
|
},
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
},
|
||||||
|
"vis": {
|
||||||
|
"dx": -3,
|
||||||
|
"dy": -88
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"body": {
|
||||||
|
"dx": 0,
|
||||||
|
"dy": 20
|
||||||
|
},
|
||||||
|
"headline": {
|
||||||
|
"dx": 0,
|
||||||
|
"dy": 3
|
||||||
|
},
|
||||||
|
"vis": {
|
||||||
|
"dx": -68,
|
||||||
|
"dy": -25,
|
||||||
|
"size": 1028
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"body": {
|
||||||
|
"dy": 20
|
||||||
|
},
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
},
|
||||||
|
"vis": {
|
||||||
|
"dx": 33,
|
||||||
|
"dy": -138
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"body": {
|
||||||
|
"dy": 20
|
||||||
|
},
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
},
|
||||||
|
"vis": {
|
||||||
|
"dx": 12,
|
||||||
|
"dy": 74,
|
||||||
|
"size": 1184
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"frameStyle": "aus",
|
||||||
|
"layoutState": [
|
||||||
|
"standard",
|
||||||
|
"standard",
|
||||||
|
"standard",
|
||||||
|
"standard",
|
||||||
|
"standard",
|
||||||
|
"standard"
|
||||||
|
],
|
||||||
|
"superSample": 3,
|
||||||
|
"tagOut": "an",
|
||||||
|
"tilt": "an",
|
||||||
|
"v": 7,
|
||||||
|
"variant": "hell",
|
||||||
|
"view": "streifen"
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
{
|
||||||
|
"bandTon": "teal",
|
||||||
|
"bgState": [
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "kraeftig"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"current": 0,
|
||||||
|
"deck": [
|
||||||
|
{
|
||||||
|
"ref": "titel",
|
||||||
|
"werte": {
|
||||||
|
"body": "<br>"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ref": "prozesse",
|
||||||
|
"werte": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ref": "wechseln",
|
||||||
|
"werte": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ref": "mehr",
|
||||||
|
"werte": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ref": "verbinden",
|
||||||
|
"werte": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ref": "warteliste",
|
||||||
|
"werte": {
|
||||||
|
"body": "Setz dich auf die Warteliste, dann melden wir uns."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edits": [
|
||||||
|
{
|
||||||
|
"body": {
|
||||||
|
"dx": 102,
|
||||||
|
"dy": 0
|
||||||
|
},
|
||||||
|
"headline": {
|
||||||
|
"dx": 15,
|
||||||
|
"dy": 3,
|
||||||
|
"size": 88
|
||||||
|
},
|
||||||
|
"vis": {
|
||||||
|
"dx": 3,
|
||||||
|
"dy": -9
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"body": {
|
||||||
|
"dx": 0,
|
||||||
|
"dy": 20
|
||||||
|
},
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
},
|
||||||
|
"vis": {
|
||||||
|
"dx": 113,
|
||||||
|
"dy": -59
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"body": {
|
||||||
|
"dx": 0,
|
||||||
|
"dy": 20
|
||||||
|
},
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
},
|
||||||
|
"vis": {
|
||||||
|
"dx": -3,
|
||||||
|
"dy": -88
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"body": {
|
||||||
|
"dx": 0,
|
||||||
|
"dy": 20
|
||||||
|
},
|
||||||
|
"headline": {
|
||||||
|
"dx": 0,
|
||||||
|
"dy": 3
|
||||||
|
},
|
||||||
|
"vis": {
|
||||||
|
"dx": -68,
|
||||||
|
"dy": -25,
|
||||||
|
"size": 1028
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"body": {
|
||||||
|
"dy": 20
|
||||||
|
},
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
},
|
||||||
|
"vis": {
|
||||||
|
"dx": 33,
|
||||||
|
"dy": -138
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"body": {
|
||||||
|
"dy": 20
|
||||||
|
},
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
},
|
||||||
|
"vis": {
|
||||||
|
"dx": 102,
|
||||||
|
"dy": 70,
|
||||||
|
"size": 1184
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"frameStyle": "aus",
|
||||||
|
"layoutState": [
|
||||||
|
"standard",
|
||||||
|
"standard",
|
||||||
|
"standard",
|
||||||
|
"standard",
|
||||||
|
"standard",
|
||||||
|
"standard"
|
||||||
|
],
|
||||||
|
"rasterAn": "aus",
|
||||||
|
"superSample": 3,
|
||||||
|
"tagOut": "an",
|
||||||
|
"textMitte": "an",
|
||||||
|
"tilt": "an",
|
||||||
|
"v": 7,
|
||||||
|
"variant": "hell",
|
||||||
|
"view": "einzeln"
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
{
|
||||||
|
"bgState": [
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "kraeftig"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"current": 0,
|
||||||
|
"deck": [
|
||||||
|
{
|
||||||
|
"ref": "titel",
|
||||||
|
"werte": {
|
||||||
|
"body": "<br>"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ref": "prozesse",
|
||||||
|
"werte": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ref": "wechseln",
|
||||||
|
"werte": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ref": "mehr",
|
||||||
|
"werte": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ref": "verbinden",
|
||||||
|
"werte": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ref": "warteliste",
|
||||||
|
"werte": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edits": [
|
||||||
|
{
|
||||||
|
"body": {
|
||||||
|
"dx": 102,
|
||||||
|
"dy": -95
|
||||||
|
},
|
||||||
|
"headline": {
|
||||||
|
"dx": 102,
|
||||||
|
"dy": -96
|
||||||
|
},
|
||||||
|
"vis": {
|
||||||
|
"dx": -38,
|
||||||
|
"dy": -79,
|
||||||
|
"size": 692
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vis": {
|
||||||
|
"dx": 0,
|
||||||
|
"dy": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
{}
|
||||||
|
],
|
||||||
|
"frameStyle": "rund",
|
||||||
|
"layoutState": [
|
||||||
|
"ecke",
|
||||||
|
"standard",
|
||||||
|
"standard",
|
||||||
|
"standard",
|
||||||
|
"standard",
|
||||||
|
"standard"
|
||||||
|
],
|
||||||
|
"superSample": 2,
|
||||||
|
"tagOut": "an",
|
||||||
|
"tilt": "an",
|
||||||
|
"v": 6,
|
||||||
|
"variant": "hell",
|
||||||
|
"view": "streifen"
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"bgState": [
|
"bgState": [
|
||||||
{
|
{
|
||||||
"bg": "raster",
|
"bg": "raster",
|
||||||
"int": "dezent"
|
"int": "kraeftig"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"bg": "raster",
|
"bg": "raster",
|
||||||
@@ -25,11 +25,13 @@
|
|||||||
"int": "dezent"
|
"int": "dezent"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"current": 0,
|
"current": 5,
|
||||||
"deck": [
|
"deck": [
|
||||||
{
|
{
|
||||||
"ref": "titel",
|
"ref": "titel",
|
||||||
"werte": {}
|
"werte": {
|
||||||
|
"body": "<br>"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ref": "prozesse",
|
"ref": "prozesse",
|
||||||
@@ -49,36 +51,90 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ref": "warteliste",
|
"ref": "warteliste",
|
||||||
"werte": {}
|
"werte": {
|
||||||
|
"body": "Setz dich auf die Warteliste, dann melden wir uns."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"edits": [
|
"edits": [
|
||||||
{
|
{
|
||||||
|
"body": {
|
||||||
|
"dx": 102,
|
||||||
|
"dy": 0
|
||||||
|
},
|
||||||
|
"headline": {
|
||||||
|
"dx": 37,
|
||||||
|
"dy": 3,
|
||||||
|
"size": 88
|
||||||
|
},
|
||||||
"vis": {
|
"vis": {
|
||||||
"dx": -100,
|
"dx": -38,
|
||||||
"dy": 250,
|
"dy": -79,
|
||||||
"size": 700
|
"size": 692
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"body": {
|
||||||
|
"dx": 0,
|
||||||
|
"dy": 20
|
||||||
|
},
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
},
|
||||||
"vis": {
|
"vis": {
|
||||||
"dx": -7,
|
"dx": 0,
|
||||||
"dy": -19
|
"dy": 16
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"body": {
|
||||||
|
"dx": 0,
|
||||||
|
"dy": 20
|
||||||
|
},
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
},
|
||||||
"vis": {
|
"vis": {
|
||||||
"dx": 19,
|
"dx": 0,
|
||||||
"dy": -27
|
"dy": -109
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{},
|
{
|
||||||
{},
|
"body": {
|
||||||
{}
|
"dx": 0,
|
||||||
|
"dy": 20
|
||||||
|
},
|
||||||
|
"headline": {
|
||||||
|
"dx": 0,
|
||||||
|
"dy": 3
|
||||||
|
},
|
||||||
|
"vis": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"body": {
|
||||||
|
"dy": 20
|
||||||
|
},
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
},
|
||||||
|
"vis": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"body": {
|
||||||
|
"dy": 20
|
||||||
|
},
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
},
|
||||||
|
"vis": {
|
||||||
|
"dx": 3,
|
||||||
|
"dy": -88
|
||||||
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"frameStyle": "chamfer",
|
"frameStyle": "rund",
|
||||||
"layoutState": [
|
"layoutState": [
|
||||||
"standard",
|
"ecke",
|
||||||
"standard",
|
"standard",
|
||||||
"standard",
|
"standard",
|
||||||
"standard",
|
"standard",
|
||||||
@@ -90,5 +146,5 @@
|
|||||||
"tilt": "an",
|
"tilt": "an",
|
||||||
"v": 6,
|
"v": 6,
|
||||||
"variant": "hell",
|
"variant": "hell",
|
||||||
"view": "streifen"
|
"view": "einzeln"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"bandTon": "orange",
|
||||||
|
"bgState": [
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"current": 1,
|
||||||
|
"deck": [
|
||||||
|
{
|
||||||
|
"ref": "verbinden",
|
||||||
|
"werte": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ref": "aufbau",
|
||||||
|
"werte": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edits": [
|
||||||
|
{
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
},
|
||||||
|
"vis": {
|
||||||
|
"dx": -2,
|
||||||
|
"dy": -67
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"body": {},
|
||||||
|
"headline": {
|
||||||
|
"dx": 0,
|
||||||
|
"dy": 3
|
||||||
|
},
|
||||||
|
"vis": {
|
||||||
|
"dx": 2,
|
||||||
|
"dy": -93
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"frameStyle": "chamfer",
|
||||||
|
"layoutState": [
|
||||||
|
"standard",
|
||||||
|
"standard"
|
||||||
|
],
|
||||||
|
"rasterAn": "aus",
|
||||||
|
"superSample": 2,
|
||||||
|
"tagOut": "an",
|
||||||
|
"textMitte": "an",
|
||||||
|
"tilt": "an",
|
||||||
|
"v": 7,
|
||||||
|
"variant": "hell",
|
||||||
|
"view": "einzeln"
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
{
|
||||||
|
"bandTon": "orange",
|
||||||
|
"bgState": [
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"current": 2,
|
||||||
|
"deck": [
|
||||||
|
{
|
||||||
|
"ref": "anfrage",
|
||||||
|
"werte": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ref": "ausloeser",
|
||||||
|
"werte": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ref": "aktionen",
|
||||||
|
"werte": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ref": "vorlagen",
|
||||||
|
"werte": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edits": [
|
||||||
|
{
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"headline": {
|
||||||
|
"dx": 0,
|
||||||
|
"dy": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"frameStyle": "chamfer",
|
||||||
|
"layoutState": [
|
||||||
|
"standard",
|
||||||
|
"standard",
|
||||||
|
"standard",
|
||||||
|
"standard"
|
||||||
|
],
|
||||||
|
"rasterAn": "punkte",
|
||||||
|
"superSample": 2,
|
||||||
|
"tagOut": "an",
|
||||||
|
"textMitte": "an",
|
||||||
|
"tilt": "an",
|
||||||
|
"v": 7,
|
||||||
|
"variant": "hell",
|
||||||
|
"view": "streifen"
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
{
|
||||||
|
"bandTon": "orange",
|
||||||
|
"bgState": [
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"current": 0,
|
||||||
|
"deck": [
|
||||||
|
{
|
||||||
|
"ref": "board",
|
||||||
|
"werte": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ref": "karte",
|
||||||
|
"werte": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ref": "detail",
|
||||||
|
"werte": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ref": "liste",
|
||||||
|
"werte": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ref": "schablone",
|
||||||
|
"werte": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edits": [
|
||||||
|
{
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"frameStyle": "chamfer",
|
||||||
|
"layoutState": [
|
||||||
|
"standard",
|
||||||
|
"standard",
|
||||||
|
"standard",
|
||||||
|
"standard",
|
||||||
|
"standard"
|
||||||
|
],
|
||||||
|
"rasterAn": "punkte",
|
||||||
|
"superSample": 2,
|
||||||
|
"tagOut": "an",
|
||||||
|
"textMitte": "an",
|
||||||
|
"tilt": "an",
|
||||||
|
"v": 7,
|
||||||
|
"variant": "hell",
|
||||||
|
"view": "streifen"
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
{
|
||||||
|
"bandTon": "orange",
|
||||||
|
"bgState": [
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "kraeftig"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"current": 0,
|
||||||
|
"deck": [
|
||||||
|
{
|
||||||
|
"ref": "chat",
|
||||||
|
"werte": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ref": "kanaele",
|
||||||
|
"werte": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ref": "aufgabe",
|
||||||
|
"werte": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ref": "menue",
|
||||||
|
"werte": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ref": "mobil",
|
||||||
|
"werte": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edits": [
|
||||||
|
{
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
},
|
||||||
|
"vis": {
|
||||||
|
"dx": -27,
|
||||||
|
"dy": -124,
|
||||||
|
"size": 950
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"body": {},
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
},
|
||||||
|
"vis": {
|
||||||
|
"dx": 0,
|
||||||
|
"dy": -8
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
},
|
||||||
|
"vis": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"frameStyle": "chamfer",
|
||||||
|
"layoutState": [
|
||||||
|
"standard",
|
||||||
|
"standard",
|
||||||
|
"standard",
|
||||||
|
"standard",
|
||||||
|
"standard"
|
||||||
|
],
|
||||||
|
"rasterAn": "punkte",
|
||||||
|
"superSample": 2,
|
||||||
|
"tagOut": "an",
|
||||||
|
"textMitte": "an",
|
||||||
|
"tilt": "an",
|
||||||
|
"v": 7,
|
||||||
|
"variant": "hell",
|
||||||
|
"view": "streifen"
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
{
|
||||||
|
"bandTon": "orange",
|
||||||
|
"bgState": [
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"current": 0,
|
||||||
|
"deck": [
|
||||||
|
{
|
||||||
|
"ref": "felder",
|
||||||
|
"werte": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ref": "bedingt",
|
||||||
|
"werte": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ref": "lead",
|
||||||
|
"werte": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edits": [
|
||||||
|
{
|
||||||
|
"body": {
|
||||||
|
"dx": 0,
|
||||||
|
"dy": 0
|
||||||
|
},
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
},
|
||||||
|
"vis": {
|
||||||
|
"dx": 0,
|
||||||
|
"dy": -28
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
},
|
||||||
|
"vis": {
|
||||||
|
"dx": 0,
|
||||||
|
"dy": -126
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
},
|
||||||
|
"vis": {
|
||||||
|
"dx": 0,
|
||||||
|
"dy": -162
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"frameStyle": "rund",
|
||||||
|
"layoutState": [
|
||||||
|
"standard",
|
||||||
|
"standard",
|
||||||
|
"standard"
|
||||||
|
],
|
||||||
|
"rasterAn": "punkte",
|
||||||
|
"superSample": 2,
|
||||||
|
"tagOut": "an",
|
||||||
|
"textMitte": "an",
|
||||||
|
"tilt": "aus",
|
||||||
|
"v": 7,
|
||||||
|
"variant": "hell",
|
||||||
|
"view": "streifen"
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
{
|
||||||
|
"bandTon": "orange",
|
||||||
|
"bgState": [
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"current": 3,
|
||||||
|
"deck": [
|
||||||
|
{
|
||||||
|
"ref": "segment",
|
||||||
|
"werte": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ref": "vorlagen",
|
||||||
|
"werte": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ref": "pruefung",
|
||||||
|
"werte": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ref": "mail",
|
||||||
|
"werte": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edits": [
|
||||||
|
{
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
},
|
||||||
|
"vis": {
|
||||||
|
"dx": 0,
|
||||||
|
"dy": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"headline": {
|
||||||
|
"dy": 3
|
||||||
|
},
|
||||||
|
"vis": {
|
||||||
|
"dx": 0,
|
||||||
|
"dy": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"frameStyle": "rund",
|
||||||
|
"layoutState": [
|
||||||
|
"standard",
|
||||||
|
"standard",
|
||||||
|
"standard",
|
||||||
|
"standard"
|
||||||
|
],
|
||||||
|
"rasterAn": "punkte",
|
||||||
|
"superSample": 2,
|
||||||
|
"tagOut": "an",
|
||||||
|
"textMitte": "an",
|
||||||
|
"tilt": "aus",
|
||||||
|
"v": 7,
|
||||||
|
"variant": "hell",
|
||||||
|
"view": "streifen"
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"bandTon": "orange",
|
||||||
|
"bgState": [
|
||||||
|
{
|
||||||
|
"bg": "raster",
|
||||||
|
"int": "dezent"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"current": 0,
|
||||||
|
"deck": [
|
||||||
|
{
|
||||||
|
"ref": "anmeldung",
|
||||||
|
"werte": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edits": [
|
||||||
|
{}
|
||||||
|
],
|
||||||
|
"format": "1x1",
|
||||||
|
"frameStyle": "chamfer",
|
||||||
|
"layoutState": [
|
||||||
|
"standard"
|
||||||
|
],
|
||||||
|
"rasterAn": "punkte",
|
||||||
|
"superSample": 2,
|
||||||
|
"tagOut": "an",
|
||||||
|
"textMitte": "an",
|
||||||
|
"tilt": "an",
|
||||||
|
"v": 7,
|
||||||
|
"variant": "hell",
|
||||||
|
"view": "streifen"
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>PostGrid · Alle Projekte</title>
|
||||||
|
<link rel="stylesheet" href="./assets/fonts/manrope.css">
|
||||||
|
<style>
|
||||||
|
/* ─── Projektübersicht ────────────────────────────────────────────────────
|
||||||
|
Alle Streifen untereinander auf einer Fläche. Die Seite baut die Slides
|
||||||
|
nicht selbst: jedes Projekt kommt als iframe im Modus ?nur=streifen, in
|
||||||
|
dem die Post-Seite ihre Bedienoberfläche ausblendet und nur den Streifen
|
||||||
|
in natürlicher Größe zeigt. Skaliert wird hier außen.
|
||||||
|
|
||||||
|
Warum nicht nachbauen: die Streifen müssen genau das zeigen, was der
|
||||||
|
Generator zeigt — bis auf den letzten gespeicherten Versatz. Jede zweite
|
||||||
|
Implementierung würde irgendwann abweichen, und dann wäre die Übersicht
|
||||||
|
schlimmer als keine. */
|
||||||
|
* { 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; }
|
||||||
|
.menu { position: relative; }
|
||||||
|
.menu > button {
|
||||||
|
font: inherit; color: #C7CCD6; background: none; border: 0;
|
||||||
|
padding: 5px 10px; border-radius: 6px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.menu > button:hover, .menu[data-auf] > button { background: #23262F; color: #FFFFFF; }
|
||||||
|
.menu .liste {
|
||||||
|
display: none; position: absolute; left: 0; top: 100%; min-width: 250px;
|
||||||
|
padding: 5px; background: #1B1E26; border: 1px solid #2E313B;
|
||||||
|
border-radius: 9px; flex-direction: column;
|
||||||
|
}
|
||||||
|
.menu[data-auf] .liste { display: flex; }
|
||||||
|
.menu .liste button {
|
||||||
|
font: inherit; text-align: left; color: #C7CCD6; background: none;
|
||||||
|
border: 0; padding: 7px 10px; border-radius: 6px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.menu .liste button:hover { background: #262A34; color: #FFFFFF; }
|
||||||
|
.menu .liste hr { border: 0; border-top: 1px solid #2E313B; margin: 5px 2px; }
|
||||||
|
|
||||||
|
/* Derselbe gepunktete Grund wie im Generator, aber ohne dessen Canvas:
|
||||||
|
hier wird nur gescrollt. */
|
||||||
|
.stage { position: absolute; inset: 34px 0 0; overflow: hidden;
|
||||||
|
background-color: #101116;
|
||||||
|
background-image: radial-gradient(circle, #282B35 1.6px, transparent 1.6px);
|
||||||
|
background-size: 26px 26px;
|
||||||
|
}
|
||||||
|
/* Nur hoch und runter. Die Streifen sind auf die Breite eingepasst,
|
||||||
|
also gibt es waagerecht nichts zu holen. */
|
||||||
|
.scroller { position: absolute; inset: 0; overflow-y: auto; overflow-x: hidden;
|
||||||
|
overscroll-behavior: contain; }
|
||||||
|
.flaeche {
|
||||||
|
width: max-content; min-width: 100%; height: max-content; min-height: 100%;
|
||||||
|
padding: 54px; display: flex; flex-direction: column; gap: 46px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reihe { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.reihe h2 {
|
||||||
|
margin: 0; font-size: 12px; font-weight: 800; letter-spacing: .14em;
|
||||||
|
text-transform: uppercase; color: #6E7480;
|
||||||
|
}
|
||||||
|
.reihe h2 b { color: #C7CCD6; font-weight: 800; }
|
||||||
|
/* Der Rahmen bekommt die Größe des skalierten Streifens, das iframe darin
|
||||||
|
bleibt in Originalgröße und wird nur transformiert. Ohne den Rahmen
|
||||||
|
würde das ungeskalierte iframe die Fläche aufblähen. */
|
||||||
|
.rahmen { overflow: hidden; background: #FFFFFF; border: 1px solid #2E313B; }
|
||||||
|
/* pointer-events: none -- sonst zoomt das Mausrad im Streifen die
|
||||||
|
Post-Seite darin, die ja ihren eigenen Canvas mitbringt. Die Uebersicht
|
||||||
|
ist zum Ansehen da, bedient wird im Projekt selbst. */
|
||||||
|
.rahmen iframe {
|
||||||
|
display: block; border: 0; transform-origin: 0 0; background: #FFFFFF;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.reihe.fehlt .rahmen { display: grid; place-items: center; width: 420px; height: 90px;
|
||||||
|
background: #16181F; color: #6E7480; font-size: 13px; }
|
||||||
|
|
||||||
|
.hud {
|
||||||
|
position: absolute; left: 0; right: 0; bottom: 0; z-index: 5;
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 8px 12px; background: rgba(16,17,22,.92);
|
||||||
|
border-top: 1px solid #23262F;
|
||||||
|
}
|
||||||
|
.hud p { margin: 0; font-size: 12px; color: #6E7480; letter-spacing: .04em; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="menubar">
|
||||||
|
<span class="marke">PostGrid</span>
|
||||||
|
<div class="menu" data-menu="projekt">
|
||||||
|
<button type="button">Projekt</button>
|
||||||
|
<div class="liste">
|
||||||
|
<button type="button" data-gehe="./post1.html">Post 1 · Lösung</button>
|
||||||
|
<button type="button" data-gehe="./post2.html">Post 2 · Die Geschichte</button>
|
||||||
|
<button type="button" data-gehe="./post2-alt.html">Post 2 · Alternative</button>
|
||||||
|
<button type="button" data-gehe="./post3.html">Post 3 · Verbinden statt ersetzen</button>
|
||||||
|
<button type="button" data-gehe="./post4.html">Post 4 · Workflow-Engine</button>
|
||||||
|
<button type="button" data-gehe="./post5.html">Post 5 · Projektmanagement</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="./post8.html">Post 8 · E-Mail-Marketing</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>
|
||||||
|
<button type="button" data-klick="neu">Streifen neu laden</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="stage">
|
||||||
|
<div class="scroller" id="scroller">
|
||||||
|
<div class="flaeche" id="flaeche"></div>
|
||||||
|
</div>
|
||||||
|
<div class="hud">
|
||||||
|
<p id="meta">Lade Streifen ...</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const PROJEKTE = [
|
||||||
|
{ datei: "./post1.html", name: "Post 1 · Lösung" },
|
||||||
|
{ datei: "./post2.html", name: "Post 2 · Die Geschichte", theme: "notebook" },
|
||||||
|
{ datei: "./post2-alt.html", name: "Post 2 · Alternative", theme: "modern" },
|
||||||
|
{ datei: "./post3.html", name: "Post 3 · Verbinden statt ersetzen", theme: "modern" },
|
||||||
|
{ datei: "./post4.html", name: "Post 4 · Workflow-Engine", theme: "modern" },
|
||||||
|
{ datei: "./post5.html", name: "Post 5 · Projektmanagement", theme: "modern" },
|
||||||
|
{ datei: "./post6.html", name: "Post 6 · Team-Chat", theme: "modern" },
|
||||||
|
{ datei: "./post7.html", name: "Post 7 · Form Builder", theme: "modern" },
|
||||||
|
{ datei: "./post8.html", name: "Post 8 · E-Mail-Marketing", 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 scroller = document.getElementById("scroller");
|
||||||
|
const meta = document.getElementById("meta");
|
||||||
|
|
||||||
|
/* Kein Zoom. Ein Streifen ist bis zu 6480 px breit und wird immer auf die
|
||||||
|
Fensterbreite eingepasst; gescrollt wird nur hoch und runter. Ein
|
||||||
|
Zwischenstand hatte Rad-Zoom und Zoomknöpfe -- das war doppelt gemoppelt,
|
||||||
|
weil die Post-Seite im iframe ihren eigenen Canvas mitbringt, und die
|
||||||
|
Beschriftungen skalierten nicht mit, standen beim Herauszoomen also
|
||||||
|
riesig über winzigen Streifen. */
|
||||||
|
const reihen = [];
|
||||||
|
|
||||||
|
function bauen() {
|
||||||
|
flaeche.innerHTML = "";
|
||||||
|
reihen.length = 0;
|
||||||
|
PROJEKTE.forEach(pr => {
|
||||||
|
const reihe = document.createElement("div");
|
||||||
|
reihe.className = "reihe";
|
||||||
|
const h2 = document.createElement("h2");
|
||||||
|
h2.innerHTML = "<b>" + pr.name + "</b>" + (pr.theme ? " · Theme " + pr.theme : "");
|
||||||
|
const rahmen = document.createElement("div");
|
||||||
|
rahmen.className = "rahmen";
|
||||||
|
const ram = document.createElement("iframe");
|
||||||
|
ram.src = pr.datei + "?nur=streifen";
|
||||||
|
ram.loading = "eager";
|
||||||
|
rahmen.appendChild(ram);
|
||||||
|
reihe.appendChild(h2); reihe.appendChild(rahmen);
|
||||||
|
flaeche.appendChild(reihe);
|
||||||
|
reihen.push({ pr, reihe, rahmen, ram, w: 6480, h: 1350, da: false });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Jede Post-Seite meldet ihre Streifenbreite, sobald sie steht. Die Zahl
|
||||||
|
der Slides ist je Projekt anders und kann sich ändern, deshalb wird sie
|
||||||
|
nicht hier gepflegt. */
|
||||||
|
window.addEventListener("message", e => {
|
||||||
|
const d = e.data;
|
||||||
|
if (!d || d.hg !== "streifen") return;
|
||||||
|
const r = reihen.find(x => x.ram.contentWindow === e.source);
|
||||||
|
if (!r) return;
|
||||||
|
r.w = d.w; r.h = d.h; r.da = true;
|
||||||
|
legen();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Eingepasst wird nur in die Breite, die Höhe ergibt sich und wird gescrollt.
|
||||||
|
const POLSTER = 54 * 2;
|
||||||
|
function passend() {
|
||||||
|
const breit = Math.max(...reihen.map(r => r.w), 1080);
|
||||||
|
return Math.min((scroller.clientWidth - POLSTER) / breit, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
function legen() {
|
||||||
|
const k = passend();
|
||||||
|
reihen.forEach(r => {
|
||||||
|
r.ram.style.width = r.w + "px";
|
||||||
|
r.ram.style.height = r.h + "px";
|
||||||
|
r.ram.style.transform = "scale(" + k + ")";
|
||||||
|
r.rahmen.style.width = Math.round(r.w * k) + "px";
|
||||||
|
r.rahmen.style.height = Math.round(r.h * k) + "px";
|
||||||
|
});
|
||||||
|
const offen = reihen.filter(r => r.da).length;
|
||||||
|
meta.textContent = offen < reihen.length
|
||||||
|
? "Lade Streifen ... " + offen + " von " + reihen.length
|
||||||
|
// Seit Post 3 gibt es Einzelbilder: "1 Slides" stand vorher da.
|
||||||
|
: reihen.length + " Projekte · " + reihen.map(r => {
|
||||||
|
const n = Math.round(r.w / 1080);
|
||||||
|
return n + (n === 1 ? " Slide" : " Slides");
|
||||||
|
}).join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kein Ziehen, kein Rad-Zoom: die Seite scrollt wie eine gewoehnliche Seite.
|
||||||
|
window.addEventListener("resize", legen);
|
||||||
|
|
||||||
|
// ─── Menü ────────────────────────────────────────────────────────────────
|
||||||
|
document.querySelectorAll(".menu").forEach(m => {
|
||||||
|
m.querySelector("button").addEventListener("click", e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const auf = m.hasAttribute("data-auf");
|
||||||
|
document.querySelectorAll(".menu").forEach(x => x.removeAttribute("data-auf"));
|
||||||
|
if (!auf) m.setAttribute("data-auf", "");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.addEventListener("click", e => {
|
||||||
|
const b = e.target.closest("button[data-gehe], button[data-klick]");
|
||||||
|
document.querySelectorAll(".menu").forEach(x => x.removeAttribute("data-auf"));
|
||||||
|
if (!b) return;
|
||||||
|
if (b.dataset.gehe) { location.href = b.dataset.gehe; return; }
|
||||||
|
if (b.dataset.klick === "neu") { bauen(); legen(); }
|
||||||
|
});
|
||||||
|
bauen();
|
||||||
|
legen();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
# Video-Komponenten
|
||||||
|
|
||||||
|
Einblendungen für den Videoschnitt, in derselben Bildsprache wie die Posts:
|
||||||
|
weiße Karten mit Haarlinie und weichem Schatten, ein gelber Akzent, Manrope.
|
||||||
|
Gedacht für Noahs Beitrag über den Betrieb, taugt aber für jeden Schnitt.
|
||||||
|
|
||||||
|
Jede Komponente liegt zweimal vor — als **Standbild** und als **Bildsequenz**
|
||||||
|
mit fertiger Ein- und Ausblendung. Alles mit Alphakanal, alles freigestellt:
|
||||||
|
kein Rand, keine Leinwand, keine Farbe hinter der Karte.
|
||||||
|
|
||||||
|
```
|
||||||
|
video/
|
||||||
|
├── komponenten.html die Vorlage, hier steht die Gestaltung
|
||||||
|
├── bauen.mjs rendert daraus die Dateien
|
||||||
|
├── liste.json was zuletzt gebaut wurde, mit Maßen
|
||||||
|
├── png/ Standbilder
|
||||||
|
└── sequenz/ je Komponente ein Ordner "-ein" und einer "-aus"
|
||||||
|
```
|
||||||
|
|
||||||
|
## In Premiere holen
|
||||||
|
|
||||||
|
**Standbild:** `png/<name>.png` in die Timeline ziehen. Ein- und Ausblendung
|
||||||
|
machst du selbst über Position und Deckkraft. Das ist der flexiblere Weg.
|
||||||
|
|
||||||
|
**Sequenz:** *Datei → Importieren*, im Ordner `sequenz/<name>-ein/` das **erste**
|
||||||
|
Bild anklicken und unten **„Bildsequenz"** ankreuzen. Premiere legt daraus
|
||||||
|
einen einzigen Clip an, nicht 20 Einzelbilder.
|
||||||
|
|
||||||
|
> **Eine Bildsequenz hat keine eigene Bildrate.** Die Dateien wissen nichts
|
||||||
|
> davon, wie schnell sie laufen sollen — Premiere vergibt sie beim Import aus
|
||||||
|
> *Einstellungen → Medien → Zeitbasis für unbestimmte Medien*. Ab Werk stehen
|
||||||
|
> dort meist 30 fps. Was das für die Dauer heißt, steht in der Tabelle unten.
|
||||||
|
> Ändern lässt es sich jederzeit: Rechtsklick auf den Clip im Projektfenster →
|
||||||
|
> *Ändern → Videomaterial interpretieren → Bildrate annehmen*. Die Bewegung
|
||||||
|
> wird davon nur schneller oder langsamer, nie ruckelig — es liegt für jedes
|
||||||
|
> Bild eine eigene Datei vor.
|
||||||
|
|
||||||
|
Der übliche Aufbau im Schnitt sind drei Teile hintereinander:
|
||||||
|
`<name>-ein` → `png/<name>.png` beliebig lang gezogen → `<name>-aus`.
|
||||||
|
Das letzte Bild von `-ein` und das erste von `-aus` sind das Standbild, die
|
||||||
|
Stöße sind also unsichtbar. Alle drei Teile haben dieselben Maße und denselben
|
||||||
|
Bildausschnitt — was du am ersten an Position und Größe einstellst, kannst du
|
||||||
|
auf die beiden anderen einfügen (*Bearbeiten → Einfügen → Attribute*).
|
||||||
|
|
||||||
|
Der Alphakanal kommt mit. Kein Keying, keine Maske, kein Mischmodus: die Karte
|
||||||
|
liegt auf einer eigenen Spur über dem Bild und der Rest ist durchsichtig.
|
||||||
|
|
||||||
|
### Dauer je Komponente
|
||||||
|
|
||||||
|
| Komponente | Bilder ein / aus | bei 25 fps | bei 30 fps |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `bauchbinde-noah` | 20 / 12 | 0,80 s / 0,48 s | 0,67 s / 0,40 s |
|
||||||
|
| alle `satz-*` | 18 / 11 | 0,72 s / 0,44 s | 0,60 s / 0,37 s |
|
||||||
|
| beide `stapel-*` | 22 / 13 | 0,88 s / 0,52 s | 0,73 s / 0,43 s |
|
||||||
|
| `ring-kern` | 12 / 7 | 0,48 s / 0,28 s | 0,40 s / 0,23 s |
|
||||||
|
| `logo-bug` | 14 / 8 | 0,56 s / 0,32 s | 0,47 s / 0,27 s |
|
||||||
|
| `wabenfeld` | **50 / 30** | **2,00 s / 1,20 s** | **1,67 s / 1,00 s** |
|
||||||
|
|
||||||
|
Mehr Bilder heißt längere Bewegung, nicht flüssigere: die Staffelung steckt in
|
||||||
|
Anteilen des Verlaufs, nicht in Zeitwerten. Wer eine langsamere Einblendung
|
||||||
|
will, erhöht `bilder` bei der Komponente in `komponenten.html` und baut neu.
|
||||||
|
|
||||||
|
**Das Wabenfeld tanzt aus der Reihe, und das ist Absicht.** Alle anderen
|
||||||
|
Einblendungen setzen ein Stichwort, während Noah spricht — die dürfen nichts
|
||||||
|
wollen und blenden deshalb schlicht auf. Das Wabenfeld ist das Bild zur
|
||||||
|
Aussage und baut sich auf: die Nabe steht, dann wachsen die Waben in fünf
|
||||||
|
Wellen von innen nach außen, und die Stränge ziehen jeweils kurz danach nach.
|
||||||
|
Rückwärts zieht sich alles wieder in die Marke zurück.
|
||||||
|
|
||||||
|
Die Bewegung ist die `introTimeline` aus `HoneycombSection.tsx` der Website,
|
||||||
|
nicht neu erfunden: `scale` von 0,16 auf 1, `back.out(1.75)`, gestaffelt nach
|
||||||
|
Abstand zur Mitte, danach die Stränge mit einer eigenen Deckungsrampe. Wer an
|
||||||
|
der Zeitrechnung dreht, findet sie als `WABE_ZEIT` in `komponenten.html`.
|
||||||
|
|
||||||
|
> **Nicht skalieren, wo es nicht sein muss.** Die Dateien sind in doppelter
|
||||||
|
> Größe gerendert (`FAKTOR` in `bauen.mjs`). In einer Zeitleiste mit
|
||||||
|
> 1920 × 1080 setzt du sie also auf rund 50 % — dann sitzt jeder Pixel, und
|
||||||
|
> nach oben ist bis 4K Luft.
|
||||||
|
|
||||||
|
## Was es gibt
|
||||||
|
|
||||||
|
| Schlüssel | Was es zeigt | Wofür im Skript |
|
||||||
|
|---|---|---|
|
||||||
|
| `bauchbinde-noah` | Noah Geerkens · Gründer | wenn er sich vorstellt |
|
||||||
|
| `satz-erp-bleibt` | „Dein ERP **bleibt**." | der Einwand, direkt beantwortet |
|
||||||
|
| `satz-kein-ersatz` | „Kein **Ersatz**." / „Der Aufbau drumherum." | die Kernaussage |
|
||||||
|
| `satz-miteinander-reden` | „Die Tools reden **miteinander**." | was gefehlt hat |
|
||||||
|
| `satz-handwerksalltag` | „Kein **Startup**-Projekt." | der Abbinder |
|
||||||
|
| `stapel-haengt-drin` | Stammdaten · Aufträge · Buchhaltung | was einen Wechsel unmöglich macht |
|
||||||
|
| `stapel-was-fehlte` | KI-Lösungen · CRM · Automatisierungen | was sie wollten |
|
||||||
|
| `ring-kern` | der Kern bleibt, HiveGrid liegt darum | „kein Ersatz, sondern der Aufbau drumherum" |
|
||||||
|
| `wabenfeld` | die Marke in der Mitte, die Programme drumherum | „dass die Tools endlich miteinander reden" |
|
||||||
|
| `logo-bug` | Wortmarke für die Ecke | durchgehend oder am Anfang |
|
||||||
|
|
||||||
|
> Eine Bauchbinde für den Betrieb ließ sich über `?name=` und `?rolle=` ohnehin
|
||||||
|
> aus der ersten erzeugen und ist deshalb weg, mit ihr der Stempel „Vierte
|
||||||
|
> Generation". Eine Endkarte gab es auch einmal — ein Schlussbild gehört in den
|
||||||
|
> Schnitt und nicht als Einblendung darauf.
|
||||||
|
|
||||||
|
**Wenig Text ist Absicht.** Noah spricht das Skript, die Einblendung setzt das
|
||||||
|
Stichwort. Zwei Sätze auf einer Karte lesen sich nicht, während jemand redet.
|
||||||
|
|
||||||
|
## Texte ändern
|
||||||
|
|
||||||
|
Die Texte hängen an der Adresse, nicht am Code. Server starten (`./serve.sh`),
|
||||||
|
dann zum Beispiel:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://127.0.0.1:4321/video/komponenten.html?k=satz-erp-bleibt&zeile=Dein *CRM* bleibt.
|
||||||
|
http://127.0.0.1:4321/video/komponenten.html?k=bauchbinde-noah&name=Lena Geerkens&rolle=Geschäftsführung
|
||||||
|
```
|
||||||
|
|
||||||
|
`*Wort*` setzt den gelben Akzent, **höchstens einer je Zeile** (rules.md §4).
|
||||||
|
Welche Felder eine Komponente kennt, steht bei ihr unter `felder` in
|
||||||
|
`komponenten.html`. `?alle=1` zeigt alle nebeneinander auf einem Karo-Grund.
|
||||||
|
|
||||||
|
Wer einen geänderten Text als Datei braucht, trägt ihn in `komponenten.html`
|
||||||
|
ein und baut neu — die Adresse wirkt nur im Browser.
|
||||||
|
|
||||||
|
## Neu bauen
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./serve.sh # muss laufen, in einem zweiten Terminal
|
||||||
|
node video/bauen.mjs # alles
|
||||||
|
node video/bauen.mjs bauchbinde satz # nur passende Schlüssel
|
||||||
|
```
|
||||||
|
|
||||||
|
Ein paar Entscheidungen, die dahinterstehen:
|
||||||
|
|
||||||
|
- **Bildsequenz statt Video mit Alphakanal.** Ein Video mit echter
|
||||||
|
Transparenz wäre ProRes 4444. Als das hier entstand, war kein ffmpeg auf der
|
||||||
|
Maschine; inzwischen schon (siehe `animation.mjs` im Wurzelverzeichnis).
|
||||||
|
Geblieben ist die Bildsequenz trotzdem: Premiere importiert sie genauso, sie
|
||||||
|
behält die Transparenz ohne Codec-Frage, und jedes Einzelbild lässt sich
|
||||||
|
ansehen. Ein ProRes-Ausgang wäre jetzt möglich, ist aber kein Gewinn.
|
||||||
|
- **Node statt Python**, anders als sonst im Repo. Gebraucht wird eine
|
||||||
|
Steuerverbindung zu Chrome, und die hat Node ohne Zusatzpaket an Bord.
|
||||||
|
Chrome pro Einzelbild neu zu starten wäre die Alternative gewesen — bei rund
|
||||||
|
380 Bildern sind das zehn Minuten Warten für nichts.
|
||||||
|
- **Der Ausschnitt wird gemessen, nicht angegeben.** `KOMP_KASTEN()` misst
|
||||||
|
nach dem Rendern, was tatsächlich dasteht, und legt 52 px Zugabe drumherum:
|
||||||
|
der Schatten liegt außerhalb der Box, und was aufblendet, startet 26 px
|
||||||
|
tiefer. Ohne die Zugabe schneidet das PNG beides ab.
|
||||||
|
- **Die Ausblendung ist die Einblendung rückwärts**, nur kürzer. Ein eigener
|
||||||
|
Ausgang sähe eine Spur besser aus und kostete doppelt so viele Bilder.
|
||||||
|
|
||||||
|
## Drei Fallen, in die wir hier schon getappt sind
|
||||||
|
|
||||||
|
**`document.fonts.ready` war erfüllt, bevor es etwas zu laden gab.** Die Seite
|
||||||
|
hat auf die Schrift gewartet, *bevor* die Karte im DOM stand — zu dem Zeitpunkt
|
||||||
|
brauchte niemand Manrope, es lief also kein Ladevorgang, und das Versprechen
|
||||||
|
löste sofort auf. Danach wurde die Karte gebaut, Chrome fing an zu laden, und ob
|
||||||
|
es bis zur Messung fertig war, entschied der Zufall. Dieselbe Komponente
|
||||||
|
hintereinander gemessen kam abwechselnd 984 und 1010 px breit heraus.
|
||||||
|
|
||||||
|
Das ist die Falle aus `rules.md` §4, nur eine Ebene früher: dort steht „auf
|
||||||
|
`document.fonts.ready` warten", und genau das tat die Seite — nur zu früh.
|
||||||
|
`document.fonts.load()` holt eine Schriftlage unabhängig davon, ob gerade jemand
|
||||||
|
sie benutzt; erst danach ist `ready` eine Aussage. Dieselbe Wette steckt in
|
||||||
|
jedem `<img>` mit `width:auto`, deshalb wartet der Aufbau jetzt auch auf die
|
||||||
|
Bilder.
|
||||||
|
|
||||||
|
> **Woran man es merkt:** an den Maßen in `liste.json`. Eine Komponente, die
|
||||||
|
> beim Neubau plötzlich 2 bis 4 Prozent schmaler wird, hat keine Textänderung
|
||||||
|
> hinter sich, sondern die Ersatzschrift. Die reinen Zeichnungen (`ring-kern`,
|
||||||
|
> `wabenfeld`) verraten es nicht — ihr SVG hat feste Maße.
|
||||||
|
|
||||||
|
**Der Schatten war nicht da.** Die Karte trug `clip-path` für die
|
||||||
|
abgeschnittenen Ecken und `box-shadow` für den Schatten — und `clip-path`
|
||||||
|
beschneidet den Schatten mit. Genau so steht es in `rules.md` §3, und genau so
|
||||||
|
ist es passiert. Am gerenderten PNG fiel die Deckung an der Kartenkante von
|
||||||
|
255 auf 0, ohne einen einzigen Zwischenwert. Der Schatten sitzt jetzt als
|
||||||
|
`filter: drop-shadow()` auf einer Hülle. Nebenbei folgt der der Alphamaske und
|
||||||
|
legt sich um die abgeschnittenen Ecken herum statt um ein Rechteck.
|
||||||
|
|
||||||
|
**`inset(0 0 0 0)` ist nicht dasselbe wie kein Beschnitt.** Die Einblendung
|
||||||
|
zieht die Karte über `clip-path: inset()` von links auf. Am Ende steht dort
|
||||||
|
rechnerisch null — das schneidet aber weiterhin exakt auf die Box und kappt
|
||||||
|
damit wieder den Schatten. Bei vollem Verlauf fällt der Beschnitt deshalb ganz
|
||||||
|
weg.
|
||||||
|
|
||||||
|
## Was fehlt
|
||||||
|
|
||||||
|
Der Grund, warum die Marken im Wabenfeld sitzen, steht in `rules.md` §3
|
||||||
|
unter *Wabenfeld*. Wer dort etwas ändert, muss an drei Dateien denken:
|
||||||
|
`post2-alt.html`, `post3.html` und diese hier. Das ist der Preis dafür, dass
|
||||||
|
jede Datei für sich lauffähig bleibt.
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/* Rendert komponenten.html zu PNGs mit Alphakanal und zu Bildsequenzen.
|
||||||
|
*
|
||||||
|
* node video/bauen.mjs alles neu
|
||||||
|
* node video/bauen.mjs bauchbinde nur passende Schluessel
|
||||||
|
*
|
||||||
|
* Warum Node und nicht Python wie sonst im Repo: gebraucht wird eine
|
||||||
|
* Steuerverbindung zu Chrome (CDP, ueber WebSocket), und die hat Node ohne
|
||||||
|
* Zusatzpaket an Bord, Python nicht. Der Umweg ueber "Chrome pro Bild neu
|
||||||
|
* starten" waere die Alternative gewesen -- bei rund 400 Einzelbildern und
|
||||||
|
* anderthalb Sekunden Startzeit sind das zehn Minuten Warten fuer nichts.
|
||||||
|
*
|
||||||
|
* Es laeuft ueber den Server aus dem Repo und nicht ueber file://, weil die
|
||||||
|
* Seite Schrift und Marken per fetch holt und das lokale Dateiprotokoll das
|
||||||
|
* verbietet.
|
||||||
|
*
|
||||||
|
* Kein ffmpeg noetig. Ein Video mit echtem Alphakanal waere ProRes 4444; eine
|
||||||
|
* Bildsequenz kann Premiere genauso importieren, behaelt die Transparenz ohne
|
||||||
|
* Codec-Frage und laesst sich Bild fuer Bild ansehen. (ffmpeg liegt inzwischen
|
||||||
|
* auf der Maschine -- animation.mjs im Wurzelverzeichnis benutzt es fuer die
|
||||||
|
* MP4s der Posts. Hier braucht es das trotzdem nicht.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { mkdir, writeFile, readFile, rm } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const HIER = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const BASIS = "http://127.0.0.1:4321/video/komponenten.html";
|
||||||
|
const CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
||||||
|
const PORT = 9333;
|
||||||
|
|
||||||
|
/* Aufloesung. Die Komponenten sind fuer eine Zeitleiste in 1920 x 1080
|
||||||
|
entworfen -- eine Bauchbinde misst dort rund 500 px. Zweifach gerendert
|
||||||
|
traegt sie damit auch in 4K und haelt eine Vergroesserung im Schnitt aus,
|
||||||
|
ohne dass die Sequenzen den Ordner sprengen. Standbild und Sequenz haben
|
||||||
|
denselben Faktor, sonst passen sie im Schnitt nicht uebereinander. */
|
||||||
|
const FAKTOR = 2;
|
||||||
|
|
||||||
|
/* Die Ausblendung ist die Einblendung rueckwaerts, nur kuerzer. Ein eigener
|
||||||
|
Ausgang saehe eine Spur besser aus und kostete die doppelte Zahl an
|
||||||
|
Bildern; bei einer halben Sekunde sieht das niemand. */
|
||||||
|
const AUS_ANTEIL = 0.6;
|
||||||
|
|
||||||
|
const filter = process.argv.slice(2);
|
||||||
|
const passt = key => !filter.length || filter.some(f => key.includes(f));
|
||||||
|
|
||||||
|
/* ── CDP, das Noetigste ──────────────────────────────────────────────────── */
|
||||||
|
async function verbinden(port) {
|
||||||
|
let ziele = null;
|
||||||
|
for (let i = 0; i < 50; 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 seite = ziele && ziele.find(t => t.type === "page");
|
||||||
|
if (!seite) throw new Error("Chrome antwortet nicht auf " + port);
|
||||||
|
|
||||||
|
const ws = new WebSocket(seite.webSocketDebuggerUrl);
|
||||||
|
const offen = new Map();
|
||||||
|
const horcher = [];
|
||||||
|
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); }
|
||||||
|
else if (m.method) horcher.forEach(h => h(m));
|
||||||
|
};
|
||||||
|
await new Promise((ok, weg) => { ws.onopen = ok; ws.onerror = weg; });
|
||||||
|
|
||||||
|
let n = 0;
|
||||||
|
const ruf = (method, params = {}) => new Promise((ok, weg) => {
|
||||||
|
const id = ++n;
|
||||||
|
offen.set(id, m => m.error ? weg(new Error(method + ": " + m.error.message)) : ok(m.result));
|
||||||
|
ws.send(JSON.stringify({ id, method, params }));
|
||||||
|
});
|
||||||
|
const einmal = ereignis => new Promise(ok => {
|
||||||
|
const h = m => { if (m.method === ereignis) { horcher.splice(horcher.indexOf(h), 1); ok(m); } };
|
||||||
|
horcher.push(h);
|
||||||
|
});
|
||||||
|
return { ruf, einmal, zu: () => ws.close() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ein Ausdruck in der Seite. Fehler dort sollen hier ankommen und nicht als
|
||||||
|
stilles undefined durchrutschen. */
|
||||||
|
async function werte(cdp, ausdruck) {
|
||||||
|
const r = await cdp.ruf("Runtime.evaluate", {
|
||||||
|
expression: ausdruck, awaitPromise: true, returnByValue: true,
|
||||||
|
});
|
||||||
|
if (r.exceptionDetails) {
|
||||||
|
const d = r.exceptionDetails;
|
||||||
|
throw new Error("in der Seite: " + (d.exception?.description || d.text));
|
||||||
|
}
|
||||||
|
return r.result.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function aufnehmen(cdp, kasten) {
|
||||||
|
const r = await cdp.ruf("Page.captureScreenshot", {
|
||||||
|
format: "png",
|
||||||
|
captureBeyondViewport: true,
|
||||||
|
clip: { x: kasten.x, y: kasten.y, width: kasten.w, height: kasten.h, scale: FAKTOR },
|
||||||
|
});
|
||||||
|
return Buffer.from(r.data, "base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Ablauf ──────────────────────────────────────────────────────────────── */
|
||||||
|
const chrome = spawn(CHROME, [
|
||||||
|
"--headless", "--disable-gpu", "--hide-scrollbars",
|
||||||
|
"--remote-debugging-port=" + PORT,
|
||||||
|
"--user-data-dir=" + path.join(HIER, ".chrome"),
|
||||||
|
"about:blank",
|
||||||
|
], { stdio: "ignore" });
|
||||||
|
|
||||||
|
let cdp;
|
||||||
|
try {
|
||||||
|
cdp = await verbinden(PORT);
|
||||||
|
await cdp.ruf("Page.enable");
|
||||||
|
await cdp.ruf("Runtime.enable");
|
||||||
|
// Ohne das steht Weiss unter der Seite und jedes PNG waere deckend.
|
||||||
|
await cdp.ruf("Emulation.setDefaultBackgroundColorOverride",
|
||||||
|
{ color: { r: 0, g: 0, b: 0, a: 0 } });
|
||||||
|
|
||||||
|
const laden = async adresse => {
|
||||||
|
await cdp.ruf("Page.navigate", { url: adresse });
|
||||||
|
await cdp.einmal("Page.loadEventFired");
|
||||||
|
await werte(cdp, "window.KOMP_BEREIT");
|
||||||
|
};
|
||||||
|
|
||||||
|
await laden(BASIS + "?k=");
|
||||||
|
const liste = (await werte(cdp, "window.KOMP_LISTE")).filter(k => passt(k.key));
|
||||||
|
if (!liste.length) throw new Error("kein Schluessel passt auf: " + filter.join(", "));
|
||||||
|
|
||||||
|
const pngDir = path.join(HIER, "png");
|
||||||
|
const seqDir = path.join(HIER, "sequenz");
|
||||||
|
await mkdir(pngDir, { recursive: true });
|
||||||
|
await mkdir(seqDir, { recursive: true });
|
||||||
|
|
||||||
|
let dateien = 0, bytes = 0;
|
||||||
|
const bericht = [];
|
||||||
|
|
||||||
|
for (const k of liste) {
|
||||||
|
// Die Buehne gross genug stellen, sonst schneidet der Viewport ab, bevor
|
||||||
|
// ueberhaupt gemessen werden kann.
|
||||||
|
await cdp.ruf("Emulation.setDeviceMetricsOverride", {
|
||||||
|
width: k.w, height: k.h, deviceScaleFactor: 1, mobile: false,
|
||||||
|
});
|
||||||
|
await laden(`${BASIS}?k=${encodeURIComponent(k.key)}&t=1`);
|
||||||
|
|
||||||
|
const kasten = await werte(cdp, "window.KOMP_KASTEN()");
|
||||||
|
|
||||||
|
// Sequenzen. Ein bis der Kasten steht, aus rueckwaerts und knapper.
|
||||||
|
const einN = k.bilder;
|
||||||
|
const ausN = Math.max(4, Math.round(k.bilder * AUS_ANTEIL));
|
||||||
|
let gehalten = null;
|
||||||
|
for (const [name, anzahl, richtung] of [["ein", einN, 1], ["aus", ausN, -1]]) {
|
||||||
|
const ordner = path.join(seqDir, k.key + "-" + name);
|
||||||
|
await rm(ordner, { recursive: true, force: true });
|
||||||
|
await mkdir(ordner, { recursive: true });
|
||||||
|
for (let i = 0; i < anzahl; i++) {
|
||||||
|
const p = i / (anzahl - 1);
|
||||||
|
await werte(cdp, "window.setT(" + (richtung > 0 ? p : 1 - p) + ")");
|
||||||
|
const bild = await aufnehmen(cdp, kasten);
|
||||||
|
if (richtung > 0 && i === anzahl - 1) gehalten = bild;
|
||||||
|
await writeFile(path.join(ordner, `${k.key}-${name}_${String(i).padStart(4, "0")}.png`), bild);
|
||||||
|
dateien++; bytes += bild.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Das Standbild ist das letzte Bild der Einblendung, nicht eine eigene
|
||||||
|
Aufnahme. Der ganze Dreiteiler im Schnitt haengt daran, dass -ein, das
|
||||||
|
Standbild und -aus an den Stoessen dasselbe Bild zeigen; als zweite
|
||||||
|
Aufnahme war das bisher nur wahr, weil es zufaellig stimmte.
|
||||||
|
|
||||||
|
Beim Wabenfeld stimmte es dann nicht mehr. Dort tragen die Zellen
|
||||||
|
waehrend der Bewegung ein transform-Attribut, das am Ende wieder
|
||||||
|
weggenommen wird -- danach rastert Chrome eine Spur anders als bei einem
|
||||||
|
Element, das nie eines hatte. Der Unterschied lag bei einer Stufe auf
|
||||||
|
verstreuten Pixeln, also unsichtbar, aber die Zusage war weg. So wird
|
||||||
|
sie eingehalten, statt geprueft: es ist dieselbe Datei. */
|
||||||
|
await writeFile(path.join(pngDir, k.key + ".png"), gehalten);
|
||||||
|
dateien++; bytes += gehalten.length;
|
||||||
|
const zeile = `${k.key.padEnd(24)} ${String(kasten.w * FAKTOR).padStart(5)} x ` +
|
||||||
|
`${String(kasten.h * FAKTOR).padEnd(5)} still + ${einN} ein + ${ausN} aus`;
|
||||||
|
console.log(" " + zeile);
|
||||||
|
bericht.push({ key: k.key, name: k.name, w: kasten.w * FAKTOR, h: kasten.h * FAKTOR,
|
||||||
|
ein: einN, aus: ausN });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der Teilbau darf die Liste nicht auf sich selbst eindampfen: wer nur die
|
||||||
|
Bauchbinde neu baut, will danach trotzdem alle dreizehn darin finden.
|
||||||
|
Also wird zusammengefuehrt statt ueberschrieben, in der Reihenfolge der
|
||||||
|
Vorlage -- die bestimmt, was zusammengehoert, nicht der Zufall des
|
||||||
|
letzten Aufrufs. */
|
||||||
|
const vorher = JSON.parse(
|
||||||
|
await readFile(path.join(HIER, "liste.json"), "utf8").catch(() => '{"komponenten":[]}'));
|
||||||
|
const nachKey = new Map(vorher.komponenten.map(k => [k.key, k]));
|
||||||
|
bericht.forEach(k => nachKey.set(k.key, k));
|
||||||
|
const alle = (await werte(cdp, "window.KOMP_LISTE")).map(k => nachKey.get(k.key)).filter(Boolean);
|
||||||
|
await writeFile(path.join(HIER, "liste.json"),
|
||||||
|
JSON.stringify({ faktor: FAKTOR, komponenten: alle }, null, 1) + "\n");
|
||||||
|
|
||||||
|
console.log(`\n${liste.length} Komponenten, ${dateien} Dateien, ` +
|
||||||
|
`${(bytes / 1024 / 1024).toFixed(1)} MB`);
|
||||||
|
} finally {
|
||||||
|
if (cdp) cdp.zu();
|
||||||
|
chrome.kill();
|
||||||
|
/* Erst warten, bis Chrome wirklich weg ist. Ein Kill ist eine Bitte, kein
|
||||||
|
Vollzug: beim Herunterfahren schreibt der Browser noch in sein Profil,
|
||||||
|
und ein rm davor liess den Ordner wieder auferstehen. */
|
||||||
|
await new Promise(ok => chrome.once("exit", ok));
|
||||||
|
// Das Profil ist Wegwerfware und hat im Repo nichts verloren.
|
||||||
|
await rm(path.join(HIER, ".chrome"), { recursive: true, force: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
{
|
||||||
|
"faktor": 2,
|
||||||
|
"komponenten": [
|
||||||
|
{
|
||||||
|
"key": "bauchbinde-noah",
|
||||||
|
"name": "Bauchbinde · Noah",
|
||||||
|
"w": 1202,
|
||||||
|
"h": 490,
|
||||||
|
"ein": 20,
|
||||||
|
"aus": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "satz-erp-bleibt",
|
||||||
|
"name": "Satz · Dein ERP bleibt",
|
||||||
|
"w": 1140,
|
||||||
|
"h": 490,
|
||||||
|
"ein": 18,
|
||||||
|
"aus": 11
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "satz-kein-ersatz",
|
||||||
|
"name": "Satz · Kein Ersatz",
|
||||||
|
"w": 984,
|
||||||
|
"h": 588,
|
||||||
|
"ein": 18,
|
||||||
|
"aus": 11
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "satz-miteinander-reden",
|
||||||
|
"name": "Satz · Miteinander reden",
|
||||||
|
"w": 1722,
|
||||||
|
"h": 490,
|
||||||
|
"ein": 18,
|
||||||
|
"aus": 11
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "satz-handwerksalltag",
|
||||||
|
"name": "Satz · Handwerksalltag",
|
||||||
|
"w": 1390,
|
||||||
|
"h": 588,
|
||||||
|
"ein": 18,
|
||||||
|
"aus": 11
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "stapel-haengt-drin",
|
||||||
|
"name": "Stapel · Was drin haengt",
|
||||||
|
"w": 858,
|
||||||
|
"h": 824,
|
||||||
|
"ein": 22,
|
||||||
|
"aus": 13
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "stapel-was-fehlte",
|
||||||
|
"name": "Stapel · Was gefehlt hat",
|
||||||
|
"w": 1038,
|
||||||
|
"h": 824,
|
||||||
|
"ein": 22,
|
||||||
|
"aus": 13
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "ring-kern",
|
||||||
|
"name": "Ring · Dein Programm bleibt",
|
||||||
|
"w": 2184,
|
||||||
|
"h": 1668,
|
||||||
|
"ein": 12,
|
||||||
|
"aus": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "wabenfeld",
|
||||||
|
"name": "Wabenfeld · Alles verbunden",
|
||||||
|
"w": 2168,
|
||||||
|
"h": 1906,
|
||||||
|
"ein": 50,
|
||||||
|
"aus": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "logo-bug",
|
||||||
|
"name": "Logo · Ecke",
|
||||||
|
"w": 1620,
|
||||||
|
"h": 380,
|
||||||
|
"ein": 14,
|
||||||
|
"aus": 8
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||