diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..06cbf64 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + verify: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + # Must come before setup-node: the pnpm cache below needs the store to exist. + # The version is read from the "packageManager" field in package.json. + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm check + + - run: pnpm lint + + - run: pnpm format:check + + - run: pnpm build diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 592fdbc..ee530d6 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -5,5 +5,5 @@ "printWidth": 120, "trailingComma": "all", "sortPackageJson": false, - "ignorePatterns": [] + "ignorePatterns": ["src/assets/fonts/*.typeface.json"] } diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5a53d59 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,123 @@ +# CLAUDE.md + +Guidance for Claude Code when working in this repository. See [README.md](README.md) for +setup, scripts and architecture; this file only covers what changes how you should work. + +## Package manager + +**pnpm only.** There is a single `pnpm-lock.yaml` and the version is pinned via the +`packageManager` field. Never run `npm` or `yarn` here — it will produce a competing +lockfile and CI installs with `--frozen-lockfile`. + +## Toolchain + +**oxlint + oxfmt, not ESLint and not Prettier.** This is deliberate. Do not install +`eslint` or `prettier`, and do not create their config files. Rules live in +`.oxlintrc.json`; formatting in `.oxfmtrc.json`. + +Formatting: single quotes, semicolons, 120-column width, trailing commas everywhere, +2-space indent, LF, final newline. + +Before calling a task done: + +```bash +pnpm check && pnpm lint && pnpm format:check && pnpm build +``` + +## This is a single-page static site + +`output: 'static'` with no adapter. Do not reach for SSR-only APIs (`Astro.request`, +runtime endpoints) without changing `output` and installing an adapter first. + +`build.inlineStylesheets: 'always'` and `vite.build.cssCodeSplit: false` are chosen +specifically because there is one page. If real routes get added, revisit both. + +## The hero animation + +Lives in `src/scripts/hero-animation.ts`. + +- It is a faithful port of zadvorsky's "PIECE BY PIECE" pen + (https://codepen.io/zadvorsky/pen/GZmKYX): shards fly along cubic beziers and collapse + into the origin, each vertex of a face gets its own `delay + Math.random()` jitter + (that per-vertex smear is the signature look), and a click-drag scrubber pauses/seeks + the tween. Keep the port exact — magnitudes, timings and easings are the original's + absolute values, tied to `TEXT_SIZE` 14. +- Framing is responsive, unlike the pen: the camera distance is derived from the h1 + fallback's computed font-size so canvas and fallback render the same type size, the + text reflows onto two centred lines when that size drops below + `MIN_SINGLE_LINE_EM_PX`, and the bezier arcs scale with `uArcScale` (= z / 1400) so + the debris always fills the stage. Mind `REST_SCALE`: the original shader draws the + resting wordmark at exactly 2× its geometry (`position + cubicBezier(t=0)`), so every + camera-fit formula sizes against twice the measured bounds. +- The motion is entirely in the vertex shader. The render loop only advances one `uTime` + uniform. Do not add CPU-side per-frame animation — change the attributes or the GLSL. +- GSAP, THREE.BAS and PNLTRI are deliberately not used; the BAS material is ~15 lines of + inlined GLSL, the GSAP tween/scrubber is a few easing formulas in the loop, and modern + three's earcut triangulation makes PNLTRI redundant. Do not reintroduce them. +- `TRACKING` widens every glyph advance at runtime because the bevel expands each + outline by `bevelSize` per side — without it adjacent Archivo Bold glyphs touch. +- The `webgl-ready` class must go on `` *before* `createHeroAnimation` runs — it is + what gives the canvas its layout box, and the renderer needs real dimensions. It is + removed again if initialisation throws. +- Booting is deliberately deferred and chunked, and this is what keeps Total Blocking Time + down — three.js is ~550KB and init is ~200ms of main thread on a throttled phone: + - `Hero.astro` `import()`s the module at idle after `load`, never statically. A static + import puts the bundle in the preload scanner's queue, where it competes with the font + for the connection and pushes FCP/LCP out. + - It also waits for `document.hidden` to clear first. `requestIdleCallback` never fires + in a hidden document — not even on timeout — so without this a page opened in a + background tab never starts the animation at all. + - `createHeroAnimation` is **async** and `await yieldToMain()`s at every phase boundary + (each `TextGeometry`, the merge, the GL context, the first render) plus on a time + budget inside the shard-attribute loop. No single phase exceeds ~45ms; adjacent phases + left in one task is what makes tasks "long". Adding synchronous work between these, or + dropping a yield, silently regresses TBT. + - The line count is measured *before* the first build. Building single-line and letting + `resize()` discard it triangulated the wordmark twice on every phone-width viewport. + - The geometry drops its `normal` and `uv` attributes: the shader reads neither, and + they are ~30% of a ~130k-vertex upload. +- The `

