From 6f6e52dd63c3adb522da9ff5ed270d40b085f8d2 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Sun, 4 Oct 2026 14:58:35 +0200 Subject: [PATCH] feat: section on web presentations, in German and English A presentation built as a website instead of a slide file: why, for what, and when slides are enough. The main drawing shows the same presentation as a slide and as a web page on a projector, a laptop and a phone; the slide keeps its format and shrinks, the page rearranges. Four small drawings stand next to the reasons. No prices: none are agreed for this yet. Co-Authored-By: Claude Opus 5.5 --- apps/web/app/[locale]/page.tsx | 94 ++- .../illustrations/WebPresentation.tsx | 622 ++++++++++++++++++ apps/web/src/content/de.ts | 56 ++ apps/web/src/content/en.ts | 52 ++ apps/web/src/content/types.ts | 22 + 5 files changed, 841 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/components/illustrations/WebPresentation.tsx diff --git a/apps/web/app/[locale]/page.tsx b/apps/web/app/[locale]/page.tsx index ee60d58..61ea35d 100644 --- a/apps/web/app/[locale]/page.tsx +++ b/apps/web/app/[locale]/page.tsx @@ -13,6 +13,10 @@ import { AutomationFlow } from "@/src/components/illustrations/AutomationFlow"; import { CareerLine } from "@/src/components/illustrations/CareerLine"; import { ContactDiagram } from "@/src/components/illustrations/ContactDiagram"; import { PipelineRun } from "@/src/components/illustrations/PipelineRun"; +import { + PresentationCompare, + ReasonDrawing, +} from "@/src/components/illustrations/WebPresentation"; import { WorkShowcase } from "@/src/components/illustrations/WorkShowcase"; import { Reveal } from "@/src/components/Reveal"; import { contentFor } from "@/src/content"; @@ -51,8 +55,16 @@ const Title: React.FC<{ title: SplitTitle; muted?: string }> = ({ export default async function LandingPage({ params }: Props) { const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE; const content = contentFor(locale); - const { hello, services, automation, technology, working, work, faq } = - content; + const { + hello, + services, + automation, + presentations, + technology, + working, + work, + faq, + } = content; return (
@@ -133,6 +145,78 @@ export default async function LandingPage({ params }: Props) { } + > + +

+ {presentations.intro} +

+
+ + +
+ +
+
+
+ +
+ {presentations.reasons.map((reason, i) => ( + +
+ +
+

+ {reason.title} +

+

+ {reason.text} +

+
+ ))} +
+ +
+ +
+

+ {presentations.goodFor.title} +

+
    + {presentations.goodFor.items.map((item) => ( +
  • + {item} +
  • + ))} +
+
+
+ +
+

+ {presentations.honest.title} +

+

+ {presentations.honest.text} +

+ + {presentations.cta} + +
+
+
+
+ + } > @@ -201,7 +285,7 @@ export default async function LandingPage({ params }: Props) { } > @@ -227,7 +311,7 @@ export default async function LandingPage({ params }: Props) { } @@ -240,7 +324,7 @@ export default async function LandingPage({ params }: Props) { - }> + }> diff --git a/apps/web/src/components/illustrations/WebPresentation.tsx b/apps/web/src/components/illustrations/WebPresentation.tsx new file mode 100644 index 0000000..d2fce10 --- /dev/null +++ b/apps/web/src/components/illustrations/WebPresentation.tsx @@ -0,0 +1,622 @@ +"use client"; + +import * as React from "react"; +import { motion } from "framer-motion"; +import { useContent } from "@/src/components/ContentProvider"; +import { useNarrow } from "./useNarrow"; + +/** + * WebPresentation: the drawings of the web presentations section. The main one + * shows the same presentation as a slide file and as a website on three + * screens; four small ones stand next to the reasons. Same line language as + * PathScenes. + */ + +const INK = "#f8fafc"; +const LINE = "#475569"; +const RIM = "#64748b"; +const SOFT = "#1e293b"; +const MARK = "#fde68a"; +const LIVE = "#10b981"; +const TINT = "rgba(248, 250, 252, 0.14)"; +const FINE = 1.1; +const STEP_MS = 3600; +const MOVE = { duration: 0.7, ease: [0.16, 1, 0.3, 1] as const }; + +const MONO: React.CSSProperties = { + fontFamily: "var(--font-jetbrains-mono), ui-monospace, monospace", + fontSize: "var(--scene-label, 10px)", + letterSpacing: "0.14em", + textTransform: "uppercase", + fill: "#94a3b8", +}; + +interface Size { + width: number; + height: number; +} + +interface Rect extends Size { + x: number; + y: number; +} + +/** Projector, laptop, phone. */ +const SCREENS: readonly Size[] = [ + { width: 300, height: 170 }, + { width: 230, height: 150 }, + { width: 96, height: 190 }, +]; + +const SLIDE: Size = { width: 160, height: 90 }; +const PAD = 12; +const GAP = 8; + +const centred = (size: Size, cx: number, cy: number): Rect => ({ + ...size, + x: cx - size.width / 2, + y: cy - size.height / 2, +}); + +/** The slide keeps its format: it is scaled down until it fits the screen. */ +const fitSlide = (screen: Rect) => { + const scale = Math.min( + (screen.width - 2 * PAD) / SLIDE.width, + (screen.height - 2 * PAD) / SLIDE.height, + ); + return { + scale, + x: screen.x + (screen.width - SLIDE.width * scale) / 2, + y: screen.y + (screen.height - SLIDE.height * scale) / 2, + }; +}; + +/** The web page rearranges: three columns, two columns and a row, or one column. */ +const webLayout = (screen: Rect, index: number) => { + const left = screen.x + PAD; + const inner = screen.width - 2 * PAD; + const header: Rect = { x: left, y: screen.y + PAD, width: inner, height: 12 }; + const top = header.y + header.height + GAP; + const area = screen.y + screen.height - PAD - top; + + if (index === 0) { + const width = (inner - 2 * GAP) / 3; + return { + header, + cards: [0, 1, 2].map((i) => ({ + x: left + i * (width + GAP), + y: top, + width, + height: area, + })), + }; + } + if (index === 1) { + const width = (inner - GAP) / 2; + const upper = (area - GAP) * 0.56; + return { + header, + cards: [ + { x: left, y: top, width, height: upper }, + { x: left + width + GAP, y: top, width, height: upper }, + { + x: left, + y: top + upper + GAP, + width: inner, + height: area - upper - GAP, + }, + ], + }; + } + const height = (area - 2 * GAP) / 3; + return { + header, + cards: [0, 1, 2].map((i) => ({ + x: left, + y: top + i * (height + GAP), + width: inner, + height, + })), + }; +}; + +const Frame: React.FC<{ rect: Rect }> = ({ rect }) => ( + +); + +/** One 16:9 slide, drawn at its own size and scaled as a whole. */ +const Slide: React.FC<{ screen: Rect }> = ({ screen }) => { + const fit = fitSlide(screen); + return ( + + + + {[0, 1, 2].map((bullet) => ( + + + + + ))} + {[0, 1, 2, 3].map((bar) => { + const height = [18, 30, 24, 40][bar % 4]; + return ( + + ); + })} + + ); +}; + +const WebPage: React.FC<{ screen: Rect; index: number }> = ({ + screen, + index, +}) => { + const { header, cards } = webLayout(screen, index); + return ( + + + {cards.map((card, i) => ( + + + + + + ))} + + ); +}; + +/** + * PresentationCompare: the same presentation as a slide file and as a website, + * on a projector, a laptop and a phone. It steps through the screens on its + * own until the visitor picks one. + */ +export const PresentationCompare: React.FC = () => { + const { compare } = useContent().presentations; + const narrow = useNarrow(); + const ref = React.useRef(null); + const [index, setIndex] = React.useState(0); + const [auto, setAuto] = React.useState(true); + const [inView, setInView] = React.useState(false); + + React.useEffect(() => { + const node = ref.current; + if (!node) return; + const observer = new IntersectionObserver( + ([entry]) => setInView(entry?.isIntersecting ?? false), + { threshold: 0.3 }, + ); + observer.observe(node); + return () => observer.disconnect(); + }, []); + + React.useEffect(() => { + if (!auto || !inView) return; + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + const timer = window.setInterval( + () => setIndex((current) => (current + 1) % SCREENS.length), + STEP_MS, + ); + return () => window.clearInterval(timer); + }, [auto, inView]); + + // Side by side on wide screens, one above the other on a phone. + const view = narrow + ? { width: 380, height: 600 } + : { width: 760, height: 300 }; + const stages = narrow + ? [ + { cx: 190, cy: 150 }, + { cx: 190, cy: 450 }, + ] + : [ + { cx: 190, cy: 150 }, + { cx: 570, cy: 150 }, + ]; + const size = SCREENS[index] ?? SCREENS[0]; + if (!size) return null; + const slideScreen = centred(size, stages[0]?.cx ?? 0, stages[0]?.cy ?? 0); + const webScreen = centred(size, stages[1]?.cx ?? 0, stages[1]?.cy ?? 0); + const tooSmall = index === SCREENS.length - 1; + + return ( +
+ + {[ + { + stage: stages[0], + name: compare.slides, + note: compare.slideNotes[index], + colour: tooSmall ? MARK : "#94a3b8", + }, + { + stage: stages[1], + name: compare.web, + note: compare.webNotes[index], + colour: LIVE, + }, + ].map( + (column) => + column.stage && ( + + + {column.name} + + + {column.note} + + + ), + )} + + + + + + +
+ + {compare.pickScreen} + + {compare.screens.map((screen, i) => { + const selected = i === index; + return ( + + ); + })} +
+
+ ); +}; + +const draw = (delay: number) => ({ + initial: { pathLength: 0, opacity: 0 }, + whileInView: { pathLength: 1, opacity: 1 }, + viewport: { once: true, margin: "-10%" }, + transition: { duration: 0.7, delay, ease: "easeOut" as const }, +}); + +/** Every screen: a projector, a laptop and a phone drawn one after another. */ +const ScreensDrawing: React.FC = () => ( + + + + + + + +); + +/** One link: several copies of a file on one side, a single address on the other. */ +const LinkDrawing: React.FC = () => ( + + {[0, 1, 2].map((copy) => ( + + ))} + + + + + +); + +/** The real thing: a chart whose bars keep moving, because the figures are live. */ +const LiveDrawing: React.FC = () => ( + + + + {[0, 1, 2, 3, 4, 5].map((bar) => { + const low = [14, 22, 18, 30, 24, 36][bar % 6] ?? 20; + const high = [26, 16, 34, 22, 40, 28][bar % 6] ?? 30; + return ( + + ); + })} + + +); + +/** Afterwards: a page that is read on its own and passed on. */ +const AfterDrawing: React.FC = () => ( + + + {[0, 1, 2, 3].map((row) => ( + + ))} + {[24, 46, 68].map((y, i) => ( + + + + + + + ))} + +); + +const REASON_DRAWINGS: readonly React.FC[] = [ + ScreensDrawing, + LinkDrawing, + LiveDrawing, + AfterDrawing, +]; + +/** The small drawing next to one of the reasons, by its position in the list. */ +export const ReasonDrawing: React.FC<{ index: number }> = ({ index }) => { + const Drawing = REASON_DRAWINGS[index]; + if (!Drawing) return null; + return ( + + ); +}; diff --git a/apps/web/src/content/de.ts b/apps/web/src/content/de.ts index 0d67d4c..dbadfb0 100644 --- a/apps/web/src/content/de.ts +++ b/apps/web/src/content/de.ts @@ -441,6 +441,62 @@ export const de: Content = { }, ], }, + presentations: { + label: "Web-Präsentationen", + title: { lead: "Eine Präsentation als Link.", rest: "Nicht als Anhang." }, + intro: + "Die meisten Präsentationen sind noch Foliendateien: ein festes Format, verschickt als Anhang. Ich baue Präsentationen als Websites. Sie öffnen sich in jedem Browser, passen sich dem Bildschirm an, auf dem sie gezeigt werden, und können Dinge enthalten, die eine Folie nicht kann.", + compare: { + description: + "Eine Folie behält auf jedem Bildschirm ihr festes Format und wird auf dem Handy zu klein zum Lesen. Eine Web-Präsentation ordnet ihren Inhalt für jeden Bildschirm neu an.", + slides: "Foliendatei", + web: "Web-Präsentation", + pickScreen: "Gezeigt auf", + screens: ["Beamer", "Laptop", "Handy"], + slideNotes: [ + "Für dieses Format gemacht", + "Passt noch", + "Verkleinert: zu klein zum Lesen", + ], + webNotes: [ + "Nutzt die volle Breite", + "Zwei Spalten", + "Eine Spalte, gut lesbar", + ], + }, + reasons: [ + { + title: "Passt auf jeden Bildschirm", + text: "Eine Folie hat ein festes Format. Eine Web-Präsentation ordnet sich für Beamer, Laptop und Handy jeweils neu an.", + }, + { + title: "Ein Link, ein Stand", + text: "Niemand muss fragen, welche Datei die aktuelle ist. Sie ändern es an einer Stelle, und jeder, der den Link öffnet, sieht diesen Stand.", + }, + { + title: "Zeigt das Echte", + text: "Aktuelle Zahlen, eine funktionierende Demo oder ein klickbarer Prototyp können direkt in der Präsentation stehen, statt eines Screenshots davon.", + }, + { + title: "Auch danach noch nützlich", + text: "Nach dem Termin ist sie eine Seite, die man allein lesen, weitergeben und wiederfinden kann.", + }, + ], + goodFor: { + title: "Typische Einsätze", + items: [ + "Unternehmens- und Produktpräsentationen", + "Pitches und Angebote, die als Link verschickt werden", + "Berichte mit Zahlen, die sich ändern", + "Bildschirme auf Messen und Veranstaltungen", + ], + }, + honest: { + title: "Wann Folien reichen", + text: "Für einen einzelnen internen Termin ist eine Foliendatei schneller und günstiger. Eine Web-Präsentation lohnt sich, wenn sie oft gezeigt wird, an viele Menschen geht oder für Ihr Unternehmen steht.", + }, + cta: "Nach einer Präsentation fragen", + }, technology: { label: "Technik", title: { lead: "Für welche Projekte", rest: "ich der Richtige bin." }, diff --git a/apps/web/src/content/en.ts b/apps/web/src/content/en.ts index 3390eb8..eef42b7 100644 --- a/apps/web/src/content/en.ts +++ b/apps/web/src/content/en.ts @@ -425,6 +425,58 @@ export const en: Content = { }, ], }, + presentations: { + label: "Web presentations", + title: { lead: "A presentation as a link.", rest: "Not as an attachment." }, + intro: + "Most presentations are still slide files: one fixed format, sent around as an attachment. I build presentations as websites. They open in any browser, adapt to the screen they are shown on, and can contain things a slide cannot.", + compare: { + description: + "A slide keeps its fixed format on every screen and becomes too small to read on a phone. A web presentation rearranges its content for each screen.", + slides: "Slide file", + web: "Web presentation", + pickScreen: "Shown on", + screens: ["Projector", "Laptop", "Phone"], + slideNotes: [ + "Made for this format", + "Still fits", + "Shrunk: too small to read", + ], + webNotes: ["Uses the full width", "Two columns", "One column, readable"], + }, + reasons: [ + { + title: "Fits every screen", + text: "A slide has one fixed format. A web presentation rearranges itself for the projector, the laptop and the phone.", + }, + { + title: "One link, one version", + text: "Nobody has to ask which file is the current one. You change it in one place, and everyone who opens the link sees that state.", + }, + { + title: "Shows the real thing", + text: "Current figures, a working demo or a clickable prototype can sit inside the presentation, instead of a screenshot of them.", + }, + { + title: "Still useful afterwards", + text: "After the meeting it is a page people can read on their own, pass on and find again.", + }, + ], + goodFor: { + title: "Typical uses", + items: [ + "Company and product presentations", + "Pitches and offers sent as a link", + "Reports with figures that change", + "Screens at trade fairs and events", + ], + }, + honest: { + title: "When slides are enough", + text: "For a single internal meeting, a slide file is quicker and cheaper. A web presentation is worth it when it is shown often, sent to many people, or stands for your company.", + }, + cta: "Ask about a presentation", + }, technology: { label: "Technology", title: { lead: "Which projects", rest: "I am right for." }, diff --git a/apps/web/src/content/types.ts b/apps/web/src/content/types.ts index 6668822..ecb32d6 100644 --- a/apps/web/src/content/types.ts +++ b/apps/web/src/content/types.ts @@ -198,6 +198,28 @@ export type Content = { readonly workflows: Readonly>; readonly notes: readonly TitledList[]; }; + readonly presentations: { + readonly label: string; + readonly title: SplitTitle; + readonly intro: string; + readonly compare: { + /** What the drawing shows, for screen readers. */ + readonly description: string; + readonly slides: string; + readonly web: string; + readonly pickScreen: string; + /** Projector, laptop, phone: in this order. */ + readonly screens: readonly string[]; + /** What happens to the slide on each screen, in the same order. */ + readonly slideNotes: readonly string[]; + readonly webNotes: readonly string[]; + }; + /** Every screen, one link, real content, useful afterwards: in this order. */ + readonly reasons: readonly TitledText[]; + readonly goodFor: TitledList; + readonly honest: TitledText; + readonly cta: string; + }; readonly technology: { readonly label: string; readonly title: SplitTitle;