From cd8a99d6698126bc2aaa5a0c0d8c3d4d55ab0612 Mon Sep 17 00:00:00 2001 From: Alex Dolid Date: Tue, 4 Aug 2026 01:10:25 +0300 Subject: [PATCH 1/4] feat: initial site implementation Adds the full static site: Astro config, BaseLayout, Hero with WebGL wordmark animation, LogoMark, SiteFooter, global styles, brand assets (logo.svg, favicon.svg), robots.txt, CI workflow, CLAUDE.md guidance, and the offline font subsetting script with committed typeface.json. --- .github/workflows/ci.yml | 32 ++ .nvmrc | 1 + .oxfmtrc.json | 2 +- CLAUDE.md | 94 ++++++ README.md | 143 ++++++++- astro.config.mjs | 18 ++ package.json | 26 +- pnpm-lock.yaml | 89 +++++- public/favicon.svg | 9 + public/logo.svg | 1 + public/robots.txt | 4 + scripts/fonts/build-typeface.mjs | 157 ++++++++++ src/assets/fonts/archivo-bold.typeface.json | 1 + src/components/Hero.astro | 32 ++ src/components/LogoMark.astro | 6 + src/components/SiteFooter.astro | 19 ++ src/layouts/BaseLayout.astro | 42 +++ src/pages/index.astro | 15 + src/scripts/hero-animation.ts | 314 ++++++++++++++++++++ src/styles/global.css | 156 ++++++++++ tsconfig.json | 11 + 21 files changed, 1151 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .nvmrc create mode 100644 CLAUDE.md create mode 100644 astro.config.mjs create mode 100644 public/favicon.svg create mode 100644 public/logo.svg create mode 100644 public/robots.txt create mode 100644 scripts/fonts/build-typeface.mjs create mode 100644 src/assets/fonts/archivo-bold.typeface.json create mode 100644 src/components/Hero.astro create mode 100644 src/components/LogoMark.astro create mode 100644 src/components/SiteFooter.astro create mode 100644 src/layouts/BaseLayout.astro create mode 100644 src/pages/index.astro create mode 100644 src/scripts/hero-animation.ts create mode 100644 src/styles/global.css create mode 100644 tsconfig.json 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..6daf8a5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,94 @@ +# 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`. + +- 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. +- Scatter and control-point magnitudes are multiples of the wordmark's **cap height**, + never absolute units. Tie them to the nominal font size and the shards scatter several + times the wordmark's own height, which reads as noise rather than as letters breaking + apart. +- GSAP and THREE.BAS are deliberately not used; the effect they provided is ~40 lines of + GLSL here. Do not reintroduce them. +- 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. +- 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 + +![Astro](https://img.shields.io/badge/Astro-7-BC52EE) +![TypeScript](https://img.shields.io/badge/TypeScript-6-3178C6) +![three.js](https://img.shields.io/badge/three.js-r185-000000) +![pnpm](https://img.shields.io/badge/pnpm-10.34-F69220) +![License](https://img.shields.io/badge/License-Proprietary-red) + +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 `

