diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..06cbf64
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,32 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ verify:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ # Must come before setup-node: the pnpm cache below needs the store to exist.
+ # The version is read from the "packageManager" field in package.json.
+ - uses: pnpm/action-setup@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version-file: .nvmrc
+ cache: pnpm
+
+ - run: pnpm install --frozen-lockfile
+
+ - run: pnpm check
+
+ - run: pnpm lint
+
+ - run: pnpm format:check
+
+ - run: pnpm build
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000..a45fd52
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+24
diff --git a/.oxfmtrc.json b/.oxfmtrc.json
index 592fdbc..ee530d6 100644
--- a/.oxfmtrc.json
+++ b/.oxfmtrc.json
@@ -5,5 +5,5 @@
"printWidth": 120,
"trailingComma": "all",
"sortPackageJson": false,
- "ignorePatterns": []
+ "ignorePatterns": ["src/assets/fonts/*.typeface.json"]
}
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..5a53d59
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,123 @@
+# CLAUDE.md
+
+Guidance for Claude Code when working in this repository. See [README.md](README.md) for
+setup, scripts and architecture; this file only covers what changes how you should work.
+
+## Package manager
+
+**pnpm only.** There is a single `pnpm-lock.yaml` and the version is pinned via the
+`packageManager` field. Never run `npm` or `yarn` here — it will produce a competing
+lockfile and CI installs with `--frozen-lockfile`.
+
+## Toolchain
+
+**oxlint + oxfmt, not ESLint and not Prettier.** This is deliberate. Do not install
+`eslint` or `prettier`, and do not create their config files. Rules live in
+`.oxlintrc.json`; formatting in `.oxfmtrc.json`.
+
+Formatting: single quotes, semicolons, 120-column width, trailing commas everywhere,
+2-space indent, LF, final newline.
+
+Before calling a task done:
+
+```bash
+pnpm check && pnpm lint && pnpm format:check && pnpm build
+```
+
+## This is a single-page static site
+
+`output: 'static'` with no adapter. Do not reach for SSR-only APIs (`Astro.request`,
+runtime endpoints) without changing `output` and installing an adapter first.
+
+`build.inlineStylesheets: 'always'` and `vite.build.cssCodeSplit: false` are chosen
+specifically because there is one page. If real routes get added, revisit both.
+
+## The hero animation
+
+Lives in `src/scripts/hero-animation.ts`.
+
+- It is a faithful port of zadvorsky's "PIECE BY PIECE" pen
+ (https://codepen.io/zadvorsky/pen/GZmKYX): shards fly along cubic beziers and collapse
+ into the origin, each vertex of a face gets its own `delay + Math.random()` jitter
+ (that per-vertex smear is the signature look), and a click-drag scrubber pauses/seeks
+ the tween. Keep the port exact — magnitudes, timings and easings are the original's
+ absolute values, tied to `TEXT_SIZE` 14.
+- Framing is responsive, unlike the pen: the camera distance is derived from the h1
+ fallback's computed font-size so canvas and fallback render the same type size, the
+ text reflows onto two centred lines when that size drops below
+ `MIN_SINGLE_LINE_EM_PX`, and the bezier arcs scale with `uArcScale` (= z / 1400) so
+ the debris always fills the stage. Mind `REST_SCALE`: the original shader draws the
+ resting wordmark at exactly 2× its geometry (`position + cubicBezier(t=0)`), so every
+ camera-fit formula sizes against twice the measured bounds.
+- The motion is entirely in the vertex shader. The render loop only advances one `uTime`
+ uniform. Do not add CPU-side per-frame animation — change the attributes or the GLSL.
+- GSAP, THREE.BAS and PNLTRI are deliberately not used; the BAS material is ~15 lines of
+ inlined GLSL, the GSAP tween/scrubber is a few easing formulas in the loop, and modern
+ three's earcut triangulation makes PNLTRI redundant. Do not reintroduce them.
+- `TRACKING` widens every glyph advance at runtime because the bevel expands each
+ outline by `bevelSize` per side — without it adjacent Archivo Bold glyphs touch.
+- The `webgl-ready` class must go on `` *before* `createHeroAnimation` runs — it is
+ what gives the canvas its layout box, and the renderer needs real dimensions. It is
+ removed again if initialisation throws.
+- Booting is deliberately deferred and chunked, and this is what keeps Total Blocking Time
+ down — three.js is ~550KB and init is ~200ms of main thread on a throttled phone:
+ - `Hero.astro` `import()`s the module at idle after `load`, never statically. A static
+ import puts the bundle in the preload scanner's queue, where it competes with the font
+ for the connection and pushes FCP/LCP out.
+ - It also waits for `document.hidden` to clear first. `requestIdleCallback` never fires
+ in a hidden document — not even on timeout — so without this a page opened in a
+ background tab never starts the animation at all.
+ - `createHeroAnimation` is **async** and `await yieldToMain()`s at every phase boundary
+ (each `TextGeometry`, the merge, the GL context, the first render) plus on a time
+ budget inside the shard-attribute loop. No single phase exceeds ~45ms; adjacent phases
+ left in one task is what makes tasks "long". Adding synchronous work between these, or
+ dropping a yield, silently regresses TBT.
+ - The line count is measured *before* the first build. Building single-line and letting
+ `resize()` discard it triangulated the wordmark twice on every phone-width viewport.
+ - The geometry drops its `normal` and `uv` attributes: the shader reads neither, and
+ they are ~30% of a ~130k-vertex upload.
+- The `
` fallback is not decoration. Keep it in the DOM, visible by default, and
+ readable on its own.
+
+## The 3D font
+
+`src/assets/fonts/archivo-bold.typeface.json` is a **generated, committed artifact**. It
+is subsetted to exactly the glyphs the hero renders.
+
+If the hero copy or the typeface changes, update `CHARSET` in
+`scripts/fonts/build-typeface.mjs` and re-run `pnpm fonts:typeface`. Adding a character
+without regenerating will make three.js fail to find the glyph at runtime.
+
+The file is listed in `ignorePatterns` in `.oxfmtrc.json` so oxfmt does not expand the
+minified output.
+
+## Brand assets
+
+`public/logo.svg` is the designer's full lock-up. **Do not edit it.**
+
+`public/favicon.svg` is derived from it: children 0–4 (the dotted X mark, without the
+wordmark), wrapped in `translate(-90 0)` with `viewBox="0 0 120 120"`. If `logo.svg` is
+replaced, re-cut the mark rather than hand-editing the favicon.
+
+The lock-up's wordmark is not used on the page — the centre wordmark is 3D geometry built
+from Archivo.
+
+## Script targets are hardcoded
+
+`lint`, `lint:fix`, `format` and `format:check` all target `src scripts astro.config.mjs`
+explicitly. If you add a new top-level directory containing code, add it to all four or
+it will silently escape every check.
+
+## Commits
+
+Conventional Commits (`feat:`, `fix:`, `chore:`, …), matching the existing history.
+
+## License
+
+Proprietary, all rights reserved. Do not copy third-party code into this repository
+without checking license compatibility first.
+
+## Not yet written
+
+`.gitignore` reserves `.screenshots/` for a planned `scripts/dev/shot.ts` concept-review
+screenshot tool. It does not exist yet.
diff --git a/README.md b/README.md
index 7ce458d..ef1308d 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,141 @@
-# website
-Alexis Technologies Website
+# Alexis Technologies — Website
+
+
+
+
+
+
+
+The main website for Alexis Technologies. It will eventually showcase the company's
+services, projects and contact information.
+
+Right now it is a single-page placeholder: an animated wordmark that assembles itself
+out of shards, with a "coming soon" line beneath it.
+
+Production:
+
+## Tech stack
+
+| | |
+|---|---|
+| Framework | [Astro](https://astro.build) 7, static output — no adapter, no SSR |
+| Language | TypeScript 6 |
+| Graphics | [three.js](https://threejs.org) with a hand-written GLSL vertex shader |
+| Lint / format | [oxlint](https://oxc.rs) + [oxfmt](https://oxc.rs) — **not** ESLint or Prettier |
+| Typography | [Archivo](https://fonts.google.com/specimen/Archivo) via Fontsource |
+| SEO | `@astrojs/sitemap` |
+| Telemetry | Vercel Analytics + Speed Insights |
+| Hosting | Vercel (static) |
+
+## Prerequisites
+
+- **Node** ≥ 22.12 — the repo pins 24 in [`.nvmrc`](.nvmrc)
+- **pnpm** 10.34.5 — pinned via the `packageManager` field, so Corepack picks it up automatically
+
+```bash
+corepack enable
+```
+
+## Getting started
+
+```bash
+pnpm install
+```
+
+```bash
+pnpm dev
+```
+
+The dev server runs at .
+
+## Scripts
+
+| Script | What it does |
+|---|---|
+| `pnpm dev` | Start the Astro dev server |
+| `pnpm build` | Build the static site into `dist/` |
+| `pnpm preview` | Serve the built output locally |
+| `pnpm check` | Type-check `.astro` and `.ts` files (`astro check`) |
+| `pnpm lint` | Lint with oxlint |
+| `pnpm lint:fix` | Lint and apply fixable rules |
+| `pnpm format` | Format with oxfmt |
+| `pnpm format:check` | Verify formatting without writing — what CI runs |
+| `pnpm fonts:typeface` | Regenerate the subsetted 3D font. **One-off** — the output is committed |
+
+CI runs `check`, `lint`, `format:check` and `build` on every push to `main` and every
+pull request.
+
+## Project structure
+
+```
+.github/workflows/ CI
+public/ Served as-is
+ logo.svg Full brand lock-up (source of truth, not used on the page)
+ favicon.svg The dotted X mark, cut from logo.svg — favicon and corner mark
+scripts/
+ fonts/ Offline font conversion (not part of the build)
+src/
+ assets/fonts/ Generated typeface.json consumed by three.js
+ components/ LogoMark, Hero, SiteFooter
+ layouts/ BaseLayout — head, meta, telemetry
+ pages/ index.astro — the only route
+ scripts/ hero-animation.ts — the WebGL scene
+ styles/ global.css
+```
+
+## How the hero animation works
+
+`TextGeometry` extrudes the wordmark into a bevelled, **non-indexed** geometry, so every
+triangle owns its three vertices and can be moved independently. Each triangle is then
+given its own scatter offset, two bezier control points, a delay and a duration, stored
+as vertex attributes.
+
+The vertex shader walks each triangle along that cubic bezier from "scattered" to
+"assembled", easing it and growing it out of its own centroid on the way in. Nothing is
+animated on the CPU — the render loop only advances a single `uTime` uniform, which
+ping-pongs so the wordmark repeatedly assembles and blows apart.
+
+Scatter magnitudes are expressed as multiples of the wordmark's cap height rather than in
+absolute units, so the debris field stays proportional to the text at any viewport size.
+
+**Fallbacks.** The real `
+
+
+
diff --git a/src/components/LogoMark.astro b/src/components/LogoMark.astro
new file mode 100644
index 0000000..2ccf84d
--- /dev/null
+++ b/src/components/LogoMark.astro
@@ -0,0 +1,6 @@
+---
+// The mark is cut from public/logo.svg (the dotted X, without the wordmark) and doubles
+// as the favicon, so the browser fetches and caches a single file for both roles.
+---
+
+
diff --git a/src/components/SiteFooter.astro b/src/components/SiteFooter.astro
new file mode 100644
index 0000000..a7842fc
--- /dev/null
+++ b/src/components/SiteFooter.astro
@@ -0,0 +1,19 @@
+---
+// Rendered at build time, so the year is correct without JS and for crawlers.
+const buildYear = new Date().getFullYear();
+---
+
+
+
+
diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro
new file mode 100644
index 0000000..45b8fb8
--- /dev/null
+++ b/src/layouts/BaseLayout.astro
@@ -0,0 +1,49 @@
+---
+import Analytics from '@vercel/analytics/astro';
+import SpeedInsights from '@vercel/speed-insights/astro';
+
+import '@fontsource-variable/archivo';
+import '../styles/global.css';
+
+// The latin subset is the only one this page's copy touches. Preloading it starts the
+// fetch with the document instead of after the inlined @font-face is matched to text, so
+// the wordmark paints in Archivo first time rather than swapping out of the fallback —
+// which is both the LCP and the page's only source of layout shift.
+import archivoLatin from '@fontsource-variable/archivo/files/archivo-latin-wght-normal.woff2?url';
+
+interface Props {
+ title: string;
+ description: string;
+}
+
+const { title, description } = Astro.props;
+const canonical = new URL(Astro.url.pathname, Astro.site).href;
+---
+
+
+
+
+
+
+ {title}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/index.astro b/src/pages/index.astro
new file mode 100644
index 0000000..033ba99
--- /dev/null
+++ b/src/pages/index.astro
@@ -0,0 +1,15 @@
+---
+import Hero from '../components/Hero.astro';
+import LogoMark from '../components/LogoMark.astro';
+import SiteFooter from '../components/SiteFooter.astro';
+import BaseLayout from '../layouts/BaseLayout.astro';
+---
+
+
+
+
+
+
diff --git a/src/scripts/hero-animation.ts b/src/scripts/hero-animation.ts
new file mode 100644
index 0000000..cb0a7e5
--- /dev/null
+++ b/src/scripts/hero-animation.ts
@@ -0,0 +1,587 @@
+/**
+ * Hero wordmark animation.
+ *
+ * Faithful port of zadvorsky's "PIECE BY PIECE" (https://codepen.io/zadvorsky/pen/GZmKYX),
+ * which was built on THREE.BAS + GSAP. Neither library is needed: the BAS material is the
+ * `cubic_bezier` shader chunk plus a two-line vertex transform (inlined below), and the
+ * GSAP tween/scrubber is reproduced with the same easings in the render loop.
+ *
+ * At progress 0 the wordmark is assembled; as uTime advances every triangle shrinks its
+ * base position toward the origin while flying along its own cubic bezier into the
+ * centre. The tween ping-pongs (yoyo) forever. Dragging scrubs the tween: pressing
+ * decelerates playback to a halt over 2s, horizontal movement seeks, releasing
+ * accelerates playback back to full speed over 2s — exactly like createTweenScrubber
+ * in the original.
+ *
+ * Unlike the pen (fixed camera in a full-window canvas), the wordmark is sized to match
+ * the `
` fallback: the camera distance is derived from the h1's computed font-size,
+ * and on screens where that would leave the type too small the text reflows onto two
+ * centred lines. The bezier arcs scale with the visible stage height (uArcScale) so the
+ * debris field always fills the canvas the way it fills the pen's window.
+ *
+ * Nothing is animated on the CPU — the loop only advances a single `uTime` uniform.
+ */
+
+import {
+ BufferAttribute,
+ type BufferGeometry,
+ Color,
+ DoubleSide,
+ MathUtils,
+ Mesh,
+ PerspectiveCamera,
+ Scene,
+ ShaderMaterial,
+ Vector3,
+ WebGLRenderer,
+} from 'three';
+import { FontLoader } from 'three/examples/jsm/loaders/FontLoader.js';
+import { TextGeometry } from 'three/examples/jsm/geometries/TextGeometry.js';
+import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
+
+import typefaceData from '../assets/fonts/archivo-bold.typeface.json';
+
+/** Keep in sync with CHARSET in scripts/fonts/build-typeface.mjs — the font is subsetted to these glyphs. */
+const HERO_TEXT = 'ALEXIS TECHNOLOGIES';
+
+/**
+ * Original geometry parameters: size 14, height 0, bevel 0.75/0.5. The bevel is what
+ * gives the flat shards their outline thickness. TEXT_SIZE is the em size in world
+ * units; all animation magnitudes are expressed relative to it, exactly as in the
+ * source, and only the camera decides how large the wordmark renders.
+ */
+const TEXT_SIZE = 14;
+const BEVEL_SIZE = 0.75;
+const BEVEL_THICKNESS = 0.5;
+
+/**
+ * Extra advance between glyphs, in font units (the typeface is normalised to 1000/em).
+ * The bevel expands every outline by BEVEL_SIZE on each side — about 54 font units at
+ * TEXT_SIZE 14 — which is enough to make adjacent Archivo Bold glyphs touch. This puts
+ * the eaten gap back so letters stay separate.
+ */
+const TRACKING = 130;
+
+/**
+ * The original shader draws the *resting* wordmark at exactly twice its geometry:
+ * at tProgress 0 the undamped `position` term and `cubicBezier(t=0) == p0` add up to
+ * `2.0 * position`. That doubling is part of the effect (the pen's text is this large
+ * too), so every camera-fit formula below must size against REST_SCALE × geometry.
+ */
+const REST_SCALE = 2;
+
+const CAMERA_FOV = 10;
+/**
+ * The pen parks its camera at z=1400, which shows 2·1400·tan(fov/2) ≈ 245 world units
+ * of height. That reference height is what the original control-point magnitudes
+ * (y up to 120) were tuned against; uArcScale is the ratio between the current visible
+ * height and this one.
+ */
+const REFERENCE_CAMERA_Z = 1400;
+
+/** Below this h1 font-size the single-line wordmark reads too small — reflow to two lines. */
+const MIN_SINGLE_LINE_EM_PX = 36;
+/** Baseline-to-baseline distance in em for the two-line layout. */
+const LINE_HEIGHT = 1.05;
+/** Share of the canvas width the wordmark may span before the camera pulls back. */
+const SINGLE_LINE_MAX_FILL = 0.95;
+const TWO_LINE_FILL = 0.88;
+/** The two-line block also may not exceed this share of the canvas height. */
+const BLOCK_MAX_HEIGHT_RATIO = 0.75;
+
+/** One direction of the ping-pong tween takes 4s (TweenMax.fromTo(..., 4, ...)). */
+const TWEEN_SECONDS = 4;
+/** timeScale eases to 0 / back to 1 over 2s on press / release. */
+const TIMESCALE_SECONDS = 2;
+/** Drag distance to progress conversion, matching the original seekSpeed. */
+const SEEK_SPEED = 0.001;
+
+const vertexShader = /* glsl */ `
+ uniform float uTime;
+ uniform float uArcScale;
+
+ attribute vec2 aAnimation;
+ attribute vec3 aControl0;
+ attribute vec3 aControl1;
+
+ // THREE.BAS.ShaderChunk['cubic_bezier']
+ vec3 cubicBezier(vec3 p0, vec3 c0, vec3 c1, vec3 p1, float t) {
+ float tn = 1.0 - t;
+ return tn * tn * tn * p0
+ + 3.0 * tn * tn * t * c0
+ + 3.0 * tn * t * t * c1
+ + t * t * t * p1;
+ }
+
+ void main() {
+ float tDelay = aAnimation.x;
+ float tDuration = aAnimation.y;
+ float tTime = clamp(uTime - tDelay, 0.0, tDuration);
+ float tProgress = tTime / tDuration;
+
+ vec3 tPosition = position;
+ tPosition *= 1.0 - tProgress;
+ tPosition += cubicBezier(position, aControl0 * uArcScale, aControl1 * uArcScale, vec3(0.0), tProgress);
+
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(tPosition, 1.0);
+ }
+`;
+
+const fragmentShader = /* glsl */ `
+ uniform vec3 uColor;
+
+ void main() {
+ gl_FragColor = vec4(uColor, 1.0);
+ }
+`;
+
+const quadEaseInOut = (t: number): number => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t);
+const quadEaseOut = (t: number): number => 1 - (1 - t) * (1 - t);
+
+interface SchedulerWithYield {
+ yield?: () => Promise;
+}
+
+/**
+ * Hands the main thread back so the browser can paint and answer input.
+ *
+ * Building the wordmark is ~44k faces of triangulation and attribute filling. Run as one
+ * task on a throttled phone that is a third of a second in which nothing responds, and it
+ * lands squarely in Lighthouse's Total Blocking Time window. Split across tasks the work
+ * costs the same but never blocks for more than a frame or two.
+ *
+ * `scheduler.yield` resumes ahead of unrelated tasks where it exists; the setTimeout
+ * fallback goes to the back of the queue but still breaks the task up.
+ */
+function yieldToMain(): Promise {
+ const scheduler = (globalThis as { scheduler?: SchedulerWithYield }).scheduler;
+ if (scheduler?.yield) return scheduler.yield();
+ return new Promise((resolve) => {
+ setTimeout(resolve, 0);
+ });
+}
+
+/**
+ * How long a chunk of face work may run before handing the thread back. Well under the
+ * 50ms that makes a task "long", with room for the browser's own work in the same frame.
+ * A time budget rather than a face count because the same chunk size is a rounding error
+ * on a desktop and a visible stall on a throttled phone.
+ */
+const CHUNK_BUDGET_MS = 8;
+
+/** Faces between clock checks — often enough to hold the budget, rare enough to be free. */
+const CHUNK_CHECK_INTERVAL = 256;
+
+/**
+ * Per-face animation attributes, mirroring the loop in the original TextAnimation.
+ * Control points are shared by a face's three vertices, but the delay gets an extra
+ * Math.random() *per vertex* — that per-vertex jitter is what smears triangles apart
+ * mid-flight instead of moving them as rigid shards.
+ *
+ * Returns the animation duration: maxDelay + maxDuration + max per-vertex jitter.
+ */
+async function buildShardAttributes(geometry: BufferGeometry): Promise {
+ const position = geometry.getAttribute('position');
+ const vertexCount = position.count;
+ const faceCount = vertexCount / 3;
+
+ // Read straight out of the backing store. At this vertex count the getX/getY/getZ
+ // accessor calls are a measurable share of the loop on a throttled device.
+ const points = position.array as ArrayLike;
+
+ const animation = new Float32Array(vertexCount * 2);
+ const control0 = new Float32Array(vertexCount * 3);
+ const control1 = new Float32Array(vertexCount * 3);
+
+ let chunkStart = performance.now();
+
+ for (let face = 0; face < faceCount; face++) {
+ if (face % CHUNK_CHECK_INTERVAL === 0 && performance.now() - chunkStart > CHUNK_BUDGET_MS) {
+ await yieldToMain();
+ chunkStart = performance.now();
+ }
+
+ const a = face * 3;
+ const p = a * 3;
+
+ const centroidX = (points[p] + points[p + 3] + points[p + 6]) / 3;
+ const centroidY = (points[p + 1] + points[p + 4] + points[p + 7]) / 3;
+ const centroidZ = (points[p + 2] + points[p + 5] + points[p + 8]) / 3;
+
+ const dirX = centroidX > 0 ? 1 : -1;
+ const dirY = centroidY > 0 ? 1 : -1;
+
+ const distance = Math.sqrt(centroidX * centroidX + centroidY * centroidY + centroidZ * centroidZ);
+ const delay = distance * MathUtils.randFloat(0.03, 0.06);
+ const duration = MathUtils.randFloat(2, 4);
+
+ const c0x = MathUtils.randFloat(0, 30) * dirX;
+ const c0y = MathUtils.randFloat(60, 120) * dirY;
+ const c0z = MathUtils.randFloat(-20, 20);
+
+ const c1x = MathUtils.randFloat(30, 60) * dirX;
+ const c1y = MathUtils.randFloat(0, 60) * dirY;
+ const c1z = MathUtils.randFloat(-20, 20);
+
+ for (let v = 0; v < 3; v++) {
+ const i = a + v;
+ const i2 = i * 2;
+ const i3 = i * 3;
+
+ animation[i2] = delay + Math.random();
+ animation[i2 + 1] = duration;
+
+ control0[i3] = c0x;
+ control0[i3 + 1] = c0y;
+ control0[i3 + 2] = c0z;
+
+ control1[i3] = c1x;
+ control1[i3 + 1] = c1y;
+ control1[i3 + 2] = c1z;
+ }
+ }
+
+ geometry.setAttribute('aAnimation', new BufferAttribute(animation, 2));
+ geometry.setAttribute('aControl0', new BufferAttribute(control0, 3));
+ geometry.setAttribute('aControl1', new BufferAttribute(control1, 3));
+
+ geometry.computeBoundingBox();
+ const bounds = geometry.boundingBox;
+ const size = bounds ? bounds.max.clone().sub(bounds.min) : new Vector3(TEXT_SIZE, TEXT_SIZE, TEXT_SIZE);
+ const maxDelay = size.multiplyScalar(0.5).length() * 0.06;
+
+ return maxDelay + 4 + 1;
+}
+
+interface Wordmark {
+ geometry: BufferGeometry;
+ width: number;
+ height: number;
+ duration: number;
+}
+
+export interface HeroAnimation {
+ destroy: () => void;
+}
+
+export async function createHeroAnimation(canvas: HTMLCanvasElement): Promise {
+ const spacedTypeface = {
+ ...typefaceData,
+ glyphs: Object.fromEntries(
+ Object.entries(typefaceData.glyphs).map(([char, glyph]) => [char, { ...glyph, ha: glyph.ha + TRACKING }]),
+ ),
+ };
+ const font = new FontLoader().parse(spacedTypeface);
+
+ /** Builds the (possibly multi-line) wordmark with every line centred, plus its shard attributes. */
+ async function buildWordmark(lines: string[]): Promise {
+ const parts: BufferGeometry[] = [];
+
+ for (const [index, line] of lines.entries()) {
+ // One TextGeometry is the single largest indivisible piece of work here (~40ms per
+ // line on a throttled phone). Give each one a task of its own — including the first,
+ // which otherwise lands in the same task as this module's own evaluation.
+ await yieldToMain();
+
+ const part = new TextGeometry(line, {
+ font,
+ size: TEXT_SIZE,
+ depth: 0,
+ bevelEnabled: true,
+ bevelSize: BEVEL_SIZE,
+ bevelThickness: BEVEL_THICKNESS,
+ });
+
+ // The material is flat, unlit and single-coloured: the shader reads `position` and
+ // the three shard attributes, nothing else. Dropping the generated normals and UVs
+ // keeps them out of the merge and out of the ~130k-vertex buffer upload.
+ part.deleteAttribute('normal');
+ part.deleteAttribute('uv');
+
+ part.computeBoundingBox();
+ const b = part.boundingBox;
+ if (b) part.translate(-b.min.x - (b.max.x - b.min.x) / 2, -index * TEXT_SIZE * LINE_HEIGHT, 0);
+ parts.push(part);
+ }
+
+ await yieldToMain();
+
+ const merged = parts.length === 1 ? parts[0] : mergeGeometries(parts);
+ if (parts.length > 1) for (const part of parts) part.dispose();
+
+ // Equivalent of THREE.BAS.Utils.separateFaces: every triangle must own its vertices
+ // so per-face attributes do not bleed between shards.
+ const geometry = merged.index === null ? merged : merged.toNonIndexed();
+ if (geometry !== merged) merged.dispose();
+
+ // anchor: {x: 0.5, y: 0.5, z: 0.5}
+ geometry.center();
+ geometry.computeBoundingBox();
+ const bounds = geometry.boundingBox;
+
+ await yieldToMain();
+
+ return {
+ geometry,
+ width: bounds ? bounds.max.x - bounds.min.x : TEXT_SIZE,
+ height: bounds ? bounds.max.y - bounds.min.y : TEXT_SIZE,
+ duration: await buildShardAttributes(geometry),
+ };
+ }
+
+ const material = new ShaderMaterial({
+ vertexShader,
+ fragmentShader,
+ side: DoubleSide,
+ uniforms: {
+ uTime: { value: 0 },
+ uArcScale: { value: 1 },
+ uColor: { value: new Color(0x000000) },
+ },
+ });
+
+ const titleElement = canvas.parentElement?.querySelector('h1') ?? null;
+
+ /** The h1 fallback's computed font-size — the wordmark matches it so canvas and fallback read the same. */
+ function measureTitleEmPx(): number {
+ if (titleElement) {
+ const px = Number.parseFloat(getComputedStyle(titleElement).fontSize);
+ if (Number.isFinite(px) && px > 0) return px;
+ }
+ // Mirrors the h1's clamp(1rem, 7.8vw, 8rem) if the element is missing.
+ return MathUtils.clamp(window.innerWidth * 0.078, 16, 128);
+ }
+
+ const measureLineCount = (): number => (measureTitleEmPx() < MIN_SINGLE_LINE_EM_PX ? 2 : 1);
+ const linesFor = (count: number): string[] => (count === 1 ? [HERO_TEXT] : HERO_TEXT.split(' '));
+
+ // Choose the layout before building. Building single-line and letting the first resize()
+ // discard it meant every phone-width viewport — where the reflow always triggers —
+ // triangulated the wordmark twice, on exactly the devices least able to afford it.
+ let lineCount = measureLineCount();
+ let wordmark = await buildWordmark(linesFor(lineCount));
+
+ const mesh = new Mesh(wordmark.geometry, material);
+ mesh.frustumCulled = false;
+
+ const scene = new Scene();
+ scene.add(mesh);
+
+ const camera = new PerspectiveCamera(CAMERA_FOV, 1, 1, 10000);
+
+ // Creating the GL context costs about as much as building a line of text; keep it out
+ // of the task that just finished the shard attributes.
+ await yieldToMain();
+
+ const renderer = new WebGLRenderer({ canvas, antialias: true, alpha: true });
+ renderer.setClearColor(0xffffff, 0);
+
+ let rebuilding = false;
+
+ /** Rebuilds the wordmark when the viewport crosses the one/two-line threshold. */
+ async function reflow(): Promise {
+ const nextLineCount = measureLineCount();
+ if (rebuilding || nextLineCount === lineCount) return false;
+
+ rebuilding = true;
+ try {
+ const next = await buildWordmark(linesFor(nextLineCount));
+ wordmark.geometry.dispose();
+ wordmark = next;
+ lineCount = nextLineCount;
+ mesh.geometry = next.geometry;
+ } finally {
+ rebuilding = false;
+ }
+ return true;
+ }
+
+ /** Fits the drawing buffer and camera to the canvas, then repaints. */
+ function applyLayout(): void {
+ const { clientWidth, clientHeight } = canvas;
+ if (clientWidth === 0 || clientHeight === 0) return;
+
+ // Cap the pixel ratio: an uncapped one blows the framebuffer up on 3x displays for
+ // no visible gain on flat black shapes.
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
+ renderer.setSize(clientWidth, clientHeight, false);
+
+ const emPx = measureTitleEmPx();
+ const aspect = clientWidth / clientHeight;
+ camera.aspect = aspect;
+
+ const tanHalfFov = Math.tan((CAMERA_FOV / 2) * (Math.PI / 180));
+
+ // Distance at which the resting wordmark's em renders as emPx pixels — i.e. the
+ // same type size as the h1 fallback.
+ const zMatchTitle = (REST_SCALE * TEXT_SIZE * clientHeight) / (emPx * 2 * tanHalfFov);
+ // Never let the wordmark overflow the canvas, whatever the title size asks for.
+ const maxFill = lineCount === 1 ? SINGLE_LINE_MAX_FILL : TWO_LINE_FILL;
+ const zFitWidth = (REST_SCALE * wordmark.width) / maxFill / (2 * tanHalfFov * aspect);
+ const zFitHeight = (REST_SCALE * wordmark.height) / BLOCK_MAX_HEIGHT_RATIO / (2 * tanHalfFov);
+ const z = Math.max(zMatchTitle, zFitWidth, zFitHeight);
+
+ camera.position.set(0, 0, z);
+ camera.updateProjectionMatrix();
+
+ // Scale the bezier arcs with the visible stage height so the debris field fills
+ // the canvas the way the original fills its window.
+ material.uniforms.uArcScale.value = z / REFERENCE_CAMERA_Z;
+
+ // A resize must repaint immediately: waiting for the next rAF leaves a stretched
+ // stale frame on screen for a beat (or indefinitely, in a backgrounded tab).
+ applyProgress();
+ renderer.render(scene, camera);
+ }
+
+ /**
+ * Repaints at the new size straight away, then — only when the viewport has crossed the
+ * one/two-line threshold — rebuilds the wordmark and fits the camera to it. The first
+ * pass may fit the outgoing geometry for a frame; that beats leaving a stretched frame
+ * on screen for the length of a rebuild.
+ */
+ function resize(): void {
+ applyLayout();
+ reflow()
+ .then((rebuilt) => {
+ if (rebuilt) applyLayout();
+ })
+ .catch((error: unknown) => {
+ // A failed reflow leaves the previous wordmark on screen, which still reads.
+ console.error('Hero wordmark reflow failed.', error);
+ });
+ }
+
+ // --- tween state (TweenMax.fromTo(..., 4, {animationProgress: 0 → 1, yoyo, repeat: -1})) ---
+
+ /** Linear time along the infinite yoyo timeline, in seconds. */
+ let tweenTime = 0;
+ /** Playback rate; dragged to 0 on press and back to 1 on release. */
+ let timeScale = 1;
+ let timeScaleFrom = 1;
+ let timeScaleTarget = 1;
+ let timeScaleElapsed = TIMESCALE_SECONDS;
+
+ function applyProgress(): void {
+ const cycle = (tweenTime % (TWEEN_SECONDS * 2)) / TWEEN_SECONDS;
+ const linear = cycle <= 1 ? cycle : 2 - cycle;
+ material.uniforms.uTime.value = wordmark.duration * quadEaseInOut(linear);
+ }
+
+ let frame = 0;
+ let last = performance.now();
+
+ function tick(now: number): void {
+ frame = requestAnimationFrame(tick);
+
+ const delta = Math.min((now - last) / 1000, 0.1);
+ last = now;
+
+ // TweenMax.to(tween, 2, {timeScale}) with GSAP's default Power1.easeOut.
+ if (timeScaleElapsed < TIMESCALE_SECONDS) {
+ timeScaleElapsed = Math.min(timeScaleElapsed + delta, TIMESCALE_SECONDS);
+ timeScale = timeScaleFrom + (timeScaleTarget - timeScaleFrom) * quadEaseOut(timeScaleElapsed / TIMESCALE_SECONDS);
+ }
+
+ tweenTime += delta * timeScale;
+ applyProgress();
+ renderer.render(scene, camera);
+ }
+
+ // --- scrubber (createTweenScrubber) ---
+
+ function tweenTimeScaleTo(target: number): void {
+ timeScaleFrom = timeScale;
+ timeScaleTarget = target;
+ timeScaleElapsed = 0;
+ }
+
+ /** Seek within the current iteration, clamped to it — same as the original's tween.progress(). */
+ function seek(dx: number): void {
+ const iteration = Math.floor(tweenTime / TWEEN_SECONDS);
+ const progress = tweenTime / TWEEN_SECONDS - iteration;
+ const next = MathUtils.clamp(progress + dx * SEEK_SPEED, 0, 1);
+ tweenTime = (iteration + next) * TWEEN_SECONDS;
+ }
+
+ let pointerX = 0;
+ let dragging = false;
+
+ canvas.style.cursor = 'pointer';
+ canvas.style.touchAction = 'none';
+
+ function onPointerDown(event: PointerEvent): void {
+ // Stops the drag from starting a native text selection on the rest of the page.
+ event.preventDefault();
+ dragging = true;
+ pointerX = event.clientX;
+ canvas.style.cursor = 'ew-resize';
+ canvas.setPointerCapture(event.pointerId);
+ tweenTimeScaleTo(0);
+ }
+
+ function onPointerMove(event: PointerEvent): void {
+ if (!dragging) return;
+ const dx = event.clientX - pointerX;
+ pointerX = event.clientX;
+ seek(dx);
+ }
+
+ function onPointerUp(event: PointerEvent): void {
+ if (!dragging) return;
+ dragging = false;
+ canvas.style.cursor = 'pointer';
+ canvas.releasePointerCapture(event.pointerId);
+ tweenTimeScaleTo(1);
+ }
+
+ canvas.addEventListener('pointerdown', onPointerDown);
+ canvas.addEventListener('pointermove', onPointerMove);
+ canvas.addEventListener('pointerup', onPointerUp);
+ canvas.addEventListener('pointercancel', onPointerUp);
+
+ function start(): void {
+ if (frame !== 0) return;
+ last = performance.now();
+ frame = requestAnimationFrame(tick);
+ }
+
+ function stop(): void {
+ if (frame === 0) return;
+ cancelAnimationFrame(frame);
+ frame = 0;
+ }
+
+ function onVisibilityChange(): void {
+ if (document.hidden) stop();
+ else start();
+ }
+
+ // The first render compiles the shader and uploads every buffer — the last big chunk,
+ // and one more task boundary keeps it off the back of the GL context creation.
+ await yieldToMain();
+
+ resize();
+ start();
+
+ // Watching the element rather than the window also covers the case where the canvas
+ // gains its layout box after this runs.
+ const observer = new ResizeObserver(resize);
+ observer.observe(canvas);
+ document.addEventListener('visibilitychange', onVisibilityChange);
+
+ return {
+ destroy() {
+ stop();
+ observer.disconnect();
+ document.removeEventListener('visibilitychange', onVisibilityChange);
+ canvas.removeEventListener('pointerdown', onPointerDown);
+ canvas.removeEventListener('pointermove', onPointerMove);
+ canvas.removeEventListener('pointerup', onPointerUp);
+ canvas.removeEventListener('pointercancel', onPointerUp);
+ wordmark.geometry.dispose();
+ material.dispose();
+ renderer.dispose();
+ },
+ };
+}
diff --git a/src/styles/global.css b/src/styles/global.css
new file mode 100644
index 0000000..0b43fa3
--- /dev/null
+++ b/src/styles/global.css
@@ -0,0 +1,158 @@
+:root {
+ --color-bg: #fff;
+ --color-ink: #000;
+ --color-muted: #666;
+ /* The lightest grey that still clears WCAG AA (4.6:1 on white) at the footer's size,
+ so the footer stays recessed against --color-muted without failing contrast. */
+ --color-faint: #757575;
+
+ --font-sans: 'Archivo Variable', system-ui, -apple-system, 'Segoe UI', sans-serif;
+
+ --gutter: clamp(1rem, 3vw, 2rem);
+ --mark-size: clamp(1.75rem, 4vw, 3rem);
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html {
+ -webkit-text-size-adjust: 100%;
+}
+
+body {
+ margin: 0;
+ min-height: 100svh;
+ display: flex;
+ flex-direction: column;
+ background: var(--color-bg);
+ color: var(--color-ink);
+ font-family: var(--font-sans);
+ overflow-x: hidden;
+ -webkit-font-smoothing: antialiased;
+}
+
+img,
+canvas {
+ display: block;
+ max-width: 100%;
+}
+
+/* ---------- Mark ---------- */
+
+.mark {
+ position: fixed;
+ inset-block-start: var(--gutter);
+ inset-inline-start: var(--gutter);
+ z-index: 1;
+ width: var(--mark-size);
+ height: var(--mark-size);
+}
+
+/* ---------- Hero ---------- */
+
+.hero {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: clamp(0.75rem, 2vw, 1.5rem);
+ /* Tighter than --gutter: a single-line wordmark is width-starved on phones, so every
+ pixel of horizontal room goes to the text. */
+ padding-inline: clamp(0.5rem, 2vw, 2rem);
+}
+
+/*
+ * The stage owns an explicit height so the tagline sits immediately below the
+ * wordmark. A full-viewport canvas would leave the tagline's position depending on
+ * the rendered text height, which drifts on every resize.
+ */
+.hero__stage {
+ position: relative;
+ width: 100%;
+ /* Tall enough for the shard scatter (~2 cap heights either side of the wordmark)
+ and no taller, so the tagline still sits right under the animation. */
+ height: min(36svh, 30vw);
+ min-height: 6rem;
+}
+
+.hero__canvas {
+ width: 100%;
+ height: 100%;
+ /* Revealed only once WebGL has actually initialised. */
+ display: none;
+}
+
+.webgl-ready .hero__canvas {
+ display: block;
+}
+
+/*
+ * The real heading is always in the DOM and visible by default, so the page still
+ * reads without JS, without WebGL and to crawlers. WebGL replaces it rather than
+ * layering on top of it.
+ */
+.hero__title {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin: 0;
+ /* Uppercased in CSS only: the DOM keeps the brand's real casing for crawlers and
+ screen readers while the visible fallback matches the WebGL wordmark.
+ The vw factor is sized so the uppercased string never overflows at nowrap. */
+ font-size: clamp(1rem, 7.8vw, 8rem);
+ font-weight: 700;
+ letter-spacing: -0.01em;
+ line-height: 1;
+ text-transform: uppercase;
+ white-space: nowrap;
+}
+
+.webgl-ready .hero__title {
+ /* Kept for assistive tech and crawlers once the canvas takes over. */
+ clip-path: inset(50%);
+ height: 1px;
+ width: 1px;
+ inset: auto;
+ overflow: hidden;
+ white-space: nowrap;
+}
+
+.hero__tagline {
+ margin: 0;
+ color: var(--color-muted);
+ font-size: clamp(0.8rem, 1.6vw, 1rem);
+ font-weight: 500;
+ letter-spacing: 0.32em;
+ text-indent: 0.32em;
+ text-transform: uppercase;
+}
+
+/* ---------- Footer ---------- */
+
+.footer {
+ padding: var(--gutter);
+ color: var(--color-faint);
+ font-size: clamp(0.68rem, 1.2vw, 0.8rem);
+ letter-spacing: 0.04em;
+ text-align: center;
+}
+
+.footer p {
+ margin: 0;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..990abae
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "astro/tsconfigs/strict",
+ "include": [".astro/types.d.ts", "**/*"],
+ "exclude": ["dist"],
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ }
+}