From the team
Read our Blog
- Insights, tutorials, and deep dives on software development, web technologies, and innovative solutions.
+ Tutorials and deep dives on Blazor, .NET, and the way we build software.
-
- Blazor
- C#
- Web Development
- Tutorials
-
+
+
+
diff --git a/src/scripts/sceneKit.ts b/src/scripts/sceneKit.ts
new file mode 100644
index 0000000..eaf9d8c
--- /dev/null
+++ b/src/scripts/sceneKit.ts
@@ -0,0 +1,328 @@
+import * as THREE from 'three';
+import { dashedStrokeGeometry } from './shapes';
+
+/**
+ * Primitives shared by every Three.js scene on the site: flat materials, canvas-
+ * backed text, dashed connectors with travelling packets, and the easing used to
+ * drive things from scroll position.
+ *
+ * All scenes are flat, screen-space artwork under an orthographic camera, so
+ * everything here lives in the XY plane and cares about opacity, not lighting.
+ */
+
+/** Mirrors the brand custom properties in src/styles/global.css. */
+export const BRAND = {
+ light: '#E5E2E1',
+ dark: '#333C4D',
+ darkText: '#1E2532',
+ muted: '#7F8FA4',
+ primary: '#66CC8A',
+ secondary: '#377CFB',
+ danger: '#EA5234',
+};
+
+/** Which colour draws lines and which draws captions, per background. */
+export interface Palette {
+ ink: string;
+ muted: string;
+}
+
+export const ON_DARK: Palette = { ink: BRAND.light, muted: BRAND.muted };
+export const ON_LIGHT: Palette = { ink: BRAND.dark, muted: BRAND.muted };
+
+export function smoothstep(t: number): number {
+ const c = Math.min(1, Math.max(0, t));
+ return c * c * (3 - 2 * c);
+}
+
+export function vec(x: number, y: number): THREE.Vector2 {
+ return new THREE.Vector2(x, y);
+}
+
+export function basicMaterial(color: string, opacity = 1): THREE.MeshBasicMaterial {
+ return new THREE.MeshBasicMaterial({
+ color: new THREE.Color(color),
+ transparent: true,
+ opacity,
+ depthWrite: false,
+ side: THREE.DoubleSide,
+ });
+}
+
+/** Records a mesh's design opacity so group fades stay relative to it. */
+export function mesh(geometry: THREE.BufferGeometry, color: string, opacity = 1): THREE.Mesh {
+ const created = new THREE.Mesh(geometry, basicMaterial(color, opacity));
+ created.userData.baseOpacity = opacity;
+ return created;
+}
+
+export function setGroupOpacity(group: THREE.Object3D, opacity: number): void {
+ group.visible = opacity > 0.002;
+ if (!group.visible) return;
+ group.traverse((child) => {
+ const material = (child as THREE.Mesh).material as THREE.MeshBasicMaterial | undefined;
+ if (!material || !material.isMaterial) return;
+ const base = (child.userData.baseOpacity as number) ?? 1;
+ material.opacity = base * opacity;
+ });
+}
+
+/* ------------------------------------------------------------------ *
+ * Canvas-backed text
+ * ------------------------------------------------------------------ */
+
+/** Canvas pixels per design unit — keeps text crisp when a screen is zoomed. */
+export const TEXTURE_SCALE = 3;
+
+export function mono(size: number, weight = 500): string {
+ return `${weight} ${size}px 'JetBrains Mono', ui-monospace, monospace`;
+}
+
+export function roundedPath(
+ ctx: CanvasRenderingContext2D,
+ x: number,
+ y: number,
+ w: number,
+ h: number,
+ r: number
+): void {
+ // roundRect is well supported but still worth guarding — the fallback is a plain
+ // rect, which only costs the shape its corner radius.
+ if (typeof ctx.roundRect === 'function') {
+ ctx.beginPath();
+ ctx.roundRect(x, y, w, h, r);
+ return;
+ }
+ ctx.beginPath();
+ ctx.rect(x, y, w, h);
+}
+
+export function createLabel(text: string, size = 20, color = BRAND.muted): THREE.Mesh {
+ const canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('2d')!;
+ ctx.font = mono(size * TEXTURE_SCALE, 500);
+ const width = Math.ceil(ctx.measureText(text).width) + 8 * TEXTURE_SCALE;
+ const height = Math.ceil(size * TEXTURE_SCALE * 1.6);
+ canvas.width = width;
+ canvas.height = height;
+
+ ctx.font = mono(size * TEXTURE_SCALE, 500);
+ ctx.fillStyle = color;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(text, width / 2, height / 2);
+
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.colorSpace = THREE.SRGBColorSpace;
+ const labelMesh = new THREE.Mesh(
+ new THREE.PlaneGeometry(width / TEXTURE_SCALE, height / TEXTURE_SCALE),
+ new THREE.MeshBasicMaterial({ map: texture, transparent: true, depthWrite: false })
+ );
+ labelMesh.userData.baseOpacity = 1;
+ return labelMesh;
+}
+
+export function labelWidth(label: THREE.Mesh): number {
+ return (label.geometry as THREE.PlaneGeometry).parameters.width;
+}
+
+/* ------------------------------------------------------------------ *
+ * Dashed connectors with travelling packets
+ * ------------------------------------------------------------------ */
+
+/** Links are built along +X at this length, then rotated and stretched to fit. */
+const LINK_LENGTH = 1000;
+
+export interface LinkOptions {
+ /** How many packets travel the link at once. */
+ packets?: number;
+ /** Where along the link the label sits, 0 at the start and 1 at the end. */
+ labelAt?: number;
+ /** How far the label sits off the line, signed so it can pick a side. */
+ labelOffset?: number;
+ /** Line, dash and packet sizes, in the scene's own units. */
+ thickness?: number;
+ dash?: number;
+ gap?: number;
+ packetRadius?: number;
+ labelSize?: number;
+ /** Packets per second along the link. */
+ speed?: number;
+ /** Set false for a packets-only link, e.g. the return leg of a two-way line. */
+ line?: boolean;
+}
+
+export interface Link {
+ group: THREE.Group;
+ /** Points the link at a pair of world positions and sets its fade. */
+ place(from: THREE.Vector2, to: THREE.Vector2, opacity: number): void;
+ /** Hides the caption where there is no room for it. */
+ showLabel(visible: boolean): void;
+ tick(time: number): void;
+}
+
+export function createLink(text: string, color: string, options: LinkOptions = {}): Link {
+ const {
+ packets: packetCount = 3,
+ labelAt = 0.5,
+ labelOffset = -22,
+ thickness = 3,
+ dash = 26,
+ gap = 20,
+ packetRadius = 6,
+ labelSize = 17,
+ speed = 0.22,
+ line: showLine = true,
+ } = options;
+ const group = new THREE.Group();
+
+ // Built once along +X at a known length, then rotated and stretched into place —
+ // far cheaper than rebuilding dash geometry every frame.
+ const line = mesh(
+ dashedStrokeGeometry([vec(0, 0), vec(LINK_LENGTH, 0)], thickness, dash, gap),
+ color,
+ 0.55
+ );
+ const lineHolder = new THREE.Group();
+ if (showLine) lineHolder.add(line);
+ group.add(lineHolder);
+
+ const packets: THREE.Mesh[] = [];
+ for (let i = 0; i < packetCount; i++) {
+ const packet = mesh(new THREE.CircleGeometry(packetRadius, 16), color);
+ packets.push(packet);
+ group.add(packet);
+ }
+
+ const label = text ? createLabel(text, labelSize, color) : null;
+ if (label) group.add(label);
+
+ const from = vec(0, 0);
+ const to = vec(0, 0);
+ let labelVisible = true;
+
+ return {
+ group,
+ place(a, b, opacity) {
+ from.copy(a);
+ to.copy(b);
+ const delta = vec(b.x - a.x, b.y - a.y);
+ const distance = delta.length() || 1;
+ lineHolder.position.set(a.x, a.y, 0);
+ lineHolder.rotation.z = Math.atan2(delta.y, delta.x);
+ lineHolder.scale.x = distance / LINK_LENGTH;
+ if (label) {
+ // Offset along the line's normal, so a diagonal link pushes its label
+ // clear of the line rather than always straight up.
+ const normalX = delta.y / distance;
+ const normalY = -delta.x / distance;
+ label.position.set(
+ a.x + delta.x * labelAt + normalX * labelOffset,
+ a.y + delta.y * labelAt + normalY * labelOffset,
+ 0.2
+ );
+ }
+ setGroupOpacity(group, opacity);
+ if (label) label.visible = labelVisible;
+ },
+ showLabel(visible) {
+ labelVisible = visible;
+ },
+ tick(time) {
+ packets.forEach((packet, i) => {
+ const t = (time * speed + i / packets.length) % 1;
+ packet.position.set(from.x + (to.x - from.x) * t, from.y + (to.y - from.y) * t, 0.3);
+ const fade = Math.sin(t * Math.PI);
+ (packet.material as THREE.MeshBasicMaterial).opacity =
+ (packet.userData.baseOpacity as number) * fade;
+ });
+ },
+ };
+}
+
+/* ------------------------------------------------------------------ *
+ * Mounting a scene on a canvas
+ * ------------------------------------------------------------------ */
+
+export interface SceneView {
+ scene: THREE.Scene;
+ /** Visible width and height in design units — width follows the canvas aspect. */
+ viewWidth: number;
+ viewHeight: number;
+ reduceMotion: boolean;
+}
+
+export interface MountOptions {
+ canvas: HTMLCanvasElement;
+ /** Fixed visible height in design units; width follows the canvas aspect. */
+ designHeight: number;
+ /** Element whose visibility starts and stops the render loop. */
+ watch: Element;
+ /** Called once with the view; returns the per-frame update. */
+ build(view: SceneView): (time: number) => void;
+ /** Called after every resize with the new view size. */
+ onResize?(view: SceneView): void;
+}
+
+/**
+ * Sets up a renderer, an orthographic camera over a fixed design space, and a
+ * render loop that only runs while `watch` is on screen.
+ */
+export function mountScene({ canvas, designHeight, watch, build, onResize }: MountOptions): void {
+ const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+ const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+
+ const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 100);
+ camera.position.z = 50;
+
+ const view: SceneView = {
+ scene: new THREE.Scene(),
+ viewWidth: designHeight,
+ viewHeight: designHeight,
+ reduceMotion,
+ };
+
+ function resize(): void {
+ const width = canvas.clientWidth || window.innerWidth;
+ const height = canvas.clientHeight || window.innerHeight;
+ renderer.setSize(width, height, false);
+
+ view.viewHeight = designHeight;
+ view.viewWidth = designHeight * (width / height);
+ camera.left = -view.viewWidth / 2;
+ camera.right = view.viewWidth / 2;
+ camera.top = view.viewHeight / 2;
+ camera.bottom = -view.viewHeight / 2;
+ camera.updateProjectionMatrix();
+ onResize?.(view);
+ }
+
+ const update = build(view);
+ resize();
+ window.addEventListener('resize', resize);
+
+ let running = false;
+ function frame(now: number): void {
+ if (!running) return;
+ requestAnimationFrame(frame);
+ update(now / 1000);
+ renderer.render(view.scene, camera);
+ }
+
+ // Nothing off screen needs a frame budget, so the loop only runs while the
+ // watched element is actually visible.
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (entry.isIntersecting && !running) {
+ running = true;
+ requestAnimationFrame(frame);
+ } else if (!entry.isIntersecting) {
+ running = false;
+ }
+ },
+ { rootMargin: '120px' }
+ );
+ observer.observe(watch);
+}
diff --git a/src/scripts/sectionScenes.ts b/src/scripts/sectionScenes.ts
new file mode 100644
index 0000000..88da36b
--- /dev/null
+++ b/src/scripts/sectionScenes.ts
@@ -0,0 +1,351 @@
+import * as THREE from 'three';
+import { roundedRectGeometry, roundedRectOutlineGeometry, strokeGeometry } from './shapes';
+import {
+ BRAND,
+ ON_LIGHT,
+ createLabel,
+ createLink,
+ mesh,
+ mountScene,
+ setGroupOpacity,
+ smoothstep,
+ vec,
+ type Link,
+ type SceneView,
+} from './sceneKit';
+
+/**
+ * Small outline diagrams for the homepage's colour-washed sections, drawn in the
+ * same style as the stack story: outlines in the brand ink, one accent colour per
+ * section, dashed links with travelling packets.
+ *
+ * Each scene lives in a design space 400 units tall (width follows the canvas
+ * aspect, 640 at the 8:5 the component uses) and reveals itself as its section
+ * scrolls into view, then idles with a little ambient motion.
+ */
+
+const DESIGN_HEIGHT = 400;
+const INK = ON_LIGHT.ink;
+const MUTED = ON_LIGHT.muted;
+const STROKE = 3.5;
+
+export type SceneName = 'workflow' | 'ownership' | 'blog';
+
+type Updater = (time: number, progress: number) => void;
+type SceneBuilder = (view: SceneView) => Updater;
+
+/** 0→1 as the reveal window starting at `start` and lasting `span` passes. */
+function reveal(progress: number, start: number, span = 0.25): number {
+ return smoothstep((progress - start) / span);
+}
+
+/** Sets a group's fade and a subtle settle-in scale from a reveal value. */
+function settle(group: THREE.Object3D, amount: number, baseScale = 1): void {
+ setGroupOpacity(group, amount);
+ group.scale.setScalar(baseScale * (0.86 + 0.14 * amount));
+}
+
+/** A person icon: head ring above a shoulders arch. */
+function buildPerson(color = INK, opacity = 1): THREE.Group {
+ const group = new THREE.Group();
+ const head = mesh(new THREE.RingGeometry(9, 12.5, 32), color, opacity);
+ head.position.y = 24;
+ group.add(head);
+ const shoulders = mesh(new THREE.RingGeometry(18.5, 22, 32, 1, 0, Math.PI), color, opacity);
+ shoulders.position.y = -6;
+ group.add(shoulders);
+ return group;
+}
+
+function box(width: number, height: number, radius: number, color = INK, opacity = 1): THREE.Mesh {
+ return mesh(roundedRectOutlineGeometry(width, height, radius, STROKE), color, opacity);
+}
+
+function line(points: THREE.Vector2[], thickness: number, color = INK, opacity = 1): THREE.Mesh {
+ return mesh(strokeGeometry(points, thickness), color, opacity);
+}
+
+function bar(width: number, height: number, color = INK, opacity = 1): THREE.Mesh {
+ return mesh(roundedRectGeometry(width, height, Math.min(width, height) / 2), color, opacity);
+}
+
+/* ------------------------------------------------------------------ *
+ * How We Work — discovery → build → review → ship
+ * ------------------------------------------------------------------ */
+
+const workflow: SceneBuilder = ({ scene, reduceMotion }) => {
+ const accent = BRAND.secondary;
+ const steps = ['Discovery', 'Build', 'Review', 'Ship'];
+ const xs = [-225, -75, 75, 225];
+ const y = 18;
+
+ const icons: Array<(node: THREE.Group) => void> = [
+ (node) => {
+ // A spec sheet.
+ node.add(box(30, 38, 3));
+ [0, 1, 2].forEach((i) => {
+ const row = bar(i === 2 ? 10 : 16, 2.5, INK, 0.7);
+ row.position.set(i === 2 ? -3 : 0, 8 - i * 8, 0.1);
+ node.add(row);
+ });
+ },
+ (node) => {
+ // A code window with angle brackets.
+ node.add(box(46, 36, 4));
+ node.add(line([vec(-23, 10), vec(23, 10)], 2.5, INK, 0.6));
+ node.add(line([vec(-6, 2), vec(-13, -4), vec(-6, -10)], 2.5, accent));
+ node.add(line([vec(6, 2), vec(13, -4), vec(6, -10)], 2.5, accent));
+ },
+ (node) => {
+ // A checklist, first item ticked.
+ [0, 1, 2].forEach((i) => {
+ const rowY = 12 - i * 12;
+ const tick = mesh(roundedRectOutlineGeometry(9, 9, 2, 2), INK, 0.8);
+ tick.position.set(-14, rowY, 0);
+ node.add(tick);
+ const text = bar(22, 2.5, INK, 0.55);
+ text.position.set(4, rowY, 0);
+ node.add(text);
+ });
+ const check = line([vec(-19, 12), vec(-14.5, 7.5), vec(-8, 17)], 2.8, accent);
+ check.position.z = 0.3;
+ check.userData.check = true;
+ node.add(check);
+ },
+ (node) => {
+ // An upward arrow in a ring.
+ node.add(mesh(new THREE.RingGeometry(15.5, 18, 40), INK));
+ const arrow = new THREE.Group();
+ arrow.add(line([vec(0, -8), vec(0, 8)], 2.5, accent));
+ arrow.add(line([vec(-6, 2), vec(0, 8), vec(6, 2)], 2.5, accent));
+ arrow.userData.arrow = true;
+ node.add(arrow);
+ },
+ ];
+
+ const nodes = steps.map((step, i) => {
+ const node = new THREE.Group();
+ node.add(box(104, 78, 12));
+ icons[i](node);
+ const label = createLabel(step, 14, MUTED);
+ label.position.y = -60;
+ node.add(label);
+ node.position.set(xs[i], y, 0);
+ scene.add(node);
+ return node;
+ });
+
+ const links: Link[] = xs.slice(0, -1).map(() => {
+ const link = createLink('', accent, {
+ packets: 2,
+ thickness: 3,
+ dash: 12,
+ gap: 8,
+ packetRadius: 4,
+ speed: 0.35,
+ });
+ scene.add(link.group);
+ return link;
+ });
+
+ return (time, progress) => {
+ nodes.forEach((node, i) => {
+ settle(node, reveal(progress, 0.02 + i * 0.16, 0.22));
+ node.traverse((child) => {
+ if (child.userData.arrow) child.position.y = reduceMotion ? 0 : Math.sin(time * 2.2) * 2;
+ if (child.userData.check) {
+ child.scale.setScalar(reduceMotion ? 1 : 0.9 + 0.1 * Math.abs(Math.sin(time * 1.6)));
+ }
+ });
+ });
+ links.forEach((link, i) => {
+ const amount = reveal(progress, 0.14 + i * 0.16, 0.2);
+ const from = vec(xs[i] + 52, y);
+ const to = vec(xs[i + 1] - 52, y);
+ link.place(from, from.clone().lerp(to, amount), amount);
+ link.tick(reduceMotion ? 0 : time);
+ });
+ };
+};
+
+/* ------------------------------------------------------------------ *
+ * Why Y-A-S — you, talking straight to the people writing the code
+ * ------------------------------------------------------------------ */
+
+const ownership: SceneBuilder = ({ scene, reduceMotion }) => {
+ const accent = BRAND.primary;
+
+ const you = buildPerson();
+ you.scale.setScalar(1.2);
+ you.position.set(-230, 20, 0);
+ scene.add(you);
+ const youLabel = createLabel('You', 14, MUTED);
+ youLabel.position.set(-230, -32, 0);
+ scene.add(youLabel);
+
+ const devPositions = [vec(215, 100), vec(248, 18), vec(215, -64)];
+ const devs = devPositions.map((position) => {
+ const dev = buildPerson();
+ dev.position.set(position.x, position.y, 0);
+ scene.add(dev);
+ return dev;
+ });
+ const devLabel = createLabel('the developers', 14, MUTED);
+ devLabel.position.set(230, -110, 0);
+ scene.add(devLabel);
+
+ const outbound = devPositions.map(() => {
+ const link = createLink('', accent, { packets: 2, thickness: 2.5, dash: 12, gap: 8, packetRadius: 4, speed: 0.3 });
+ scene.add(link.group);
+ return link;
+ });
+ const inbound = devPositions.map(() => {
+ const link = createLink('', accent, { packets: 1, packetRadius: 4, speed: 0.3, line: false });
+ scene.add(link.group);
+ return link;
+ });
+
+ // The middleman that isn't there.
+ const middleman = new THREE.Group();
+ middleman.add(mesh(roundedRectOutlineGeometry(132, 44, 8, 2.5), MUTED, 0.6));
+ const middlemanLabel = createLabel('account manager', 12, MUTED);
+ middleman.add(middlemanLabel);
+ middleman.position.set(0, -128, 0);
+ scene.add(middleman);
+ const cross = new THREE.Group();
+ cross.add(line([vec(-60, -18), vec(60, 18)], 3.5, accent));
+ cross.add(line([vec(-60, 18), vec(60, -18)], 3.5, accent));
+ cross.position.copy(middleman.position);
+ cross.position.z = 0.3;
+ scene.add(cross);
+ const crossLabel = createLabel('no hand-offs', 13, accent);
+ crossLabel.position.set(0, -170, 0);
+ scene.add(crossLabel);
+
+ return (time, progress) => {
+ settle(you, reveal(progress, 0, 0.2), 1.2);
+ setGroupOpacity(youLabel, reveal(progress, 0.05, 0.2));
+ devs.forEach((dev, i) => settle(dev, reveal(progress, 0.32 + i * 0.08, 0.2)));
+ setGroupOpacity(devLabel, reveal(progress, 0.5, 0.2));
+
+ const from = vec(-200, 20);
+ devPositions.forEach((position, i) => {
+ const amount = reveal(progress, 0.12 + i * 0.08, 0.28);
+ const to = vec(position.x - 28, position.y);
+ outbound[i].place(from, from.clone().lerp(to, amount), amount);
+ inbound[i].place(to, to.clone().lerp(from, amount), amount);
+ outbound[i].tick(reduceMotion ? 0 : time);
+ inbound[i].tick(reduceMotion ? 0.5 : time + 0.5);
+ });
+
+ setGroupOpacity(middleman, reveal(progress, 0.55, 0.2));
+ settle(cross, reveal(progress, 0.7, 0.2));
+ setGroupOpacity(crossLabel, reveal(progress, 0.78, 0.2));
+ };
+};
+
+/* ------------------------------------------------------------------ *
+ * Blog — a stack of posts, the front one still being written
+ * ------------------------------------------------------------------ */
+
+const blog: SceneBuilder = ({ scene, reduceMotion }) => {
+ const cards = [
+ { offset: vec(30, 28), opacity: 0.3 },
+ { offset: vec(15, 14), opacity: 0.6 },
+ { offset: vec(0, 0), opacity: 1 },
+ ].map(({ offset, opacity }) => {
+ const card = new THREE.Group();
+ card.add(box(250, 290, 10, INK, opacity));
+ card.position.set(offset.x, offset.y, 0);
+ scene.add(card);
+ return card;
+ });
+ const front = cards[2];
+
+ const title = bar(150, 10, INK, 0.85);
+ title.position.set(-30, 112, 0);
+ front.add(title);
+ const date = bar(60, 5, MUTED, 0.8);
+ date.position.set(-75, 94, 0);
+ front.add(date);
+ [BRAND.secondary, BRAND.primary].forEach((color, i) => {
+ const chip = mesh(roundedRectOutlineGeometry(44, 16, 3, 2), color, 0.9);
+ chip.position.set(-83 + i * 50, 72, 0);
+ front.add(chip);
+ });
+
+ const widths = [190, 170, 200, 150, 180, 120];
+ const lines = widths.map((width, i) => {
+ const row = bar(width, 5, INK, 0.35);
+ row.position.set(-105 + width / 2, 44 - i * 15, 0);
+ row.userData.width = width;
+ row.userData.left = -105;
+ front.add(row);
+ return row;
+ });
+
+ const codeBlock = mesh(roundedRectGeometry(206, 66, 4), INK, 0.08);
+ codeBlock.position.set(0, -98, 0);
+ front.add(codeBlock);
+ const codeLines = [
+ { width: 90, color: BRAND.secondary },
+ { width: 140, color: BRAND.primary },
+ { width: 110, color: INK },
+ ].map(({ width, color }, i) => {
+ const row = bar(width, 4, color, color === INK ? 0.65 : 0.9);
+ row.position.set(-90 + width / 2, -80 - i * 17, 0.1);
+ row.userData.width = width;
+ row.userData.left = -90;
+ front.add(row);
+ return row;
+ });
+ const cursor = mesh(roundedRectGeometry(2.5, 11, 1), INK, 0.9);
+ cursor.position.z = 0.2;
+ front.add(cursor);
+
+ return (time, progress) => {
+ cards.forEach((card, i) => {
+ const amount = reveal(progress, i * 0.1, 0.25);
+ setGroupOpacity(card, amount);
+ card.position.y = [28, 14, 0][i] - (1 - amount) * 40;
+ });
+
+ const typed = reveal(progress, 0.3, 0.5);
+ const all = [...lines, ...codeLines];
+ all.forEach((row, i) => {
+ const own = smoothstep(typed * all.length - i);
+ const width = row.userData.width as number;
+ row.scale.x = Math.max(0.01, own);
+ // Keep the left edge pinned while the line grows.
+ row.position.x = (row.userData.left as number) + (width * own) / 2;
+ });
+
+ const last = codeLines[codeLines.length - 1];
+ cursor.position.set(last.position.x + (last.userData.width as number) * last.scale.x / 2 + 5, last.position.y, 0.2);
+ setGroupOpacity(cursor, reduceMotion ? 1 : (Math.sin(time * 6) > 0 ? 1 : 0));
+ };
+};
+
+const SCENES: Record
= { workflow, ownership, blog };
+
+/** How far the section has come into view, 0 as it enters and 1 once settled. */
+function sectionProgress(section: Element): number {
+ const rect = section.getBoundingClientRect();
+ const vh = window.innerHeight;
+ return Math.min(1, Math.max(0, (vh * 0.92 - rect.top) / (vh * 0.5)));
+}
+
+export function initSectionScene(canvas: HTMLCanvasElement, name: SceneName, section: Element): void {
+ const builder = SCENES[name];
+ if (!builder) throw new Error(`Unknown section scene: ${name}`);
+
+ mountScene({
+ canvas,
+ designHeight: DESIGN_HEIGHT,
+ watch: section,
+ build(view) {
+ const update = builder(view);
+ return (time) => update(time, view.reduceMotion ? 1 : sectionProgress(section));
+ },
+ });
+}
diff --git a/src/scripts/shapes.ts b/src/scripts/shapes.ts
new file mode 100644
index 0000000..a43530a
--- /dev/null
+++ b/src/scripts/shapes.ts
@@ -0,0 +1,166 @@
+import * as THREE from 'three';
+
+/**
+ * 2D shape helpers shared by the page's Three.js scenes.
+ *
+ * Both scenes draw flat, screen-space artwork with an orthographic camera, so
+ * everything here works in the XY plane and returns geometry that can be dropped
+ * straight into a `THREE.Mesh` with a `MeshBasicMaterial`.
+ *
+ * Outlines are built as ring geometry (a shape with a smaller copy punched out as
+ * a hole) rather than `THREE.Line`, because WebGL ignores
+ * `LineBasicMaterial.linewidth` — a ring is the only way to get a stroke whose
+ * thickness we actually control.
+ */
+
+/** Traces a rounded rectangle centred on the origin onto a Shape or a Path. */
+function traceRoundedRect(
+ target: T,
+ width: number,
+ height: number,
+ radius: number
+): T {
+ const hw = width / 2;
+ const hh = height / 2;
+ const r = Math.max(0, Math.min(radius, hw, hh));
+ target.moveTo(-hw + r, -hh);
+ target.lineTo(hw - r, -hh);
+ target.quadraticCurveTo(hw, -hh, hw, -hh + r);
+ target.lineTo(hw, hh - r);
+ target.quadraticCurveTo(hw, hh, hw - r, hh);
+ target.lineTo(-hw + r, hh);
+ target.quadraticCurveTo(-hw, hh, -hw, hh - r);
+ target.lineTo(-hw, -hh + r);
+ target.quadraticCurveTo(-hw, -hh, -hw + r, -hh);
+ return target;
+}
+
+/** A filled rounded rectangle centred on the origin. */
+export function roundedRectShape(width: number, height: number, radius: number): THREE.Shape {
+ return traceRoundedRect(new THREE.Shape(), width, height, radius);
+}
+
+/** Filled geometry for a rounded rectangle centred on the origin. */
+export function roundedRectGeometry(
+ width: number,
+ height: number,
+ radius: number
+): THREE.ShapeGeometry {
+ return new THREE.ShapeGeometry(roundedRectShape(width, height, radius));
+}
+
+/**
+ * A rounded-rectangle *outline* of the given stroke thickness, drawn inside the
+ * width/height footprint so the outer edge stays where you asked for it.
+ */
+export function roundedRectOutlineGeometry(
+ width: number,
+ height: number,
+ radius: number,
+ thickness: number
+): THREE.ShapeGeometry {
+ const shape = roundedRectShape(width, height, radius);
+ shape.holes.push(
+ traceRoundedRect(
+ new THREE.Path(),
+ Math.max(0, width - thickness * 2),
+ Math.max(0, height - thickness * 2),
+ radius - thickness
+ )
+ );
+ return new THREE.ShapeGeometry(shape);
+}
+
+/** Appends a flat ribbon of the given thickness along `points` to the buffers. */
+function appendStroke(
+ points: THREE.Vector2[],
+ thickness: number,
+ positions: number[],
+ indices: number[]
+): void {
+ if (points.length < 2) return;
+
+ const half = thickness / 2;
+ const base = positions.length / 3;
+ const direction = new THREE.Vector2();
+
+ for (let i = 0; i < points.length; i++) {
+ // Averaging the neighbouring segment directions gives a mitre join, which is
+ // plenty for the shallow bends these paths actually take.
+ const prev = points[i - 1] ?? points[i];
+ const next = points[i + 1] ?? points[i];
+ direction.subVectors(next, prev);
+ if (direction.lengthSq() === 0) direction.set(1, 0);
+ direction.normalize();
+ const nx = -direction.y * half;
+ const ny = direction.x * half;
+ positions.push(points[i].x + nx, points[i].y + ny, 0);
+ positions.push(points[i].x - nx, points[i].y - ny, 0);
+ }
+
+ for (let i = 0; i < points.length - 1; i++) {
+ const a = base + i * 2;
+ indices.push(a, a + 1, a + 2, a + 1, a + 3, a + 2);
+ }
+}
+
+function buildGeometry(positions: number[], indices: number[]): THREE.BufferGeometry {
+ const geometry = new THREE.BufferGeometry();
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
+ geometry.setIndex(indices);
+ return geometry;
+}
+
+/** A solid stroked polyline of the given thickness. */
+export function strokeGeometry(points: THREE.Vector2[], thickness: number): THREE.BufferGeometry {
+ const positions: number[] = [];
+ const indices: number[] = [];
+ appendStroke(points, thickness, positions, indices);
+ return buildGeometry(positions, indices);
+}
+
+/**
+ * A dashed stroked polyline, returned as a single geometry so a whole dashed
+ * connector costs one draw call.
+ */
+export function dashedStrokeGeometry(
+ points: THREE.Vector2[],
+ thickness: number,
+ dashLength: number,
+ gapLength: number
+): THREE.BufferGeometry {
+ const positions: number[] = [];
+ const indices: number[] = [];
+ const period = dashLength + gapLength;
+ let travelled = 0;
+ let run: THREE.Vector2[] = [];
+
+ for (let i = 0; i < points.length - 1; i++) {
+ const a = points[i];
+ const b = points[i + 1];
+ const segmentLength = a.distanceTo(b);
+ if (segmentLength === 0) continue;
+
+ let walked = 0;
+ while (walked < segmentLength) {
+ const phase = (travelled + walked) % period;
+ const inDash = phase < dashLength;
+ const remaining = inDash ? dashLength - phase : period - phase;
+ const step = Math.min(remaining, segmentLength - walked);
+ if (step <= 1e-6) break;
+
+ if (inDash) {
+ if (run.length === 0) run.push(a.clone().lerp(b, walked / segmentLength));
+ run.push(a.clone().lerp(b, (walked + step) / segmentLength));
+ } else {
+ appendStroke(run, thickness, positions, indices);
+ run = [];
+ }
+ walked += step;
+ }
+ travelled += segmentLength;
+ }
+ appendStroke(run, thickness, positions, indices);
+
+ return buildGeometry(positions, indices);
+}
diff --git a/src/scripts/stackStory.ts b/src/scripts/stackStory.ts
new file mode 100644
index 0000000..2906d6b
--- /dev/null
+++ b/src/scripts/stackStory.ts
@@ -0,0 +1,714 @@
+import * as THREE from 'three';
+import { roundedRectGeometry, roundedRectOutlineGeometry, strokeGeometry } from './shapes';
+import {
+ BRAND,
+ ON_DARK,
+ TEXTURE_SCALE,
+ createLabel,
+ createLink,
+ labelWidth,
+ mesh,
+ mono,
+ mountScene,
+ roundedPath,
+ setGroupOpacity,
+ smoothstep,
+ vec,
+} from './sceneKit';
+
+/**
+ * The "how a Y-A-S build fits together" scroll story.
+ *
+ * Four acts, driven entirely by how far the section has scrolled: a web app on a
+ * monitor, the same app on a phone, and the API and database underneath them.
+ * Every device except the phone is drawn as an outline, so the whole thing reads
+ * as a diagram of the parts working together rather than a product shot.
+ *
+ * The scene lives in a fixed "design space" 1000 units tall with the origin in the
+ * middle and Y pointing up, so device geometry is written once and only the camera
+ * frustum changes on resize.
+ */
+
+const DESIGN_HEIGHT = 1000;
+
+const COLORS = {
+ outline: ON_DARK.ink,
+ muted: ON_DARK.muted,
+ screen: BRAND.darkText,
+ primary: BRAND.primary,
+ secondary: BRAND.secondary,
+};
+
+
+/** One pose in a device's keyframe track. Positions are fractions of the view. */
+interface Keyframe {
+ at: number;
+ x: number;
+ y: number;
+ scale: number;
+ opacity: number;
+}
+
+/** Numbers shown on the device screens, nudged by the ticker so they feel live. */
+interface AppState {
+ requests: number;
+ latency: number;
+ uptime: number;
+ series: number[];
+}
+
+function sampleTrack(track: Keyframe[], progress: number): Keyframe {
+ if (progress <= track[0].at) return track[0];
+ const last = track[track.length - 1];
+ if (progress >= last.at) return last;
+
+ for (let i = 0; i < track.length - 1; i++) {
+ const a = track[i];
+ const b = track[i + 1];
+ if (progress > b.at) continue;
+ const t = smoothstep((progress - a.at) / (b.at - a.at));
+ return {
+ at: progress,
+ x: a.x + (b.x - a.x) * t,
+ y: a.y + (b.y - a.y) * t,
+ scale: a.scale + (b.scale - a.scale) * t,
+ opacity: a.opacity + (b.opacity - a.opacity) * t,
+ };
+ }
+ return last;
+}
+
+/* ------------------------------------------------------------------ *
+ * Canvas-backed textures: screens and labels
+ * ------------------------------------------------------------------ */
+
+interface Screen {
+ mesh: THREE.Mesh;
+ redraw(state: AppState): void;
+}
+
+function createScreen(
+ width: number,
+ height: number,
+ radius: number,
+ paint: (ctx: CanvasRenderingContext2D, w: number, h: number, state: AppState) => void
+): Screen {
+ const canvas = document.createElement('canvas');
+ canvas.width = Math.round(width * TEXTURE_SCALE);
+ canvas.height = Math.round(height * TEXTURE_SCALE);
+ const ctx = canvas.getContext('2d')!;
+
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.colorSpace = THREE.SRGBColorSpace;
+
+ const material = new THREE.MeshBasicMaterial({ map: texture, transparent: true, depthWrite: false });
+ const screenMesh = new THREE.Mesh(new THREE.PlaneGeometry(width, height), material);
+ screenMesh.userData.baseOpacity = 1;
+
+ return {
+ mesh: screenMesh,
+ redraw(state: AppState) {
+ ctx.save();
+ ctx.setTransform(TEXTURE_SCALE, 0, 0, TEXTURE_SCALE, 0, 0);
+ ctx.clearRect(0, 0, width, height);
+ roundedPath(ctx, 0, 0, width, height, radius);
+ ctx.clip();
+ paint(ctx, width, height, state);
+ ctx.restore();
+ texture.needsUpdate = true;
+ },
+ };
+}
+
+/* ------------------------------------------------------------------ *
+ * Screen contents
+ * ------------------------------------------------------------------ */
+
+function paintSparkline(
+ ctx: CanvasRenderingContext2D,
+ series: number[],
+ x: number,
+ y: number,
+ w: number,
+ h: number,
+ color: string
+): void {
+ const max = Math.max(...series, 1);
+ ctx.beginPath();
+ series.forEach((value, i) => {
+ const px = x + (i / (series.length - 1)) * w;
+ const py = y + h - (value / max) * h;
+ if (i === 0) ctx.moveTo(px, py);
+ else ctx.lineTo(px, py);
+ });
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 2;
+ ctx.stroke();
+
+ ctx.lineTo(x + w, y + h);
+ ctx.lineTo(x, y + h);
+ ctx.closePath();
+ ctx.fillStyle = `${color}22`;
+ ctx.fill();
+}
+
+/** The desktop web app: nav rail, KPI tiles, a chart and a short table. */
+function paintDesktopApp(
+ ctx: CanvasRenderingContext2D,
+ w: number,
+ h: number,
+ state: AppState
+): void {
+ ctx.fillStyle = COLORS.screen;
+ ctx.fillRect(0, 0, w, h);
+
+ // Top bar
+ ctx.fillStyle = 'rgba(229,226,225,0.06)';
+ ctx.fillRect(0, 0, w, 22);
+ ctx.fillStyle = COLORS.outline;
+ ctx.font = mono(11, 700);
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'middle';
+ ctx.fillText('Y-A-S', 10, 11);
+ ctx.fillStyle = COLORS.muted;
+ ctx.font = mono(7);
+ ['Dashboard', 'Jobs', 'Data'].forEach((item, i) => {
+ ctx.fillText(item, 54 + i * 62, 11);
+ });
+ ctx.fillStyle = COLORS.primary;
+ ctx.beginPath();
+ ctx.arc(w - 12, 11, 3.5, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Left rail
+ ctx.fillStyle = 'rgba(229,226,225,0.04)';
+ ctx.fillRect(0, 22, 26, h - 22);
+ for (let i = 0; i < 4; i++) {
+ ctx.fillStyle = i === 0 ? COLORS.primary : 'rgba(127,143,164,0.5)';
+ ctx.fillRect(8, 36 + i * 16, 10, 3);
+ }
+
+ // KPI tiles
+ const tiles: Array<[string, string, string]> = [
+ ['REQ / MIN', String(Math.round(state.requests)), COLORS.primary],
+ ['P95', `${state.latency.toFixed(0)}ms`, COLORS.secondary],
+ ['UPTIME', `${state.uptime.toFixed(2)}%`, COLORS.outline],
+ ];
+ const tileW = (w - 26 - 32) / 3;
+ tiles.forEach(([label, value, color], i) => {
+ const x = 34 + i * (tileW + 8);
+ ctx.fillStyle = 'rgba(229,226,225,0.05)';
+ roundedPath(ctx, x, 32, tileW, 34, 3);
+ ctx.fill();
+ ctx.fillStyle = COLORS.muted;
+ ctx.font = mono(6);
+ ctx.fillText(label, x + 6, 41);
+ ctx.fillStyle = color;
+ ctx.font = mono(14, 700);
+ ctx.fillText(value, x + 6, 56);
+ });
+
+ // Chart
+ ctx.fillStyle = 'rgba(229,226,225,0.05)';
+ roundedPath(ctx, 34, 72, w - 42, h - 84, 3);
+ ctx.fill();
+ ctx.fillStyle = COLORS.muted;
+ ctx.font = mono(6);
+ ctx.fillText('THROUGHPUT', 40, 80);
+ paintSparkline(ctx, state.series, 40, 86, w - 54, h - 104, COLORS.primary);
+}
+
+/** The same app on the phone: header, one headline number, chart and list rows. */
+function paintPhoneApp(
+ ctx: CanvasRenderingContext2D,
+ w: number,
+ h: number,
+ state: AppState
+): void {
+ ctx.fillStyle = COLORS.screen;
+ ctx.fillRect(0, 0, w, h);
+
+ ctx.fillStyle = 'rgba(229,226,225,0.06)';
+ ctx.fillRect(0, 0, w, 26);
+ ctx.fillStyle = COLORS.outline;
+ ctx.font = mono(9, 700);
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'middle';
+ ctx.fillText('Y-A-S', 10, 16);
+ ctx.fillStyle = COLORS.primary;
+ ctx.beginPath();
+ ctx.arc(w - 12, 16, 3, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.fillStyle = COLORS.muted;
+ ctx.font = mono(6);
+ ctx.fillText('REQ / MIN', 10, 40);
+ ctx.fillStyle = COLORS.primary;
+ ctx.font = mono(22, 700);
+ ctx.fillText(String(Math.round(state.requests)), 10, 58);
+
+ paintSparkline(ctx, state.series, 10, 72, w - 20, 40, COLORS.secondary);
+
+ const rows: Array<[string, string]> = [
+ ['api', 'ok'],
+ ['worker', 'ok'],
+ ['db', 'ok'],
+ ];
+ rows.forEach(([name, status], i) => {
+ const y = 124 + i * 18;
+ ctx.fillStyle = 'rgba(229,226,225,0.05)';
+ roundedPath(ctx, 10, y, w - 20, 14, 3);
+ ctx.fill();
+ ctx.fillStyle = COLORS.muted;
+ ctx.font = mono(6);
+ ctx.fillText(name, 15, y + 7);
+ ctx.fillStyle = COLORS.primary;
+ ctx.textAlign = 'right';
+ ctx.fillText(status, w - 15, y + 7);
+ ctx.textAlign = 'left';
+ });
+}
+
+/* ------------------------------------------------------------------ *
+ * Device builders — outlines, except the phone
+ * ------------------------------------------------------------------ */
+
+const OUTLINE_WEIGHT = 5;
+
+function buildMonitor(): { group: THREE.Group; screen: Screen } {
+ const group = new THREE.Group();
+
+ const screen = createScreen(296, 176, 6, paintDesktopApp);
+ group.add(screen.mesh);
+
+ group.add(mesh(roundedRectOutlineGeometry(320, 200, 14, OUTLINE_WEIGHT), COLORS.outline));
+
+ const neck = mesh(strokeGeometry([vec(0, -100), vec(0, -134)], 20), COLORS.outline, 0.85);
+ group.add(neck);
+
+ const base = mesh(roundedRectOutlineGeometry(150, 14, 7, 4), COLORS.outline, 0.85);
+ base.position.y = -144;
+ group.add(base);
+
+ const label = createLabel('Web app · Blazor', 20, COLORS.muted);
+ label.position.y = 142;
+ group.add(label);
+ group.userData.label = label;
+
+ return { group, screen };
+}
+
+function buildPhone(): { group: THREE.Group; screen: Screen } {
+ const group = new THREE.Group();
+
+ // The one solid device in the story: a filled body rather than an outline.
+ const body = mesh(roundedRectGeometry(124, 244, 24), COLORS.outline);
+ group.add(body);
+
+ const screen = createScreen(106, 208, 12, paintPhoneApp);
+ screen.mesh.position.z = 0.1;
+ group.add(screen.mesh);
+
+ const speaker = mesh(roundedRectGeometry(30, 5, 2.5), COLORS.screen, 0.55);
+ speaker.position.set(0, 114, 0.2);
+ group.add(speaker);
+
+ const indicator = mesh(roundedRectGeometry(38, 4, 2), COLORS.screen, 0.45);
+ indicator.position.set(0, -114, 0.2);
+ group.add(indicator);
+
+ const label = createLabel('Mobile · same codebase', 20, COLORS.muted);
+ label.position.y = 164;
+ group.add(label);
+ group.userData.label = label;
+
+ return { group, screen };
+}
+
+function buildApiServer(): THREE.Group {
+ const group = new THREE.Group();
+
+ group.add(mesh(roundedRectOutlineGeometry(210, 140, 10, OUTLINE_WEIGHT), COLORS.outline));
+
+ // Rack units, each with a status LED and a couple of vent slots.
+ for (let i = 0; i < 3; i++) {
+ const y = 42 - i * 42;
+ if (i > 0) {
+ group.add(mesh(strokeGeometry([vec(-105, y + 21), vec(105, y + 21)], 3), COLORS.outline, 0.4));
+ }
+ const led = mesh(new THREE.CircleGeometry(5, 16), i === 1 ? COLORS.secondary : COLORS.primary);
+ led.position.set(-78, y, 0.1);
+ group.add(led);
+ for (let slot = 0; slot < 3; slot++) {
+ const vent = mesh(roundedRectGeometry(34, 5, 2.5), COLORS.outline, 0.35);
+ vent.position.set(10 + slot * 42, y, 0.1);
+ group.add(vent);
+ }
+ }
+
+ // Positioned by the scene, which knows whether there is room beside the rack.
+ const label = createLabel('API · ASP.NET Core', 20, COLORS.muted);
+ group.add(label);
+ group.userData.label = label;
+
+ return group;
+}
+
+function buildDatabase(): THREE.Group {
+ const group = new THREE.Group();
+ const rx = 86;
+ const bodyHeight = 96;
+ const squash = 0.42;
+
+ function ellipseRing(y: number, opacity: number, half: boolean): THREE.Mesh {
+ const ring = mesh(
+ new THREE.RingGeometry(rx - OUTLINE_WEIGHT, rx, 64, 1, half ? Math.PI : 0, half ? Math.PI : Math.PI * 2),
+ COLORS.outline,
+ opacity
+ );
+ ring.scale.y = squash;
+ ring.position.y = y;
+ return ring;
+ }
+
+ group.add(ellipseRing(bodyHeight / 2, 1, false));
+ group.add(ellipseRing(-bodyHeight / 2, 1, true));
+ group.add(ellipseRing(0, 0.35, true));
+
+ const half = bodyHeight / 2;
+ group.add(mesh(strokeGeometry([vec(-rx + OUTLINE_WEIGHT / 2, half), vec(-rx + OUTLINE_WEIGHT / 2, -half)], OUTLINE_WEIGHT), COLORS.outline));
+ group.add(mesh(strokeGeometry([vec(rx - OUTLINE_WEIGHT / 2, half), vec(rx - OUTLINE_WEIGHT / 2, -half)], OUTLINE_WEIGHT), COLORS.outline));
+
+ const label = createLabel('Data · PostgreSQL', 20, COLORS.muted);
+ label.position.y = -116;
+ group.add(label);
+ group.userData.label = label;
+
+ return group;
+}
+
+/* ------------------------------------------------------------------ *
+ * Act 1 backdrop: drifting grid and floating code windows
+ * ------------------------------------------------------------------ */
+
+function createDotGrid(): THREE.Mesh {
+ const canvas = document.createElement('canvas');
+ canvas.width = 64;
+ canvas.height = 64;
+ const ctx = canvas.getContext('2d')!;
+ ctx.fillStyle = 'rgba(229,226,225,0.5)';
+ ctx.beginPath();
+ ctx.arc(32, 32, 2, 0, Math.PI * 2);
+ ctx.fill();
+
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.wrapS = THREE.RepeatWrapping;
+ texture.wrapT = THREE.RepeatWrapping;
+ texture.colorSpace = THREE.SRGBColorSpace;
+
+ const grid = new THREE.Mesh(
+ new THREE.PlaneGeometry(1, 1),
+ new THREE.MeshBasicMaterial({ map: texture, transparent: true, opacity: 0.16, depthWrite: false })
+ );
+ grid.userData.baseOpacity = 0.16;
+ grid.userData.texture = texture;
+ return grid;
+}
+
+/** An outlined "code window" card — the ambience that drifts past in act 1. */
+function buildCodeWindow(width: number, height: number): THREE.Group {
+ const group = new THREE.Group();
+ group.add(mesh(roundedRectOutlineGeometry(width, height, 8, 3), COLORS.outline, 0.5));
+ group.add(
+ mesh(strokeGeometry([vec(-width / 2, height / 2 - 22), vec(width / 2, height / 2 - 22)], 2), COLORS.outline, 0.35)
+ );
+ for (let i = 0; i < 3; i++) {
+ const dot = mesh(new THREE.CircleGeometry(3, 12), COLORS.outline, 0.45);
+ dot.position.set(-width / 2 + 14 + i * 11, height / 2 - 11, 0.1);
+ group.add(dot);
+ }
+ const lineWidths = [0.7, 0.45, 0.85, 0.35];
+ lineWidths.forEach((fraction, i) => {
+ const row = mesh(roundedRectGeometry((width - 32) * fraction, 4, 2), COLORS.outline, 0.3);
+ row.position.set(-width / 2 + 16 + ((width - 32) * fraction) / 2, height / 2 - 42 - i * 14, 0.1);
+ group.add(row);
+ });
+ return group;
+}
+
+/* ------------------------------------------------------------------ *
+ * Keyframe tracks
+ * ------------------------------------------------------------------ */
+
+const TRACKS: Record = {
+ monitor: [
+ { at: 0.0, x: 0, y: 0.02, scale: 1.0, opacity: 1 },
+ { at: 0.2, x: 0, y: 0.02, scale: 1.0, opacity: 1 },
+ { at: 0.4, x: 0, y: 0.04, scale: 1.85, opacity: 1 },
+ { at: 0.52, x: 0, y: 0.04, scale: 1.85, opacity: 1 },
+ { at: 0.68, x: -0.2, y: 0.14, scale: 1.0, opacity: 1 },
+ { at: 0.8, x: -0.2, y: 0.14, scale: 1.0, opacity: 1 },
+ { at: 1.0, x: -0.21, y: 0.2, scale: 0.86, opacity: 1 },
+ ],
+ phone: [
+ { at: 0.52, x: 0.52, y: 0.06, scale: 1.0, opacity: 0 },
+ { at: 0.68, x: 0.24, y: 0.1, scale: 1.0, opacity: 1 },
+ { at: 0.8, x: 0.24, y: 0.1, scale: 1.0, opacity: 1 },
+ { at: 1.0, x: 0.25, y: 0.19, scale: 0.86, opacity: 1 },
+ ],
+ server: [
+ { at: 0.78, x: 0.04, y: -0.42, scale: 0.9, opacity: 0 },
+ { at: 0.9, x: 0.04, y: -0.1, scale: 1.0, opacity: 1 },
+ { at: 1.0, x: 0.04, y: -0.08, scale: 1.0, opacity: 1 },
+ ],
+ database: [
+ { at: 0.84, x: 0.04, y: -0.58, scale: 0.9, opacity: 0 },
+ { at: 0.96, x: 0.04, y: -0.37, scale: 1.0, opacity: 1 },
+ { at: 1.0, x: 0.04, y: -0.37, scale: 1.0, opacity: 1 },
+ ],
+ ambience: [
+ { at: 0.0, x: 0, y: 0, scale: 1, opacity: 1 },
+ { at: 0.18, x: 0, y: 0, scale: 1, opacity: 1 },
+ { at: 0.32, x: 0, y: -0.12, scale: 1, opacity: 0 },
+ ],
+};
+
+const LINK_FADES = {
+ sync: [
+ { at: 0.66, opacity: 0 },
+ { at: 0.74, opacity: 1 },
+ ],
+ api: [
+ { at: 0.88, opacity: 0 },
+ { at: 0.95, opacity: 1 },
+ ],
+ data: [
+ { at: 0.94, opacity: 0 },
+ { at: 1.0, opacity: 1 },
+ ],
+};
+
+function fadeAt(steps: Array<{ at: number; opacity: number }>, progress: number): number {
+ const [start, end] = steps;
+ return smoothstep((progress - start.at) / (end.at - start.at)) * (end.opacity - start.opacity) + start.opacity;
+}
+
+/* ------------------------------------------------------------------ *
+ * Entry point
+ * ------------------------------------------------------------------ */
+
+export interface StackStoryOptions {
+ canvas: HTMLCanvasElement;
+ /** The tall element whose scroll position drives the story. */
+ runway: HTMLElement;
+ /** One caption element per act, in order. */
+ captions: HTMLElement[];
+}
+
+export function initStackStory({ canvas, runway, captions }: StackStoryOptions): void {
+ let viewWidth = DESIGN_HEIGHT;
+ let viewHeight = DESIGN_HEIGHT;
+ let unit = DESIGN_HEIGHT;
+ let onViewResize: () => void = () => {};
+
+ mountScene({
+ canvas,
+ designHeight: DESIGN_HEIGHT,
+ watch: runway,
+ onResize(view) {
+ viewWidth = view.viewWidth;
+ viewHeight = view.viewHeight;
+ unit = Math.min(viewWidth, viewHeight);
+ onViewResize();
+ },
+ build(view) {
+ const { scene, reduceMotion } = view;
+
+ const ambience = new THREE.Group();
+ const grid = createDotGrid();
+ ambience.add(grid);
+
+ const deskLine = mesh(strokeGeometry([vec(-300, 0), vec(300, 0)], 3), COLORS.outline, 0.4);
+ ambience.add(deskLine);
+
+ const windows = [
+ { node: buildCodeWindow(210, 150), depth: 0.35, x: -0.34, y: 0.24 },
+ { node: buildCodeWindow(170, 120), depth: 0.6, x: 0.3, y: 0.28 },
+ { node: buildCodeWindow(140, 100), depth: 0.9, x: 0.42, y: -0.14 },
+ { node: buildCodeWindow(190, 130), depth: 0.5, x: -0.42, y: -0.18 },
+ ];
+ windows.forEach(({ node }) => ambience.add(node));
+ scene.add(ambience);
+
+ const links = {
+ sync: createLink('live sync · SignalR', COLORS.primary),
+ apiToMonitor: createLink('HTTPS', COLORS.secondary, { packets: 2, labelAt: 0.45, labelOffset: -30 }),
+ apiToPhone: createLink('HTTPS', COLORS.secondary, { packets: 2, labelAt: 0.45, labelOffset: 30 }),
+ data: createLink('EF Core', COLORS.outline, { packets: 2, labelAt: 0.32, labelOffset: 48 }),
+ };
+ Object.values(links).forEach((link) => scene.add(link.group));
+
+ const monitor = buildMonitor();
+ const phone = buildPhone();
+ const server = buildApiServer();
+ const database = buildDatabase();
+ scene.add(monitor.group, phone.group, server, database);
+
+ const devices: Record = {
+ monitor: monitor.group,
+ phone: phone.group,
+ server,
+ database,
+ };
+
+ const state: AppState = {
+ requests: 1840,
+ latency: 42,
+ uptime: 99.98,
+ series: Array.from({ length: 26 }, (_, i) => 40 + Math.sin(i * 0.6) * 18 + Math.random() * 10),
+ };
+
+ function redrawScreens(): void {
+ monitor.screen.redraw(state);
+ phone.screen.redraw(state);
+ }
+ redrawScreens();
+
+ function isPortrait(): boolean {
+ return viewWidth < viewHeight;
+ }
+
+ onViewResize = () => {
+ grid.scale.set(viewWidth * 1.4, viewHeight * 1.4, 1);
+ const gridTexture = grid.userData.texture as THREE.Texture;
+ gridTexture.repeat.set((viewWidth * 1.4) / 64, (viewHeight * 1.4) / 64);
+
+ // Portrait has no room beside the rack or along the links, so the rack's
+ // caption drops underneath it and the link captions step aside entirely.
+ const portrait = isPortrait();
+ const serverLabel = server.userData.label as THREE.Mesh;
+ serverLabel.position.set(
+ portrait ? 0 : 105 + labelWidth(serverLabel) / 2 + 14,
+ portrait ? -106 : 0,
+ 0
+ );
+ Object.values(links).forEach((link) => link.showLabel(!portrait));
+ };
+
+ /** How far the runway has scrolled through the sticky stage, 0 to 1. */
+ function scrollProgress(): number {
+ const rect = runway.getBoundingClientRect();
+ const travel = rect.height - window.innerHeight;
+ if (travel <= 0) return 0;
+ return Math.min(1, Math.max(0, -rect.top / travel));
+ }
+
+ /**
+ * Maps a track's vertical fraction onto the view. Portrait viewports pull
+ * the diagram together and lift it, so it clears the caption card.
+ */
+ function worldY(fraction: number): number {
+ const portrait = isPortrait();
+ return (fraction * (portrait ? 0.74 : 1) + (portrait ? 0.16 : 0.04)) * viewHeight;
+ }
+
+ /** A point on a device, given in that device's own units. */
+ function anchorOf(group: THREE.Group, dx: number, dy: number): THREE.Vector2 {
+ return vec(group.position.x + dx * group.scale.x, group.position.y + dy * group.scale.y);
+ }
+
+ function applyPose(name: string, progress: number): void {
+ const pose = sampleTrack(TRACKS[name], progress);
+ const group = devices[name];
+ group.position.set(pose.x * viewWidth, worldY(pose.y), 0);
+ const unitScale = unit / DESIGN_HEIGHT;
+ group.scale.setScalar(pose.scale * unitScale);
+ // Captions hold a constant size whether the device is parked small or
+ // filling the stage, and never shrink past legibility on narrow viewports.
+ const label = group.userData.label as THREE.Mesh | undefined;
+ if (label) label.scale.setScalar(Math.max(unitScale, 0.7) / (pose.scale * unitScale));
+ setGroupOpacity(group, pose.opacity);
+ }
+
+ let lastTick = 0;
+
+ return (time: number) => {
+ // The numbers are decoration, so they only need to move a few times a second.
+ if (!reduceMotion && time - lastTick > 0.2) {
+ lastTick = time;
+ state.requests += (Math.random() - 0.45) * 40;
+ state.requests = Math.min(2600, Math.max(900, state.requests));
+ state.latency = Math.min(90, Math.max(24, state.latency + (Math.random() - 0.5) * 6));
+ const next = state.series[state.series.length - 1] + (Math.random() - 0.5) * 14;
+ state.series.push(Math.min(95, Math.max(12, next)));
+ state.series.shift();
+ redrawScreens();
+ }
+
+ const progress = scrollProgress();
+
+ applyPose('monitor', progress);
+ applyPose('phone', progress);
+ applyPose('server', progress);
+ applyPose('database', progress);
+
+ const ambiencePose = sampleTrack(TRACKS.ambience, progress);
+ setGroupOpacity(ambience, ambiencePose.opacity);
+ if (ambience.visible) {
+ const drift = reduceMotion ? 0 : time * 0.02;
+ const gridTexture = grid.userData.texture as THREE.Texture;
+ gridTexture.offset.set(drift * 0.6, -drift * 0.2);
+ grid.position.y = ambiencePose.y * viewHeight;
+ deskLine.position.y = worldY(-0.2 + ambiencePose.y);
+ deskLine.scale.x = unit / DESIGN_HEIGHT;
+ windows.forEach(({ node, depth, x, y }) => {
+ // Wrap each card across the view so the drift never runs out of scenery.
+ const span = viewWidth + 400;
+ const travelled = (x * viewWidth + span / 2 - drift * 60 * depth) % span;
+ node.position.set((travelled + span) % span - span / 2, worldY(y + ambiencePose.y), 0);
+ node.scale.setScalar((unit / DESIGN_HEIGHT) * (0.6 + depth * 0.5));
+ });
+ }
+
+ links.sync.place(
+ anchorOf(devices.monitor, 175, 0),
+ anchorOf(devices.phone, -70, 0),
+ fadeAt(LINK_FADES.sync, progress)
+ );
+ const apiOpacity = fadeAt(LINK_FADES.api, progress);
+ links.apiToMonitor.place(
+ anchorOf(devices.server, -60, 80),
+ anchorOf(devices.monitor, 40, -160),
+ apiOpacity
+ );
+ links.apiToPhone.place(
+ anchorOf(devices.server, 60, 80),
+ anchorOf(devices.phone, -20, -130),
+ apiOpacity
+ );
+ links.data.place(
+ anchorOf(devices.database, 0, 70),
+ anchorOf(devices.server, 0, -75),
+ fadeAt(LINK_FADES.data, progress)
+ );
+
+ // Frozen at t=0 the packets still space themselves along each link, so
+ // reduced motion gets a still diagram rather than dots piled at the origin.
+ Object.values(links).forEach((link) => link.tick(reduceMotion ? 0 : time));
+
+ // Captions cross-fade with their act; act boundaries are the quarter marks.
+ // The first and last acts hold rather than fade, so the section never opens
+ // or closes on an empty stage.
+ captions.forEach((caption, i) => {
+ const start = i * 0.25;
+ const fadeIn = i === 0 ? 1 : smoothstep((progress - start + 0.04) / 0.08);
+ const fadeOut =
+ i === captions.length - 1 ? 1 : 1 - smoothstep((progress - start - 0.21) / 0.08);
+ const opacity = fadeIn * fadeOut;
+ caption.style.opacity = String(opacity);
+ caption.style.transform = `translateY(${(1 - opacity) * 16}px)`;
+ });
+ };
+ },
+ });
+}
diff --git a/src/styles/global.css b/src/styles/global.css
index 8963f63..1522fed 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -1691,11 +1691,6 @@ h1, h2, h3, h4, h5, h6 {
/* ===== BLOG CTA SECTION (inside white panel) ===== */
-.blog-cta-section {
- padding-top: 3rem;
- margin-top: 0;
-}
-
.blog-cta-heading {
font-family: 'Rubik Mono One', monospace;
font-size: 2rem;