diff --git a/.fallowrc.json b/.fallowrc.json index a2960dab98..3494e711f0 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -42,9 +42,7 @@ "examples/sdk/batch-orchestration.ts", "examples/sdk/ai-sdk-tools.ts", "examples/sdk/host-runtime.ts", - "test/integration/**/*.test.ts", - "website/docs/404.mdx", - "website/rspress.config.ts" + "test/integration/**/*.test.ts" ], "framework": [ { @@ -59,6 +57,8 @@ ], "ignorePatterns": [ "examples/test-app/**", + // The social card template: scripts/og.mjs serves it with generated /og/* files. + "website/og/og.html", "scripts/perf/**", "scripts/layering/**", "scripts/di-seams/**", @@ -71,7 +71,17 @@ // by an import edge. Same shape as the help-conformance declaration files above. "scripts/size-report-package.d.mts" ], - "dynamicallyLoaded": ["packages/daemon/src/execution/interaction/index.ts"], + "dynamicallyLoaded": [ + "packages/daemon/src/execution/interaction/index.ts", + // The site's modules that Astro loads without an import Fallow can read: from an .astro + // ${home}` }); + assert.equal(builtBase(page('home')), BASE); + assert.throws(() => builtBase(page('home')), /cannot read the site base/); +}); diff --git a/scripts/capability-matrix/generate.ts b/scripts/capability-matrix/generate.ts index 17cdacf152..4b91efbc9b 100644 --- a/scripts/capability-matrix/generate.ts +++ b/scripts/capability-matrix/generate.ts @@ -270,8 +270,6 @@ export function renderPage(data: MatrixData): string { 'description: Which commands each target supports, read from the runtime code that decides it.', '---', '', - '# Capabilities', - '', 'The target tables below are generated from the runtime command-admission facts that back the `capabilities` command: a command that leaves the code leaves this page. Labels, limits, and integration rows are maintained beside the generator, and command availability does not establish support for every operation, subcommand, or device model.', `It covers agent-device ${data.version}, generated ${data.generatedAt}.`, '', diff --git a/scripts/check-affected/model.test.ts b/scripts/check-affected/model.test.ts index 5a01805a95..bd278d9f9f 100644 --- a/scripts/check-affected/model.test.ts +++ b/scripts/check-affected/model.test.ts @@ -200,8 +200,8 @@ test('colocated Android binary fixtures select the unit suite instead of failing ); }); -test('MCP metadata change selects the mcp-metadata check', () => { - assert.deepEqual(ids(['server.json']), ['mcp-metadata']); +test('MCP metadata change selects the mcp-metadata check and the site build that serves it', () => { + assert.deepEqual(ids(['server.json']), ['mcp-metadata', 'docs-links']); }); test('public package surface change selects the build and the published-package gate via exports', () => { @@ -231,7 +231,11 @@ test('user docs pages and README select the docs-links and docs-prose checks', ( assert.deepEqual(result.checks, ['docs-links', 'docs-prose'], file); assert.deepEqual(result.docsOnlyPaths, []); } - for (const file of ['website/package.json', 'website/rspress.config.ts']) { + for (const file of [ + 'website/package.json', + 'website/astro.config.mjs', + 'website/src/content.config.ts', + ]) { assert.deepEqual(plan([file]).checks, ['docs-links'], file); } assert.deepEqual(plan(['website/docs/docs/commands.md']).checks, [ diff --git a/scripts/check-affected/model.ts b/scripts/check-affected/model.ts index 0cb44ec071..941eea309e 100644 --- a/scripts/check-affected/model.ts +++ b/scripts/check-affected/model.ts @@ -490,6 +490,12 @@ const BUILD_OWNERSHIP: ReadonlyArray<{ detail: 'MCP registry metadata must stay in sync', owns: (file) => file === 'server.json' || file === 'smithery.yaml', }, + { + check: 'docs-links', + rule: 'own:docs-links', + detail: 'the site builds /.well-known/mcp.json from server.json', + owns: (file) => file === 'server.json', + }, // image-size ships no fixed version for its parser DoS advisories; the in-tree // pnpm patch is the mitigation, and only its defining files own the proof. { @@ -566,9 +572,11 @@ function isAgentGuidance(file: string): boolean { } // The user docs: README and the docs site pages. `check:doc-prose` reads them; `check:doc-links` -// resolves their links and also reruns when the website manifest or config that decide what the -// built site renders change. -const SITE_BUILD_INPUTS = new Set(['website/package.json', 'website/rspress.config.ts']); +// resolves their links and also reruns when the site's own code changes: its manifest, config, +// pages and components (everything in website/ outside the docs tree and loose Markdown). +function isSiteBuildInput(file: string): boolean { + return file.startsWith('website/') && !file.startsWith('website/docs/') && !/\.mdx?$/.test(file); +} // The generated capabilities page. Its cells are a read of the runtime facts, so the // page and the generator that renders it share one drift gate. @@ -610,7 +618,7 @@ const docsOwnership: OwnershipRule = ({ file }) => { ), ); } - if (isUserDocPage(file) || SITE_BUILD_INPUTS.has(file)) { + if (isUserDocPage(file) || isSiteBuildInput(file)) { selections.push( reason( 'docs-links', diff --git a/scripts/check-doc-links.ts b/scripts/check-doc-links.ts index 759c04d341..82ae9bbf2c 100644 --- a/scripts/check-doc-links.ts +++ b/scripts/check-doc-links.ts @@ -1,9 +1,10 @@ -// `pnpm check:doc-links` — every internal link on the built docs site lands on a page and an -// element ID that rspress actually rendered, and README.md's links into the published docs do too. -// It reads website/doc_build after `pnpm --dir website build`, so rspress's own slugger, link +// `pnpm check:doc-links` — every internal link on the built site lands on a page and an +// element ID that Astro actually rendered, and README.md's links into the published docs do too. +// It reads website/dist after `pnpm --dir website build`, so Starlight's own slugger, link // rewriting, and clean URLs are what get checked. External URLs are skipped (no network). +// Every docs page also needs its Markdown twin (`docs/.md`), which LLM clients fetch. // -// The HTML is parsed with regexes on purpose: it is rspress's generated output, which always +// The HTML is parsed with regexes on purpose: it is Astro's generated output, which always // writes attributes double-quoted with `&`, `"`, `<`, `>` entity-escaped, so `id="…"` and // `href="…"` cannot contain an unescaped quote. @@ -12,12 +13,11 @@ import path from 'node:path'; import { pathToFileURL } from 'node:url'; const REPO_ROOT = path.resolve(import.meta.dirname, '..'); -const BUILD_DIR = path.join(REPO_ROOT, 'website/doc_build'); +const BUILD_DIR = path.join(REPO_ROOT, 'website/dist'); const DOCS_SOURCE_DIR = 'website/docs'; -// README links to the published site, whose root is this URL whatever base a local build used. -const PUBLISHED_SITE = 'https://oss.callstack.com/agent-device/'; -const README_DOCS_URL = - /https:\/\/oss\.callstack\.com\/agent-device\/docs\/[\w./-]*(?:#[\w%.-]+)?/g; +// README links to the published site, whose root is this URL. +const PUBLISHED_SITE = 'https://agent-device.dev/'; +const README_DOCS_URL = /https:\/\/agent-device\.dev\/docs\/[\w./-]*(?:#[\w%.-]+)?/g; export type DocLinkViolation = { /** Built page route (`docs/sessions`), or `README.md:`. */ @@ -55,6 +55,15 @@ function listHtml(buildDir: string): string[] { .sort(); } +/** Every built docs page (`docs/x.html`) has its Markdown twin (`docs/x.md`). */ +function missingMarkdownTwins(buildDir: string, htmlFiles: readonly string[]): DocLinkViolation[] { + return htmlFiles + .filter((file) => file.startsWith('docs/')) + .map((file) => file.replace(/\.html$/, '')) + .filter((route) => !fs.existsSync(path.join(buildDir, `${route}.md`))) + .map((route) => ({ where: route, message: `no Markdown twin "${route}.md"` })); +} + function duplicateHeadingIds(route: string, html: string): DocLinkViolation[] { const seen = new Set(); const violations: DocLinkViolation[] = []; @@ -133,7 +142,7 @@ function readmeViolations(site: Site, readmeText: string): DocLinkViolation[] { } /** - * Check a built rspress site. `base` is the base it was built with, with leading and trailing + * Check a built site. `base` is the base it was built with, with leading and trailing * slashes (`/agent-device/`); internal hrefs start with it. README's URLs name the published site. */ export function checkDocLinks( @@ -142,8 +151,9 @@ export function checkDocLinks( base: string, ): DocLinkViolation[] { const pages = new Map(); - const violations: DocLinkViolation[] = []; - for (const file of listHtml(buildDir)) { + const htmlFiles = listHtml(buildDir); + const violations = missingMarkdownTwins(buildDir, htmlFiles); + for (const file of htmlFiles) { const html = fs.readFileSync(path.join(buildDir, file), 'utf8'); const ids = new Set( [...html.matchAll(ATTRIBUTE('id'))].map(([, id]) => decodeEntities(id ?? '')), @@ -160,16 +170,15 @@ export function checkDocLinks( } /** - * The base the site was built with, read from the build rather than restated: rspress emits its - * entry script under `assetPrefix`, which defaults to `base` (rspress `initRsbuild.js`). + * The base the site was built with, read from the build rather than restated: Astro emits its + * assets under `_astro/`. */ -function builtBase(buildDir: string): string { +export function builtBase(buildDir: string): string { const home = fs.readFileSync(path.join(buildDir, 'index.html'), 'utf8'); - const match = / + + diff --git a/website/package.json b/website/package.json index caf4917cd5..4881603401 100644 --- a/website/package.json +++ b/website/package.json @@ -1,13 +1,28 @@ { - "name": "agent-device-docs", + "name": "agent-device-website", + "version": "0.0.0", "private": true, "type": "module", + "engines": { + "node": ">=22.23.4" + }, "scripts": { - "dev": "rspress dev", - "build": "rspress build" + "dev": "astro dev", + "build": "astro build --force", + "preview": "astro preview", + "check": "astro check", + "og": "node scripts/og.mjs" + }, + "dependencies": { + "@astrojs/sitemap": "^3.7.4", + "@astrojs/starlight": "^0.42.2", + "astro": "^7.3.3", + "sharp": "^0.35.4", + "starlight-llms-txt": "^0.12.0" }, "devDependencies": { - "@callstack/rspress-preset": "^0.6.6", - "@rspress/core": "^2.0.12" + "@astrojs/check": "^0.9.10", + "playwright-core": "^1.62.1", + "typescript": "~5.9.2" } } diff --git a/website/rspress.config.ts b/website/rspress.config.ts deleted file mode 100644 index c8a3f5b8d2..0000000000 --- a/website/rspress.config.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { defineConfig } from '@rspress/core'; -import { withCallstackPreset } from '@callstack/rspress-preset'; - -export default withCallstackPreset( - { - context: __dirname, - docs: { - title: 'agent-device', - description: - 'Mobile app automation and verification for AI coding agents through a CLI, MCP server, and typed Node.js API.', - editUrl: 'https://github.com/callstack/agent-device/edit/main/website', - rootUrl: 'https://oss.callstack.com/agent-device', - rootDir: 'docs', - icon: '/logo.svg', - logoLight: '/logo-light.svg', - logoDark: '/logo-dark.svg', - ogImage: '/og-image.jpg', - socials: { - github: 'https://github.com/callstack/agent-device', - discord: 'https://discord.gg/eYapw6F3', - }, - }, - theme: { - content: { - outlineCTAHeadline: 'Curious about developing mobile apps with AI agents?', - outlineCTADescription: - 'We can help you take your agentic workflows to the next level and ship faster.', - outlineCTAButtonText: 'Book a call', - }, - }, - }, - defineConfig({ - base: process.env.RSPRESS_BASE || '/agent-device', - }), -); diff --git a/website/scripts/og.mjs b/website/scripts/og.mjs new file mode 100644 index 0000000000..fe6bab56d7 --- /dev/null +++ b/website/scripts/og.mjs @@ -0,0 +1,155 @@ +// Renders og/og.html twice, as the social card (docs/public/og-image.jpg, 1200x630) and the README +// banner (docs/public/agent-device-banner.jpg, 1660x512), and writes the icons (docs/public/favicon.svg, +// favicon-32.png, apple-touch-icon.png) from the mark. Run `pnpm og` after changing +// src/site/content.ts, src/site/brand.ts, og/og.html or the signature scene. +// +// The card never repeats a fact: this script imports content.ts and brand.ts (Node strips +// their types) and serves them to the page as /og/site.json and /og/brand.css. The dither +// module is served the same way, as /og/dither.js. +// +// Uses playwright-core's Chromium if installed (`pnpm exec playwright-core install chromium`), +// otherwise a local Google Chrome. +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { stripTypeScriptTypes } from 'node:module'; +import { extname, join, normalize } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { chromium } from 'playwright-core'; +import sharp from 'sharp'; + +import { accent, accentRgb, brandCss } from '../src/site/brand.ts'; +import { jobs, site } from '../src/site/content.ts'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const types = { + '.html': 'text/html', + '.js': 'text/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.woff2': 'font/woff2', +}; + +// What the card shows, all from content.ts and brand.ts. +const card = { + name: site.name, + owner: site.owner, + wordmark: site.wordmark, + headline: site.headline, + install: site.install, + platforms: site.platforms, + mark: site.mark ?? [], + transcript: jobs.find((job) => job.id === 'verify')?.transcript ?? [], + rgb: accentRgb.split(' ').map(Number), +}; + +// A static server over the website folder, plus the three generated files above. +const server = createServer(async (req, res) => { + try { + const path = new URL(req.url ?? '/', 'http://localhost').pathname; + if (path === '/og/dither.js') { + const source = await readFile(join(root, 'src/components/site/dither.ts'), 'utf8'); + res.writeHead(200, { 'Content-Type': types['.js'] }); + res.end(stripTypeScriptTypes(source)); + return; + } + if (path === '/og/site.json') { + res.writeHead(200, { 'Content-Type': types['.json'] }); + res.end(JSON.stringify(card)); + return; + } + if (path === '/og/brand.css') { + res.writeHead(200, { 'Content-Type': types['.css'] }); + res.end(brandCss); + return; + } + const file = join(root, normalize(path).replace(/^(\.\.[/\\])+/, '')); + const body = await readFile(file); + res.writeHead(200, { 'Content-Type': types[extname(file)] ?? 'application/octet-stream' }); + res.end(body); + } catch { + res.writeHead(404).end(); + } +}); +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +const { port } = /** @type {import('node:net').AddressInfo} */ (server.address()); + +await mkdir(join(root, 'docs/public'), { recursive: true }); +const browser = await chromium.launch().catch(() => chromium.launch({ channel: 'chrome' })); +try { + const shots = [ + { query: '', width: 1200, height: 630, scale: 1, path: 'docs/public/og-image.jpg' }, + // 1245x384 at 4/3 gives the banner's 1660x512. + { + query: '?format=banner', + width: 1245, + height: 384, + scale: 4 / 3, + path: 'docs/public/agent-device-banner.jpg', + }, + ]; + for (const { query, width, height, scale, path } of shots) { + const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: scale }); + page.on('pageerror', (error) => console.error(error)); + await page.goto(`http://127.0.0.1:${port}/og/og.html${query}`); + await page.waitForFunction(() => /** @type {any} */ (window).ogReady === true); + const png = await page.screenshot({ type: 'png' }); + await writeFile( + join(root, path), + await sharp(png).jpeg({ quality: 90, mozjpeg: true }).toBuffer(), + ); + await page.close(); + } +} finally { + await browser.close(); + server.close(); +} + +// ---------- Icons ---------- +// The mark in the accent, each step a whole number of pixels. Without a mark, the wordmark's +// first letter in dark on an accent block. +const BG = '#0a0a0a'; +const mark = site.mark ?? []; +const markW = Math.max(0, ...mark.map((r) => r.length)); +const markH = mark.length; + +/** An SVG `size` px square: the mark centred on whole-pixel steps, `pad` px clear around it. */ +function icon(size, pad, background) { + const fill = background ? `` : ''; + if (!markH) { + const letter = [...site.wordmark.toUpperCase()][0] ?? ''; + const box = size - pad * 2; + return `${fill}${letter}`; + } + const step = Math.max(1, Math.floor((size - pad * 2) / Math.max(markW, markH))); + const x0 = Math.floor((size - step * markW) / 2); + const y0 = Math.floor((size - step * markH) / 2); + const rects = mark + .flatMap((row, y) => + [...row].map((c, x) => + c === '#' + ? `` + : '', + ), + ) + .join(''); + return `${fill}${rects}`; +} + +// The SVG favicon scales, so any whole step size does; 10px steps keep the numbers readable. +const side = Math.max(markW, markH, 1) * 10; +await writeFile(join(root, 'docs/public/favicon.svg'), icon(side, 0, '')); +for (const [name, size, pad, background] of [ + ['favicon-32.png', 32, 0, ''], + ['apple-touch-icon.png', 180, 36, BG], +]) { + await writeFile( + join(root, 'docs/public', name), + await sharp(Buffer.from(icon(size, pad, background))) + .png() + .toBuffer(), + ); +} +console.log( + 'Wrote docs/public/og-image.jpg, agent-device-banner.jpg, favicon.svg and the PNG icons', +); diff --git a/website/scripts/oss-redirects.mjs b/website/scripts/oss-redirects.mjs new file mode 100644 index 0000000000..fc2b522ceb --- /dev/null +++ b/website/scripts/oss-redirects.mjs @@ -0,0 +1,94 @@ +// Writes the redirect site that replaces the old docs at oss.callstack.com/agent-device (GitHub +// Pages, the gh-pages branch). GitHub Pages cannot send HTTP redirects, so every old page becomes +// a stub that points at the same path on agent-device.dev. deploy.yml publishes the output dir. +// +// - Home and docs pages (`docs/.html`): a canonical link and an instant refresh, plus a +// script that keeps the #fragment. +// - Docs Markdown (`docs/.md`), llms.txt and llms-full.txt: copied from agent-device.dev, +// so LLM clients that run no scripts still get the content. The script fails when the new site +// does not serve them, so the old site stays published until agent-device.dev is live. +// - index.md: a short note with the new URLs. +// - Any other path: 404.html sends it to the same path on agent-device.dev. +// - Static assets (docs/public): copied, so pages that embed the old image URLs keep working. +// - website/vercel.json: turns off Vercel deployments of gh-pages. The Vercel project builds every +// branch from its website/ root, and gh-pages has no site to build. +// +// Usage: node scripts/oss-redirects.mjs +import { cp, mkdir, readdir, rm, writeFile } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { site } from '../src/site/content.ts'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const out = process.argv[2]; +if (!out) throw new Error('usage: node scripts/oss-redirects.mjs '); + +const OLD_BASE = '/agent-device'; + +const stub = (url) => ` + + + + Moved to ${site.url} + + + + + +

This page moved to ${url}.

+ + +`; + +const notFound = ` + + + + Moved to ${site.url} + + + +

agent-device moved to ${site.url}.

+ + +`; + +/** A text file from the live site; fails unless agent-device.dev serves it as text. */ +async function live(path) { + const url = `${site.url}/${path}`; + const response = await fetch(url, { signal: AbortSignal.timeout(15_000) }); + const type = response.headers.get('content-type') ?? ''; + if (!response.ok || !/^text\/(markdown|plain)/.test(type)) { + throw new Error(`oss-redirects: ${url} is not live (HTTP ${response.status}, ${type}).`); + } + return response.text(); +} + +const pages = (await readdir(join(root, 'docs/docs'))) + .filter((file) => file.endsWith('.md') && !file.startsWith('_')) + .map((file) => basename(file, '.md')); + +const text = ['llms.txt', 'llms-full.txt', ...pages.map((page) => `docs/${page}.md`)]; +const files = { + '.nojekyll': '', + 'index.html': stub(`${site.url}/`), + 'index.md': `# Moved\n\nagent-device moved to ${site.url}. Its docs index for LLMs is ${site.url}/llms.txt\n`, + '404.html': notFound, + 'website/vercel.json': `${JSON.stringify({ git: { deploymentEnabled: false } }, null, 2)}\n`, + ...Object.fromEntries( + pages.map((page) => [`docs/${page}.html`, stub(`${site.url}/docs/${page}`)]), + ), + ...Object.fromEntries(await Promise.all(text.map(async (path) => [path, await live(path)]))), +}; + +await rm(out, { recursive: true, force: true }); +await cp(join(root, 'docs/public'), out, { recursive: true }); +for (const [name, body] of Object.entries(files)) { + await mkdir(dirname(join(out, name)), { recursive: true }); + await writeFile(join(out, name), body); +} +console.log(`oss-redirects: ${pages.length} docs pages → ${site.url} in ${out}`); diff --git a/website/src/components/home/Hero.astro b/website/src/components/home/Hero.astro new file mode 100644 index 0000000000..b4a7b13bf5 --- /dev/null +++ b/website/src/components/home/Hero.astro @@ -0,0 +1,339 @@ +--- +// The home page hero, in one centred column: the motto in two beats, a lede, the install command +// beside the GitHub stars and npm downloads, then the session on the loop scene: a tab per kind +// of job (build, verify, profile), each the prompt a developer gives, every agent-device call the +// agent makes as its own card with the output nested inside, and the agent's reply. Behind it +// all, the dimmed loop scene. The sparkles above the fold span the page (index.astro). +import CopyCommand from '../site/CopyCommand.astro'; +import Dither from '../site/Dither.astro'; +import GitHubStars from '../site/GitHubStars.astro'; +import PixelIcon from '../site/PixelIcon.astro'; +import { docs, jobs, site } from '../../site/content'; +import type { Line } from '../../site/content'; +import { compact, monthlyDownloads } from '../../site/npm'; + +const downloads = await monthlyDownloads(); + +// Each command starts a tool call; the lines after it, up to the next command, are its output. +const calls = (lines: Line[]) => + lines.reduce<{ args: string; out: Line[] }[]>((acc, l) => { + if (l.kind === 'cmd') acc.push({ args: l.text.replace(/^agent-device /, ''), out: [] }); + else acc.at(-1)?.out.push(l); + return acc; + }, []); +--- + +
+ +
+

{site.headline[0]} {site.headline[1]}

+

{site.lede}

+
+ + + { + downloads !== undefined && ( + + + npm + {compact(downloads)}/mo + + ) + } +
+ Read the docs +
+ +
+ + + +
+
+
+ { + jobs.map((j, i) => ( + + )) + } +
+
+ { + jobs.map((j, i) => ( + + )) + } +
+
+
+ +
+
+ + + + diff --git a/website/src/components/home/hero.ts b/website/src/components/home/hero.ts new file mode 100644 index 0000000000..29f2420970 --- /dev/null +++ b/website/src/components/home/hero.ts @@ -0,0 +1,66 @@ +// The hero animation, loaded on demand by Hero.astro. There is a tab per job (build, verify, +// profile); the loop plays each in turn: the prompt appears, then the agent's tool calls land one +// by one. Choosing a tab stops the loop and shows that job in full. The page renders the first job +// in full (the still frame) and hides the tabs when scripts are off; under reduced motion only the +// tabs work. +import { sleep, whenVisible } from '../site/anim'; + +export default function runHero(viz: HTMLElement) { + const tabs = [...viz.querySelectorAll('[data-job-tab]')]; + const panels = [...viz.querySelectorAll('[data-job]')]; + if (!tabs.length || tabs.length !== panels.length) return; + const steps = panels.map((panel) => [...panel.querySelectorAll('[data-step]')]); + + let stopped = false; + + const select = (index: number) => { + tabs.forEach((tab, i) => { + tab.setAttribute('aria-selected', String(i === index)); + tab.tabIndex = i === index ? 0 : -1; + panels[i]!.hidden = i !== index; + }); + }; + const showAll = (index: number) => + steps[index]!.forEach((step) => step.classList.remove('is-hidden', 'is-in')); + + const choose = (index: number) => { + stopped = true; + select(index); + showAll(index); + }; + tabs.forEach((tab, i) => { + tab.addEventListener('click', () => choose(i)); + tab.addEventListener('keydown', (event) => { + const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0; + if (!step) return; + event.preventDefault(); + const next = (i + step + tabs.length) % tabs.length; + choose(next); + tabs[next]!.focus(); + }); + }); + + let current = 0; + const play = async () => { + if (stopped) return sleep(1000); + select(current); + const own = steps[current]!; + own.forEach((step) => { + step.classList.remove('is-in'); + step.classList.add('is-hidden'); + }); + await sleep(400); + for (const [i, step] of own.entries()) { + if (stopped) return; + step.classList.remove('is-hidden'); + step.classList.add('is-in'); + // The prompt is read first; each call lands a beat after the last. + await sleep(i === 0 ? 1100 : 650); + } + await sleep(3200); + if (!stopped) current = (current + 1) % panels.length; + }; + + // Under reduced motion the server-rendered jobs stay in full, and the tabs still switch. + whenVisible(viz, play, () => {}); +} diff --git a/website/src/components/site/AdopterLogo.astro b/website/src/components/site/AdopterLogo.astro new file mode 100644 index 0000000000..1693d5c807 --- /dev/null +++ b/website/src/components/site/AdopterLogo.astro @@ -0,0 +1,43 @@ +--- +// An adopter's official mark (content.ts `adopters[].logo`), followed by its name. `mono` marks +// are drawn as white silhouettes so the row reads as one; `keep` marks (a mark on its own solid +// tile) keep their light and dark parts, in greyscale. +import type { Adopter } from '../../site/content'; + +interface Props { + adopter: Adopter; + class?: string; + /** The mark's height. */ + size?: string; +} + +const { adopter, size = '1.5rem', class: className } = Astro.props; +--- + + + {adopter.logo && } + {adopter.name} + + + diff --git a/website/src/components/site/Analytics.astro b/website/src/components/site/Analytics.astro new file mode 100644 index 0000000000..17e6d38ea6 --- /dev/null +++ b/website/src/components/site/Analytics.astro @@ -0,0 +1,60 @@ +--- +// The trackers the site loads in production only: Vercel Analytics, Google Analytics and Common +// Room. Vercel sets VERCEL_ENV at build time, so previews, CI and local builds load none of them. +// Vercel Analytics uses Vercel's plain HTML snippet: its Astro component renders a custom element, +// which would end early. +import { analytics } from '../../site/content'; + +const production = process.env.VERCEL_ENV === 'production'; +const { googleAnalyticsId, commonRoomSiteId } = analytics; +--- + +{ + production && ( + <> + + + + + ) +} diff --git a/website/src/components/site/Closing.astro b/website/src/components/site/Closing.astro new file mode 100644 index 0000000000..3fa4338435 --- /dev/null +++ b/website/src/components/site/Closing.astro @@ -0,0 +1,10 @@ +--- +// The close of a landing page: the accent CTA bar and the giant wordmark, as wide as the frame. +import { docs, site } from '../../site/content'; +import { giantSize } from './giant'; +--- + +
+ Get started + +
diff --git a/website/src/components/site/CodeBlock.astro b/website/src/components/site/CodeBlock.astro new file mode 100644 index 0000000000..37ffff5a68 --- /dev/null +++ b/website/src/components/site/CodeBlock.astro @@ -0,0 +1,119 @@ +--- +// Highlighted code with zero-padded line numbers. Colours come from the --code-* CSS variables +// in landing.css, so strings and numbers follow the accent. +import { Code } from 'astro:components'; + +interface Props { + class?: string; + code: string; + lang: string; + /** Pads the gutter to this many lines, so short snippets share a height. */ + minLines?: number; + /** Wraps long lines under their own line number instead of scrolling sideways. */ + wrap?: boolean; +} + +const { code, lang, minLines = 0, wrap = false, class: className } = Astro.props; +const lines = code.split('\n').length; +const padded = lines < minLines ? code + '\n'.repeat(minLines - lines) : code; + +const theme = { + name: 'site', + type: 'dark', + colors: { 'editor.background': 'transparent', 'editor.foreground': 'var(--code-fg)' }, + tokenColors: [ + { scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: 'var(--code-com)' } }, + { + scope: [ + 'keyword', + 'storage', + 'storage.type', + 'storage.modifier', + 'keyword.control', + 'variable.language', + 'constant.language', + 'keyword.operator.new', + ], + settings: { foreground: 'var(--code-kw)' }, + }, + { + scope: ['string', 'string.quoted', 'string.template', 'punctuation.definition.string'], + settings: { foreground: 'var(--code-str)' }, + }, + { scope: ['constant.numeric'], settings: { foreground: 'var(--code-num)' } }, + { + scope: ['entity.name.function', 'support.function', 'meta.function-call'], + settings: { foreground: 'var(--code-fn)' }, + }, + { + scope: ['entity.name.type', 'support.type', 'support.class', 'support.type.property-name'], + settings: { foreground: 'var(--code-type)' }, + }, + { scope: ['punctuation', 'meta.brace', 'keyword.operator'], settings: { foreground: 'var(--code-punc)' } }, + // Shell: only quoted strings are strings. Plain arguments read as text, options dimmed like + // keywords, line continuations like punctuation. + { scope: ['string.unquoted'], settings: { foreground: 'var(--code-fg)' } }, + { scope: ['constant.other.option'], settings: { foreground: 'var(--code-kw)' } }, + { scope: ['constant.character.escape.line-continuation'], settings: { foreground: 'var(--code-punc)' } }, + // JSON: keys white, values in the accent. + { scope: ['support.type.property-name.json'], settings: { foreground: 'var(--code-type)' } }, + ], +}; +--- + +
+ +
+ + diff --git a/website/src/components/site/CopyCommand.astro b/website/src/components/site/CopyCommand.astro new file mode 100644 index 0000000000..ed4de3319c --- /dev/null +++ b/website/src/components/site/CopyCommand.astro @@ -0,0 +1,111 @@ +--- +// A one-line shell command with a copy button, and an optional label before it (INSTALL). +interface Props { + class?: string; + command: string; + label?: string; +} +const { command, class: className, label } = Astro.props; +--- + +
+ {label && {label}} + {command} + + +
+ + + + diff --git a/website/src/components/site/Dither.astro b/website/src/components/site/Dither.astro new file mode 100644 index 0000000000..f3d1b891e3 --- /dev/null +++ b/website/src/components/site/Dither.astro @@ -0,0 +1,38 @@ +--- +// A dithered pixel scene in the accent, drawn on a canvas. It fills its positioned parent. The +// scenes live in dither.ts: `loop` (the product's signature, in the hero), `loop-hollow` (the +// loop with its main track missing, on the 404 page) and `flow` (soft bands of light, for +// quiet backdrops behind code). +import type { SceneName } from './dither'; + +interface Props { + /** Size of one dither cell in CSS pixels. */ + cell?: number; + class?: string; + /** Where the scene centres, 0..1 across: put it behind the product visual. */ + focus?: number; + scene?: SceneName; +} +const { scene = 'loop', focus = 0.72, cell = 2, class: className } = Astro.props; +--- + + + + diff --git a/website/src/components/site/DocsSearch.astro b/website/src/components/site/DocsSearch.astro new file mode 100644 index 0000000000..db7795bb96 --- /dev/null +++ b/website/src/components/site/DocsSearch.astro @@ -0,0 +1,222 @@ +--- +// The docs search on the landing pages: the same Pagefind index and UI the docs header opens +// (Starlight builds both into /pagefind/), in a dialog styled like the docs one. The UI loads +// when the reader first reaches for it (hover, focus, ⌘K or Ctrl+K), not with the page. +import PixelIcon from './PixelIcon.astro'; +--- + + + + +
+ + +
+
+
+ + + + diff --git a/website/src/components/site/Faq.astro b/website/src/components/site/Faq.astro new file mode 100644 index 0000000000..c570d28af4 --- /dev/null +++ b/website/src/components/site/Faq.astro @@ -0,0 +1,25 @@ +--- +// Question-and-answer cells, three across, from content.ts `faq`. +import { faq } from '../../site/content'; +import { inlineCode } from '../../site/markup'; + +interface Props { + num: string; +} +const { num } = Astro.props; +--- + +
+

[{num}] FAQ

+

Frequently asked questions

+
+ { + faq.map(({ q, a }) => ( +
+
{q}
+
+
+ )) + } +
+
diff --git a/website/src/components/site/Footer.astro b/website/src/components/site/Footer.astro new file mode 100644 index 0000000000..43b5091559 --- /dev/null +++ b/website/src/components/site/Footer.astro @@ -0,0 +1,42 @@ +--- +// One line: the copyright on the left, mono links on the right. +import { footer, site } from '../../site/content'; + +const year = new Date().getFullYear(); +--- + +
+

© {year} {site.owner}

+ +
+ + diff --git a/website/src/components/site/GitHubStars.astro b/website/src/components/site/GitHubStars.astro new file mode 100644 index 0000000000..1b2149080c --- /dev/null +++ b/website/src/components/site/GitHubStars.astro @@ -0,0 +1,96 @@ +--- +// The GitHub link with the repository's star count, top right in the landing and docs headers. +// The count is baked in when the site is built, so it renders at once and without scripts. Once +// the page is idle, the browser refreshes it from the GitHub API, at most once per session every +// ten minutes (sessionStorage). A refusal is remembered as long, and the built figure stays. +import { site } from '../../site/content'; +import { buildStars, formatStars, starsApi } from '../../site/github'; +import PixelIcon from './PixelIcon.astro'; + +interface Props { + class?: string; + /** Writes "GitHub" before the count, for places where the star alone does not say where it links. */ + named?: boolean; +} +const { class: className, named = false } = Astro.props; +const stars = await buildStars(site.repo); +const label = `${site.name} on GitHub`; +--- + + + + {named && GitHub} + {stars === undefined ? (named ? '' : 'GitHub') : formatStars(stars)} + + + + + diff --git a/website/src/components/site/Lockup.astro b/website/src/components/site/Lockup.astro new file mode 100644 index 0000000000..d491980c1a --- /dev/null +++ b/website/src/components/site/Lockup.astro @@ -0,0 +1,55 @@ +--- +// The lockup: the mark (when the product has one), the accent wordmark block and the +// BY CALLSTACK chip, which is dropped on phones. +import { site } from '../../site/content'; +import Mark from './Mark.astro'; + +interface Props { + href?: string; +} +const { href = '/' } = Astro.props; +--- + + + + {site.wordmark} + by Callstack + + + diff --git a/website/src/components/site/Mark.astro b/website/src/components/site/Mark.astro new file mode 100644 index 0000000000..9ac47b7143 --- /dev/null +++ b/website/src/components/site/Mark.astro @@ -0,0 +1,38 @@ +--- +// The mark: the pixel bitmap from content.ts on its own step grid, in currentColor, with crisp +// edges. The height is rounded down to whole steps, so every step stays a whole number of +// pixels. Renders nothing when the product has no mark, or when `height` is under one pixel +// per row. +import { site } from '../../site/content'; +import { bitmapPath } from './icons'; + +interface Props { + class?: string; + /** The largest height to fill; the mark takes the nearest whole-step height under it. */ + height?: number; + label?: string; +} +const { height = 20, class: className, label } = Astro.props; +const mark = site.mark ?? []; +const rows = mark.length; +const cols = Math.max(0, ...mark.map((r) => r.length)); +const step = rows ? Math.floor(height / rows) : 0; +--- + +{ + step > 0 && ( + + + + ) +} diff --git a/website/src/components/site/Marquee.astro b/website/src/components/site/Marquee.astro new file mode 100644 index 0000000000..f1e5433455 --- /dev/null +++ b/website/src/components/site/Marquee.astro @@ -0,0 +1,70 @@ +--- +// Platforms, frameworks and tools scrolling under the hero (content.ts `marquee`). The list is +// rendered twice so the loop is seamless; screen readers get it once. +import { marquee } from '../../site/content'; +--- + +
+

Works with {marquee.join(', ')}.

+ +
+ + diff --git a/website/src/components/site/NameCase.astro b/website/src/components/site/NameCase.astro new file mode 100644 index 0000000000..e347792c56 --- /dev/null +++ b/website/src/components/site/NameCase.astro @@ -0,0 +1,22 @@ +--- +// Text for uppercase labels with the product's name kept as written, like the lockup: "Why +// agent-device" renders WHY agent-device. A word that contains the name (a path such as +// scripts/agent-device.ts) keeps its case whole. +import { site } from '../../site/content'; + +interface Props { + text: string; +} +const { text } = Astro.props; +const name = site.name.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); +const parts = text.split(new RegExp(String.raw`(\S*${name}\S*)`)); +--- + +{parts.map((part, i) => (i % 2 ? {part} : part))} + + diff --git a/website/src/components/site/Nav.astro b/website/src/components/site/Nav.astro new file mode 100644 index 0000000000..1a4d0934b0 --- /dev/null +++ b/website/src/components/site/Nav.astro @@ -0,0 +1,113 @@ +--- +// Sticky, blurred, a hairline under it: the lockup left, mono links centred, the GitHub stars and +// the docs search right. +// Below 52rem the links move into a Menu disclosure, which works without JavaScript. +import { nav } from '../../site/content'; +import DocsSearch from './DocsSearch.astro'; +import GitHubStars from './GitHubStars.astro'; +import Lockup from './Lockup.astro'; +--- + + + + + + diff --git a/website/src/components/site/PixelIcon.astro b/website/src/components/site/PixelIcon.astro new file mode 100644 index 0000000000..ce61eb6df0 --- /dev/null +++ b/website/src/components/site/PixelIcon.astro @@ -0,0 +1,20 @@ +--- +// A 9x9 pixel icon from icons.ts, in currentColor. +import { bitmapPath, icons, type IconName } from './icons'; + +interface Props { + class?: string; + name: IconName; + size?: number; +} +const { name, size = 18, class: className } = Astro.props; +--- + + diff --git a/website/src/components/site/Sparkles.astro b/website/src/components/site/Sparkles.astro new file mode 100644 index 0000000000..5da132da77 --- /dev/null +++ b/website/src/components/site/Sparkles.astro @@ -0,0 +1,80 @@ +--- +// Accent pixels scattered over the top of the home hero, twinkling in steps. Placed once at +// build time from a fixed seed, so every visit gets the same sky with no script and no layout +// shift. None over the centre, where the headline and lede sit; still under reduced motion. +interface Props { + /** How many pixels. */ + count?: number; + /** How far down the parent they reach, in percent. */ + depth?: number; +} +const { count = 48, depth = 46 } = Astro.props; + +// mulberry32: a small seeded generator, so the layout is the same on every build. +let seed = 0x5eed; +const random = () => { + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; +}; + +const sparks: { x: number; y: number; size: number; opacity: number; duration: number; delay: number }[] = []; +while (sparks.length < count) { + const spark = { + x: 2 + random() * 96, + y: 2 + random() * (depth - 2), + size: random() < 0.6 ? 2 : 4, + opacity: 0.35 + random() * 0.55, + duration: 2.4 + random() * 3.6, + delay: -random() * 6, + }; + // None over the centred text column. + if (spark.x < 24 || spark.x > 76) sparks.push(spark); +} +--- + + + + diff --git a/website/src/components/site/Window.astro b/website/src/components/site/Window.astro new file mode 100644 index 0000000000..50ed8072aa --- /dev/null +++ b/website/src/components/site/Window.astro @@ -0,0 +1,77 @@ +--- +// Window chrome: a mini wordmark tag, a mono title (a file name, `zsh` or `agent`) and the +// three window controls. Code and terminals go inside; it has a solid fill, so it can sit on a +// dithered scene. +import { site } from '../../site/content'; + +interface Props { + class?: string; + title?: string; + /** Rendered as the title element's id so a script can swap it. */ + titleId?: string; +} +const { title = '', class: className, titleId } = Astro.props; +--- + +
+
+ {site.wordmark} + {title} + +
+
+ +
+
+ + diff --git a/website/src/components/site/anim.ts b/website/src/components/site/anim.ts new file mode 100644 index 0000000000..974e353ac9 --- /dev/null +++ b/website/src/components/site/anim.ts @@ -0,0 +1,26 @@ +// Timing helpers every hero animation uses. + +export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Runs an async animation loop while `el` is on screen, and once (statically) when the reader + * prefers reduced motion. A cycle that is running when `el` leaves the screen finishes first. + */ +export function whenVisible(el: Element, loop: () => Promise, still: () => void) { + if (matchMedia('(prefers-reduced-motion: reduce)').matches) { + still(); + return; + } + let visible = false; + let running = false; + const run = async () => { + if (running) return; + running = true; + while (visible) await loop(); + running = false; + }; + new IntersectionObserver(([entry]) => { + visible = !!entry?.isIntersecting; + if (visible) void run(); + }).observe(el); +} diff --git a/website/src/components/site/dither-host.ts b/website/src/components/site/dither-host.ts new file mode 100644 index 0000000000..f75ed95fa3 --- /dev/null +++ b/website/src/components/site/dither-host.ts @@ -0,0 +1,54 @@ +// Starts a dithered canvas (see Dither.astro). Where the browser can, the canvas is handed to a +// worker so building and drawing the scene never blocks the page; otherwise it runs here. +import type { DitherOptions } from './dither'; + +let worker: Worker | undefined; +let next = 0; + +/** The shared worker, or undefined where workers are unavailable or cannot start. */ +function startWorker(): Worker | undefined { + if (worker || typeof Worker === 'undefined') return worker; + try { + worker = new Worker(new URL('./dither.worker.ts', import.meta.url), { type: 'module' }); + } catch (error) { + console.error('dither: the worker cannot start; drawing on the page instead.', error); + return undefined; + } + // The canvases already belong to the worker, so a failed load can only be reported. + worker.addEventListener('error', (event) => + console.error('dither: the worker failed.', event.message), + ); + return worker; +} + +export async function host(canvas: HTMLCanvasElement) { + const options: DitherOptions = { + scene: canvas.dataset.scene ?? 'loop', + focus: Number(canvas.dataset.focus ?? 0.72), + cell: Number(canvas.dataset.cell ?? 2), + rgb: getComputedStyle(document.documentElement) + .getPropertyValue('--accent-rgb') + .trim() + .split(/\s+/) + .map(Number), + still: matchMedia('(prefers-reduced-motion: reduce)').matches, + }; + + let setSize: (width: number, height: number) => void; + let setVisible: (on: boolean) => void; + const w = 'transferControlToOffscreen' in canvas ? startWorker() : undefined; + if (w) { + const id = next++; + const offscreen = canvas.transferControlToOffscreen(); + w.postMessage({ type: 'init', id, canvas: offscreen, ...options }, [offscreen]); + setSize = (width, height) => w.postMessage({ type: 'size', id, width, height }); + setVisible = (on) => w.postMessage({ type: 'visible', id, on }); + } else { + const run = (await import('./dither')).animate(canvas, options); + setSize = run.setSize; + setVisible = run.setVisible; + } + + new ResizeObserver(() => setSize(canvas.clientWidth, canvas.clientHeight)).observe(canvas); + new IntersectionObserver(([entry]) => setVisible(!!entry?.isIntersecting)).observe(canvas); +} diff --git a/website/src/components/site/dither.ts b/website/src/components/site/dither.ts new file mode 100644 index 0000000000..b184d6852d --- /dev/null +++ b/website/src/components/site/dither.ts @@ -0,0 +1,431 @@ +// The dithered pixel scenes behind the landing pages' windows: a scene generated in the browser +// and ordered-dithered at a fine grain into a ramp from near-black through a dark mix of the +// accent to the accent. Generated instead of shipped as an image, so it follows the accent. +// +// A scene is built once per size (the slow part: geometry and shading) and then animated +// cheaply, with a few arithmetic operations per cell each frame. Every scene runs in the dither +// worker (dither.worker.ts) or, where OffscreenCanvas is missing, on the page (dither-host.ts), +// and the social card imports this file too (og/og.html), so keep it free of DOM access. +// +// --------------------------------------------------------------------------------------------- +// THE PRODUCT'S SIGNATURE SCENE +// +// Every product gets one scene of its own: a metaphor taken from the product itself (its name, +// what it does, the vocabulary of its docs), drawn in the stepped, dithered language of the +// family. It goes behind the hero visual, on the social card and the banner. agent-device's is +// `loop` (below). Never reuse a sibling product's idea. +// +// To add a scene: +// 1. Write a SceneBuilder: `(cols, rows, focus) => ({ frame: (t) => tones })`. +// `cols` x `rows` are dither cells (2 CSS px each), `focus` is 0..1 across, where the +// product visual sits, `t` is seconds. `frame` fills and returns one tone (0..1) per cell: +// 0 is the background, about 0.5 the dark accent mix, 1 the accent. +// 2. Do the geometry once, outside `frame` (which typed arrays hold what per cell: which +// shape owns it, its shaded tone, how far along it is), and keep `frame` to a few +// operations per cell. It runs 12 times a second. +// 3. Shade like the family: lit faces about 0.85, shaded faces about 0.55, a bright rim on +// exposed top edges, a dark gap around each shape so it separates from the ones behind, +// a faint haze or rock texture behind (0.1 to 0.2), never a gradient on its own. +// 4. Motion is slow and stepped: a glint sweeping across, pulses travelling along. Pick a +// still moment (`animate` draws t = 9 under reduced motion) that tells the story. +// 5. Add it to `scenes`, then use it: ``, and in og.html. +// 6. Keep sizes tied to the shorter side of the scene, so phone-width scenes don't blow up. +// No landscapes, photos, 3D renders or smooth gradients. +// --------------------------------------------------------------------------------------------- + +// 4x4 Bayer matrix, normalised to 0..1: the threshold a cell's tone must beat to step up. +const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map((n) => (n + 0.5) / 16); + +// Integer-hashed value noise. `period` makes it wrap horizontally. +const hash = (x: number, y: number) => { + let h = Math.imul(x, 374761393) + Math.imul(y, 668265263); + h = Math.imul(h ^ (h >>> 13), 1274126177); + return ((h ^ (h >>> 16)) >>> 0) / 4294967295; +}; +const noise = (x: number, y: number, period = 0) => { + const xi = Math.floor(x); + const yi = Math.floor(y); + const xf = x - xi; + const yf = y - yi; + const u = xf * xf * (3 - 2 * xf); + const v = yf * yf * (3 - 2 * yf); + const x0 = period ? ((xi % period) + period) % period : xi; + const x1 = period ? (x0 + 1) % period : xi + 1; + const a = hash(x0, yi); + const b = hash(x1, yi); + const c = hash(x0, yi + 1); + const d = hash(x1, yi + 1); + return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v; +}; +const fbm = (x: number, y: number, octaves = 4, period = 0) => { + let sum = 0; + let amp = 0.5; + let p = period; + for (let i = 0; i < octaves; i++) { + sum += amp * noise(x, y, p); + x *= 2; + y *= 2; + p *= 2; + amp *= 0.5; + } + return sum / (1 - Math.pow(0.5, octaves)); +}; +const smooth = (a: number, b: number, x: number) => { + const t = Math.min(1, Math.max(0, (x - a) / (b - a))); + return t * t * (3 - 2 * t); +}; +const clamp01 = (x: number) => Math.min(1, Math.max(0, x)); + +/** A scene at one size: `frame(t)` fills and returns the tone (0..1) of every cell. */ +type Scene = { frame: (t: number) => Float32Array }; +type SceneBuilder = (cols: number, rows: number, focus: number) => Scene; + +/** A horizontally wrapping noise texture, sampled with a sliding offset. */ +function mistTexture(cols: number, rows: number, scaleX: number, scaleY: number) { + const tex = new Float32Array(cols * rows); + const period = Math.max(1, Math.round(scaleX)); + for (let y = 0; y < rows; y++) { + for (let x = 0; x < cols; x++) { + tex[y * cols + x] = fbm((x / cols) * period, (y / rows) * scaleY, 4, period); + } + } + return tex; +} + +function backdrop(cols: number, rows: number, focus: number, cy = 0.55) { + const rock = new Float32Array(cols * rows); + const aspect = cols / rows; + for (let y = 0; y < rows; y++) { + for (let x = 0; x < cols; x++) { + const d = Math.hypot((x / cols - focus) * aspect, (y / rows - cy) * 1.2); + const grain = fbm((x / cols) * 4.5 * aspect, (y / rows) * 9, 4); + rock[y * cols + x] = 0.11 * smooth(0.52, 0.85, grain) + 0.12 * Math.exp(-d * d * 4.5); + } + } + return rock; +} + +/** One stepped track round the focus: its half width and height, thickness and depth. */ +type Track = { a: number; b: number; th: number; depth: number }; + +/** Signed distance from a cell (relative to the centre) to the track's rounded outline. */ +function trackDistance(dx: number, dy: number, R: Track) { + const r = R.b * 0.45; + const qx = Math.abs(dx) - (R.a - r); + const qy = Math.abs(dy) - (R.b - r); + return Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - r; +} + +/** A track cell's tone, lit from the top left: the upper and left runs bright. */ +function trackTone(d: number, dx: number, dy: number, R: Track) { + let tone = dy < 0 || (dx < 0 && dy <= R.b * 0.5) ? 0.84 : 0.54; + if (d > R.th / 2 - 1.5 && dy < 0) tone = 1; // the outer rim on the upper run + if (d < -R.th / 2 + 1.5) tone *= 0.75; // the inner edge, in shadow + return tone; +} + +/** The per-cell layers a loop scene is built into. */ +type LoopCells = { + cols: number; + rows: number; + /** The track that owns a cell (0 is the main one), or -1 rock, -2 gap, -3 socket, -4 outline. */ + ring: Int8Array; + shade: Float32Array; + /** 0..1 round the track, clockwise from the left. */ + along: Float32Array; + /** The station that owns a cell, or -1. */ + station: Int8Array; +}; + +/** + * Gives cell (x, y) to the first track it falls on, around the centre (cx, cy). With `hollow`, + * the main track becomes a dashed outline over an empty socket. + */ +function paintTrackCell( + c: LoopCells, + rings: readonly Track[], + rock: Float32Array, + x: number, + y: number, + cx: number, + cy: number, + hollow: boolean, +) { + const q = Math.max(2, Math.round(rings[0]!.th / 2)); // the step every corner is cut on + // Quantise to the step grid, so the corners come out stepped. + const dx = Math.round((x - cx) / q) * q; + const dy = Math.round((y - cy) / q) * q; + const k = rings.findIndex((R) => Math.abs(trackDistance(dx, dy, R)) <= R.th / 2); + if (k < 0) return; + const R = rings[k]!; + const d = trackDistance(dx, dy, R); + const i = y * c.cols + x; + c.along[i] = (Math.atan2(y - cy, x - cx) + Math.PI) / (2 * Math.PI); + if (hollow && k === 0) { + const edge = Math.abs(d) > R.th / 2 - 1.2; + c.ring[i] = edge && (x + y) % 8 < 4 ? -4 : -3; + rock[i] = 0; + return; + } + c.ring[i] = k; + const fade = smooth(0, 0.12, x / c.cols) * smooth(1, 0.88, x / c.cols); + c.shade[i] = trackTone(d, dx, dy, R) * R.depth * fade; +} + +/** A station: a stepped block of `size` centred on (sx, sy), with a dark gap around it. */ +function paintStation(c: LoopCells, sx: number, sy: number, size: number, k: number) { + const y0 = Math.round(sy - size / 2); + const y1 = Math.round(sy + size / 2); + const x0 = Math.round(sx - size / 2); + const x1 = Math.round(sx + size / 2); + for (let y = Math.max(0, y0 - 1); y < Math.min(c.rows, y1 + 1); y++) { + for (let x = Math.max(0, x0 - 1); x < Math.min(c.cols, x1 + 1); x++) { + const i = y * c.cols + x; + if (y < y0 || y >= y1 || x < x0 || x >= x1) { + c.shade[i] = 0; + c.ring[i] = -2; + continue; + } + c.station[i] = k; + c.ring[i] = 0; + c.shade[i] = y - y0 < 2 ? 1 : x < sx ? 0.8 : 0.56; + } + } +} + +/** The glow of two pulses half a turn apart, `speed` turns a second, at `at` round the track. */ +function pulseGlow(at: number, t: number, speed: number, width: number, gain: number, depth = 1) { + let glow = 0; + for (let p = 0; p < 2; p++) { + const pos = (((t * speed + p / 2) % 1) + 1) % 1; + let dd = Math.abs(at - pos); + dd = Math.min(dd, 1 - dd); + glow += Math.exp(-((dd / width) ** 2)) * gain * depth; + } + return glow; +} + +// Loop: the agent closing its own loop on a running app. A stepped track circles the focus, +// with four stations on it (run, read, act, verify), and fainter echoes of the track around +// it. Pulses travel round the track and each station lights as a pulse reaches it. On the 404 +// page (`hollow`) the main track and its stations are missing: a dashed outline marks where the +// track should be, and flares as the pulses pass along it. +/** `maxUnit` caps the size of the loop, in cells, on tall and wide scenes. */ +function loopScene( + cols: number, + rows: number, + focus: number, + hollow = false, + maxUnit = 280, +): Scene { + const out = new Float32Array(cols * rows); + const rock = backdrop(cols, rows, focus, 0.5); + const unit = Math.min(rows, cols * 0.8, maxUnit); + const cx = focus * cols; + const cy = rows * 0.5; + const cells: LoopCells = { + cols, + rows, + ring: new Int8Array(cols * rows).fill(-1), + shade: new Float32Array(cols * rows), + along: new Float32Array(cols * rows), + station: new Int8Array(cols * rows).fill(-1), + }; + const { ring, shade, along, station } = cells; + const rings: Track[] = [0, 1, 2, 3, 4].map((k) => ({ + a: unit * (0.62 + 0.34 * k), + b: unit * (0.4 + 0.24 * k), + th: Math.max(3, Math.round(unit * (0.055 - 0.008 * k))), + depth: 1 - 0.19 * k, + })); + for (let y = 0; y < rows; y++) { + for (let x = 0; x < cols; x++) paintTrackCell(cells, rings, rock, x, y, cx, cy, hollow); + } + // The four stations on the main track: stepped blocks at left, top, right and bottom. + const R0 = rings[0]!; + if (!hollow) { + const size = Math.round(R0.th * 2.6); + paintStation(cells, cx - R0.a, cy, size, 0); + paintStation(cells, cx, cy - R0.b, size, 1); + paintStation(cells, cx + R0.a, cy, size, 2); + paintStation(cells, cx, cy + R0.b, size, 3); + } + const stationAt = [0, 0.25, 0.5, 0.75]; + + const tone = (i: number, t: number) => { + const k = ring[i]!; + if (k === -2 || k === -3) return 0; + // The missing track's outline: faint, flaring as a pulse passes. + if (k === -4) return clamp01(0.36 + pulseGlow(along[i]!, t, 0.09, 0.03, 0.6)); + if (k < 0) return rock[i]!; + // Two pulses on each track, the echoes slower and turning the other way. + const s = station[i]!; + const depth = rings[k]!.depth; + const glow = + s >= 0 + ? pulseGlow(stationAt[s]!, (k % 2 ? -1 : 1) * t, 0.09 - 0.012 * k, 0.03, 0.5, depth) + : pulseGlow(along[i]!, (k % 2 ? -1 : 1) * t, 0.09 - 0.012 * k, 0.018, 0.45, depth); + return clamp01(Math.max(rock[i]!, shade[i]! + glow)); + }; + return { + frame: (t) => { + for (let i = 0; i < out.length; i++) out[i] = tone(i, t); + return out; + }, + }; +} + +const scenes = { + // The signature scene: the agent's loop (run, read, act, verify) around the product visual. + loop: (cols, rows, focus) => loopScene(cols, rows, focus, false, 340), + // The 404 page: the loop with its main track missing. + 'loop-hollow': (cols, rows, focus) => loopScene(cols, rows, focus, true), + + // Soft, slowly turning bands of light on black: a quiet backdrop for code and commands. + flow: (cols, rows) => { + const out = new Float32Array(cols * rows); + const aspect = cols / rows; + const warp = mistTexture(cols, rows, 2 * aspect, 2); + return { + frame: (t) => { + const shift = Math.floor(t * 4) % cols; + for (let y = 0; y < rows; y++) { + const v = y / rows; + const row = y * cols; + for (let x = 0; x < cols; x++) { + const u = x / cols; + const w = warp[row + ((x + shift) % cols)]! * 3.2; + const band = + 0.5 + 0.5 * Math.sin((u * 1.6 * aspect * 0.35 + v * 1.3) * Math.PI + w + t * 0.15); + const edges = smooth(0, 0.3, u) * smooth(1, 0.7, u) * 0.4 + 0.6; + // Capped just above the midtone, so the bands stay the dark accent mix with only a + // few accent pixels at their crests: quiet enough to sit behind code. + out[row + x] = smooth(0.35, 1, band) * edges * (1 - Math.abs(u - 0.5) * 0.6) * 0.55; + } + } + return out; + }, + }; + }, +} satisfies Record; + +export type SceneName = keyof typeof scenes; + +/** A canvas on the page, or one handed to the dither worker (dither.worker.ts). */ +type Surface = HTMLCanvasElement | OffscreenCanvas; +type Painted = { key: string; scene: Scene; image: ImageData; px: Uint32Array; palette: number[] }; +const cache = new WeakMap(); + +/** Packs the three tones (background, a dark mix of the accent, the accent) as RGBA words. */ +function palette(rgb: number[]) { + const [r, g, b] = rgb as [number, number, number]; + const pack = (cr: number, cg: number, cb: number) => + (255 << 24) | (Math.round(cb) << 16) | (Math.round(cg) << 8) | Math.round(cr); + const mix = (c: number) => 10 + (c - 10) * 0.3; + return [pack(10, 10, 10), pack(mix(r), mix(g), mix(b)), pack(r, g, b)]; +} + +/** + * Draws frame t of a scene onto a canvas sized one pixel per cell; CSS scales it up with + * `image-rendering: pixelated`. The scene and the pixel buffer are kept per canvas and rebuilt + * only when its size changes. Tones are ordered-dithered between neighbouring colours. + */ +export function drawDither( + canvas: Surface, + name: string, + t: number, + rgb: number[], + cols: number, + rows: number, + focus: number, +) { + // Only the scene table's own keys: a name like `constructor` must not reach Object.prototype. + const build: SceneBuilder | undefined = Object.hasOwn(scenes, name) + ? scenes[name as SceneName] + : undefined; + const ctx = canvas.getContext('2d') as + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D + | null; + if (!build || !ctx || cols < 2 || rows < 2) return; + const key = `${name}:${cols}x${rows}:${focus}`; + let entry = cache.get(canvas); + if (!entry || entry.key !== key) { + canvas.width = cols; + canvas.height = rows; + const image = ctx.createImageData(cols, rows); + entry = { + key, + scene: build(cols, rows, focus), + image, + px: new Uint32Array(image.data.buffer), + palette: palette(rgb), + }; + cache.set(canvas, entry); + } + const tones = entry.scene.frame(t); + const { px, palette: colours } = entry; + for (let y = 0; y < rows; y++) { + for (let x = 0; x < cols; x++) { + const i = y * cols + x; + const scaled = tones[i]! * 2; + const low = Math.min(1, Math.floor(scaled)); + const step = scaled - low > BAYER[(y & 3) * 4 + (x & 3)]! ? 1 : 0; + px[i] = colours[low + step]!; + } + } + ctx.putImageData(entry.image, 0, 0); +} + +const FRAME_MS = 1000 / 12; // a deliberately low frame rate keeps the pixels reading as pixels + +export type DitherOptions = { + scene: string; + focus: number; + cell: number; + rgb: number[]; + still: boolean; +}; + +/** + * Animates a scene on a canvas at 12 frames a second while it is visible, or draws one still + * frame under reduced motion. Runs the same in the dither worker and, as a fallback, on the + * page; the caller reports the canvas's CSS size and whether it is on screen. + */ +export function animate(canvas: Surface, o: DitherOptions) { + const start = performance.now() - Math.random() * 20000; + let width = 0; + let height = 0; + let visible = false; + let timer: ReturnType | undefined; + + const draw = () => { + const cols = Math.ceil(width / o.cell); + const rows = Math.ceil(height / o.cell); + // The still frame is taken nine seconds in. + const t = o.still ? 9 : (performance.now() - start) / 1000; + drawDither(canvas, o.scene, t, o.rgb, cols, rows, o.focus); + }; + const loop = () => { + timer = undefined; + if (!visible || !width) return; + draw(); + if (!o.still) timer = setTimeout(loop, FRAME_MS); + }; + + return { + setSize(w: number, h: number) { + width = w; + height = h; + if (visible && !timer) loop(); + }, + setVisible(on: boolean) { + visible = on; + if (on && !timer) loop(); + if (!on && timer) { + clearTimeout(timer); + timer = undefined; + } + }, + }; +} diff --git a/website/src/components/site/dither.worker.ts b/website/src/components/site/dither.worker.ts new file mode 100644 index 0000000000..956da727fd --- /dev/null +++ b/website/src/components/site/dither.worker.ts @@ -0,0 +1,17 @@ +// Runs the dither scenes off the main thread: each canvas is handed over as an OffscreenCanvas, +// and the page only reports its size and visibility. See dither-host.ts. +import { animate, type DitherOptions } from './dither'; + +type Message = + | ({ type: 'init'; id: number; canvas: OffscreenCanvas } & DitherOptions) + | { type: 'size'; id: number; width: number; height: number } + | { type: 'visible'; id: number; on: boolean }; + +const runs = new Map>(); + +self.onmessage = (event: MessageEvent) => { + const m = event.data; + if (m.type === 'init') runs.set(m.id, animate(m.canvas, m)); + else if (m.type === 'size') runs.get(m.id)?.setSize(m.width, m.height); + else runs.get(m.id)?.setVisible(m.on); +}; diff --git a/website/src/components/site/giant.ts b/website/src/components/site/giant.ts new file mode 100644 index 0000000000..94ec7958f6 --- /dev/null +++ b/website/src/components/site/giant.ts @@ -0,0 +1,15 @@ +// The closing wordmark is set as wide as the frame. Its font size is worked out here, on the +// server, so it fits any product name with no script and no layout shift: the size is in cqi +// (percent of the frame's width). The wordmark is set like the lockup, in Geist Mono Medium, +// where every character advances 0.6em (measured in Chromium). + +const ADVANCE = 0.6; +const TRACKING = -0.02; // em, as .giant in landing.css +/** How much of the frame the word fills. */ +const FILL = 94; + +/** The `font-size` for `.giant` that sets `word` across the frame. */ +export function giantSize(word: string): string { + const em = [...word].length * (ADVANCE + TRACKING); + return `${(FILL / Math.max(em, 0.01)).toFixed(2)}cqi`; +} diff --git a/website/src/components/site/icons.ts b/website/src/components/site/icons.ts new file mode 100644 index 0000000000..55c629ea23 --- /dev/null +++ b/website/src/components/site/icons.ts @@ -0,0 +1,233 @@ +// 9x9 pixel icons, one square per pixel. PixelIcon.astro draws them as a single path, so they +// stay crisp at any multiple of 9px and take their colour from `currentColor`. Add the +// product's own icons on the same 9x9 grid; never squeeze the mark into it. +export const icons = { + network: [ + '.#.....#.', + '#..#.#..#', + '#.#...#.#', + '#.#.#.#.#', + '#.#...#.#', + '#..#.#..#', + '.#.....#.', + '....#....', + '...###...', + ], + search: [ + '.####....', + '#....#...', + '#....#...', + '#....#...', + '#....#...', + '.####....', + '.....##..', + '......##.', + '.......##', + ], + cube: [ + '....#....', + '..##.##..', + '##.....##', + '#.##.##.#', + '#...#...#', + '#...#...#', + '#...#...#', + '.##.#.##.', + '...###...', + ], + signpost: [ + '....#....', + '.######..', + '.#######.', + '.######..', + '....#....', + '..######.', + '.#######.', + '..######.', + '....#....', + ], + database: [ + '.#######.', + '#.......#', + '.#######.', + '#.......#', + '.#######.', + '#.......#', + '.#######.', + '#.......#', + '.#######.', + ], + gauge: [ + '.........', + '..#####..', + '.#.....#.', + '#.....#.#', + '#....#..#', + '#...#...#', + '#.......#', + '.#######.', + '.........', + ], + faders: [ + '.#..#..#.', + '.#..#.###', + '.#..#..#.', + '###.#..#.', + '.#.###.#.', + '.#..#..#.', + '.#..#..#.', + '.#..#..#.', + '.#..#..#.', + ], + folder: [ + '.........', + '####.....', + '#...#####', + '#.......#', + '#########', + '#.......#', + '#.......#', + '#.......#', + '#########', + ], + flag: [ + '#........', + '########.', + '#######..', + '######...', + '#######..', + '########.', + '#........', + '#........', + '#........', + ], + table: [ + '#########', + '#########', + '#...#...#', + '#########', + '#...#...#', + '#########', + '#...#...#', + '#########', + '.........', + ], + stack: [ + '.........', + '.#######.', + '.........', + '#########', + '.........', + '.#######.', + '.........', + '#########', + '.........', + ], + flame: [ + '....#....', + '....##...', + '...###...', + '..####.#.', + '..######.', + '.#######.', + '.###.###.', + '.##...##.', + '..#####..', + ], + form: [ + '####.....', + '.........', + '#########', + '#.......#', + '#.#.....#', + '#.#.....#', + '#.......#', + '#########', + '.........', + ], + grid: [ + '#.#.#.#.#', + '.........', + '#.#.#.#.#', + '.........', + '#.#.#.#.#', + '.........', + '#.#.#.#.#', + '.........', + '#.#.#.#.#', + ], + panel: [ + '#########', + '#.#.#####', + '#########', + '#.......#', + '#.......#', + '#.......#', + '#.......#', + '#########', + '.........', + ], + lock: [ + '..#####..', + '.##...##.', + '.#.....#.', + '#########', + '#########', + '####.####', + '####.####', + '#########', + '.........', + ], + code: [ + '...#.#...', + '..#...#..', + '.#.....#.', + '#.......#', + '.#.....#.', + '..#...#..', + '...#.#...', + '.........', + '.........', + ], + test: [ + '.........', + '.......##', + '......##.', + '.....##..', + '##..##...', + '.####....', + '..##.....', + '.........', + '.........', + ], + package: [ + '..#####..', + '.#..#..#.', + '#########', + '#...#...#', + '#...#...#', + '#.......#', + '#.......#', + '#########', + '.........', + ], + star: [ + '....#....', + '...###...', + '...###...', + '#########', + '.#######.', + '..#####..', + '..##.##..', + '.##...##.', + '.#.....#.', + ], +} as const; + +export type IconName = keyof typeof icons; + +/** One SVG path of unit squares for a bitmap. */ +export const bitmapPath = (rows: readonly string[]) => + rows + .flatMap((row, y) => [...row].map((c, x) => (c === '#' ? `M${x} ${y}h1v1h-1z` : ''))) + .join(''); diff --git a/website/src/components/site/lazy.ts b/website/src/components/site/lazy.ts new file mode 100644 index 0000000000..c1b4e9d82a --- /dev/null +++ b/website/src/components/site/lazy.ts @@ -0,0 +1,15 @@ +/** + * Calls `start` once, the first time `el` comes within `margin` of the viewport. Pair it with + * a dynamic import() so the code behind it is fetched and run only then. + */ +export function whenNear(el: Element, start: () => void, margin = '200px') { + const io = new IntersectionObserver( + (entries) => { + if (!entries.some((e) => e.isIntersecting)) return; + io.disconnect(); + start(); + }, + { rootMargin: margin }, + ); + io.observe(el); +} diff --git a/website/src/components/starlight/Footer.astro b/website/src/components/starlight/Footer.astro new file mode 100644 index 0000000000..c34e5dbb6d --- /dev/null +++ b/website/src/components/starlight/Footer.astro @@ -0,0 +1,47 @@ +--- +// Docs pages keep Starlight's footer (edit link, pagination) and end with the brand strip: the +// same copyright and links as the landing pages' footer, from content.ts. +import Default from '@astrojs/starlight/components/Footer.astro'; + +import { footer, site } from '../../site/content'; + +const year = new Date().getFullYear(); +--- + + + + + diff --git a/website/src/components/starlight/Head.astro b/website/src/components/starlight/Head.astro new file mode 100644 index 0000000000..735265ca4a --- /dev/null +++ b/website/src/components/starlight/Head.astro @@ -0,0 +1,9 @@ +--- +// Starlight's head plus the production trackers the landing pages load too. +import Default from '@astrojs/starlight/components/Head.astro'; + +import Analytics from '../site/Analytics.astro'; +--- + + + diff --git a/website/src/components/starlight/SiteTitle.astro b/website/src/components/starlight/SiteTitle.astro new file mode 100644 index 0000000000..de16c6848a --- /dev/null +++ b/website/src/components/starlight/SiteTitle.astro @@ -0,0 +1,55 @@ +--- +// The same lockup as the landing pages: the mark (if any), the wordmark block, "by Callstack". +// The block is the bright accent in both themes, from the --accent variable (brand.ts). +import { site } from '../../site/content'; +import Mark from '../site/Mark.astro'; + +const { siteTitleHref } = Astro.locals.starlightRoute; +--- + + + + {site.wordmark} + by Callstack + + + diff --git a/website/src/components/starlight/SocialIcons.astro b/website/src/components/starlight/SocialIcons.astro new file mode 100644 index 0000000000..618aab44d1 --- /dev/null +++ b/website/src/components/starlight/SocialIcons.astro @@ -0,0 +1,38 @@ +--- +// Header links: mono, uppercase, text only. The nav links from content.ts that leave the docs, +// then the GitHub stars. +import GitHubStars from '../site/GitHubStars.astro'; +import { nav } from '../../site/content'; + +const links = nav + .filter(({ href }) => !href.startsWith('/docs')) + .map(({ text, href }) => ({ label: text, href, external: /^https?:/.test(href) })); +--- + +{ + links.map(({ label, href, external }) => ( + + {label} + {external && } + + )) +} + + + diff --git a/website/src/components/starlight/ThemeProvider.astro b/website/src/components/starlight/ThemeProvider.astro new file mode 100644 index 0000000000..9afbff056e --- /dev/null +++ b/website/src/components/starlight/ThemeProvider.astro @@ -0,0 +1,13 @@ +--- +// Dark is the default look. Starlight falls back to the system preference when nothing is +// stored, so seed "dark" for first-time visitors only. Choosing "Auto" in the picker stores "" +// (not null), so an explicit choice is never overwritten. +import Default from '@astrojs/starlight/components/ThemeProvider.astro'; +--- + + + diff --git a/website/src/content.config.ts b/website/src/content.config.ts new file mode 100644 index 0000000000..c1193c2d98 --- /dev/null +++ b/website/src/content.config.ts @@ -0,0 +1,13 @@ +import { defineCollection } from 'astro:content'; +import { glob } from 'astro/loaders'; +import { docsSchema } from '@astrojs/starlight/schema'; + +// The docs stay in docs/docs/*.md, where the repo's guards and generators read them +// (capability matrix, command coverage, client API snippets). Their ids keep the `docs/` prefix, +// so every page is served at /docs/. +export const collections = { + docs: defineCollection({ + loader: glob({ base: './docs', pattern: 'docs/**/[^_]*.{md,mdx}' }), + schema: docsSchema(), + }), +}; diff --git a/website/src/env.d.ts b/website/src/env.d.ts new file mode 100644 index 0000000000..52a7f6b03a --- /dev/null +++ b/website/src/env.d.ts @@ -0,0 +1,3 @@ +// Brings in Starlight's global types, so `Astro.locals.starlightRoute` type-checks in +// SiteTitle.astro and routeData.ts (nothing else in src imports the integration itself). +/// diff --git a/website/src/fonts/GeistMono-Medium.woff2 b/website/src/fonts/GeistMono-Medium.woff2 new file mode 100644 index 0000000000..36ef98cfd7 Binary files /dev/null and b/website/src/fonts/GeistMono-Medium.woff2 differ diff --git a/website/src/fonts/GeistMono-Regular.woff2 b/website/src/fonts/GeistMono-Regular.woff2 new file mode 100644 index 0000000000..8a27d80bf4 Binary files /dev/null and b/website/src/fonts/GeistMono-Regular.woff2 differ diff --git a/website/src/fonts/Switzer-Medium.woff2 b/website/src/fonts/Switzer-Medium.woff2 new file mode 100644 index 0000000000..c6ccae4649 Binary files /dev/null and b/website/src/fonts/Switzer-Medium.woff2 differ diff --git a/website/src/fonts/Switzer-Regular.woff2 b/website/src/fonts/Switzer-Regular.woff2 new file mode 100644 index 0000000000..df335d6cf7 Binary files /dev/null and b/website/src/fonts/Switzer-Regular.woff2 differ diff --git a/website/src/fonts/font-face.css b/website/src/fonts/font-face.css new file mode 100644 index 0000000000..3909b6e9be --- /dev/null +++ b/website/src/fonts/font-face.css @@ -0,0 +1,34 @@ +/* + * Switzer by Jérémie Blanc, distributed by the Indian Type Foundry under the ITF Free Font + * License (https://www.fontshare.com/licenses/itf-ffl). Geist Mono by the Geist Project Authors + * (https://github.com/vercel/geist-font), SIL Open Font License 1.1, Latin subset. + * Self-hosted so the site makes no requests to third-party font services. + */ +@font-face { + font-family: 'Switzer'; + src: url('./Switzer-Regular.woff2') format('woff2'); + font-weight: 400; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'Switzer'; + src: url('./Switzer-Medium.woff2') format('woff2'); + font-weight: 500; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'Geist Mono'; + src: url('./GeistMono-Regular.woff2') format('woff2'); + font-weight: 400; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'Geist Mono'; + src: url('./GeistMono-Medium.woff2') format('woff2'); + font-weight: 500; + font-style: normal; + font-display: swap; +} diff --git a/website/src/layouts/Site.astro b/website/src/layouts/Site.astro new file mode 100644 index 0000000000..34f82f248d --- /dev/null +++ b/website/src/layouts/Site.astro @@ -0,0 +1,88 @@ +--- +// The shell for pages outside Starlight (home, 404, any other landing page): fonts, tokens, the +// accent from brand.ts, search and social tags, and the pointers agents use. Pages add the nav +// and footer themselves. +import '../fonts/font-face.css'; +import '../styles/landing.css'; + +import Analytics from '../components/site/Analytics.astro'; +import geistMono from '../fonts/GeistMono-Regular.woff2?url'; +import switzerMedium from '../fonts/Switzer-Medium.woff2?url'; +import switzerRegular from '../fonts/Switzer-Regular.woff2?url'; +import { brandCss } from '../site/brand'; +import { site } from '../site/content'; +import { fullTitle } from '../site/meta'; + +interface Props { + /** The canonical path, when it differs from this page's own (a duplicate page). */ + canonicalPath?: string; + description: string; + /** Structured data for this page, rendered as JSON-LD. */ + jsonLd?: object; + /** Pages that should stay out of search results (the 404). Add them to noindexPaths too. */ + noindex?: boolean; + /** + * The page's own title, without the product name; leave it out on the home page. The full + * title ("Page | Name", or "Name: subtitle" for home) is composed by fullTitle() in meta.ts. + */ + title?: string; +} + +const { title: pageTitle, description, noindex = false, canonicalPath, jsonLd } = Astro.props; +const title = fullTitle(pageTitle); +// Pages are built as files (`build.format: 'file'`) and served without `.html` or a trailing +// slash (`cleanUrls`, `trailingSlash: false` in vercel.json), so the canonical drops both. +const path = (canonicalPath ?? Astro.url.pathname) + .replace(/\.html$/, '') + .replace(/(^|\/)index$/, '$1') + .replace(/(.)\/$/, '$1'); +const canonical = new URL(path, Astro.site).href; +const ogImage = new URL('/og-image.jpg', Astro.site).href; +--- + + + + + + + {title} + + {noindex ? : } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {jsonLd && + + diff --git a/website/src/pages/cloud.astro b/website/src/pages/cloud.astro new file mode 100644 index 0000000000..70d4e7a669 --- /dev/null +++ b/website/src/pages/cloud.astro @@ -0,0 +1,130 @@ +--- +// Run anywhere: the same agent-device commands on a laptop, in CI, through a remote device host, +// and on device clouds (content.ts `cloud`). Ends with the door to Callstack for team setups. +import Site from '../layouts/Site.astro'; +import Closing from '../components/site/Closing.astro'; +import Dither from '../components/site/Dither.astro'; +import Footer from '../components/site/Footer.astro'; +import Nav from '../components/site/Nav.astro'; +import PixelIcon from '../components/site/PixelIcon.astro'; +import { cloud, docs } from '../site/content'; +import { inlineCode } from '../site/markup'; +--- + + +