Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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.
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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).
Expand Down
8 changes: 5 additions & 3 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions public/_headers
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Served by Cloudflare's static assets (wrangler.jsonc). Hashed build output never changes under its
# name, so it may be cached for good; everything else revalidates.

/*
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=()
Cross-Origin-Opener-Policy: same-origin

/_astro/*
Cache-Control: public, max-age=31536000, immutable
22 changes: 17 additions & 5 deletions src/components/install.astro
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ const command = "npm create bounda@latest my-app";
<div class="install">
<span class="prompt" aria-hidden="true">$</span>
<code>{command}</code>
<button type="button" data-copy={command}>Copy</button>
<button type="button" data-copy={command} aria-label="Copy the install command">Copy</button>
<span class="visually-hidden" role="status" aria-live="polite" data-copy-status></span>
</div>

<style>
Expand Down Expand Up @@ -50,11 +51,16 @@ const command = "npm create bounda@latest my-app";
cursor: pointer;
}

button:hover {
button:hover,
button:focus-visible {
color: var(--text);
background: var(--raised);
}

button:focus-visible {
outline-offset: -3px;
}

@media (max-width: 760px) {
.install {
width: 100%;
Expand All @@ -80,15 +86,21 @@ const command = "npm create bounda@latest my-app";

<script>
for (const button of document.querySelectorAll<HTMLButtonElement>("[data-copy]")) {
const status = button.parentElement?.querySelector("[data-copy-status]");
let reset: ReturnType<typeof setTimeout> | undefined;
button.addEventListener("click", async () => {
let outcome = "Copied";
try {
await navigator.clipboard.writeText(button.dataset.copy ?? "");
button.textContent = "Copied";
} catch {
button.textContent = "Select";
outcome = "Select";
}
setTimeout(() => {
button.textContent = outcome;
if (status) status.textContent = outcome === "Copied" ? "Install command copied" : "Copying failed; select the command instead";
clearTimeout(reset);
reset = setTimeout(() => {
button.textContent = "Copy";
if (status) status.textContent = "";
}, 1600);
});
}
Expand Down
3 changes: 2 additions & 1 deletion src/components/landing/facts.astro
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ const facts = [
];
---

<section class="facts" aria-label="What Bounda gives you">
<section class="facts" aria-labelledby="facts-title">
<div class="wrap">
<h2 class="visually-hidden" id="facts-title">What Bounda gives you</h2>
<div class="grid">
{
facts.map((f, i) => (
Expand Down
21 changes: 7 additions & 14 deletions src/components/landing/hero.astro
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,20 @@ import Actions from "../actions.astro";
<div class="copy" id="hero-copy">
<p class="label eyebrow" data-enter>Event sourcing and CQRS for TypeScript</p>
<h1 class="display">
<span class="line"><span data-rise style="--enter-delay: 0.06s">Event sourcing</span></span>{" "}
<span class="line"><span data-rise style="--enter-delay: 0.14s">without the</span></span>{" "}
<span class="line"><span data-rise class="quiet" style="--enter-delay: 0.22s">ceremony.</span></span>
<span class="line" data-enter style="--enter-delay: 0.05s">Event sourcing</span>{" "}
<span class="line" data-enter style="--enter-delay: 0.12s">without the</span>{" "}
<span class="line quiet" data-enter style="--enter-delay: 0.19s">ceremony.</span>
</h1>
<p class="lead-short" data-enter style="--enter-delay: 0.38s">
<p class="lead-short" data-enter style="--enter-delay: 0.28s">
<b>Your tables keep the last write. Bounda keeps every change.</b> Rebuild any view from it, on one database.
</p>
</div>
<div class="foot" id="hero-foot">
<p class="lead" data-enter style="--enter-delay: 0.38s">
<p class="lead" data-enter style="--enter-delay: 0.28s">
<b>Your tables keep the last write. Bounda keeps every change.</b> Rebuild any view, explain any past state. Plain
TypeScript modules, inferred types, one database, no broker.
</p>
<Actions data-enter style="--enter-delay: 0.46s" />
<Actions data-enter style="--enter-delay: 0.36s" />
</div>
</section>

Expand Down Expand Up @@ -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 {
Expand Down
56 changes: 13 additions & 43 deletions src/components/landing/stage.astro
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ for (let r = 0; r < 7; r++) {
const round = (n: number): string => n.toFixed(1);
---

<!-- Before first paint: hold the hero back for the intro (src/scripts/landing/intro.ts), once per session
and never with reduced motion. If the page's script does not run, the hero shows anyway. -->
<!-- Before first paint: mark a first visit for the intro (src/scripts/landing/intro.ts), once per session
and never with reduced motion. The hero enters from the first frame either way; if the page's
script never runs, the intro's stream is taken down after a while. -->
<script is:inline>
(function () {
var root = document.documentElement;
Expand Down Expand Up @@ -72,14 +73,11 @@ const round = (n: number): string => n.toFixed(1);
<div class="callout" data-callout="rebuilt"><i></i><span>Orders · <em>rebuilt</em></span></div>
</div>

<!-- The intro: the stream drawn up to its head, where the scene then lights up (src/scripts/landing/intro.ts). -->
<!-- The intro: while the scene loads, the stream draws itself up to where its head will be, and the
readout counts up to it (src/scripts/landing/intro.ts). -->
<div class="intro-stream" id="intro-stream" aria-hidden="true">
<div class="ruler"></div>
<div class="cursor"></div>
<div class="count">
<span class="count-key">Global stream · catching up</span>
<span class="count-value" id="intro-count">000 001</span>
</div>
</div>

<div class="hud" id="hud" aria-hidden="true">
Expand Down Expand Up @@ -287,6 +285,7 @@ const round = (n: number): string => n.toFixed(1);
transition: opacity 0.6s var(--ease);
}

/* Until the script writes its opacity, the readout waits; during the intro it is the counter. */
:global(.intro) .hud {
opacity: 0;
}
Expand All @@ -295,14 +294,15 @@ const round = (n: number): string => n.toFixed(1);
opacity: 0;
}