` fallback is not decoration. Keep it in the DOM, visible by default, and + readable on its own. + +## The 3D font + +`src/assets/fonts/archivo-bold.typeface.json` is a **generated, committed artifact**. It +is subsetted to exactly the glyphs the hero renders. + +If the hero copy or the typeface changes, update `CHARSET` in +`scripts/fonts/build-typeface.mjs` and re-run `pnpm fonts:typeface`. Adding a character +without regenerating will make three.js fail to find the glyph at runtime. + +The file is listed in `ignorePatterns` in `.oxfmtrc.json` so oxfmt does not expand the +minified output. + +## Brand assets + +`public/logo.svg` is the designer's full lock-up. **Do not edit it.** + +`public/favicon.svg` is derived from it: children 0–4 (the dotted X mark, without the +wordmark), wrapped in `translate(-90 0)` with `viewBox="0 0 120 120"`. If `logo.svg` is +replaced, re-cut the mark rather than hand-editing the favicon. + +The lock-up's wordmark is not used on the page — the centre wordmark is 3D geometry built +from Archivo. + +## Script targets are hardcoded + +`lint`, `lint:fix`, `format` and `format:check` all target `src scripts astro.config.mjs` +explicitly. If you add a new top-level directory containing code, add it to all four or +it will silently escape every check. + +## Commits + +Conventional Commits (`feat:`, `fix:`, `chore:`, …), matching the existing history. + +## License + +Proprietary, all rights reserved. Do not copy third-party code into this repository +without checking license compatibility first. + +## Not yet written + +`.gitignore` reserves `.screenshots/` for a planned `scripts/dev/shot.ts` concept-review +screenshot tool. It does not exist yet. diff --git a/README.md b/README.md index 7ce458d..ef1308d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,141 @@ -# website -Alexis Technologies Website +# Alexis Technologies — Website + +![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..6512a12 --- /dev/null +++ b/src/components/Hero.astro @@ -0,0 +1,72 @@ +--- +--- + +
+
+ +

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..45b8fb8 --- /dev/null +++ b/src/layouts/BaseLayout.astro @@ -0,0 +1,49 @@ +--- +import Analytics from '@vercel/analytics/astro'; +import SpeedInsights from '@vercel/speed-insights/astro'; + +import '@fontsource-variable/archivo'; +import '../styles/global.css'; + +// The latin subset is the only one this page's copy touches. Preloading it starts the +// fetch with the document instead of after the inlined @font-face is matched to text, so +// the wordmark paints in Archivo first time rather than swapping out of the fallback — +// which is both the LCP and the page's only source of layout shift. +import archivoLatin from '@fontsource-variable/archivo/files/archivo-latin-wght-normal.woff2?url'; + +interface Props { + title: string; + description: string; +} + +const { title, description } = Astro.props; +const canonical = new URL(Astro.url.pathname, Astro.site).href; +--- + + + + + + + {title} + + + + + + + + + + + + + + + + + + + + + diff --git a/src/pages/index.astro b/src/pages/index.astro new file mode 100644 index 0000000..033ba99 --- /dev/null +++ b/src/pages/index.astro @@ -0,0 +1,15 @@ +--- +import Hero from '../components/Hero.astro'; +import LogoMark from '../components/LogoMark.astro'; +import SiteFooter from '../components/SiteFooter.astro'; +import BaseLayout from '../layouts/BaseLayout.astro'; +--- + + + + + + diff --git a/src/scripts/hero-animation.ts b/src/scripts/hero-animation.ts new file mode 100644 index 0000000..cb0a7e5 --- /dev/null +++ b/src/scripts/hero-animation.ts @@ -0,0 +1,587 @@ +/** + * Hero wordmark animation. + * + * Faithful port of zadvorsky's "PIECE BY PIECE" (https://codepen.io/zadvorsky/pen/GZmKYX), + * which was built on THREE.BAS + GSAP. Neither library is needed: the BAS material is the + * `cubic_bezier` shader chunk plus a two-line vertex transform (inlined below), and the + * GSAP tween/scrubber is reproduced with the same easings in the render loop. + * + * At progress 0 the wordmark is assembled; as uTime advances every triangle shrinks its + * base position toward the origin while flying along its own cubic bezier into the + * centre. The tween ping-pongs (yoyo) forever. Dragging scrubs the tween: pressing + * decelerates playback to a halt over 2s, horizontal movement seeks, releasing + * accelerates playback back to full speed over 2s — exactly like createTweenScrubber + * in the original. + * + * Unlike the pen (fixed camera in a full-window canvas), the wordmark is sized to match + * the `

