Merge feat/german-locale: German version, glow effects and search metadata
Build & Deploy / 🩺 Smoke Test (push) Failing after 2s
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🏗️ Build (push) Successful in 6m36s
Build & Deploy / 🚀 Deploy (push) Successful in 24s
Build & Deploy / 🔔 Notify (push) Successful in 2s

Brings the German main language (English under /en), the binary glow
effects with the refined drawings and calls to action, and the search
metadata onto the server-side analytics from v2.1.0.

Both sides had added apps/web/proxy.ts. It is now one request shell: it
picks the language and maps the address (routes.ts), and records the page
view server-side (pageview-decision.ts) for every request that is not a
redirect, using the public address the visitor asked for. The browser
analytics component and the /stats proxy route stay removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 14:20:10 +02:00
co-authored by Claude Opus 5.5
81 changed files with 6319 additions and 1915 deletions
+57 -129
View File
@@ -1,29 +1,42 @@
"use client";
import * as React from "react";
import { motion } from "framer-motion";
import { ArrowRight } from "lucide-react";
import Link from "next/link";
type ButtonVariant = "primary" | "outline" | "ghost";
type ButtonSize = "normal" | "large";
interface ButtonProps {
href: string;
children: React.ReactNode;
variant?: "primary" | "outline" | "ghost";
size?: "normal" | "large";
variant?: ButtonVariant;
size?: ButtonSize;
className?: string;
showArrow?: boolean;
onClick?: (e: React.MouseEvent<any>) => void;
[key: string]: any;
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
}
const BASE =
"group relative inline-flex cursor-pointer items-center justify-center gap-3 rounded-full font-sans font-bold uppercase not-italic no-underline transition-all duration-300";
const SIZES: Record<ButtonSize, string> = {
normal: "py-2.5 pl-7 pr-2.5 text-[11px] tracking-[0.18em]",
large: "py-3 pl-8 pr-3 text-[12px] tracking-[0.18em]",
};
const VARIANTS: Record<ButtonVariant, string> = {
primary:
"bit-cta bg-slate-900 text-white shadow-lg shadow-slate-900/10 hover:-translate-y-0.5",
outline:
"bit-cta bit-cta-quiet border border-slate-300 bg-white text-slate-900 hover:-translate-y-0.5",
ghost: "bg-transparent text-slate-500 hover:text-slate-900",
};
/**
* Premium Button: Pill-shaped, binary-accent hover effect, unified design.
*
* On hover:
* - A stream of binary characters scrolls across the button background
* - Primary: white binary on dark bg
* - Outline: blue binary on transparent bg
* - Subtle, fast, and satisfying
* Button: the pill-shaped call to action. A light travels round its edge
* (`bit-cta` in globals.css) and the dark variant carries a spot of light that
* follows the pointer (see BitGlow); the label itself stays still.
*/
export const Button: React.FC<ButtonProps> = ({
href,
@@ -33,135 +46,50 @@ export const Button: React.FC<ButtonProps> = ({
className = "",
showArrow = true,
onClick,
...props
}) => {
const [hovered, setHovered] = React.useState(false);
const [displayText, setDisplayText] = React.useState<string | null>(null);
const contentRef = React.useRef<HTMLSpanElement>(null);
// Binary scramble on hover
React.useEffect(() => {
if (!hovered) {
setDisplayText(null);
return;
}
const original = contentRef.current?.textContent || "";
if (!original) return;
const chars = original.split("");
const total = 20;
let frame = 0;
const iv = setInterval(() => {
frame++;
const s = chars.map((c, i) => {
if (c === " ") return " ";
const settle = (frame / total) * chars.length;
if (i < settle) return c;
return Math.random() > 0.5 ? "1" : "0";
});
setDisplayText(s.join(""));
if (frame >= total) {
setDisplayText(null);
clearInterval(iv);
}
}, 1000 / 60);
return () => clearInterval(iv);
}, [hovered]);
const [binaryStr, setBinaryStr] = React.useState(
"0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1",
);
React.useEffect(() => {
setBinaryStr(
Array.from({ length: 60 }, () => (Math.random() > 0.5 ? "0" : "1")).join(
" ",
),
);
}, []);
const base =
"relative inline-flex items-center justify-center gap-3 overflow-hidden rounded-full font-bold uppercase tracking-[0.15em] transition-all duration-300 group cursor-pointer";
const sizes: Record<string, string> = {
normal: "px-8 py-4 text-[10px]",
large: "px-10 py-5 text-[11px]",
};
const variants: Record<string, string> = {
primary:
"bg-slate-900 text-white hover:shadow-xl hover:shadow-slate-900/20 hover:-translate-y-0.5",
outline:
"border border-slate-200 bg-transparent text-slate-900 hover:border-slate-400 hover:-translate-y-0.5",
ghost: "bg-transparent text-slate-500 hover:text-slate-900",
};
// Binary stream overlay colors by variant
const binaryColor =
variant === "primary"
? "rgba(255,255,255,0.12)"
: variant === "outline"
? "rgba(59,130,246,0.15)"
: "rgba(148,163,184,0.15)";
const inner = (
<motion.span
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
className={`${base} ${sizes[size]} ${variants[variant]} ${className}`}
<span
data-glow={variant === "primary" ? "" : undefined}
className={`${BASE} ${SIZES[size]} ${VARIANTS[variant]} ${className}`}
>
{/* Binary stream hover overlay */}
<span
className="absolute inset-0 flex items-center pointer-events-none overflow-hidden"
style={{ opacity: hovered ? 1 : 0, transition: "opacity 0.3s ease" }}
>
<motion.span
className="whitespace-nowrap font-mono text-xs tracking-[0.4em] select-none"
style={{ color: binaryColor }}
animate={hovered ? { x: [0, -200] } : { x: 0 }}
transition={
hovered ? { duration: 3, repeat: Infinity, ease: "linear" } : {}
}
>
{binaryStr}&nbsp;{binaryStr}
</motion.span>
</span>
{/* Shimmer line on hover (top edge) */}
<span
className="absolute top-0 left-0 right-0 h-px pointer-events-none"
style={{
opacity: hovered ? 1 : 0,
transition: "opacity 0.5s ease",
background:
variant === "primary"
? "linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent)"
: "linear-gradient(90deg, transparent, rgba(59,130,246,0.15), transparent)",
}}
/>
{/* Content */}
<span
className="relative z-10 flex items-center gap-3"
style={{ fontVariantNumeric: "tabular-nums" }}
>
<span ref={contentRef}>{displayText ?? children}</span>
{variant === "primary" && (
<span className="bit-glow bit-glow-spot" aria-hidden="true" />
)}
<span className="relative z-10 flex items-center gap-4">
{children}
{showArrow && (
<ArrowRight className="w-3.5 h-3.5 group-hover:translate-x-1 transition-transform duration-300" />
<span
className={`grid h-8 w-8 place-items-center rounded-full transition-colors duration-300 ${
variant === "primary"
? "bg-white/10 group-hover:bg-white group-hover:text-slate-900"
: "bg-slate-100 group-hover:bg-slate-900 group-hover:text-white"
}`}
aria-hidden="true"
>
<ArrowRight
className="h-3.5 w-3.5 transition-transform duration-300 group-hover:translate-x-0.5"
strokeWidth={2.25}
/>
</span>
)}
</span>
</motion.span>
</span>
);
if (href.startsWith("#")) {
return (
<a
href={href}
onClick={(e) => {
if (onClick) onClick(e);
e.preventDefault();
document.querySelector(href)?.scrollIntoView({ behavior: "smooth" });
className="no-underline"
onClick={(event) => {
onClick?.(event);
event.preventDefault();
if (href.length > 1) {
document
.querySelector(href)
?.scrollIntoView({ behavior: "smooth" });
}
}}
{...props}
>
{inner}
</a>
@@ -169,7 +97,7 @@ export const Button: React.FC<ButtonProps> = ({
}
return (
<Link href={href} onClick={onClick} {...props}>
<Link href={href} onClick={onClick} className="no-underline">
{inner}
</Link>
);
@@ -0,0 +1,34 @@
"use client";
import * as React from "react";
import type { Content } from "@/src/content/types";
import type { Locale } from "@/src/domain/locale";
type Value = { readonly locale: Locale; readonly content: Content };
const ContentContext = React.createContext<Value | null>(null);
/**
* ContentProvider: hands the language of the page and its texts to the client
* components. The layout picks both; only the texts of that one language are
* sent to the browser.
*/
export const ContentProvider: React.FC<
Value & { children: React.ReactNode }
> = ({ locale, content, children }) => {
const value = React.useMemo(() => ({ locale, content }), [locale, content]);
return (
<ContentContext.Provider value={value}>{children}</ContentContext.Provider>
);
};
const useValue = (): Value => {
const value = React.useContext(ContentContext);
if (value === null) {
throw new Error("useContent and useLocale need a ContentProvider above.");
}
return value;
};
export const useContent = (): Content => useValue().content;
export const useLocale = (): Locale => useValue().locale;
+14 -6
View File
@@ -1,11 +1,18 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import * as React from "react";
import LogoBlack from "../assets/logo/Logo-Black-Transparent.svg";
import { pathFor } from "@/src/domain/routes";
import { BinaryTicker } from "./bits/BinaryTicker";
import { useContent, useLocale } from "./ContentProvider";
import { LanguageSwitch } from "./LanguageSwitch";
export const Footer: React.FC = () => {
const { nav } = useContent();
const locale = useLocale();
const currentYear = new Date().getFullYear();
return (
@@ -38,24 +45,25 @@ export const Footer: React.FC = () => {
<span>© {currentYear}</span>
<div className="flex flex-wrap gap-x-8 gap-y-3 md:justify-end">
<Link
href="/#contact"
href={`${pathFor("home", locale)}#contact`}
className="hover:text-slate-900 transition-colors duration-300 no-underline"
>
Get in touch
{nav.contactLong}
</Link>
<Link
href="/imprint"
href={pathFor("imprint", locale)}
className="hover:text-slate-900 transition-colors duration-300 no-underline"
>
Imprint
{nav.imprint}
</Link>
<Link
href="/privacy"
href={pathFor("privacy", locale)}
className="hover:text-slate-900 transition-colors duration-300 no-underline"
>
Privacy
{nav.privacy}
</Link>
</div>
<LanguageSwitch variant="text" />
{/* Subtle binary decoration */}
<span className="flex gap-3 text-[8px] text-slate-300 tracking-[0.5em] select-none font-mono">
{[77, 73, 78].map((code) => (
+21 -13
View File
@@ -3,12 +3,17 @@ import Image from "next/image";
import Link from "next/link";
import * as React from "react";
import { pathFor } from "@/src/domain/routes";
import IconWhite from "../assets/logo/Icon-White-Transparent.svg";
import { DecodeText } from "./bits/DecodeText";
import { ScrollBits } from "./bits/ScrollBits";
import { useContent, useLocale } from "./ContentProvider";
import { LanguageSwitch } from "./LanguageSwitch";
/** Header: the logo and one action. Everything else lives on the homepage. */
/** Header: the logo, the language switch and one action. Everything else lives on the homepage. */
export const Header: React.FC = () => {
const { nav } = useContent();
const home = pathFor("home", useLocale());
const [isScrolled, setIsScrolled] = React.useState(false);
React.useEffect(() => {
@@ -32,11 +37,11 @@ export const Header: React.FC = () => {
<div className="mx-auto w-full max-w-[1400px] px-5 md:px-12 py-4 flex items-center justify-between relative z-10">
<Link
href="/"
aria-label="Marc Mintel, back to top"
href={home}
aria-label={nav.logoLabel}
onClick={(event) => {
// Already on the homepage: scroll up instead of reloading the route.
if (window.location.pathname === "/") {
if (window.location.pathname === home) {
event.preventDefault();
window.scrollTo({ top: 0, behavior: "smooth" });
}
@@ -56,15 +61,18 @@ export const Header: React.FC = () => {
<ScrollBits className="hidden font-mono text-[10px] tracking-[0.3em] text-slate-400 md:block" />
<Link
href="/#contact"
className="font-sans text-[10px] font-bold uppercase tracking-[0.2em] text-slate-900 border border-slate-200 px-5 py-2.5 rounded-full no-underline hover:border-slate-400 hover:bg-slate-50 transition-all duration-500 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100"
style={{
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
}}
>
<DecodeText text="Get in touch" trigger="hover" />
</Link>
<div className="flex items-center gap-2.5 md:gap-4">
<LanguageSwitch />
<Link
href={`${home}#contact`}
className="bit-cta bit-cta-quiet font-sans text-[10px] font-bold uppercase tracking-[0.2em] text-slate-900 border border-slate-200 px-5 py-2.5 rounded-full no-underline hover:border-slate-400 hover:bg-slate-50 transition-all duration-500 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100"
style={{
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
}}
>
<DecodeText text={nav.contact} trigger="hover" />
</Link>
</div>
</div>
</header>
);
@@ -1,8 +1,10 @@
"use client";
import React, { useEffect, useState } from "react";
import { useContent } from "./ContentProvider";
export const InteractiveElements: React.FC = () => {
const { nav } = useContent();
const [progress, setProgress] = useState(0);
const [showBackToTop, setShowBackToTop] = useState(false);
@@ -42,7 +44,7 @@ export const InteractiveElements: React.FC = () => {
<button
onClick={scrollToTop}
className={`floating-back-to-top ${showBackToTop ? "visible" : ""}`}
aria-label="Back to top"
aria-label={nav.backToTop}
style={{ display: "flex" }}
>
<svg
+108
View File
@@ -0,0 +1,108 @@
"use client";
import { usePathname } from "next/navigation";
import * as React from "react";
import { LOCALES, localeCookie, type Locale } from "@/src/domain/locale";
import { switchPath } from "@/src/domain/routes";
import { DecodeText } from "./bits/DecodeText";
import { useContent, useLocale } from "./ContentProvider";
import {
getIdleState,
getSwitchState,
startSwitch,
subscribe,
} from "./locale-switch-store";
const EASE = "cubic-bezier(0.23, 1, 0.32, 1)";
/**
* LanguageSwitch: the two languages side by side, with a marker that slides to
* the chosen one. Choosing a language closes the bit curtain (see BitCurtain),
* swaps the page behind it and keeps the reading position. The choice is stored
* in a cookie so that the language detection does not undo it.
*
* The options are plain links to the other language's address, so the switch
* also works without JavaScript and for search engines.
*/
export const LanguageSwitch: React.FC<{
/** `pill` for the header, `text` for the footer. */
variant?: "pill" | "text";
}> = ({ variant = "pill" }) => {
const locale = useLocale();
const { nav } = useContent();
const pathname = usePathname();
const state = React.useSyncExternalStore(
subscribe,
getSwitchState,
getIdleState,
);
// The marker moves as soon as a language is chosen, before the page follows.
const shown = state.phase === "idle" ? locale : state.target;
const choose = (target: Locale) => (event: React.MouseEvent) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
event.preventDefault();
if (target === locale) return;
document.cookie = localeCookie(target);
startSwitch(locale, target, switchPath(pathname, target));
};
if (variant === "text") {
return (
<nav aria-label={nav.language} className="flex gap-x-4">
{LOCALES.map((option) => (
<a
key={option}
href={switchPath(pathname, option)}
hrefLang={option}
lang={option}
aria-current={option === locale ? "true" : undefined}
onClick={choose(option)}
className={`no-underline transition-colors duration-300 hover:text-slate-900 ${
option === locale ? "text-slate-900" : "text-slate-400"
}`}
>
{nav.languageNames[option]}
</a>
))}
</nav>
);
}
return (
<nav
aria-label={nav.language}
className="group/lang relative flex items-center rounded-full border border-slate-200 bg-white/70 p-[3px] font-mono text-[10px] font-bold tracking-[0.2em] transition-colors duration-500 hover:border-slate-400"
>
<span
aria-hidden="true"
className="absolute inset-y-[3px] left-[3px] w-[calc(50%-3px)] rounded-full bg-slate-900 shadow-sm transition-transform duration-500"
style={{
transform: `translateX(${LOCALES.indexOf(shown) * 100}%)`,
transitionTimingFunction: EASE,
}}
/>
{LOCALES.map((option) => (
<a
key={option}
href={switchPath(pathname, option)}
hrefLang={option}
lang={option}
aria-label={nav.languageNames[option]}
aria-current={option === locale ? "true" : undefined}
title={nav.languageNames[option]}
onClick={choose(option)}
className={`relative z-10 grid h-8 w-10 place-items-center rounded-full pl-[0.2em] no-underline transition-colors duration-300 md:h-9 md:w-11 ${
option === shown
? "text-white"
: "text-slate-400 hover:text-slate-900"
}`}
>
<DecodeText text={option.toUpperCase()} trigger="hover" />
</a>
))}
</nav>
);
};
+440
View File
@@ -0,0 +1,440 @@
"use client";
import * as React from "react";
type AuraSource = "top" | "right" | "bottom" | "left" | "around";
type AuraTone = "dark" | "light";
/** How the energy behaves: `sparks` drift off the edge, `rain` falls onto it, `pulse` sends rings outward. */
type AuraMotion = "sparks" | "rain" | "pulse";
interface BitAuraProps {
/** The edge the energy sits on, or `around` to let it travel round the element. */
source: AuraSource;
/** The background the element stands on: `dark` adds light, `light` only ink digits. */
tone: AuraTone;
motion?: AuraMotion;
/** Share of the usual amount of digits (1 = default). */
density?: number;
/** Corner radius of the lit element in px. */
radius?: number;
className?: string;
children: React.ReactNode;
}
interface Box {
width: number;
height: number;
}
/** A bright zone on the outline, placed by its position along the perimeter (0..1). */
interface Spot {
at: number;
power: number;
}
interface Digit {
x: number;
y: number;
vx: number;
vy: number;
age: number;
life: number;
seed: number;
power: number;
large: boolean;
/** Outward normal at the point of the outline the digit belongs to. */
nx: number;
ny: number;
}
interface Palette {
blend: GlobalCompositeOperation;
glow: string;
core: string;
digit: string;
gain: number;
/** Opacity of the pulse rings at the edge. */
ring: number;
/** Share of the digit sparks that are kept (1 = all). */
density: number;
}
const MARGIN = 180;
const FRAME_MS = 1000 / 30;
const MAX_STEP_S = 0.1;
const MAX_PIXEL_RATIO = 2;
/** `rounded-3xl` in this project's Tailwind theme. */
const DEFAULT_RADIUS = 32;
const STILL_FRAME_STEPS = 90;
/** The glow is the outline stroked from wide and faint to thin and bright. */
const GLOW_LAYERS = [
{ width: 150, alpha: 0.035, hot: false },
{ width: 110, alpha: 0.05, hot: false },
{ width: 78, alpha: 0.07, hot: false },
{ width: 52, alpha: 0.1, hot: false },
{ width: 32, alpha: 0.15, hot: false },
{ width: 18, alpha: 0.22, hot: false },
{ width: 7, alpha: 0.45, hot: true },
{ width: 2, alpha: 0.95, hot: true },
] as const;
const LIGHT_PALETTE: Palette = {
blend: "source-over",
glow: "15, 23, 42",
core: "15, 23, 42",
digit: "15, 23, 42",
// No haze on light backgrounds: coloured light only reads on dark. Ink digits only.
gain: 0,
ring: 0.45,
density: 0.6,
};
const DARK_PALETTE: Palette = {
blend: "lighter",
glow: "99, 102, 241",
core: "224, 231, 255",
digit: "199, 210, 254",
gain: 1,
ring: 0.8,
density: 1,
};
const MOTION_DENSITY: Record<AuraMotion, number> = {
sparks: 0.45,
rain: 0.3,
pulse: 0.2,
};
const PULSE_RINGS = 3;
const PULSE_PERIOD_S = 4.2;
/** How often a drifting digit switches between 0 and 1. */
const FLIPS_PER_S = 0.9;
const PULSE_REACH = 56;
const fraction = (value: number) => value - Math.floor(value);
const perimeter = ({ width, height }: Box) => 2 * (width + height);
/** Point and outward normal on the outline, walking clockwise from the top left corner. */
const outlinePoint = (at: number, { width, height }: Box) => {
let distance = fraction(at) * 2 * (width + height);
if (distance < width) return { x: distance, y: 0, nx: 0, ny: -1 };
distance -= width;
if (distance < height) return { x: width, y: distance, nx: 1, ny: 0 };
distance -= height;
if (distance < width) return { x: width - distance, y: height, nx: 0, ny: 1 };
distance -= width;
return { x: 0, y: height - distance, nx: -1, ny: 0 };
};
/** The stretch of the perimeter (0..1) that one edge covers. */
const edgeRange = (
source: Exclude<AuraSource, "around">,
box: Box,
): readonly [number, number] => {
const total = perimeter(box);
const top = box.width / total;
const right = (box.width + box.height) / total;
const bottom = (2 * box.width + box.height) / total;
if (source === "top") return [0, top];
if (source === "right") return [top, right];
if (source === "bottom") return [right, bottom];
return [bottom, 1];
};
/** Where the energy is at time `t` (seconds): it wanders along one edge or circles the element. */
const spotsAt = (
source: AuraSource,
motion: AuraMotion,
t: number,
box: Box,
): readonly Spot[] => {
if (motion === "pulse") {
return [0, 1, 2, 3].map((corner) => ({
at: (corner + 0.5) / 4,
power: 0.45 + 0.25 * Math.sin(t * 0.8 - corner * 1.57),
}));
}
if (source === "around") {
return [
{ at: fraction(t * 0.03), power: 1 },
{ at: fraction(t * 0.03 + 0.5), power: 0.7 },
{ at: fraction(1.2 - t * 0.018), power: 0.5 },
];
}
const [from, to] = edgeRange(source, box);
const along = (share: number) => from + (to - from) * share;
return [
{
at: along(0.5 + 0.32 * Math.sin(t * 0.5)),
power: 0.85 + 0.15 * Math.sin(t * 0.9),
},
{ at: along(0.5 + 0.4 * Math.sin(t * 0.31 + 2)), power: 0.6 },
{
at: along(0.5 + 0.45 * Math.sin(t * 0.83 + 4)),
power: 0.35 + 0.2 * Math.sin(t * 1.3),
},
];
};
const spawnDigit = (
spots: readonly Spot[],
motion: AuraMotion,
box: Box,
): Digit => {
const total = spots.reduce((sum, spot) => sum + spot.power, 0);
let pick = Math.random() * total;
const spot =
spots.find((candidate) => (pick -= candidate.power) <= 0) ?? spots[0];
const power = spot?.power ?? 1;
const base = {
age: 0,
seed: Math.random(),
power,
large: Math.random() < 0.22,
};
const wide = motion === "rain" ? 0.12 : 0.07;
const scatter = (Math.random() + Math.random() + Math.random() - 1.5) * wide;
const point = outlinePoint((spot?.at ?? 0) + scatter, box);
if (motion === "rain") {
const distance = 40 + Math.random() * 80;
const speed = 22 + Math.random() * 26;
return {
...base,
x: point.x + point.nx * distance,
y: point.y + point.ny * distance,
vx: -point.nx * speed,
vy: -point.ny * speed,
nx: point.nx,
ny: point.ny,
life: distance / speed,
};
}
const speed = 10 + Math.random() * 24;
const slide = (Math.random() - 0.5) * 12;
return {
...base,
x: point.x,
y: point.y,
vx: point.nx * speed - point.ny * slide,
vy: point.ny * speed + point.nx * slide,
nx: point.nx,
ny: point.ny,
life: 2.4 + Math.random() * 2.6,
};
};
/**
* BitAura: energy that hugs the outline of the element it wraps. The glow flows
* along the edge and binary digits drift off it like sparks. Decorative only:
* it runs while in view and stands still when reduced motion is requested.
*/
export const BitAura: React.FC<BitAuraProps> = ({
source,
tone,
motion = "sparks",
density = 1,
radius = DEFAULT_RADIUS,
className = "",
children,
}) => {
const wrapperRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
React.useEffect(() => {
const wrapper = wrapperRef.current;
const canvas = canvasRef.current;
const context = canvas?.getContext("2d");
if (!wrapper || !canvas || !context) return;
const palette = tone === "light" ? LIGHT_PALETTE : DARK_PALETTE;
const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const fontFamily = window.getComputedStyle(canvas).fontFamily;
let box: Box = { width: 0, height: 0 };
let digits: Digit[] = [];
let owed = 0;
let clock = Math.random() * 40;
let last = 0;
let frame = 0;
let visible = false;
const step = (seconds: number) => {
clock += seconds;
const spots = spotsAt(source, motion, clock, box);
const rate =
Math.min(90, Math.max(28, perimeter(box) / 30)) *
palette.density *
MOTION_DENSITY[motion] *
density;
owed += rate * seconds;
while (owed >= 1) {
digits.push(spawnDigit(spots, motion, box));
owed -= 1;
}
const drag = 1 - 0.6 * seconds;
digits = digits.filter((digit) => {
digit.age += seconds;
if (motion !== "rain") {
digit.vx *= drag;
digit.vy = digit.vy * drag - 7 * seconds;
}
digit.x += digit.vx * seconds;
digit.y += digit.vy * seconds;
return digit.age < digit.life;
});
return spots;
};
const draw = (spots: readonly Spot[]) => {
const ratio = Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO);
context.setTransform(ratio, 0, 0, ratio, MARGIN * ratio, MARGIN * ratio);
context.clearRect(
-MARGIN,
-MARGIN,
box.width + 2 * MARGIN,
box.height + 2 * MARGIN,
);
context.globalCompositeOperation = palette.blend;
context.globalAlpha = 1;
context.beginPath();
context.roundRect(0, 0, box.width, box.height, radius);
const reach = Math.min(560, Math.max(220, perimeter(box) * 0.2));
for (const spot of palette.gain > 0 ? spots : []) {
const point = outlinePoint(spot.at, box);
for (const layer of GLOW_LAYERS) {
const colour = layer.hot ? palette.core : palette.glow;
const light = context.createRadialGradient(
point.x,
point.y,
0,
point.x,
point.y,
reach,
);
light.addColorStop(
0,
`rgba(${colour}, ${layer.alpha * spot.power * palette.gain})`,
);
light.addColorStop(1, `rgba(${colour}, 0)`);
context.strokeStyle = light;
context.lineWidth = layer.width;
context.stroke();
}
}
if (motion === "pulse") {
context.setLineDash([3, 7]);
context.lineWidth = 1.5;
for (let ring = 0; ring < PULSE_RINGS; ring++) {
const phase = fraction(clock / PULSE_PERIOD_S + ring / PULSE_RINGS);
const grow = phase * PULSE_REACH;
context.strokeStyle = `rgba(${palette.core}, ${(1 - phase) ** 2 * palette.ring})`;
context.beginPath();
context.roundRect(
-grow,
-grow,
box.width + 2 * grow,
box.height + 2 * grow,
radius + grow,
);
context.stroke();
}
context.setLineDash([]);
}
context.fillStyle = `rgb(${palette.digit})`;
context.textAlign = "center";
context.textBaseline = "middle";
for (const large of [false, true]) {
context.font = `${large ? 700 : 500} ${large ? 14 : 11}px ${fontFamily}`;
for (const digit of digits) {
if (digit.large !== large) continue;
const fade = Math.sin((Math.PI * digit.age) / digit.life);
context.globalAlpha = Math.min(1, fade * digit.power * 1.1);
const one =
Math.floor(digit.age * FLIPS_PER_S + digit.seed * 10) % 2 === 0;
context.fillText(one ? "1" : "0", digit.x, digit.y);
}
}
};
const resize = () => {
const rect = wrapper.getBoundingClientRect();
box = { width: rect.width, height: rect.height };
const ratio = Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO);
canvas.width = Math.round((box.width + 2 * MARGIN) * ratio);
canvas.height = Math.round((box.height + 2 * MARGIN) * ratio);
if (still) {
digits = [];
let spots = spotsAt(source, motion, clock, box);
for (let i = 0; i < STILL_FRAME_STEPS; i++)
spots = step(FRAME_MS / 1000);
draw(spots);
}
};
const tick = (now: number) => {
frame = window.requestAnimationFrame(tick);
if (now - last < FRAME_MS) return;
const seconds = Math.min(MAX_STEP_S, (now - last) / 1000);
last = now;
draw(step(seconds));
};
const sizeObserver = new ResizeObserver(resize);
sizeObserver.observe(wrapper);
resize();
const viewObserver = new IntersectionObserver(
([entry]) => {
const next = entry?.isIntersecting ?? false;
if (next === visible || still) return;
visible = next;
if (visible) {
last = performance.now();
frame = window.requestAnimationFrame(tick);
} else {
window.cancelAnimationFrame(frame);
}
},
{ rootMargin: `${MARGIN}px` },
);
viewObserver.observe(wrapper);
return () => {
sizeObserver.disconnect();
viewObserver.disconnect();
window.cancelAnimationFrame(frame);
};
}, [source, tone, motion, density, radius]);
return (
<div
ref={wrapperRef}
// The wrapper has to be positioned; keep the caller's own positioning if it brings one.
className={
className.split(" ").includes("absolute")
? className
: `relative ${className}`
}
>
<canvas
ref={canvasRef}
aria-hidden="true"
className="bit-aura pointer-events-none absolute font-mono"
style={{
left: -MARGIN,
top: -MARGIN,
width: `calc(100% + ${2 * MARGIN}px)`,
height: `calc(100% + ${2 * MARGIN}px)`,
}}
/>
<div className="relative h-full">{children}</div>
</div>
);
};
+275
View File
@@ -0,0 +1,275 @@
"use client";
import { useRouter } from "next/navigation";
import * as React from "react";
import { useContent, useLocale } from "@/src/components/ContentProvider";
import {
endSwitch,
enterPhase,
getIdleState,
getSwitchState,
subscribe,
type SwitchState,
} from "@/src/components/locale-switch-store";
import {
captureAnchor,
restoreAnchor,
type SectionBox,
} from "@/src/domain/scroll-anchor";
const COVER_MS = 460;
const REVEAL_MS = 560;
/** The curtain stays closed at least this long, so the language name can be read. */
const HOLD_MS = 320;
const LABEL_DELAY_MS = 140;
const LABEL_MS = 420;
/** If the new page has not arrived by then, load it the plain way. */
const GIVE_UP_MS = 6000;
const CELL = 16;
/** How ragged the edge of the curtain is, as a share of the screen width. */
const SPREAD = 0.24;
const TRAIL = 11;
const INK = "#0f172a"; // slate-900
const MARK = "#fde68a";
const clamp01 = (n: number): number => Math.min(1, Math.max(0, n));
const easeInOut = (t: number): number =>
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
/** A fixed pseudo-random number per row, so the edge looks the same after the page swap. */
const rowLag = (row: number): number => {
const x = Math.sin(row * 12.9898) * 43758.5453;
return x - Math.floor(x);
};
const sectionBoxes = (): SectionBox[] =>
Array.from(document.querySelectorAll("main section")).map((section) => {
const rect = section.getBoundingClientRect();
return { top: rect.top + window.scrollY, height: rect.height };
});
/** A tile of faint digits that textures the closed curtain. */
const makeTile = (dpr: number, mono: string): HTMLCanvasElement => {
const cells = 8;
const tile = document.createElement("canvas");
tile.width = cells * CELL * dpr;
tile.height = cells * CELL * dpr;
const ctx = tile.getContext("2d");
if (!ctx) return tile;
ctx.scale(dpr, dpr);
ctx.fillStyle = INK;
ctx.fillRect(0, 0, cells * CELL, cells * CELL);
ctx.font = `500 11px ${mono}`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = "rgba(148, 163, 184, 0.16)";
for (let row = 0; row < cells; row++) {
for (let col = 0; col < cells; col++) {
const bit = rowLag(row * cells + col + 1) < 0.5 ? "0" : "1";
ctx.fillText(bit, col * CELL + CELL / 2, row * CELL + CELL / 2);
}
}
return tile;
};
/**
* BitCurtain: the transition of the language switch. A curtain of binary digits
* closes over the page from left to right, the name of the new language resolves
* out of bits on it, the page is swapped behind it, and the curtain opens again
* on the same spot of the page in the other language.
*
* With reduced motion there is no curtain: the page is swapped at once.
*/
export const BitCurtain: React.FC = () => {
const locale = useLocale();
const { nav } = useContent();
const router = useRouter();
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const state = React.useSyncExternalStore(
subscribe,
getSwitchState,
getIdleState,
);
const active = state.phase !== "idle";
// The loop below reads these through refs, so it survives re-renders.
const localeRef = React.useRef(locale);
localeRef.current = locale;
const namesRef = React.useRef(nav.languageNames);
namesRef.current = nav.languageNames;
React.useEffect(() => {
if (!active) return;
const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d") ?? null;
const mono =
getComputedStyle(document.documentElement)
.getPropertyValue("--font-jetbrains-mono")
.trim() || "ui-monospace";
const font = `${mono}, ui-monospace, Menlo, monospace`;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
let width = 0;
let height = 0;
let pattern: CanvasPattern | null = null;
const resize = () => {
if (!canvas || !ctx) return;
width = window.innerWidth;
height = window.innerHeight;
canvas.width = Math.ceil(width * dpr);
canvas.height = Math.ceil(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
pattern = ctx.createPattern(makeTile(dpr, font), "repeat");
// The tile is drawn at device resolution; scale it back to CSS pixels.
pattern?.setTransform(new DOMMatrix().scale(1 / dpr));
};
const paint = (current: Exclude<SwitchState, { phase: "idle" }>) => {
if (!ctx) return;
const now = performance.now();
const elapsed = now - current.phaseAt;
const opening = current.phase === "reveal";
const progress =
current.phase === "hold"
? 1
: easeInOut(clamp01(elapsed / (opening ? REVEAL_MS : COVER_MS)));
const rows = Math.ceil(height / CELL);
// The edge of the curtain in each row. Closing, the curtain is left of it;
// opening, right of it.
const edge = (row: number): number =>
current.phase === "hold"
? width
: clamp01(progress * (1 + SPREAD) - rowLag(row) * SPREAD) * width;
ctx.clearRect(0, 0, width, height);
ctx.save();
ctx.beginPath();
for (let row = 0; row < rows; row++) {
const x = edge(row);
if (opening) ctx.rect(x, row * CELL, width - x, CELL);
else ctx.rect(0, row * CELL, x, CELL);
}
ctx.clip();
ctx.fillStyle = pattern ?? INK;
ctx.fillRect(0, 0, width, height);
// The name of the language, resolving out of bits. Clipped to the curtain.
const sinceStart = now - current.startedAt - LABEL_DELAY_MS;
const name = namesRef.current[current.target].toUpperCase();
const shown = Math.floor(clamp01(sinceStart / LABEL_MS) * name.length);
const text = Array.from(name)
.map((char, i) => (i < shown ? char : Math.random() < 0.5 ? "0" : "1"))
.join("");
const size = Math.min(width * 0.15, 168);
ctx.globalAlpha = clamp01(sinceStart / 160);
ctx.textAlign = "center";
ctx.textBaseline = "alphabetic";
ctx.font = `800 ${size}px ${font}`;
ctx.fillStyle = "#ffffff";
ctx.fillText(text, width / 2, height / 2 + size * 0.2);
const textWidth = ctx.measureText(text).width;
ctx.fillStyle = MARK;
ctx.fillRect(
width / 2 - textWidth / 2,
height / 2 + size * 0.42,
textWidth * clamp01(sinceStart / LABEL_MS),
Math.max(3, size * 0.035),
);
ctx.font = `500 12px ${font}`;
ctx.fillStyle = "#94a3b8";
ctx.fillText(
`${current.from.toUpperCase()} → ${current.target.toUpperCase()}`,
width / 2,
height / 2 - size * 0.78,
);
ctx.globalAlpha = 1;
ctx.restore();
// Loose digits along the moving edge.
if (current.phase !== "hold") {
ctx.font = `700 12px ${font}`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
for (let row = 0; row < rows; row++) {
const x = edge(row);
if (x <= 0 || x >= width) continue;
for (let k = -2; k < TRAIL; k++) {
// k counts cells from the edge into the curtain; negative is on the page.
const cx = x + (opening ? 1 : -1) * (k + 0.5) * CELL;
const onPage = k < 0;
ctx.globalAlpha = onPage ? 0.5 : (1 - k / TRAIL) * 0.9;
ctx.fillStyle = onPage ? INK : k < 2 ? MARK : "#ffffff";
ctx.fillText(
Math.random() < 0.5 ? "0" : "1",
cx,
row * CELL + CELL / 2,
);
}
}
ctx.globalAlpha = 1;
}
};
let raf = 0;
const tick = () => {
const current = getSwitchState();
if (current.phase === "idle") return;
const elapsed = performance.now() - current.phaseAt;
if (current.phase === "cover" && (still || elapsed >= COVER_MS)) {
enterPhase("hold", captureAnchor(sectionBoxes(), window.scrollY));
router.push(current.href, { scroll: false });
} else if (current.phase === "hold") {
const arrived = localeRef.current === current.target;
if (arrived && (still || elapsed >= HOLD_MS)) {
window.scrollTo({
top: restoreAnchor(current.anchor, sectionBoxes()),
behavior: "instant",
});
if (still) endSwitch();
else enterPhase("reveal");
} else if (elapsed > GIVE_UP_MS) {
endSwitch();
window.location.assign(current.href);
return;
}
} else if (current.phase === "reveal" && elapsed >= REVEAL_MS) {
endSwitch();
return;
}
const next = getSwitchState();
if (next.phase === "idle") return;
if (!still) paint(next);
raf = requestAnimationFrame(tick);
};
const started = getSwitchState();
if (started.phase !== "idle") router.prefetch(started.href);
resize();
window.addEventListener("resize", resize);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", resize);
};
}, [active, router]);
return (
<div
aria-hidden="true"
className={`fixed inset-0 z-[300] motion-reduce:hidden ${active ? "block" : "hidden"}`}
style={{
// Closed: solid behind the canvas, so the page swap never shows through.
backgroundColor: state.phase === "hold" ? INK : "transparent",
}}
>
<canvas ref={canvasRef} className="block h-full w-full" />
</div>
);
};
+49
View File
@@ -0,0 +1,49 @@
"use client";
import * as React from "react";
const SURFACE = "[data-glow]";
/**
* BitGlow: a light that follows the pointer across every `data-glow` surface.
* It only publishes the pointer position as `--gx` / `--gy` on those surfaces;
* the light itself is drawn in CSS by a `.bit-glow` child (see globals.css).
* Purely decorative, and off when reduced motion is requested.
*/
export const BitGlow: React.FC = () => {
React.useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let frame = 0;
let latest: PointerEvent | null = null;
const publish = () => {
frame = 0;
if (!latest) return;
const { clientX, clientY, target } = latest;
let surface =
target instanceof Element ? target.closest<HTMLElement>(SURFACE) : null;
// Surfaces nest (a button inside a dark section): each one gets its own position.
while (surface) {
const rect = surface.getBoundingClientRect();
surface.style.setProperty("--gx", `${clientX - rect.left}px`);
surface.style.setProperty("--gy", `${clientY - rect.top}px`);
surface =
surface.parentElement?.closest<HTMLElement>(SURFACE) ?? null;
}
};
const onPointerMove = (event: PointerEvent) => {
latest = event;
if (frame === 0) frame = window.requestAnimationFrame(publish);
};
window.addEventListener("pointermove", onPointerMove, { passive: true });
return () => {
window.removeEventListener("pointermove", onPointerMove);
if (frame !== 0) window.cancelAnimationFrame(frame);
};
}, []);
return null;
};
+17
View File
@@ -0,0 +1,17 @@
import * as React from "react";
import { BitAura } from "./BitAura";
/**
* BitSeam: a lit top edge for a dark section, with digits drifting down from it.
* The section needs `relative` and has to clip its overflow.
*/
export const BitSeam: React.FC<{ density?: number }> = ({ density = 0.4 }) => (
<div
className="pointer-events-none absolute inset-x-0 top-0"
aria-hidden="true"
>
<BitAura source="bottom" tone="dark" radius={0} density={density}>
<div className="h-0" />
</BitAura>
</div>
);
+10 -1
View File
@@ -91,6 +91,8 @@ export const BitHeadline: React.FC<{
const reducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
// Touch screens have no pointer to light the digits up, so a spot wanders on its own.
const noHover = window.matchMedia("(hover: none)").matches;
let measured: Measured[] = [];
let width = 0;
@@ -306,6 +308,13 @@ export const BitHeadline: React.FC<{
if (disposed) return;
if (now - lastFrame >= FRAME_MS) {
lastFrame = now;
if (noHover) {
const seconds = now / 1000;
pointer = {
x: width * (0.5 + 0.46 * Math.sin(seconds * 0.55)),
y: height * (0.5 + 0.4 * Math.sin(seconds * 0.9 + 1)),
};
}
paint(now);
}
raf = requestAnimationFrame(loop);
@@ -339,7 +348,7 @@ export const BitHeadline: React.FC<{
restart();
setPainted(true);
resizeObserver.observe(wrap);
if (!reducedMotion) {
if (!reducedMotion && !noHover) {
window.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerleave", onPointerLeave);
}
+60 -32
View File
@@ -1,5 +1,6 @@
import * as React from "react";
import { BinaryTicker } from "@/src/components/bits/BinaryTicker";
import { BitSeam } from "@/src/components/bits/BitSeam";
import { BitWipe } from "@/src/components/bits/BitWipe";
import { DecodeText } from "@/src/components/bits/DecodeText";
import { Reveal } from "@/src/components/Reveal";
@@ -10,6 +11,8 @@ interface BlockProps {
label: string;
title: React.ReactNode;
id?: string;
/** `dark` turns the section into a dark stage. */
tone?: "light" | "dark";
children: React.ReactNode;
}
@@ -22,36 +25,61 @@ export const Block: React.FC<BlockProps> = ({
label,
title,
id,
tone = "light",
children,
}) => (
<section
id={id}
className="relative scroll-mt-24 border-t border-slate-200 bg-white"
>
<div className="mx-auto w-full max-w-[1400px] px-5 py-20 md:px-12 md:py-36">
<Reveal>
<div className="mb-8 flex items-center gap-4 md:mb-12">
<span className="font-mono text-[11px] font-bold tracking-[0.2em] text-slate-900">
{index}
</span>
<span className="h-px w-10 bg-slate-300" />
<DecodeText
text={label}
className="font-mono text-[11px] uppercase tracking-[0.25em] text-slate-500"
/>
<BinaryTicker
value={parseInt(index, 10)}
alive
className="ml-auto hidden font-mono text-[10px] tracking-[0.3em] text-slate-300 md:block"
/>
</div>
</Reveal>
<BitWipe delay={0.1}>
<h2 className="m-0 max-w-6xl text-4xl font-bold leading-[0.95] tracking-tighter text-slate-900 md:text-7xl lg:text-[6.5rem]">
{title}
</h2>
</BitWipe>
<div className="mt-14 md:mt-24">{children}</div>
</div>
</section>
);
}) => {
const dark = tone === "dark";
return (
<section
id={id}
className={`relative scroll-mt-24 border-t ${
dark
? "overflow-clip border-slate-800 bg-slate-900"
: "overflow-x-clip border-slate-200 bg-white"
}`}
>
{dark && (
<div
className="pointer-events-none absolute inset-0 opacity-70"
style={{
backgroundImage: "radial-gradient(#1e293b 1px, transparent 1px)",
backgroundSize: "20px 20px",
}}
aria-hidden="true"
/>
)}
{dark && <BitSeam />}
<div className="relative mx-auto w-full max-w-[1400px] px-5 py-20 md:px-12 md:py-36">
<Reveal>
<div className="mb-8 flex items-center gap-4 md:mb-12">
<span
className={`font-mono text-[11px] font-bold tracking-[0.2em] ${dark ? "text-white" : "text-slate-900"}`}
>
{index}
</span>
<span
className={`h-px w-10 ${dark ? "bg-slate-700" : "bg-slate-300"}`}
/>
<DecodeText
text={label}
className={`font-mono text-[11px] uppercase tracking-[0.25em] ${dark ? "text-slate-400" : "text-slate-500"}`}
/>
<BinaryTicker
value={parseInt(index, 10)}
alive
className={`ml-auto hidden font-mono text-[10px] tracking-[0.3em] md:block ${dark ? "text-slate-600" : "text-slate-300"}`}
/>
</div>
</Reveal>
<BitWipe delay={0.1} tone={dark ? "light" : "dark"}>
<h2
className={`m-0 max-w-6xl text-4xl font-bold leading-[0.95] tracking-tighter md:text-7xl lg:text-[6.5rem] ${dark ? "text-white" : "text-slate-900"}`}
>
{title}
</h2>
</BitWipe>
<div className="mt-14 md:mt-24">{children}</div>
</div>
</section>
);
};
+16 -12
View File
@@ -1,19 +1,20 @@
import * as React from "react";
import { Reveal } from "@/src/components/Reveal";
import { EmailLink } from "@/src/components/EmailLink";
import { BitHeadline, type BitLine } from "./BitHeadline";
import { BitAura } from "@/src/components/bits/BitAura";
import { BitSeam } from "@/src/components/bits/BitSeam";
import type { Content } from "@/src/content/types";
import { BitHeadline } from "./BitHeadline";
import { ContactComposer } from "./ContactComposer";
const CLOSING: readonly BitLine[] = [
{ text: "LET'S", tone: "bits" },
{ text: "TALK.", tone: "solid" },
];
/** ClosingCta: the contact section. One address and a message starter, no form to submit. */
export const ClosingCta: React.FC = () => (
export const ClosingCta: React.FC<{ contact: Content["contact"] }> = ({
contact,
}) => (
<section
id="contact"
className="relative border-t border-slate-200 bg-slate-900 text-white"
data-glow
className="relative overflow-clip border-t border-slate-200 bg-slate-900 text-white"
>
<div
className="pointer-events-none absolute inset-0 opacity-70"
@@ -23,24 +24,27 @@ export const ClosingCta: React.FC = () => (
}}
aria-hidden="true"
/>
<div className="bit-glow bit-glow-dots" aria-hidden="true" />
<BitSeam />
<div className="relative mx-auto grid w-full max-w-[1400px] grid-cols-1 gap-14 px-5 py-24 md:px-12 md:py-40 lg:grid-cols-12 lg:items-center lg:gap-12">
<Reveal className="lg:col-span-6">
<BitHeadline
as="h2"
invert
lines={CLOSING}
lines={contact.headline}
className="font-sans text-[19vw] font-black leading-[0.86] tracking-tighter md:text-[13vw] lg:text-[8.5vw] xl:text-[9.5rem]"
/>
<p className="m-0 mt-10 max-w-xl font-sans text-xl leading-snug text-slate-300 md:text-3xl">
Pick what you need and the email writes itself. Or write to me
directly:
{contact.lead}
</p>
<div className="mt-8">
<EmailLink className="inline-block border-b-2 border-white pb-2 font-sans text-2xl font-bold tracking-tight text-white no-underline transition-opacity hover:opacity-70 md:text-4xl" />
</div>
</Reveal>
<Reveal delay={0.15} className="lg:col-span-6">
<ContactComposer />
<BitAura source="around" tone="dark">
<ContactComposer />
</BitAura>
</Reveal>
</div>
</section>
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
import { CONTACT_TOPICS } from "@/src/content/contact";
import { useContent } from "@/src/components/ContentProvider";
import { CONTACT_EMAIL_PARTS } from "@/src/content/site";
import { assembleEmail, mailtoHref, spellOutEmail } from "@/src/domain/email";
@@ -51,7 +51,9 @@ const withBlanks = (text: string): React.ReactNode =>
* is assembled in the browser, so it never appears in the page source.
*/
export const ContactComposer: React.FC = () => {
const [topicId, setTopicId] = React.useState(CONTACT_TOPICS[0]?.id ?? "");
const { contact } = useContent();
const topics = contact.topics;
const [topicId, setTopicId] = React.useState(topics[0]?.id ?? "");
const [address, setAddress] = React.useState<string | null>(null);
const [copied, setCopied] = React.useState(false);
@@ -59,8 +61,7 @@ export const ContactComposer: React.FC = () => {
setAddress(assembleEmail(CONTACT_EMAIL_PARTS));
}, []);
const topic =
CONTACT_TOPICS.find((item) => item.id === topicId) ?? CONTACT_TOPICS[0];
const topic = topics.find((item) => item.id === topicId) ?? topics[0];
const typed = useTyped(topic?.draft.body ?? "");
if (!topic) return null;
@@ -87,18 +88,18 @@ export const ContactComposer: React.FC = () => {
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
</span>
<span className={`${MONO} text-slate-400`}>New message</span>
<span className={`${MONO} text-slate-400`}>{contact.newMessage}</span>
</div>
<span className={`${MONO} flex items-center gap-2 text-slate-500`}>
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-400" />
draft
{contact.draft}
</span>
</div>
<div className="border-b border-slate-800 px-5 py-5 md:px-7">
<p className={`${MONO} m-0 text-slate-500`}>I need</p>
<p className={`${MONO} m-0 text-slate-500`}>{contact.need}</p>
<div className="mt-3 flex flex-wrap gap-2">
{CONTACT_TOPICS.map((item) => {
{topics.map((item) => {
const selected = item.id === topic.id;
return (
<button
@@ -121,13 +122,13 @@ export const ContactComposer: React.FC = () => {
<dl className="m-0 border-b border-slate-800 px-5 py-4 font-mono text-[12px] leading-relaxed md:px-7 md:text-[13px]">
<div className="flex gap-4">
<dt className="w-16 shrink-0 text-slate-500">to</dt>
<dd className="m-0 text-slate-200">
<dt className="w-16 shrink-0 text-slate-500">{contact.to}</dt>
<dd className="m-0 select-all text-slate-200">
{address ?? spellOutEmail(CONTACT_EMAIL_PARTS)}
</dd>
</div>
<div className="mt-1 flex gap-4">
<dt className="w-16 shrink-0 text-slate-500">subject</dt>
<dt className="w-16 shrink-0 text-slate-500">{contact.subject}</dt>
<dd className="m-0 text-white">{topic.draft.subject}</dd>
</div>
</dl>
@@ -148,9 +149,9 @@ export const ContactComposer: React.FC = () => {
href={
address ? mailtoHref(CONTACT_EMAIL_PARTS, topic.draft) : "#contact"
}
className="group inline-flex flex-1 items-center justify-between gap-4 rounded-full bg-white px-7 py-4 font-sans text-[12px] font-bold uppercase tracking-[0.18em] text-slate-900 no-underline transition-transform duration-300 hover:-translate-y-0.5"
className="bit-cta bit-cta-light group inline-flex flex-1 items-center justify-between gap-4 rounded-full bg-white px-7 py-4 font-sans text-[12px] font-bold uppercase tracking-[0.18em] text-slate-900 no-underline transition-transform duration-300 hover:-translate-y-0.5"
>
Open in my mail app
{contact.open}
<span
aria-hidden="true"
className="transition-transform duration-300 group-hover:translate-x-1"
@@ -163,7 +164,7 @@ export const ContactComposer: React.FC = () => {
onClick={copy}
className="rounded-full border border-slate-700 px-6 py-4 font-sans text-[12px] font-bold uppercase tracking-[0.18em] text-slate-300 transition-colors duration-200 hover:border-slate-400 hover:text-white"
>
{copied ? "Copied" : "Copy address"}
{copied ? contact.copied : contact.copy}
</button>
</div>
</div>
+6 -32
View File
@@ -1,41 +1,15 @@
import * as React from "react";
import type { FaqItem } from "@/src/content/types";
const QUESTIONS = [
{
q: "I am not technical. Is that a problem?",
a: "No. I explain what I am doing in plain language, and you can ask at any time. You do not need to read code.",
},
{
q: "How long am I committed?",
a: "Project work is billed by effort. Either side can end it with two weeks notice to the end of a month.",
},
{
q: "Who owns what you build?",
a: "You do. Code and design belong to you, and you can hand them to another developer.",
},
{
q: "Can you work with our existing team and code?",
a: "Yes. I worked in agencies and product teams for years, so joining an existing project is familiar to me.",
},
{
q: "How do I reach you?",
a: "By email or video call. I work remotely and am available during European business hours.",
},
{
q: "What do you not do?",
a: "Mobile apps for the app stores, Windows software and running your servers. If your project needs those, I say so in the first call.",
},
] as const;
export const Faq: React.FC = () => (
export const Faq: React.FC<{ items: readonly FaqItem[] }> = ({ items }) => (
<div className="grid grid-cols-1 gap-x-12 md:grid-cols-2">
{QUESTIONS.map((item) => (
{items.map((item) => (
<details
key={item.q}
key={item.question}
className="group border-t border-slate-200 py-6 open:pb-8"
>
<summary className="flex cursor-pointer list-none items-start justify-between gap-6 font-sans text-xl font-bold tracking-tight text-slate-900 md:text-2xl [&::-webkit-details-marker]:hidden">
{item.q}
{item.question}
<span
className="mt-1 shrink-0 font-mono text-xl text-slate-400 transition-transform duration-300 group-open:rotate-45"
aria-hidden="true"
@@ -44,7 +18,7 @@ export const Faq: React.FC = () => (
</span>
</summary>
<p className="m-0 mt-4 font-sans text-base leading-relaxed text-slate-500 md:text-lg">
{item.a}
{item.answer}
</p>
</details>
))}
+11 -18
View File
@@ -1,16 +1,12 @@
import * as React from "react";
import { Button } from "@/src/components/Button";
import { AbstractCircuit } from "@/src/components/Effects";
import { BitHeadline, type BitLine } from "./BitHeadline";
import type { Content } from "@/src/content/types";
import { BitHeadline } from "./BitHeadline";
const HEADLINE: readonly BitLine[] = [
{ text: "WEB APPS", tone: "solid" },
{ text: "& WEBSITES.", tone: "bits" },
];
const TRUST = ["15 years in web", "Remote, EU core hours"] as const;
export const HeroStatement: React.FC = () => (
export const HeroStatement: React.FC<{ hero: Content["hero"] }> = ({
hero,
}) => (
<section className="relative overflow-hidden bg-white">
<div className="absolute inset-0 opacity-70">
<AbstractCircuit />
@@ -18,16 +14,13 @@ export const HeroStatement: React.FC = () => (
<div className="relative z-10 mx-auto flex min-h-[calc(100dvh-5rem)] w-full max-w-[1400px] flex-col justify-center px-5 py-16 md:px-12 md:py-24">
<BitHeadline
lines={HEADLINE}
lines={hero.headline}
className="font-sans text-[12.5vw] font-black leading-[0.88] tracking-tighter md:text-[11.5vw] xl:text-[10.5rem]"
/>
<div className="mt-10 grid grid-cols-1 gap-10 md:mt-14 md:grid-cols-12 md:items-end md:gap-12">
<p className="m-0 max-w-2xl font-sans text-xl leading-snug text-slate-500 md:col-span-7 md:text-3xl">
Freelance developer with 15 years of experience.{" "}
<span className="text-slate-900">
I build web apps, interfaces and websites.
</span>
{hero.lead} <span className="text-slate-900">{hero.leadStrong}</span>
</p>
<div className="flex flex-col gap-4 md:col-span-5 md:items-end">
@@ -36,7 +29,7 @@ export const HeroStatement: React.FC = () => (
className="flex w-full flex-col gap-4 sm:flex-row md:w-auto"
>
<Button href="#contact" size="large" className="w-full sm:w-auto">
Get in touch
{hero.primaryCta}
</Button>
<Button
href="#hello"
@@ -44,14 +37,14 @@ export const HeroStatement: React.FC = () => (
size="large"
className="w-full sm:w-auto"
>
What I do
{hero.secondaryCta}
</Button>
</div>
</div>
</div>
<ul className="m-0 mt-14 flex list-none flex-wrap gap-x-8 gap-y-3 border-t border-slate-200 p-0 pt-6 md:mt-20">
{TRUST.map((item) => (
{hero.trust.map((item) => (
<li
key={item}
className="font-mono text-[10px] uppercase tracking-[0.2em] text-slate-500 md:text-[11px]"
@@ -60,7 +53,7 @@ export const HeroStatement: React.FC = () => (
</li>
))}
<li className="font-mono text-[10px] uppercase tracking-[0.2em] text-slate-500 md:text-[11px]">
Built and running:{" "}
{hero.builtAndRunning}{" "}
<a
href="https://klz-cables.com"
target="_blank"
+46 -42
View File
@@ -1,45 +1,49 @@
import { contentFor } from "@/src/content";
import { OWNER, PROFILES, SITE_NAME, SITE_URL } from "@/src/content/site";
import type { Locale } from "@/src/domain/locale";
import { pathFor } from "@/src/domain/routes";
import {
OWNER,
SITE_DESCRIPTION,
SITE_NAME,
SITE_URL,
} from "@/src/content/site";
buildStructuredData,
serializeJsonLd,
} from "@/src/domain/structured-data";
const STRUCTURED_DATA = {
"@context": "https://schema.org",
"@type": "ProfessionalService",
name: SITE_NAME,
url: SITE_URL,
description: SITE_DESCRIPTION,
image: `${SITE_URL}/marc-mintel.png`,
address: {
"@type": "PostalAddress",
streetAddress: OWNER.street,
postalCode: OWNER.city.split(" ")[0],
addressLocality: OWNER.city.split(" ").slice(1).join(" "),
addressCountry: "DE",
},
founder: {
"@type": "Person",
name: SITE_NAME,
jobTitle: "Freelance developer",
url: SITE_URL,
},
knowsAbout: [
"Web applications",
"User interfaces",
"Websites",
"Process automation",
"Web3 frontends",
],
} as const;
/** Built server-side from the page content; see src/domain/structured-data.ts. */
export const HomeJsonLd: React.FC<{ locale: Locale }> = ({ locale }) => {
const { site, services, technology, faq } = contentFor(locale);
const data = buildStructuredData({
siteUrl: SITE_URL,
pageUrl: `${SITE_URL}${pathFor("home", locale)}`,
siteName: SITE_NAME,
language: locale,
description: site.description,
imageUrl: `${SITE_URL}/marc-mintel.png`,
person: {
name: OWNER.name,
jobTitle: site.jobTitle,
profiles: PROFILES,
knowsAbout: [
...site.knowsAbout,
...technology.tech.flatMap((group) => group.items),
],
},
address: {
street: OWNER.street,
postalCode: OWNER.postalCode,
locality: OWNER.locality,
countryCode: OWNER.countryCode,
},
catalogName: site.catalogName,
services: services.paths.map((path) => ({
name: path.service,
description: path.intro,
})),
faq: faq.items,
});
/** Serialized server-side from constants only; "<" is escaped to keep it inert inside a script tag. */
export const HomeJsonLd: React.FC = () => (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(STRUCTURED_DATA).replace(/</g, "\\u003c"),
}}
/>
);
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: serializeJsonLd(data) }}
/>
);
};
+40 -26
View File
@@ -3,9 +3,11 @@
import * as React from "react";
import { motion } from "framer-motion";
import { Button } from "@/src/components/Button";
import { BitAura } from "@/src/components/bits/BitAura";
import { DecodeText } from "@/src/components/bits/DecodeText";
import { PathScene } from "@/src/components/illustrations/PathScenes";
import { SERVICE_PATHS, type ServicePath } from "@/src/content/paths";
import { useContent } from "@/src/components/ContentProvider";
import type { ServicePath } from "@/src/content/types";
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500";
const STEP_MS = 4800;
@@ -49,25 +51,33 @@ const PathStory: React.FC<{ path: ServicePath; active: boolean }> = ({
return (
<div ref={ref} className="grid grid-cols-1 gap-8 md:grid-cols-12 md:gap-12">
<div className="md:order-2 md:col-span-7">
<div className="relative overflow-hidden rounded-3xl bg-slate-900 md:sticky md:top-28">
<div className="flex items-center justify-between border-b border-slate-800 px-5 py-4 md:px-7">
<DecodeText
key={current?.title}
text={current?.title ?? ""}
className="font-mono text-[11px] uppercase tracking-[0.2em] text-slate-300"
/>
<span className="font-mono text-[11px] tracking-[0.2em] text-slate-500">
{step + 1} / {path.steps.length}
</span>
<BitAura source="top" tone="light" className="md:sticky md:top-28">
<div
data-glow
className="relative overflow-hidden rounded-3xl bg-slate-900"
>
<span className="bit-glow bit-glow-dots" aria-hidden="true" />
<div className="relative flex items-center justify-between border-b border-slate-800 px-5 py-4 md:px-7">
<DecodeText
key={current?.title}
text={current?.title ?? ""}
className="font-mono text-[11px] uppercase tracking-[0.2em] text-slate-300"
/>
<span className="font-mono text-[11px] tracking-[0.2em] text-slate-500">
{step + 1} / {path.steps.length}
</span>
</div>
<div className="relative px-2 py-4 md:px-10 md:py-8">
<PathScene
pathId={path.id}
step={step}
title={
current ? `${current.title}. ${current.text}` : path.need
}
/>
</div>
</div>
<div className="px-2 py-4 md:px-10 md:py-8">
<PathScene
pathId={path.id}
step={step}
title={current ? `${current.title}. ${current.text}` : path.need}
/>
</div>
</div>
</BitAura>
</div>
<ol className="m-0 list-none p-0 md:order-1 md:col-span-5">
@@ -134,16 +144,18 @@ const PathStory: React.FC<{ path: ServicePath; active: boolean }> = ({
* All three panels stay in the DOM so the content is readable without JavaScript.
*/
export const PathPicker: React.FC = () => {
const [activeId, setActiveId] = React.useState(SERVICE_PATHS[0]?.id ?? "");
const { services } = useContent();
const paths = services.paths;
const [activeId, setActiveId] = React.useState(paths[0]?.id ?? "");
return (
<div>
<div
role="tablist"
aria-label="What do you need?"
aria-label={services.tablistLabel}
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4 lg:gap-4"
>
{SERVICE_PATHS.map((path, index) => {
{paths.map((path, index) => {
const selected = path.id === activeId;
return (
<button
@@ -154,12 +166,14 @@ export const PathPicker: React.FC = () => {
aria-selected={selected}
aria-controls={`path-panel-${path.id}`}
onClick={() => setActiveId(path.id)}
data-glow
className={`bit-card group flex h-full flex-col justify-between rounded-3xl border p-5 text-left transition-all duration-300 md:p-7 ${
selected
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-200 bg-white text-slate-900 hover:-translate-y-1 hover:border-slate-400"
}`}
>
<span className="bit-glow bit-glow-edge" aria-hidden="true" />
<span className="font-mono text-[11px] tracking-[0.2em] text-slate-400">
0{index + 1}
</span>
@@ -176,7 +190,7 @@ export const PathPicker: React.FC = () => {
})}
</div>
{SERVICE_PATHS.map((path) => {
{paths.map((path) => {
const selected = path.id === activeId;
return (
<div
@@ -203,7 +217,7 @@ export const PathPicker: React.FC = () => {
</p>
</div>
<div className="md:col-span-5">
<p className={`${MONO} m-0`}>What you get</p>
<p className={`${MONO} m-0`}>{services.youGet}</p>
<ul className="m-0 mt-4 list-none p-0">
{path.youGet.map((item) => (
<li
@@ -222,12 +236,12 @@ export const PathPicker: React.FC = () => {
</div>
<div className="mt-14 md:mt-24">
<p className={`${MONO} m-0 mb-6`}>How it runs</p>
<p className={`${MONO} m-0 mb-6`}>{services.howItRuns}</p>
<PathStory path={path} active={selected} />
</div>
<div className="mt-12 rounded-3xl bg-slate-50 p-6 md:mt-16 md:p-10">
<p className={`${MONO} m-0`}>What it costs</p>
<p className={`${MONO} m-0`}>{services.whatItCosts}</p>
<div className="mt-6 grid grid-cols-1 gap-10 md:grid-cols-12 md:gap-12">
<div className="md:col-span-6">
<p className="m-0 font-sans text-2xl font-bold leading-tight tracking-tight text-slate-900 md:text-4xl">
@@ -1,5 +1,6 @@
"use client";
import { BitSeam } from "@/src/components/bits/BitSeam";
import * as React from "react";
import Image from "next/image";
import { motion } from "framer-motion";
@@ -16,8 +17,9 @@ export const PortraitSection: React.FC<{
index: string;
label: string;
title: React.ReactNode;
portraitAlt: string;
children: React.ReactNode;
}> = ({ id, index, label, title, children }) => (
}> = ({ id, index, label, title, portraitAlt, children }) => (
<section
id={id}
className="relative scroll-mt-24 overflow-hidden bg-slate-900 text-white"
@@ -32,12 +34,14 @@ export const PortraitSection: React.FC<{
>
<Image
src="/marc-mintel.png"
alt="Portrait of Marc Mintel"
alt={portraitAlt}
fill
sizes="(min-width: 768px) 62vw, 100vw"
className="object-cover object-[50%_18%] grayscale"
priority={false}
/>
{/* Slow light behind the fades, so the dark surface is not flat. */}
<div className="bit-aurora" aria-hidden="true" />
{/* Fades: into the surface on the left, and at top and bottom. */}
<div className="absolute inset-0 hidden bg-gradient-to-r from-slate-900 via-slate-900/75 to-slate-900/10 md:block" />
<div className="absolute inset-x-0 bottom-0 h-2/5 bg-gradient-to-t from-slate-900 to-transparent" />
@@ -72,5 +76,6 @@ export const PortraitSection: React.FC<{
<div className="mt-10 md:mt-14">{children}</div>
</motion.div>
</div>
<BitSeam density={1} />
</section>
);
+4 -2
View File
@@ -1,6 +1,7 @@
"use client";
import * as React from "react";
import { useContent } from "@/src/components/ContentProvider";
/**
* StickyBook: a quiet contact button (wide screens only; on phones the sticky header
@@ -8,6 +9,7 @@ import * as React from "react";
* scrolled away, and steps aside again when the closing section is in view.
*/
export const StickyBook: React.FC = () => {
const { nav } = useContent();
const [visible, setVisible] = React.useState(false);
React.useEffect(() => {
@@ -37,9 +39,9 @@ export const StickyBook: React.FC = () => {
<a
href="#contact"
tabIndex={visible ? 0 : -1}
className="flex items-center gap-3 rounded-full bg-slate-900 px-7 py-4 font-sans text-[11px] font-bold uppercase tracking-[0.2em] text-white no-underline shadow-2xl shadow-slate-900/20 transition-transform duration-300 hover:-translate-y-0.5"
className="bit-cta flex items-center gap-3 rounded-full bg-slate-900 px-7 py-4 font-sans text-[11px] font-bold uppercase tracking-[0.2em] text-white no-underline shadow-2xl shadow-slate-900/20 transition-transform duration-300 hover:-translate-y-0.5"
>
Get in touch <span aria-hidden="true">→</span>
{nav.contactLong} <span aria-hidden="true">→</span>
</a>
</div>
);
@@ -1,14 +1,11 @@
"use client";
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
@@ -94,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;
@@ -106,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);
@@ -115,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;
@@ -194,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 =
@@ -205,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;
@@ -214,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
@@ -242,170 +252,171 @@ export const AutomationFlow: React.FC = () => {
{flow.summary}
</p>
<div className="mt-8 overflow-hidden rounded-3xl bg-slate-900">
<div className="flex items-center justify-between border-b border-slate-800 px-5 py-4 md:px-7">
<span className={`${MONO} text-slate-400`}>{flow.name}</span>
<span className={`${MONO} flex items-center gap-2 text-slate-500`}>
<span
className={`h-1.5 w-1.5 rounded-full ${finished ? "bg-emerald-400" : "bg-amber-300 animate-pulse"}`}
/>
{finished ? "run complete" : "running"}
</span>
</div>
<div>
<svg
viewBox={`0 0 ${canvas.w} ${canvas.h}`}
role="img"
aria-label={`${flow.name}: ${flow.summary}`}
className="block h-auto w-full"
>
<defs>
<pattern
id="flow-grid"
width="20"
height="20"
patternUnits="userSpaceOnUse"
>
<circle cx="1" cy="1" r="1" fill="#1e293b" />
</pattern>
</defs>
<rect width={canvas.w} height={canvas.h} fill="url(#flow-grid)" />
{flow.edges.map(([from, to]) => {
const a = nodeOf(from);
const b = nodeOf(to);
if (!a || !b) return null;
const done = still || travelled.has(`${from}>${to}`);
return (
<path
key={`${from}>${to}`}
d={pathOf(a, b, tall)}
fill="none"
stroke={done ? "#e2e8f0" : "#334155"}
strokeWidth={done ? 2 : 1.5}
style={{ transition: "stroke 300ms" }}
/>
);
})}
{activeEdges.map(([from, to]) => {
const a = nodeOf(from);
const b = nodeOf(to);
if (!a || !b || clock.t >= 1) return null;
const eased = ease(clock.t);
return (
<g key={`packet-${from}>${to}`}>
{[0.16, 0.1, 0.05].map((lag, i) => {
const p = pointOn(a, b, Math.max(0, eased - lag), tall);
return (
<circle
key={lag}
cx={p.x}
cy={p.y}
r={5 - i}
fill={MARK}
opacity={0.12 + i * 0.12}
/>
);
})}
<circle
cx={pointOn(a, b, eased, tall).x}
cy={pointOn(a, b, eased, tall).y}
r={7}
fill={MARK}
/>
</g>
);
})}
{placed.map((node) => {
const style = KIND_STYLE[node.kind];
const on = reached.has(node.id);
return (
<g key={node.id}>
<rect
x={node.cx - NODE_W / 2}
y={node.cy - NODE_H / 2}
width={NODE_W}
height={NODE_H}
rx={14}
fill={on ? "#1e293b" : "#0f172a"}
stroke={on ? style.stroke : "#334155"}
strokeWidth={on ? 1.75 : 1.25}
strokeDasharray={style.dash}
style={{ transition: "fill 300ms, stroke 300ms" }}
/>
<text
x={node.cx - NODE_W / 2 + 16}
y={node.cy - 8}
fill={on ? style.tag : "#64748b"}
style={{
fontFamily:
"var(--font-jetbrains-mono), ui-monospace, monospace",
fontSize: 10,
letterSpacing: "0.14em",
textTransform: "uppercase",
transition: "fill 300ms",
}}
>
{node.system}
</text>
<text
x={node.cx - NODE_W / 2 + 16}
y={node.cy + 14}
fill={on ? "#f8fafc" : "#94a3b8"}
style={{
fontFamily:
"var(--font-inter), Inter, system-ui, sans-serif",
fontSize: 15,
fontWeight: 600,
transition: "fill 300ms",
}}
>
{node.label}
</text>
{on && (
<circle
cx={node.cx + NODE_W / 2 - 16}
cy={node.cy - NODE_H / 2 + 16}
r={4}
fill={node.kind === "human" ? "#f8fafc" : "#34d399"}
/>
)}
</g>
);
})}
</svg>
</div>
<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>
<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">
<span className="text-slate-600">
{String(index + 1).padStart(2, "0")}
</span>
<span
className={
line.startsWith("unclear") ? "text-amber-200" : undefined
}
>
{line}
</span>
</li>
))}
</ol>
<BitAura source="around" tone="light" motion="pulse" className="mt-8">
<div className="overflow-hidden rounded-3xl bg-slate-900">
<div className="flex items-center justify-between border-b border-slate-800 px-5 py-4 md:px-7">
<span className={`${MONO} text-slate-400`}>{flow.name}</span>
<span className={`${MONO} flex items-center gap-2 text-slate-500`}>
<span
className={`h-1.5 w-1.5 rounded-full ${finished ? "bg-emerald-400" : "bg-amber-300 animate-pulse"}`}
/>
{finished ? automation.runComplete : automation.running}
</span>
</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) => (
<div>
<svg
viewBox={`0 0 ${canvas.w} ${canvas.h}`}
role="img"
aria-label={`${flow.name}: ${flow.summary}`}
className="block h-auto w-full"
>
<defs>
<pattern
id="flow-grid"
width="20"
height="20"
patternUnits="userSpaceOnUse"
>
<circle cx="1" cy="1" r="1" fill="#1e293b" />
</pattern>
</defs>
<rect width={canvas.w} height={canvas.h} fill="url(#flow-grid)" />
{flow.edges.map(([from, to]) => {
const a = nodeOf(from);
const b = nodeOf(to);
if (!a || !b) return null;
const done = still || travelled.has(`${from}>${to}`);
return (
<path
key={`${from}>${to}`}
d={pathOf(a, b, tall)}
fill="none"
stroke={done ? "#e2e8f0" : "#334155"}
strokeWidth={done ? 2 : 1.5}
style={{ transition: "stroke 300ms" }}
/>
);
})}
{activeEdges.map(([from, to]) => {
const a = nodeOf(from);
const b = nodeOf(to);
if (!a || !b || clock.t >= 1) return null;
const eased = ease(clock.t);
return (
<g key={`packet-${from}>${to}`}>
{[0.16, 0.1, 0.05].map((lag, i) => {
const p = pointOn(a, b, Math.max(0, eased - lag), tall);
return (
<circle
key={lag}
cx={p.x}
cy={p.y}
r={5 - i}
fill={MARK}
opacity={0.12 + i * 0.12}
/>
);
})}
<circle
cx={pointOn(a, b, eased, tall).x}
cy={pointOn(a, b, eased, tall).y}
r={7}
fill={MARK}
/>
</g>
);
})}
{placed.map((node) => {
const style = KIND_STYLE[node.kind];
const on = reached.has(node.id);
return (
<g key={node.id}>
<rect
x={node.cx - NODE_W / 2}
y={node.cy - NODE_H / 2}
width={NODE_W}
height={NODE_H}
rx={14}
fill={on ? "#1e293b" : "#0f172a"}
stroke={on ? style.stroke : "#334155"}
strokeWidth={on ? 1.75 : 1.25}
strokeDasharray={style.dash}
style={{ transition: "fill 300ms, stroke 300ms" }}
/>
<text
x={node.cx - NODE_W / 2 + 16}
y={node.cy - 8}
fill={on ? style.tag : "#64748b"}
style={{
fontFamily:
"var(--font-jetbrains-mono), ui-monospace, monospace",
fontSize: 10,
letterSpacing: "0.14em",
textTransform: "uppercase",
transition: "fill 300ms",
}}
>
{node.system}
</text>
<text
x={node.cx - NODE_W / 2 + 16}
y={node.cy + 14}
fill={on ? "#f8fafc" : "#94a3b8"}
style={{
fontFamily:
"var(--font-inter), Inter, system-ui, sans-serif",
fontSize: 15,
fontWeight: 600,
transition: "fill 300ms",
}}
>
{node.label}
</text>
{on && (
<circle
cx={node.cx + NODE_W / 2 - 16}
cy={node.cy - NODE_H / 2 + 16}
r={4}
fill={node.kind === "human" ? "#f8fafc" : "#34d399"}
/>
)}
</g>
);
})}
</svg>
</div>
<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`}>{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.text} className="flex gap-3">
<span className="text-slate-600">
{String(index + 1).padStart(2, "0")}
</span>
<span
className={
line.kind === "human" ? "text-amber-200" : undefined
}
>
{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">
{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"
@@ -423,22 +434,23 @@ export const AutomationFlow: React.FC = () => {
strokeDasharray={KIND_STYLE[kind].dash}
/>
</svg>
{KIND_LABEL[kind]}
{automation.kinds[kind]}
</li>
))}
</ul>
</ul>
</div>
</div>
</div>
</BitAura>
<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>
@@ -2,29 +2,33 @@
import * as React from "react";
import { motion } from "framer-motion";
export type CareerStation = {
readonly years: string;
readonly role: string;
readonly place: string;
};
import { BitAura } from "@/src/components/bits/BitAura";
import type { CareerStation } from "@/src/content/types";
/**
* CareerLine: the path so far as a line that draws itself, one station after another.
* CareerLine: the path so far as a line of light that draws itself, one station
* after another. Made for a dark surface.
*/
export const CareerLine: React.FC<{ stations: readonly CareerStation[] }> = ({
stations,
}) => (
<ol className="relative m-0 list-none p-0">
<motion.span
className="absolute left-[5px] top-2 w-px origin-top bg-slate-900 md:left-[7px]"
style={{ bottom: 8 }}
initial={{ scaleY: 0 }}
whileInView={{ scaleY: 1 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 1.4, ease: "easeOut" }}
aria-hidden="true"
/>
<BitAura
source="left"
tone="dark"
radius={0}
density={0.5}
className="absolute bottom-2 left-[5px] top-2 w-px md:left-[7px]"
>
<motion.span
className="block h-full w-px origin-top bg-indigo-100"
initial={{ scaleY: 0 }}
whileInView={{ scaleY: 1 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 1.4, ease: "easeOut" }}
aria-hidden="true"
/>
</BitAura>
{stations.map((station, index) => (
<motion.li
key={station.years}
@@ -35,19 +39,21 @@ export const CareerLine: React.FC<{ stations: readonly CareerStation[] }> = ({
transition={{ duration: 0.4, delay: 0.15 + index * 0.18 }}
>
<span
className={`absolute left-0 top-1.5 h-[11px] w-[11px] rounded-full border-2 border-slate-900 md:h-[15px] md:w-[15px] ${
index === stations.length - 1 ? "bg-slate-900" : "bg-white"
className={`absolute left-0 top-1.5 h-[11px] w-[11px] rounded-full border-2 border-indigo-100 md:h-[15px] md:w-[15px] ${
index === stations.length - 1
? "bit-node-live bg-white"
: "bg-slate-900"
}`}
aria-hidden="true"
/>
<span className="font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500 md:col-span-3 md:pt-1.5">
<span className="font-mono text-[11px] uppercase tracking-[0.2em] text-slate-400 md:col-span-3 md:pt-1.5">
{station.years}
</span>
<span className="md:col-span-9">
<span className="block font-sans text-xl font-bold tracking-tight text-slate-900 md:text-3xl">
<span className="block font-sans text-xl font-bold tracking-tight text-white md:text-3xl">
{station.role}
</span>
<span className="mt-1 block font-sans text-base text-slate-500 md:text-lg">
<span className="mt-1 block font-sans text-base text-slate-400 md:text-lg">
{station.place}
</span>
</span>
@@ -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.
@@ -15,6 +18,10 @@ const LINE = "#475569";
const SOFT = "#1e293b";
const MARK = "#fde68a";
const LIVE = "#10b981";
/** A lit surface: a light tint instead of a solid white block. */
const TINT = "rgba(248, 250, 252, 0.14)";
const RIM = "#64748b";
const FINE = 1.1;
const MONO = {
fontFamily: "var(--font-jetbrains-mono), ui-monospace, monospace",
// Set on the svg: larger on phones, where the drawing is scaled down.
@@ -36,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. */
@@ -70,8 +80,11 @@ const Packet: React.FC<{
pause?: number;
}> = ({ xs, ys, delay = 0, duration = 1.2, pause = 1.4 }) => (
<motion.circle
r={5}
r={3.5}
fill={MARK}
stroke={MARK}
strokeOpacity={0.28}
strokeWidth={6}
initial={{ cx: xs[0], cy: ys[0], opacity: 0 }}
animate={{
cx: [...xs],
@@ -96,18 +109,29 @@ const Person: React.FC<{ x: number; label: string; filled?: boolean }> = ({
<g>
<circle
cx={x}
cy={128}
r={20}
fill={filled ? INK : PAPER}
cy={130}
r={15}
fill={filled ? TINT : "none"}
stroke={INK}
strokeWidth={1.5}
strokeWidth={FINE}
/>
<path
d={`M ${x - 34} 196 Q ${x - 34} 158 ${x} 158 Q ${x + 34} 158 ${x + 34} 196`}
fill={filled ? INK : PAPER}
d={`M ${x - 28} 194 C ${x - 28} 172 ${x - 16} 160 ${x} 160 C ${x + 16} 160 ${x + 28} 172 ${x + 28} 194`}
fill={filled ? TINT : "none"}
stroke={INK}
strokeWidth={1.5}
strokeWidth={FINE}
strokeLinecap="round"
/>
<line
x1={x - 36}
x2={x + 36}
y1={194}
y2={194}
stroke={LINE}
strokeWidth={1}
strokeLinecap="round"
/>
{filled && <circle cx={x + 12} cy={119} r={3.5} fill={MARK} />}
<text x={x} y={222} textAnchor="middle" style={MONO}>
{label}
</text>
@@ -115,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 },
@@ -129,31 +153,41 @@ const Talk: React.FC = () => (
x={b.x}
y={b.y}
width={b.w}
height={26}
rx={13}
fill={b.side === "left" ? SOFT : INK}
height={24}
rx={12}
fill={b.side === "left" ? SOFT : TINT}
stroke={b.side === "left" ? LINE : INK}
strokeWidth={b.side === "left" ? 1 : FINE}
/>
<line
x1={b.x + 14}
x2={b.x + b.w - 14}
y1={b.y + 13}
y2={b.y + 13}
stroke={b.side === "left" ? "#94a3b8" : PAPER}
strokeWidth={2}
y1={b.y + 12}
y2={b.y + 12}
stroke={b.side === "left" ? "#94a3b8" : INK}
strokeWidth={1.5}
strokeLinecap="round"
/>
</motion.g>
))}
<motion.g {...appear(1.7)}>
<rect x={258} y={200} width={72} height={26} rx={13} fill={INK} />
<rect
x={270}
y={200}
width={60}
height={24}
rx={12}
fill={TINT}
stroke={INK}
strokeWidth={FINE}
/>
{[0, 1, 2].map((dot) => (
<motion.circle
key={dot}
cx={280 + dot * 14}
cy={213}
r={3}
fill={PAPER}
cx={288 + dot * 12}
cy={212}
r={2.25}
fill={INK}
animate={{ opacity: [0.2, 1, 0.2] }}
transition={{ duration: 1.1, delay: dot * 0.18, repeat: Infinity }}
/>
@@ -166,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}
@@ -186,15 +218,16 @@ const WeeklyHours: React.FC = () => (
return (
<motion.rect
key={h}
x={52 + d * 72}
y={64 + h * 22}
width={52}
height={16}
rx={4}
x={56 + d * 72}
y={66 + h * 21}
width={44}
height={10}
rx={5}
strokeWidth={1}
initial={{ fill: PAPER, stroke: LINE }}
animate={{
fill: reserved ? INK : PAPER,
stroke: reserved ? INK : LINE,
fill: reserved ? "#e2e8f0" : PAPER,
stroke: reserved ? "#e2e8f0" : LINE,
}}
transition={{
duration: 0.25,
@@ -212,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;
@@ -242,14 +275,14 @@ const WorkList: React.FC = () => (
rx={4}
fill={done ? INK : PAPER}
stroke={INK}
strokeWidth={1.5}
strokeWidth={FINE}
/>
{done && (
<motion.path
d={`M ${76} ${y + 16} l 3.5 3.5 l 6 -7`}
fill="none"
stroke={PAPER}
strokeWidth={2}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
{...draw(0.3 + row * 0.45)}
@@ -261,7 +294,7 @@ const WorkList: React.FC = () => (
y1={y + 16}
y2={y + 16}
stroke={done ? INK : LINE}
strokeWidth={2}
strokeWidth={1.5}
strokeLinecap="round"
/>
{done && (
@@ -269,7 +302,7 @@ const WorkList: React.FC = () => (
<path
d={`M 322 ${y + 16} h 46`}
stroke={INK}
strokeWidth={1.5}
strokeWidth={FINE}
markerEnd="url(#arrow)"
/>
<Packet
@@ -297,7 +330,7 @@ const WorkList: React.FC = () => (
fontSize: "calc(var(--scene-label, 10px) - 1px)",
}}
>
you
{l.you}
</text>
</motion.g>
)}
@@ -311,15 +344,15 @@ 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={2} />
<line x1={50} x2={430} y1={150} y2={150} stroke={LINE} strokeWidth={1.5} />
{Array.from({ length: 5 }, (_, w) => (
<g key={w}>
<line
@@ -328,18 +361,18 @@ const Notice: React.FC = () => (
y1={140}
y2={160}
stroke={INK}
strokeWidth={1.5}
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>
))}
<motion.rect
x={240}
y={132}
height={36}
rx={6}
y={139}
height={22}
rx={5}
fill={MARK}
initial={{ width: 0, opacity: 0.35 }}
animate={{ width: 190 }}
@@ -350,13 +383,13 @@ const Notice: React.FC = () => (
d="M 240 118 v -14 h 190 v 14"
fill="none"
stroke={INK}
strokeWidth={1.5}
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={7} fill={INK} {...appear(1.4, 0)} />
<motion.circle cx={430} cy={150} r={4.5} fill={INK} {...appear(1.4, 0)} />
</g>
);
@@ -385,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]!;
@@ -398,7 +431,7 @@ const RepairWeek: React.FC = () => (
x2={to[0]}
y2={to[1]}
stroke="#f59e0b"
strokeWidth={1.5}
strokeWidth={FINE}
strokeDasharray="3 5"
/>
<motion.line
@@ -407,7 +440,7 @@ const RepairWeek: React.FC = () => (
x2={to[0]}
y2={to[1]}
stroke={INK}
strokeWidth={2}
strokeWidth={1.5}
{...draw(0.4 + i * 0.22)}
/>
</g>
@@ -418,9 +451,9 @@ const RepairWeek: React.FC = () => (
key={i}
cx={x}
cy={y}
r={7}
r={5}
stroke={INK}
strokeWidth={1.5}
strokeWidth={FINE}
initial={{ fill: PAPER }}
animate={{ fill: LIVE }}
transition={{ duration: 0.4, delay: 1 + i * 0.28 }}
@@ -433,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}
@@ -449,9 +482,9 @@ const Summary: React.FC = () => (
rx={10}
fill={PAPER}
stroke={INK}
strokeWidth={1.5}
strokeWidth={FINE}
/>
{["found", "changed", "next"].map((label, s) => (
{l.summaryParts.map((label, s) => (
<g key={label}>
<motion.text
x={170}
@@ -469,7 +502,7 @@ const Summary: React.FC = () => (
y1={84 + s * 62 + l * 11}
y2={84 + s * 62 + l * 11}
stroke={LINE}
strokeWidth={3}
strokeWidth={2}
strokeLinecap="round"
{...draw(0.35 + s * 0.6 + l * 0.12)}
/>
@@ -483,75 +516,81 @@ 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} cy={150} r={26} fill={INK} />
<circle
cx={90}
cy={150}
r={26}
fill={TINT}
stroke={INK}
strokeWidth={FINE}
/>
<text
x={90}
y={154}
textAnchor="middle"
style={{
...MONO,
fill: PAPER,
fill: INK,
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={1.5}
{...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}
@@ -561,9 +600,9 @@ const Browser: React.FC<{
rx={12}
fill={PAPER}
stroke={INK}
strokeWidth={1.5}
strokeWidth={FINE}
/>
<line x1={70} x2={410} y1={58} y2={58} stroke={INK} strokeWidth={1.5} />
<line x1={70} x2={410} y1={58} y2={58} stroke={INK} strokeWidth={FINE} />
{[0, 1, 2].map((d) => (
<circle key={d} cx={88 + d * 12} cy={44} r={3} fill={LINE} />
))}
@@ -581,7 +620,7 @@ const Browser: React.FC<{
{url}
</text>
)}
{live && (
{onlineLabel !== undefined && (
<motion.g {...appear(1.6, 0)}>
<motion.circle
cx={352}
@@ -600,7 +639,7 @@ const Browser: React.FC<{
fill: LIVE,
}}
>
online
{onlineLabel}
</text>
</motion.g>
)}
@@ -617,8 +656,58 @@ const BLOCKS: readonly { x: number; y: number; w: number; h: number }[] = [
{ x: 298, y: 174, w: 94, h: 62 },
];
/** What a finished page holds: a headline, a button, and a line of text per card. */
const PageDetail: React.FC<{ delay: number }> = ({ delay }) => (
<motion.g {...appear(delay, 0)}>
<line
x1={102}
x2={206}
y1={118}
y2={118}
stroke={INK}
strokeWidth={3}
strokeLinecap="round"
/>
<line
x1={102}
x2={176}
y1={129}
y2={129}
stroke={INK}
strokeWidth={3}
strokeLinecap="round"
/>
<rect x={102} y={140} width={40} height={11} rx={5.5} fill={INK} />
{[BLOCKS[2], BLOCKS[3], BLOCKS[4], BLOCKS[5]].map(
(block) =>
block && (
<g key={`${block.x}-${block.y}`}>
<line
x1={block.x + 10}
x2={block.x + block.w - 24}
y1={block.y + block.h - 20}
y2={block.y + block.h - 20}
stroke={RIM}
strokeWidth={2}
strokeLinecap="round"
/>
<line
x1={block.x + 10}
x2={block.x + block.w - 44}
y1={block.y + block.h - 12}
y2={block.y + block.h - 12}
stroke={LINE}
strokeWidth={2}
strokeLinecap="round"
/>
</g>
),
)}
</motion.g>
);
/** Website, step 2: a first design as outlines. */
const Wireframe: React.FC = () => (
const Wireframe: Scene = ({ l }) => (
<g>
<Browser>
{BLOCKS.map((b, i) => (
@@ -644,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}
@@ -662,10 +751,14 @@ const Built: React.FC = () => (
height={b.h}
rx={5}
initial={{ fill: PAPER, stroke: LINE }}
animate={{ fill: i === 1 ? INK : SOFT, stroke: i === 1 ? INK : LINE }}
animate={{
fill: i === 1 ? TINT : SOFT,
stroke: i === 1 ? INK : LINE,
}}
transition={{ duration: 0.35, delay: 0.2 + i * 0.18 }}
/>
))}
<PageDetail delay={1.3} />
</Browser>
<motion.text
x={240}
@@ -674,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}
@@ -691,10 +784,11 @@ const ChangeByEmail: React.FC = () => (
width={b.w}
height={b.h}
rx={5}
fill={i === 1 ? INK : SOFT}
fill={i === 1 ? TINT : SOFT}
stroke={i === 1 ? INK : LINE}
/>
))}
<PageDetail delay={0} />
<motion.rect
x={BLOCKS[4]!.x}
y={BLOCKS[4]!.y}
@@ -702,8 +796,6 @@ const ChangeByEmail: React.FC = () => (
height={BLOCKS[4]!.h}
rx={5}
fill={MARK}
stroke={INK}
strokeWidth={1.5}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4, delay: 1.3 }}
@@ -718,20 +810,20 @@ const ChangeByEmail: React.FC = () => (
rx={5}
fill={PAPER}
stroke={INK}
strokeWidth={1.5}
strokeWidth={FINE}
/>
<path
d="M 16 190 l 23 16 l 23 -16"
fill="none"
stroke={INK}
strokeWidth={1.5}
strokeWidth={FINE}
/>
</motion.g>
<motion.path
d="M 66 202 C 120 202 150 205 188 205"
fill="none"
stroke={INK}
strokeWidth={1.5}
strokeWidth={FINE}
markerEnd="url(#arrow)"
{...draw(0.6)}
/>
@@ -742,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 }> = ({
@@ -766,9 +859,9 @@ const Tool: React.FC<{ x: number; y: number; label: string }> = ({
width={112}
height={44}
rx={10}
fill={PAPER}
stroke={INK}
strokeWidth={1.5}
fill={SOFT}
stroke={RIM}
strokeWidth={FINE}
/>
<text x={x} y={y + 4} textAnchor="middle" style={{ ...MONO, fill: INK }}>
{label}
@@ -777,18 +870,18 @@ 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"
strokeWidth={1.5}
strokeWidth={FINE}
strokeDasharray="3 5"
{...draw(0.3 + i * 0.25)}
/>
@@ -800,13 +893,13 @@ const ByHand: React.FC = () => (
r={13}
fill={PAPER}
stroke={INK}
strokeWidth={1.5}
strokeWidth={FINE}
/>
<path
d="M 218 172 Q 218 146 240 146 Q 262 146 262 172"
fill={PAPER}
stroke={INK}
strokeWidth={1.5}
strokeWidth={FINE}
/>
</motion.g>
<motion.text
@@ -816,7 +909,7 @@ const ByHand: React.FC = () => (
style={MONO}
{...appear(1.5, 0)}
>
today: copied by hand
{l.byHand}
</motion.text>
</g>
);
@@ -829,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
@@ -837,12 +930,12 @@ const Wiring: React.FC = () => (
d={d}
fill="none"
stroke={INK}
strokeWidth={2}
strokeWidth={1.5}
{...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}
@@ -851,16 +944,16 @@ 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={2} />
<path key={d} d={d} fill="none" stroke={INK} strokeWidth={1.5} />
))}
{[
{ from: [146, 80], to: [334, 80], delay: 0 },
@@ -870,10 +963,11 @@ const Running: React.FC = () => (
].map((dot) => (
<motion.circle
key={dot.delay}
r={6}
r={3.5}
fill={MARK}
stroke={INK}
strokeWidth={1.5}
stroke={MARK}
strokeOpacity={0.28}
strokeWidth={6}
initial={{ cx: dot.from[0], cy: dot.from[1], opacity: 0 }}
animate={{
cx: [dot.from[0]!, dot.to[0]!],
@@ -889,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}
@@ -899,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],
@@ -916,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;
@@ -932,8 +1027,8 @@ export const PathScene: React.FC<{
viewBox="0 0 10 10"
refX="8"
refY="5"
markerWidth="7"
markerHeight="7"
markerWidth="5"
markerHeight="5"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill={INK} />
@@ -957,7 +1052,7 @@ export const PathScene: React.FC<{
animate={{ opacity: 1 }}
transition={{ duration: 0.25 }}
>
<Scene />
<Scene l={labels} />
</motion.g>
</svg>
);
@@ -1,6 +1,9 @@
"use client";
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.
@@ -23,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);
/**
@@ -128,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);
@@ -169,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;
@@ -177,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) {
@@ -200,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
@@ -221,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;
@@ -240,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
@@ -280,134 +256,139 @@ 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>
<div className="md:col-span-8">
<div className="overflow-hidden rounded-3xl bg-slate-900 font-mono text-[12px] leading-relaxed text-slate-300 md:text-[13px]">
{/* Run header */}
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-800 px-5 py-4 md:px-7">
<div className="flex items-center gap-3">
<span className="flex gap-1.5" aria-hidden="true">
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
</span>
<span className="text-slate-400">
{scenario.branch}
<span className="text-slate-600"> → </span>main
<BitAura source="right" tone="light">
<div className="overflow-hidden rounded-3xl bg-slate-900 font-mono text-[12px] leading-relaxed text-slate-300 md:text-[13px]">
{/* Run header */}
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-800 px-5 py-4 md:px-7">
<div className="flex items-center gap-3">
<span className="flex gap-1.5" aria-hidden="true">
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
</span>
<span className="text-slate-400">
{scenario.branch}
<span className="text-slate-600"> → </span>main
</span>
</div>
<span className="text-slate-500">
{copy.run} #{frame.attempt === 1 ? 481 : 482}
{frame.attempt === 2 && (
<span className="text-slate-400"> · {copy.fixPushed}</span>
)}
</span>
</div>
<span className="text-slate-500">
run #{frame.attempt === 1 ? 481 : 482}
{frame.attempt === 2 && (
<span className="text-slate-400"> · fix pushed</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) => {
const state = stageState(index);
return (
<div
key={stage}
className={`flex items-center justify-between rounded-xl border px-3 py-2.5 transition-colors duration-200 ${STAGE_STYLE[state]}`}
>
<span>{stage}</span>
<span aria-hidden="true">{STAGE_MARK[state]}</span>
</div>
);
})}
</div>
{/* Test output */}
<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…"}
</p>
)}
{SUITES.slice(0, frame.suitesDone).map((suite, index) => {
const failed = firstAttempt && index === failingIndex;
return (
<div key={suite.file}>
<div className="flex items-baseline gap-3">
<span
className={`w-10 shrink-0 font-bold ${failed ? "text-rose-400" : "text-emerald-400"}`}
>
{failed ? "FAIL" : "PASS"}
</span>
<span className="min-w-0 flex-1 truncate text-slate-200">
{suite.file}
</span>
<span className="hidden text-slate-500 sm:inline">
{suite.tests} tests
</span>
<span className="w-14 shrink-0 text-right text-slate-600">
{suite.ms} ms
</span>
{/* 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">
{copy.stages.map((stage, index) => {
const state = stageState(index);
return (
<div
key={stage}
className={`flex items-center justify-between rounded-xl border px-3 py-2.5 transition-colors duration-200 ${STAGE_STYLE[state]}`}
>
<span>{stage}</span>
<span aria-hidden="true">{STAGE_MARK[state]}</span>
</div>
{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}
</p>
<p className="m-0 text-[12px] leading-relaxed text-emerald-400 md:text-[13px]">
expected&nbsp; {scenario.expected}
</p>
<p className="m-0 text-[12px] leading-relaxed text-rose-400 md:text-[13px]">
received&nbsp; {scenario.received}
</p>
</div>
)}
</div>
);
})}
{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}…
</p>
)}
</div>
);
})}
</div>
{/* 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>
{failedCount > 0 && (
<span className="text-rose-400">{failedCount} failed, </span>
{/* Test output */}
<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 ? copy.lint : copy.types}
</p>
)}
<span className="text-emerald-400">
{reportedTests - failedCount} passed
{SUITES.slice(0, frame.suitesDone).map((suite, index) => {
const failed = firstAttempt && index === failingIndex;
return (
<div key={suite.file}>
<div className="flex items-baseline gap-3">
<span
className={`w-10 shrink-0 font-bold ${failed ? "text-rose-400" : "text-emerald-400"}`}
>
{failed ? "FAIL" : "PASS"}
</span>
<span className="min-w-0 flex-1 truncate text-slate-200">
{suite.file}
</span>
<span className="hidden text-slate-500 sm:inline">
{suite.tests} {copy.tests}
</span>
<span className="w-14 shrink-0 text-right text-slate-600">
{suite.ms} ms
</span>
</div>
{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]">
● {scenarioCopy.testName}
</p>
<p className="m-0 text-[12px] leading-relaxed text-emerald-400 md:text-[13px]">
{copy.expected}&nbsp; {scenarioCopy.expected}
</p>
<p className="m-0 text-[12px] leading-relaxed text-rose-400 md:text-[13px]">
{copy.received}&nbsp; {scenarioCopy.received}
</p>
</div>
)}
</div>
);
})}
{frame.stagesDone === 2 && frame.suitesDone < SUITES.length && (
<p className="m-0 text-[12px] leading-relaxed text-slate-500 md:text-[13px]">
{fill(copy.runningFile, {
file: SUITES[frame.suitesDone]?.file ?? "",
})}
</p>
)}
</div>
{/* 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">{copy.summaryLabel} </span>
{failedCount > 0 && (
<span className="text-rose-400">
{fill(copy.failed, { n: failedCount })},{" "}
</span>
)}
<span className="text-emerald-400">
{fill(copy.passed, { n: reportedTests - failedCount })}
</span>
<span className="text-slate-500">
{" "}
{fill(copy.of, { total: TOTAL_TESTS })}
</span>
</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] ${
!finished
? "bg-slate-800 text-slate-400"
<span
className={`rounded-full px-3 py-1 font-bold uppercase tracking-[0.15em] ${
!finished
? "bg-slate-800 text-slate-400"
: firstAttempt
? "bg-rose-500/15 text-rose-400"
: "bg-emerald-500/15 text-emerald-400"
}`}
>
{!finished
? copy.pending
: firstAttempt
? "bg-rose-500/15 text-rose-400"
: "bg-emerald-500/15 text-emerald-400"
}`}
>
{!finished
? "release pending"
: firstAttempt
? "release blocked"
: "released"}
</span>
? copy.blocked
: copy.released}
</span>
</div>
</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>
@@ -3,25 +3,10 @@
import * as React from "react";
import Image from "next/image";
import { motion, useMotionValue, useSpring } from "framer-motion";
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 { BitAura } from "@/src/components/bits/BitAura";
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]";
@@ -69,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);
@@ -107,7 +93,7 @@ const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
ref={ref}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
className="relative overflow-hidden rounded-3xl bg-slate-900 px-5 pb-0 pt-8 [perspective:1400px] md:px-12 md:pt-14"
className="relative overflow-hidden rounded-3xl border border-slate-700 bg-slate-950 px-5 pb-0 pt-8 [perspective:1400px] md:px-12 md:pt-14"
>
<div
className="pointer-events-none absolute inset-0 opacity-60"
@@ -146,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}
@@ -166,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}
@@ -179,65 +165,78 @@ const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
};
/**
* WorkShowcase: each live site on a dark stage, as a desktop and a phone view
* 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" : ""}`}>
<WorkStage item={item} />
</div>
<div className="md:col-span-4">
<p className={`${MONO} m-0 text-slate-500`}>
0{index + 1} · {item.client}
</p>
<h3 className="m-0 mt-4 font-sans text-3xl font-bold tracking-tighter text-slate-900 md:text-5xl">
{item.name}
</h3>
<p className="m-0 mt-5 font-sans text-lg leading-relaxed text-slate-500 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-200 py-3 font-sans text-base text-slate-900 md:text-lg"
>
{line}
</li>
))}
</ul>
<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-200 px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.12em] text-slate-600"
>
{tech}
</li>
))}
</ul>
<a
href={item.href}
target="_blank"
rel="noopener noreferrer"
className="mt-8 inline-flex items-center gap-3 rounded-full bg-slate-900 px-6 py-3 font-sans text-[11px] font-bold uppercase tracking-[0.15em] text-white no-underline transition-transform duration-300 hover:-translate-y-0.5"
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"
>
Open the live site <span aria-hidden="true">↗</span>
</a>
</div>
</article>
))}
</div>
);
<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"}
>
<WorkStage item={item} />
</BitAura>
</div>
<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"
>
{work.openLive} <span aria-hidden="true">↗</span>
</a>
</div>
</article>
);
})}
</div>
);
};
@@ -0,0 +1,73 @@
"use client";
import type { Locale } from "@/src/domain/locale";
import type { ScrollAnchor } from "@/src/domain/scroll-anchor";
/**
* The state of a running language switch. It lives outside React on purpose:
* changing the language swaps the whole page below the root layout, and the
* curtain that hides the swap has to carry on across it.
*
* cover: the curtain closes over the old page. hold: the page is swapped behind
* it. reveal: the curtain opens on the new page.
*/
export type SwitchState =
| { readonly phase: "idle" }
| {
readonly phase: "cover" | "hold" | "reveal";
readonly from: Locale;
readonly target: Locale;
readonly href: string;
/** When the switch began and when the current phase began (performance.now). */
readonly startedAt: number;
readonly phaseAt: number;
/** Where the visitor was reading, to find the same place in the new page. */
readonly anchor: ScrollAnchor | null;
};
const IDLE: SwitchState = { phase: "idle" };
let state: SwitchState = IDLE;
const listeners = new Set<() => void>();
const set = (next: SwitchState) => {
state = next;
listeners.forEach((listener) => listener());
};
export const subscribe = (listener: () => void): (() => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
export const getSwitchState = (): SwitchState => state;
export const getIdleState = (): SwitchState => IDLE;
export const startSwitch = (from: Locale, target: Locale, href: string) => {
if (state.phase !== "idle") return;
const now = performance.now();
set({
phase: "cover",
from,
target,
href,
startedAt: now,
phaseAt: now,
anchor: null,
});
};
export const enterPhase = (
phase: "hold" | "reveal",
anchor?: ScrollAnchor | null,
) => {
if (state.phase === "idle") return;
set({
...state,
phase,
phaseAt: performance.now(),
anchor: anchor === undefined ? state.anchor : anchor,
});
};
export const endSwitch = () => set(IDLE);