Notizzettel, keine Schatten, Szenen-Mockup

Drei Aenderungen am Generator, im selben Bereich der Datei verzahnt.

Notizzettel: die StickyNote aus dem Pitch-Projekt als HTML nachgebaut,
Variante purple, Farbwerte uebernommen und die Masse auf das 1080er Blatt
hochskaliert. Liegt auf der Workflow-Slide und ist wie jedes andere Element
verschiebbar.

Schatten: raus, ueberall. Kein box-shadow, kein drop-shadow. Tiefe entsteht
ueber Ueberlappung, Rahmenfarbe und Versatz. Der Umschalter faellt mit weg,
er haette nichts mehr zu schalten gehabt. Die Blaetter im Stapel-Rahmen
bekommen eine Haarlinie, sonst laege auf der hellen Variante Weiss auf Weiss.

Szenen-Mockup: Macbook Air und iPad Pro in Perspektive, aus einem Pixeden-PSD
freigestellt. Beide Displays sind ausgeknockt, die Screenshots liegen dahinter
und werden per matrix3d in die Flaechen projiziert. Die Eckpunkte sind
gemessen, nicht geschaetzt: beim Macbook aus dem transform_box des
Smart-Objekts, beim iPad ueber Ausgleichsgeraden auf den vier Glaskanten, weil
das Smart-Object dort zu hoch sitzt. Der Spalt zur weggefallenen Glasplatte
wird spaltenweise aus den Nachbarfarben interpoliert, damit am Displayrand
Schwarz und am Gehaeuse Metallic steht.

Das PSD selbst bleibt draussen: Pixeden erlaubt die Nutzung, nicht die
Weitergabe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
dejanandClaude Opus 5 committed 2026-08-05 21:36:55 +02:00
1 parent cdc979fa71
commit 89434d728e
4 files changed
+423 -85

No files matched your search

+1 -1
View File
@@ -48,7 +48,7 @@ pixelweise schieben (`⇧` = 10 px) und über `A−`/`A+` in der Größe ändern
assets/
├── brand/ Wortmarke hell/dunkel, G-Marke (SVG + PNG), Wabe, Geerkens
├── partner/ DATEV, KWP, Google Calendar, Google Drive, MS Outlook, MS Teams
├── mockups/ Geräte-Mockups (Display ausgeknockt, Screenshot liegt dahinter)
├── mockups/ Geräte-Mockups (Displays ausgeknockt, Screenshot liegt dahinter)
├── screens/ Software-Screenshots
└── fonts/ Manrope 400–800, lokal
vendor/ html-to-image, JSZip
Binary file not shown.

After

Width:  |  Height:  |  Size: 1011 KiB