` is always in the DOM and visible by default; the canvas +replaces it only once WebGL has actually initialised. That covers no-JS, no-WebGL and +crawlers for free. When `prefers-reduced-motion: reduce` is set, WebGL never boots at all +and the plain heading stays. + +## Brand assets + +`public/logo.svg` is the full vertical lock-up delivered by design — mark, "Alexis" +wordmark and "TECHNOLOGIES" tagline. Treat it as read-only. + +`public/favicon.svg` is the dotted X mark cut out of it (`viewBox="0 0 120 120"`). It is +used both as the favicon and as the corner mark on the page, so the browser fetches one +file for both. If `logo.svg` ever changes, re-cut it. + +## Code style + +Enforced by oxfmt and `.editorconfig`: + +- 2-space indent, LF endings, final newline +- single quotes, semicolons, trailing commas everywhere +- 120-column print width + +Lint rules live in [`.oxlintrc.json`](.oxlintrc.json). This project deliberately does not +use ESLint or Prettier — do not add them. + +Commits follow [Conventional Commits](https://www.conventionalcommits.org/). + +## Deployment + +`pnpm build` emits a fully static site to `dist/`. Vercel picks it up directly; there is +no Astro adapter and no server runtime. + +The three.js bundle is roughly 139 KB gzipped — the dominant asset on the page, and +inherent to shipping a WebGL renderer. + +## License + +Proprietary. Copyright © Alexis Technologies. All rights reserved. + +See [LICENSE](LICENSE) — no part of this repository may be copied, modified or +distributed without prior written permission. diff --git a/astro.config.mjs b/astro.config.mjs new file mode 100644 index 0000000..db0565b --- /dev/null +++ b/astro.config.mjs @@ -0,0 +1,18 @@ +import sitemap from '@astrojs/sitemap'; +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + site: 'https://alexis.dev', + output: 'static', + integrations: [sitemap()], + build: { + inlineStylesheets: 'always', + }, + vite: { + build: { + // The whole site is a single page; splitting CSS across chunks only adds + // requests to the critical path. + cssCodeSplit: false, + }, + }, +}); diff --git a/package.json b/package.json index a32c4ce..5becf31 100644 --- a/package.json +++ b/package.json @@ -9,14 +9,22 @@ "preview": "astro preview", "check": "astro check", "lint": "oxlint src scripts astro.config.mjs", + "lint:fix": "oxlint --fix src scripts astro.config.mjs", "format": "oxfmt src scripts astro.config.mjs", - "format:check": "oxfmt --check src scripts astro.config.mjs" + "format:check": "oxfmt --check src scripts astro.config.mjs", + "fonts:typeface": "node scripts/fonts/build-typeface.mjs" }, "repository": { "type": "git", "url": "git+https://github.com/Alexis-Technologies/website.git" }, - "keywords": [], + "keywords": [ + "alexis-technologies", + "astro", + "three.js", + "webgl", + "website" + ], "author": "Alex Dolid ", "bugs": { "url": "https://github.com/Alexis-Technologies/website/issues" @@ -24,18 +32,24 @@ "homepage": "https://alexis.dev/", "dependencies": { "@vercel/analytics": "^2.0.1", - "@vercel/speed-insights": "^2.0.0" + "@vercel/speed-insights": "^2.0.0", + "three": "^0.185.1" }, "devDependencies": { "@astrojs/check": "^0.9.10", "@astrojs/sitemap": "^3.7.3", "@fontsource-variable/archivo": "^5.3.0", - "@fontsource-variable/inter": "^5.3.0", - "@fontsource-variable/jetbrains-mono": "^5.3.0", + "@fontsource/archivo": "^5.3.0", + "@types/three": "^0.185.3", "astro": "^7.1.6", + "opentype.js": "^2.0.0", "oxfmt": "^0.58.0", "oxlint": "^1.73.0", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "wawoff2": "^2.0.1" + }, + "engines": { + "node": ">=22.12.0" }, "packageManager": "pnpm@10.34.5", "pnpm": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d74449..accd710 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@vercel/speed-insights': specifier: ^2.0.0 version: 2.0.0 + three: + specifier: ^0.185.1 + version: 0.185.1 devDependencies: '@astrojs/check': specifier: ^0.9.10 @@ -24,15 +27,18 @@ importers: '@fontsource-variable/archivo': specifier: ^5.3.0 version: 5.3.0 - '@fontsource-variable/inter': - specifier: ^5.3.0 - version: 5.3.0 - '@fontsource-variable/jetbrains-mono': + '@fontsource/archivo': specifier: ^5.3.0 version: 5.3.0 + '@types/three': + specifier: ^0.185.3 + version: 0.185.3 astro: specifier: ^7.1.6 version: 7.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0) + opentype.js: + specifier: ^2.0.0 + version: 2.0.0 oxfmt: specifier: ^0.58.0 version: 0.58.0 @@ -42,6 +48,9 @@ importers: typescript: specifier: ^6.0.3 version: 6.0.3 + wawoff2: + specifier: ^2.0.1 + version: 2.0.1 packages: @@ -229,6 +238,9 @@ packages: resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} engines: {node: '>= 20.12.0'} + '@dimforge/rapier3d-compat@0.12.0': + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + '@emmetio/abbreviation@2.3.3': resolution: {integrity: sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==} @@ -421,11 +433,8 @@ packages: '@fontsource-variable/archivo@5.3.0': resolution: {integrity: sha512-HogK8FJelrD1o7TlZlkIVtHgc20bO5PZRWE7mUeUTdMN055alznQV6/00J00IBeu8FQAH4s3zW9UJNvKExXf+g==} - '@fontsource-variable/inter@5.3.0': - resolution: {integrity: sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==} - - '@fontsource-variable/jetbrains-mono@5.3.0': - resolution: {integrity: sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==} + '@fontsource/archivo@5.3.0': + resolution: {integrity: sha512-5DIMgPVJRi62OqdOVoogCFxP73EkNM/E0YVTSDIQlDEJfDbxqZduwM/YoNZwC9Sx1CDgpKinqf8ckRY1QNIecw==} '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} @@ -982,6 +991,9 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@tweenjs/tween.js@23.1.3': + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} @@ -1006,9 +1018,18 @@ packages: '@types/sax@1.2.7': resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + + '@types/three@0.185.3': + resolution: {integrity: sha512-8TqTn1+fjPWuJ4mR6Igtg56DCf9b5EeAlwhb5xaa6WlBrsg7SvG0NQbyctGRkHCKwg0uftfCspOEsTXelgktMA==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/webxr@0.5.24': + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + '@ungap/structured-clone@1.3.3': resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} @@ -1324,6 +1345,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + flattie@1.1.1: resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} engines: {node: '>=8'} @@ -1507,6 +1531,9 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + meshoptimizer@1.1.1: + resolution: {integrity: sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==} + micromark-util-character@2.1.1: resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} @@ -1570,6 +1597,10 @@ packages: oniguruma-to-es@4.3.6: resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + opentype.js@2.0.0: + resolution: {integrity: sha512-kCyjv6xdDY1W/jLWZ/L3QhhTlKUqDZMQ5+Jdlw12b3dXkKNpYBqqlMMj0YDQPShWFTMwgZI1hG14kN3XUDSg/A==} + hasBin: true + oxfmt@0.58.0: resolution: {integrity: sha512-8feG/7NVEHDVwc1OUpP6Pks+TnaDFUw2jLLFIMi5bcmmwxAX2wBQvjSzj62RRTYBf2Op1Wt8xbkmagmPTR5ETg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1759,6 +1790,9 @@ packages: engines: {node: '>=16'} hasBin: true + three@0.185.1: + resolution: {integrity: sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==} + tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} @@ -2055,6 +2089,10 @@ packages: vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + wawoff2@2.0.1: + resolution: {integrity: sha512-r0CEmvpH63r4T15ebFqeOjGqU4+EgTx4I510NtK35EMciSdcTxCw3Byy3JnBonz7iyIFZ0AbVo0bbFpEVuhCYA==} + hasBin: true + web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} @@ -2295,6 +2333,8 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 + '@dimforge/rapier3d-compat@0.12.0': {} + '@emmetio/abbreviation@2.3.3': dependencies: '@emmetio/scanner': 1.0.4 @@ -2419,9 +2459,7 @@ snapshots: '@fontsource-variable/archivo@5.3.0': {} - '@fontsource-variable/inter@5.3.0': {} - - '@fontsource-variable/jetbrains-mono@5.3.0': {} + '@fontsource/archivo@5.3.0': {} '@img/colour@1.1.0': optional: true @@ -2754,6 +2792,8 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@tweenjs/tween.js@23.1.3': {} + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 @@ -2785,8 +2825,21 @@ snapshots: dependencies: '@types/node': 24.13.3 + '@types/stats.js@0.17.4': {} + + '@types/three@0.185.3': + dependencies: + '@dimforge/rapier3d-compat': 0.12.0 + '@tweenjs/tween.js': 23.1.3 + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.24 + fflate: 0.8.3 + meshoptimizer: 1.1.1 + '@types/unist@3.0.3': {} + '@types/webxr@0.5.24': {} + '@ungap/structured-clone@1.3.3': {} '@vercel/analytics@2.0.1': {} @@ -3141,6 +3194,8 @@ snapshots: optionalDependencies: picomatch: 4.0.5 + fflate@0.8.3: {} + flattie@1.1.1: {} fontace@0.4.1: @@ -3327,6 +3382,8 @@ snapshots: mdn-data@2.27.1: {} + meshoptimizer@1.1.1: {} + micromark-util-character@2.1.1: dependencies: micromark-util-symbol: 2.0.1 @@ -3384,6 +3441,8 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 + opentype.js@2.0.0: {} + oxfmt@0.58.0: dependencies: tinypool: 2.1.0 @@ -3634,6 +3693,8 @@ snapshots: picocolors: 1.1.1 sax: 1.6.1 + three@0.185.1: {} + tiny-inflate@1.0.3: {} tinyclip@0.1.15: {} @@ -3858,6 +3919,10 @@ snapshots: vscode-uri@3.1.0: {} + wawoff2@2.0.1: + dependencies: + argparse: 2.0.1 + web-namespaces@2.0.1: {} wrap-ansi@9.0.2: diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..30dc0aa --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 0000000..c99bce9 --- /dev/null +++ b/public/logo.svg @@ -0,0 +1 @@ + diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..37f6e3e --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://alexis.dev/sitemap-index.xml diff --git a/scripts/fonts/build-typeface.mjs b/scripts/fonts/build-typeface.mjs new file mode 100644 index 0000000..5c5ef95 --- /dev/null +++ b/scripts/fonts/build-typeface.mjs @@ -0,0 +1,157 @@ +/** + * Generates the subsetted typeface.json consumed by three.js FontLoader. + * + * three.js TextGeometry cannot read woff2/ttf directly — it only understands the + * typeface.json format produced by facetype.js. This script reproduces that + * conversion offline so the result can be committed and shipped as a tiny asset. + * + * Source is the *static* Archivo Bold face. The variable font is not usable here: + * opentype.js does not apply gvar deltas, so it would silently emit weight 400. + * + * Run manually after changing the hero copy or the typeface: + * pnpm fonts:typeface + */ + +import { createRequire } from 'node:module'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const opentype = require('opentype.js'); +const { decompress } = require('wawoff2'); + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); + +const SOURCE_WOFF2 = resolve(ROOT, 'node_modules/@fontsource/archivo/files/archivo-latin-700-normal.woff2'); +const OUTPUT_JSON = resolve(ROOT, 'src/assets/fonts/archivo-bold.typeface.json'); + +/** Every character the hero renders. Keep in sync with HERO_TEXT in src/scripts/hero-animation.ts. */ +const CHARSET = 'ALEXIS TECHNOLOGIES'; + +/** Units the outlines are normalised to, mirroring what facetype.js emits. */ +const RESOLUTION = 1000; + +/** Outline coordinates are rounded to this many decimals to keep the JSON small. */ +const PRECISION = 2; + +const round = (value) => Number(value.toFixed(PRECISION)); + +/** + * Converts an opentype.js path into the space-separated command string three.js expects. + * + * The typeface format orders arguments end-point-first: `q endX endY cpX cpY` and + * `b endX endY cp1X cp1Y cp2X cp2Y`. Getting that order wrong produces glyphs that + * parse without error but render as garbage, so it is worth stating explicitly. + */ +function encodeOutline(path, scale) { + const out = []; + const sx = (v) => round(v * scale); + const sy = (v) => round(v * scale); + + for (const cmd of path.commands) { + switch (cmd.type) { + case 'M': + out.push('m', sx(cmd.x), sy(cmd.y)); + break; + case 'L': + out.push('l', sx(cmd.x), sy(cmd.y)); + break; + case 'Q': + out.push('q', sx(cmd.x), sy(cmd.y), sx(cmd.x1), sy(cmd.y1)); + break; + case 'C': + out.push('b', sx(cmd.x), sy(cmd.y), sx(cmd.x1), sy(cmd.y1), sx(cmd.x2), sy(cmd.y2)); + break; + case 'Z': + break; + default: + throw new Error(`Unsupported path command: ${cmd.type}`); + } + } + + return out.join(' '); +} + +async function main() { + const woff2 = await readFile(SOURCE_WOFF2); + const ttf = await decompress(woff2); + const font = opentype.parse(Uint8Array.from(ttf).buffer); + + // Everything is expressed in RESOLUTION units regardless of the source unitsPerEm. + const scale = RESOLUTION / font.unitsPerEm; + + const glyphs = {}; + const chars = [...new Set([...CHARSET])].sort(); + + for (const char of chars) { + const glyph = font.charToGlyph(char); + + if (!glyph || (glyph.index === 0 && char !== ' ')) { + throw new Error(`Font has no glyph for ${JSON.stringify(char)}`); + } + + const path = glyph.getPath(0, 0, font.unitsPerEm); + // getPath flips Y for screen coordinates; the typeface format keeps font-space Y-up. + for (const cmd of path.commands) { + for (const key of ['y', 'y1', 'y2']) { + if (key in cmd) cmd[key] = -cmd[key]; + } + } + + const bbox = glyph.getBoundingBox(); + + glyphs[char] = { + ha: round(glyph.advanceWidth * scale), + x_min: round((bbox.x1 || 0) * scale), + x_max: round((bbox.x2 || 0) * scale), + o: encodeOutline(path, scale), + }; + } + + const os2 = font.tables.os2 ?? {}; + const post = font.tables.post ?? {}; + const head = font.tables.head ?? {}; + + const typeface = { + glyphs, + familyName: font.names.fontFamily?.en ?? 'Archivo', + ascender: round(font.ascender * scale), + descender: round(font.descender * scale), + underlinePosition: round((post.underlinePosition ?? -100) * scale), + underlineThickness: round((post.underlineThickness ?? 50) * scale), + boundingBox: { + xMin: round((head.xMin ?? 0) * scale), + xMax: round((head.xMax ?? 0) * scale), + yMin: round((head.yMin ?? 0) * scale), + yMax: round((head.yMax ?? 0) * scale), + }, + resolution: RESOLUTION, + original_font_information: { + // facetype.js emits this as a number, but three types the block as + // Record and never reads it at runtime. + format: '0', + copyright: font.names.copyright?.en ?? '', + fontFamily: font.names.fontFamily?.en ?? 'Archivo', + fontSubfamily: font.names.fontSubfamily?.en ?? 'Bold', + fullName: font.names.fullName?.en ?? 'Archivo Bold', + version: font.names.version?.en ?? '', + postScriptName: font.names.postScriptName?.en ?? '', + manufacturer: font.names.manufacturer?.en ?? '', + designer: font.names.designer?.en ?? '', + licenseURL: font.names.licenseURL?.en ?? '', + }, + cssFontWeight: String(os2.usWeightClass ?? 700), + cssFontStyle: 'normal', + }; + + await mkdir(dirname(OUTPUT_JSON), { recursive: true }); + await writeFile(OUTPUT_JSON, `${JSON.stringify(typeface)}\n`, 'utf8'); + + const bytes = Buffer.byteLength(JSON.stringify(typeface)); + console.log(`Wrote ${OUTPUT_JSON}`); + console.log(` glyphs: ${chars.length} (${chars.map((c) => (c === ' ' ? '␠' : c)).join('')})`); + console.log(` size: ${(bytes / 1024).toFixed(1)} KB`); +} + +await main(); diff --git a/src/assets/fonts/archivo-bold.typeface.json b/src/assets/fonts/archivo-bold.typeface.json new file mode 100644 index 0000000..d57f7dc --- /dev/null +++ b/src/assets/fonts/archivo-bold.typeface.json @@ -0,0 +1 @@ +{"glyphs":{" ":{"ha":196,"x_min":0,"x_max":0,"o":""},"A":{"ha":724,"x_min":6,"x_max":718,"o":"m 160 0 l 6 0 l 270 686 l 454 686 l 718 0 l 557 0 l 508 135 l 209 135 l 160 0 m 313 424 l 252 255 l 465 255 l 404 424 q 394.5 450.5 400 435 q 383 485 389 466 q 371.5 522.5 377 504 q 361 556 366 541 l 361 556 l 355 556 q 342 511.5 350 537 q 326 462.5 334 486 q 313 424 318 439 l 313 424"},"C":{"ha":733,"x_min":45,"x_max":692,"o":"m 383 -12 l 383 -12 q 200 25.5 276 -12 q 84.5 142 124 63 q 45 343 45 221 l 45 343 q 133.5 610.5 45 523 q 383 698 222 698 l 383 698 q 541 668 471 698 q 651.5 578 611 638 q 692 427 692 518 l 692 427 l 542 427 q 522.5 508 542 475 q 467.5 558.5 503 541 q 385 576 432 576 l 385 576 q 279.5 551 321 576 q 218.5 477.5 238 526 q 199 359 199 429 l 199 359 l 199 328 q 219 207.5 199 256 q 279.5 134.5 239 159 q 384 110 320 110 l 384 110 q 471 127 434 110 q 528 177 508 144 q 548 258 548 210 l 548 258 l 692 258 q 652.5 107 692 167 q 543 17.5 613 47 q 383 -12 473 -12"},"E":{"ha":683,"x_min":76,"x_max":630,"o":"m 630 0 l 76 0 l 76 686 l 624 686 l 624 564 l 226 564 l 226 411 l 578 411 l 578 290 l 226 290 l 226 123 l 630 123 l 630 0"},"G":{"ha":802,"x_min":51,"x_max":734,"o":"m 388 -12 l 388 -12 q 138.5 74.5 226 -12 q 51 343 51 161 l 51 343 q 93 542.5 51 464 q 215 659.5 135 621 q 407 698 295 698 l 407 698 q 533 683 474 698 q 637.5 637 592 668 q 708.5 559.5 683 606 q 734 449 734 513 l 734 449 l 583 449 q 569.5 504 583 480 q 532 544 556 528 q 477 568 508 560 q 412 576 446 576 l 412 576 q 321 562.5 360 576 q 256.5 521.5 282 549 q 218 453 231 494 q 205 358 205 412 l 205 358 l 205 328 q 227 205 205 253 q 293 133.5 249 157 q 402 110 337 110 l 402 110 q 497.5 126.5 456 110 q 562.5 174 539 143 q 586 250 586 205 l 586 250 l 586 257 l 381 257 l 381 371 l 734 371 l 734 0 l 636 0 l 623 74 q 558.5 26 593 45 q 482 -2.5 524 7 q 388 -12 440 -12"},"H":{"ha":754,"x_min":76,"x_max":678,"o":"m 225 0 l 76 0 l 76 686 l 225 686 l 225 414 l 529 414 l 529 686 l 678 686 l 678 0 l 529 0 l 529 288 l 225 288 l 225 0"},"I":{"ha":301,"x_min":76,"x_max":225,"o":"m 225 0 l 76 0 l 76 686 l 225 686 l 225 0"},"L":{"ha":591,"x_min":76,"x_max":571,"o":"m 571 0 l 76 0 l 76 686 l 225 686 l 225 127 l 571 127 l 571 0"},"N":{"ha":754,"x_min":76,"x_max":678,"o":"m 217 0 l 76 0 l 76 686 l 213 686 l 489 316 q 504 296.5 495 309 q 521 272 513 284 q 532 253 529 260 l 532 253 l 537 253 q 537 286.5 537 270 q 537 316 537 303 l 537 316 l 537 686 l 678 686 l 678 0 l 541 0 l 259 379 q 238 410 249 393 q 222 436 227 427 l 222 436 l 217 436 q 217 406.5 217 421 q 217 379 217 392 l 217 379 l 217 0"},"O":{"ha":793,"x_min":45,"x_max":748,"o":"m 396 -12 l 396 -12 q 208 27 287 -12 q 87 145 129 66 q 45 343 45 224 l 45 343 q 87 542.5 45 464 q 208 659.5 129 621 q 396 698 287 698 l 396 698 q 585.5 659.5 507 698 q 706 542.5 664 621 q 748 343 748 464 l 748 343 q 706 145 748 224 q 585.5 27 664 66 q 396 -12 507 -12 m 396 110 l 396 110 q 482 124 445 110 q 544 166 519 138 q 582 234.5 569 194 q 595 327 595 275 l 595 327 l 595 358 q 582 452 595 411 q 544 520.5 569 493 q 482 562 519 548 q 396 576 445 576 l 396 576 q 311 562 348 576 q 249 520.5 274 548 q 211.5 452 224 493 q 199 358 199 411 l 199 358 l 199 327 q 211.5 234.5 199 275 q 249 166 224 194 q 311 124 274 138 q 396 110 348 110"},"S":{"ha":679,"x_min":45,"x_max":636,"o":"m 343 -12 l 343 -12 q 229 0 283 -12 q 133.5 38.5 175 12 q 68.5 105.5 92 65 q 45 204 45 146 l 45 204 q 45 214.5 45 209 q 46 223 45 220 l 46 223 l 194 223 q 193 215 193 220 q 193 206 193 210 l 193 206 q 210.5 152 193 174 q 262 119 228 130 q 341 108 296 108 l 341 108 q 392.5 111.5 370 108 q 432.5 121.5 415 115 q 461.5 137 450 128 q 478.5 158 473 146 q 484 185 484 170 l 484 185 q 466.5 229 484 212 q 418.5 258 449 246 q 350 280 388 270 q 272 300.5 312 290 q 194 326.5 232 311 q 126 364 156 342 q 78 420 96 386 q 60 502 60 454 l 60 502 q 81.5 590 60 553 q 142 651 103 627 q 232 686.5 181 675 q 343 698 283 698 l 343 698 q 449 686.5 399 698 q 537 650 499 675 q 596.5 587 575 625 q 618 497 618 549 l 618 497 l 618 485 l 473 485 l 473 493 q 457 539 473 520 q 412 569 441 558 q 344 580 383 580 l 344 580 q 272.5 572 302 580 q 227.5 548.5 243 564 q 212 512 212 533 l 212 512 q 229.5 472.5 212 488 q 277.5 445.5 247 457 q 346 425 308 434 q 424 405 384 416 q 502 379 464 394 q 570 341.5 540 364 q 618 286 600 319 q 636 207 636 253 l 636 207 q 598 80 636 128 q 494 10 560 32 q 343 -12 428 -12"},"T":{"ha":641,"x_min":25,"x_max":616,"o":"m 395 0 l 245 0 l 245 561 l 25 561 l 25 686 l 616 686 l 616 561 l 395 561 l 395 0"},"X":{"ha":706,"x_min":9,"x_max":697,"o":"m 181 0 l 9 0 l 256 361 l 33 686 l 215 686 l 357 469 l 362 469 l 503 686 l 674 686 l 450 361 l 697 0 l 515 0 l 350 254 l 345 254 l 181 0"}},"familyName":"Archivo","ascender":878,"descender":-210,"underlinePosition":-148,"underlineThickness":44,"boundingBox":{"xMin":-236,"xMax":1149,"yMin":-208,"yMax":1047},"resolution":1000,"original_font_information":{"format":"0","copyright":"","fontFamily":"Archivo","fontSubfamily":"Bold","fullName":"Archivo Bold","version":"","postScriptName":"","manufacturer":"","designer":"","licenseURL":""},"cssFontWeight":"700","cssFontStyle":"normal"} diff --git a/src/components/Hero.astro b/src/components/Hero.astro new file mode 100644 index 0000000..55a8085 --- /dev/null +++ b/src/components/Hero.astro @@ -0,0 +1,32 @@ +--- +--- + +
+
+ +

Alexis Technologies

+
+

Coming soon…

+
+ + 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. +--- + +Alexis Technologies 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(); +--- + +
+

Copyright © {buildYear} Alexis Technologies Inc. All rights reserved.

+
+ + diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro new file mode 100644 index 0000000..72fe2f3 --- /dev/null +++ b/src/layouts/BaseLayout.astro @@ -0,0 +1,42 @@ +--- +import Analytics from '@vercel/analytics/astro'; +import SpeedInsights from '@vercel/speed-insights/astro'; + +import '@fontsource-variable/archivo'; +import '../styles/global.css'; + +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..7d43c73 --- /dev/null +++ b/src/scripts/hero-animation.ts @@ -0,0 +1,314 @@ +/** + * Hero wordmark animation. + * + * The wordmark is extruded into a bevelled, non-indexed geometry so every triangle + * can be addressed independently. Each triangle carries its own scatter offset, + * bezier control points, delay and duration; the vertex shader walks it along that + * curve from "scattered" to "assembled". Nothing is animated on the CPU — the whole + * loop only advances a single `uTime` uniform. + */ + +import { + BufferAttribute, + Color, + DoubleSide, + 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 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'; + +const TEXT_SIZE = 14; +/** + * Share of the canvas width the assembled wordmark should span. The remainder is + * headroom for the shards that fly out past the first and last glyph. + */ +const FILL_RATIO = 0.86; +/** Seconds for one direction of the ping-pong. */ +const SWEEP_SECONDS = 4; +const CAMERA_FOV = 10; + +const vertexShader = /* glsl */ ` + uniform float uTime; + + attribute float aDelay; + attribute float aDuration; + attribute vec3 aScatter; + attribute vec3 aControl0; + attribute vec3 aControl1; + attribute vec3 aCentroid; + + vec3 cubicBezier(vec3 p0, vec3 c0, vec3 c1, vec3 p1, float t) { + float u = 1.0 - t; + return u * u * u * p0 + + 3.0 * u * u * t * c0 + + 3.0 * u * t * t * c1 + + t * t * t * p1; + } + + float easeInOut(float t) { + return t < 0.5 ? 2.0 * t * t : -1.0 + (4.0 - 2.0 * t) * t; + } + + void main() { + float p = clamp((uTime - aDelay) / aDuration, 0.0, 1.0); + float e = easeInOut(p); + + vec3 end = position; + vec3 start = position + aScatter; + vec3 travelled = cubicBezier(start, start + aControl0, end + aControl1, end, e); + + // Shards also grow out of their own centroid, so early frames read as debris + // rather than as a shrunken copy of the wordmark. + vec3 transformed = mix(aCentroid, travelled, mix(0.25, 1.0, e)); + + gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0); + } +`; + +const fragmentShader = /* glsl */ ` + uniform vec3 uColor; + + void main() { + gl_FragColor = vec4(uColor, 1.0); + } +`; + +const randomBetween = (min: number, max: number) => min + Math.random() * (max - min); + +/** + * Assigns the per-triangle attributes. All three vertices of a triangle get identical + * values, which is what makes each face move as one rigid shard. + * + * Magnitudes are expressed as multiples of the wordmark's cap height rather than as + * the absolute units the original effect used. Anchoring them to the rendered height + * keeps the debris field proportional to the text at any size — tie them to the nominal + * font size instead and the shards scatter several times the wordmark's own height, + * which reads as noise rather than as letters coming apart. + */ +function buildShardAttributes(geometry: TextGeometry, capHeight: number): number { + const position = geometry.getAttribute('position'); + const vertexCount = position.count; + const faceCount = vertexCount / 3; + + const delay = new Float32Array(vertexCount); + const duration = new Float32Array(vertexCount); + const scatter = new Float32Array(vertexCount * 3); + const control0 = new Float32Array(vertexCount * 3); + const control1 = new Float32Array(vertexCount * 3); + const centroids = new Float32Array(vertexCount * 3); + + const centroid = new Vector3(); + const unit = capHeight; + let maxEnd = 0; + + for (let face = 0; face < faceCount; face++) { + const a = face * 3; + + centroid.set(0, 0, 0); + for (let v = 0; v < 3; v++) { + centroid.x += position.getX(a + v); + centroid.y += position.getY(a + v); + centroid.z += position.getZ(a + v); + } + centroid.divideScalar(3); + + const dirX = centroid.x > 0 ? 1 : -1; + const dirY = centroid.y > 0 ? 1 : -1; + + // Shards further from the centre wait longer, so the wordmark resolves outwards. + // Normalised by cap height so the stagger does not stretch with a longer string. + const faceDelay = (centroid.length() / unit) * randomBetween(0.12, 0.26); + const faceDuration = randomBetween(1.6, 2.8); + maxEnd = Math.max(maxEnd, faceDelay + faceDuration); + + // Kept inside roughly two cap heights vertically: the stage is only tall enough for + // that, and anything further just gets clipped at the canvas edge. + const s = new Vector3( + randomBetween(0.4, 1.6) * unit * dirX, + randomBetween(0.7, 1.9) * unit * dirY, + randomBetween(-0.5, 0.5) * unit, + ); + const c0 = new Vector3( + randomBetween(0, 0.8) * unit * dirX, + randomBetween(1.2, 2.4) * unit * dirY, + randomBetween(-0.6, 0.6) * unit, + ); + const c1 = new Vector3( + randomBetween(0.8, 1.6) * unit * dirX, + randomBetween(0, 1.4) * unit * dirY, + randomBetween(-0.6, 0.6) * unit, + ); + + for (let v = 0; v < 3; v++) { + const i = a + v; + const i3 = i * 3; + + delay[i] = faceDelay; + duration[i] = faceDuration; + + scatter[i3] = s.x; + scatter[i3 + 1] = s.y; + scatter[i3 + 2] = s.z; + + control0[i3] = c0.x; + control0[i3 + 1] = c0.y; + control0[i3 + 2] = c0.z; + + control1[i3] = c1.x; + control1[i3 + 1] = c1.y; + control1[i3 + 2] = c1.z; + + centroids[i3] = centroid.x; + centroids[i3 + 1] = centroid.y; + centroids[i3 + 2] = centroid.z; + } + } + + geometry.setAttribute('aDelay', new BufferAttribute(delay, 1)); + geometry.setAttribute('aDuration', new BufferAttribute(duration, 1)); + geometry.setAttribute('aScatter', new BufferAttribute(scatter, 3)); + geometry.setAttribute('aControl0', new BufferAttribute(control0, 3)); + geometry.setAttribute('aControl1', new BufferAttribute(control1, 3)); + geometry.setAttribute('aCentroid', new BufferAttribute(centroids, 3)); + + return maxEnd; +} + +export interface HeroAnimation { + destroy: () => void; +} + +export function createHeroAnimation(canvas: HTMLCanvasElement): HeroAnimation { + const font = new FontLoader().parse(typefaceData); + + const geometry = new TextGeometry(HERO_TEXT, { + font, + size: TEXT_SIZE, + depth: 0, + curveSegments: 4, + bevelEnabled: true, + bevelSize: 0.75, + bevelThickness: 0.5, + bevelSegments: 1, + }); + + // ExtrudeGeometry is already non-indexed, but assert it: the whole effect depends on + // every triangle owning its three vertices. + const shardGeometry = geometry.index === null ? geometry : (geometry.toNonIndexed() as TextGeometry); + shardGeometry.center(); + shardGeometry.computeBoundingBox(); + + const bounds = shardGeometry.boundingBox; + const textWidth = bounds ? bounds.max.x - bounds.min.x : TEXT_SIZE; + const capHeight = bounds ? bounds.max.y - bounds.min.y : TEXT_SIZE; + + const animationDuration = buildShardAttributes(shardGeometry, capHeight); + + const material = new ShaderMaterial({ + vertexShader, + fragmentShader, + side: DoubleSide, + uniforms: { + uTime: { value: 0 }, + uColor: { value: new Color(0x000000) }, + }, + }); + + const mesh = new Mesh(shardGeometry, material); + mesh.frustumCulled = false; + + const scene = new Scene(); + scene.add(mesh); + + const camera = new PerspectiveCamera(CAMERA_FOV, 1, 1, 10000); + + const renderer = new WebGLRenderer({ canvas, antialias: true, alpha: true }); + renderer.setClearColor(0xffffff, 0); + + function resize(): 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 aspect = clientWidth / clientHeight; + camera.aspect = aspect; + + // Pull the camera back far enough that the assembled wordmark spans FILL_RATIO of + // the canvas width, whatever the viewport is. + const targetWidth = textWidth / FILL_RATIO; + const halfFovY = (CAMERA_FOV / 2) * (Math.PI / 180); + camera.position.z = targetWidth / (2 * aspect * Math.tan(halfFovY)); + camera.updateProjectionMatrix(); + } + + let frame = 0; + let elapsed = 0; + let last = performance.now(); + + function tick(now: number): void { + frame = requestAnimationFrame(tick); + + const delta = Math.min((now - last) / 1000, 0.1); + last = now; + elapsed += delta; + + // Ping-pong 0 → 1 → 0, matching the original's yoyo tween. + const cycle = (elapsed % (SWEEP_SECONDS * 2)) / SWEEP_SECONDS; + const linear = cycle <= 1 ? cycle : 2 - cycle; + const progress = linear < 0.5 ? 2 * linear * linear : -1 + (4 - 2 * linear) * linear; + + material.uniforms.uTime.value = animationDuration * progress; + renderer.render(scene, camera); + } + + 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(); + } + + 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); + shardGeometry.dispose(); + material.dispose(); + renderer.dispose(); + }, + }; +} diff --git a/src/styles/global.css b/src/styles/global.css new file mode 100644 index 0000000..bdc5be1 --- /dev/null +++ b/src/styles/global.css @@ -0,0 +1,156 @@ +:root { + --color-bg: #fff; + --color-ink: #000; + --color-muted: #666; + --color-faint: #9a9a9a; + + --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/*"] + } + } +} From 2dfc2c74eda7f029c3ffb043bf3656f9d8a70cca Mon Sep 17 00:00:00 2001 From: Alex Dolid Date: Tue, 4 Aug 2026 01:29:35 +0300 Subject: [PATCH 2/4] Align hero animation with original Piece by Piece MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks `hero-animation.ts` into a faithful port of zadvorsky’s original effect: switches to BAS-style bezier math in the vertex shader, restores original timing/control-point ranges, adds per-vertex delay jitter, and implements GSAP-equivalent yoyo playback with drag scrubbing and timeScale easing. It also fixes glyph spacing by applying runtime tracking to account for bevel expansion, keeps the original fixed camera framing behavior, and adds pointer listener cleanup on destroy. `CLAUDE.md` was updated to document these constraints and the intent to keep the port exact. --- CLAUDE.md | 17 +- src/scripts/hero-animation.ts | 294 +++++++++++++++++++++------------- 2 files changed, 194 insertions(+), 117 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6daf8a5..bf41062 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,14 +36,19 @@ specifically because there is one page. If real routes get added, revisit both. 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 and the fixed fov-10 / z-1400 camera. - 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. -- Scatter and control-point magnitudes are multiples of the wordmark's **cap height**, - never absolute units. Tie them to the nominal font size and the shards scatter several - times the wordmark's own height, which reads as noise rather than as letters breaking - apart. -- GSAP and THREE.BAS are deliberately not used; the effect they provided is ~40 lines of - GLSL here. Do not reintroduce them. +- 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. diff --git a/src/scripts/hero-animation.ts b/src/scripts/hero-animation.ts index 7d43c73..61a066d 100644 --- a/src/scripts/hero-animation.ts +++ b/src/scripts/hero-animation.ts @@ -1,17 +1,26 @@ /** * Hero wordmark animation. * - * The wordmark is extruded into a bevelled, non-indexed geometry so every triangle - * can be addressed independently. Each triangle carries its own scatter offset, - * bezier control points, delay and duration; the vertex shader walks it along that - * curve from "scattered" to "assembled". Nothing is animated on the CPU — the whole - * loop only advances a single `uTime` uniform. + * 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. + * + * Nothing is animated on the CPU — the loop only advances a single `uTime` uniform. */ import { BufferAttribute, Color, DoubleSide, + MathUtils, Mesh, PerspectiveCamera, Scene, @@ -27,51 +36,61 @@ 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. All animation magnitudes below are + * absolute units tied to this size, exactly as in the source. + */ const TEXT_SIZE = 14; +const BEVEL_SIZE = 0.75; +const BEVEL_THICKNESS = 0.5; + /** - * Share of the canvas width the assembled wordmark should span. The remainder is - * headroom for the shards that fly out past the first and last glyph. + * 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 FILL_RATIO = 0.86; -/** Seconds for one direction of the ping-pong. */ -const SWEEP_SECONDS = 4; +const TRACKING = 130; + const CAMERA_FOV = 10; +/** The original's fixed camera distance. Pulled back further only if the text would overflow. */ +const CAMERA_Z = 1400; + +/** 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; - attribute float aDelay; - attribute float aDuration; - attribute vec3 aScatter; + attribute vec2 aAnimation; attribute vec3 aControl0; attribute vec3 aControl1; - attribute vec3 aCentroid; + // THREE.BAS.ShaderChunk['cubic_bezier'] vec3 cubicBezier(vec3 p0, vec3 c0, vec3 c1, vec3 p1, float t) { - float u = 1.0 - t; - return u * u * u * p0 - + 3.0 * u * u * t * c0 - + 3.0 * u * t * t * c1 + 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; } - float easeInOut(float t) { - return t < 0.5 ? 2.0 * t * t : -1.0 + (4.0 - 2.0 * t) * t; - } - void main() { - float p = clamp((uTime - aDelay) / aDuration, 0.0, 1.0); - float e = easeInOut(p); - - vec3 end = position; - vec3 start = position + aScatter; - vec3 travelled = cubicBezier(start, start + aControl0, end + aControl1, end, e); + float tDelay = aAnimation.x; + float tDuration = aAnimation.y; + float tTime = clamp(uTime - tDelay, 0.0, tDuration); + float tProgress = tTime / tDuration; - // Shards also grow out of their own centroid, so early frames read as debris - // rather than as a shrunken copy of the wordmark. - vec3 transformed = mix(aCentroid, travelled, mix(0.25, 1.0, e)); + vec3 tPosition = position; + tPosition *= 1.0 - tProgress; + tPosition += cubicBezier(position, aControl0, aControl1, vec3(0.0), tProgress); - gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0); + gl_Position = projectionMatrix * modelViewMatrix * vec4(tPosition, 1.0); } `; @@ -83,33 +102,27 @@ const fragmentShader = /* glsl */ ` } `; -const randomBetween = (min: number, max: number) => min + Math.random() * (max - min); +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); /** - * Assigns the per-triangle attributes. All three vertices of a triangle get identical - * values, which is what makes each face move as one rigid shard. + * 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. * - * Magnitudes are expressed as multiples of the wordmark's cap height rather than as - * the absolute units the original effect used. Anchoring them to the rendered height - * keeps the debris field proportional to the text at any size — tie them to the nominal - * font size instead and the shards scatter several times the wordmark's own height, - * which reads as noise rather than as letters coming apart. + * Returns the animation duration: maxDelay + maxDuration + max per-vertex jitter. */ -function buildShardAttributes(geometry: TextGeometry, capHeight: number): number { +function buildShardAttributes(geometry: TextGeometry): number { const position = geometry.getAttribute('position'); const vertexCount = position.count; const faceCount = vertexCount / 3; - const delay = new Float32Array(vertexCount); - const duration = new Float32Array(vertexCount); - const scatter = new Float32Array(vertexCount * 3); + const animation = new Float32Array(vertexCount * 2); const control0 = new Float32Array(vertexCount * 3); const control1 = new Float32Array(vertexCount * 3); - const centroids = new Float32Array(vertexCount * 3); const centroid = new Vector3(); - const unit = capHeight; - let maxEnd = 0; for (let face = 0; face < faceCount; face++) { const a = face * 3; @@ -125,63 +138,45 @@ function buildShardAttributes(geometry: TextGeometry, capHeight: number): number const dirX = centroid.x > 0 ? 1 : -1; const dirY = centroid.y > 0 ? 1 : -1; - // Shards further from the centre wait longer, so the wordmark resolves outwards. - // Normalised by cap height so the stagger does not stretch with a longer string. - const faceDelay = (centroid.length() / unit) * randomBetween(0.12, 0.26); - const faceDuration = randomBetween(1.6, 2.8); - maxEnd = Math.max(maxEnd, faceDelay + faceDuration); - - // Kept inside roughly two cap heights vertically: the stage is only tall enough for - // that, and anything further just gets clipped at the canvas edge. - const s = new Vector3( - randomBetween(0.4, 1.6) * unit * dirX, - randomBetween(0.7, 1.9) * unit * dirY, - randomBetween(-0.5, 0.5) * unit, - ); - const c0 = new Vector3( - randomBetween(0, 0.8) * unit * dirX, - randomBetween(1.2, 2.4) * unit * dirY, - randomBetween(-0.6, 0.6) * unit, - ); - const c1 = new Vector3( - randomBetween(0.8, 1.6) * unit * dirX, - randomBetween(0, 1.4) * unit * dirY, - randomBetween(-0.6, 0.6) * unit, - ); + const delay = centroid.length() * 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; - delay[i] = faceDelay; - duration[i] = faceDuration; - - scatter[i3] = s.x; - scatter[i3 + 1] = s.y; - scatter[i3 + 2] = s.z; + animation[i2] = delay + Math.random(); + animation[i2 + 1] = duration; - control0[i3] = c0.x; - control0[i3 + 1] = c0.y; - control0[i3 + 2] = c0.z; + control0[i3] = c0x; + control0[i3 + 1] = c0y; + control0[i3 + 2] = c0z; - control1[i3] = c1.x; - control1[i3 + 1] = c1.y; - control1[i3 + 2] = c1.z; - - centroids[i3] = centroid.x; - centroids[i3 + 1] = centroid.y; - centroids[i3 + 2] = centroid.z; + control1[i3] = c1x; + control1[i3 + 1] = c1y; + control1[i3 + 2] = c1z; } } - geometry.setAttribute('aDelay', new BufferAttribute(delay, 1)); - geometry.setAttribute('aDuration', new BufferAttribute(duration, 1)); - geometry.setAttribute('aScatter', new BufferAttribute(scatter, 3)); + geometry.setAttribute('aAnimation', new BufferAttribute(animation, 2)); geometry.setAttribute('aControl0', new BufferAttribute(control0, 3)); geometry.setAttribute('aControl1', new BufferAttribute(control1, 3)); - geometry.setAttribute('aCentroid', new BufferAttribute(centroids, 3)); - return maxEnd; + 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; } export interface HeroAnimation { @@ -189,30 +184,34 @@ export interface HeroAnimation { } export function createHeroAnimation(canvas: HTMLCanvasElement): HeroAnimation { - const font = new FontLoader().parse(typefaceData); + 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); const geometry = new TextGeometry(HERO_TEXT, { font, size: TEXT_SIZE, depth: 0, - curveSegments: 4, bevelEnabled: true, - bevelSize: 0.75, - bevelThickness: 0.5, - bevelSegments: 1, + bevelSize: BEVEL_SIZE, + bevelThickness: BEVEL_THICKNESS, }); - // ExtrudeGeometry is already non-indexed, but assert it: the whole effect depends on - // every triangle owning its three vertices. + // Equivalent of THREE.BAS.Utils.separateFaces: every triangle must own its vertices + // so per-face attributes do not bleed between shards. const shardGeometry = geometry.index === null ? geometry : (geometry.toNonIndexed() as TextGeometry); + // anchor: {x: 0.5, y: 0.5, z: 0.5} shardGeometry.center(); shardGeometry.computeBoundingBox(); const bounds = shardGeometry.boundingBox; const textWidth = bounds ? bounds.max.x - bounds.min.x : TEXT_SIZE; - const capHeight = bounds ? bounds.max.y - bounds.min.y : TEXT_SIZE; - const animationDuration = buildShardAttributes(shardGeometry, capHeight); + const animationDuration = buildShardAttributes(shardGeometry); const material = new ShaderMaterial({ vertexShader, @@ -247,16 +246,32 @@ export function createHeroAnimation(canvas: HTMLCanvasElement): HeroAnimation { const aspect = clientWidth / clientHeight; camera.aspect = aspect; - // Pull the camera back far enough that the assembled wordmark spans FILL_RATIO of - // the canvas width, whatever the viewport is. - const targetWidth = textWidth / FILL_RATIO; + // The original parks the camera at z=1400 regardless of viewport. Keep that exact + // framing (it is what leaves room for the bezier arcs), only pulling back further + // when a narrow canvas would otherwise crop the wordmark itself. const halfFovY = (CAMERA_FOV / 2) * (Math.PI / 180); - camera.position.z = targetWidth / (2 * aspect * Math.tan(halfFovY)); + const zToFitWidth = textWidth / 0.9 / (2 * aspect * Math.tan(halfFovY)); + camera.position.set(0, 0, Math.max(CAMERA_Z, zToFitWidth)); camera.updateProjectionMatrix(); } + // --- 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 = animationDuration * quadEaseInOut(linear); + } + let frame = 0; - let elapsed = 0; let last = performance.now(); function tick(now: number): void { @@ -264,17 +279,70 @@ export function createHeroAnimation(canvas: HTMLCanvasElement): HeroAnimation { const delta = Math.min((now - last) / 1000, 0.1); last = now; - elapsed += delta; - // Ping-pong 0 → 1 → 0, matching the original's yoyo tween. - const cycle = (elapsed % (SWEEP_SECONDS * 2)) / SWEEP_SECONDS; - const linear = cycle <= 1 ? cycle : 2 - cycle; - const progress = linear < 0.5 ? 2 * linear * linear : -1 + (4 - 2 * linear) * linear; + // 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); + } - material.uniforms.uTime.value = animationDuration * progress; + 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(); @@ -306,6 +374,10 @@ export function createHeroAnimation(canvas: HTMLCanvasElement): HeroAnimation { stop(); observer.disconnect(); document.removeEventListener('visibilitychange', onVisibilityChange); + canvas.removeEventListener('pointerdown', onPointerDown); + canvas.removeEventListener('pointermove', onPointerMove); + canvas.removeEventListener('pointerup', onPointerUp); + canvas.removeEventListener('pointercancel', onPointerUp); shardGeometry.dispose(); material.dispose(); renderer.dispose(); From 0d3d072bcba15358946edf03632afd4b8a94e7d9 Mon Sep 17 00:00:00 2001 From: Alex Dolid Date: Tue, 4 Aug 2026 01:52:10 +0300 Subject: [PATCH 3/4] feat: responsive hero wordmark sizing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derive camera distance from the h1 fallback's computed font-size so the canvas and fallback render at the same type size. Reflow to two centred lines when the em size drops below MIN_SINGLE_LINE_EM_PX. Scale bezier arcs with uArcScale (z / REFERENCE_CAMERA_Z) so the debris field always fills the stage. Introduce REST_SCALE to account for the shader's 2× resting position offset in all camera-fit formulas. --- CLAUDE.md | 9 +- src/scripts/hero-animation.ts | 167 +++++++++++++++++++++++++++------- 2 files changed, 140 insertions(+), 36 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bf41062..bd14364 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,14 @@ Lives in `src/scripts/hero-animation.ts`. 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 and the fixed fov-10 / z-1400 camera. + 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 diff --git a/src/scripts/hero-animation.ts b/src/scripts/hero-animation.ts index 61a066d..8d5171e 100644 --- a/src/scripts/hero-animation.ts +++ b/src/scripts/hero-animation.ts @@ -13,11 +13,18 @@ * 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, @@ -30,6 +37,7 @@ import { } 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'; @@ -38,8 +46,9 @@ 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. All animation magnitudes below are - * absolute units tied to this size, exactly as in the source. + * 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; @@ -53,9 +62,32 @@ const BEVEL_THICKNESS = 0.5; */ 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 original's fixed camera distance. Pulled back further only if the text would overflow. */ -const CAMERA_Z = 1400; +/** + * 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; @@ -66,6 +98,7 @@ const SEEK_SPEED = 0.001; const vertexShader = /* glsl */ ` uniform float uTime; + uniform float uArcScale; attribute vec2 aAnimation; attribute vec3 aControl0; @@ -88,7 +121,7 @@ const vertexShader = /* glsl */ ` vec3 tPosition = position; tPosition *= 1.0 - tProgress; - tPosition += cubicBezier(position, aControl0, aControl1, vec3(0.0), tProgress); + tPosition += cubicBezier(position, aControl0 * uArcScale, aControl1 * uArcScale, vec3(0.0), tProgress); gl_Position = projectionMatrix * modelViewMatrix * vec4(tPosition, 1.0); } @@ -113,7 +146,7 @@ const quadEaseOut = (t: number): number => 1 - (1 - t) * (1 - t); * * Returns the animation duration: maxDelay + maxDuration + max per-vertex jitter. */ -function buildShardAttributes(geometry: TextGeometry): number { +function buildShardAttributes(geometry: BufferGeometry): number { const position = geometry.getAttribute('position'); const vertexCount = position.count; const faceCount = vertexCount / 3; @@ -179,6 +212,13 @@ function buildShardAttributes(geometry: TextGeometry): number { return maxDelay + 4 + 1; } +interface Wordmark { + geometry: BufferGeometry; + width: number; + height: number; + duration: number; +} + export interface HeroAnimation { destroy: () => void; } @@ -192,26 +232,43 @@ export function createHeroAnimation(canvas: HTMLCanvasElement): HeroAnimation { }; const font = new FontLoader().parse(spacedTypeface); - const geometry = new TextGeometry(HERO_TEXT, { - font, - size: TEXT_SIZE, - depth: 0, - bevelEnabled: true, - bevelSize: BEVEL_SIZE, - bevelThickness: BEVEL_THICKNESS, - }); - - // Equivalent of THREE.BAS.Utils.separateFaces: every triangle must own its vertices - // so per-face attributes do not bleed between shards. - const shardGeometry = geometry.index === null ? geometry : (geometry.toNonIndexed() as TextGeometry); - // anchor: {x: 0.5, y: 0.5, z: 0.5} - shardGeometry.center(); - shardGeometry.computeBoundingBox(); - - const bounds = shardGeometry.boundingBox; - const textWidth = bounds ? bounds.max.x - bounds.min.x : TEXT_SIZE; - - const animationDuration = buildShardAttributes(shardGeometry); + /** Builds the (possibly multi-line) wordmark with every line centred, plus its shard attributes. */ + function buildWordmark(lines: string[]): Wordmark { + const parts = lines.map((line, index) => { + const part = new TextGeometry(line, { + font, + size: TEXT_SIZE, + depth: 0, + bevelEnabled: true, + bevelSize: BEVEL_SIZE, + bevelThickness: BEVEL_THICKNESS, + }); + 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); + return part; + }); + + 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; + + return { + geometry, + width: bounds ? bounds.max.x - bounds.min.x : TEXT_SIZE, + height: bounds ? bounds.max.y - bounds.min.y : TEXT_SIZE, + duration: buildShardAttributes(geometry), + }; + } const material = new ShaderMaterial({ vertexShader, @@ -219,11 +276,15 @@ export function createHeroAnimation(canvas: HTMLCanvasElement): HeroAnimation { side: DoubleSide, uniforms: { uTime: { value: 0 }, + uArcScale: { value: 1 }, uColor: { value: new Color(0x000000) }, }, }); - const mesh = new Mesh(shardGeometry, material); + let lineCount = 1; + let wordmark = buildWordmark([HERO_TEXT]); + + const mesh = new Mesh(wordmark.geometry, material); mesh.frustumCulled = false; const scene = new Scene(); @@ -234,6 +295,18 @@ export function createHeroAnimation(canvas: HTMLCanvasElement): HeroAnimation { const renderer = new WebGLRenderer({ canvas, antialias: true, alpha: true }); renderer.setClearColor(0xffffff, 0); + 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); + } + function resize(): void { const { clientWidth, clientHeight } = canvas; if (clientWidth === 0 || clientHeight === 0) return; @@ -243,16 +316,40 @@ export function createHeroAnimation(canvas: HTMLCanvasElement): HeroAnimation { renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); renderer.setSize(clientWidth, clientHeight, false); + const emPx = measureTitleEmPx(); + const nextLineCount = emPx < MIN_SINGLE_LINE_EM_PX ? 2 : 1; + if (nextLineCount !== lineCount) { + lineCount = nextLineCount; + wordmark.geometry.dispose(); + wordmark = buildWordmark(nextLineCount === 1 ? [HERO_TEXT] : HERO_TEXT.split(' ')); + mesh.geometry = wordmark.geometry; + } + const aspect = clientWidth / clientHeight; camera.aspect = aspect; - // The original parks the camera at z=1400 regardless of viewport. Keep that exact - // framing (it is what leaves room for the bezier arcs), only pulling back further - // when a narrow canvas would otherwise crop the wordmark itself. - const halfFovY = (CAMERA_FOV / 2) * (Math.PI / 180); - const zToFitWidth = textWidth / 0.9 / (2 * aspect * Math.tan(halfFovY)); - camera.position.set(0, 0, Math.max(CAMERA_Z, zToFitWidth)); + 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); } // --- tween state (TweenMax.fromTo(..., 4, {animationProgress: 0 → 1, yoyo, repeat: -1})) --- @@ -268,7 +365,7 @@ export function createHeroAnimation(canvas: HTMLCanvasElement): HeroAnimation { function applyProgress(): void { const cycle = (tweenTime % (TWEEN_SECONDS * 2)) / TWEEN_SECONDS; const linear = cycle <= 1 ? cycle : 2 - cycle; - material.uniforms.uTime.value = animationDuration * quadEaseInOut(linear); + material.uniforms.uTime.value = wordmark.duration * quadEaseInOut(linear); } let frame = 0; @@ -378,7 +475,7 @@ export function createHeroAnimation(canvas: HTMLCanvasElement): HeroAnimation { canvas.removeEventListener('pointermove', onPointerMove); canvas.removeEventListener('pointerup', onPointerUp); canvas.removeEventListener('pointercancel', onPointerUp); - shardGeometry.dispose(); + wordmark.geometry.dispose(); material.dispose(); renderer.dispose(); }, From a27347aa5a044e36b7a8a79f13dfa2f48068913f Mon Sep 17 00:00:00 2001 From: Alex Dolid Date: Tue, 4 Aug 2026 11:23:11 +0300 Subject: [PATCH 4/4] feat: Optimize hero boot and wordmark rendering Defer the hero animation until the page is loaded, visible, and idle, and lazy-load the animation module so the font and document win the initial network and main-thread budget. The wordmark build is now async and chunked to reduce blocking, avoids rebuilding the wrong line layout on first paint, and drops unused geometry attributes before upload. This also preloads the Archivo Latin font for a stable LCP, switches the hero wrapper to `
`, improves faint text contrast, and documents the hero boot/perf constraints in `CLAUDE.md`. --- CLAUDE.md | 17 ++++ src/components/Hero.astro | 70 ++++++++++--- src/layouts/BaseLayout.astro | 7 ++ src/scripts/hero-animation.ts | 178 +++++++++++++++++++++++++++------- src/styles/global.css | 4 +- 5 files changed, 223 insertions(+), 53 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bd14364..5a53d59 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,23 @@ Lives in `src/scripts/hero-animation.ts`. - 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. diff --git a/src/components/Hero.astro b/src/components/Hero.astro index 55a8085..6512a12 100644 --- a/src/components/Hero.astro +++ b/src/components/Hero.astro @@ -1,32 +1,72 @@ --- --- -
+

Alexis Technologies

Coming soon…

-
+

diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 72fe2f3..45b8fb8 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -5,6 +5,12 @@ 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; @@ -22,6 +28,7 @@ const canonical = new URL(Astro.url.pathname, Astro.site).href; {title} + diff --git a/src/scripts/hero-animation.ts b/src/scripts/hero-animation.ts index 8d5171e..cb0a7e5 100644 --- a/src/scripts/hero-animation.ts +++ b/src/scripts/hero-animation.ts @@ -138,6 +138,40 @@ const fragmentShader = /* glsl */ ` 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 @@ -146,32 +180,39 @@ const quadEaseOut = (t: number): number => 1 - (1 - t) * (1 - t); * * Returns the animation duration: maxDelay + maxDuration + max per-vertex jitter. */ -function buildShardAttributes(geometry: BufferGeometry): number { +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); - const centroid = new Vector3(); + 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; - centroid.set(0, 0, 0); - for (let v = 0; v < 3; v++) { - centroid.x += position.getX(a + v); - centroid.y += position.getY(a + v); - centroid.z += position.getZ(a + v); - } - centroid.divideScalar(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 = centroid.x > 0 ? 1 : -1; - const dirY = centroid.y > 0 ? 1 : -1; + const dirX = centroidX > 0 ? 1 : -1; + const dirY = centroidY > 0 ? 1 : -1; - const delay = centroid.length() * MathUtils.randFloat(0.03, 0.06); + 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; @@ -223,7 +264,7 @@ export interface HeroAnimation { destroy: () => void; } -export function createHeroAnimation(canvas: HTMLCanvasElement): HeroAnimation { +export async function createHeroAnimation(canvas: HTMLCanvasElement): Promise { const spacedTypeface = { ...typefaceData, glyphs: Object.fromEntries( @@ -233,8 +274,15 @@ export function createHeroAnimation(canvas: HTMLCanvasElement): HeroAnimation { const font = new FontLoader().parse(spacedTypeface); /** Builds the (possibly multi-line) wordmark with every line centred, plus its shard attributes. */ - function buildWordmark(lines: string[]): Wordmark { - const parts = lines.map((line, index) => { + 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, @@ -243,11 +291,20 @@ export function createHeroAnimation(canvas: HTMLCanvasElement): HeroAnimation { 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); - return part; - }); + 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(); @@ -262,11 +319,13 @@ export function createHeroAnimation(canvas: HTMLCanvasElement): HeroAnimation { 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: buildShardAttributes(geometry), + duration: await buildShardAttributes(geometry), }; } @@ -281,8 +340,26 @@ export function createHeroAnimation(canvas: HTMLCanvasElement): HeroAnimation { }, }); - let lineCount = 1; - let wordmark = buildWordmark([HERO_TEXT]); + 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; @@ -292,22 +369,35 @@ export function createHeroAnimation(canvas: HTMLCanvasElement): HeroAnimation { 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); - const titleElement = canvas.parentElement?.querySelector('h1') ?? null; + let rebuilding = false; - /** 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; + /** 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; } - // Mirrors the h1's clamp(1rem, 7.8vw, 8rem) if the element is missing. - return MathUtils.clamp(window.innerWidth * 0.078, 16, 128); + return true; } - function resize(): void { + /** Fits the drawing buffer and camera to the canvas, then repaints. */ + function applyLayout(): void { const { clientWidth, clientHeight } = canvas; if (clientWidth === 0 || clientHeight === 0) return; @@ -317,14 +407,6 @@ export function createHeroAnimation(canvas: HTMLCanvasElement): HeroAnimation { renderer.setSize(clientWidth, clientHeight, false); const emPx = measureTitleEmPx(); - const nextLineCount = emPx < MIN_SINGLE_LINE_EM_PX ? 2 : 1; - if (nextLineCount !== lineCount) { - lineCount = nextLineCount; - wordmark.geometry.dispose(); - wordmark = buildWordmark(nextLineCount === 1 ? [HERO_TEXT] : HERO_TEXT.split(' ')); - mesh.geometry = wordmark.geometry; - } - const aspect = clientWidth / clientHeight; camera.aspect = aspect; @@ -352,6 +434,24 @@ export function createHeroAnimation(canvas: HTMLCanvasElement): HeroAnimation { 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. */ @@ -457,6 +557,10 @@ export function createHeroAnimation(canvas: HTMLCanvasElement): HeroAnimation { 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(); diff --git a/src/styles/global.css b/src/styles/global.css index bdc5be1..0b43fa3 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -2,7 +2,9 @@ --color-bg: #fff; --color-ink: #000; --color-muted: #666; - --color-faint: #9a9a9a; + /* 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;