From 8a29cbf301d1213d85cc4dfb804877de48ffbd23 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 02:22:27 +0000 Subject: [PATCH 1/4] Add scroll-driven Three.js story of the Y-A-S stack to the homepage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a second Three.js piece below the hero: a four-act, scroll-driven diagram of how one of our builds fits together — a Blazor web app on a monitor, the same app on a phone, and the ASP.NET Core API and PostgreSQL database underneath them, with the data flowing between them. Every device except the phone is drawn as an outline, so the section reads as a diagram of the parts working together rather than a product shot. - src/scripts/shapes.ts: 2D shape helpers shared by both scenes. Outlines are ring geometry (a shape with a smaller copy punched out) rather than THREE.Line, because WebGL ignores LineBasicMaterial.linewidth. - src/scripts/stackStory.ts: the scene. Orthographic camera over a fixed design space 1000 units tall, so geometry is written once and only the frustum changes on resize. Device poses come from keyframe tracks sampled by scroll position; screens and labels are canvas textures, since text in raw Three.js would otherwise need a font loader. - src/components/StackStory.astro: the section — a sticky stage inside a tall runway, with four captions that cross-fade with the acts. The stage is sticky rather than fixed so it never competes with the hero's fixed canvas, and it sits outside .yas-page-canvas because that element's overflow-x makes it a scroll container, which would stop the stage sticking. The hero canvas now fades out as the story scrolls in. The hero's local roundedSquareGeometry is replaced by the shared helper so there is one implementation of the shape rather than two. The loop only runs while the stage is on screen, and prefers-reduced-motion drops the drifting backdrop, travelling packets and ticking numbers while keeping the diagram itself. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HQauSUJBoizYmd62J95vDx --- src/components/StackStory.astro | 218 ++++++++ src/pages/index.astro | 39 +- src/scripts/shapes.ts | 166 ++++++ src/scripts/stackStory.ts | 937 ++++++++++++++++++++++++++++++++ 4 files changed, 1342 insertions(+), 18 deletions(-) create mode 100644 src/components/StackStory.astro create mode 100644 src/scripts/shapes.ts create mode 100644 src/scripts/stackStory.ts diff --git a/src/components/StackStory.astro b/src/components/StackStory.astro new file mode 100644 index 0000000..3c8a8dd --- /dev/null +++ b/src/components/StackStory.astro @@ -0,0 +1,218 @@ +--- +/** + * Scroll-driven Three.js story: a Y-A-S web app on a monitor, the same app on a + * phone, and the API and database underneath them. Everything but the phone is + * drawn as an outline so the section reads as a diagram of the parts working + * together. + * + * The stage is `position: sticky` inside a tall runway rather than `position: + * fixed` like the homepage hero, so the two canvases never compete for the same + * scroll or stacking context. + */ + +const acts = [ + { + label: 'In the wild', + title: 'It starts as a screen someone uses.', + body: 'A web app running on a real desk — the surface your users actually touch. Everything behind it exists to keep this screen fast and correct.', + tags: ['Blazor', 'C#'], + }, + { + label: 'The build', + title: 'One interface, designed to be read.', + body: 'Components written in C#, laid out so the number that matters is the biggest thing on the page. No half-finished states, no framework churn.', + tags: ['Components', 'Design system'], + }, + { + label: 'Everywhere', + title: 'The phone runs the same app.', + body: 'One codebase and one set of rules. The phone and the browser stay in step over a live connection instead of quietly drifting apart.', + tags: ['SignalR', 'Shared codebase'], + }, + { + label: 'Underneath', + title: 'An API and a database you can trust.', + body: 'ASP.NET Core over PostgreSQL, deployed to Azure with CI/CD. Boring, reliable pieces — so the thing on the screen keeps working.', + tags: ['ASP.NET Core', 'PostgreSQL', 'Azure'], + }, +]; +--- + +
+
+ Connected systems +

How a Y-A-S build fits together.

+

+ Scroll to follow one app from the screen it runs on all the way down to the database + that feeds it. +

