From e6785e3e88508da92bb28b35fa735d0b87e83933 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Be=C3=B1at=20Espi=C3=B1a?= Date: Fri, 30 Oct 2026 23:12:58 +0100 Subject: [PATCH 1/2] perf: run the scene in a worker and paint the hero on the first frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lighthouse on mobile scored 41: the hero was held at opacity 0 until the intro finished, and three.js loaded, compiled and rendered on the main thread, which the intro waited on. LCP 7.3 s, TBT 7.5 s. The scene now runs in a Web Worker on an OffscreenCanvas wherever the browser allows it. scene.ts touches no DOM any more: everything it needs from the page comes through a SceneHost, so the same module runs in the worker (scene-worker.ts) or on the page for browsers without OffscreenCanvas WebGL (older Safari). scene-client.ts picks the path, sends the beat with one frame in flight at a time, and pins the callouts on the page from what the scene reports. The main thread is left with the scroll spring and a few style writes: TBT is 0 ms on both presets. The hero no longer waits for anything. It is painted, legible, on the first frame and settles into place with a transform-only animation; a fade would keep Chrome from counting the text as the LCP at all. The intro (the ruler drawing itself up to the head, the readout counting from 000 001) becomes the scene's loading state, drawn in the stage beside the copy, and gives way to the scene when it is ready. Its elements are placed by transforms, so the intro no longer shifts layout. Also: no prefetch script (every link leaves the site), the third font preloaded (the install command is in the hero), the story's CSS height matched to the timeline, and the scene skipped entirely while it is covered. Mobile 41 → 100 (LCP 0.9 s, TBT 0 ms, CLS 0); desktop 92 → 100. Co-Authored-By: Claude Fable 5.1 --- DESIGN.md | 4 +- README.md | 10 +- astro.config.mjs | 8 +- src/components/landing/hero.astro | 21 +-- src/components/landing/stage.astro | 56 ++----- src/components/landing/story.astro | 3 +- src/scripts/landing/intro.ts | 75 +++++---- src/scripts/landing/scene-client.ts | 241 ++++++++++++++++++++++++++++ src/scripts/landing/scene-worker.ts | 118 ++++++++++++++ src/scripts/landing/scene.ts | 150 +++++++++-------- src/scripts/landing/story.ts | 45 ++---- src/styles/global.css | 25 ++- 12 files changed, 548 insertions(+), 208 deletions(-) create mode 100644 src/scripts/landing/scene-client.ts create mode 100644 src/scripts/landing/scene-worker.ts diff --git a/DESIGN.md b/DESIGN.md index 6963ca3..68f4793 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -90,6 +90,7 @@ How every table of facts is set, in docs, on the landing page and in Studio: - The hero is the mechanism: the ⅃ as a machined monolith whose foot becomes the global stream, events moving along it, the live head in ochre. - Real-time 3D in code (three.js), art-directed, in Basalt or Bone. No AI photography. +- The scene runs in a worker on an OffscreenCanvas wherever the browser allows it, so the page's own thread never loads, compiles or renders it; the page only sends the beat and pins the callouts. Where it cannot (older Safari) the same scene runs on the page. - **The object takes the logo's colour** (`--text` on `--bg`): satin aluminium on Basalt, black anodised on Bone. Each mode has its own studio; the geometry and the choreography are the same. - Glows are ochre. On Basalt they add light; on Bone they tint, because added light disappears on a pale ground. - Red appears in a scene only for a refused command or an error. @@ -108,6 +109,7 @@ It is not a marketing website. It answers what Bounda is and why; anything about - **One scene, one accent, at most three annotations on screen.** Light in the scene means something; no decorative glows, particles or neon. - **No metaphors, no "enterprise transformation" language, no exaggerated claims.** Say how stable it is: before 1.0, a minor release can change the API. - **Nothing depends on the 3D.** The page reads before the scene loads; without WebGL a drawing stands in, and with reduced motion the story is a still and a grid. +- **The hero is painted on the first frame.** On a first visit it settles into place from a few pixels below, in order, but it never fades in or waits: the intro (the stream catching up, the readout counting to the head) is the scene's loading state, drawn in the stage beside the copy, and gives way to the scene when it is ready or after about two seconds. - **Inspirations, by feeling and not by copying:** Vercel (clarity, spacing), Resend (confidence, restraint), Linear (focus, calm UI), EventStoreDB's docs (technical seriousness), and scroll-driven product films (one manufactured object that plays its own mechanism as you scroll). ## Voice @@ -116,6 +118,6 @@ Plain, technical, short. Active voice. The vocabulary of the product: event stor ## Where it lives -- Landing: `bounda-dev/bounda-website`, `src/styles/tokens.css`; the scene is `src/scripts/landing/scene.ts`. +- Landing: `bounda-dev/bounda-website`, `src/styles/tokens.css`; the scene is `src/scripts/landing/scene.ts`, started by `scene-client.ts` in a worker (`scene-worker.ts`) or on the page. - Docs: `docs/src/styles/bounda.css` in `bounda-dev/bounda`, mirroring the same tokens on Starlight's variables. - Studio: the same tokens; the ledger grammar for every table. diff --git a/README.md b/README.md index 7ced6a5..f0ccaf5 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,12 @@ pnpm dev # http://localhost:4321 pnpm build # dist/ ``` +The 3D scene (`src/scripts/landing/scene.ts`) runs in a Web Worker on an OffscreenCanvas where the +browser supports it, and on the page otherwise; `scene-client.ts` decides and `scene-worker.ts` is the +worker. Either way the page paints its hero on the first frame and never blocks on three.js, which is +what keeps Lighthouse at 100 on mobile. Measure with `pnpm build && pnpm preview` and Lighthouse +against `http://localhost:4321/`; the Cloudflare beacon is the one request that fails offline. + pnpm, like the framework repository, and not only for consistency: `npm ci` cannot install this tree on Linux from a lockfile resolved on macOS, because rolldown ships its native bindings as per-platform optional dependencies. The pnpm lockfile carries all of them. @@ -40,7 +46,9 @@ A Worker with static assets, built by Cloudflare on every push to `main`: | `NODE_VERSION` | `24`, since Astro 7 requires `>=22.12.0` | | `PNPM_VERSION` | `12.4.2`, because `allowBuilds` in `pnpm-workspace.yaml` is pnpm 12 | -`wrangler.jsonc` serves `dist/` and answers anything missing with `404.html`. The build image ships +`wrangler.jsonc` serves `dist/` and answers anything missing with `404.html`. `public/_headers` sets the +response headers: a year's immutable cache for the hashed files under `/_astro/`, and the usual safety +headers for everything. The build image ships pnpm 10, which reads `pnpm-workspace.yaml` as a workspace definition, so that file declares `packages` and names the built dependencies under both `allowBuilds` (pnpm 12) and `onlyBuiltDependencies` (pnpm 10). diff --git a/astro.config.mjs b/astro.config.mjs index ce2a176..b7e0da0 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -5,13 +5,15 @@ import { defineConfig, fontProviders } from "astro/config"; // so it is requested at that one weight across the width axis it uses (100% to 125%). export default defineConfig({ site: "https://bounda.dev", - prefetch: true, - integrations: [sitemap()], + // Every link on the page leaves the site (docs, demo, GitHub), so there is nothing to prefetch. + prefetch: false, + // The site is rebuilt on every push, so the build is the last change. + integrations: [sitemap({ lastmod: new Date() })], build: { inlineStylesheets: "always", }, vite: { - // three.js is one chunk, loaded after first paint and only where WebGL 2 is available. + // three.js is one chunk, run in a worker where the browser allows it (see src/scripts/landing/scene-client.ts). build: { chunkSizeWarningLimit: 650 }, // The scene imports three.js dynamically, so the dev server would only find it on first use, // re-optimize, and fail that import as an outdated dependency. Pre-bundle it from the start. diff --git a/src/components/landing/hero.astro b/src/components/landing/hero.astro index 8e78272..f6221e7 100644 --- a/src/components/landing/hero.astro +++ b/src/components/landing/hero.astro @@ -6,20 +6,20 @@ import Actions from "../actions.astro";

