feat: German as the main language, English under /en, with a language switch

The site now exists in German (at the root, with /impressum and /datenschutz)
and English (/en). A request without a language follows the visitor's choice
from the language switch, then the browser language, then German; the old
English legal addresses move to /en for good.

All visible texts are data in src/content/de.ts and en.ts, so components hold
none of their own. The header carries a DE/EN switch whose bit curtain covers
the page while it is swapped and reopens at the same reading position. Title,
description, canonical, hreflang, Open Graph, the share image, the sitemap and
the structured data are per language. The privacy notice now describes the
cookie that remembers the chosen language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 13:07:22 +02:00
co-authored by Claude Opus 5.5
parent e89a248ca5
commit 75993100fa
72 changed files with 4418 additions and 1405 deletions
@@ -3,13 +3,9 @@
import { BitAura } from "@/src/components/bits/BitAura";
import * as React from "react";
import { useNarrow } from "./useNarrow";
import {
KIND_LABEL,
WORKFLOWS,
type FlowNode,
type NodeKind,
type Workflow,
} from "@/src/content/automation";
import { useContent } from "@/src/components/ContentProvider";
import { workflowsFor, type FlowNode } from "@/src/content/automation";
import type { NodeKind } from "@/src/content/types";
/**
* AutomationFlow: a workflow drawn as connected systems, with the work visibly
@@ -95,6 +91,9 @@ const KIND_STYLE: Readonly<
human: { stroke: "#f8fafc", dash: "2 4", tag: "#f8fafc" },
};
/** The order of the legend below the drawing. */
const KINDS: readonly NodeKind[] = ["trigger", "rule", "ai", "action", "human"];
type Clock = {
/** Which run of the workflow is playing. */
readonly run: number;
@@ -107,7 +106,12 @@ type Clock = {
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em]";
export const AutomationFlow: React.FC = () => {
const [flowId, setFlowId] = React.useState(WORKFLOWS[0]?.id ?? "");
const { automation } = useContent();
const workflows = React.useMemo(
() => workflowsFor(automation.workflows),
[automation.workflows],
);
const [flowId, setFlowId] = React.useState(workflows[0]?.id ?? "");
const [clock, setClock] = React.useState<Clock>({ run: 0, step: 0, t: 0 });
const ref = React.useRef<HTMLDivElement>(null);
const [inView, setInView] = React.useState(false);
@@ -116,7 +120,7 @@ export const AutomationFlow: React.FC = () => {
const tall = useNarrow();
const canvas = tall ? TALL : WIDE;
const flow = WORKFLOWS.find((item) => item.id === flowId) ?? WORKFLOWS[0];
const flow = workflows.find((item) => item.id === flowId) ?? workflows[0];
React.useEffect(() => {
const node = ref.current;
@@ -195,10 +199,15 @@ export const AutomationFlow: React.FC = () => {
}
});
const logLines: string[] = [];
const logLines: { text: string; kind: NodeKind }[] = [];
const log = (node: Placed) => {
if (logLines.every((line) => line.text !== node.log)) {
logLines.push({ text: node.log, kind: node.kind });
}
};
if (firstNode) {
const first = nodeOf(firstNode);
if (first) logLines.push(first.log);
if (first) log(first);
}
steps.forEach((edges, index) => {
const arrived =
@@ -206,7 +215,7 @@ export const AutomationFlow: React.FC = () => {
if (!arrived) return;
edges.forEach(([, to]) => {
const node = nodeOf(to);
if (node && !logLines.includes(node.log)) logLines.push(node.log);
if (node) log(node);
});
});
const finished = clock.step >= steps.length;
@@ -215,10 +224,10 @@ export const AutomationFlow: React.FC = () => {
<div ref={ref}>
<div
role="tablist"
aria-label="Example workflows"
aria-label={automation.examplesLabel}
className="flex flex-wrap gap-2"
>
{WORKFLOWS.map((item) => {
{workflows.map((item) => {
const selected = item.id === flow.id;
return (
<button
@@ -251,7 +260,7 @@ export const AutomationFlow: React.FC = () => {
<span
className={`h-1.5 w-1.5 rounded-full ${finished ? "bg-emerald-400" : "bg-amber-300 animate-pulse"}`}
/>
{finished ? "run complete" : "running"}
{finished ? automation.runComplete : automation.running}
</span>
</div>
@@ -383,53 +392,51 @@ export const AutomationFlow: React.FC = () => {
<div className="grid grid-cols-1 border-t border-slate-800 md:grid-cols-12">
<div className="border-b border-slate-800 px-5 py-5 md:col-span-7 md:border-b-0 md:border-r md:px-7">
<p className={`${MONO} m-0 text-slate-500`}>Log</p>
<p className={`${MONO} m-0 text-slate-500`}>{automation.log}</p>
<ol
className="m-0 mt-3 min-h-[7.5rem] list-none p-0 font-mono text-[12px] leading-relaxed text-slate-300 md:text-[13px]"
aria-live="off"
>
{logLines.map((line, index) => (
<li key={line} className="flex gap-3">
<li key={line.text} className="flex gap-3">
<span className="text-slate-600">
{String(index + 1).padStart(2, "0")}
</span>
<span
className={
line.startsWith("unclear")
? "text-amber-200"
: undefined
line.kind === "human" ? "text-amber-200" : undefined
}
>
{line}
{line.text}
</span>
</li>
))}
</ol>
</div>
<ul className="m-0 flex list-none flex-col justify-center gap-2 px-5 py-5 md:col-span-5 md:px-7">
{(Object.keys(KIND_LABEL) as NodeKind[])
.filter((kind) => flow.nodes.some((node) => node.kind === kind))
.map((kind) => (
<li
key={kind}
className="flex items-center gap-3 font-mono text-[11px] text-slate-400"
>
<svg width="22" height="12" aria-hidden="true">
<rect
x="1"
y="1"
width="20"
height="10"
rx="3"
fill="none"
stroke={KIND_STYLE[kind].stroke}
strokeWidth="1.5"
strokeDasharray={KIND_STYLE[kind].dash}
/>
</svg>
{KIND_LABEL[kind]}
</li>
))}
{KINDS.filter((kind) =>
flow.nodes.some((node) => node.kind === kind),
).map((kind) => (
<li
key={kind}
className="flex items-center gap-3 font-mono text-[11px] text-slate-400"
>
<svg width="22" height="12" aria-hidden="true">
<rect
x="1"
y="1"
width="20"
height="10"
rx="3"
fill="none"
stroke={KIND_STYLE[kind].stroke}
strokeWidth="1.5"
strokeDasharray={KIND_STYLE[kind].dash}
/>
</svg>
{automation.kinds[kind]}
</li>
))}
</ul>
</div>
</div>
@@ -437,13 +444,13 @@ export const AutomationFlow: React.FC = () => {
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="rounded-3xl border border-slate-200 p-6 md:p-8">
<p className={`${MONO} m-0 text-slate-400`}>By hand</p>
<p className={`${MONO} m-0 text-slate-400`}>{automation.byHand}</p>
<p className="m-0 mt-3 font-sans text-lg leading-relaxed text-slate-500 md:text-xl">
{flow.byHand}
</p>
</div>
<div className="rounded-3xl border border-slate-900 p-6 md:p-8">
<p className={`${MONO} m-0 text-slate-900`}>Automated</p>
<p className={`${MONO} m-0 text-slate-900`}>{automation.automated}</p>
<p className="m-0 mt-3 font-sans text-lg leading-relaxed text-slate-900 md:text-xl">
{flow.automated}
</p>
@@ -3,12 +3,7 @@
import * as React from "react";
import { motion } from "framer-motion";
import { BitAura } from "@/src/components/bits/BitAura";
export type CareerStation = {
readonly years: string;
readonly role: string;
readonly place: string;
};
import type { CareerStation } from "@/src/content/types";
/**
* CareerLine: the path so far as a line of light that draws itself, one station
@@ -2,6 +2,7 @@
import * as React from "react";
import { motion } from "framer-motion";
import { useContent } from "@/src/components/ContentProvider";
import { useNarrow } from "./useNarrow";
const INK = "#f8fafc";
@@ -10,8 +11,6 @@ const MARK = "#fde68a";
const NODE_W = 112;
const NODE_H = 38;
const CHAIN = ["You", "Sales", "Manager", "Designer", "Developer"] as const;
const Node: React.FC<{
x: number;
y: number;
@@ -78,12 +77,13 @@ const Caption: React.FC<{
*/
export const ContactDiagram: React.FC = () => {
const tall = useNarrow();
const label =
"Often a request passes through several people before it reaches the developer. With me there is one direct line between you and the person who does the work.";
const copy = useContent().working.diagram;
const chain = copy.chain;
const label = copy.description;
if (tall) {
// Two columns: the chain on the left, the direct line on the right.
const ys = CHAIN.map((_, i) => 96 + i * 84);
const ys = chain.map((_, i) => 96 + i * 84);
const first = ys[0] ?? 96;
const last = ys[ys.length - 1] ?? 432;
const stops = ys.flatMap((y) => [y, y]);
@@ -95,10 +95,10 @@ export const ContactDiagram: React.FC = () => {
className="block h-auto w-full"
>
<Caption x={24} y={38} size={12}>
often
{copy.often}
</Caption>
<Caption x={196} y={38} size={12} strong>
with me
{copy.withMe}
</Caption>
<line
x1={90}
@@ -109,7 +109,7 @@ export const ContactDiagram: React.FC = () => {
strokeWidth={1.5}
strokeDasharray="4 4"
/>
{CHAIN.map((name, i) => (
{chain.map((name, i) => (
<Node
key={name}
x={90}
@@ -143,8 +143,8 @@ export const ContactDiagram: React.FC = () => {
stroke={INK}
strokeWidth={2}
/>
<Node x={250} y={first} label="You" size={12} />
<Node x={250} y={last} label="Me" size={12} solid />
<Node x={250} y={first} label={copy.you} size={12} />
<Node x={250} y={last} label={copy.me} size={12} solid />
<motion.circle
r={5}
cx={250}
@@ -159,7 +159,7 @@ export const ContactDiagram: React.FC = () => {
);
}
const xs = CHAIN.map((_, i) => 76 + i * 132);
const xs = chain.map((_, i) => 76 + i * 132);
const first = xs[0] ?? 76;
const last = xs[xs.length - 1] ?? 604;
const stops = xs.flatMap((x) => [x, x]);
@@ -183,7 +183,7 @@ export const ContactDiagram: React.FC = () => {
<rect width="680" height="250" fill="url(#contact-grid)" />
<Caption x={20} y={30} size={10}>
often: a request is passed along
{copy.oftenLong}
</Caption>
<line
x1={first}
@@ -194,7 +194,7 @@ export const ContactDiagram: React.FC = () => {
strokeWidth={1.5}
strokeDasharray="4 4"
/>
{CHAIN.map((name, i) => (
{chain.map((name, i) => (
<Node
key={name}
x={xs[i] ?? 0}
@@ -222,7 +222,7 @@ export const ContactDiagram: React.FC = () => {
/>
<Caption x={20} y={160} size={10} strong>
with me: one direct line
{copy.withMeLong}
</Caption>
<line
x1={first}
@@ -232,8 +232,8 @@ export const ContactDiagram: React.FC = () => {
stroke={INK}
strokeWidth={2}
/>
<Node x={first} y={204} label="You" size={10} />
<Node x={last} y={204} label="Me" size={10} solid />
<Node x={first} y={204} label={copy.you} size={10} />
<Node x={last} y={204} label={copy.me} size={10} solid />
<motion.circle
r={5}
cy={204}
@@ -2,6 +2,9 @@
import * as React from "react";
import { motion } from "framer-motion";
import { useContent } from "@/src/components/ContentProvider";
import type { SceneLabels } from "@/src/content/types";
import { fill } from "@/src/domain/template";
/**
* PathScenes: one small animated drawing per step of a service path.
@@ -40,6 +43,9 @@ const appear = (delay: number, y = 6) => ({
transition: { duration: 0.4, delay, ease: "easeOut" as const },
});
/** A drawing of one step. `l` holds its labels in the language of the page. */
type Scene = React.FC<{ l: SceneLabels }>;
type Pt = readonly [number, number];
/** Points along a cubic curve, for packets that travel on it. */
@@ -133,10 +139,10 @@ const Person: React.FC<{ x: number; label: string; filled?: boolean }> = ({
);
/** Step 1 of every path: a short conversation. */
const Talk: React.FC = () => (
const Talk: Scene = ({ l }) => (
<g>
<Person x={84} label="You" />
<Person x={396} label="Me" filled />
<Person x={84} label={l.you} />
<Person x={396} label={l.me} filled />
{[
{ x: 150, y: 86, w: 120, side: "left", delay: 0.2 },
{ x: 210, y: 124, w: 120, side: "right", delay: 0.7 },
@@ -194,17 +200,15 @@ const Talk: React.FC = () => (
style={MONO}
{...appear(1.6, 0)}
>
about 20 minutes
{l.talkLength}
</motion.text>
</g>
);
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri"] as const;
/** Developer, step 2: agreed days of the week. */
const WeeklyHours: React.FC = () => (
const WeeklyHours: Scene = ({ l }) => (
<g>
{DAYS.map((day, d) => (
{l.days.map((day, d) => (
<g key={day}>
<text x={78 + d * 72} y={52} textAnchor="middle" style={MONO}>
{day}
@@ -241,13 +245,13 @@ const WeeklyHours: React.FC = () => (
style={MONO}
{...appear(1.3, 0)}
>
for example three days a week
{l.daysNote}
</motion.text>
</g>
);
/** Developer, step 3: the list gets worked through and comes back for review. */
const WorkList: React.FC = () => (
const WorkList: Scene = ({ l }) => (
<g>
{[0, 1, 2, 3].map((row) => {
const done = row < 3;
@@ -326,7 +330,7 @@ const WorkList: React.FC = () => (
fontSize: "calc(var(--scene-label, 10px) - 1px)",
}}
>
you
{l.you}
</text>
</motion.g>
)}
@@ -340,13 +344,13 @@ const WorkList: React.FC = () => (
style={MONO}
{...appear(1.8, 0)}
>
finished parts go to you for review
{l.review}
</motion.text>
</g>
);
/** Developer, step 4: two weeks notice to the end of a month. */
const Notice: React.FC = () => (
const Notice: Scene = ({ l }) => (
<g>
<line x1={50} x2={430} y1={150} y2={150} stroke={LINE} strokeWidth={1.5} />
{Array.from({ length: 5 }, (_, w) => (
@@ -360,7 +364,7 @@ const Notice: React.FC = () => (
strokeWidth={FINE}
/>
<text x={50 + w * 95} y={182} textAnchor="middle" style={MONO}>
{w === 4 ? "end of month" : `week ${w + 1}`}
{w === 4 ? l.monthEnd : fill(l.week, { n: w + 1 })}
</text>
</g>
))}
@@ -382,7 +386,7 @@ const Notice: React.FC = () => (
strokeWidth={FINE}
/>
<text x={335} y={92} textAnchor="middle" style={{ ...MONO, fill: INK }}>
two weeks notice
{l.notice}
</text>
</motion.g>
<motion.circle cx={430} cy={150} r={4.5} fill={INK} {...appear(1.4, 0)} />
@@ -414,7 +418,7 @@ const EDGES: readonly (readonly [number, number])[] = [
];
/** Rescue, step 2: fragile connections become solid over the week. */
const RepairWeek: React.FC = () => (
const RepairWeek: Scene = ({ l }) => (
<g>
{EDGES.map(([a, b], i) => {
const from = NODES[a]!;
@@ -462,13 +466,13 @@ const RepairWeek: React.FC = () => (
style={MONO}
{...appear(2.6, 0)}
>
fragile parts get tests and fixes
{l.repair}
</motion.text>
</g>
);
/** Rescue, step 3: a written summary. */
const Summary: React.FC = () => (
const Summary: Scene = ({ l }) => (
<g>
<rect
x={150}
@@ -480,7 +484,7 @@ const Summary: React.FC = () => (
stroke={INK}
strokeWidth={FINE}
/>
{["found", "changed", "next"].map((label, s) => (
{l.summaryParts.map((label, s) => (
<g key={label}>
<motion.text
x={170}
@@ -512,13 +516,13 @@ const Summary: React.FC = () => (
style={MONO}
{...appear(2.2, 0)}
>
written so you can follow it
{l.summaryNote}
</motion.text>
</g>
);
/** Rescue, step 4: the client decides how to continue. */
const Decide: React.FC = () => (
const Decide: Scene = ({ l }) => (
<g>
<circle
cx={90}
@@ -538,56 +542,55 @@ const Decide: React.FC = () => (
fontSize: "calc(var(--scene-label, 10px) - 1px)",
}}
>
you
{l.you}
</text>
{[
{ y: 70, label: "continue with me" },
{ y: 150, label: "your own team" },
{ y: 230, label: "stop here" },
].map((option, i) => (
<g key={option.label}>
<motion.path
d={`M 116 150 C 190 150 180 ${option.y} 250 ${option.y}`}
fill="none"
stroke={INK}
strokeWidth={FINE}
{...draw(0.3 + i * 0.4)}
/>
<Packet
{...along([116, 150], [190, 150], [180, option.y], [250, option.y])}
delay={1.6 + i * 1.3}
duration={1.1}
pause={2.8}
/>
<motion.g {...appear(0.7 + i * 0.4, 0)}>
<rect
x={250}
y={option.y - 16}
width={180}
height={32}
rx={16}
fill={PAPER}
stroke={LINE}
{[70, 150, 230]
.map((y, i) => ({ y, label: l.options[i] ?? "" }))
.map((option, i) => (
<g key={option.label}>
<motion.path
d={`M 116 150 C 190 150 180 ${option.y} 250 ${option.y}`}
fill="none"
stroke={INK}
strokeWidth={FINE}
{...draw(0.3 + i * 0.4)}
/>
<text
x={340}
y={option.y + 4}
textAnchor="middle"
style={{ ...MONO, fill: INK }}
>
{option.label}
</text>
</motion.g>
</g>
))}
<Packet
{...along([116, 150], [190, 150], [180, option.y], [250, option.y])}
delay={1.6 + i * 1.3}
duration={1.1}
pause={2.8}
/>
<motion.g {...appear(0.7 + i * 0.4, 0)}>
<rect
x={250}
y={option.y - 16}
width={180}
height={32}
rx={16}
fill={PAPER}
stroke={LINE}
/>
<text
x={340}
y={option.y + 4}
textAnchor="middle"
style={{ ...MONO, fill: INK }}
>
{option.label}
</text>
</motion.g>
</g>
))}
</g>
);
const Browser: React.FC<{
children: React.ReactNode;
url?: string;
live?: boolean;
}> = ({ children, url, live = false }) => (
/** Shown next to a pulsing dot once the page is live. */
onlineLabel?: string;
}> = ({ children, url, onlineLabel }) => (
<g>
<rect
x={70}
@@ -617,7 +620,7 @@ const Browser: React.FC<{
{url}
</text>
)}
{live && (
{onlineLabel !== undefined && (
<motion.g {...appear(1.6, 0)}>
<motion.circle
cx={352}
@@ -636,7 +639,7 @@ const Browser: React.FC<{
fill: LIVE,
}}
>
online
{onlineLabel}
</text>
</motion.g>
)}
@@ -704,7 +707,7 @@ const PageDetail: React.FC<{ delay: number }> = ({ delay }) => (
);
/** Website, step 2: a first design as outlines. */
const Wireframe: React.FC = () => (
const Wireframe: Scene = ({ l }) => (
<g>
<Browser>
{BLOCKS.map((b, i) => (
@@ -730,15 +733,15 @@ const Wireframe: React.FC = () => (
style={MONO}
{...appear(1.5, 0)}
>
a first version to click through
{l.firstVersion}
</motion.text>
</g>
);
/** Website, step 3: built and online. */
const Built: React.FC = () => (
const Built: Scene = ({ l }) => (
<g>
<Browser url="yourcompany.com" live>
<Browser url={l.exampleDomain} onlineLabel={l.online}>
{BLOCKS.map((b, i) => (
<motion.rect
key={i}
@@ -764,15 +767,15 @@ const Built: React.FC = () => (
style={MONO}
{...appear(1.8, 0)}
>
you approve it before it goes live
{l.approve}
</motion.text>
</g>
);
/** Website, step 4: a change request by email. */
const ChangeByEmail: React.FC = () => (
const ChangeByEmail: Scene = ({ l }) => (
<g>
<Browser url="yourcompany.com" live>
<Browser url={l.exampleDomain} onlineLabel={l.online}>
{BLOCKS.map((b, i) => (
<rect
key={i}
@@ -831,16 +834,17 @@ const ChangeByEmail: React.FC = () => (
style={MONO}
{...appear(1.7, 0)}
>
you send an email, I make the change
{l.changeByEmail}
</motion.text>
</g>
);
const TOOLS: readonly { x: number; y: number; label: string }[] = [
{ x: 90, y: 80, label: "Shop" },
{ x: 390, y: 80, label: "Accounting" },
{ x: 90, y: 200, label: "Email" },
{ x: 390, y: 200, label: "CRM" },
/** Where the four tools stand; their names are `tools` in the labels, in this order. */
const TOOLS: readonly { x: number; y: number }[] = [
{ x: 90, y: 80 },
{ x: 390, y: 80 },
{ x: 90, y: 200 },
{ x: 390, y: 200 },
];
const Tool: React.FC<{ x: number; y: number; label: string }> = ({
@@ -866,14 +870,14 @@ const Tool: React.FC<{ x: number; y: number; label: string }> = ({
);
/** Automation, step 2: today a person carries the data from tool to tool. */
const ByHand: React.FC = () => (
const ByHand: Scene = ({ l }) => (
<g>
{TOOLS.map((tool) => (
<Tool key={tool.label} {...tool} />
{TOOLS.map((tool, i) => (
<Tool key={i} {...tool} label={l.tools[i] ?? ""} />
))}
{TOOLS.map((tool, i) => (
<motion.path
key={tool.label}
key={i}
d={`M ${tool.x + (tool.x < 240 ? 56 : -56)} ${tool.y} L ${tool.x < 240 ? 214 : 266} ${140 + (tool.y < 140 ? -12 : 12)}`}
fill="none"
stroke="#f59e0b"
@@ -905,7 +909,7 @@ const ByHand: React.FC = () => (
style={MONO}
{...appear(1.5, 0)}
>
today: copied by hand
{l.byHand}
</motion.text>
</g>
);
@@ -918,7 +922,7 @@ const WIRES: readonly string[] = [
];
/** Automation, step 3: the systems are connected one step at a time. */
const Wiring: React.FC = () => (
const Wiring: Scene = ({ l }) => (
<g>
{WIRES.map((d, i) => (
<motion.path
@@ -930,8 +934,8 @@ const Wiring: React.FC = () => (
{...draw(0.3 + i * 0.4)}
/>
))}
{TOOLS.map((tool) => (
<Tool key={tool.label} {...tool} />
{TOOLS.map((tool, i) => (
<Tool key={i} {...tool} label={l.tools[i] ?? ""} />
))}
<motion.text
x={240}
@@ -940,13 +944,13 @@ const Wiring: React.FC = () => (
style={MONO}
{...appear(2, 0)}
>
connected one step at a time
{l.wiring}
</motion.text>
</g>
);
/** Automation, step 4: the work travels between the systems on its own. */
const Running: React.FC = () => (
const Running: Scene = ({ l }) => (
<g>
{WIRES.map((d) => (
<path key={d} d={d} fill="none" stroke={INK} strokeWidth={1.5} />
@@ -979,8 +983,8 @@ const Running: React.FC = () => (
}}
/>
))}
{TOOLS.map((tool) => (
<Tool key={tool.label} {...tool} />
{TOOLS.map((tool, i) => (
<Tool key={i} {...tool} label={l.tools[i] ?? ""} />
))}
<motion.text
x={240}
@@ -989,12 +993,12 @@ const Running: React.FC = () => (
style={MONO}
{...appear(0.6, 0)}
>
the steps run without anyone copying
{l.running}
</motion.text>
</g>
);
const SCENES: Readonly<Record<string, readonly React.FC[]>> = {
const SCENES: Readonly<Record<string, readonly Scene[]>> = {
developer: [Talk, WeeklyHours, WorkList, Notice],
rescue: [Talk, RepairWeek, Summary, Decide],
automation: [Talk, ByHand, Wiring, Running],
@@ -1006,6 +1010,7 @@ export const PathScene: React.FC<{
step: number;
title: string;
}> = ({ pathId, step, title }) => {
const labels = useContent().services.scenes;
const Scene = SCENES[pathId]?.[step];
if (!Scene) return null;
@@ -1047,7 +1052,7 @@ export const PathScene: React.FC<{
animate={{ opacity: 1 }}
transition={{ duration: 0.25 }}
>
<Scene />
<Scene l={labels} />
</motion.g>
</svg>
);
@@ -2,6 +2,8 @@
import { BitAura } from "@/src/components/bits/BitAura";
import * as React from "react";
import { useContent } from "@/src/components/ContentProvider";
import { fill } from "@/src/domain/template";
/**
* PipelineRun: what happens between a change and its release, shown as the test run it is.
@@ -24,56 +26,37 @@ const SUITES: readonly Suite[] = [
{ file: "emails/invoice-mail.spec.ts", tests: 5, ms: 290 },
];
/** A change and the test it breaks elsewhere. The texts are in en.ts and de.ts, by id. */
type Scenario = {
readonly id: string;
readonly label: string;
readonly branch: string;
readonly failingFile: string;
readonly testName: string;
readonly expected: string;
readonly received: string;
};
const SCENARIOS: readonly Scenario[] = [
{
id: "orders",
label: "Orders",
branch: "change/order-discounts",
failingFile: "invoices/total.spec.ts",
testName: "invoice total matches the discounted order",
expected: "119.00",
received: "125.00",
},
{
id: "login",
label: "Login",
branch: "change/login-with-username",
failingFile: "emails/confirmation.spec.ts",
testName: "confirmation goes to the account's email address",
expected: '"anna@example.com"',
received: "undefined",
},
{
id: "invoices",
label: "Invoices",
branch: "change/invoice-layout",
failingFile: "emails/invoice-mail.spec.ts",
testName: "invoice email attaches the PDF",
expected: "1 attachment",
received: "0 attachments",
},
{
id: "emails",
label: "Emails",
branch: "change/email-queue",
failingFile: "orders/status.spec.ts",
testName: "order is confirmed once the email is queued",
expected: '"confirmed"',
received: '"pending"',
},
];
const STAGES = ["Lint", "Types", "Tests", "Deploy"] as const;
const STAGE_COUNT = 4;
const TOTAL_TESTS = SUITES.reduce((sum, suite) => sum + suite.tests, 0);
/**
@@ -129,6 +112,7 @@ const STAGE_MARK: Readonly<Record<StageState, string>> = {
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em]";
export const PipelineRun: React.FC = () => {
const copy = useContent().technology.pipeline;
const [scenarioId, setScenarioId] = React.useState(SCENARIOS[0]?.id ?? "");
const [frameIndex, setFrameIndex] = React.useState(0);
const [run, setRun] = React.useState(0);
@@ -170,7 +154,8 @@ export const PipelineRun: React.FC = () => {
return () => window.clearTimeout(timer);
}, [inView, scenarioId, run]);
if (!scenario) return null;
const scenarioCopy = scenario ? copy.scenarios[scenario.id] : undefined;
if (!scenario || !scenarioCopy) return null;
const frame = FRAMES[frameIndex] ?? FRAMES[0];
if (!frame) return null;
@@ -178,7 +163,7 @@ export const PipelineRun: React.FC = () => {
const firstAttempt = frame.attempt === 1;
const failingIndex = SUITES.findIndex((s) => s.file === scenario.failingFile);
const failureSeen = firstAttempt && frame.suitesDone > failingIndex;
const finished = frame.stagesDone === STAGES.length;
const finished = frame.stagesDone === STAGE_COUNT;
const stageState = (index: number): StageState => {
if (index === 2 && firstAttempt && failureSeen && frame.stagesDone >= 3) {
@@ -201,20 +186,10 @@ export const PipelineRun: React.FC = () => {
// Which of the three explanations applies right now.
const explain = firstAttempt ? (finished ? 1 : 0) : finished ? 2 : 1;
const EXPLANATIONS = [
{
title: "A change comes in",
text: `Something is changed in ${scenario.label}. Before it can go live, the whole project is checked automatically.`,
},
{
title: "A test fails, the release is blocked",
text: "The change broke something in a different place. It is caught here, on my side, and nothing reaches your users.",
},
{
title: "Fixed, checked again, released",
text: "I repair what broke, the run passes, and only then does the change go live.",
},
] as const;
const explanations = copy.explanations.map((item) => ({
title: item.title,
text: fill(item.text, { area: scenarioCopy.label }),
}));
return (
<div
@@ -222,7 +197,7 @@ export const PipelineRun: React.FC = () => {
className="grid grid-cols-1 gap-10 md:grid-cols-12 md:gap-12"
>
<div className="md:col-span-4">
<p className={`${MONO} m-0 text-slate-500`}>Simulate a change in</p>
<p className={`${MONO} m-0 text-slate-500`}>{copy.simulate}</p>
<div className="mt-4 flex flex-wrap gap-2">
{SCENARIOS.map((item) => {
const selected = item.id === scenario.id;
@@ -241,14 +216,14 @@ export const PipelineRun: React.FC = () => {
: "border-slate-200 bg-white text-slate-600 hover:border-slate-400 hover:text-slate-900"
}`}
>
{item.label}
{copy.scenarios[item.id]?.label}
</button>
);
})}
</div>
<ol className="m-0 mt-10 list-none p-0" aria-live="polite">
{EXPLANATIONS.map((item, index) => {
{explanations.map((item, index) => {
const active = index === explain;
return (
<li
@@ -281,7 +256,7 @@ export const PipelineRun: React.FC = () => {
onClick={() => setRun((n) => n + 1)}
className="mt-6 rounded-full border border-slate-200 bg-white px-4 py-2 font-mono text-[11px] uppercase tracking-[0.15em] text-slate-600 transition-colors hover:border-slate-400 hover:text-slate-900"
>
Run again
{copy.runAgain}
</button>
</div>
@@ -302,16 +277,16 @@ export const PipelineRun: React.FC = () => {
</span>
</div>
<span className="text-slate-500">
run #{frame.attempt === 1 ? 481 : 482}
{copy.run} #{frame.attempt === 1 ? 481 : 482}
{frame.attempt === 2 && (
<span className="text-slate-400"> · fix pushed</span>
<span className="text-slate-400"> · {copy.fixPushed}</span>
)}
</span>
</div>
{/* Stages */}
<div className="grid grid-cols-2 gap-2 border-b border-slate-800 px-5 py-4 sm:grid-cols-4 md:px-7">
{STAGES.map((stage, index) => {
{copy.stages.map((stage, index) => {
const state = stageState(index);
return (
<div
@@ -329,9 +304,7 @@ export const PipelineRun: React.FC = () => {
<div className="min-h-[21rem] px-5 py-5 md:px-7 md:py-6">
{frame.stagesDone < 2 && (
<p className="m-0 text-[12px] leading-relaxed text-slate-500 md:text-[13px]">
{frame.stagesDone === 0
? "$ lint: checking the code style…"
: "$ types: checking that the parts fit together…"}
{frame.stagesDone === 0 ? copy.lint : copy.types}
</p>
)}
{SUITES.slice(0, frame.suitesDone).map((suite, index) => {
@@ -348,7 +321,7 @@ export const PipelineRun: React.FC = () => {
{suite.file}
</span>
<span className="hidden text-slate-500 sm:inline">
{suite.tests} tests
{suite.tests} {copy.tests}
</span>
<span className="w-14 shrink-0 text-right text-slate-600">
{suite.ms} ms
@@ -357,13 +330,13 @@ export const PipelineRun: React.FC = () => {
{failed && (
<div className="my-2 ml-[3.25rem] border-l border-rose-500/50 pl-4">
<p className="m-0 text-[12px] leading-relaxed text-slate-200 md:text-[13px]">
● {scenario.testName}
● {scenarioCopy.testName}
</p>
<p className="m-0 text-[12px] leading-relaxed text-emerald-400 md:text-[13px]">
expected&nbsp; {scenario.expected}
{copy.expected}&nbsp; {scenarioCopy.expected}
</p>
<p className="m-0 text-[12px] leading-relaxed text-rose-400 md:text-[13px]">
received&nbsp; {scenario.received}
{copy.received}&nbsp; {scenarioCopy.received}
</p>
</div>
)}
@@ -372,7 +345,9 @@ export const PipelineRun: React.FC = () => {
})}
{frame.stagesDone === 2 && frame.suitesDone < SUITES.length && (
<p className="m-0 text-[12px] leading-relaxed text-slate-500 md:text-[13px]">
running {SUITES[frame.suitesDone]?.file}…
{fill(copy.runningFile, {
file: SUITES[frame.suitesDone]?.file ?? "",
})}
</p>
)}
</div>
@@ -380,14 +355,19 @@ export const PipelineRun: React.FC = () => {
{/* Summary */}
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-800 px-5 py-4 md:px-7">
<span>
<span className="text-slate-500">Tests </span>
<span className="text-slate-500">{copy.summaryLabel} </span>
{failedCount > 0 && (
<span className="text-rose-400">{failedCount} failed, </span>
<span className="text-rose-400">
{fill(copy.failed, { n: failedCount })},{" "}
</span>
)}
<span className="text-emerald-400">
{reportedTests - failedCount} passed
{fill(copy.passed, { n: reportedTests - failedCount })}
</span>
<span className="text-slate-500">
{" "}
{fill(copy.of, { total: TOTAL_TESTS })}
</span>
<span className="text-slate-500"> of {TOTAL_TESTS}</span>
</span>
<span
className={`rounded-full px-3 py-1 font-bold uppercase tracking-[0.15em] ${
@@ -399,18 +379,16 @@ export const PipelineRun: React.FC = () => {
}`}
>
{!finished
? "release pending"
? copy.pending
: firstAttempt
? "release blocked"
: "released"}
? copy.blocked
: copy.released}
</span>
</div>
</div>
</BitAura>
<p className="m-0 mt-4 font-sans text-sm leading-relaxed text-slate-400">
An illustration with made-up file names and numbers, modelled on a
real test run. Tests do not catch every mistake, but they do catch
this kind.
{copy.footnote}
</p>
</div>
</div>
@@ -4,25 +4,9 @@ import * as React from "react";
import Image from "next/image";
import { motion, useMotionValue, useSpring } from "framer-motion";
import { BitAura } from "@/src/components/bits/BitAura";
export type WorkItem = {
readonly name: string;
readonly href: string;
readonly client: string;
readonly text: string;
readonly did: readonly string[];
readonly tech: readonly string[];
readonly desktop: {
readonly src: string;
readonly width: number;
readonly height: number;
};
readonly mobile: {
readonly src: string;
readonly width: number;
readonly height: number;
};
};
import { useContent } from "@/src/components/ContentProvider";
import { WORK_SITES, type WorkSite } from "@/src/content/work";
import { fill } from "@/src/domain/template";
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em]";
@@ -70,7 +54,8 @@ const ScrollingShot: React.FC<{
</motion.div>
);
const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
const WorkStage: React.FC<{ item: WorkSite }> = ({ item }) => {
const { work } = useContent();
const ref = React.useRef<HTMLDivElement>(null);
const [inView, setInView] = React.useState(false);
const [still, setStill] = React.useState(false);
@@ -147,7 +132,7 @@ const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
<ScrollingShot
shot={item.desktop}
frameAspect={16 / 10}
alt={`${item.name} on a desktop screen`}
alt={fill(work.desktopAlt, { name: item.name })}
sizes="(min-width: 1024px) 900px, 100vw"
active={active}
seconds={22}
@@ -167,7 +152,7 @@ const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
<ScrollingShot
shot={item.mobile}
frameAspect={9 / 17}
alt={`${item.name} on a phone`}
alt={fill(work.phoneAlt, { name: item.name })}
sizes="240px"
active={active}
seconds={26}
@@ -183,68 +168,75 @@ const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
* WorkShowcase: for a dark section. Each live site on a lit stage, as a desktop and a phone view
* that scroll through the real pages.
*/
export const WorkShowcase: React.FC<{ items: readonly WorkItem[] }> = ({
items,
}) => (
<div className="flex flex-col gap-20 md:gap-32">
{items.map((item, index) => (
<article
key={item.name}
className="grid grid-cols-1 gap-10 md:grid-cols-12 md:items-center md:gap-12"
>
<div className={`md:col-span-8 ${index % 2 === 1 ? "md:order-2" : ""}`}>
<BitAura
source={index % 2 === 0 ? "bottom" : "top"}
tone="dark"
motion={index % 2 === 0 ? "sparks" : "rain"}
export const WorkShowcase: React.FC = () => {
const { work } = useContent();
return (
<div className="flex flex-col gap-20 md:gap-32">
{WORK_SITES.map((item, index) => {
const copy = work.items[item.name];
if (copy === undefined) return null;
return (
<article
key={item.name}
className="grid grid-cols-1 gap-10 md:grid-cols-12 md:items-center md:gap-12"
>
<WorkStage item={item} />
</BitAura>
</div>
<div className="md:col-span-4">
<p className={`${MONO} m-0 text-slate-400`}>
0{index + 1} · {item.client}
</p>
<h3 className="m-0 mt-4 font-sans text-3xl font-bold tracking-tighter text-white md:text-5xl">
{item.name}
</h3>
<p className="m-0 mt-5 font-sans text-lg leading-relaxed text-slate-300 md:text-xl">
{item.text}
</p>
<ul className="m-0 mt-8 list-none p-0">
{item.did.map((line) => (
<li
key={line}
className="border-t border-slate-800 py-3 font-sans text-base text-slate-100 md:text-lg"
<div
className={`md:col-span-8 ${index % 2 === 1 ? "md:order-2" : ""}`}
>
<BitAura
source={index % 2 === 0 ? "bottom" : "top"}
tone="dark"
motion={index % 2 === 0 ? "sparks" : "rain"}
>
{line}
</li>
))}
</ul>
<WorkStage item={item} />
</BitAura>
</div>
<ul className="m-0 mt-6 flex list-none flex-wrap gap-2 p-0">
{item.tech.map((tech) => (
<li
key={tech}
className="rounded-full border border-slate-700 px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.12em] text-slate-300"
<div className="md:col-span-4">
<p className={`${MONO} m-0 text-slate-400`}>
0{index + 1} · {copy.client}
</p>
<h3 className="m-0 mt-4 font-sans text-3xl font-bold tracking-tighter text-white md:text-5xl">
{item.name}
</h3>
<p className="m-0 mt-5 font-sans text-lg leading-relaxed text-slate-300 md:text-xl">
{copy.text}
</p>
<ul className="m-0 mt-8 list-none p-0">
{copy.did.map((line) => (
<li
key={line}
className="border-t border-slate-800 py-3 font-sans text-base text-slate-100 md:text-lg"
>
{line}
</li>
))}
</ul>
<ul className="m-0 mt-6 flex list-none flex-wrap gap-2 p-0">
{copy.tech.map((tech) => (
<li
key={tech}
className="rounded-full border border-slate-700 px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.12em] text-slate-300"
>
{tech}
</li>
))}
</ul>
<a
href={item.href}
target="_blank"
rel="noopener noreferrer"
className="bit-cta bit-cta-light mt-8 inline-flex items-center gap-3 rounded-full bg-white px-6 py-3 font-sans text-[11px] font-bold uppercase tracking-[0.15em] text-slate-900 no-underline transition-transform duration-300 hover:-translate-y-0.5"
>
{tech}
</li>
))}
</ul>
<a
href={item.href}
target="_blank"
rel="noopener noreferrer"
className="bit-cta bit-cta-light mt-8 inline-flex items-center gap-3 rounded-full bg-white px-6 py-3 font-sans text-[11px] font-bold uppercase tracking-[0.15em] text-slate-900 no-underline transition-transform duration-300 hover:-translate-y-0.5"
>
Open the live site <span aria-hidden="true">↗</span>
</a>
</div>
</article>
))}
</div>
);
{work.openLive} <span aria-hidden="true">↗</span>
</a>
</div>
</article>
);
})}
</div>
);
};