/* A ruler of events on a hairline rod, fading in from the far end like the stream in the scene. */
/* A ruler of events on a hairline rod, fading in from the far end like the stream in the scene.
Both are placed by transforms, so the drawing never moves anything else on the page. */
.ruler {
position: absolute;
left: 0;
top: var(--head-y, 68vh);
top: 0;
width: var(--head-x, 0px);
height: 16px;
transform: translateY(-50%);
transform: translateY(calc(var(--head-y, 68vh) - 50%));
background:
linear-gradient(var(--rule), var(--rule)) 0 50% / 100% 1px no-repeat,
repeating-linear-gradient(90deg, var(--muted) 0 1px, transparent 1px 80px) 0 0 / 100% 100% no-repeat,
Expand All @@ -312,45 +312,15 @@ const round = (n: number): string => n.toFixed(1);

.cursor {
position: absolute;
left: var(--head-x, 0px);
top: var(--head-y, 68vh);
left: 0;
top: 0;
width: 2px;
height: 34px;
transform: translate(-1px, -50%);
transform: translate(calc(var(--head-x, 0px) - 1px), calc(var(--head-y, 68vh) - 50%));
background: var(--accent);
box-shadow: 0 0 14px 2px color-mix(in srgb, var(--accent) 55%, transparent);
}

/* The position rides just past the cursor (placed by intro.ts), above the ruler. */
.count {
position: absolute;
left: var(--count-x, var(--gutter));
top: var(--head-y, 68vh);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
transform: translateY(calc(-100% - 26px));
font: 500 clamp(2.4rem, 1.4rem + 2.6vw, 4.25rem) / 1 var(--font-mono);
white-space: nowrap;
}

.count-key {
font-family: var(--font-display);
font-weight: 600;
font-stretch: 125%;
font-size: 0.625rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
}

.count-value {
letter-spacing: 0.01em;
font-variant-numeric: tabular-nums;
color: var(--accent-text);
}

/* ---- Readout ---- */
.hud {
position: fixed;
Expand Down
3 changes: 2 additions & 1 deletion src/components/landing/story.astro
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,10 @@
</section>

<style>
/* The script sets the exact height from the timeline's segments; this is the same figure. */
.story {
position: relative;
height: 480vh;
height: 520vh;
}

.pin {
Expand Down
22 changes: 15 additions & 7 deletions src/components/nav.astro
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import ThemeToggle from "./theme-toggle.astro";

<header class="nav">
<a class="brand" href="/" aria-label="Bounda home"><Lockup /></a>
<ul data-enter style="--enter-delay: 0.46s">
<li><a class="link" href="https://docs.bounda.dev/getting-started/">Docs</a></li>
<li class="wide"><a class="link" href="https://demo.bounda.dev">Demo</a></li>
<li><a class="link" href="https://github.com/bounda-dev/bounda">GitHub</a></li>
<li><ThemeToggle /></li>
</ul>
<nav aria-label="Primary" data-enter style="--enter-delay: 0.42s">
<ul>
<li><a class="link" href="https://docs.bounda.dev/getting-started/">Docs</a></li>
<li class="wide"><a class="link" href="https://demo.bounda.dev">Demo</a></li>
<li><a class="link" href="https://github.com/bounda-dev/bounda">GitHub</a></li>
<li><ThemeToggle /></li>
</ul>
</nav>
</header>

<style>
Expand Down Expand Up @@ -60,7 +62,11 @@ import ThemeToggle from "./theme-toggle.astro";
padding: 0;
}

/* Small type, but a finger-sized target: the padding is folded back into the margin. */
.link {
display: inline-block;
padding: 12px 6px;
margin: -12px -6px;
font-family: var(--font-display);
font-weight: 600;
font-stretch: 125%;
Expand All @@ -70,9 +76,11 @@ import ThemeToggle from "./theme-toggle.astro";
text-transform: uppercase;
text-decoration: none;
color: var(--muted);
transition: color 0.16s var(--ease);
}

.link:hover {
.link:hover,
.link:focus-visible {
color: var(--text);
}

Expand Down
6 changes: 3 additions & 3 deletions src/components/theme-toggle.astro
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
<button class="theme-toggle" type="button" data-theme-toggle>
<span class="disc" aria-hidden="true"></span>
<span class="visually-hidden" data-theme-label>Switch mode</span>
<span class="visually-hidden" data-theme-label>Switch between light and dark mode</span>
</button>

<style>
.theme-toggle {
appearance: none;
display: inline-grid;
place-items: center;
width: 30px;
height: 26px;
width: 32px;
height: 28px;
padding: 0;
background: transparent;
border: 1px solid var(--rule);
Expand Down
Loading
Loading