Event sourcing and CQRS for TypeScript

- Event sourcing{" "} - without the{" "} - ceremony. + Event sourcing{" "} + without the{" "} + ceremony.

-

+

Your tables keep the last write. Bounda keeps every change. Rebuild any view from it, on one database.

-

+

Your tables keep the last write. Bounda keeps every change. Rebuild any view, explain any past state. Plain TypeScript modules, inferred types, one database, no broker.

- +
@@ -63,16 +63,9 @@ import Actions from "../actions.astro"; color: var(--text); } - /* Each line rises from its own baseline, the way new entries arrive at the end. */ + /* Each line enters on its own, the way new entries arrive at the end. */ .line { display: block; - overflow: hidden; - padding-bottom: 0.1em; - margin-bottom: -0.1em; - } - - .line > span { - display: block; } h1 .quiet { diff --git a/src/components/landing/stage.astro b/src/components/landing/stage.astro index f4bcb52..6b6561d 100644 --- a/src/components/landing/stage.astro +++ b/src/components/landing/stage.astro @@ -28,8 +28,9 @@ for (let r = 0; r < 7; r++) { const round = (n: number): string => n.toFixed(1); --- - + + - + + diff --git a/src/pages/404.astro b/src/pages/404.astro index d3cc414..7250ede 100644 --- a/src/pages/404.astro +++ b/src/pages/404.astro @@ -4,7 +4,7 @@ import Nav from "../components/nav.astro"; import BaseLayout from "../layouts/base.astro"; --- - +