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

+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 ────────────────────────────────────────────────────────────