feat: section on web presentations, in German and English
Build & Deploy / 🔔 Notify (push) Successful in 2s
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🏗️ Build (push) Successful in 6m37s
Build & Deploy / 🚀 Deploy (push) Successful in 19s
Build & Deploy / 🩺 Smoke Test (push) Failing after 3s

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 14:58:35 +02:00
co-authored by Claude Opus 5.5
parent 666b1d7547
commit 6f6e52dd63
5 changed files with 841 additions and 5 deletions
@@ -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 }) => (
<motion.rect
rx={12}
fill="#0b1220"
stroke={RIM}
strokeWidth={FINE}
initial={false}
animate={{
attrX: rect.x,
attrY: rect.y,
width: rect.width,
height: rect.height,
}}
transition={MOVE}
/>
);
/** 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 (
<motion.g
style={{ originX: 0, originY: 0 }}
initial={false}
animate={{ x: fit.x, y: fit.y, scale: fit.scale }}
transition={MOVE}
>
<rect
width={SLIDE.width}
height={SLIDE.height}
rx={4}
fill={TINT}
stroke={INK}
strokeWidth={FINE}
/>
<line
x1={12}
x2={84}
y1={18}
y2={18}
stroke={INK}
strokeWidth={4}
strokeLinecap="round"
/>
{[0, 1, 2].map((bullet) => (
<g key={bullet}>
<circle cx={14} cy={38 + bullet * 13} r={1.6} fill={RIM} />
<line
x1={20}
x2={20 + [58, 46, 52][bullet % 3]}
y1={38 + bullet * 13}
y2={38 + bullet * 13}
stroke={RIM}
strokeWidth={2.5}
strokeLinecap="round"
/>
</g>
))}
{[0, 1, 2, 3].map((bar) => {
const height = [18, 30, 24, 40][bar % 4];
return (
<rect
key={bar}
x={104 + bar * 11}
y={74 - height}
width={7}
height={height}
rx={2}
fill={bar === 3 ? MARK : RIM}
/>
);
})}
</motion.g>
);
};
const WebPage: React.FC<{ screen: Rect; index: number }> = ({
screen,
index,
}) => {
const { header, cards } = webLayout(screen, index);
return (
<g>
<motion.rect
rx={4}
fill={SOFT}
stroke={LINE}
strokeWidth={1}
initial={false}
animate={{
attrX: header.x,
attrY: header.y,
width: header.width,
height: header.height,
}}
transition={MOVE}
/>
{cards.map((card, i) => (
<g key={i}>
<motion.rect
rx={6}
fill={TINT}
stroke={INK}
strokeWidth={FINE}
initial={false}
animate={{
attrX: card.x,
attrY: card.y,
width: card.width,
height: card.height,
}}
transition={MOVE}
/>
<motion.line
stroke={i === 2 ? MARK : INK}
strokeWidth={3}
strokeLinecap="round"
initial={false}
animate={{
x1: card.x + 9,
x2: card.x + Math.min(card.width - 9, 9 + 46),
y1: card.y + 12,
y2: card.y + 12,
}}
transition={MOVE}
/>
<motion.line
stroke={RIM}
strokeWidth={2.5}
strokeLinecap="round"
initial={false}
animate={{
x1: card.x + 9,
x2: card.x + Math.min(card.width - 9, 9 + 62),
y1: card.y + 23,
y2: card.y + 23,
}}
transition={MOVE}
/>
</g>
))}
</g>
);
};
/**
* 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<HTMLDivElement>(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 (
<div ref={ref}>
<svg
viewBox={`0 0 ${view.width} ${view.height}`}
role="img"
aria-label={compare.description}
className="block h-auto w-full [--scene-label:13px] md:[--scene-label:10px]"
>
{[
{
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 && (
<g key={column.name}>
<text
x={column.stage.cx}
y={column.stage.cy - 118}
textAnchor="middle"
style={{ ...MONO, fill: INK }}
>
{column.name}
</text>
<motion.text
key={column.note}
x={column.stage.cx}
y={column.stage.cy + 128}
textAnchor="middle"
style={{ ...MONO, fill: column.colour }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4, delay: 0.35 }}
>
{column.note}
</motion.text>
</g>
),
)}
<Frame rect={slideScreen} />
<Slide screen={slideScreen} />
<Frame rect={webScreen} />
<WebPage screen={webScreen} index={index} />
</svg>
<div className="mt-6 flex flex-wrap items-center justify-center gap-2 md:mt-8">
<span className="mr-2 font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500">
{compare.pickScreen}
</span>
{compare.screens.map((screen, i) => {
const selected = i === index;
return (
<button
key={screen}
type="button"
aria-pressed={selected}
onClick={() => {
setIndex(i);
setAuto(false);
}}
className={`rounded-full border px-4 py-2 font-mono text-[11px] uppercase tracking-[0.15em] transition-colors duration-200 ${
selected
? "border-white bg-white text-slate-900"
: "border-slate-700 text-slate-300 hover:border-slate-400 hover:text-white"
}`}
>
{screen}
</button>
);
})}
</div>
</div>
);
};
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 = () => (
<g fill="none" stroke={INK} strokeWidth={FINE} strokeLinejoin="round">
<motion.rect x={14} y={14} width={86} height={50} rx={5} {...draw(0)} />
<motion.path d="M 44 74 h 26 M 57 64 v 10" stroke={RIM} {...draw(0.2)} />
<motion.rect x={112} y={30} width={60} height={38} rx={4} {...draw(0.35)} />
<motion.path d="M 104 72 h 76" stroke={RIM} {...draw(0.5)} />
<motion.rect
x={190}
y={22}
width={28}
height={52}
rx={6}
fill={TINT}
{...draw(0.7)}
/>
</g>
);
/** One link: several copies of a file on one side, a single address on the other. */
const LinkDrawing: React.FC = () => (
<g>
{[0, 1, 2].map((copy) => (
<rect
key={copy}
x={18 + copy * 12}
y={18 + copy * 8}
width={38}
height={48}
rx={4}
fill="#0b1220"
stroke={LINE}
strokeWidth={1}
strokeDasharray="3 3"
/>
))}
<motion.path
d="M 96 46 h 30"
stroke={INK}
strokeWidth={FINE}
markerEnd="url(#reason-arrow)"
{...draw(0.2)}
/>
<rect
x={138}
y={32}
width={82}
height={28}
rx={14}
fill={TINT}
stroke={INK}
strokeWidth={FINE}
/>
<motion.circle
cx={152}
cy={46}
r={3.5}
fill={LIVE}
animate={{ opacity: [1, 0.35, 1] }}
transition={{ duration: 1.8, repeat: Infinity }}
/>
<line
x1={162}
x2={208}
y1={46}
y2={46}
stroke={INK}
strokeWidth={2.5}
strokeLinecap="round"
/>
</g>
);
/** The real thing: a chart whose bars keep moving, because the figures are live. */
const LiveDrawing: React.FC = () => (
<g>
<rect
x={24}
y={12}
width={186}
height={66}
rx={8}
fill="#0b1220"
stroke={RIM}
strokeWidth={FINE}
/>
<line x1={38} x2={196} y1={66} y2={66} stroke={LINE} strokeWidth={1} />
{[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 (
<motion.rect
key={bar}
x={44 + bar * 25}
width={14}
rx={3}
fill={bar === 5 ? MARK : RIM}
initial={false}
animate={{
height: [low, high, low],
attrY: [66 - low, 66 - high, 66 - low],
}}
transition={{
duration: 3.6,
delay: bar * 0.25,
repeat: Infinity,
ease: "easeInOut",
}}
/>
);
})}
<motion.circle
cx={198}
cy={24}
r={3}
fill={LIVE}
animate={{ opacity: [1, 0.3, 1] }}
transition={{ duration: 1.6, repeat: Infinity }}
/>
</g>
);
/** Afterwards: a page that is read on its own and passed on. */
const AfterDrawing: React.FC = () => (
<g>
<rect
x={30}
y={10}
width={74}
height={70}
rx={6}
fill={TINT}
stroke={INK}
strokeWidth={FINE}
/>
{[0, 1, 2, 3].map((row) => (
<line
key={row}
x1={42}
x2={42 + [48, 40, 46, 30][row % 4]}
y1={26 + row * 12}
y2={26 + row * 12}
stroke={row === 0 ? INK : RIM}
strokeWidth={row === 0 ? 3 : 2.5}
strokeLinecap="round"
/>
))}
{[24, 46, 68].map((y, i) => (
<g key={y}>
<motion.path
d={`M 104 45 C 136 45 138 ${y} 168 ${y}`}
fill="none"
stroke={INK}
strokeWidth={FINE}
{...draw(0.2 + i * 0.2)}
/>
<circle
cx={180}
cy={y}
r={9}
fill="#0b1220"
stroke={RIM}
strokeWidth={FINE}
/>
<circle cx={180} cy={y - 2} r={2.6} fill={RIM} />
<path
d={`M 174.5 ${y + 5.5} q 5.5 -6 11 0`}
fill="none"
stroke={RIM}
strokeWidth={1.4}
strokeLinecap="round"
/>
</g>
))}
</g>
);
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 (
<svg
viewBox="0 0 234 90"
aria-hidden="true"
className="block h-auto w-full"
>
<defs>
<marker
id="reason-arrow"
viewBox="0 0 10 10"
refX="8"
refY="5"
markerWidth="5"
markerHeight="5"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill={INK} />
</marker>
</defs>
<Drawing />
</svg>
);
};