+284 -82
View File
@@ -348,7 +348,7 @@
Textfluss: So verschiebt ein Element nie seine Nachbarn und lässt sich
jederzeit exakt auf 0 zurücksetzen. Elemente mit eigener Transformation
komponieren die Offsets in ihre bestehende Regel hinein. */
[data-edit] { transform: translate(var(--ex, 0px), var(--ey, 0px)); }
[data-edit] { transform: translate(var(--ex, 0px), var(--ey, 0px)) rotate(var(--rot, 0deg)); }
.slide[data-editmode="an"] [data-edit] { cursor: grab; }
.slide[data-editmode="an"] [data-edit]:hover { outline: 3px dashed rgba(241,183,41,.75); outline-offset: 6px; }
@@ -419,14 +419,14 @@
);
/* Rahmenform ist umschaltbar. Bei "rund" muss clip-path weichen, sonst
gewinnt der Schnitt gegen border-radius; bei "aus" fällt auch Polsterung
und Fläche weg, der Schatten bleibt als einzige Tiefe. */
/* clip-path also clips box-shadow, so depth has to come from a filter —
documented caveat in lib/chamfer.ts. */
filter: drop-shadow(0 34px 60px rgba(0,0,0,.30));
und Fläche weg.
Kein Schatten: die Gestaltung arbeitet flächig, Tiefe entsteht über
Überlappung und Rahmenfarbe, nicht über Weichzeichnung. */
}
.slide[data-tilt="an"] .mock {
transform: translate(var(--ex, 0px), var(--ey, 0px))
perspective(2600px) rotateY(-7deg) rotateX(2.5deg) rotate(-1.2deg);
perspective(2600px) rotateY(-7deg) rotateX(2.5deg)
rotate(calc(-1.2deg + var(--rot, 0deg)));
}
.mock-inner {
@@ -439,6 +439,57 @@
);
}
/* Ein zweites Bild pro Slide. Es ist bewusst wieder ein .mock, damit alle
Rahmenformen, Schatten und Perspektiven ohne Sonderregeln greifen; nur
Position, Größe und Stapelebene kommen von aussen. */
.mock-2 {
z-index: var(--m2-z, 2);
bottom: var(--m2-bottom, 96px);
}
.slide[data-frame="stapel"] .mock-2 .sheet-back,
.slide[data-frame="stapel"] .mock-2 .sheet-mid { display: none; }
/* ─── Notizzettel ────────────────────────────────────────────────────────
Nachbau der StickyNote aus dem Pitch-Projekt
(ui/components/StickyNote/StickyNote.tsx), Variante "purple". Farbwerte
und Aufbau sind von dort übernommen, die Maße hochskaliert: die Vorlage
ist 200 px breit bei 14 px Text, hier liegt sie auf einem 1080er Blatt.
Bewusst echtes HTML statt Screenshot, damit die Kanten beim Export
genauso scharf sind wie die Schrift und der Text austauschbar bleibt. */
.note {
position: absolute; z-index: var(--note-z, 3);
left: var(--note-left, 60px);
bottom: var(--note-bottom, 150px);
width: var(--note-w, 320px);
border-radius: 10px; overflow: hidden;
font-family: Manrope, sans-serif;
}
.note-head {
height: 50px; background: #ce93d8;
display: flex; align-items: center; justify-content: flex-end;
padding-right: 12px;
}
.note-head svg { width: 26px; height: 26px; display: block; }
.note-body {
background: #f3e5f5; color: #424242;
padding: 18px 20px 26px; min-height: 168px;
font-size: 23px; font-weight: 400; line-height: 1.5;
}
/* Der Anfasser unten rechts: zwei ineinanderliegende Ecken, wie im Original
zwei Spans mit border-b/border-r in zwei Deckkraften. */
.note-grip { position: absolute; right: 9px; bottom: 9px; }
.note-grip i {
position: absolute; right: 0; bottom: 0; display: block;
border-right: 2px solid rgba(66,66,66,.45);
border-bottom: 2px solid rgba(66,66,66,.45);
}
.note-grip i:first-child { width: 17px; height: 17px; }
.note-grip i:last-child {
width: 10px; height: 10px;
border-right-color: rgba(66,66,66,.65);
border-bottom-color: rgba(66,66,66,.65);
}
.slide[data-frame="rund"] .mock { clip-path: none; border-radius: 30px; }
.slide[data-frame="rund"] .mock-inner { clip-path: none; border-radius: 20px; }
@@ -448,30 +499,29 @@
.slide[data-frame="aus"] .mock-inner { clip-path: none; border-radius: 0; }
/* ─── Stapel ─────────────────────────────────────────────────────────────
Zwei versetzte Blätter hinter dem Screenshot. Der Rahmen selbst fällt weg,
jedes Blatt trägt seinen eigenen Schatten: box-shadow statt drop-shadow,
weil ohne clip-path nichts mehr beschnitten wird und jede Karte einzeln
abheben soll. */
Zwei versetzte Blätter hinter dem Screenshot. Der Rahmen selbst fällt weg.
Getrennt werden die Blätter nicht durch Schatten, sondern durch den
Versatz und eine Haarlinie: auf der hellen Variante läge sonst Weiss auf
Weiss und der Stapel wäre nicht mehr zu erkennen. */
.sheet-back, .sheet-mid {
display: none; position: absolute; inset: 0; z-index: 0;
background: #fff; border-radius: 22px; overflow: hidden;
border: 1px solid rgba(48,49,61,.18);
}
.slide[data-frame="stapel"] .mock {
clip-path: none; padding: 0; background: none; filter: none;
clip-path: none; padding: 0; background: none;
}
.slide[data-frame="stapel"] .mock-inner {
clip-path: none; border-radius: 22px; z-index: 2;
box-shadow: 0 34px 64px rgba(0,0,0,.30), 0 3px 10px rgba(0,0,0,.14);
border: 1px solid rgba(48,49,61,.18);
}
.slide[data-frame="stapel"] .sheet-mid {
display: block; z-index: 1;
transform: rotate(-2.4deg) translate(-2.2%, 3%) scale(.975);
box-shadow: 0 22px 44px rgba(0,0,0,.20);
}
.slide[data-frame="stapel"] .sheet-back {
display: block;
transform: rotate(3.2deg) translate(3%, 5.5%) scale(.95);
box-shadow: 0 18px 38px rgba(0,0,0,.16);
}
/* Das hinterste Blatt zeigt bewusst einen anderen Screen: der Stapel soll
nach mehreren Ansichten aussehen, nicht nach einer Kopie. */
@@ -480,15 +530,6 @@
transform-origin: top left;
}
/* Schatten global abschaltbar. Zwei Techniken im Spiel: drop-shadow für die
chamferten Rahmen (clip-path würde box-shadow wegschneiden) und box-shadow
für den Stapel. Beide müssen mit. */
.slide[data-shadow="aus"] .mock,
.slide[data-shadow="aus"] .laptop,
.slide[data-shadow="aus"] .hex-frame { filter: none; }
.slide[data-shadow="aus"] .mock-inner,
.slide[data-shadow="aus"] .sheet-mid,
.slide[data-shadow="aus"] .sheet-back { box-shadow: none; }
/* Screens are ~2000px wide landscape captures; showing them whole makes the
UI text unreadable at this size, so each one is zoomed onto its telling
region via the per-slide vars below. */
@@ -505,7 +546,6 @@
.hex-frame {
padding: 15px; background: var(--frame-bg);
clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
filter: drop-shadow(0 26px 48px rgba(0,0,0,.26));
}
.hex-shot {
position: relative; overflow: hidden;
@@ -516,14 +556,14 @@
.hex-shot img {
position: absolute; top: 0; left: 0; width: 100%;
transform-origin: top left;
transform: scale(1.75) translate(-21%, -4%);
transform: scale(1.533) translate(-17.4%, 0%);
}
/* Laptop mockup. The display was knocked out of the PNG, so the screenshot
sits BEHIND the device and the notch plus the rounded corners stay on top,
the way a real mockup reads. Rect measured from the file: 739 x 489 of
1050 x 700. */
.laptop { position: relative; width: 960px; filter: drop-shadow(0 28px 50px rgba(0,0,0,.30)); }
.laptop { position: relative; width: 960px; }
.laptop-frame { width: 100%; display: block; position: relative; z-index: 2; }
.laptop-screen {
position: absolute; z-index: 1; overflow: hidden;
@@ -535,7 +575,7 @@
.laptop-screen > img.laptop-shot {
position: absolute; top: 0; left: 0; width: 100%;
transform-origin: top left;
transform: scale(1) translate(0%, 0%);
transform: scale(1.566) translate(-18.1%, 0%);
}
/* Blurred variant: just enough softening to seat the wordmark, the app stays
readable underneath. Slightly overscaled so the blur never pulls the empty
@@ -554,6 +594,59 @@
width: 430px; height: auto;
}
/* ─── Szenen-Mockup ──────────────────────────────────────────────────────
Macbook Air + iPad Pro in Perspektive (Pixeden-PSD, Hintergrund entfernt,
beide Displays ausgeknockt). Anders als beim frontalen Laptop reicht kein
Rechteck: die Displayflächen sind Vierecke. Die Screens werden deshalb
per matrix3d hineinprojiziert. Die Matrizen wurden aus den Eckpunkten der
Smart-Objekte im PSD gerechnet und bilden Design-Pixel auf Bildpixel des
5742 px breiten Originals ab; --sk skaliert von dort auf die Breite in
der Slide und wird beim Anwenden der Größe gesetzt. */
.szene { position: relative; width: 980px; }
.szene-dev { width: 100%; display: block; position: relative; z-index: 2; }
.szene-screen {
position: absolute; top: 0; left: 0; z-index: 1;
overflow: hidden; background: #fff;
transform-origin: 0 0;
}
.szene-mb {
width: 3456px; height: 2234px;
transform: scale(var(--sk, .1707))
matrix3d(0.853327,-0.145151,0,-0.000016242,
-0.025684,1.027061,0,-0.000007324,
0,0,1,0, 1614.15,493.94,0,1);
}
/* Das iPad-Viereck stammt aus der Glasflaeche, nicht aus dem Smart-Object
des PSD: das sass zu hoch und liess unten einen dreimal so breiten
schwarzen Streifen stehen. Die Glasecken sind ueber Ausgleichsgeraden auf
den vier Kanten bestimmt und geschnitten, nicht ueber Extrempunkte: die
lagen auf den Rundungen und ergaben ein zu kleines, schiefes Viereck.
Glas 1514 x 2240 mit Eckradius 95, davon 38 px Rand nach innen, also
Display 1438 x 2164 mit Radius 57. Der Radius ist konzentrisch, aussen
minus Randbreite, damit der schwarze Rand ueberall gleich breit bleibt. */
.szene-ip {
width: 1438px; height: 2164px;
border-radius: 57px; overflow: hidden;
transform: scale(var(--sk, .1707))
matrix3d(0.852061,-0.170328,0,-0.000024605,
0.005789,0.984772,0,0.000001055,
0,0,1,0, 4244.78,536.57,0,1);
}
.szene-screen > img {
position: absolute; top: 0; left: 0; width: 100%;
transform-origin: top left;
}
/* Beide Aufnahmen liegen originalgetreu auf ihrem Display, ohne Zoom.
Sie sind flacher als der Bildschirm, es bleibt also Flaeche frei; die
traegt mit #FAFAFA den Hintergrundton der Anwendung und ist deshalb nicht
als Rand zu erkennen.
Das Board rueckt 5,15 % nach unten: das Smart-Object des PSD ragt oben
hinter die Blende des Deckels, ohne Versatz verschwindet die Kopfzeile.
Der Chat steht mittig, sein Display verdeckt nichts. */
.szene-mb, .szene-ip { background: #FAFAFA; }
.szene-mb > img { transform: translate(0, 5.15%); }
.szene-ip > img { transform: translate(0, 16.49%); }
.laptop-missing {
width: 940px; padding: 80px 40px; text-align: center;
border: 3px dashed var(--shape-line); color: var(--body-color);
@@ -591,7 +684,8 @@
.page.title[data-layout="laptop"] .content,
.page.title[data-layout="laptop-logo"] .content,
.page.title[data-layout="laptop-blur"] .content,
.page.title[data-layout="laptop-footer"] .content {
.page.title[data-layout="laptop-footer"] .content,
.page.title[data-layout="szene"] .content {
padding: 92px 96px; justify-content: space-between;
}
.logo-full { width: 560px; height: auto; display: block; margin-bottom: 74px; }
@@ -657,7 +751,7 @@
<aside class="panel">
<h1>Post 1 · Lösung</h1>
<div class="projects"><a class="here" href="./post1.html">Post 1 · Lösung</a><a class="" href="./index.html">Post 2 · Statement</a></div>
<div class="projects"><a class="here" href="./post1.html">Post 1</a><a class="" href="./post1-alt.html">Post 1 · Alt</a><a class="" href="./index.html">Post 2</a></div>
<p class="sub">Carousel, 5 Slides · 1080 × 1350 px<span class="saved" id="saved"></span><br><button class="forget" id="forget">Projekt zurücksetzen</button></p>
<div class="group">
@@ -753,11 +847,6 @@
<button data-f="stapel" aria-pressed="false">Stapel</button>
<button data-f="aus" aria-pressed="false">Aus</button>
</div>
<label style="display:block;margin:14px 0 9px">Schatten</label>
<div class="opts" id="shadows">
<button data-s="an" aria-pressed="true">An</button>
<button data-s="aus" aria-pressed="false">Aus</button>
</div>
</details>
<details class="group" data-grp="mockup-perspektive">
@@ -791,7 +880,7 @@
<kbd>←</kbd><kbd>→</kbd> Slide · <kbd>V</kbd> Variante<br>
<kbd>L</kbd> Layout · <kbd>M</kbd> Muster · <kbd>I</kbd> Intensität<br>
<kbd>C</kbd> Durchgehend · <kbd>W</kbd> Ansicht · <kbd>G</kbd> Tag<br>
<kbd>R</kbd> Bildrahmen · <kbd>S</kbd> Schatten<br>
<kbd>R</kbd> Bildrahmen<br>
<kbd>T</kbd> Perspektive<br>
<kbd>E</kbd> Bearbeiten · Pfeile verschieben (<kbd>⇧</kbd> = 10 px)<br>
<kbd>+</kbd> <kbd>−</kbd> <kbd>0</kbd> Zoom · ⌘/Strg + Rad<br>
@@ -851,8 +940,8 @@
const SLIDES = [
{
key: "titel", type: "title",
layouts: [{ id: "klassisch", label: "Klassisch" }, { id: "wabe", label: "Wabe" }, { id: "laptop", label: "Laptop" }, { id: "laptop-logo", label: "Laptop · Logo" }, { id: "laptop-blur", label: "Laptop · Blur" }, { id: "laptop-footer", label: "Laptop · Logo unten" }],
head: 'Vom <span class="a">Handwerker</span><br>f\u00fcr den Handwerker',
layouts: [{ id: "klassisch", label: "Klassisch" }, { id: "wabe", label: "Wabe" }, { id: "laptop", label: "Laptop" }, { id: "laptop-logo", label: "Laptop · Logo" }, { id: "laptop-blur", label: "Laptop · Blur" }, { id: "laptop-footer", label: "Laptop · Logo unten" }, { id: "szene", label: "Szene" }],
head: 'Vom <span class="a">Handwerker</span><br>f\u00fcr Handwerker',
sub: "Die Software, die deine Branchenprogramme nicht ersetzt, sondern verbindet.",
},
{
@@ -862,36 +951,59 @@
head: 'Du siehst auf einen Blick,<br><span class="a">wo jeder Auftrag steht.</span>',
body: "Projekte, Kunden, Angebote, F\u00f6rderantr\u00e4ge. Kein Suchen, kein Nachfragen, kein Zettelchaos.",
img: "./assets/screens/pipelines.png",
mockAR: 1.8519, // 2000 x 1080
imgScale: 1, imgX: "0%", imgY: "0%",
// Nur die linke Bildhälfte: Zoom 2 bei Versatz 0 zeigt exakt den Anteil
// 0 bis 0,5. Das Rahmenverhältnis ist die halbierte Bildbreite.
mockW: 580, mockAR: 0.8855,
imgScale: 2, imgX: "0%", imgY: "0%",
// Detailspalte eines Projekts vor dem Board: zeigt, was hinter einer
// Karte steckt, ohne das Board zu verdecken.
media2: {
img: "./assets/screens/pipelines-detail.png",
w: 300, ar: 0.781, left: 500, bottom: 52, z: 2, rot: -2,
},
},
{
key: "workflow-engine", type: "feature",
layouts: [{ id: "standard", label: "Standard" }],
eyebrow: "Workflow-Engine",
head: '<span class="a">Wenn-dann</span><br>statt Handarbeit.',
body: "Ausl\u00f6ser w\u00e4hlen, Aktionen dranh\u00e4ngen, fertig. Anfrage rein, Aufgabe raus, Best\u00e4tigung an den Kunden, alles automatisch und ohne eine Zeile Code.",
body: "Auslöser wählen, Aktionen dranhängen, fertig. Alles automatisch und ohne eine Zeile Code.",
img: "./assets/screens/workflow.png",
mockAR: 1.5038, // 2000 x 1330
mockW: 940, mockAR: 2.366,
imgScale: 1, imgX: "0%", imgY: "0%",
// Die Bausteinliste ragt über den Ablauf hinaus: sie zeigt, woraus er entsteht.
media2: {
img: "./assets/screens/workflow-bausteine.png",
w: 340, ar: 0.8326, left: 700, bottom: 96, z: 2, rot: 0,
},
// Der Zettel liegt bewusst schief und leicht über der linken Blattkante:
// er gehört zum Ablauf, ist aber sichtbar von Hand dazugelegt.
note: {
text: "Eigene Notizen direkt am Ablauf hinterlegen.",
w: 320, left: 44, bottom: 150, z: 3, rot: -3,
},
},
{
key: "chat", type: "feature",
layouts: [{ id: "standard", label: "Standard" }],
eyebrow: "Team-Chat",
head: 'Jedes Projekt bekommt<br>automatisch <span class="a">seinen Kanal.</span>',
body: "Foto von der Baustelle rein, alle sehen\u2019s sofort und es landet direkt in den Projektdateien. Aus jeder Nachricht wird mit einem Klick eine Aufgabe.",
body: "Foto von der Baustelle rein, alle sehen's sofort. Aus jeder Nachricht wird mit einem Klick eine Aufgabe.",
img: "./assets/screens/chat.png",
// 2000 x 1357. Etwas schmaler, sonst reicht die Hoehe unter dem Text nicht.
mockW: 1070, mockAR: 1.4738,
mockW: 1030, mockAR: 1.703,
imgScale: 1, imgX: "0%", imgY: "0%",
// Das Anhang-Menü zeigt, was aus einer Nachricht alles werden kann.
media2: {
img: "./assets/screens/chat-menue.png",
w: 300, ar: 0.866, left: 120, bottom: 55, z: 2, rot: -3,
},
},
{
key: "form-builder", type: "feature",
layouts: [{ id: "standard", label: "Standard" }],
eyebrow: "Form Builder",
head: 'Formulare selbst bauen.<br><span class="a">Per Drag-and-drop.</span>',
body: "Von der Kontaktanfrage bis zum Aufma\u00df mit Unterschrift vor Ort. Teil sie per Link oder QR-Code, jede Einsendung wird automatisch zu Lead, Kontakt oder Auftrag.",
body: "Von der Kontaktanfrage bis zum Aufmaß mit Unterschrift vor Ort. Teil sie per Link oder QR-Code, jede Einsendung wird automatisch zu Lead, Kontakt oder Auftrag.",
img: "./assets/screens/form-builder.png",
mockW: 540, mockAR: 0.75, mockLeft: 270,
imgScale: 3.8, imgX: "-36.9%", imgY: "-10%",
@@ -913,7 +1025,10 @@
"logo-full": { label: "Logo", size: 560, prop: "width" },
"screen-logo": { label: "Logo im Screen", size: 430, prop: "width" },
"laptop": { label: "Laptop", size: 960, prop: "width" },
"szene": { label: "Szene", size: 980, prop: "width" },
"mock": { label: "Bild", size: null, prop: "width" },
"mock-2": { label: "Zweites Bild", size: null, prop: "width" },
"note": { label: "Notizzettel", size: null, prop: "width" },
"hex-frame": { label: "Wabe", size: 778, prop: "width" },
};
const EDIT_KEYS = Object.keys(STANDARD);
@@ -922,7 +1037,9 @@
Hochformat von 540, alle anderen 1090. Deshalb nicht aus der Tabelle,
sondern aus der Slide selbst. */
function stdSize(key, i) {
if (key === "mock") return SLIDES[i].mockW || 1090;
if (key === "mock") return SLIDES[i].mockW || 1090;
if (key === "mock-2") return (SLIDES[i].media2 || {}).w || 400;
if (key === "note") return (SLIDES[i].note || {}).w || 320;
return (STANDARD[key] || {}).size;
}
@@ -941,7 +1058,7 @@
let editMode = "aus";
let current = 0;
let variant = "dunkel", tilt = "an", superSample = 2, continuous = "aus", view = "streifen", tagOut = "an", frameStyle = "chamfer", shadow = "an";
let variant = "dunkel", tilt = "an", superSample = 2, continuous = "aus", view = "streifen", tagOut = "an", frameStyle = "chamfer";
/* ─── Projektstand ────────────────────────────────────────────────────────
Alles, was am Projekt hängt, wird gespeichert: Einstellungen, Layouts,
@@ -953,7 +1070,7 @@
function saveState() {
try {
localStorage.setItem(STORE, JSON.stringify({
v: 1, variant, tilt, superSample, continuous, view, tagOut, frameStyle, shadow,
v: 1, variant, tilt, superSample, continuous, view, tagOut, frameStyle,
current, layoutState, bgState, edits,
}));
markSaved();
@@ -982,7 +1099,6 @@
view = d.view || view;
tagOut = d.tagOut || tagOut;
frameStyle = d.frameStyle || frameStyle;
shadow = d.shadow || shadow;
current = Number.isInteger(d.current) ? d.current : 0;
return true;
}
@@ -1057,6 +1173,12 @@
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" ' +
'stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg>';
// FeatherMoreVertical aus dem Kopf der StickyNote: drei Punkte, keine Linien.
const NOTE_MENU_SVG =
'<svg viewBox="0 0 24 24" fill="rgba(66,66,66,.72)">' +
'<circle cx="12" cy="5" r="1.9"/><circle cx="12" cy="12" r="1.9"/>' +
'<circle cx="12" cy="19" r="1.9"/></svg>';
const SVG_NS = "http://www.w3.org/2000/svg";
const G_POINTS = "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 G_W = 384.5, G_H = 332.9;
@@ -1132,6 +1254,23 @@
'<p class="body">' + s.body + '</p>';
}
const logo = variant === "dunkel" ? assets["./assets/brand/logo-white.svg"] : assets["./assets/brand/logo.svg"];
if (layoutState[i] === "szene") {
const dev = assets["./assets/mockups/macbook-szene.png"]
? '<div class="szene">' +
'<div class="szene-screen szene-mb"><img alt="" src="' +
assets["./assets/screens/pipelines.png"] + '"></div>' +
'<div class="szene-screen szene-ip"><img alt="" src="' +
assets["./assets/screens/chat-tablet.png"] + '"></div>' +
'<img class="szene-dev" alt="" src="' +
assets["./assets/mockups/macbook-szene.png"] + '">' +
'</div>'
: '<div class="laptop-missing">Szenen-Mockup fehlt.<br>Das freigestellte PNG als <b>assets/mockups/macbook-szene.png</b> ablegen.</div>';
return '<div class="title-top">' +
'<p class="wabe-tagline">Vom Handwerker<br>für <span class="a">Handwerker</span></p>' +
'<img class="wabe-logo" alt="HiveGrid" src="' + logo + '">' +
'</div>' + dev +
'<p class="wabe-claim">' + s.sub + '</p>';
}
if (layoutState[i] === "laptop-footer") {
const dev = assets["./assets/mockups/laptop.png"]
? '<div class="laptop">' +
@@ -1199,6 +1338,10 @@
for (const key of EDIT_KEYS) {
slideEl.querySelectorAll("." + key).forEach(n => { n.dataset.edit = key; });
}
// ".mock" trifft auch das zweite Bild, weil es dieselbe Klasse trägt.
// Der spezifischere Schlüssel muss gewinnen, sonst teilen sich beide
// Bilder eine Bearbeitung.
slideEl.querySelectorAll(".mock-2").forEach(n => { n.dataset.edit = "mock-2"; });
}
function applyEdits(slideEl, i) {
@@ -1213,8 +1356,20 @@
e.size ? e.size + "px" : "";
}
});
// Die Perspektiv-Matrizen der Szene rechnen in Bildpixeln des Originals.
// Der Faktor auf die Breite in der Slide haengt also an der Geraetebreite
// und muss jede Groessenaenderung mitmachen.
const sz = slideEl.querySelector(".szene");
if (sz) {
const w = (set["szene"] || {}).size || STANDARD["szene"].size;
sz.style.setProperty("--sk", w / SZENE_PX);
}
}
// Breite des freigestellten Mockups im Originalmassstab. Die matrix3d-Werte
// sind darauf bezogen, deshalb steht die Zahl hier und nicht im CSS.
const SZENE_PX = 5742;
// Eine Seite = der Inhalt genau einer Slide, unabhängig davon, in welcher
// Slide sie gerade mitgerendert wird.
function buildPage(j) {
@@ -1246,6 +1401,35 @@
';--img-x:' + sl.imgX + ';--img-y:' + sl.imgY + '">' +
'<img class="laptop-shot" alt="" src="' + assets[sl.img] + '"></div>';
page.appendChild(mock);
if (sl.media2) {
const m2 = sl.media2;
const el2 = document.createElement("div");
el2.className = "mock mock-2";
el2.style.cssText =
"--mock-w:" + m2.w + "px;--mock-ar:" + m2.ar +
";--mock-left:" + m2.left + "px;--m2-bottom:" + m2.bottom +
"px;--m2-z:" + m2.z + ";--rot:" + (m2.rot || 0) + "deg";
el2.innerHTML =
'<div class="mock-inner"><img class="laptop-shot" alt="" src="' +
assets[m2.img] + '"></div>';
page.appendChild(el2);
}
if (sl.note) {
const nt = sl.note;
const el3 = document.createElement("div");
el3.className = "note";
el3.style.cssText =
"--note-w:" + nt.w + "px;--note-left:" + nt.left +
"px;--note-bottom:" + nt.bottom + "px;--note-z:" + nt.z +
";--rot:" + (nt.rot || 0) + "deg";
el3.innerHTML =
'<div class="note-head">' + NOTE_MENU_SVG + '</div>' +
'<div class="note-body">' + nt.text + '</div>' +
'<div class="note-grip"><i></i><i></i></div>';
page.appendChild(el3);
}
} else {
const sw = document.createElement("div");
sw.className = "swipe";
@@ -1278,7 +1462,6 @@
el.dataset.cont = continuous;
el.dataset.tagout = tagOut;
el.dataset.frame = frameStyle;
el.dataset.shadow = shadow;
el.dataset.editmode = editMode;
el.style.setProperty("--slide-i", i);
el.innerHTML = BG_HTML;
@@ -1301,13 +1484,14 @@
re-render without touching the network. */
async function preloadAssets() {
const paths = ["./assets/brand/logo.svg", "./assets/brand/logo-white.svg"]
.concat(SLIDES.filter(s => s.img).map(s => s.img));
paths.push("./assets/screens/pipelines.png");
.concat(SLIDES.filter(s => s.img).map(s => s.img))
.concat(SLIDES.filter(s => s.media2).map(s => s.media2.img));
paths.push("./assets/screens/pipelines.png", "./assets/screens/chat-tablet.png");
// Optional: the layout falls back to a hint if the file is not there yet.
const optional = new Set(["./assets/mockups/laptop.png"]);
paths.push("./assets/mockups/laptop.png");
const optional = new Set(["./assets/mockups/laptop.png", "./assets/mockups/macbook-szene.png"]);
paths.push("./assets/mockups/laptop.png", "./assets/mockups/macbook-szene.png");
for (const path of [...new Set(paths)]) {
const res = await fetch(path).catch(() => null);
const res = await fetch(path, { cache: "no-store" }).catch(() => null);
if (!res || !res.ok) {
if (optional.has(path)) continue;
throw new Error(path + " -> HTTP " + (res ? res.status : "kein Zugriff"));
@@ -1338,7 +1522,6 @@
el.dataset.cont = continuous;
el.dataset.tagout = tagOut;
el.dataset.frame = frameStyle;
el.dataset.shadow = shadow;
if (SLIDES[i].type === "title") rebuildPage(i);
el.dataset.editmode = editMode;
});
@@ -1433,13 +1616,29 @@
return out;
}
/* Die Bibliothek baut ein SVG in Knotengröße und zieht es beim Zeichnen auf
die Canvas-Größe hoch. WebKit rasterisiert das SVG dabei in seiner
*intrinsischen* Größe und interpoliert danach: Ein höherer pixelRatio
erzeugt so keine zusätzlichen Details, nur weichere Kanten. Genau deshalb
wirkte 3x nicht schärfer als 1x.
Der Ausweg: Das SVG bekommt gleich die Zielgröße über width/height, die
Slide behält per style ihre echten 1080 x 1350 und wird darin skaliert.
Die Bibliothek setzt style nach width/height, das Überschreiben greift.
pixelRatio bleibt 1, weil die Vergrößerung schon im SVG steckt. */
function renderOptions() {
const k = superSample;
return {
width: 1080, height: 1350, pixelRatio: superSample,
width: 1080 * k,
height: 1350 * k,
pixelRatio: 1,
fontEmbedCSS: fontEmbedCss,
// The scaler transform is preview-only — neutralise it for the export,
// otherwise the bitmap comes out at 45 %.
style: { transform: "none", transformOrigin: "top left" },
style: {
width: "1080px",
height: "1350px",
transform: k === 1 ? "none" : "scale(" + k + ")",
transformOrigin: "top left",
},
};
}
@@ -1466,7 +1665,7 @@
missing every few exports while the rest of the slide was fine. It is a
race, so the fix is not to hope but to verify: render, check that every
screenshot area actually carries pixels, and repeat if it does not. */
const SHOT_BOXES = ".mock-inner, .laptop-screen, .hex-shot";
const SHOT_BOXES = ".mock-inner, .laptop-screen, .hex-shot, .szene-screen";
const MAX_TRIES = 5;
// Rect of a child inside the slide, in slide coordinates (the preview is
@@ -1485,19 +1684,24 @@
/* A screenshot area is never a flat surface: it carries UI, text and colour.
If the sampled pixels are all but identical, the image did not make it. */
function areaHasContent(ctx, b) {
const x = Math.max(0, b.x + 4), y = Math.max(0, b.y + 4);
const w = Math.min(1080 - x, b.w - 8), h = Math.min(1350 - y, b.h - 8);
if (w < 8 || h < 8) return true;
/* Ein fehlendes Bild hinterlässt die reine Hintergrundfarbe des Rahmens,
also eine vollkommen gleichmäßige Fläche. Genau darauf wird geprüft, nicht
auf "genug Kontrast": Eine helle Formular-Oberfläche hat wenig Spanne, ist
aber nie gleichmäßig. Ein zu hoher Schwellenwert hat sie fälschlich als
fehlend gemeldet. */
function areaRange(ctx, b) {
const ix = Math.round(b.w * 0.08), iy = Math.round(b.h * 0.08);
const x = Math.max(0, b.x + ix), y = Math.max(0, b.y + iy);
const w = Math.min(1080 - x, b.w - ix * 2), h = Math.min(1350 - y, b.h - iy * 2);
if (w < 8 || h < 8) return 255;
const data = ctx.getImageData(x, y, w, h).data;
const step = Math.max(4, Math.floor((w * h) / 4000)) * 4;
let min = 255, max = 0;
for (let i = 0; i < data.length; i += step) {
for (let i = 0; i < data.length; i += 16) { // jedes vierte Pixel
const lum = (data[i] * 299 + data[i + 1] * 587 + data[i + 2] * 114) / 1000;
if (lum < min) min = lum;
if (lum > max) max = lum;
}
return max - min > 12;
return max - min;
}
async function renderSlide(i) {
@@ -1513,9 +1717,12 @@
el.dataset.editmode = "aus";
try {
await document.fonts.ready;
await Promise.all([...el.querySelectorAll("img")].map(im =>
const imgs = [...el.querySelectorAll("img")];
await Promise.all(imgs.map(im =>
im.complete ? im.decode().catch(() => {})
: new Promise(r => { im.onload = r; im.onerror = r; })));
const broken = imgs.filter(im => !im.naturalWidth);
if (broken.length) throw new Error(broken.length + " Bild(er) nicht geladen");
const boxes = [...el.querySelectorAll(SHOT_BOXES)].map(c => boxInSlide(el, c));
@@ -1523,20 +1730,22 @@
const raw = await htmlToImage.toCanvas(el, renderOptions());
const canvas = downscaleTo(raw, 1080, 1350);
const ctx = canvas.getContext("2d", { willReadFrequently: true });
const missing = boxes.filter(b => !areaHasContent(ctx, b)).length;
const ranges = boxes.map(b => areaRange(ctx, b));
const missing = ranges.filter(r => r <= 3).length;
if (!missing) {
const blob = await new Promise(r => canvas.toBlob(r, "image/png"));
if (blob) return blob;
}
if (attempt < MAX_TRIES) {
console.warn("Slide " + (i + 1) + ": Versuch " + attempt + " unvollstaendig" +
(missing ? " (" + missing + " Screenshot-Flaeche leer)" : ""));
console.warn("Slide " + (i + 1) + ": Versuch " + attempt + " unvollstaendig. " +
"Luminanzspannen je Bildflaeche: " + ranges.map(r => r.toFixed(1)).join(", "));
setStatus("Slide " + (i + 1) + ": Screenshot fehlte, Versuch " + (attempt + 1) + " ...");
await new Promise(r => setTimeout(r, 180 * attempt));
}
}
throw new Error("Slide " + (i + 1) + " nach " + MAX_TRIES + " Versuchen unvollstaendig");
throw new Error("Slide " + (i + 1) + " nach " + MAX_TRIES +
" Versuchen unvollstaendig. Details in der Konsole.");
} finally {
el.dataset.editmode = modeBefore;
holder.style.display = restore;
@@ -1728,7 +1937,7 @@
(function renderStandards() {
const groups = [
["Schrift", ["badge", "headline", "body", "wabe-tagline", "wabe-claim", "title-head", "title-sub"]],
["Breite", ["wabe-logo", "logo-full", "screen-logo", "laptop"]],
["Breite", ["wabe-logo", "logo-full", "screen-logo", "laptop", "szene"]],
];
let html = "";
for (const [title, keys] of groups) {
@@ -1831,10 +2040,6 @@
const b = e.target.closest("button");
if (b) { frameStyle = b.dataset.f; press("frames", "f", frameStyle); applyAll(); saveState(); }
});
document.getElementById("shadows").addEventListener("click", e => {
const b = e.target.closest("button");
if (b) { shadow = b.dataset.s; press("shadows", "s", shadow); applyAll(); saveState(); }
});
document.getElementById("tilts").addEventListener("click", e => {
const b = e.target.closest("button");
if (b) { tilt = b.dataset.t; press("tilts", "t", tilt); applyAll(); saveState(); }
@@ -1889,10 +2094,8 @@
else if (k === "r") {
const cyc = ["chamfer", "rund", "stapel", "aus"];
frameStyle = cyc[(cyc.indexOf(frameStyle) + 1) % cyc.length];
press("frames", "f", frameStyle);
press("shadows", "s", shadow); applyAll(); saveState();
press("frames", "f", frameStyle); applyAll(); saveState();
}
else if (k === "s") { shadow = shadow === "an" ? "aus" : "an"; press("shadows", "s", shadow); applyAll(); saveState(); }
else if (k === "g") { tagOut = tagOut === "an" ? "aus" : "an"; press("tagouts", "o", tagOut); applyAll(); saveState(); }
else if (k === "c") { continuous = continuous === "an" ? "aus" : "an"; press("conts", "c", continuous); applyAll(); saveState(); }
else if (k === "d") guard(downloadOne)();
@@ -1915,7 +2118,6 @@
press("conts", "c", continuous);
press("tagouts", "o", tagOut);
press("frames", "f", frameStyle);
press("shadows", "s", shadow);
press("views", "w", view);
show(current);
if (restored) setStatus("Projektstand geladen", "ok");
+138 -2
View File
@@ -104,7 +104,7 @@ clip-path: polygon(
**Drei Fallstricke** (stehen auch in `chamfer.ts`):
- `clip-path` beschneidet **auch den Schatten** → Tiefe über `filter: drop-shadow()`, nie `box-shadow`
- `clip-path` beschneidet **auch den Schatten** → wäre über `filter: drop-shadow()` zu lösen, nie über `box-shadow`. Praktisch irrelevant, seit die Posts schattenfrei sind (siehe §5)
- `clip-path` beschneidet **auch den Rahmen** → Rand über eine Hintergrundfläche lösen, nicht über `border`
- Kinder, die aus der Box ragen (Popups, Tooltips), werden abgeschnitten → solche Elemente nicht chamfern
@@ -287,6 +287,27 @@ x 72 — immer noch weit innerhalb der Safe Zone.
| 2 | Inhalt: Badge, Headline, Fließtext |
| 3 | Swipe-Hinweis |
### Keine Schatten
**Nichts wirft einen Schatten** — weder Bilder noch Mockups, Logos, Zettel oder
Text. Kein `box-shadow`, kein `filter: drop-shadow()`, kein `text-shadow`.
Tiefe entsteht stattdessen über:
- **Überlappung** — ein zweites Bild vor dem ersten liest sich auch ohne Schlagschatten als davor
- **Rahmenfarbe** — der chamferte Rahmen (`--frame-bg`) setzt das Bild von der Fläche ab
- **Versatz und Drehung** — beim Stapel und beim Notizzettel
**Ausnahme, und nur diese:** Die Blätter im Stapel-Rahmen bekommen eine
Haarlinie (`1px rgba(48,49,61,.18)`). Ohne sie läge auf der hellen Variante
Weiß auf Weiß und der Stapel wäre nicht mehr zu erkennen. Eine Linie ist kein
Schatten.
Warum: Weichgezeichnete Schatten sind das erste, was einen Post nach Vorlage
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
Filter muss beim 3-fachen Rendern dreifach berechnet werden.
### Was global gilt und was pro Slide
| Einstellung | Geltung | Warum |
@@ -403,7 +424,122 @@ soll ein Bildschirm zeigen, was ein Bildschirm zeigt. Die Displayfläche misst
Screenshot dahinter liegen kann. Nur so bleiben Notch und Rundungen oben
3. Displayfläche ausmessen und als Prozentwerte eintragen — sie hängt am
konkreten Bild
4. Schatten über `filter: drop-shadow()` auf dem Container, nicht im PNG
4. **Kein Schatten im PNG** — die Posts sind schattenfrei (§5), ein eingebrannter
Schlagschatten im Mockup-Bild lässt sich nicht mehr abschalten
### Szenen-Mockup (Perspektive)
`assets/mockups/macbook-szene.png` — Macbook Air + iPad Pro, freigestellt aus
einem Pixeden-PSD. Anders als `laptop.png` steht die Szene schräg, die
Displayflächen sind also **Vierecke, keine Rechtecke**.
Damit reicht die übliche Prozent-Box nicht. Der Screenshot wird per
`matrix3d` in die Fläche projiziert:
| | Macbook | iPad |
|---|---|---|
| Design-Größe | 3456 × 2234 | 1438 × 2164 |
| Format | 1,547 | **0,6645** |
| Eckradius | 0 | 57 px |
| Viereck aus | Smart-Object | vermessene Glasfläche |
**Zulieferung: ein iPad-Screenshot muss 0,6645 haben** (1 : 1,505), also z. B.
1440 × 2167. Beim Macbook 1,547, dort verschwinden die unteren 15 % hinter der
Tastaturschale.
Die Matrizen bilden **Design-Pixel auf Bildpixel des 5742 px breiten
Originals** ab; `--sk` skaliert von dort auf die Breite in der Slide und wird
in `applyEdits()` gesetzt, damit eine Größenänderung mitläuft. Die Eckpunkte
stammen aus dem `transform_box` der Smart-Objekte im PSD, sind also gemessen
und nicht angepasst.
**Beide Displays sind ausgeknockt**, der Ausschnitt am Macbook endet dort, wo
das iPad davor liegt. Sonst läge der Screenshot vor dem Tablet. Der Ausschnitt
kommt aus der Deckung der Screen-Ebene, **nicht** aus dem Design-Rechteck: das
reicht hinter das Gehäuse und würde die Tastaturschale anschneiden.
**Die Geräte standen auf einer Glasplatte.** Mit dem Hintergrund fällt die
Platte weg, ihre Spiegelung bleibt aber als zweiter Körper stehen, und der
Spalt dazwischen wird auf hellem Grund zur weißen Linie.
**Der Spalt bekommt keinen festen Farbwert, sondern wird spaltenweise
interpoliert.** Für jede Bildspalte: Medianfarbe der fünf Pixel über dem
Spalt, Medianfarbe der fünf darunter, linear dazwischen. Am Display bleibt so
Schwarz, am Gehäuse das Metallic, an der Kante der Verlauf. Es gibt keinen
Farbsprung mehr und damit auch keinen Streifen.
> Ein einheitlicher Ton reicht nicht. Der Spalt läuft quer über Displayrand
> **und** Aluminium, also über zwei völlig verschiedene Töne. Jeder feste Wert
> passt an einer Stelle und sitzt an der anderen als Fremdkörper.
> Nicht wegschneiden: die Spiegelung hängt am Fuß des iPads, ein Schnitt nach
> Zusammenhangskomponenten trennt den mit ab. Das war der erste Versuch.
Die Füllung läuft über die **volle Breite**, auch unter dem iPad: dessen
eigener Spalt ist sonst als weiße Linie unter dem Rahmen zu sehen. Mit einem
festen Ton ging das nicht, da saß dort ein harter dunkler Block auf dem
Aluminium; mit der Interpolation passt es.
**Das iPad-Viereck kommt nicht aus dem Smart-Object.** Das sitzt im PSD zu
hoch und lässt unten einen dreimal so breiten schwarzen Streifen stehen wie
oben. Statt dessen wird die schwarze Glasfläche vermessen:
1. Randpunkte der vier Kanten sammeln, **nur im mittleren Drittel** — an den
Ecken liegen sie auf den Rundungen
2. Ausgleichsgerade je Kante, benachbarte schneiden → die **echten** Ecken.
Über Extrempunkte kommt ein zu kleines, verdrehtes Viereck heraus, weil
die Extrema auf den Rundungen sitzen
3. Eckradius aus dem Abstand Ecke zu Glas: bei einem rechten Winkel liegt der
Scheitel der Rundung bei `r · (√2 − 1)` → **Glas 1514 × 2240, r = 95**
4. **38 px** Rand nach innen (2,5 % der Breite) → Display 1438 × 2164
5. Innenradius **konzentrisch**: `95 − 38 = 57`. Nur so bleibt der schwarze
Rand auch in den Ecken gleich breit
Der Ausschnitt wird als gerundetes Rechteck im Design-Raum gezeichnet und dann
durch die Homographie geschoben, im CSS macht `border-radius: 57px` mit
`overflow: hidden` dasselbe. Ohne das stößt eine scharfe Bildecke in die runde
Glasecke, und genau das sieht man sofort.
Beim Macbook stimmt das Smart-Object, dort bleibt es dabei.
Belegung: Kanban-Board auf dem Macbook, Projekt-Chat hochkant auf dem iPad.
**Beide liegen originalgetreu auf ihrem Display, ohne Zoom.** Sie sind flacher
als der Bildschirm, es bleibt also Fläche frei; die trägt mit `#FAFAFA` den
Hintergrundton der Anwendung und ist deshalb nicht als Rand zu erkennen.
> **Das Board rückt 5,15 % nach unten.** Das Smart-Object des PSD ragt oben
> hinter die Blende des Deckels. Ohne Versatz verschwindet genau die Kopfzeile
> der Anwendung, und der Screen sieht angeschnitten aus, obwohl gar nicht
> gezoomt wurde. Unten wird der Screen von der Tastaturschale beschnitten —
> das ist so gewollt und im Pixeden-Original genauso.
> **Lizenz beachten.** Pixeden-Ressourcen sind für kommerzielle Projekte frei
> und ohne Namensnennung nutzbar, dürfen aber **nicht weitergegeben** werden.
> Das PSD gehört deshalb nicht ins Repo, nur das für uns abgeleitete PNG.
### Nachgebaute UI-Bausteine
Manche Produkt-Elemente liegen **nicht** als Screenshot, sondern als HTML im
Generator. Bisher: der **Notizzettel** auf der Workflow-Slide.
Nachbau aus `hivegrid-pitch/ui/components/StickyNote/StickyNote.tsx`,
Variante `purple`:
| Teil | Wert |
|---|---|
| Kopfleiste | `#ce93d8` |
| Fläche | `#f3e5f5` |
| Text | `#424242`, Gewicht 400, Zeilenhöhe 1,5 |
| Menü | drei Punkte (FeatherMoreVertical), kein Balken |
| Anfasser | zwei Ecken unten rechts, 45 % und 65 % Deckkraft |
Die Vorlage ist 200 px breit bei 14 px Text; auf dem 1080er Blatt sind alle
Maße rund 1,6× hochskaliert. **Farbwerte nicht neu erfinden** — sie stammen aus
der Software und müssen wiedererkennbar bleiben.
Warum HTML und nicht Screenshot: Kanten und Schrift bleiben beim 3×-Export
genauso scharf wie der Rest der Slide, der Text ist austauschbar, und es gibt
keine Scrollbalken oder Cursor-Reste im Bild.
---