` fallback: the camera distance is derived from the h1's computed font-size, + * and on screens where that would leave the type too small the text reflows onto two + * centred lines. The bezier arcs scale with the visible stage height (uArcScale) so the + * debris field always fills the canvas the way it fills the pen's window. + * + * Nothing is animated on the CPU — the loop only advances a single `uTime` uniform. + */ + +import { + BufferAttribute, + type BufferGeometry, + Color, + DoubleSide, + MathUtils, + Mesh, + PerspectiveCamera, + Scene, + ShaderMaterial, + Vector3, + WebGLRenderer, +} from 'three'; +import { FontLoader } from 'three/examples/jsm/loaders/FontLoader.js'; +import { TextGeometry } from 'three/examples/jsm/geometries/TextGeometry.js'; +import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'; + +import typefaceData from '../assets/fonts/archivo-bold.typeface.json'; + +/** Keep in sync with CHARSET in scripts/fonts/build-typeface.mjs — the font is subsetted to these glyphs. */ +const HERO_TEXT = 'ALEXIS TECHNOLOGIES'; + +/** + * Original geometry parameters: size 14, height 0, bevel 0.75/0.5. The bevel is what + * gives the flat shards their outline thickness. TEXT_SIZE is the em size in world + * units; all animation magnitudes are expressed relative to it, exactly as in the + * source, and only the camera decides how large the wordmark renders. + */ +const TEXT_SIZE = 14; +const BEVEL_SIZE = 0.75; +const BEVEL_THICKNESS = 0.5; + +/** + * Extra advance between glyphs, in font units (the typeface is normalised to 1000/em). + * The bevel expands every outline by BEVEL_SIZE on each side — about 54 font units at + * TEXT_SIZE 14 — which is enough to make adjacent Archivo Bold glyphs touch. This puts + * the eaten gap back so letters stay separate. + */ +const TRACKING = 130; + +/** + * The original shader draws the *resting* wordmark at exactly twice its geometry: + * at tProgress 0 the undamped `position` term and `cubicBezier(t=0) == p0` add up to + * `2.0 * position`. That doubling is part of the effect (the pen's text is this large + * too), so every camera-fit formula below must size against REST_SCALE × geometry. + */ +const REST_SCALE = 2; + +const CAMERA_FOV = 10; +/** + * The pen parks its camera at z=1400, which shows 2·1400·tan(fov/2) ≈ 245 world units + * of height. That reference height is what the original control-point magnitudes + * (y up to 120) were tuned against; uArcScale is the ratio between the current visible + * height and this one. + */ +const REFERENCE_CAMERA_Z = 1400; + +/** Below this h1 font-size the single-line wordmark reads too small — reflow to two lines. */ +const MIN_SINGLE_LINE_EM_PX = 36; +/** Baseline-to-baseline distance in em for the two-line layout. */ +const LINE_HEIGHT = 1.05; +/** Share of the canvas width the wordmark may span before the camera pulls back. */ +const SINGLE_LINE_MAX_FILL = 0.95; +const TWO_LINE_FILL = 0.88; +/** The two-line block also may not exceed this share of the canvas height. */ +const BLOCK_MAX_HEIGHT_RATIO = 0.75; + +/** One direction of the ping-pong tween takes 4s (TweenMax.fromTo(..., 4, ...)). */ +const TWEEN_SECONDS = 4; +/** timeScale eases to 0 / back to 1 over 2s on press / release. */ +const TIMESCALE_SECONDS = 2; +/** Drag distance to progress conversion, matching the original seekSpeed. */ +const SEEK_SPEED = 0.001; + +const vertexShader = /* glsl */ ` + uniform float uTime; + uniform float uArcScale; + + attribute vec2 aAnimation; + attribute vec3 aControl0; + attribute vec3 aControl1; + + // THREE.BAS.ShaderChunk['cubic_bezier'] + vec3 cubicBezier(vec3 p0, vec3 c0, vec3 c1, vec3 p1, float t) { + float tn = 1.0 - t; + return tn * tn * tn * p0 + + 3.0 * tn * tn * t * c0 + + 3.0 * tn * t * t * c1 + + t * t * t * p1; + } + + void main() { + float tDelay = aAnimation.x; + float tDuration = aAnimation.y; + float tTime = clamp(uTime - tDelay, 0.0, tDuration); + float tProgress = tTime / tDuration; + + vec3 tPosition = position; + tPosition *= 1.0 - tProgress; + tPosition += cubicBezier(position, aControl0 * uArcScale, aControl1 * uArcScale, vec3(0.0), tProgress); + + gl_Position = projectionMatrix * modelViewMatrix * vec4(tPosition, 1.0); + } +`; + +const fragmentShader = /* glsl */ ` + uniform vec3 uColor; + + void main() { + gl_FragColor = vec4(uColor, 1.0); + } +`; + +const quadEaseInOut = (t: number): number => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t); +const quadEaseOut = (t: number): number => 1 - (1 - t) * (1 - t); + +interface SchedulerWithYield { + yield?: () => Promise; +} + +/** + * Hands the main thread back so the browser can paint and answer input. + * + * Building the wordmark is ~44k faces of triangulation and attribute filling. Run as one + * task on a throttled phone that is a third of a second in which nothing responds, and it + * lands squarely in Lighthouse's Total Blocking Time window. Split across tasks the work + * costs the same but never blocks for more than a frame or two. + * + * `scheduler.yield` resumes ahead of unrelated tasks where it exists; the setTimeout + * fallback goes to the back of the queue but still breaks the task up. + */ +function yieldToMain(): Promise { + const scheduler = (globalThis as { scheduler?: SchedulerWithYield }).scheduler; + if (scheduler?.yield) return scheduler.yield(); + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +/** + * How long a chunk of face work may run before handing the thread back. Well under the + * 50ms that makes a task "long", with room for the browser's own work in the same frame. + * A time budget rather than a face count because the same chunk size is a rounding error + * on a desktop and a visible stall on a throttled phone. + */ +const CHUNK_BUDGET_MS = 8; + +/** Faces between clock checks — often enough to hold the budget, rare enough to be free. */ +const CHUNK_CHECK_INTERVAL = 256; + +/** + * Per-face animation attributes, mirroring the loop in the original TextAnimation. + * Control points are shared by a face's three vertices, but the delay gets an extra + * Math.random() *per vertex* — that per-vertex jitter is what smears triangles apart + * mid-flight instead of moving them as rigid shards. + * + * Returns the animation duration: maxDelay + maxDuration + max per-vertex jitter. + */ +async function buildShardAttributes(geometry: BufferGeometry): Promise { + const position = geometry.getAttribute('position'); + const vertexCount = position.count; + const faceCount = vertexCount / 3; + + // Read straight out of the backing store. At this vertex count the getX/getY/getZ + // accessor calls are a measurable share of the loop on a throttled device. + const points = position.array as ArrayLike; + + const animation = new Float32Array(vertexCount * 2); + const control0 = new Float32Array(vertexCount * 3); + const control1 = new Float32Array(vertexCount * 3); + + let chunkStart = performance.now(); + + for (let face = 0; face < faceCount; face++) { + if (face % CHUNK_CHECK_INTERVAL === 0 && performance.now() - chunkStart > CHUNK_BUDGET_MS) { + await yieldToMain(); + chunkStart = performance.now(); + } + + const a = face * 3; + const p = a * 3; + + const centroidX = (points[p] + points[p + 3] + points[p + 6]) / 3; + const centroidY = (points[p + 1] + points[p + 4] + points[p + 7]) / 3; + const centroidZ = (points[p + 2] + points[p + 5] + points[p + 8]) / 3; + + const dirX = centroidX > 0 ? 1 : -1; + const dirY = centroidY > 0 ? 1 : -1; + + const distance = Math.sqrt(centroidX * centroidX + centroidY * centroidY + centroidZ * centroidZ); + const delay = distance * MathUtils.randFloat(0.03, 0.06); + const duration = MathUtils.randFloat(2, 4); + + const c0x = MathUtils.randFloat(0, 30) * dirX; + const c0y = MathUtils.randFloat(60, 120) * dirY; + const c0z = MathUtils.randFloat(-20, 20); + + const c1x = MathUtils.randFloat(30, 60) * dirX; + const c1y = MathUtils.randFloat(0, 60) * dirY; + const c1z = MathUtils.randFloat(-20, 20); + + for (let v = 0; v < 3; v++) { + const i = a + v; + const i2 = i * 2; + const i3 = i * 3; + + animation[i2] = delay + Math.random(); + animation[i2 + 1] = duration; + + control0[i3] = c0x; + control0[i3 + 1] = c0y; + control0[i3 + 2] = c0z; + + control1[i3] = c1x; + control1[i3 + 1] = c1y; + control1[i3 + 2] = c1z; + } + } + + geometry.setAttribute('aAnimation', new BufferAttribute(animation, 2)); + geometry.setAttribute('aControl0', new BufferAttribute(control0, 3)); + geometry.setAttribute('aControl1', new BufferAttribute(control1, 3)); + + geometry.computeBoundingBox(); + const bounds = geometry.boundingBox; + const size = bounds ? bounds.max.clone().sub(bounds.min) : new Vector3(TEXT_SIZE, TEXT_SIZE, TEXT_SIZE); + const maxDelay = size.multiplyScalar(0.5).length() * 0.06; + + return maxDelay + 4 + 1; +} + +interface Wordmark { + geometry: BufferGeometry; + width: number; + height: number; + duration: number; +} + +export interface HeroAnimation { + destroy: () => void; +} + +export async function createHeroAnimation(canvas: HTMLCanvasElement): Promise { + const spacedTypeface = { + ...typefaceData, + glyphs: Object.fromEntries( + Object.entries(typefaceData.glyphs).map(([char, glyph]) => [char, { ...glyph, ha: glyph.ha + TRACKING }]), + ), + }; + const font = new FontLoader().parse(spacedTypeface); + + /** Builds the (possibly multi-line) wordmark with every line centred, plus its shard attributes. */ + async function buildWordmark(lines: string[]): Promise { + const parts: BufferGeometry[] = []; + + for (const [index, line] of lines.entries()) { + // One TextGeometry is the single largest indivisible piece of work here (~40ms per + // line on a throttled phone). Give each one a task of its own — including the first, + // which otherwise lands in the same task as this module's own evaluation. + await yieldToMain(); + + const part = new TextGeometry(line, { + font, + size: TEXT_SIZE, + depth: 0, + bevelEnabled: true, + bevelSize: BEVEL_SIZE, + bevelThickness: BEVEL_THICKNESS, + }); + + // The material is flat, unlit and single-coloured: the shader reads `position` and + // the three shard attributes, nothing else. Dropping the generated normals and UVs + // keeps them out of the merge and out of the ~130k-vertex buffer upload. + part.deleteAttribute('normal'); + part.deleteAttribute('uv'); + + part.computeBoundingBox(); + const b = part.boundingBox; + if (b) part.translate(-b.min.x - (b.max.x - b.min.x) / 2, -index * TEXT_SIZE * LINE_HEIGHT, 0); + parts.push(part); + } + + await yieldToMain(); + + const merged = parts.length === 1 ? parts[0] : mergeGeometries(parts); + if (parts.length > 1) for (const part of parts) part.dispose(); + + // Equivalent of THREE.BAS.Utils.separateFaces: every triangle must own its vertices + // so per-face attributes do not bleed between shards. + const geometry = merged.index === null ? merged : merged.toNonIndexed(); + if (geometry !== merged) merged.dispose(); + + // anchor: {x: 0.5, y: 0.5, z: 0.5} + geometry.center(); + geometry.computeBoundingBox(); + const bounds = geometry.boundingBox; + + await yieldToMain(); + + return { + geometry, + width: bounds ? bounds.max.x - bounds.min.x : TEXT_SIZE, + height: bounds ? bounds.max.y - bounds.min.y : TEXT_SIZE, + duration: await buildShardAttributes(geometry), + }; + } + + const material = new ShaderMaterial({ + vertexShader, + fragmentShader, + side: DoubleSide, + uniforms: { + uTime: { value: 0 }, + uArcScale: { value: 1 }, + uColor: { value: new Color(0x000000) }, + }, + }); + + const titleElement = canvas.parentElement?.querySelector('h1') ?? null; + + /** The h1 fallback's computed font-size — the wordmark matches it so canvas and fallback read the same. */ + function measureTitleEmPx(): number { + if (titleElement) { + const px = Number.parseFloat(getComputedStyle(titleElement).fontSize); + if (Number.isFinite(px) && px > 0) return px; + } + // Mirrors the h1's clamp(1rem, 7.8vw, 8rem) if the element is missing. + return MathUtils.clamp(window.innerWidth * 0.078, 16, 128); + } + + const measureLineCount = (): number => (measureTitleEmPx() < MIN_SINGLE_LINE_EM_PX ? 2 : 1); + const linesFor = (count: number): string[] => (count === 1 ? [HERO_TEXT] : HERO_TEXT.split(' ')); + + // Choose the layout before building. Building single-line and letting the first resize() + // discard it meant every phone-width viewport — where the reflow always triggers — + // triangulated the wordmark twice, on exactly the devices least able to afford it. + let lineCount = measureLineCount(); + let wordmark = await buildWordmark(linesFor(lineCount)); + + const mesh = new Mesh(wordmark.geometry, material); + mesh.frustumCulled = false; + + const scene = new Scene(); + scene.add(mesh); + + const camera = new PerspectiveCamera(CAMERA_FOV, 1, 1, 10000); + + // Creating the GL context costs about as much as building a line of text; keep it out + // of the task that just finished the shard attributes. + await yieldToMain(); + + const renderer = new WebGLRenderer({ canvas, antialias: true, alpha: true }); + renderer.setClearColor(0xffffff, 0); + + let rebuilding = false; + + /** Rebuilds the wordmark when the viewport crosses the one/two-line threshold. */ + async function reflow(): Promise { + const nextLineCount = measureLineCount(); + if (rebuilding || nextLineCount === lineCount) return false; + + rebuilding = true; + try { + const next = await buildWordmark(linesFor(nextLineCount)); + wordmark.geometry.dispose(); + wordmark = next; + lineCount = nextLineCount; + mesh.geometry = next.geometry; + } finally { + rebuilding = false; + } + return true; + } + + /** Fits the drawing buffer and camera to the canvas, then repaints. */ + function applyLayout(): void { + const { clientWidth, clientHeight } = canvas; + if (clientWidth === 0 || clientHeight === 0) return; + + // Cap the pixel ratio: an uncapped one blows the framebuffer up on 3x displays for + // no visible gain on flat black shapes. + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + renderer.setSize(clientWidth, clientHeight, false); + + const emPx = measureTitleEmPx(); + const aspect = clientWidth / clientHeight; + camera.aspect = aspect; + + const tanHalfFov = Math.tan((CAMERA_FOV / 2) * (Math.PI / 180)); + + // Distance at which the resting wordmark's em renders as emPx pixels — i.e. the + // same type size as the h1 fallback. + const zMatchTitle = (REST_SCALE * TEXT_SIZE * clientHeight) / (emPx * 2 * tanHalfFov); + // Never let the wordmark overflow the canvas, whatever the title size asks for. + const maxFill = lineCount === 1 ? SINGLE_LINE_MAX_FILL : TWO_LINE_FILL; + const zFitWidth = (REST_SCALE * wordmark.width) / maxFill / (2 * tanHalfFov * aspect); + const zFitHeight = (REST_SCALE * wordmark.height) / BLOCK_MAX_HEIGHT_RATIO / (2 * tanHalfFov); + const z = Math.max(zMatchTitle, zFitWidth, zFitHeight); + + camera.position.set(0, 0, z); + camera.updateProjectionMatrix(); + + // Scale the bezier arcs with the visible stage height so the debris field fills + // the canvas the way the original fills its window. + material.uniforms.uArcScale.value = z / REFERENCE_CAMERA_Z; + + // A resize must repaint immediately: waiting for the next rAF leaves a stretched + // stale frame on screen for a beat (or indefinitely, in a backgrounded tab). + applyProgress(); + renderer.render(scene, camera); + } + + /** + * Repaints at the new size straight away, then — only when the viewport has crossed the + * one/two-line threshold — rebuilds the wordmark and fits the camera to it. The first + * pass may fit the outgoing geometry for a frame; that beats leaving a stretched frame + * on screen for the length of a rebuild. + */ + function resize(): void { + applyLayout(); + reflow() + .then((rebuilt) => { + if (rebuilt) applyLayout(); + }) + .catch((error: unknown) => { + // A failed reflow leaves the previous wordmark on screen, which still reads. + console.error('Hero wordmark reflow failed.', error); + }); + } + + // --- tween state (TweenMax.fromTo(..., 4, {animationProgress: 0 → 1, yoyo, repeat: -1})) --- + + /** Linear time along the infinite yoyo timeline, in seconds. */ + let tweenTime = 0; + /** Playback rate; dragged to 0 on press and back to 1 on release. */ + let timeScale = 1; + let timeScaleFrom = 1; + let timeScaleTarget = 1; + let timeScaleElapsed = TIMESCALE_SECONDS; + + function applyProgress(): void { + const cycle = (tweenTime % (TWEEN_SECONDS * 2)) / TWEEN_SECONDS; + const linear = cycle <= 1 ? cycle : 2 - cycle; + material.uniforms.uTime.value = wordmark.duration * quadEaseInOut(linear); + } + + let frame = 0; + let last = performance.now(); + + function tick(now: number): void { + frame = requestAnimationFrame(tick); + + const delta = Math.min((now - last) / 1000, 0.1); + last = now; + + // TweenMax.to(tween, 2, {timeScale}) with GSAP's default Power1.easeOut. + if (timeScaleElapsed < TIMESCALE_SECONDS) { + timeScaleElapsed = Math.min(timeScaleElapsed + delta, TIMESCALE_SECONDS); + timeScale = timeScaleFrom + (timeScaleTarget - timeScaleFrom) * quadEaseOut(timeScaleElapsed / TIMESCALE_SECONDS); + } + + tweenTime += delta * timeScale; + applyProgress(); + renderer.render(scene, camera); + } + + // --- scrubber (createTweenScrubber) --- + + function tweenTimeScaleTo(target: number): void { + timeScaleFrom = timeScale; + timeScaleTarget = target; + timeScaleElapsed = 0; + } + + /** Seek within the current iteration, clamped to it — same as the original's tween.progress(). */ + function seek(dx: number): void { + const iteration = Math.floor(tweenTime / TWEEN_SECONDS); + const progress = tweenTime / TWEEN_SECONDS - iteration; + const next = MathUtils.clamp(progress + dx * SEEK_SPEED, 0, 1); + tweenTime = (iteration + next) * TWEEN_SECONDS; + } + + let pointerX = 0; + let dragging = false; + + canvas.style.cursor = 'pointer'; + canvas.style.touchAction = 'none'; + + function onPointerDown(event: PointerEvent): void { + // Stops the drag from starting a native text selection on the rest of the page. + event.preventDefault(); + dragging = true; + pointerX = event.clientX; + canvas.style.cursor = 'ew-resize'; + canvas.setPointerCapture(event.pointerId); + tweenTimeScaleTo(0); + } + + function onPointerMove(event: PointerEvent): void { + if (!dragging) return; + const dx = event.clientX - pointerX; + pointerX = event.clientX; + seek(dx); + } + + function onPointerUp(event: PointerEvent): void { + if (!dragging) return; + dragging = false; + canvas.style.cursor = 'pointer'; + canvas.releasePointerCapture(event.pointerId); + tweenTimeScaleTo(1); + } + + canvas.addEventListener('pointerdown', onPointerDown); + canvas.addEventListener('pointermove', onPointerMove); + canvas.addEventListener('pointerup', onPointerUp); + canvas.addEventListener('pointercancel', onPointerUp); + + function start(): void { + if (frame !== 0) return; + last = performance.now(); + frame = requestAnimationFrame(tick); + } + + function stop(): void { + if (frame === 0) return; + cancelAnimationFrame(frame); + frame = 0; + } + + function onVisibilityChange(): void { + if (document.hidden) stop(); + else start(); + } + + // The first render compiles the shader and uploads every buffer — the last big chunk, + // and one more task boundary keeps it off the back of the GL context creation. + await yieldToMain(); + + resize(); + start(); + + // Watching the element rather than the window also covers the case where the canvas + // gains its layout box after this runs. + const observer = new ResizeObserver(resize); + observer.observe(canvas); + document.addEventListener('visibilitychange', onVisibilityChange); + + return { + destroy() { + stop(); + observer.disconnect(); + document.removeEventListener('visibilitychange', onVisibilityChange); + canvas.removeEventListener('pointerdown', onPointerDown); + canvas.removeEventListener('pointermove', onPointerMove); + canvas.removeEventListener('pointerup', onPointerUp); + canvas.removeEventListener('pointercancel', onPointerUp); + wordmark.geometry.dispose(); + material.dispose(); + renderer.dispose(); + }, + }; +} diff --git a/src/styles/global.css b/src/styles/global.css new file mode 100644 index 0000000..0b43fa3 --- /dev/null +++ b/src/styles/global.css @@ -0,0 +1,158 @@ +:root { + --color-bg: #fff; + --color-ink: #000; + --color-muted: #666; + /* The lightest grey that still clears WCAG AA (4.6:1 on white) at the footer's size, + so the footer stays recessed against --color-muted without failing contrast. */ + --color-faint: #757575; + + --font-sans: 'Archivo Variable', system-ui, -apple-system, 'Segoe UI', sans-serif; + + --gutter: clamp(1rem, 3vw, 2rem); + --mark-size: clamp(1.75rem, 4vw, 3rem); +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + min-height: 100svh; + display: flex; + flex-direction: column; + background: var(--color-bg); + color: var(--color-ink); + font-family: var(--font-sans); + overflow-x: hidden; + -webkit-font-smoothing: antialiased; +} + +img, +canvas { + display: block; + max-width: 100%; +} + +/* ---------- Mark ---------- */ + +.mark { + position: fixed; + inset-block-start: var(--gutter); + inset-inline-start: var(--gutter); + z-index: 1; + width: var(--mark-size); + height: var(--mark-size); +} + +/* ---------- Hero ---------- */ + +.hero { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: clamp(0.75rem, 2vw, 1.5rem); + /* Tighter than --gutter: a single-line wordmark is width-starved on phones, so every + pixel of horizontal room goes to the text. */ + padding-inline: clamp(0.5rem, 2vw, 2rem); +} + +/* + * The stage owns an explicit height so the tagline sits immediately below the + * wordmark. A full-viewport canvas would leave the tagline's position depending on + * the rendered text height, which drifts on every resize. + */ +.hero__stage { + position: relative; + width: 100%; + /* Tall enough for the shard scatter (~2 cap heights either side of the wordmark) + and no taller, so the tagline still sits right under the animation. */ + height: min(36svh, 30vw); + min-height: 6rem; +} + +.hero__canvas { + width: 100%; + height: 100%; + /* Revealed only once WebGL has actually initialised. */ + display: none; +} + +.webgl-ready .hero__canvas { + display: block; +} + +/* + * The real heading is always in the DOM and visible by default, so the page still + * reads without JS, without WebGL and to crawlers. WebGL replaces it rather than + * layering on top of it. + */ +.hero__title { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + margin: 0; + /* Uppercased in CSS only: the DOM keeps the brand's real casing for crawlers and + screen readers while the visible fallback matches the WebGL wordmark. + The vw factor is sized so the uppercased string never overflows at nowrap. */ + font-size: clamp(1rem, 7.8vw, 8rem); + font-weight: 700; + letter-spacing: -0.01em; + line-height: 1; + text-transform: uppercase; + white-space: nowrap; +} + +.webgl-ready .hero__title { + /* Kept for assistive tech and crawlers once the canvas takes over. */ + clip-path: inset(50%); + height: 1px; + width: 1px; + inset: auto; + overflow: hidden; + white-space: nowrap; +} + +.hero__tagline { + margin: 0; + color: var(--color-muted); + font-size: clamp(0.8rem, 1.6vw, 1rem); + font-weight: 500; + letter-spacing: 0.32em; + text-indent: 0.32em; + text-transform: uppercase; +} + +/* ---------- Footer ---------- */ + +.footer { + padding: var(--gutter); + color: var(--color-faint); + font-size: clamp(0.68rem, 1.2vw, 0.8rem); + letter-spacing: 0.04em; + text-align: center; +} + +.footer p { + margin: 0; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..990abae --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + } +}