Post 6: fuenfte Slide fuer den mobilen Chat, Tiefe ueber den Stapel

Zwei Nachbesserungen an Post 6: eine Slide mehr und Zeichnungen, die nicht
mehr flach aussehen.

DIE FUENFTE SLIDE KOMMT VON DER WEBSITE
Die App gibt es noch nicht, und die Website sagt das bereits. In
components/sections/ChatDemoSection.tsx steht unter der Fahne comingSoon
genau dieser Zustand: ein Handy-Mockup, verwischt, und darueber ein
Absperrband aus dem Bau -- gedreht um -13 Grad, in accent-yellow F1B729 mit
Kanten in 171106, darauf immer wieder Schutzhelm und BALD VERFUEGBAR.

Ein Zwischenstand hatte hier einen selbst erfundenen gelben Aufkleber
"In Arbeit". Das war eine zweite Bildsprache neben einer vorhandenen.
Uebernommen ist jetzt die der Website, samt Winkel, Farben und Wortlaut.

Auch der Inhalt des Schirms kommt von dort und nicht aus dem Pitch-Paket:
Heizungsprojekt Schulz, "4 Mitglieder · 2 online", die vier Nachrichten aus
seedMessages, die Blasenfarben 0D6E6E fuer eigene und FFFFFF mit Rand E9E7E5
fuer fremde, der warme Grund hsl(30 17% 95.5%). Ueberschrift und Fliesstext
ebenso: "Der Chat fuer unterwegs wird gerade gebaut."

Das Geraet selbst ist eine bewusste Abweichung. Die Website zeichnet nur
einen hellen Umriss, border 4px D1D5DB; auf einer Slide, die selbst hell ist,
faellt der in sich zusammen und sieht nach Platzhalter aus. Hier steht ein
richtiges Geraet: dunkler Korpus, heller Grat, Schirm eingelassen, Insel und
Tasten. Die Abweichung betrifft den Rahmen, nicht den Inhalt. Verwischt wird
schwaecher als dort -- auf der Website liegt das Handy gross neben Fliesstext
und darf unlesbar sein, hier ist es klein, und bei voller Staerke blieb Brei.

DIE TIEFE
Zwei Anlaeufe waren falsch. Der erste legte graue Flaechen hinter die
Elemente: das nimmt den Zeichnungen ihre Durchsichtigkeit und setzt einen
Kasten auf den Punktgrund, der dort nicht hingehoert. Wieder raus, die
Zeichnungen sind wieder durchsichtig.

Der zweite waren bessere Schatten allein. Richtig, aber zu wenig. Die
Schatten des Produkts sind in ui/theme.css gestaffelt und nicht einlagig:

  --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 Flaeche, die weite helle hebt es davon ab. In SVG zwei
feDropShadow im selben Filter. Vorher stand dort einer mit dy 7.

Gebracht hat es der Stapel. post1.html hat ihn als Bildrahmen im CSS, fuer
Screenshots; hier ist er in SVG nachgebaut, mit denselben Werten:

  .sheet-mid   rotate(-2.4deg) translate(-2.2%, 3%)  scale(.975)
  .sheet-back  rotate( 3.2deg) translate( 3%,  5.5%) scale(.95)

Er gibt Tiefe, die ein Schatten allein nicht schafft, und sagt, dass hinter
der einen Ansicht weitere liegen. Die hinteren Blaetter 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, und nicht auf
Slide 4, die eine Ueberlagerung ist.

AUS DER QUELLE NACHGEHOLT
Beim gruendlichen Durchgang durch GeneralTeamChatPage.tsx sind drei Dinge
aufgefallen, die vorher fehlten oder falsch waren:

- Die Datumsmarke "Heute · 30.07.2026" ueber dem ersten Beitrag und die Zeile
  "Maike Berger schreibt gerade ..." ueber dem Eingabefeld. Beides gibt der
  Unterhaltung Struktur, ohne eine Flaeche zu behaupten.
- Der offene Kanal ist grau hinterlegt mit Rand und shadow-sm, nicht violett.
- Die Blase muss so breit sein, wie ihr Anhang es braucht. Vorher richtete
  sie sich nur nach dem Fliesstext, und "Materialliste pruefen · faellig
  31.07.2026" wurde abgeschnitten -- an einer Stelle, an der der Titel die
  Aussage traegt.

ZWEI FALLEN
Gedrehte Blaetter ragen aus dem Kasten. Das hinterste steht um 3,2 Grad
gedreht und um 3 und 5,5 Prozent verschoben; seine Ecken lagen bis zu 24 px
ausserhalb der 92-px-Schutzzone. Der Ueberstand wird jetzt gerechnet:
stapelRand() schickt die vier Ecken durch dieselbe Kette wie das Blatt.

getBBox() kennt keine Beschneidung. Das Absperrband war erst auf die 175 %
der Website gezogen und am Geraet abgeschnitten -- im Bild richtig, in der
Messung ein Ueberstand von 146 px, den es nicht gab. Es ist jetzt nur so
lang, wie es bei -13 Grad quer ueber das Geraet sein muss.

GEPRUEFT
Raender 93 / 227 / 110 / 140 / 350, Luft 116 / 79 / 190 / 141 / 77 px, alle
Ueberschriften innerhalb der Schutzzone, 214 CSS-Regeln, Konsole ohne Fehler,
fuenf PNGs aus dem echten Ausgabeweg bei 2160 x 2700. Downloads blieb leer,
der Projektstand unveraendert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
dejanandClaude Opus 5 committed 2026-08-24 17:24:45 +02:00
1 parent 7723e5bf5a
commit ce87a7fb57
4 files changed
+511 -37

No files matched your search

+109 -6
View File
@@ -8,8 +8,8 @@ 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`.
**Vier Slides.** Fünf waren erlaubt; der fünfte Punkt wäre DSGVO und deutsche
Server gewesen, und dafür gibt es keinen Bildschirm. Er steht in der
**Fünf Slides.** DSGVO und deutsche Server hätten einen sechsten Punkt
ergeben, dafür gibt es aber keinen Bildschirm. Der steht in der
Bildunterschrift, wo eine Aussage ohne Bild hingehört.
---
@@ -22,6 +22,7 @@ Bildunterschrift, wo eine Aussage ohne Bild hingehört.
| 2 | Die Kanäle | Ein Kanal **pro Projekt.** | Die Chatliste groß: Projektkanäle, Direktnachrichten, Gruppe |
| 3 | Die Aufgabe | Foto rein, **Aufgabe raus.** | Baustellenfotos, die Aufgabe daraus, die Bestätigung im Projekt |
| 4 | Das Pluszeichen | Nicht nur **Text.** | Das Menü hinter dem Plus im Eingabefeld |
| 5 | Bald verfügbar | Der Chat für unterwegs **wird gerade gebaut.** | Das Handy-Mockup der Website, verwischt, mit Absperrband |
**Slide 3 ist der Kern.** Der Redaktionsplan verlangt „Foto rein, Aufgabe
raus", und genau das liegt im Werkzeug als Abfolge vor: Tobias schickt die
@@ -110,14 +111,14 @@ Beitrag zu setzen, der von der eigenen Baustelle spricht, wäre eine Behauptung
| | |
|---|---|
| Slides | 4 |
| Slides | 5 |
| Format | 1080 × 1350, Ausgabe zweifach: 2160 × 2700 |
| Luft zwischen Text und Zeichnung | 102 / 61 / 254 / 141 px (Grenze 40) |
| Luft zwischen Text und Zeichnung | 116 / 79 / 190 / 141 / 77 px (Grenze 40) |
| Zeichnung ragt über die Seite | nein, alle innerhalb der 92-px-Schutzzone |
| Breiteste Überschriftzeile | 818 px von 896 |
| Stylesheet vollständig | 214 Regeln, `[data-edit]` vorhanden |
| Konsole | keine Fehler |
| Ausgabeweg | geprüft, `01-chat-hell.png` bis `04-menue-hell.png` |
| Ausgabeweg | geprüft, `01-chat-hell.png` bis `05-handy-hell.png` |
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
@@ -127,7 +128,94 @@ Ausgabeweg und fängt `/save` ab.
---
## 6. Drei Fallen aus diesem Bau
## 5a. Slide 5 kommt von der Website, nicht von mir
**Das Gerät ist eine bewusste Abweichung.** Die Website zeichnet nur einen
hellen Umriss (`border: 4px solid #D1D5DB`). Auf einer Slide, die selbst hell
ist, fällt der in sich zusammen und sieht nach Platzhalter aus. Hier steht
deshalb ein richtiges Gerät: dunkler Korpus, heller Grat, Schirm eingelassen,
Tasten an der Seite. Die Abweichung betrifft den Rahmen, nicht den Inhalt. Der
Schirm wird auch schwächer verwischt als dort: auf der Website liegt das Handy
groß neben Fließtext und darf unlesbar sein, hier ist es klein, und bei voller
Stärke blieb nur Brei.
**Die App gibt es noch nicht, und die Website sagt das bereits.** In
`components/sections/ChatDemoSection.tsx` steht unter der Fahne `comingSoon`
genau dieser Zustand: ein Handy-Mockup, das mit `blur(3px) saturate(0.92)`
verwischt wird, und darüber ein **Absperrband** aus dem Bau, gedreht um
**-13°**, in `--accent-yellow` `#F1B729` mit Kanten in `#171106`, darauf immer
wieder Schutzhelm und **BALD VERFÜGBAR**.
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.
Auch der Inhalt des Schirms kommt von dort und **nicht** aus dem Pitch-Paket:
Heizungsprojekt Schulz, „4 Mitglieder · 2 online", die vier Nachrichten aus
`seedMessages`, die Blasenfarben `#0D6E6E` für eigene und `#FFFFFF` mit Rand
`#E9E7E5` für fremde, der warme Grund `hsl(30 17% 95.5%)`. Auf der Website
steht dieses Handy für den mobilen Chat, also zeigt es hier dasselbe.
Ü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.
---
## 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 und 2 liegt er, weil dort ein Bildschirm zu sehen ist. Auf Slide 3
nicht: ein Ausschnitt aus einer Unterhaltung ist kein Blatt, dort tragen die
Blasen ihre eigene Erhebung. Slide 4 ist eine Überlagerung und trägt
`--shadow-overlay`.
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. Fünf 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
@@ -150,6 +238,21 @@ 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.
**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.
**`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.
+22 -4
View File
@@ -26,7 +26,7 @@ Downloads landen direkt in `~/Downloads`, ohne Browser-Dialog.
| 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, 4 Slides, Theme `modern`, erscheint 26.08. | [`POST-6.md`](./POST-6.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
@@ -224,6 +224,7 @@ Baustellenfotos.
| 2 | die Chatliste groß: Projektkanäle, Direktnachrichten, Gruppe |
| 3 | Fotos, die Aufgabe daraus, die Bestätigung im Projekt |
| 4 | das Menü hinter dem Plus im Eingabefeld |
| 5 | das Handy-Mockup der Website, verwischt, 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,
@@ -231,9 +232,26 @@ 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.
**Vier Slides, nicht fünf.** Der fünfte Punkt wäre DSGVO und deutsche Server
gewesen. Dafür gibt es keinen Bildschirm, und eine Behauptung ohne Bild gehört
in die Bildunterschrift.
**Slide 5 kommt von der Website, nicht aus dem Pitch-Paket.** Die App gibt es
noch nicht, und `ChatDemoSection.tsx` hat den Zustand bereits: ein Handy-Mockup
mit `blur(3px)` und darüber ein **Absperrband** aus dem Bau, -13° gedreht, in
`#F1B729` mit Kanten in `#171106`, darauf Schutzhelm und „BALD VERFÜGBAR".
Auch Inhalt, Blasenfarben, Überschrift und Fließtext sind die der Website. Ein
Zwischenstand hatte hier einen selbst erfundenen Aufkleber; das war eine eigene
Bildsprache neben einer vorhandenen.
**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
+366 -27
View File
@@ -1305,7 +1305,7 @@ if (location.search.indexOf("nur=streifen") >= 0)
eyebrow: "Team-Chat",
head: 'Büro und Baustelle,<br><span class="a">endlich im selben Chat.</span>',
body: "",
vis: "chat", visW: 896, visLeft: 92, visBottom: 120,
vis: "chat", visW: 948, visLeft: 66, visBottom: 120,
},
{
/* Der Fliesstext ist das Merkmal "Themen-Kanäle" von der Website,
@@ -1316,7 +1316,7 @@ if (location.search.indexOf("nur=streifen") >= 0)
eyebrow: "Die Kanäle",
head: 'Ein Kanal<br><span class="a">pro Projekt.</span>',
body: "Projektkanäle, Direktnachrichten, Gruppen. Jeder Kanal hängt an seiner Projektnummer.",
vis: "kanaele", visW: 620, visLeft: 230, visBottom: 120,
vis: "kanaele", visW: 672, visLeft: 204, visBottom: 120,
},
{
/* Der Satz aus dem Redaktionsplan: "Foto rein, Aufgabe raus." Die
@@ -1337,6 +1337,22 @@ if (location.search.indexOf("nur=streifen") >= 0)
body: "Dokument, Fotos, Sprachnachricht, Kontakt. Und wenn daraus etwas werden soll: Termin oder Aufgabe.",
vis: "menue", visW: 800, visLeft: 140, visBottom: 120,
},
{
/* Ein Ausblick, kein Beleg. Die App gibt es noch nicht -- deshalb steht
die Marke "In Arbeit" ueber dem Geraet, und der Fliesstext sagt es
geradeheraus. Auf dem Schirm ist nichts Neues: dieselben zwei
Nachrichten wie auf Slide 1, nur schmaler gesetzt.
"Im Buero am Rechner, auf der Baustelle am Handy" steht als Merkmal
"Desktop & Mobile" auf der Website. Der Satz beschreibt das Ziel,
nicht den heutigen Stand -- deshalb ist er hier nicht zitiert. */
key: "handy", type: "feature",
layouts: [{ id: "standard", label: "Standard" }],
eyebrow: "Bald verfügbar",
head: 'Der Chat für unterwegs<br><span class="a">wird gerade gebaut.</span>',
body: "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.",
vis: "handy", visW: 358, visLeft: 361, visBottom: 120,
},
];
/* Vorlage für eine selbst angelegte Slide. Sie startet bewusst mit Text
@@ -1482,10 +1498,45 @@ if (location.search.indexOf("nur=streifen") >= 0)
mit beschnitten (lib/chamfer.ts der Website). Die doppelte id in
mehreren Blaettern stoert nicht: alle Filter sind gleich, und url(#..)
nimmt den ersten. */
/* Die Schatten des Produkts, aufgeloest aus ui/theme.css. Sie sind
gestaffelt und nicht einlagig -- genau daran hing es, dass die
Zeichnungen flach aussahen:
--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 Flaeche, die weite helle hebt es davon ab. In
SVG heisst das zwei feDropShadow hintereinander im selben Filter.
Der negative Spread der CSS-Fassung (-2px, -4px) laesst sich in SVG nicht
nachbilden; er wird ueber eine etwas kleinere Streuung angenaehert. */
/* Wie wfSvg, aber mit Rand: der Inhalt wird um `rand` verschoben und der
Kasten entsprechend groesser. Damit liegt eine Flaeche, die bei -rand
beginnt, genau auf der Kante des Kastens -- und die Randmessung sieht
wieder, was wirklich dasteht. */
const svgMitRand = (b, h, rand, inhalt) =>
wfSvg(b + 2 * rand, h + 2 * rand,
'<g transform="translate(' + rand + ',' + rand + ')">' + inhalt + '</g>');
const SCHATTEN_DEFS =
'<defs><filter id="hgschatten" x="-35%" y="-35%" width="170%" height="170%">' +
'<feDropShadow dx="0" dy="7" stdDeviation="10" flood-color="#101828" ' +
'flood-opacity="0.14"/></filter></defs>';
'<defs>' +
'<filter id="hgklein" x="-40%" y="-40%" width="180%" height="180%">' +
'<feDropShadow dx="0" dy="1" stdDeviation="1" flood-color="#000000" flood-opacity="0.08"/>' +
'</filter>' +
'<filter id="hgschatten" x="-45%" y="-45%" width="190%" height="190%">' +
'<feDropShadow dx="0" dy="2" stdDeviation="1.6" flood-color="#000000" flood-opacity="0.08"/>' +
'<feDropShadow dx="0" dy="4" stdDeviation="6" flood-color="#000000" flood-opacity="0.08"/>' +
'</filter>' +
'<filter id="hgblur" x="-10%" y="-10%" width="120%" height="120%">' +
'<feGaussianBlur stdDeviation="3.6"/>' +
'</filter>' +
'<filter id="hgtief" x="-45%" y="-45%" width="190%" height="190%">' +
'<feDropShadow dx="0" dy="4" stdDeviation="3" flood-color="#000000" flood-opacity="0.08"/>' +
'<feDropShadow dx="0" dy="12" stdDeviation="12" flood-color="#000000" flood-opacity="0.10"/>' +
'</filter>' +
'</defs>';
// Eine Karte: Flaeche, Haarlinie, Schatten. Das ist die Grundform des Themes.
const karteFlaeche = (x, y, w, h, c, fuell) =>
@@ -1822,6 +1873,8 @@ if (location.search.indexOf("nur=streifen") >= 0)
und der <svg>-Rahmen muss weg, sonst rendert ein einkindriges Zeichen
nicht. */
const FEATHER = {
"hard-hat": "<path d=\"M10 10V5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v5\"/> <path d=\"M14 6a6 6 0 0 1 6 6v3\"/> <path d=\"M4 15v-3a6 6 0 0 1 6-6\"/> <rect x=\"2\" y=\"15\" width=\"20\" height=\"4\" rx=\"1\"/>",
"building": "<rect width=\"16\" height=\"20\" x=\"4\" y=\"2\" rx=\"2\"/> <path d=\"M9 22v-4h6v4\"/> <path d=\"M8 6h.01\"/> <path d=\"M16 6h.01\"/> <path d=\"M12 6h.01\"/> <path d=\"M12 10h.01\"/> <path d=\"M12 14h.01\"/> <path d=\"M16 10h.01\"/> <path d=\"M16 14h.01\"/> <path d=\"M8 10h.01\"/> <path d=\"M8 14h.01\"/>",
"briefcase": "<rect width=\"20\" height=\"14\" x=\"2\" y=\"7\" rx=\"2\" ry=\"2\"/> <path d=\"M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16\"/>",
"folder-open": "<path d=\"m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2\"/>",
"mic": "<path d=\"M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z\"/> <path d=\"M19 10v2a7 7 0 0 1-14 0v-2\"/> <line x1=\"12\" x2=\"12\" y1=\"19\" y2=\"22\"/>",
@@ -2381,6 +2434,20 @@ if (location.search.indexOf("nur=streifen") >= 0)
Alle Namen, Zeiten, Vorschautexte und Anhaenge stehen woertlich in CHATS
und INITIAL_MESSAGES. Erfunden ist nichts. */
const CHAT_TEXT = "#24242B", CHAT_TEXT2 = "#505057", CHAT_TEXT3 = "#858589";
/* Die drei Flaechen des Werkzeugs, aufgeloest aus ui/theme.css:
surface-neutral-primary grey-50 #F5F4F4 der Grund der Anwendung
surface-neutral-tertiary grey-100 #F0F0F0 die Unterhaltungsspalte
surface-neutral-secondary grey-0 #FAFAFA Karten und Blasen
Auf den Slides werden sie nicht als Hintergrund gemalt -- die
Zeichnungen bleiben durchsichtig und stehen auf dem Punktgrund der
Slide. Gebraucht werden die Werte dort, wo eine Flaeche wirklich eine
ist: im Schirm des Geraets auf Slide 5.
Karten und Blasen stehen auf reinem Weiss statt auf #FAFAFA. Auf dem
hellen Blatt der Slide waere der Unterschied sonst nicht mehr zu sehen
und die Blase verloere ihre Kante. */
const FL_GRUND = "#F5F4F4", FL_SPALTE = "#F0F0F0", FL_KARTE = "#FFFFFF";
const CHAT_RAND = "#E5E5E5";
/* Die Chatliste links. type steuert das Sinnbild davor: ein Projekt bekommt
@@ -2420,8 +2487,12 @@ if (location.search.indexOf("nur=streifen") >= 0)
function chatZeile(x, y, c) {
let g = "";
if (c.aktiv) {
g += '<rect x="' + n1(x) + '" y="' + n1(y) + '" width="' + LISTE_B + '" height="' +
ZEILE_H + '" rx="10" fill="#F0E7FF"/>';
/* Der offene Kanal ist hell hinterlegt, mit Rand und feinem Schatten --
nicht farbig. Der erste Nachbau hatte ihn violett; im Original steht
dort bg-surface-neutral-primary mit border und shadow-sm. */
g += '<g filter="url(#hgklein)"><rect x="' + n1(x) + '" y="' + n1(y) + '" width="' +
LISTE_B + '" height="' + ZEILE_H + '" rx="10" fill="' + FL_KARTE +
'" stroke="' + CHAT_RAND + '" stroke-width="1.2"/></g>';
}
const mx = x + 16, my = y + ZEILE_H / 2;
if (c.art === "direkt") {
@@ -2471,9 +2542,16 @@ if (location.search.indexOf("nur=streifen") >= 0)
const BLASE_MAX = 470;
const blaseBreite = spalte => Math.min(BLASE_MAX, spalte - 60);
/* Wie breit eine Anhangkarte sein muss, damit ihr Titel ungekuerzt
hineinpasst. Ohne diese Rechnung war die Blase nur so breit wie ihr
Fliesstext, und "Materialliste pruefen · faellig 31.07.2026" wurde
abgeschnitten -- an einer Stelle, an der der Titel die Aussage traegt. */
const anhangNoetig = a =>
52 + messen(a[1], 15, 600) + (a[3] ? messen(a[3], 13, 800) + 56 : 0) + 12;
function anhangKarte(x, y, b, ikon, titel, meta, marke) {
let g = '<rect x="' + n1(x) + '" y="' + n1(y) + '" width="' + n1(b) + '" height="54" rx="9" ' +
'fill="#FFFFFF" stroke="' + CHAT_RAND + '" stroke-width="1.2"/>';
'fill="' + FL_KARTE + '" stroke="' + CHAT_RAND + '" stroke-width="1.2"/>';
g += '<rect x="' + n1(x + 10) + '" y="' + n1(y + 11) + '" width="32" height="32" rx="8" ' +
'fill="#FAFAFA" stroke="' + CHAT_RAND + '" stroke-width="1.1"/>';
g += feather(ikon, x + 26, y + 27, 17, CHAT_TEXT2, 1.9);
@@ -2496,7 +2574,7 @@ if (location.search.indexOf("nur=streifen") >= 0)
let b = 32 + Math.max(
...zeilen.map(z => messen(z, 17, 400)),
m.mein ? 0 : messen(m.autor, 14, 800),
m.anhang ? 300 : 0, 90);
m.anhang ? anhangNoetig(m.anhang) : 0, 90);
return { zeilen, hoehe: Math.round(h), breite: Math.round(Math.min(b, max || BLASE_MAX)) };
}
@@ -2520,7 +2598,7 @@ if (location.search.indexOf("nur=streifen") >= 0)
}
const kontur = rundRechteck(bx, y, ma.breite, ma.hoehe, 14);
g += '<g filter="url(#hgschatten)"><path d="' + kontur + '" fill="' +
(m.mein ? "#C8FFF7" : "#FFFFFF") + '"/></g>';
(m.mein ? "#C8FFF7" : FL_KARTE) + '"/></g>';
if (!m.mein) g += '<path d="' + kontur + '" fill="none" stroke="' + CHAT_RAND +
'" stroke-width="1.2"/>';
let cy = y + 14;
@@ -2564,13 +2642,89 @@ if (location.search.indexOf("nur=streifen") >= 0)
];
/* Der Stapel-Look aus post1.html, in SVG nachgebaut. Dort macht ihn CSS
ueber zwei Blaetter hinter dem Bild:
.sheet-mid rotate(-2.4deg) translate(-2.2%, 3%) scale(.975)
.sheet-back rotate( 3.2deg) translate( 3%, 5.5%) scale(.95)
Dieselben Winkel und Verschiebungen, damit die Reihe zusammenpasst. In
CSS liegt der Drehpunkt in der Mitte und die Prozente beziehen sich auf
die eigene Groesse -- in SVG heisst das
translate(mitte) rotate() translate(prozent*groesse) scale() translate(-mitte).
Der Stapel traegt zwei Aufgaben: er gibt Tiefe, die ein Schatten allein
nicht schafft, und er sagt, dass hinter der einen Ansicht weitere
liegen. Deshalb sind die hinteren Blaetter leer -- sie sind Andeutung,
keine Kopie. */
/* Die beiden hinteren Blaetter je: Winkel, Verschiebung in Anteilen der
eigenen Groesse, Massstab, Fuellung. Reihenfolge wie in CSS -- erst
skalieren, dann verschieben, dann drehen. */
const STAPEL_BLAETTER = [
{ winkel: 3.2, px: 0.03, py: 0.055, skala: 0.95, fuell: "#F7F7F8" },
{ winkel: -2.4, px: -0.022, py: 0.03, skala: 0.975, fuell: "#FCFCFD" },
];
/* Wie weit ragen die gedrehten Blaetter ueber das vordere hinaus? Gerechnet
statt geschaetzt: die vier Ecken durch dieselbe Kette schicken und den
groessten Ueberstand nehmen. Der erste Anlauf hatte den Ueberstand
ignoriert -- die Blaetter standen dann bis zu 24 px ausserhalb der
92-px-Schutzzone. */
function stapelRand(b, h) {
let raus = 0;
for (const bl of STAPEL_BLAETTER) {
const co = Math.cos(bl.winkel * Math.PI / 180), si = Math.sin(bl.winkel * Math.PI / 180);
for (const [ex, ey] of [[-1, -1], [1, -1], [1, 1], [-1, 1]]) {
// Ecke relativ zur Mitte: skalieren, verschieben, drehen.
const x = ex * b / 2 * bl.skala + bl.px * b;
const y = ey * h / 2 * bl.skala + bl.py * h;
raus = Math.max(raus, Math.abs(x * co - y * si) - b / 2,
Math.abs(x * si + y * co) - h / 2);
}
}
return Math.ceil(raus) + 2;
}
/* Der Stapel-Look aus post1.html, in SVG nachgebaut. Dort macht ihn CSS
ueber zwei Blaetter hinter dem Bild, mit genau diesen Winkeln und
Verschiebungen -- damit die Reihe zusammenpasst.
Der Stapel traegt zwei Aufgaben: er gibt Tiefe, die ein Schatten allein
nicht schafft, und er sagt, dass hinter der einen Ansicht weitere
liegen. Deshalb sind die hinteren Blaetter leer -- sie sind Andeutung,
keine Kopie.
b und h sind die Groesse des vorderen Blattes. Der Kasten drumherum wird
um stapelRand() groesser; darum kuemmert sich stapelKasten(). */
function stapel(b, h, radius, inhalt) {
const m = stapelRand(b, h);
const blatt = bl => {
const cx = b / 2, cy = h / 2;
return '<g transform="translate(' + n1(cx + m) + ',' + n1(cy + m) + ') rotate(' +
bl.winkel + ') translate(' + n1(bl.px * b) + ',' + n1(bl.py * h) + ') scale(' +
bl.skala + ') translate(' + n1(-cx) + ',' + n1(-cy) + ')">' +
'<g filter="url(#hgschatten)"><path d="' + rundRechteck(0, 0, b, h, radius) +
'" fill="' + bl.fuell + '" stroke="' + CHAT_RAND + '" stroke-width="1.2"/></g></g>';
};
return blatt(STAPEL_BLAETTER[0]) + blatt(STAPEL_BLAETTER[1]) +
'<g transform="translate(' + m + ',' + m + ')">' +
'<g filter="url(#hgtief)"><path d="' + rundRechteck(0, 0, b, h, radius) +
'" fill="#FFFFFF" stroke="' + CHAT_RAND + '" stroke-width="1.2"/></g>' + inhalt + '</g>';
}
// Der Kasten, in den ein Stapel dieser Groesse hineinpasst.
const stapelKasten = (b, h, radius, inhalt) => {
const m = stapelRand(b, h);
return wfSvg(b + 2 * m, h + 2 * m, stapel(b, h, radius, inhalt));
};
/* ─── Slide 1 · Der Chat ─────────────────────────────────────────────────
Kanalliste links, Verlauf rechts. Im Profilraster ist die Slide eine
Kachel von rund 150 px -- dort ist kein Wort lesbar, und was ankommt,
sind zwei Spalten mit Blasen. Daran erkennt jemand einen Chat, bevor er
etwas gelesen hat. Dieselbe Erwaegung wie bei Post 3, 4 und 5. */
function visChat() {
const SP = 26, RECHTS = 440, W = LISTE_B + SP + RECHTS;
const SP = 26, RECHTS = 460, W = LISTE_B + SP + RECHTS, PAD = 26;
let g = "";
/* Die Filterzeile ueber der Liste. Die vier Woerter sind der Typ
@@ -2579,18 +2733,35 @@ if (location.search.indexOf("nur=streifen") >= 0)
["Alle", "Projekte", "Direkt", "Ungelesen"].forEach((f, i) => {
const b = Math.round(messen(f, 15, 700)) + 26;
g += '<rect x="' + n1(fx) + '" y="0" width="' + b + '" height="32" rx="8" fill="' +
(i === 0 ? "#24242B" : "#FFFFFF") + '" stroke="' + (i === 0 ? "#24242B" : CHAT_RAND) +
(i === 0 ? "#24242B" : FL_KARTE) + '" stroke="' + (i === 0 ? "#24242B" : CHAT_RAND) +
'" stroke-width="1.2"/>';
g += uiText(fx + b / 2, 21, f, 15, 700, i === 0 ? "#FFFFFF" : CHAT_TEXT2, { anker: "middle" });
fx += b + 8;
});
/* Die Unterhaltungsspalte liegt auf eigener Flaeche, eine Stufe dunkler
als die Blasen darauf. Das ist die Tiefe, die vorher fehlte: ohne sie
schwebten weisse Blasen auf weissem Grund. */
const listeO = 50;
CHATS.forEach((c, i) => { g += chatZeile(0, listeO + i * ZEILE_H, c); });
const listeH = listeO + CHATS.length * ZEILE_H;
// Die Kopfzeile des offenen Kanals, wie in ChatHeader.
const kx = LISTE_B + SP;
/* Keine Flaeche hinter der Unterhaltung. Ein Zwischenstand hatte hier
drei graue Stufen liegen, um Tiefe zu erzeugen -- das nimmt den
Zeichnungen aber ihre Durchsichtigkeit und setzt einen Kasten auf den
Punktgrund, der da nicht hingehoert. Die Tiefe kommt jetzt allein aus
den gestaffelten Schatten und daraus, dass die Blasen einen Rand
tragen und der offene Kanal als Karte liegt.
Die duenne Linie zwischen Liste und Unterhaltung bleibt: sie steht so
im Werkzeug (border-r an der Chatliste) und trennt zwei Spalten, ohne
eine Flaeche zu behaupten. */
g += '<path d="M' + n1(kx - 14) + ',-6V' + n1(listeH) + '" stroke="' + CHAT_RAND +
'" stroke-width="1.2"/>';
g = '<g transform="translate(' + PAD + ',' + PAD + ')">' + g + '</g>';
// Die Kopfzeile des offenen Kanals, wie in ChatHeader.
g += '<rect x="' + n1(kx) + '" y="0" width="40" height="40" rx="11" fill="#F0E7FF" ' +
'stroke="#e4d4ff" stroke-width="1.2"/>';
g += feather("briefcase", kx + 20, 20, 20, "#691CD4", 1.9);
@@ -2602,24 +2773,46 @@ if (location.search.indexOf("nur=streifen") >= 0)
/* Der Verlauf laeuft bis kurz vor das Eingabefeld. Was nicht mehr
hineinpasst, faellt weg -- ein Chat ist oben abgeschnitten, nicht
unten, aber fuer ein Standbild ist die Leserichtung wichtiger. */
const feldH = 64, platz = listeH - feldH - 20;
let y = 76;
const feldH = 64, tippH = 40, platz = listeH - feldH - tippH - 16;
/* Die Datumsmarke steht so ueber dem ersten Beitrag des Tages, als
Badge mit dem Ton brand-primary-subtle. Sie kostet wenig und sagt,
dass man einen Verlauf sieht und keine Liste. */
const dt = "Heute · 30.07.2026";
const dtB = Math.round(messen(dt, 14, 700)) + 28;
g += '<rect x="' + n1(kx + RECHTS / 2 - dtB / 2) + '" y="72" width="' + dtB +
'" height="30" rx="15" fill="#FCF6C5" stroke="#faeb8e" stroke-width="1.2"/>' +
uiText(kx + RECHTS / 2, 92, dt, 14, 700, "#9B5E0D", { anker: "middle" });
let y = 118;
for (const m of VERLAUF) {
if (y + blaseHoehe(m, RECHTS) > platz) break;
g += blase(kx, y, RECHTS, m);
y += blaseHoehe(m, RECHTS) + 14;
}
/* "Maike Berger schreibt gerade …" steht im Werkzeug als eigene Zeile
ueber dem Eingabefeld. Sie sagt in vier Woertern, dass da jemand
mitliest -- das traegt eine Standaufnahme besser als jede Bewegung. */
const tt = "Maike Berger schreibt gerade …";
const ttB = Math.round(messen(tt, 14, 500)) + 32;
const ty = listeH - feldH - tippH + 2;
g += '<g filter="url(#hgklein)"><rect x="' + n1(kx) + '" y="' + n1(ty) + '" width="' +
ttB + '" height="32" rx="16" fill="' + FL_KARTE + '" stroke="' + CHAT_RAND +
'" stroke-width="1.1"/></g>' +
uiText(kx + 16, ty + 21, tt, 14, 500, CHAT_TEXT3);
// Das Eingabefeld unten, wie in Composer.
const fy = listeH - feldH;
g += '<path d="M' + n1(kx) + ',' + n1(fy) + 'H' + n1(kx + RECHTS) + '" stroke="' +
CHAT_RAND + '" stroke-width="1.2"/>';
g += feather("plus", kx + 20, fy + 34, 21, CHAT_TEXT2, 2);
g += '<rect x="' + n1(kx + 42) + '" y="' + n1(fy + 14) + '" width="' + (RECHTS - 84) +
'" height="40" rx="20" fill="#FFFFFF" stroke="' + CHAT_RAND + '" stroke-width="1.2"/>';
g += '<g filter="url(#hgklein)"><rect x="' + n1(kx + 42) + '" y="' + n1(fy + 14) +
'" width="' + (RECHTS - 84) + '" height="40" rx="20" fill="' + FL_KARTE +
'" stroke="' + CHAT_RAND + '" stroke-width="1.2"/></g>';
g += uiText(kx + 62, fy + 40, "Nachricht schreiben …", 16, 400, "#C9C9CE");
g += feather("mic", kx + RECHTS - 20, fy + 34, 21, CHAT_TEXT2, 2);
return wfSvg(W, listeH, g);
return stapelKasten(W + 2 * PAD, listeH + 2 * PAD, 22, g);
}
/* ─── Slide 2 · Die Kanäle ───────────────────────────────────────────────
@@ -2632,9 +2825,10 @@ if (location.search.indexOf("nur=streifen") >= 0)
Direktnachricht, Gruppe -- und lassen die Zeilen gross genug, dass die
Projektnummer lesbar bleibt. Eine Liste scrollt ohnehin. */
const zeigen = CHATS.slice(0, 5);
const hoehe = zeigen.length * ZEILE_H, PAD = 22;
let g = "";
zeigen.forEach((c, i) => { g += chatZeile(0, i * ZEILE_H, c); });
return wfSvg(LISTE_B, zeigen.length * ZEILE_H, g);
zeigen.forEach((c, i) => { g += chatZeile(PAD, PAD + i * ZEILE_H, c); });
return stapelKasten(LISTE_B + 2 * PAD, hoehe + 2 * PAD, 20, g);
}
/* ─── Slide 3 · Aus der Nachricht wird die Aufgabe ───────────────────────
@@ -2643,13 +2837,16 @@ if (location.search.indexOf("nur=streifen") >= 0)
sie im Projekt liegt. Das ist der Satz aus dem Redaktionsplan --
"Foto rein, Aufgabe raus" -- und er steht so im Werkzeug. */
function visAufgabe() {
const W = 520;
let g = "", y = 0;
/* Schmaler gesetzt als die Spalte auf Slide 1: dadurch skaliert die
Zeichnung groesser und fuellt die Flaeche, statt mit einem Handbreit
Leere darueber zu stehen. */
const W = 470;
let inhalt = "", y = 0;
for (const m of VERLAUF.slice(2, 5)) {
g += blase(0, y, W, m);
inhalt += blase(0, y, W, m);
y += blaseHoehe(m, W) + 18;
}
return wfSvg(W, y - 18, g);
return wfSvg(W, y - 18, inhalt);
}
/* ─── Slide 4 · Was noch in den Chat passt ───────────────────────────────
@@ -2670,7 +2867,9 @@ if (location.search.indexOf("nur=streifen") >= 0)
const W = 470, ZH = 52, PAD = 14;
const hoehe = 2 * PAD + MENUE.reduce((a, e) => a + (e ? ZH : 13), 0);
const kontur = rundRechteck(0, 0, W, hoehe, 14);
let g = '<g filter="url(#hgschatten)"><path d="' + kontur + '" fill="#FFFFFF"/>' +
/* Ein aufgeklapptes Menue liegt ueber allem anderen. Im Werkzeug traegt
es --shadow-overlay, also dieselbe Staffelung wie --shadow-lg. */
let g = '<g filter="url(#hgtief)"><path d="' + kontur + '" fill="' + FL_KARTE + '"/>' +
'<path d="' + kontur + '" fill="none" stroke="' + CHAT_RAND + '" stroke-width="1.3"/></g>';
let y = PAD;
for (const e of MENUE) {
@@ -2687,18 +2886,158 @@ if (location.search.indexOf("nur=streifen") >= 0)
es schwebt die Liste im Nichts und man sieht nicht, woher sie kommt. */
const fy = hoehe + 18;
g += feather("plus", 20, fy + 26, 22, CHAT_TEXT2, 2);
g += '<rect x="44" y="' + n1(fy + 4) + '" width="' + (W - 88) + '" height="44" rx="22" ' +
'fill="#FFFFFF" stroke="' + CHAT_RAND + '" stroke-width="1.2"/>';
g += '<g filter="url(#hgklein)"><rect x="44" y="' + n1(fy + 4) + '" width="' + (W - 88) +
'" height="44" rx="22" fill="' + FL_KARTE + '" stroke="' + CHAT_RAND +
'" stroke-width="1.2"/></g>';
g += uiText(64, fy + 33, "Nachricht schreiben …", 17, 400, "#C9C9CE");
g += feather("mic", W - 20, fy + 26, 22, CHAT_TEXT2, 2);
return wfSvg(W, fy + 52, g);
}
/* ─── Slide 5 · Der Chat für unterwegs ──────────────────────────────────
Diese Slide erfindet nichts: die Website hat den Zustand bereits, in
components/sections/ChatDemoSection.tsx unter der Fahne `comingSoon`.
Dort steht ein Handy-Mockup, das verwischt wird (blur(3px)), und
darueber liegt ein Absperrband -- gedreht, mit Schutzhelm und der Zeile
"Bald verfuegbar", wieder und wieder.
Ich hatte das zuerst selbst gebaut, mit einem gelben Aufkleber "In
Arbeit". Das war eine eigene Erfindung neben einer vorhandenen
Bildsprache. Uebernommen ist jetzt die der Website, samt Winkel (-13°),
Farben (#F1B729 auf #171106) und Wortlaut.
Auch der Inhalt des Schirms kommt von dort und nicht aus dem Pitch-Paket:
Heizungsprojekt Schulz, 4 Mitglieder · 2 online, die Nachrichten aus
seedMessages. Auf der Website steht dieses Handy fuer den mobilen Chat --
dann soll es hier dasselbe zeigen. */
const WEB = {
grund: "#F5F2EF", // CHAT_BACKGROUND, hsl(30 17% 95.5%)
eigen: "#0D6E6E", // OWN_BUBBLE_BACKGROUND
eigenR: "#0A5A5A", // OWN_BUBBLE_BORDER
fremd: "#FFFFFF", // OTHER_BUBBLE_BACKGROUND
fremdR: "#E9E7E5", // OTHER_BUBBLE_BORDER
zeit: "#7A726C", // OTHER_BUBBLE_TIME
sender: "#F59F0A", // OTHER_BUBBLE_SENDER
rahmen: "#D1D5DB", // border des Mockups
band: "#F1B729", // accent-yellow
bandDunkel: "#171106",
};
// seedMessages aus ChatDemoSection.tsx, woertlich und in dieser Reihenfolge.
const WEB_NACHRICHTEN = [
["Murat Kaya", "Foto vom Heizungskeller hochgeladen.", "09:12", false],
["Lea Brandt", "Aufmaß ist fertig, schicke gleich die Unterlagen.", "09:15", false],
["Du", "Super, dann können wir morgen mit der Installation anfangen.", "09:17", true],
["Murat Kaya", "Material ist bestellt, Lieferung Donnerstag.", "09:20", false],
];
function visHandy() {
/* Das Geraet. Die Website zeichnet nur einen hellen Umriss (border 4px
#D1D5DB) -- auf einer Slide, die selbst hell ist, faellt der in sich
zusammen und sieht nach Platzhalter aus. Hier steht deshalb ein
richtiges Geraet: dunkler Korpus, heller Grat darauf, Schirm
eingelassen, Tasten an der Seite. Das ist eine Abweichung, aber eine
am Rahmen und nicht am Inhalt -- was auf dem Schirm steht, bleibt das
der Website.
Der Schirm wird weniger stark verwischt als dort. Auf der Website
liegt das Handy gross neben Fliesstext und darf unlesbar sein; hier
ist es klein, und bei blur(6) blieb nur Brei. Erkennbar, aber nicht
lesbar ist die Absicht. */
const B = 600, H = 1240, BEZEL = 20, R = 92;
const sx = BEZEL, sy = BEZEL, sb = B - 2 * BEZEL, sh = H - 2 * BEZEL, sr = R - BEZEL + 4;
let schirm = "";
// Statuszeile und Kopf.
schirm += uiText(sx + 40, sy + 56, "9:41", 26, 700, CHAT_TEXT);
const ko = sy + 84;
schirm += '<rect x="' + n1(sx) + '" y="' + n1(sy) + '" width="' + sb +
'" height="' + (ko - sy + 112) + '" fill="#FFFFFF"/>';
schirm += uiText(sx + 40, sy + 56, "9:41", 26, 700, CHAT_TEXT);
schirm += '<circle cx="' + n1(sx + 64) + '" cy="' + n1(ko + 56) + '" r="34" fill="#F3E4C2"/>';
schirm += feather("building", sx + 64, ko + 56, 30, "#8A6116", 2.2);
schirm += uiText(sx + 110, ko + 50, "Heizungsprojekt Schulz", 27, 700, CHAT_TEXT);
schirm += uiText(sx + 110, ko + 80, "4 Mitglieder · 2 online", 21, 500, CHAT_TEXT2);
schirm += '<path d="M' + n1(sx) + ',' + n1(ko + 112) + 'H' + n1(sx + sb) +
'" stroke="#E5E7EB" stroke-width="2"/>';
let y = ko + 142;
for (const [wer, text, zeit, eigen] of WEB_NACHRICHTEN) {
const maxB = sb - 88;
const zeilen = umbrechen(text, maxB - 40, 24, 400);
const h = 20 + (eigen ? 0 : 26) + zeilen.length * 32 + 26;
const breite = 40 + Math.max(...zeilen.map(z => messen(z, 24, 400)),
eigen ? 0 : messen(wer, 19, 700));
const x0 = eigen ? sx + sb - 28 - breite : sx + 28;
const k = rundRechteck(x0, y, breite, h, 20);
schirm += '<path d="' + k + '" fill="' + (eigen ? WEB.eigen : WEB.fremd) +
'" stroke="' + (eigen ? WEB.eigenR : WEB.fremdR) + '" stroke-width="2"/>';
let cy = y + 20;
if (!eigen) { schirm += uiText(x0 + 20, cy + 19, wer, 19, 700, WEB.sender); cy += 26; }
zeilen.forEach((z, i) => {
schirm += uiText(x0 + 20, cy + 25 + i * 32, z, 24, 400, eigen ? "#FFFFFF" : CHAT_TEXT);
});
schirm += uiText(x0 + 20, cy + 25 + zeilen.length * 32, zeit, 18, 400,
eigen ? "rgba(255,255,255,0.82)" : WEB.zeit);
y += h + 18;
}
const fy = sy + sh - 92;
schirm += '<rect x="' + n1(sx) + '" y="' + n1(fy - 18) + '" width="' + sb +
'" height="110" fill="#FFFFFF"/>';
schirm += '<rect x="' + n1(sx + 28) + '" y="' + n1(fy) + '" width="' + (sb - 56) +
'" height="66" rx="33" fill="' + WEB.grund + '" stroke="#E5E7EB" stroke-width="2"/>';
schirm += uiText(sx + 54, fy + 43, "Nachricht schreiben...", 23, 400, "#A8A29E");
// Der Balken unten, den jedes heutige Geraet hat.
schirm += '<rect x="' + n1(B / 2 - 70) + '" y="' + n1(sy + sh - 16) +
'" width="140" height="6" rx="3" fill="#C9C9CE"/>';
/* Der Korpus. Tasten zuerst, damit sie hinter dem Rand verschwinden. */
let g = '';
[[-6, 300, 90], [-6, 420, 130], [B, 360, 150]].forEach(([tx, ty2, th]) => {
g += '<rect x="' + n1(tx) + '" y="' + n1(ty2) + '" width="8" height="' + th +
'" rx="4" fill="#3A3B42"/>';
});
g += '<g filter="url(#hgtief)"><path d="' + rundRechteck(0, 0, B, H, R) +
'" fill="#1C1D22"/></g>';
g += '<path d="' + rundRechteck(5, 5, B - 10, H - 10, R - 5) +
'" fill="none" stroke="#4A4B54" stroke-width="2"/>';
g += '<clipPath id="hgschirm"><path d="' + rundRechteck(sx, sy, sb, sh, sr) + '"/></clipPath>';
g += '<path d="' + rundRechteck(sx, sy, sb, sh, sr) + '" fill="' + WEB.grund + '"/>';
g += '<g clip-path="url(#hgschirm)" filter="url(#hgblur)">' + schirm + '</g>';
// Die Insel oben.
g += '<rect x="' + n1(B / 2 - 62) + '" y="' + n1(sy + 14) +
'" width="124" height="34" rx="17" fill="#0B0B0E"/>';
/* Das Absperrband, Winkel und Wortlaut der Website. */
const bh = 64, ty = H / 2 - bh / 2;
const bL = B / Math.cos(13 * Math.PI / 180) + 24, bA = (B - bL) / 2;
let band = '<rect x="' + n1(bA) + '" y="' + n1(ty) + '" width="' + n1(bL) +
'" height="' + bh + '" fill="' + WEB.band + '"/>' +
'<path d="M' + n1(bA) + ',' + n1(ty) + 'h' + n1(bL) + '" stroke="' + WEB.bandDunkel +
'" stroke-width="4"/>' +
'<path d="M' + n1(bA) + ',' + n1(ty + bh) + 'h' + n1(bL) + '" stroke="' + WEB.bandDunkel +
'" stroke-width="4"/>';
const wort = "BALD VERFÜGBAR";
const wb = messen(wort, 21, 800) + 21 * 0.16 * wort.length;
let bx = bA + 14;
while (bx < bA + bL - 40) {
band += feather("hard-hat", bx + 13, ty + bh / 2, 24, WEB.bandDunkel, 2.6);
band += uiText(bx + 32, ty + bh / 2 + 8, wort, 21, 800, WEB.bandDunkel, { sperr: "0.16em" });
bx += wb + 78;
}
g += '<g clip-path="url(#hgschirm)"><g transform="rotate(-13,' + n1(B / 2) + ',' +
n1(H / 2) + ')" filter="url(#hgtief)">' + band + '</g></g>';
return wfSvg(B, H, g);
}
const VIS = {
chat: () => visChat(),
kanaele: () => visKanaele(),
aufgabe: () => visAufgabe(),
menue: () => visMenue(),
handy: () => visHandy(),
};
/* ─── Das Deck ────────────────────────────────────────────────────────────
+14
View File
@@ -13,6 +13,10 @@
"bg": "raster",
"int": "dezent"
},
{
"bg": "raster",
"int": "dezent"
},
{
"bg": "raster",
"int": "dezent"
@@ -35,6 +39,10 @@
{
"ref": "menue",
"werte": {}
},
{
"ref": "handy",
"werte": {}
}
],
"edits": [
@@ -53,6 +61,11 @@
"dy": 3
}
},
{
"headline": {
"dy": 3
}
},
{
"headline": {
"dy": 3
@@ -64,6 +77,7 @@
"standard",
"standard",
"standard",
"standard",
"standard"
],
"rasterAn": "punkte",