+
+ +
+
+ +
+ { + acts.map((act) => ( +
+ {act.label} +

{act.title}

+

{act.body}

+
+ {act.tags.map((tag) => ( + {tag} + ))} +
+
+ )) + } +
+
+
+
+ + + + diff --git a/src/pages/index.astro b/src/pages/index.astro index fdbe076..9a8b31f 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,5 +1,6 @@ --- import BaseLayout from '../layouts/BaseLayout.astro'; +import StackStory from '../components/StackStory.astro'; import { getCollection } from 'astro:content'; const projects = await getCollection('projects', ({ data }) => !data.draft); @@ -218,6 +219,8 @@ const projects = await getCollection('projects', ({ data }) => !data.draft); + + + + diff --git a/src/pages/index.astro b/src/pages/index.astro index 9a8b31f..0e0afb8 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,6 +1,7 @@ --- import BaseLayout from '../layouts/BaseLayout.astro'; import StackStory from '../components/StackStory.astro'; +import SectionScene from '../components/SectionScene.astro'; import { getCollection } from 'astro:content'; const projects = await getCollection('projects', ({ data }) => !data.draft); @@ -113,17 +114,24 @@ const projects = await getCollection('projects', ({ data }) => !data.draft);
- How We Work -

From idea to shipped.

-

- We move in small, focused iterations — spec, build, review, ship. Every project gets a clear - architecture, tested code, and documentation your team can actually use. -

-
- Discovery - Build - Review - Ship +
+
+ How We Work +

From idea to shipped.

+

+ We move in small, focused iterations — spec, build, review, ship. Every project gets a clear + architecture, tested code, and documentation your team can actually use. +

+
+ Discovery + Build + Review + Ship +
+
+
+ +
@@ -132,33 +140,40 @@ const projects = await getCollection('projects', ({ data }) => !data.draft);
- Talks & Presentations -

We talk shop, too.

-

- Conference talks, meetups, and workshop sessions from the team — mostly on Blazor, .NET, and the - hackathons we help run. -

-
-
- 2024 -
-

Building Real-Time Apps with Blazor Server

-

Hack Esbjerg — IT Hackathon

-
-
-
- 2024 -
-

PostgreSQL Performance Patterns for .NET Developers

-

Cod3rs Community Meetup

+
+
+ Talks & Presentations +

We talk shop, too.

+

+ Conference talks, meetups, and workshop sessions from the team — mostly on Blazor, .NET, and the + hackathons we help run. +

+
+
+ 2024 +
+

Building Real-Time Apps with Blazor Server

+

Hack Esbjerg — IT Hackathon

+
+
+
+ 2024 +
+

PostgreSQL Performance Patterns for .NET Developers

+

Cod3rs Community Meetup

+
+
+
+ 2023 +
+

From Zero to Cipher: Designing Hack The Flag

+

Nordic DevFest

+
+
-
- 2023 -
-

From Zero to Cipher: Designing Hack The Flag

-

Nordic DevFest

-
+
+
@@ -168,17 +183,24 @@ const projects = await getCollection('projects', ({ data }) => !data.draft);
- Why Y-A-S -

Small team, real ownership.

-

- No hand-offs between account managers and engineers — you talk directly to the people writing - your code. We pick boring, reliable technology so your product stays maintainable long after launch. -

-
- .NET & C# - PostgreSQL - Cloud-native - CI/CD +
+
+ Why Y-A-S +

Small team, real ownership.

+

+ No hand-offs between account managers and engineers — you talk directly to the people writing + your code. We pick boring, reliable technology so your product stays maintainable long after launch. +

