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 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()) ?? []; +--- + +
+
+
+ {`${project.data.title}`} +
+
+

{project.data.title}

+
+ {techs.map((tech) => ( + {tech} + ))} +
+

+ {project.data.description} +

+ + Learn More + + + + + {project.data.webUrl && ( + + Visit Page + + )} + {project.data.github && ( + + GitHub + + )} +
+
+
+ + diff --git a/src/components/SectionScene.astro b/src/components/SectionScene.astro new file mode 100644 index 0000000..ff8a262 --- /dev/null +++ b/src/components/SectionScene.astro @@ -0,0 +1,57 @@ +--- +/** + * A small outline diagram for one of the homepage's colour-washed sections. + * The scene itself lives in src/scripts/sectionScenes.ts; this component only + * provides the canvas and wires it to the nearest `.reveal-section`, whose + * position in the viewport drives the reveal. + */ +import type { SceneName } from '../scripts/sectionScenes'; + +interface Props { + name: SceneName; +} + +const { name } = Astro.props; +--- + +
+ +
+ + + + diff --git a/src/components/StackStory.astro b/src/components/StackStory.astro new file mode 100644 index 0000000..db6e3b3 --- /dev/null +++ b/src/components/StackStory.astro @@ -0,0 +1,215 @@ +--- +/** + * 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 down to the database.

+
+ +
+
+ +
+ { + 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..daff076 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,8 +1,14 @@ --- 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); --- @@ -77,8 +83,8 @@ const projects = await getCollection('projects', ({ data }) => !data.draft);
- -
+ +
@@ -108,99 +114,90 @@ 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 +
+
+ 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. +

+
+
+ +
- +
- 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

+
+
+ 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
-
- 2023 -
-

From Zero to Cipher: Designing Hack The Flag

-

Nordic DevFest

-
+
+
- +
- Why Y-A-S -

Small team, real ownership.

+ Selected Work +

Things we've shipped.

- 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. + Open-source libraries, hackathons and community platforms — built and run by the team.

-
- .NET & C# - PostgreSQL - Cloud-native - CI/CD +
+ {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 -
+ + + 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;