T45 Website: a home in motion and every story live, chapters on the left

This commit is contained in:
ZacharyZhang-NY committed 2026-09-29 23:40:04 -04:00
1 parent 58d3fd7fe2
commit 6ff43607b7
23 files changed
+2634 -1

No files matched your search

+3 -1
View File
@@ -17,7 +17,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- Pictures: `image` 0.25 (MIT OR Apache-2.0), the crate gpui decodes with, default features off; it turns decoded frames and rims map tiles.
- Maps: Natural Earth's 110m countries (public domain). `assets/maps/countries.geojson`, trimmed by `scripts/countries.py` to an ISO code, a name and positions to 0.01°, backs `maps::WorldMap`; the gallery's tiles are cut from the same file, z0 to z3, by `scripts/tiles.py` with Pillow.
- Web: on wasm32, `web-time` 1.1 (MIT OR Apache-2.0) gives the `Instant` gpui's executor returns, std's own natively; jiff takes `js`, rust-embed `debug-embed`, similar `wasm32_web_time`. The gallery's web entry takes `gpui_web`, `wasm-bindgen`, `js-sys` and `web-sys` as wasm-only dev-dependencies, and a Hebrew cut of Noto Sans Hebrew 2.003 (OFL, `examples/gallery/fonts`): gpui_web asks the browser for emoji and CJK alone. The `wasm-bindgen` CLI at Cargo.lock's version and binaryen's `wasm-opt` finish the bundle.
- Gallery: `examples/gallery`. Website: `frontend/` (Vite 8, pnpm), built after the components: a home page in motion with three.js and smooth scroll, and a components page with categories on the left and the chosen component on the right, after astryx.atmeta.com and gpui-kit.com. Each component runs live from the gallery built for wasm32 through gpui_web, in an iframe, never as a screenshot. It deploys to Cloudflare through `npx wrangler`.
- Gallery: `examples/gallery`. Website: `frontend/` (Vite 8.3, pnpm 11, TypeScript, no framework): a home page in motion with three.js 0.186 and Lenis 1.3, and a components page with chapters on the left and the chosen story on the right, after astryx.atmeta.com and gpui-kit.com. Each story runs live from the gallery built for wasm32 through gpui_web, in a same-origin iframe, never as a screenshot. Geist and Geist Mono (OFL, `@fontsource-variable`), Reicon icons (MIT, based on Solar Icons, CC BY 4.0, credited in the footer), simple-icons' GitHub mark (CC0). It deploys to Cloudflare Workers static assets through `npx wrangler` (4.143).
## Commands
@@ -26,6 +26,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- `cargo run --example gallery -- --narrow 280 --capture <dir>` lays each page, header and body, at that width in a padded card and shoots it without its script, whose aims are measured at full width. Demos that set their own width run past the card.
- `RUSTC_BOOTSTRAP=1 cargo check --lib --target wasm32-unknown-unknown` checks the web build; gpui_web's `wasm_thread` needs the bootstrap, as Zed's web examples do.
- `ELY_GALLERY_ASSETS=<address of out/assets/> scripts/web.sh <out>` builds the gallery for the browser, profile `web`, and fails past Cloudflare's 25 MiB. It opens at `?page=<slug>&story=<title or slug>&theme=light|dark`; the host page calls `gallery.setTheme(mode)` or posts `{ ely: "theme", theme }`, before the wasm runs or after, and the page posts `{ ely: "ready" }` to its parent once it runs.
- In `frontend/`: `ELY_GALLERY_ASSETS=<site>/gallery/assets/ pnpm gallery` builds the gallery into `public/gallery`, `pnpm dev` serves the site, `pnpm build` checks types and writes `dist`, `pnpm deploy` runs `wrangler deploy`. The site reads `examples/gallery/stories.json` for its chapters, stories and search.
- `python3 scripts/stories.py` writes `examples/gallery/stories.json`, every page's sections in drawing order with their components, for the site: the names in a title, and in its `SHOWN` table what a story draws beyond them. It fails on a section opened inside another and on a ticked entry with a home of its own that no story names. `scripts/check.sh` refuses a stale one.
- `cargo test --lib --features test-support`, `cargo clippy --all-targets --features test-support -- -D warnings`, `cargo fmt --check`. `scripts/check.sh` runs them with the house rules and lints the gallery for wasm32.
- `rm -rf target/debug/incremental` and `cargo clean -p ely-gpui-component` after each task item keep the disk lean. Cargo keeps every old build of the crate and its tests under a new hash; by T25 they had filled the disk.
@@ -181,6 +182,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- What cannot build for wasm32 is absent there through `cfg(not(target_family = "wasm"))`, never faked: the live terminal, `Terminal` and `Launch` with their grid, keys and search, and the gallery's capture harness.
- A gallery demo a browser cannot run says why on the web through `ui::web_note`, in the blocked style: a second window (`own_window`), files from the dialog or a drop (`NO_FILES`), the menu bar, window moves.
- A story is a gallery section: its title is a literal, one per page. Under `ui::Story` the one asked for draws without its title; the rest drop their demos (`ui::Section`), so nothing of theirs lays out, loads or moves. Their builders still run, so a demo that starts work on its own, a clock, a load or a shell, starts it through `ui::live`, which a story grants its own section alone and fails on a title no section has. A story the page lacks fails in the page.
- Embedded in a page, the web gallery keeps gpui_web's hidden text field from taking focus, and hands focus back to the host, until a press or a key reaches it (`examples/gallery/index.html`); gpui_web focuses that field whenever its window is active. The host shows its own state until `{ ely: "ready" }`, sends the mode on ready and on every turn, and puts a skip link before the frame, since Tab inside gpui cycles within it.
- The gallery's pictures go through `asset!` and `ui::picture` or `ui::resource`: a file natively, on the web an address under `ELY_GALLERY_ASSETS`, since gpui reads no files there.
- On the web a paste reaches the focused field as the browser's paste event, through `EntityInputHandler::paste`, so Paste stays unbound there (`TextInput`, `CodeEditor`). Code that reads the clipboard itself awaits `read_from_clipboard_async`: gpui_web's `read_from_clipboard` answers at once and finds nothing (`tables::DataGrid`'s Cmd-V).
- The first frame wears the host's mode through `Theme::set_mode_now`, a mode sent before the window opens included; `set_mode` fades from the colors on screen.
+10
View File
@@ -39,6 +39,16 @@ ELY_GALLERY_ASSETS=https://example.com/gallery/assets/ scripts/web.sh dist # f
In a browser, `?page=buttons&story=icon-button&theme=dark` draws one section alone; `examples/gallery/stories.json` lists them all.
## Website
```sh
cd frontend
pnpm install
ELY_GALLERY_ASSETS=https://<site>/gallery/assets/ pnpm gallery # the wasm gallery, into public/gallery
pnpm dev # or: pnpm build && pnpm preview
pnpm deploy # Cloudflare, through wrangler
```
## Build notes
- gpui compiles its Metal shaders at runtime here (`runtime_shaders`, on by default), so a full Xcode install is not required. gpui_platform needs `font-kit`, or macOS draws no text.
+18
View File
@@ -14,6 +14,24 @@
document.documentElement.classList.add("dark");
}
</script>
<script>
// Embedded, gpui's hidden field waits for a press or key.
if (window.parent !== window) {
let touched = false;
const touch = () => {
if (touched) return;
touched = true;
document.querySelector("textarea")?.focus({ preventScroll: true });
};
addEventListener("pointerdown", touch, true);
addEventListener("keydown", touch, true);
document.addEventListener("focusin", (event) => {
if (touched) return;
event.target.blur();
window.parent.focus();
}, true);
}
</script>
<script type="module">
import init, { set_theme } from "./gallery.js";
// The host sets the mode by setTheme or a message.
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
public/gallery
.wrangler
+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Ely · GPUI components</title>
<meta name="description" content="A component library for GPUI, in light and dark. Every component runs live in the browser." />
<script>
// The saved mode, or the system's, before first paint.
try {
const saved = localStorage.getItem("ely-theme");
const dark = saved ? saved === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
document.documentElement.classList.toggle("dark", dark);
} catch {}
</script>
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
<header class="bar" id="bar"></header>
<main id="main"></main>
<footer class="foot" id="foot"></footer>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
{
"name": "ely-site",
"private": true,
"type": "module",
"scripts": {
"gallery": "../scripts/web.sh public/gallery",
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"deploy": "wrangler deploy"
},
"devDependencies": {
"@types/three": "0.186.0",
"typescript": "^7.0.2",
"vite": "8.3.1",
"wrangler": "4.143.0"
},
"dependencies": {
"@fontsource-variable/geist": "5.3.0",
"@fontsource-variable/geist-mono": "5.3.0",
"lenis": "1.3.26",
"reicon": "1.2.4",
"three": "0.186.1"
}
}
+1616
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -0,0 +1,3 @@
allowBuilds:
esbuild: true
workerd: true
+4
View File
@@ -0,0 +1,4 @@
/assets/*
Cache-Control: public, max-age=31536000, immutable
/gallery/*
X-Content-Type-Options: nosniff
+88
View File
@@ -0,0 +1,88 @@
import { all, chapter, chapters, esc, href, storyCount, type Chapter, type Story } from "./data";
import { icon } from "./icons";
import { preview } from "./preview";
/** Chapters on the left, a story or the index beside. */
export function components(main: HTMLElement, path: string[]): () => void {
const [pageSlug, storySlug] = path;
const current = pageSlug ? chapter(pageSlug) : undefined;
if (pageSlug && !current) return missing(main, `No chapter “${pageSlug}”.`);
const story = current && (storySlug ? current.stories.find((s) => s.slug === storySlug) : current.stories[0]);
if (current && !story) return missing(main, `No story “${storySlug}” in ${current.title}.`);
main.innerHTML = `<div class="docs"><aside class="side" aria-label="Components"></aside><div class="body"></div></div>`;
const side = main.querySelector<HTMLElement>(".side")!;
const body = main.querySelector<HTMLElement>(".body")!;
sidebar(side, current, story);
if (!current || !story) {
body.innerHTML = index();
document.title = "Components · Ely";
return () => {};
}
document.title = `${story.title} · ${current.title} · Ely`;
body.innerHTML = detail(current, story);
return preview(body.querySelector<HTMLElement>(".live")!, current.slug, story.slug, story.title);
}
function sidebar(side: HTMLElement, current?: Chapter, story?: Story): void {
side.innerHTML = `<input type="search" placeholder="Filter components" aria-label="Filter components" /><div class="tree"></div>`;
const input = side.querySelector("input")!;
const tree = side.querySelector<HTMLElement>(".tree")!;
const draw = () => {
const query = input.value.trim().toLowerCase();
const rows = chapters
.map((c) => {
const hits = query ? c.stories.filter((s) => matches(s, query)) : c === current ? c.stories : [];
if (query && !hits.length && !c.title.toLowerCase().includes(query)) return "";
const open = query || c === current;
return `<a class="chapter" href="${href(c)}" aria-current="${c === current}"><span class="n">${String(c.number).padStart(2, "0")}</span>${esc(c.title)}</a>${
open && hits.length
? `<ul>${hits.map((s) => `<li><a href="${href(c, s)}" ${s === story ? 'aria-current="page"' : ""}>${esc(s.title)}</a></li>`).join("")}</ul>`
: ""
}`;
})
.join("");
tree.innerHTML = rows || `<p class="none">Nothing matches “${esc(input.value)}”.</p>`;
};
input.addEventListener("input", draw);
draw();
side.querySelector<HTMLElement>('[aria-current="page"]')?.scrollIntoView({ block: "center" });
}
export function matches(story: Story, query: string): boolean {
return story.title.toLowerCase().includes(query) || story.components.some((c) => c.toLowerCase().includes(query));
}
function detail(current: Chapter, story: Story): string {
const at = all.findIndex((e) => e.story === story && e.chapter === current);
const [prev, next] = [all[at - 1], all[at + 1]];
const pager = (entry: (typeof all)[number] | undefined, cls: string, word: string, arrow: string) =>
entry
? `<a class="${cls}" href="${href(entry.chapter, entry.story)}"><span class="label">${cls === "prev" ? arrow : ""}${word}${cls === "next" ? arrow : ""}</span>${esc(entry.story.title)}</a>`
: "";
return `<article class="detail">
<div class="crumb"><a href="/components/">Components</a> / <a href="${href(current)}">${esc(current.title)}</a></div>
<h1>${esc(story.title)}</h1>
${story.components.length ? `<div class="chips">${story.components.map((c) => `<span>${esc(c)}</span>`).join("")}</div>` : `<div class="chips"></div>`}
<div class="live"></div>
<nav class="pager" aria-label="More stories">${pager(prev, "prev", "Previous", icon("ArrowLeft"))}${pager(next, "next", "Next", icon("ArrowRight"))}</nav>
</article>`;
}
function index(): string {
return `<section class="index">
<h1>Components</h1>
<p>${storyCount} stories in ${chapters.length} chapters. Each one runs live, compiled to WebAssembly.</p>
<div class="grid">${chapters
.map((c) => `<a href="${href(c)}"><span class="n">${String(c.number).padStart(2, "0")}</span><span><strong>${esc(c.title)}</strong><small>${c.stories.length} stories</small></span></a>`)
.join("")}</div>
</section>`;
}
function missing(main: HTMLElement, why: string): () => void {
console.error(`components: ${why}`);
main.innerHTML = `<section class="index"><h1>Not found</h1><p>${esc(why)}</p><a class="button" href="/components/">All components</a></section>`;
document.title = "Not found · Ely";
return () => {};
}
+38
View File
@@ -0,0 +1,38 @@
import manifest from "../../examples/gallery/stories.json";
export interface Story {
title: string;
slug: string;
components: string[];
}
export interface Chapter {
number: number;
slug: string;
title: string;
summary: string;
stories: Story[];
}
export const chapters: Chapter[] = manifest.pages;
export const storyCount = chapters.reduce((sum, chapter) => sum + chapter.stories.length, 0);
export const componentCount = new Set(chapters.flatMap((c) => c.stories.flatMap((s) => s.components))).size;
export function chapter(slug: string): Chapter | undefined {
return chapters.find((c) => c.slug === slug);
}
export function href(chapter: Chapter, story?: Story): string {
return story ? `/components/${chapter.slug}/${story.slug}/` : `/components/${chapter.slug}/`;
}
/** Every story in reading order, for paging and search. */
export const all = chapters.flatMap((c) => c.stories.map((s) => ({ chapter: c, story: s })));
export function esc(text: string): string {
return text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
}
export const REPO = "https://github.com/ZacharyZhang-NY/Ely-GPUI-Components";
+71
View File
@@ -0,0 +1,71 @@
import Lenis from "lenis";
import "lenis/dist/lenis.css";
import { REPO, chapters, componentCount, storyCount } from "./data";
import { icon } from "./icons";
import { preview } from "./preview";
import { onMode } from "./theme";
const FEATURED = { page: "forms", story: "input-textfield-clearableinput", title: "Input / TextField · ClearableInput" };
/** Folding tiles, a few facts, one component live. */
export function home(main: HTMLElement): () => void {
document.title = "Ely · GPUI components";
main.innerHTML = `
<section class="hero"><div class="pin">
<canvas aria-hidden="true"></canvas>
<div class="words">
<h1>Ely, a component library for GPUI.<strong>Every one of them runs here, live.</strong></h1>
<p>${componentCount} components in ${chapters.length} chapters, in light and dark, written in Rust and compiled to WebAssembly for this page.</p>
<div class="cta">
<a class="button primary" href="/components/">Browse components ${icon("ArrowRight")}</a>
<a class="button" href="${REPO}" rel="noopener">${icon("GitHub")}GitHub</a>
</div>
</div>
</div></section>
<section class="band">
<div class="label">In numbers</div>
<h2>One crate, one theme, every part tested at 280 pixels wide.</h2>
<div class="facts">
<div><strong>${storyCount}</strong><span>stories, each its own page</span></div>
<div><strong>${chapters.length}</strong><span>chapters, from primitives to maps</span></div>
<div><strong>2</strong><span>modes, light and dark, from one palette</span></div>
<div><strong>0</strong><span>screenshots: what you see is running</span></div>
</div>
</section>
<section class="band">
<div class="label">Try one</div>
<h2>A text field, live. Type in it.</h2>
<div class="home-stage"></div>
</section>`;
const still = matchMedia("(prefers-reduced-motion: reduce)").matches;
const lenis = still ? null : new Lenis({ autoRaf: true, anchors: true });
const hero = main.querySelector<HTMLElement>(".hero")!;
// The pinned hero folds as it scrolls.
const fold = () => Math.min(1, Math.max(0, scrollY / Math.max(hero.offsetHeight - innerHeight, 1)));
let stops: Array<() => void> = [];
let sceneStop = () => {};
// three.js loads only here.
import("./scene").then(({ scene }) => {
if (!hero.isConnected) return;
const s = scene(main.querySelector("canvas")!, fold, still);
sceneStop = s.stop;
stops.push(onMode(() => s.paint()));
});
const stage = main.querySelector<HTMLElement>(".home-stage")!;
const lazy = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting) return;
lazy.disconnect();
stops.push(preview(stage, FEATURED.page, FEATURED.story, FEATURED.title));
}, { rootMargin: "200px" });
lazy.observe(stage);
return () => {
lenis?.destroy();
lazy.disconnect();
sceneStop();
stops.forEach((stop) => stop());
stops = [];
};
}
+24
View File
@@ -0,0 +1,24 @@
import ArrowLeft from "reicon/icons/ArrowLeft";
import ArrowRight from "reicon/icons/ArrowRight";
import ArrowRightUp from "reicon/icons/ArrowRightUp";
import ChevronRight from "reicon/icons/ChevronRight";
import Maximize from "reicon/icons/Maximize";
import Minimize from "reicon/icons/Minimize";
import Moon from "reicon/icons/Moon";
import Refresh from "reicon/icons/Refresh";
import Search from "reicon/icons/Search";
import Sun from "reicon/icons/Sun";
const set = { ArrowLeft, ArrowRight, ArrowRightUp, ChevronRight, Maximize, Minimize, Moon, Refresh, Search, Sun };
// simple-icons' GitHub mark (CC0), since Reicon has none.
const GITHUB =
'<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>';
export type IconName = keyof typeof set | "GitHub";
/** An 18px glyph at 1.5px in a 24px box. */
export function icon(name: IconName): string {
const svg = name === "GitHub" ? GITHUB.replace("<svg", '<svg width="18" height="18" style="margin:3px"') : set[name].toSvg({ size: 24 });
return `<span class="icon" aria-hidden="true">${svg}</span>`;
}
+82
View File
@@ -0,0 +1,82 @@
import "@fontsource-variable/geist";
import "@fontsource-variable/geist-mono";
import "./style.css";
import { components } from "./components";
import { REPO } from "./data";
import { home } from "./home";
import { icon } from "./icons";
import { menu } from "./menu";
import { search } from "./search";
import { mode, setMode } from "./theme";
const bar = document.getElementById("bar")!;
const main = document.getElementById("main")!;
let leave = () => {};
function render(): void {
leave();
const path = location.pathname.split("/").filter(Boolean);
const onDocs = path[0] === "components";
const nav = bar.querySelector('[data-nav="components"]')!;
if (onDocs) nav.setAttribute("aria-current", "page");
else nav.removeAttribute("aria-current");
if (onDocs) leave = components(main, path.slice(1));
else if (path.length === 0) leave = home(main);
else {
console.error(`site: no page at ${location.pathname}`);
leave = components(main, path);
}
scrollTo(0, 0);
}
export function go(path: string): void {
if (path === location.pathname) return;
history.pushState(null, "", path);
render();
}
bar.innerHTML = `
<a class="mark" href="/">Ely</a>
<nav aria-label="Site">
<a class="link" href="/components/" data-nav="components">Components</a>
<button class="link" aria-haspopup="menu" aria-expanded="false" data-menu>Browse</button>
</nav>
<div class="end">
<button class="find" data-find aria-label="Search components">${icon("Search")}<span>Search</span><kbd>⌘K</kbd></button>
<button class="tool" data-mode></button>
<a class="tool" href="${REPO}" rel="noopener" aria-label="Ely on GitHub">${icon("GitHub")}</a>
</div>`;
document.getElementById("foot")!.innerHTML = `
<span>MIT or Apache-2.0</span>
<a href="${REPO}" rel="noopener">Source</a>
<span>Built on <a href="https://github.com/zed-industries/zed/tree/main/crates/gpui" rel="noopener">GPUI</a></span>
<span>Fonts: Geist and Geist Mono (OFL)</span>
<span>Icons: <a href="https://reicon.dev" rel="noopener">Reicon</a> (MIT), based on Solar Icons by 480 Design (<a href="https://creativecommons.org/licenses/by/4.0/" rel="noopener">CC BY 4.0</a>)</span>`;
const modeButton = bar.querySelector<HTMLButtonElement>("[data-mode]")!;
const drawMode = () => {
const dark = mode() === "dark";
modeButton.innerHTML = icon(dark ? "Sun" : "Moon");
modeButton.setAttribute("aria-label", dark ? "Light mode" : "Dark mode");
};
modeButton.addEventListener("click", () => {
setMode(mode() === "dark" ? "light" : "dark");
drawMode();
});
drawMode();
bar.querySelector("[data-find]")!.addEventListener("click", search(go));
menu(bar.querySelector<HTMLButtonElement>("[data-menu]")!, go);
// Same-origin links route in place, except the gallery.
document.addEventListener("click", (event) => {
const link = (event.target as HTMLElement).closest("a");
if (!link || event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || link.target) return;
const url = new URL(link.href, location.href);
if (url.origin !== location.origin || url.pathname.startsWith("/gallery/") || url.hash) return;
event.preventDefault();
go(url.pathname);
});
addEventListener("popstate", render);
render();
+124
View File
@@ -0,0 +1,124 @@
import { chapters, esc, href, type Chapter } from "./data";
import { icon } from "./icons";
const OPEN_AFTER = 100;
const CLOSE_AFTER = 300;
type Point = { x: number; y: number };
/** Whether `p` lies in the triangle a, b, c. */
export function inside(p: Point, a: Point, b: Point, c: Point): boolean {
const side = (u: Point, v: Point, w: Point) => (u.x - w.x) * (v.y - w.y) - (v.x - w.x) * (u.y - w.y);
const [d1, d2, d3] = [side(p, a, b), side(p, b, c), side(p, c, a)];
return !((d1 < 0 || d2 < 0 || d3 < 0) && (d1 > 0 || d2 > 0 || d3 > 0));
}
/** The header's menu: chapters, each with its stories. */
export function menu(trigger: HTMLButtonElement, go: (path: string) => void): void {
const root = document.createElement("div");
const sub = document.createElement("div");
root.className = sub.className = "menu";
root.setAttribute("role", "menu");
sub.setAttribute("role", "menu");
root.innerHTML = chapters
.map((c, i) => `<button role="menuitem" aria-haspopup="menu" aria-expanded="false" data-i="${i}"><span>${esc(c.title)}</span>${icon("ChevronRight")}</button>`)
.join("");
let timer = 0;
let shown: HTMLElement | null = null;
let last: Point = { x: 0, y: 0 };
const later = (ms: number, act: () => void) => {
clearTimeout(timer);
timer = window.setTimeout(act, ms);
};
const place = (el: HTMLElement, x: number, y: number) => {
document.body.append(el);
const r = el.getBoundingClientRect();
el.style.left = `${Math.min(x, innerWidth - r.width - 8)}px`;
el.style.top = `${Math.max(8, Math.min(y, innerHeight - r.height - 8))}px`;
};
const showSub = (row: HTMLElement, focus: boolean) => {
const chapter: Chapter = chapters[Number(row.dataset.i)];
shown?.setAttribute("aria-expanded", "false");
shown = row;
row.setAttribute("aria-expanded", "true");
sub.innerHTML = chapter.stories.map((s) => `<a role="menuitem" href="${href(chapter, s)}">${esc(s.title)}</a>`).join("");
const r = row.getBoundingClientRect();
place(sub, r.right + 4, r.top - 4);
if (focus) sub.querySelector<HTMLElement>("a")?.focus();
};
const close = (refocus: boolean) => {
clearTimeout(timer);
root.remove();
sub.remove();
shown = null;
trigger.setAttribute("aria-expanded", "false");
if (refocus) trigger.focus();
};
const open = (focusFirst: boolean) => {
const r = trigger.getBoundingClientRect();
trigger.setAttribute("aria-expanded", "true");
place(root, r.left, r.bottom + 4);
if (focusFirst) root.querySelector<HTMLElement>("button")?.focus();
};
// A pointer heading for the open submenu keeps it.
const aiming = (p: Point) => {
if (!sub.isConnected) return false;
const r = sub.getBoundingClientRect();
return inside(p, last, { x: r.left, y: r.top - 24 }, { x: r.left, y: r.bottom + 24 });
};
root.addEventListener("pointermove", (event) => {
const p = { x: event.clientX, y: event.clientY };
const row = (event.target as HTMLElement).closest<HTMLElement>("[data-i]");
if (row && row !== shown) later(aiming(p) ? CLOSE_AFTER : OPEN_AFTER, () => showSub(row, false));
last = p;
});
for (const el of [root, sub, trigger]) {
el.addEventListener("pointerenter", () => clearTimeout(timer));
el.addEventListener("pointerleave", () => later(CLOSE_AFTER, () => close(false)));
}
trigger.addEventListener("click", () => (root.isConnected ? close(false) : open(false)));
trigger.addEventListener("keydown", (event) => {
if (event.key === "ArrowDown") {
event.preventDefault();
open(true);
}
});
const keys = (list: HTMLElement, event: KeyboardEvent) => {
const items = [...list.querySelectorAll<HTMLElement>("[role=menuitem]")];
const at = items.indexOf(document.activeElement as HTMLElement);
const move = (to: number) => items[(to + items.length) % items.length]?.focus();
if (event.key === "ArrowDown") move(at + 1);
else if (event.key === "ArrowUp") move(at - 1);
else if (event.key === "Home") move(0);
else if (event.key === "End") move(items.length - 1);
else if (event.key === "ArrowRight" && list === root && at >= 0) showSub(items[at], true);
else if (event.key === "ArrowLeft" && list === sub) (sub.remove(), shown?.focus());
else if (event.key === "Escape" || event.key === "Tab") close(event.key === "Escape");
else return;
if (event.key !== "Tab") event.preventDefault();
};
root.addEventListener("keydown", (event) => keys(root, event));
sub.addEventListener("keydown", (event) => keys(sub, event));
root.addEventListener("click", (event) => {
const row = (event.target as HTMLElement).closest<HTMLElement>("[data-i]");
if (row) showSub(row, event.detail === 0);
});
sub.addEventListener("click", (event) => {
const link = (event.target as HTMLElement).closest("a");
if (!link) return;
event.preventDefault();
close(false);
go(link.getAttribute("href")!);
});
addEventListener("pointerdown", (event) => {
const t = event.target as Node;
if (root.isConnected && !root.contains(t) && !sub.contains(t) && !trigger.contains(t)) close(false);
});
}
+86
View File
@@ -0,0 +1,86 @@
import { esc } from "./data";
import { icon } from "./icons";
import { mode, onMode } from "./theme";
const SPIN_AFTER = 400;
const GIVE_UP = 20000;
/** One story live, themed with the site; returns its stop. */
export function preview(host: HTMLElement, page: string, story: string, title: string): () => void {
const src = (theme: string) => `/gallery/?page=${page}&story=${story}&theme=${theme}`;
host.innerHTML = `
<a class="stage-skip" href="#after-stage">Skip the live example</a>
<div class="stage">
<iframe title="${esc(title)}, live" src="${src(mode())}"></iframe>
<div class="state" role="status">Starting…</div>
<div class="actions">
<button class="tool" data-act="reload" aria-label="Reload the example">${icon("Refresh")}</button>
<a class="tool" href="${src(mode())}" target="_blank" rel="noopener" aria-label="Open the example alone">${icon("ArrowRightUp")}</a>
<button class="tool" data-act="full" aria-label="Full screen">${icon("Maximize")}</button>
</div>
</div>
<span id="after-stage" tabindex="-1"></span>`;
const stage = host.querySelector<HTMLElement>(".stage")!;
const frame = stage.querySelector("iframe")!;
const state = stage.querySelector<HTMLElement>(".state")!;
let timers: number[] = [];
const starting = () => {
timers.forEach(clearTimeout);
state.hidden = false;
state.textContent = "Starting…";
timers = [
window.setTimeout(() => (state.innerHTML = `<span class="spin"></span>Starting…`), SPIN_AFTER),
window.setTimeout(() => {
state.innerHTML = `Couldn't start the example. <button class="link" data-act="reload">Reload</button>`;
console.error(`preview: ${page}/${story} sent no ready in ${GIVE_UP} ms`);
}, GIVE_UP),
];
};
const ready = (event: MessageEvent) => {
if (event.source !== frame.contentWindow || event.data?.ely !== "ready") return;
timers.forEach(clearTimeout);
state.hidden = true;
// The host's mode may have turned while the wasm loaded.
frame.contentWindow?.postMessage({ ely: "theme", theme: mode() }, location.origin);
};
const stopMode = onMode((next) => {
frame.contentWindow?.postMessage({ ely: "theme", theme: next }, location.origin);
stage.querySelector<HTMLAnchorElement>("a.tool")!.href = src(next);
});
const full = (on: boolean) => {
stage.classList.toggle("full", on);
document.body.style.overflow = on ? "hidden" : "";
const button = stage.querySelector<HTMLElement>('[data-act="full"]')!;
button.innerHTML = icon(on ? "Minimize" : "Maximize");
button.setAttribute("aria-label", on ? "Leave full screen" : "Full screen");
};
const escape = (event: KeyboardEvent) => {
if (event.key === "Escape" && stage.classList.contains("full")) full(false);
};
stage.addEventListener("click", (event) => {
const act = (event.target as HTMLElement).closest<HTMLElement>("[data-act]")?.dataset.act;
if (act === "reload") {
starting();
frame.src = src(mode());
} else if (act === "full") {
full(!stage.classList.contains("full"));
}
});
addEventListener("message", ready);
addEventListener("keydown", escape);
starting();
return () => {
timers.forEach(clearTimeout);
removeEventListener("message", ready);
removeEventListener("keydown", escape);
stopMode();
document.body.style.overflow = "";
};
}
+106
View File
@@ -0,0 +1,106 @@
import * as THREE from "three";
import { RoundedBoxGeometry } from "three/addons/geometries/RoundedBoxGeometry.js";
import { chapters } from "./data";
const STEP = 1.2;
/** A tile per story, folding into chapter columns. */
export function scene(canvas: HTMLCanvasElement, fold: () => number, still: boolean): { stop: () => void; paint: () => void } {
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
const camera = new THREE.PerspectiveCamera(32, 1, 0.1, 200);
const world = new THREE.Scene();
world.add(new THREE.HemisphereLight(0xffffff, 0x888888, 2.2));
const sun = new THREE.DirectionalLight(0xffffff, 1.2);
sun.position.set(-6, 10, 12);
world.add(sun);
const tiles = chapters.flatMap((c, col) => c.stories.map((_, row) => ({ col, row })));
const mesh = new THREE.InstancedMesh(
new RoundedBoxGeometry(1, 1, 0.24, 3, 0.16),
new THREE.MeshStandardMaterial({ roughness: 0.7, metalness: 0 }),
tiles.length,
);
world.add(mesh);
// At rest a near-square lattice; folded, one column per chapter.
const across = Math.ceil(Math.sqrt(tiles.length * 1.6));
const rest = tiles.map((_, i) => new THREE.Vector3(((i % across) - across / 2) * STEP, (Math.floor(i / across) - tiles.length / across / 2) * -STEP, 0));
const tallest = Math.max(...chapters.map((c) => c.stories.length));
const folded = tiles.map(({ col, row }) => new THREE.Vector3((col - chapters.length / 2) * STEP * 0.9, (row - tallest / 2) * -STEP * 0.5, 0));
// The tiles' widest and tallest reach, kept in view.
const reach = { w: Math.max(across, chapters.length * 0.9) * STEP + 2, h: Math.max(tiles.length / across, tallest * 0.5) * STEP + 2 };
let distance = 34;
const paint = () => {
const css = getComputedStyle(document.documentElement);
const [ink, accent] = [new THREE.Color(css.getPropertyValue("--line").trim()), new THREE.Color(css.getPropertyValue("--accent").trim())];
// About one tile in ten wears the accent.
tiles.forEach((_, i) => mesh.setColorAt(i, (i * 7) % 10 === 3 ? accent : ink));
mesh.instanceColor!.needsUpdate = true;
};
const size = () => {
const { clientWidth: w, clientHeight: h } = canvas;
renderer.setSize(w, h, false);
camera.aspect = w / Math.max(h, 1);
camera.updateProjectionMatrix();
const k = 2 * Math.tan(THREE.MathUtils.degToRad(camera.fov / 2));
distance = Math.max(reach.w / (k * camera.aspect), reach.h / k);
};
const at = new THREE.Object3D();
const spot = new THREE.Vector3();
let running = true;
let frame = 0;
const draw = (time: number) => {
const t = still ? 0 : fold();
const ease = t * t * (3 - 2 * t);
tiles.forEach((_, i) => {
spot.lerpVectors(rest[i], folded[i], ease);
const wave = still ? 0 : Math.sin(time / 1400 + rest[i].x * 0.35 + rest[i].y * 0.2) * 0.35 * (1 - ease);
at.position.set(spot.x, spot.y, wave);
at.rotation.set(-0.5 * (1 - ease), 0, 0);
at.scale.setScalar(1 - 0.45 * ease);
at.updateMatrix();
mesh.setMatrixAt(i, at.matrix);
});
mesh.instanceMatrix.needsUpdate = true;
camera.position.set(0, -0.12 * distance * (1 - ease), distance);
camera.lookAt(0, 0, 0);
renderer.render(world, camera);
if (running && !still) frame = requestAnimationFrame(draw);
};
const start = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(draw);
};
const seen = new IntersectionObserver(([entry]) => {
running = entry.isIntersecting && !document.hidden;
if (running) start();
});
seen.observe(canvas);
const visible = () => {
running = !document.hidden;
if (running) start();
};
const resized = new ResizeObserver(() => (size(), start()));
resized.observe(canvas);
document.addEventListener("visibilitychange", visible);
paint();
size();
start();
const stop = () => {
running = false;
cancelAnimationFrame(frame);
seen.disconnect();
resized.disconnect();
document.removeEventListener("visibilitychange", visible);
renderer.dispose();
mesh.geometry.dispose();
(mesh.material as THREE.Material).dispose();
};
return { stop, paint: () => (paint(), start()) };
}
+83
View File
@@ -0,0 +1,83 @@
import { matches } from "./components";
import { all, esc, href } from "./data";
const LIMIT = 50;
/** A palette over every story: ⌘K, Ctrl-K or /. */
export function search(go: (path: string) => void): () => void {
let back: HTMLElement | null = null;
const scrim = document.createElement("div");
scrim.className = "scrim";
scrim.innerHTML = `<div class="palette" role="dialog" aria-modal="true" aria-label="Search components">
<input type="search" placeholder="Search stories and components" aria-label="Search" role="combobox" aria-expanded="true" aria-controls="hits" />
<ol id="hits" role="listbox"></ol></div>`;
const input = scrim.querySelector("input")!;
const list = scrim.querySelector("ol")!;
let hits: typeof all = [];
let cursor = 0;
const draw = () => {
const query = input.value.trim().toLowerCase();
hits = (query ? all.filter((e) => matches(e.story, query) || e.chapter.title.toLowerCase().includes(query)) : all).slice(0, LIMIT);
cursor = Math.min(cursor, Math.max(hits.length - 1, 0));
list.innerHTML = hits.length
? hits
.map(
(e, i) =>
`<li role="option" id="hit-${i}" aria-selected="${i === cursor}"><a href="${href(e.chapter, e.story)}" tabindex="-1"><span>${esc(e.story.title)}</span><span>${esc(e.chapter.title)}</span></a></li>`,
)
.join("")
: `<li class="none">Nothing matches “${esc(input.value)}”.</li>`;
input.setAttribute("aria-activedescendant", hits.length ? `hit-${cursor}` : "");
list.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: "nearest" });
};
const close = () => {
scrim.remove();
back?.focus();
};
const open = () => {
if (scrim.isConnected) return;
back = document.activeElement as HTMLElement | null;
document.body.append(scrim);
input.value = "";
cursor = 0;
draw();
input.focus();
};
input.addEventListener("input", () => ((cursor = 0), draw()));
input.addEventListener("keydown", (event) => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
cursor = (cursor + (event.key === "ArrowDown" ? 1 : hits.length - 1)) % Math.max(hits.length, 1);
draw();
} else if (event.key === "Enter" && hits[cursor]) {
close();
go(href(hits[cursor].chapter, hits[cursor].story));
} else if (event.key === "Escape") {
close();
} else if (event.key === "Tab") {
// The field is the dialog's one stop.
event.preventDefault();
}
});
scrim.addEventListener("click", (event) => {
if (event.target === scrim) close();
const link = (event.target as HTMLElement).closest("a");
if (link) {
event.preventDefault();
close();
go(link.getAttribute("href")!);
}
});
addEventListener("keydown", (event) => {
const typing = (event.target as HTMLElement).closest("input, textarea, [contenteditable]");
if ((event.key === "k" && (event.metaKey || event.ctrlKey)) || (event.key === "/" && !typing)) {
event.preventDefault();
open();
}
});
return open;
}
+179
View File
@@ -0,0 +1,179 @@
:root {
--bg: #fcfaf7;
--fg: #181818;
--muted: #6a6a6a;
--line: #e1dfdd;
--hover: #f2f0ed;
--accent: #3772bb;
--overlay: #ffffff;
--sans: "Geist Variable", system-ui, sans-serif;
--mono: "Geist Mono Variable", ui-monospace, monospace;
color-scheme: light;
}
:root.dark {
--bg: #131110;
--fg: #f3f1f0;
--muted: #94928f;
--line: #2c2b29;
--hover: #252422;
--accent: #69a1e8;
--overlay: #1c1a19;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body { margin: 0; background: var(--bg); color: var(--fg); }
body {
font: 400 16px/24px var(--sans);
-webkit-font-smoothing: antialiased;
}
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 8px; }
.skip { position: absolute; left: 16px; top: -48px; padding: 8px 12px; background: var(--fg); color: var(--bg); border-radius: 8px; z-index: 30; }
.skip:focus { top: 8px; }
.icon { display: inline-flex; width: 24px; height: 24px; flex: none; }
.icon svg { width: 24px; height: 24px; }
.label { font: 500 12px/16px var(--mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
/* Header */
.bar {
position: sticky; top: 0; z-index: 20; height: 56px;
display: flex; align-items: center; gap: 24px; padding: 0 24px;
background: color-mix(in srgb, var(--bg) 84%, transparent);
backdrop-filter: blur(16px);
border-bottom: 1px solid var(--line);
}
.bar .mark { font-weight: 600; font-size: 18px; line-height: 28px; }
.bar nav { display: flex; gap: 4px; }
.bar .end { margin-left: auto; display: flex; align-items: center; gap: 4px; }
.link { padding: 4px 12px; border-radius: 8px; font-size: 14px; line-height: 20px; font-weight: 500; color: var(--muted); }
.link:hover, .link[aria-current="page"], .link[aria-expanded="true"] { color: var(--fg); background: var(--hover); }
.tool { width: 32px; height: 32px; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; color: var(--muted); }
.tool:hover { color: var(--fg); background: var(--hover); }
.find { display: inline-flex; align-items: center; gap: 8px; height: 32px; padding: 0 8px 0 4px; border-radius: 8px; color: var(--muted); font-size: 14px; }
.find:hover { background: var(--hover); color: var(--fg); }
.find kbd { font: 500 12px/16px var(--mono); padding: 0 4px; border: 1px solid var(--line); border-radius: 4px; }
/* Cascading menu */
.menu {
position: fixed; z-index: 25; min-width: 240px; max-height: min(480px, 70vh); overflow-y: auto;
padding: 4px; background: var(--overlay); border: 1px solid var(--line); border-radius: 12px;
box-shadow: 0 12px 32px rgb(0 0 0 / 0.12);
}
.menu a, .menu button { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; padding: 4px 8px; min-height: 32px; border-radius: 8px; font-size: 14px; line-height: 20px; text-align: left; }
.menu a:hover, .menu button:hover, .menu [aria-expanded="true"], .menu :focus-visible { background: var(--hover); outline: none; }
.menu .count { color: var(--muted); font: 500 12px/16px var(--mono); }
/* Components page */
.docs { display: grid; grid-template-columns: 264px minmax(0, 1fr); min-height: calc(100vh - 56px); }
.side { position: sticky; top: 56px; height: calc(100vh - 56px); overflow-y: auto; padding: 24px 12px 48px; border-right: 1px solid var(--line); }
.side input { appearance: none; width: 100%; height: 32px; margin-bottom: 16px; padding: 0 12px; font: inherit; font-size: 14px; color: var(--fg); background: transparent; border: 1px solid var(--line); border-radius: 8px; }
.side input::placeholder { color: var(--muted); }
.side input:focus-visible { outline: 2px solid var(--accent); outline-offset: 0; }
.side .chapter { display: flex; gap: 8px; align-items: center; width: 100%; padding: 4px 8px; min-height: 32px; border-radius: 8px; font-size: 14px; line-height: 20px; color: var(--muted); }
.side .chapter .n { font: 500 12px/16px var(--mono); width: 20px; }
.side .chapter:hover { background: var(--hover); color: var(--fg); }
.side .chapter[aria-current="true"] { color: var(--fg); font-weight: 500; }
.side ul { list-style: none; margin: 0 0 8px; padding: 0 0 0 28px; }
.side li a { display: block; padding: 4px 8px; border-radius: 8px; font-size: 14px; line-height: 20px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.side li a:hover { background: var(--hover); color: var(--fg); }
.side li a[aria-current="page"] { color: var(--fg); background: var(--hover); box-shadow: inset 2px 0 0 var(--accent); }
.side .none { padding: 8px; font-size: 14px; color: var(--muted); }
.detail { padding: 48px 48px 96px; max-width: 1136px; }
.crumb { font-size: 14px; line-height: 20px; color: var(--muted); }
.crumb a:hover { color: var(--fg); }
.detail h1 { margin: 4px 0 12px; font-size: 18px; line-height: 28px; font-weight: 600; }
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 24px; }
.chips span { font: 500 12px/16px var(--mono); padding: 4px 8px; border: 1px solid var(--line); border-radius: 8px; color: var(--muted); }
.stage { position: relative; height: 640px; border: 1px solid var(--line); border-radius: 16px; overflow: hidden; background: var(--bg); }
.stage iframe { display: block; width: 100%; height: 100%; border: 0; background: var(--bg); }
.stage .state { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 8px; font-size: 14px; color: var(--muted); background: var(--bg); }
.stage .state[hidden] { display: none; }
.stage .actions { position: absolute; top: 12px; right: 12px; display: flex; gap: 4px; opacity: 0; transition: opacity 150ms ease-out; }
.stage:hover .actions, .stage:focus-within .actions { opacity: 1; }
.stage .actions .tool { background: var(--bg); border: 1px solid var(--line); }
.stage.full { position: fixed; inset: 0; z-index: 40; height: auto; border-radius: 0; border: 0; }
.spin { width: 16px; height: 16px; border: 1.5px solid var(--line); border-top-color: var(--muted); border-radius: 50%; animation: spin 800ms linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.stage-skip { position: absolute; left: -9999px; }
.stage-skip:focus { position: static; display: inline-block; margin-bottom: 8px; font-size: 14px; color: var(--accent); }
.pager { display: flex; justify-content: space-between; gap: 16px; margin-top: 24px; }
.pager a { display: flex; flex-direction: column; gap: 4px; max-width: 48%; padding: 12px 16px; border-radius: 12px; font-size: 14px; line-height: 20px; }
.pager a:hover { background: var(--hover); }
.pager a.next { text-align: right; margin-left: auto; }
.pager .label { display: flex; align-items: center; gap: 4px; }
.pager a.next .label { justify-content: flex-end; }
.index { padding: 48px; max-width: 1136px; }
.index h1 { margin: 0 0 8px; font-size: 18px; line-height: 28px; font-weight: 600; }
.index p { margin: 0 0 48px; color: var(--muted); max-width: 640px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 4px 24px; }
.grid a { display: flex; gap: 12px; align-items: baseline; padding: 12px; border-radius: 12px; }
.grid a:hover { background: var(--hover); }
.grid .n { font: 500 12px/16px var(--mono); color: var(--muted); width: 20px; }
.grid strong { font-weight: 500; display: block; }
.grid small { font-size: 14px; line-height: 20px; color: var(--muted); }
/* Palette */
.scrim { position: fixed; inset: 0; z-index: 50; background: rgb(0 0 0 / 0.24); display: flex; justify-content: center; align-items: flex-start; padding: 96px 16px 16px; }
.palette { width: min(640px, 100%); max-height: calc(100vh - 128px); display: flex; flex-direction: column; background: var(--overlay); border: 1px solid var(--line); border-radius: 16px; box-shadow: 0 24px 64px rgb(0 0 0 / 0.2); overflow: hidden; }
.palette input { flex: none; appearance: none; height: 56px; padding: 0 16px; font: inherit; color: var(--fg); background: transparent; border: 0; border-bottom: 1px solid var(--line); outline: none; }
.palette ol { list-style: none; margin: 0; padding: 4px; overflow-y: auto; }
.palette li a { display: flex; justify-content: space-between; gap: 16px; padding: 8px 12px; border-radius: 8px; font-size: 14px; line-height: 20px; }
.palette li a span:last-child { color: var(--muted); white-space: nowrap; }
.palette li[aria-selected="true"] a { background: var(--hover); }
.palette .none { padding: 16px; color: var(--muted); font-size: 14px; }
/* Home */
.hero { height: 220vh; }
.pin { position: sticky; top: 56px; height: calc(100vh - 56px); display: grid; align-content: center; padding: 48px; overflow: hidden; }
.hero canvas { position: absolute; top: 0; right: 0; width: 62%; height: 100%; }
.hero .words { position: relative; max-width: 440px; }
.hero h1 { margin: 0 0 16px; font-size: 18px; line-height: 28px; font-weight: 400; }
.hero h1 strong { display: block; font-weight: 600; }
.hero p { margin: 0 0 24px; color: var(--muted); }
.cta { display: flex; gap: 12px; flex-wrap: wrap; }
.button { display: inline-flex; align-items: center; gap: 8px; height: 40px; padding: 0 16px; border-radius: 12px; font-size: 14px; font-weight: 500; border: 1px solid var(--line); }
.button:hover { background: var(--hover); }
.button.primary { background: var(--fg); color: var(--bg); border-color: var(--fg); }
.button.primary:hover { opacity: 0.88; }
.band { padding: 96px 48px; border-top: 1px solid var(--line); }
.band .label { margin-bottom: 12px; }
.band h2 { margin: 0 0 48px; font-size: 18px; line-height: 28px; font-weight: 600; max-width: 560px; }
.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 48px; }
.facts strong { display: block; font: 500 18px/28px var(--mono); }
.facts span { color: var(--muted); font-size: 14px; line-height: 20px; }
.home-stage { max-width: 1136px; }
.foot { padding: 24px 48px; border-top: 1px solid var(--line); font-size: 12px; line-height: 16px; color: var(--muted); display: flex; flex-wrap: wrap; gap: 8px 24px; }
.foot a { text-decoration: underline; text-underline-offset: 2px; }
@media (max-width: 767px) {
.bar { padding: 0 16px; gap: 12px; }
.bar nav .link { padding: 4px 8px; }
.find > span:not(.icon), .find kbd { display: none; }
.docs { grid-template-columns: 1fr; }
.side { position: static; height: auto; max-height: 40vh; border-right: 0; border-bottom: 1px solid var(--line); }
.detail, .index { padding: 24px 16px 64px; }
.stage { height: 480px; }
.pin, .band { padding: 48px 16px; }
.pin { align-content: end; }
.hero canvas { width: 100%; height: 55%; }
.foot { padding: 24px 16px; }
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; animation: none !important; }
}
+20
View File
@@ -0,0 +1,20 @@
export type Mode = "light" | "dark";
const listeners = new Set<(mode: Mode) => void>();
export function mode(): Mode {
return document.documentElement.classList.contains("dark") ? "dark" : "light";
}
export function setMode(next: Mode): void {
document.documentElement.classList.toggle("dark", next === "dark");
try {
localStorage.setItem("ely-theme", next);
} catch {}
listeners.forEach((listen) => listen(next));
}
export function onMode(listen: (mode: Mode) => void): () => void {
listeners.add(listen);
return () => listeners.delete(listen);
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"resolveJsonModule": true,
"noEmit": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"]
},
"include": ["src"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vite";
// The manifest lives beside the gallery, one folder up.
export default defineConfig({
server: { fs: { allow: [".."] } },
// three.js is a chunk of its own, for the home page.
build: { chunkSizeWarningLimit: 600 },
});
+5
View File
@@ -0,0 +1,5 @@
{
"name": "ely-site",
"compatibility_date": "2026-09-29",
"assets": { "directory": "./dist", "not_found_handling": "single-page-application" }
}