+
+ .NET & C# + PostgreSQL + Cloud-native + CI/CD +
+
+
+ +
@@ -202,6 +224,9 @@ const projects = await getCollection('projects', ({ data }) => !data.draft);
+
+ +
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..6fcb8c5 --- /dev/null +++ b/src/scripts/sectionScenes.ts @@ -0,0 +1,430 @@ +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' | 'talks' | '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.primary; + 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); + }); + }; +}; + +/* ------------------------------------------------------------------ * + * Talks & Presentations — a slide, a speaker, an audience + * ------------------------------------------------------------------ */ + +const talks: SceneBuilder = ({ scene, reduceMotion }) => { + const accent = BRAND.secondary; + + const screen = new THREE.Group(); + screen.add(box(300, 176, 6)); + const title = bar(140, 8, INK, 0.75); + title.position.set(-60, 62, 0); + screen.add(title); + const subtitle = bar(90, 5, MUTED, 0.7); + subtitle.position.set(-85, 47, 0); + screen.add(subtitle); + + const barHeights = [46, 78, 110, 64, 92]; + const bars = barHeights.map((height, i) => { + const column = mesh( + roundedRectGeometry(22, height, 3), + i === 2 ? accent : INK, + i === 2 ? 1 : 0.32 + ); + column.userData.height = height; + column.position.set(-100 + i * 42, -70 + height / 2, 0); + screen.add(column); + return column; + }); + + const pointer = mesh(new THREE.CircleGeometry(4, 16), BRAND.danger); + pointer.position.z = 0.3; + screen.add(pointer); + + screen.add(line([vec(0, -88), vec(0, -108)], 10, INK, 0.85)); + const base = mesh(roundedRectOutlineGeometry(90, 8, 4, 3), INK, 0.85); + base.position.y = -112; + screen.add(base); + screen.position.set(60, 72, 0); + scene.add(screen); + + const speaker = buildPerson(); + speaker.position.set(-225, 10, 0); + scene.add(speaker); + const speakerLabel = createLabel('the team', 13, MUTED); + speakerLabel.position.set(-225, -30, 0); + scene.add(speakerLabel); + + const rows = [-80, -118, -156].map((rowY, r) => { + const row = new THREE.Group(); + for (let x = -190 + (r % 2) * 22; x <= 250; x += 44) { + const head = buildPerson(INK, 0.95 - r * 0.28); + head.scale.setScalar(0.55); + head.position.set(x, rowY, 0); + row.add(head); + } + scene.add(row); + return row; + }); + + return (time, progress) => { + settle(speaker, reveal(progress, 0.0, 0.2)); + setGroupOpacity(speakerLabel, reveal(progress, 0.05, 0.2)); + settle(screen, reveal(progress, 0.1, 0.25)); + rows.forEach((row, r) => settle(row, reveal(progress, 0.3 + r * 0.12, 0.2))); + + const grow = reveal(progress, 0.35, 0.3); + bars.forEach((column, i) => { + const wobble = reduceMotion ? 1 : 1 + Math.sin(time * 0.9 + i * 1.3) * 0.08; + const height = (column.userData.height as number) * grow * wobble; + column.scale.y = Math.max(0.01, height / (column.userData.height as number)); + column.position.y = -70 + height / 2; + }); + + const t = reduceMotion ? 0.8 : time; + pointer.position.set(-20 + Math.sin(t * 0.7) * 95, 10 + Math.cos(t * 1.1) * 45, 0.3); + setGroupOpacity(pointer, grow); + }; +}; + +/* ------------------------------------------------------------------ * + * Why Y-A-S — you, talking straight to the people writing the code + * ------------------------------------------------------------------ */ + +const ownership: SceneBuilder = ({ scene, reduceMotion }) => { + const accent = BRAND.danger; + + 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, talks, 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/stackStory.ts b/src/scripts/stackStory.ts index 52de339..2906d6b 100644 --- a/src/scripts/stackStory.ts +++ b/src/scripts/stackStory.ts @@ -1,10 +1,20 @@ import * as THREE from 'three'; +import { roundedRectGeometry, roundedRectOutlineGeometry, strokeGeometry } from './shapes'; import { - dashedStrokeGeometry, - roundedRectGeometry, - roundedRectOutlineGeometry, - strokeGeometry, -} from './shapes'; + 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. @@ -21,15 +31,15 @@ import { const DESIGN_HEIGHT = 1000; -/** Mirrors the brand custom properties in src/styles/global.css. */ const COLORS = { - outline: '#E5E2E1', - muted: '#7F8FA4', - screen: '#1E2532', - primary: '#66CC8A', - secondary: '#377CFB', + 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; @@ -47,11 +57,6 @@ interface AppState { series: number[]; } -function smoothstep(t: number): number { - const c = Math.min(1, Math.max(0, t)); - return c * c * (3 - 2 * c); -} - function sampleTrack(track: Keyframe[], progress: number): Keyframe { if (progress <= track[0].at) return track[0]; const last = track[track.length - 1]; @@ -73,63 +78,10 @@ function sampleTrack(track: Keyframe[], progress: number): Keyframe { return last; } -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. */ -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; -} - -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; - }); -} - -function vec(x: number, y: number): THREE.Vector2 { - return new THREE.Vector2(x, y); -} - -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 screen 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); -} - /* ------------------------------------------------------------------ * * Canvas-backed textures: screens and labels * ------------------------------------------------------------------ */ -const TEXTURE_SCALE = 3; - interface Screen { mesh: THREE.Mesh; redraw(state: AppState): void; @@ -168,39 +120,6 @@ function createScreen( }; } -function mono(size: number, weight = 500): string { - return `${weight} ${size}px 'JetBrains Mono', ui-monospace, monospace`; -} - -function labelWidth(label: THREE.Mesh): number { - return (label.geometry as THREE.PlaneGeometry).parameters.width; -} - -function createLabel(text: string, size = 20, color = COLORS.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; -} - /* ------------------------------------------------------------------ * * Screen contents * ------------------------------------------------------------------ */ @@ -468,101 +387,6 @@ function buildDatabase(): THREE.Group { return group; } -/* ------------------------------------------------------------------ * - * Links between devices - * ------------------------------------------------------------------ */ - -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; - /** Portrait viewports have no room for link captions. */ - showLabel(visible: boolean): void; - tick(time: number): void; -} - -/** Links are built along +X at this length, then rotated and stretched to fit. */ -const LINK_LENGTH = 1000; - -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; -} - -function createLink(text: string, color: string, options: LinkOptions = {}): Link { - const { packets: packetCount = 3, labelAt = 0.5, labelOffset = -22 } = 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)], 3, 26, 20), - color, - 0.55 - ); - const lineHolder = new THREE.Group(); - lineHolder.add(line); - group.add(lineHolder); - - const packets: THREE.Mesh[] = []; - for (let i = 0; i < packetCount; i++) { - const packet = mesh(new THREE.CircleGeometry(6, 16), color); - packets.push(packet); - group.add(packet); - } - - const label = createLabel(text, 17, color); - 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; - // 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); - label.visible = labelVisible; - }, - showLabel(visible) { - labelVisible = visible; - }, - tick(time) { - packets.forEach((packet, i) => { - const t = ((time * 0.22 + 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; - }); - }, - }; -} - /* ------------------------------------------------------------------ * * Act 1 backdrop: drifting grid and floating code windows * ------------------------------------------------------------------ */ @@ -682,256 +506,209 @@ export interface StackStoryOptions { } export function initStackStory({ canvas, runway, captions }: StackStoryOptions): 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 scene = new THREE.Scene(); - const camera = new THREE.OrthographicCamera(-500, 500, 500, -500, 0.1, 100); - camera.position.z = 50; - 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(); - 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 resize(): void { - const width = canvas.clientWidth || window.innerWidth; - const height = canvas.clientHeight || window.innerHeight; - renderer.setSize(width, height, false); - - const aspect = width / height; - viewHeight = DESIGN_HEIGHT; - viewWidth = DESIGN_HEIGHT * aspect; - unit = Math.min(viewWidth, viewHeight); - - camera.left = -viewWidth / 2; - camera.right = viewWidth / 2; - camera.top = viewHeight / 2; - camera.bottom = -viewHeight / 2; - camera.updateProjectionMatrix(); - - 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)); - } - - function isPortrait(): boolean { - return viewWidth < viewHeight; - } - - /** - * Maps a track's vertical fraction onto the view. Portrait viewports pull the - * diagram together and lift it, so it clears the full-width 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): Keyframe { - 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); - return pose; - } - - function update(time: number): void { - 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), + 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 ); - node.scale.setScalar((unit / DESIGN_HEIGHT) * (0.6 + depth * 0.5)); - }); - } - - const syncOpacity = fadeAt(LINK_FADES.sync, progress); - links.sync.place( - anchorOf(devices.monitor, 175, 0), - anchorOf(devices.phone, -70, 0), - syncOpacity - ); - - 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 - ); - - const dataOpacity = fadeAt(LINK_FADES.data, progress); - links.data.place( - anchorOf(devices.database, 0, 70), - anchorOf(devices.server, 0, -75), - dataOpacity - ); - - // 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)`; - }); - } - - let running = false; - let lastTick = 0; - - function frame(now: number): void { - if (!running) return; - requestAnimationFrame(frame); - const time = now / 1000; - - // 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)); - state.series.push(state.series[state.series.length - 1] + (Math.random() - 0.5) * 14); - state.series[state.series.length - 1] = Math.min( - 95, - Math.max(12, state.series[state.series.length - 1]) - ); - state.series.shift(); - redrawScreens(); - } - - update(time); - renderer.render(scene, camera); - } - - function start(): void { - if (running) return; - running = true; - requestAnimationFrame(frame); - } - - function stop(): void { - running = false; - } - - resize(); - window.addEventListener('resize', resize); + 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) + ); - // Nothing below the fold needs to burn a frame budget, so the loop only runs - // while the stage is actually on screen. - const observer = new IntersectionObserver( - ([entry]) => (entry.isIntersecting ? start() : stop()), - { rootMargin: '120px' } - ); - observer.observe(runway); + // 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)`; + }); + }; + }, + }); } From 5ff216e171e4f4e03e2a4244eaed6a932008699b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 06:51:35 +0000 Subject: [PATCH 3/4] Make the GitHub workflow a build check instead of a Pages deploy Every run of "Deploy to GitHub Pages" has failed since 2025, on every push to main, at the deploy step: GitHub Pages is not enabled for the repository, and the site is hosted on Cloudflare Pages anyway, which builds from main by itself. The build step in those runs has always passed, so the red runs were noise rather than a real break. Drop the deploy job and the Pages permissions, and run the build on pull requests as well as pushes to main, so the workflow reports something true: whether the site builds. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HQauSUJBoizYmd62J95vDx --- .github/workflows/static.yml | 25 +++++-------------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index 5670ef9..ee813a3 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -1,18 +1,16 @@ -name: Deploy to GitHub Pages +# The site is hosted on Cloudflare Pages, which builds from main on its own. +# This workflow only proves the site builds, on every push to main and on +# every pull request, so a broken build is caught before it reaches Cloudflare. +name: Build on: push: branches: ["main"] + pull_request: workflow_dispatch: permissions: contents: read - pages: write - id-token: write - -concurrency: - group: "pages" - cancel-in-progress: false jobs: build: @@ -25,16 +23,3 @@ jobs: cache: npm - run: npm ci - run: npm run build - - uses: actions/upload-pages-artifact@v3 - with: - path: ./dist - - deploy: - needs: build - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - uses: actions/deploy-pages@v2 - id: deployment From 12a7fc9053d077992d99b26e935a6c9aefc19e13 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 14:33:33 +0000 Subject: [PATCH 4/4] Reorder homepage for clarity; drop forced full-height sections The homepage ran to ~11.5 screens on a 1440x900 desktop and its order buried the strongest proof of work: five colour-washed sections were each forced to min-height: 100vh, the Talks list sat above Why Y-A-S, and the projects collection was fetched but never shown. New order, top to bottom: 1. Hero (unchanged) 2. Intro "We build software." (neutral) - the only full-height band, via the new .reveal-section--tall modifier; keeps the canonical tech badges and both CTAs 3. Why Y-A-S "Small team, real ownership." (green) 4. How We Work "From idea to shipped." (blue) with its process badges 5. Selected Work "Things we've shipped." (red) - new: the three most recent projects as cards plus a "View all projects" link 6. Blog "Read our Blog" (navy) - slimmed to label, heading, one line and the Explore Blog button 7. Stack story, 8. Footer (unchanged) Sections 3-6 now size to their content (min-height: 0; padding: 4rem 0) instead of a screen each. The DOM still holds exactly five .reveal-section elements in the neutral/green/blue/red/navy order the hero's Three.js squares are paired with, so the hero script and its computeRemoval() slots are untouched; the ownership and workflow scene accents follow their bands' new colours (green and blue). Removed: the Talks & Presentations section together with its .talk-* styles and the `talks` scene builder; the tech badges on Why Y-A-S and Blog, which duplicated the intro's list; the unused .blog-cta-section rule. The project card markup and styles move out of the projects page into a shared ProjectCard component so both pages render the same card. The stack story runway shrinks from 420vh to 300vh (380vh to 280vh on mobile) and its lede is a single line inviting the scroll. Measured with Bootstrap served locally the page goes from 11.5 to 8.8 screens; hero, full-height intro and the 300vh runway alone account for five of those. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HQauSUJBoizYmd62J95vDx --- src/components/ProjectCard.astro | 121 +++++++++++++++++++++++++ src/components/StackStory.astro | 9 +- src/pages/index.astro | 147 +++++++++++-------------------- src/pages/projects/index.astro | 119 ++----------------------- src/scripts/sectionScenes.ts | 87 +----------------- src/styles/global.css | 5 -- 6 files changed, 187 insertions(+), 301 deletions(-) create mode 100644 src/components/ProjectCard.astro diff --git a/src/components/ProjectCard.astro b/src/components/ProjectCard.astro new file mode 100644 index 0000000..8ead1d1 --- /dev/null +++ b/src/components/ProjectCard.astro @@ -0,0 +1,121 @@ +--- +/** + * One project card, as shown on /projects/ and in the homepage's Selected Work + * band. Sharp corners with a left accent border on hover, matching the + * post-preview and project-details-panel treatment used elsewhere on the site. + */ +import type { CollectionEntry } from 'astro:content'; + +interface Props { + project: CollectionEntry<'projects'>; +} + +const { project } = Astro.props; +const techs = project.data.tech?.split(',').map((t) => t.trim()) ?? []; +--- + + + + diff --git a/src/components/StackStory.astro b/src/components/StackStory.astro index 3c8a8dd..db6e3b3 100644 --- a/src/components/StackStory.astro +++ b/src/components/StackStory.astro @@ -42,10 +42,7 @@ const acts = [
Connected systems

