T45 Website: a home in motion and every story live, chapters on the left
This commit is contained in:
1 parent
58d3fd7fe2
commit
6ff43607b7
23 files changed
+2634
-1
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
public/gallery
|
||||
.wrangler
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+1616
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,3 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
workerd: true
|
||||
@@ -0,0 +1,4 @@
|
||||
/assets/*
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
/gallery/*
|
||||
X-Content-Type-Options: nosniff
|
||||
@@ -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 () => {};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 = [];
|
||||
};
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 = "";
|
||||
};
|
||||
}
|
||||
@@ -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()) };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "ely-site",
|
||||
"compatibility_date": "2026-09-29",
|
||||
"assets": { "directory": "./dist", "not_found_handling": "single-page-application" }
|
||||
}
|
||||
Reference in new issue
Block a user