How a Y-A-S build fits together.

-

- Scroll to follow one app from the screen it runs on all the way down to the database - that feeds it. -

+

Scroll to follow one app from the screen down to the database.

@@ -106,7 +103,7 @@ const acts = [ /* The runway's extra height is the scroll budget the four acts play out over. */ .stack-runway { - height: 420vh; + height: 300vh; position: relative; } @@ -182,7 +179,7 @@ const acts = [ } .stack-runway { - height: 380vh; + height: 280vh; } .stack-captions { diff --git a/src/pages/index.astro b/src/pages/index.astro index 0e0afb8..daff076 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -2,9 +2,13 @@ import BaseLayout from '../layouts/BaseLayout.astro'; import StackStory from '../components/StackStory.astro'; import SectionScene from '../components/SectionScene.astro'; +import ProjectCard from '../components/ProjectCard.astro'; import { getCollection } from 'astro:content'; -const projects = await getCollection('projects', ({ data }) => !data.draft); +// The three most recent projects headline the Selected Work band. +const featuredProjects = (await getCollection('projects', ({ data }) => !data.draft)) + .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf()) + .slice(0, 3); --- @@ -79,8 +83,8 @@ const projects = await getCollection('projects', ({ data }) => !data.draft);
- -
+ +
@@ -110,118 +114,85 @@ const projects = await getCollection('projects', ({ data }) => !data.draft);
- +
- How We Work -

From idea to shipped.

+ Why Y-A-S +

Small team, real ownership.

- We move in small, focused iterations — spec, build, review, ship. Every project gets a clear - architecture, tested code, and documentation your team can actually use. + No hand-offs between account managers and engineers — you talk directly to the people writing + your code. We pick boring, reliable technology so your product stays maintainable long after launch.

-
- Discovery - Build - Review - Ship -
- +
- +
- Talks & Presentations -

We talk shop, too.

+ How We Work +

From idea to shipped.

- Conference talks, meetups, and workshop sessions from the team — mostly on Blazor, .NET, and the - hackathons we help run. + We move in small, focused iterations — spec, build, review, ship. Every project gets a clear + architecture, tested code, and documentation your team can actually use.

-
-
- 2024 -
-

Building Real-Time Apps with Blazor Server

-

Hack Esbjerg — IT Hackathon

-
-
-
- 2024 -
-

PostgreSQL Performance Patterns for .NET Developers

-

Cod3rs Community Meetup

-
-
-
- 2023 -
-

From Zero to Cipher: Designing Hack The Flag

-

Nordic DevFest

-
-
+
+ Discovery + Build + Review + Ship
- +
- +
-
-
- Why Y-A-S -

Small team, real ownership.

-

- No hand-offs between account managers and engineers — you talk directly to the people writing - your code. We pick boring, reliable technology so your product stays maintainable long after launch. -

-
- .NET & C# - PostgreSQL - Cloud-native - CI/CD + Selected Work +

Things we've shipped.

+

+ Open-source libraries, hackathons and community platforms — built and run by the team. +

+
+ {featuredProjects.map((project) => ( +
+
-
-
- -
+ ))} +
+
-
+
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 -
@@ -244,6 +215,9 @@ const projects = await getCollection('projects', ({ data }) => !data.draft);
+ diff --git a/src/scripts/sectionScenes.ts b/src/scripts/sectionScenes.ts index 6fcb8c5..88da36b 100644 --- a/src/scripts/sectionScenes.ts +++ b/src/scripts/sectionScenes.ts @@ -29,7 +29,7 @@ const INK = ON_LIGHT.ink; const MUTED = ON_LIGHT.muted; const STROKE = 3.5; -export type SceneName = 'workflow' | 'talks' | 'ownership' | 'blog'; +export type SceneName = 'workflow' | 'ownership' | 'blog'; type Updater = (time: number, progress: number) => void; type SceneBuilder = (view: SceneView) => Updater; @@ -74,7 +74,7 @@ function bar(width: number, height: number, color = INK, opacity = 1): THREE.Mes * ------------------------------------------------------------------ */ const workflow: SceneBuilder = ({ scene, reduceMotion }) => { - const accent = BRAND.primary; + const accent = BRAND.secondary; const steps = ['Discovery', 'Build', 'Review', 'Ship']; const xs = [-225, -75, 75, 225]; const y = 18; @@ -168,91 +168,12 @@ const workflow: SceneBuilder = ({ scene, reduceMotion }) => { }; }; -/* ------------------------------------------------------------------ * - * Talks & Presentations — a slide, a speaker, an audience - * ------------------------------------------------------------------ */ - -const talks: SceneBuilder = ({ scene, reduceMotion }) => { - const accent = BRAND.secondary; - - const screen = new THREE.Group(); - screen.add(box(300, 176, 6)); - const title = bar(140, 8, INK, 0.75); - title.position.set(-60, 62, 0); - screen.add(title); - const subtitle = bar(90, 5, MUTED, 0.7); - subtitle.position.set(-85, 47, 0); - screen.add(subtitle); - - const barHeights = [46, 78, 110, 64, 92]; - const bars = barHeights.map((height, i) => { - const column = mesh( - roundedRectGeometry(22, height, 3), - i === 2 ? accent : INK, - i === 2 ? 1 : 0.32 - ); - column.userData.height = height; - column.position.set(-100 + i * 42, -70 + height / 2, 0); - screen.add(column); - return column; - }); - - const pointer = mesh(new THREE.CircleGeometry(4, 16), BRAND.danger); - pointer.position.z = 0.3; - screen.add(pointer); - - screen.add(line([vec(0, -88), vec(0, -108)], 10, INK, 0.85)); - const base = mesh(roundedRectOutlineGeometry(90, 8, 4, 3), INK, 0.85); - base.position.y = -112; - screen.add(base); - screen.position.set(60, 72, 0); - scene.add(screen); - - const speaker = buildPerson(); - speaker.position.set(-225, 10, 0); - scene.add(speaker); - const speakerLabel = createLabel('the team', 13, MUTED); - speakerLabel.position.set(-225, -30, 0); - scene.add(speakerLabel); - - const rows = [-80, -118, -156].map((rowY, r) => { - const row = new THREE.Group(); - for (let x = -190 + (r % 2) * 22; x <= 250; x += 44) { - const head = buildPerson(INK, 0.95 - r * 0.28); - head.scale.setScalar(0.55); - head.position.set(x, rowY, 0); - row.add(head); - } - scene.add(row); - return row; - }); - - return (time, progress) => { - settle(speaker, reveal(progress, 0.0, 0.2)); - setGroupOpacity(speakerLabel, reveal(progress, 0.05, 0.2)); - settle(screen, reveal(progress, 0.1, 0.25)); - rows.forEach((row, r) => settle(row, reveal(progress, 0.3 + r * 0.12, 0.2))); - - const grow = reveal(progress, 0.35, 0.3); - bars.forEach((column, i) => { - const wobble = reduceMotion ? 1 : 1 + Math.sin(time * 0.9 + i * 1.3) * 0.08; - const height = (column.userData.height as number) * grow * wobble; - column.scale.y = Math.max(0.01, height / (column.userData.height as number)); - column.position.y = -70 + height / 2; - }); - - const t = reduceMotion ? 0.8 : time; - pointer.position.set(-20 + Math.sin(t * 0.7) * 95, 10 + Math.cos(t * 1.1) * 45, 0.3); - setGroupOpacity(pointer, grow); - }; -}; - /* ------------------------------------------------------------------ * * Why Y-A-S — you, talking straight to the people writing the code * ------------------------------------------------------------------ */ const ownership: SceneBuilder = ({ scene, reduceMotion }) => { - const accent = BRAND.danger; + const accent = BRAND.primary; const you = buildPerson(); you.scale.setScalar(1.2); @@ -405,7 +326,7 @@ const blog: SceneBuilder = ({ scene, reduceMotion }) => { }; }; -const SCENES: Record = { workflow, talks, ownership, blog }; +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 { 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;