T45a Site · Stack: Vite 8, React 19 and TanStack Router under pnpm; routes, theme, fonts, the manifest
This commit is contained in:
32 files changed
+699
-1309
No files matched your search
@@ -2,3 +2,4 @@
|
||||
node_modules/
|
||||
/frontend/dist/
|
||||
.DS_Store
|
||||
/frontend-reference/
|
||||
@@ -17,7 +17,8 @@ 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.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).
|
||||
- Gallery: `examples/gallery`. Website: `frontend/` (Vite 8.3, pnpm 11, TypeScript, React 19.3, TanStack Router 1.170 in code-based routes, Lenis 1.3), after `frontend-reference/`: heavy titles over thin faint text, hairlines, one accent, full-screen sections, isometric line miniatures on a canvas. 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).
|
||||
- Reference site: `frontend-reference/` (Vite 8.3, React 19, TanStack Router 1.170 in code-based routes, `lottie-web` 5.13, pnpm 11). A 1:1 study copy of serro.ai: the SSR DOM and the site's own CSS ported verbatim, Framer custom components replayed from runtime DOM captured at five widths (`RuntimeVariant`), 17 Lottie instances on the original JSONs, and the mock's feed sequence, ROI count-up and hover pills reimplemented from measured timings. Real fonts and assets served locally. Not deployed. `NOTES.md` there documents the pipeline under `RECON/tools/`.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -27,6 +28,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- `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`. `node scripts/og.mjs`, with `pnpm preview` up, renders `public/og.png` from the hero. The site reads `examples/gallery/stories.json` for its chapters, stories and search.
|
||||
- In `frontend-reference/`: `pnpm dev` serves it, `pnpm build` checks types and writes `dist`. serro.ai 403s plain curl and headless Chromium; measure it with Playwright's Firefox. `sh RECON/tools/pipeline.sh` regenerates the clone from the captured artifacts.
|
||||
- `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.
|
||||
|
||||
@@ -154,7 +154,12 @@ Per-component lines live in `tasks/`. Tags there:
|
||||
- [x] T44b Port · Build: gpui and gpui_platform from Zed's repo by git at 1a28cff; the crate, its tests and the gallery build, and check.sh passes
|
||||
- [x] T44c Port · Parity: every page and scripted state captured light and dark and matched against T44a's shots; AGENTS.md's gpui 0.2.2 and taffy 0.9 rules re-tested at 1a28cff and rewritten
|
||||
- [x] T44d Web · Stories: the gallery builds for wasm32 through gpui_web; one story per component, chosen by URL, themed by the host page; what cannot run in a browser says why
|
||||
- [ ] T45 Website: `frontend/`, Vite 8, pnpm, light and dark: a home page in motion (three.js, smooth scroll), and a components page with every component live from its story, categories on the left and the chosen one's detail on the right, after astryx.atmeta.com and gpui-kit.com; no screenshots
|
||||
- [ ] T45 Website: `frontend/` rebuilt after `frontend-reference/` (serro.ai's language: heavy titles, thin faint text, hairlines, one accent), Vite 8, pnpm, React 19, TanStack Router, light and dark; isometric line miniatures that grow cell by cell on a marimba beat; every story live, a native capture where a browser cannot run it; no box inside a box, no subheadings; run locally, not published
|
||||
- [ ] T45a Site · Stack: the old `frontend/` replaced by Vite 8, React 19 and TanStack Router under pnpm; routes `/`, `/components/`, `/components/$page/$story`; theme, fonts, the stories manifest
|
||||
- [ ] T45b Site · Iso: isometric line miniatures on a canvas, no perspective, hidden lines removed, cells growing on a sixteenth-note marimba beat, playable under the pointer, light and dark, still under reduced motion
|
||||
- [ ] T45c Site · Home: a full-screen hero and full-screen sections, each a miniature beside its live component, counts, the chapters as a city of plinths, a closing call; motion from scroll and route changes
|
||||
- [ ] T45d Site · Components: the chapter index and the story page redesigned; live stories without frames, native captures for what a browser cannot run, search, keys
|
||||
- [ ] T45e Site · Local: the gallery bundle built for localhost and the site served for the owner to see; not published
|
||||
- [ ] T46 E2E: Playwright against the built site
|
||||
- [ ] T47 Ship: GitHub repo (public), CI, Cloudflare deploy through `npx wrangler`
|
||||
- [ ] T48 Acceptance: live URL and MVP checklist
|
||||
|
||||
+2
-5
@@ -23,10 +23,7 @@
|
||||
</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>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -12,7 +12,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.63.0",
|
||||
"@types/three": "0.186.0",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@vitejs/plugin-react": "6.1.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "8.3.1",
|
||||
"wrangler": "4.143.0"
|
||||
@@ -20,8 +22,10 @@
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "5.3.0",
|
||||
"@fontsource-variable/geist-mono": "5.3.0",
|
||||
"@tanstack/react-router": "1.170.40",
|
||||
"lenis": "1.3.26",
|
||||
"reicon": "1.2.4",
|
||||
"three": "0.186.1"
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"reicon": "1.2.4"
|
||||
}
|
||||
}
|
||||
Generated
+164
-47
@@ -14,22 +14,34 @@ importers:
|
||||
'@fontsource-variable/geist-mono':
|
||||
specifier: 5.3.0
|
||||
version: 5.3.0
|
||||
'@tanstack/react-router':
|
||||
specifier: 1.170.40
|
||||
version: 1.170.40(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
|
||||
lenis:
|
||||
specifier: 1.3.26
|
||||
version: 1.3.26
|
||||
version: 1.3.26(react@19.3.0)
|
||||
react:
|
||||
specifier: 19.3.0
|
||||
version: 19.3.0
|
||||
react-dom:
|
||||
specifier: 19.3.0
|
||||
version: 19.3.0(react@19.3.0)
|
||||
reicon:
|
||||
specifier: 1.2.4
|
||||
version: 1.2.4
|
||||
three:
|
||||
specifier: 0.186.1
|
||||
version: 0.186.1
|
||||
devDependencies:
|
||||
'@playwright/test':
|
||||
specifier: 1.63.0
|
||||
version: 1.63.0
|
||||
'@types/three':
|
||||
specifier: 0.186.0
|
||||
version: 0.186.0
|
||||
'@types/react':
|
||||
specifier: 19.3.0
|
||||
version: 19.3.0
|
||||
'@types/react-dom':
|
||||
specifier: 19.3.0
|
||||
version: 19.3.0(@types/react@19.3.0)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: 6.1.1
|
||||
version: 6.1.1(vite@8.3.1(esbuild@0.28.1))
|
||||
typescript:
|
||||
specifier: ^7.0.2
|
||||
version: 7.0.2
|
||||
@@ -89,9 +101,6 @@ packages:
|
||||
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@dimforge/rapier3d-compat@0.12.0':
|
||||
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
|
||||
|
||||
'@emnapi/runtime@1.11.3':
|
||||
resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
|
||||
|
||||
@@ -552,17 +561,37 @@ packages:
|
||||
'@speed-highlight/core@1.2.24':
|
||||
resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==}
|
||||
|
||||
'@tweenjs/tween.js@23.1.3':
|
||||
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
|
||||
'@tanstack/history@1.162.4':
|
||||
resolution: {integrity: sha512-utTS5L2OkeYUzXGohL1Z8sefu1GLNOJcxe8Hd6iIdc/Xo1K1nDB2JEp4iSFhvYh33xKC9V91TxrS8qfrpoKobQ==}
|
||||
engines: {node: '>=20.19'}
|
||||
|
||||
'@types/stats.js@0.17.4':
|
||||
resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==}
|
||||
'@tanstack/react-router@1.170.40':
|
||||
resolution: {integrity: sha512-vVr3va9Mcq05rZRrDZPGoptAClRMKHvekLbpg2GcfXvAAtOVhW6Q93tOz4dvS8j03vTVrU25VM6weP1SIHGEew==}
|
||||
engines: {node: '>=20.19'}
|
||||
peerDependencies:
|
||||
react: '>=18.0.0 || >=19.0.0'
|
||||
react-dom: '>=18.0.0 || >=19.0.0'
|
||||
|
||||
'@types/three@0.186.0':
|
||||
resolution: {integrity: sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ==}
|
||||
'@tanstack/react-store@0.11.2':
|
||||
resolution: {integrity: sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@types/webxr@0.5.24':
|
||||
resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==}
|
||||
'@tanstack/router-core@1.171.33':
|
||||
resolution: {integrity: sha512-TK31LyRZuVwwmWrv3oSCBN7OuM4R1qQZePRTiexFyqQUuCo3ERHZgzArosynwZKtYlEf4qbFCvbW7UUfvGiiZw==}
|
||||
engines: {node: '>=20.19'}
|
||||
|
||||
'@tanstack/store@0.11.2':
|
||||
resolution: {integrity: sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q==}
|
||||
|
||||
'@types/react-dom@19.3.0':
|
||||
resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
|
||||
peerDependencies:
|
||||
'@types/react': ^19.3.0
|
||||
|
||||
'@types/react@19.3.0':
|
||||
resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==}
|
||||
|
||||
'@typescript/typescript-aix-ppc64@7.0.2':
|
||||
resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==}
|
||||
@@ -684,13 +713,35 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@vitejs/plugin-react@6.1.1':
|
||||
resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
peerDependencies:
|
||||
'@rolldown/plugin-babel': ^0.1.7 || ^0.2.0
|
||||
babel-plugin-react-compiler: ^1.0.0
|
||||
oxc-transform-react: ^0.145.0
|
||||
vite: ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
'@rolldown/plugin-babel':
|
||||
optional: true
|
||||
babel-plugin-react-compiler:
|
||||
optional: true
|
||||
oxc-transform-react:
|
||||
optional: true
|
||||
|
||||
blake3-wasm@2.1.5:
|
||||
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
|
||||
|
||||
cookie-es@3.1.1:
|
||||
resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==}
|
||||
|
||||
cookie@1.1.1:
|
||||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
detect-libc@2.1.2:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -712,14 +763,15 @@ packages:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
fflate@0.8.3:
|
||||
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
isbot@5.2.2:
|
||||
resolution: {integrity: sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
kleur@4.1.5:
|
||||
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -812,9 +864,6 @@ packages:
|
||||
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
|
||||
meshoptimizer@1.1.1:
|
||||
resolution: {integrity: sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==}
|
||||
|
||||
miniflare@5.20260926.0-alpha:
|
||||
resolution: {integrity: sha512-m/M3rpLnaouw6QSoKzylfw6Z1dnOwRM48idnZ7kc5ivRqBXANFQ8XqKCyQ/V+xqQ9Rg1L99j4WkZGVU9uT4L2w==}
|
||||
engines: {node: '>=22.0.0'}
|
||||
@@ -851,6 +900,15 @@ packages:
|
||||
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
react-dom@19.3.0:
|
||||
resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==}
|
||||
peerDependencies:
|
||||
react: ^19.3.0
|
||||
|
||||
react@19.3.0:
|
||||
resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
reicon@1.2.4:
|
||||
resolution: {integrity: sha512-rjTDLJIYXc4kDaw3/g2IhggGCkZHtlahdOKaWvqoo/QxsKVc3sHOM0OdNH83nZTAmvCvsWQp8wHPF9USo76TYA==}
|
||||
|
||||
@@ -859,11 +917,24 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
scheduler@0.28.0:
|
||||
resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==}
|
||||
|
||||
semver@7.8.5:
|
||||
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
seroval-plugins@1.6.8:
|
||||
resolution: {integrity: sha512-N7mWAMydj89EnYTHtRpS3LBrAz3J9O6oVrqpuXuvlAHfAUG8WEKqtbAd7SBiJNY5NfCAFhSdyAh7wI4Shjqy4g==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
seroval: ^1.0
|
||||
|
||||
seroval@1.6.8:
|
||||
resolution: {integrity: sha512-HlSgSAkTk4EqHcje1ptJjfZi1YDv5KbhVJ/d3P7T/nAXua2VmDu+AKDX5VTdFfZf48nDkWB2TKYt0DrCSa+3wg==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
sharp@0.35.4:
|
||||
resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
@@ -881,9 +952,6 @@ packages:
|
||||
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
three@0.186.1:
|
||||
resolution: {integrity: sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==}
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -903,6 +971,11 @@ packages:
|
||||
unenv@2.0.0-rc.24:
|
||||
resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
|
||||
|
||||
use-sync-external-store@1.7.0:
|
||||
resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
vite@8.3.1:
|
||||
resolution: {integrity: sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -1008,8 +1081,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.9
|
||||
|
||||
'@dimforge/rapier3d-compat@0.12.0': {}
|
||||
|
||||
'@emnapi/runtime@1.11.3':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -1281,20 +1352,40 @@ snapshots:
|
||||
|
||||
'@speed-highlight/core@1.2.24': {}
|
||||
|
||||
'@tweenjs/tween.js@23.1.3': {}
|
||||
'@tanstack/history@1.162.4': {}
|
||||
|
||||
'@types/stats.js@0.17.4': {}
|
||||
|
||||
'@types/three@0.186.0':
|
||||
'@tanstack/react-router@1.170.40(react-dom@19.3.0(react@19.3.0))(react@19.3.0)':
|
||||
dependencies:
|
||||
'@dimforge/rapier3d-compat': 0.12.0
|
||||
'@tweenjs/tween.js': 23.1.3
|
||||
'@types/stats.js': 0.17.4
|
||||
'@types/webxr': 0.5.24
|
||||
fflate: 0.8.3
|
||||
meshoptimizer: 1.1.1
|
||||
'@tanstack/history': 1.162.4
|
||||
'@tanstack/react-store': 0.11.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
|
||||
'@tanstack/router-core': 1.171.33
|
||||
isbot: 5.2.2
|
||||
react: 19.3.0
|
||||
react-dom: 19.3.0(react@19.3.0)
|
||||
|
||||
'@types/webxr@0.5.24': {}
|
||||
'@tanstack/react-store@0.11.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0)':
|
||||
dependencies:
|
||||
'@tanstack/store': 0.11.2
|
||||
react: 19.3.0
|
||||
react-dom: 19.3.0(react@19.3.0)
|
||||
use-sync-external-store: 1.7.0(react@19.3.0)
|
||||
|
||||
'@tanstack/router-core@1.171.33':
|
||||
dependencies:
|
||||
'@tanstack/history': 1.162.4
|
||||
cookie-es: 3.1.1
|
||||
seroval: 1.6.8
|
||||
seroval-plugins: 1.6.8(seroval@1.6.8)
|
||||
|
||||
'@tanstack/store@0.11.2': {}
|
||||
|
||||
'@types/react-dom@19.3.0(@types/react@19.3.0)':
|
||||
dependencies:
|
||||
'@types/react': 19.3.0
|
||||
|
||||
'@types/react@19.3.0':
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
|
||||
'@typescript/typescript-aix-ppc64@7.0.2':
|
||||
optional: true
|
||||
@@ -1356,10 +1447,19 @@ snapshots:
|
||||
'@typescript/typescript-win32-x64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@vitejs/plugin-react@6.1.1(vite@8.3.1(esbuild@0.28.1))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.3.1(esbuild@0.28.1)
|
||||
|
||||
blake3-wasm@2.1.5: {}
|
||||
|
||||
cookie-es@3.1.1: {}
|
||||
|
||||
cookie@1.1.1: {}
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
error-stack-parser-es@1.0.5: {}
|
||||
@@ -1397,14 +1497,16 @@ snapshots:
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.7
|
||||
|
||||
fflate@0.8.3: {}
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
isbot@5.2.2: {}
|
||||
|
||||
kleur@4.1.5: {}
|
||||
|
||||
lenis@1.3.26: {}
|
||||
lenis@1.3.26(react@19.3.0):
|
||||
optionalDependencies:
|
||||
react: 19.3.0
|
||||
|
||||
lightningcss-android-arm64@1.33.0:
|
||||
optional: true
|
||||
@@ -1455,8 +1557,6 @@ snapshots:
|
||||
lightningcss-win32-arm64-msvc: 1.33.0
|
||||
lightningcss-win32-x64-msvc: 1.33.0
|
||||
|
||||
meshoptimizer@1.1.1: {}
|
||||
|
||||
miniflare@5.20260926.0-alpha:
|
||||
dependencies:
|
||||
'@cspotcode/source-map-support': 0.8.1
|
||||
@@ -1492,6 +1592,13 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
react-dom@19.3.0(react@19.3.0):
|
||||
dependencies:
|
||||
react: 19.3.0
|
||||
scheduler: 0.28.0
|
||||
|
||||
react@19.3.0: {}
|
||||
|
||||
reicon@1.2.4: {}
|
||||
|
||||
rolldown@1.2.11:
|
||||
@@ -1515,8 +1622,16 @@ snapshots:
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.2.11
|
||||
'@rolldown/binding-win32-x64-msvc': 1.2.11
|
||||
|
||||
scheduler@0.28.0: {}
|
||||
|
||||
semver@7.8.5: {}
|
||||
|
||||
seroval-plugins@1.6.8(seroval@1.6.8):
|
||||
dependencies:
|
||||
seroval: 1.6.8
|
||||
|
||||
seroval@1.6.8: {}
|
||||
|
||||
sharp@0.35.4:
|
||||
dependencies:
|
||||
'@img/colour': 1.1.0
|
||||
@@ -1553,8 +1668,6 @@ snapshots:
|
||||
|
||||
supports-color@10.2.2: {}
|
||||
|
||||
three@0.186.1: {}
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.7)
|
||||
@@ -1592,6 +1705,10 @@ snapshots:
|
||||
dependencies:
|
||||
pathe: 2.0.3
|
||||
|
||||
use-sync-external-store@1.7.0(react@19.3.0):
|
||||
dependencies:
|
||||
react: 19.3.0
|
||||
|
||||
vite@8.3.1(esbuild@0.28.1):
|
||||
dependencies:
|
||||
lightningcss: 1.33.0
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Icon } from "./icons";
|
||||
import { mode, useMode } from "./theme";
|
||||
|
||||
const SPIN_AFTER = 400;
|
||||
const GIVE_UP = 20000;
|
||||
|
||||
type State = "starting" | "slow" | "ready" | "failed";
|
||||
|
||||
/** One story live from the gallery, themed with the site, no frame around it. */
|
||||
export function Live({ page, story, title }: { page: string; story: string; title: string }) {
|
||||
const theme = useMode();
|
||||
const frame = useRef<HTMLIFrameElement>(null);
|
||||
const [state, setState] = useState<State>("starting");
|
||||
const [full, setFull] = useState(false);
|
||||
const [run, setRun] = useState(0);
|
||||
const [start] = useState(mode);
|
||||
const src = (t: string) => `/gallery/?page=${page}&story=${story}&theme=${t}`;
|
||||
|
||||
useEffect(() => {
|
||||
setState("starting");
|
||||
const slow = window.setTimeout(() => setState((s) => (s === "starting" ? "slow" : s)), SPIN_AFTER);
|
||||
const failed = window.setTimeout(() => {
|
||||
setState((s) => (s === "ready" ? s : "failed"));
|
||||
console.error(`live: ${page}/${story} sent no ready in ${GIVE_UP} ms`);
|
||||
}, GIVE_UP);
|
||||
const ready = (event: MessageEvent) => {
|
||||
if (event.source !== frame.current?.contentWindow || event.data?.ely !== "ready") return;
|
||||
clearTimeout(slow);
|
||||
clearTimeout(failed);
|
||||
setState("ready");
|
||||
frame.current?.contentWindow?.postMessage({ ely: "theme", theme: mode() }, location.origin);
|
||||
};
|
||||
addEventListener("message", ready);
|
||||
return () => {
|
||||
clearTimeout(slow);
|
||||
clearTimeout(failed);
|
||||
removeEventListener("message", ready);
|
||||
};
|
||||
}, [page, story, run]);
|
||||
|
||||
useEffect(() => {
|
||||
frame.current?.contentWindow?.postMessage({ ely: "theme", theme }, location.origin);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!full) return;
|
||||
document.body.style.overflow = "hidden";
|
||||
const escape = (event: KeyboardEvent) => event.key === "Escape" && setFull(false);
|
||||
addEventListener("keydown", escape);
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
removeEventListener("keydown", escape);
|
||||
};
|
||||
}, [full]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<a className="skip-live" href="#after-live">
|
||||
Skip the live example
|
||||
</a>
|
||||
<div className={full ? "live full" : "live"}>
|
||||
<iframe key={run} ref={frame} title={`${title}, live`} src={src(start)} />
|
||||
{state !== "ready" && (
|
||||
<div className="live-state" role="status">
|
||||
{state === "slow" && <span className="spin" />}
|
||||
{state === "failed" ? (
|
||||
<>
|
||||
Couldn't start the example.
|
||||
<button className="text" onClick={() => setRun((n) => n + 1)}>
|
||||
Reload
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
"Starting…"
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="live-tools">
|
||||
<button className="tool" aria-label="Reload the example" onClick={() => setRun((n) => n + 1)}>
|
||||
<Icon name="Refresh" />
|
||||
</button>
|
||||
<a className="tool" href={src(theme)} target="_blank" rel="noopener" aria-label="Open the example alone">
|
||||
<Icon name="ArrowRightUp" />
|
||||
</a>
|
||||
<button className="tool" aria-label={full ? "Leave full screen" : "Full screen"} onClick={() => setFull(!full)}>
|
||||
<Icon name={full ? "Minimize" : "Maximize"} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span id="after-live" tabIndex={-1} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import shots from "./shots.json";
|
||||
import { useMode } from "./theme";
|
||||
|
||||
const captured = new Set<string>(shots.stories);
|
||||
const windows: Record<string, string[]> = shots.windows;
|
||||
|
||||
/** Whether the site shows this story as a native capture. */
|
||||
export const isCaptured = (page: string, story: string) => captured.has(`${page}/${story}`);
|
||||
|
||||
/** Captures are at twice the pixels, drawn at their size, in the site's mode. */
|
||||
function Shot({ name, alt }: { name: string; alt: string }) {
|
||||
const theme = useMode();
|
||||
return <img className="shot" srcSet={`/shots/${name}-${theme}.jpg 2x`} alt={alt} loading="lazy" />;
|
||||
}
|
||||
|
||||
export function Capture({ page, story, title }: { page: string; story: string; title: string }) {
|
||||
return (
|
||||
<figure className="capture">
|
||||
<Shot name={`${page}--${story}`} alt={`${title}, captured from the native gallery`} />
|
||||
<figcaption>Native capture, macOS</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
/** The windows a live story opens, captured natively. */
|
||||
export function Windows({ page, story, title }: { page: string; story: string; title: string }) {
|
||||
const names = windows[`${page}/${story}`];
|
||||
if (!names) return null;
|
||||
return (
|
||||
<figure className="capture windows">
|
||||
<div className="shots">
|
||||
{names.map((name) => (
|
||||
<Shot key={name} name={name} alt={`A window ${title} opens, captured natively`} />
|
||||
))}
|
||||
</div>
|
||||
<figcaption>The windows it opens, natively</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Link, Outlet } from "@tanstack/react-router";
|
||||
import { REPO } from "./data";
|
||||
import { Icon } from "./icons";
|
||||
import { setMode, useMode } from "./theme";
|
||||
|
||||
function Header() {
|
||||
const theme = useMode();
|
||||
const dark = theme === "dark";
|
||||
return (
|
||||
<header className="bar">
|
||||
<Link to="/" className="brand">
|
||||
Ely
|
||||
</Link>
|
||||
<nav aria-label="Site">
|
||||
<Link to="/components/" className="link" activeProps={{ "aria-current": "page" }}>
|
||||
Components
|
||||
</Link>
|
||||
</nav>
|
||||
<div className="end">
|
||||
<button className="tool" aria-label={dark ? "Light mode" : "Dark mode"} onClick={() => setMode(dark ? "light" : "dark")}>
|
||||
<Icon name={dark ? "Sun" : "Moon"} />
|
||||
</button>
|
||||
<a className="tool" href={REPO} rel="noopener" aria-label="Ely on GitHub">
|
||||
<Icon name="GitHub" />
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function Footer() {
|
||||
return (
|
||||
<footer className="foot">
|
||||
<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>Geist and Geist Mono, OFL</span>
|
||||
<span>
|
||||
Icons: <a href="https://reicon.dev" rel="noopener">Reicon</a>, MIT, after Solar Icons by 480 Design,{" "}
|
||||
<a href="https://creativecommons.org/licenses/by/4.0/" rel="noopener">CC BY 4.0</a>
|
||||
</span>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export function Shell() {
|
||||
return (
|
||||
<>
|
||||
<a className="skip" href="#main">
|
||||
Skip to content
|
||||
</a>
|
||||
<Header />
|
||||
<main id="main">
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { all, chapter, chapters, esc, href, storyCount, type Chapter, type Story } from "./data";
|
||||
import { icon } from "./icons";
|
||||
import { isCaptured, native } from "./native";
|
||||
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);
|
||||
const live = body.querySelector<HTMLElement>(".live")!;
|
||||
const shots = body.querySelector<HTMLElement>(".native")!;
|
||||
if (isCaptured(current.slug, story.slug)) return native(live, current.slug, story.slug, story.title);
|
||||
const [stopLive, stopShots] = [preview(live, current.slug, story.slug, story.title), native(shots, current.slug, story.slug, story.title)];
|
||||
return () => (stopLive(), stopShots());
|
||||
}
|
||||
|
||||
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>
|
||||
<p class="names">${story.components.map(esc).join(" · ")}</p>
|
||||
<div class="live"></div>
|
||||
<div class="native"></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 () => {};
|
||||
}
|
||||
+3
-13
@@ -20,19 +20,9 @@ export const storyCount = chapters.reduce((sum, chapter) => sum + chapter.storie
|
||||
|
||||
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 all = chapters.flatMap((chapter) => chapter.stories.map((story) => ({ chapter, story })));
|
||||
|
||||
export const REPO = "https://github.com/ZacharyZhang-NY/Ely-GPUI-Components";
|
||||
|
||||
export const pad = (n: number) => String(n).padStart(2, "0");
|
||||
@@ -1,121 +0,0 @@
|
||||
import Lenis from "lenis";
|
||||
import "lenis/dist/lenis.css";
|
||||
import { REPO, chapters, componentCount, esc, storyCount } from "./data";
|
||||
import { icon } from "./icons";
|
||||
import { app, bars, candles, chat, type Model } from "./iso/models";
|
||||
import { stage } from "./iso/stage";
|
||||
import { preview } from "./preview";
|
||||
import { onMode } from "./theme";
|
||||
|
||||
interface Show {
|
||||
glyph: "circle" | "square" | "triangle";
|
||||
name: string;
|
||||
line: string;
|
||||
page: string;
|
||||
story: string;
|
||||
title: string;
|
||||
model: () => Model;
|
||||
}
|
||||
|
||||
const SHOWS: Show[] = [
|
||||
{
|
||||
glyph: "circle",
|
||||
name: "Finance",
|
||||
line: "Candles, volume and a crosshair. Drag to pan, Cmd-scroll to zoom, switch the chart type.",
|
||||
page: "finance",
|
||||
story: "candlestickchart-ohlcchart-heikinashichart-linequotechart-volumechart-charttypeswitcher-timerangeselector-intervalselector",
|
||||
title: "CandlestickChart",
|
||||
model: candles,
|
||||
},
|
||||
{
|
||||
glyph: "square",
|
||||
name: "Charts",
|
||||
line: "A year in two series. Hover for values, drag across to zoom, press a name to hide it.",
|
||||
page: "charts",
|
||||
story: "linechart-chartaxis-chartgrid-chartlegend-charttooltip-chartcrosshair-chartannotation-chartzoom-chartexport",
|
||||
title: "LineChart",
|
||||
model: bars,
|
||||
},
|
||||
{
|
||||
glyph: "triangle",
|
||||
name: "AI chat",
|
||||
line: "Bubbles, avatars, math and code, the way a model answers.",
|
||||
page: "chat",
|
||||
story: "chatcontainer-messagelist-messagebubble-messageavatar-messageheader-messagefooter-dateseparator-scrolltobottombutton",
|
||||
title: "ChatContainer",
|
||||
model: chat,
|
||||
},
|
||||
];
|
||||
|
||||
/** A miniature that grows to a marimba, then each model beside the real thing. */
|
||||
export function home(main: HTMLElement): () => void {
|
||||
document.title = "Ely · GPUI components";
|
||||
main.innerHTML = `
|
||||
<section class="hero">
|
||||
<div class="words">
|
||||
<span class="glyphs" aria-hidden="true"><i class="circle"></i><i class="square"></i><i class="triangle"></i></span>
|
||||
<h1>A component library for GPUI.<strong>Every part runs on this page.</strong></h1>
|
||||
<p>${componentCount} components in ${chapters.length} chapters, written in Rust, compiled to WebAssembly. Point at the model. Turn the sound on.</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 class="iso-host hero-iso" data-model="app"></div>
|
||||
</section>
|
||||
${SHOWS.map(
|
||||
(s, i) => `<section class="show">
|
||||
<div class="pair">
|
||||
<header class="mark">
|
||||
<i class="${s.glyph}" aria-hidden="true"></i>
|
||||
<span class="n">${String(i + 1).padStart(2, "0")}</span>
|
||||
<h2>${esc(s.name)}</h2>
|
||||
<p>${esc(s.line)}</p>
|
||||
</header>
|
||||
<div class="iso-host" data-model="${s.page}"></div>
|
||||
</div>
|
||||
<div class="live" data-i="${i}"></div>
|
||||
</section>`,
|
||||
).join("")}
|
||||
<section class="facts">
|
||||
<div><strong>${storyCount}</strong><span>stories, each its own page</span></div>
|
||||
<div><strong>${chapters.length}</strong><span>chapters, primitives to maps</span></div>
|
||||
<div><strong>2</strong><span>modes from one palette</span></div>
|
||||
<div><strong>0</strong><span>screenshots</span></div>
|
||||
</section>`;
|
||||
|
||||
const still = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const lenis = still ? null : new Lenis({ autoRaf: true, anchors: true });
|
||||
const iso = stage(still);
|
||||
iso.add(main.querySelector<HTMLElement>(".hero-iso")!, app());
|
||||
main.querySelectorAll<HTMLElement>(".pair .iso-host").forEach((host, i) => iso.add(host, SHOWS[i].model()));
|
||||
const stopMode = onMode(() => iso.paint());
|
||||
|
||||
// A live example loads near the view and unloads far from it.
|
||||
const stops = new Map<HTMLElement, () => void>();
|
||||
const near = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
const host = e.target as HTMLElement;
|
||||
const show = SHOWS[Number(host.dataset.i)];
|
||||
if (e.isIntersecting && !stops.has(host)) {
|
||||
stops.set(host, preview(host, show.page, show.story, show.title));
|
||||
} else if (!e.isIntersecting && stops.has(host)) {
|
||||
stops.get(host)!();
|
||||
stops.delete(host);
|
||||
host.innerHTML = "";
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: "100% 0px" },
|
||||
);
|
||||
main.querySelectorAll<HTMLElement>(".live").forEach((host) => near.observe(host));
|
||||
|
||||
return () => {
|
||||
lenis?.destroy();
|
||||
near.disconnect();
|
||||
stops.forEach((stop) => stop());
|
||||
stopMode();
|
||||
iso.stop();
|
||||
};
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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 Sound from "reicon/icons/Sound";
|
||||
import Sun from "reicon/icons/Sun";
|
||||
import VolumeCross from "reicon/icons/VolumeCross";
|
||||
|
||||
const set = { ArrowLeft, ArrowRight, ArrowRightUp, ChevronRight, Maximize, Minimize, Moon, Refresh, Search, Sound, Sun, VolumeCross };
|
||||
|
||||
// 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,25 @@
|
||||
import ArrowLeft from "reicon/icons/ArrowLeft";
|
||||
import ArrowRight from "reicon/icons/ArrowRight";
|
||||
import ArrowRightUp from "reicon/icons/ArrowRightUp";
|
||||
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 Sound from "reicon/icons/Sound";
|
||||
import Sun from "reicon/icons/Sun";
|
||||
import VolumeCross from "reicon/icons/VolumeCross";
|
||||
|
||||
const set = { ArrowLeft, ArrowRight, ArrowRightUp, Maximize, Minimize, Moon, Refresh, Search, Sound, Sun, VolumeCross };
|
||||
|
||||
// simple-icons' GitHub mark (CC0); Reicon has none.
|
||||
const GITHUB =
|
||||
'<svg viewBox="0 0 24 24" width="18" height="18" style="margin:3px" fill="currentColor"><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 }: { name: IconName }) {
|
||||
const svg = name === "GitHub" ? GITHUB : set[name].toSvg({ size: 24 });
|
||||
return <span className="icon" aria-hidden="true" dangerouslySetInnerHTML={{ __html: svg }} />;
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
/** A sixteenth at 132 beats a minute. */
|
||||
export const BEAT = 60_000 / 132 / 4;
|
||||
|
||||
// C major pentatonic, C4 to E6.
|
||||
const SCALE = [0, 2, 4, 7, 9, 12, 14, 16, 19, 21, 24, 26, 28];
|
||||
const LISTENERS = new Set<(on: boolean) => void>();
|
||||
|
||||
let context: AudioContext | null = null;
|
||||
let bus: GainNode | null = null;
|
||||
let on = false;
|
||||
let last = 0;
|
||||
|
||||
export function soundOn(): boolean {
|
||||
return on;
|
||||
}
|
||||
|
||||
/** Turns sound on or off; the first turn on needs a press, as browsers ask. */
|
||||
export function setSound(next: boolean): void {
|
||||
on = next;
|
||||
if (on && !context) {
|
||||
context = new AudioContext();
|
||||
bus = context.createGain();
|
||||
bus.gain.value = 0.28;
|
||||
const squeeze = context.createDynamicsCompressor();
|
||||
bus.connect(squeeze).connect(context.destination);
|
||||
console.info("marimba: audio started");
|
||||
}
|
||||
if (on) void context!.resume();
|
||||
LISTENERS.forEach((listen) => listen(on));
|
||||
}
|
||||
|
||||
export function onSound(listen: (on: boolean) => void): () => void {
|
||||
LISTENERS.add(listen);
|
||||
return () => LISTENERS.delete(listen);
|
||||
}
|
||||
|
||||
/** Strikes the scale's `step`th note, soft or hard; strikes within 30 ms merge. */
|
||||
export function strike(step: number, velocity = 0.7): void {
|
||||
if (!on || !context || !bus) return;
|
||||
const now = context.currentTime;
|
||||
if (now - last < 0.03) return;
|
||||
last = now;
|
||||
const index = ((step % SCALE.length) + SCALE.length) % SCALE.length;
|
||||
const hz = 261.63 * 2 ** (SCALE[index] / 12);
|
||||
// A struck bar: the fundamental, a partial near four times up, a short click.
|
||||
for (const [ratio, gain, decay] of [
|
||||
[1, 1, 0.9],
|
||||
[3.93, 0.28, 0.18],
|
||||
[9.2, 0.06, 0.05],
|
||||
]) {
|
||||
const tone = context.createOscillator();
|
||||
const env = context.createGain();
|
||||
tone.type = "sine";
|
||||
tone.frequency.value = hz * ratio;
|
||||
env.gain.setValueAtTime(0, now);
|
||||
env.gain.linearRampToValueAtTime(gain * velocity, now + 0.004);
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, now + decay);
|
||||
tone.connect(env).connect(bus);
|
||||
tone.start(now);
|
||||
tone.stop(now + decay + 0.05);
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
export type Tone = "paper" | "ink" | "soft" | "accent";
|
||||
|
||||
/** A box on the table: cell, footprint, lift, height, tone, the beat it grows on. */
|
||||
export interface Block {
|
||||
x: number;
|
||||
z: number;
|
||||
w: number;
|
||||
d: number;
|
||||
y: number;
|
||||
h: number;
|
||||
tone: Tone;
|
||||
beat: number;
|
||||
}
|
||||
|
||||
export type Model = { blocks: Block[]; span: [number, number] };
|
||||
|
||||
const block = (x: number, z: number, w: number, d: number, h: number, tone: Tone, y = 0): Block => ({
|
||||
x, z, w, d, y, h, tone, beat: 0,
|
||||
});
|
||||
|
||||
/** Beats from the far corner toward the eye, one diagonal a step. */
|
||||
function sweep(blocks: Block[], pace = 1): Block[] {
|
||||
return blocks.map((b) => ({ ...b, beat: b.beat || Math.round((b.x + b.z) * pace) }));
|
||||
}
|
||||
|
||||
/** A small app on its slab: a sidebar, a chart, a chat, a switch. */
|
||||
export function app(): Model {
|
||||
const b: Block[] = [block(0, 0, 18, 12, 0.5, "paper")];
|
||||
b.push(block(0.5, 0.5, 17, 1, 0.3, "soft", 0.5));
|
||||
for (let i = 0; i < 3; i++) b.push(block(1 + i * 0.9, 0.8, 0.5, 0.4, 0.3, i === 0 ? "accent" : "ink", 0.8));
|
||||
for (let i = 0; i < 6; i++) b.push(block(0.8, 2.2 + i * 1.5, 3.4, 1, 0.3, i === 1 ? "accent" : "soft", 0.5));
|
||||
b.push(block(5, 2.2, 7.4, 1, 0.25, "soft", 0.5));
|
||||
b.push(block(5, 4, 7.4, 5.6, 0.12, "soft", 0.5));
|
||||
const bars = [0.9, 1.5, 1.2, 2.1, 1.7, 2.8, 2.3, 3.2];
|
||||
bars.forEach((h, i) => b.push(block(5.4 + i * 0.85, 7.2, 0.55, 1.2, h, i === 5 ? "accent" : "ink", 0.62)));
|
||||
b.push(block(5.4, 10.4, 6.6, 0.3, 0.15, "soft", 0.5));
|
||||
b.push(block(8.4, 10.3, 0.5, 0.5, 0.5, "accent", 0.5));
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const mine = i % 2 === 1;
|
||||
b.push(block(mine ? 14.2 : 13, 2.2 + i * 1.7, mine ? 2.8 : 3.6, 1.2, 0.5, mine ? "ink" : "soft", 0.5));
|
||||
}
|
||||
b.push(block(13, 9.6, 1.6, 0.8, 0.35, "soft", 0.5));
|
||||
b.push(block(13.9, 9.6, 0.7, 0.8, 0.7, "accent", 0.5));
|
||||
return { blocks: sweep(b, 0.9), span: [18, 12] };
|
||||
}
|
||||
|
||||
/** Twenty candles and their volume, rising in the accent, falling in ink. */
|
||||
export function candles(): Model {
|
||||
const b: Block[] = [block(0, 0, 21, 8, 0.3, "paper")];
|
||||
const days: Array<[number, number, number, number]> = [];
|
||||
let close = 0;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const open = close;
|
||||
close = open + Math.sin(i * 1.7) * 1.1 + Math.cos(i * 0.6) * 0.6 + 0.25;
|
||||
days.push([open, close, Math.min(open, close) - 0.4 - (i % 3) * 0.2, Math.max(open, close) + 0.35 + (i % 2) * 0.3]);
|
||||
}
|
||||
// Prices fill 0.9 to 4.5 above the slab.
|
||||
const floor = Math.min(...days.map((d) => d[2]));
|
||||
const range = Math.max(...days.map((d) => d[3])) - floor;
|
||||
const lift = (price: number) => 0.9 + ((price - floor) / range) * 3.6;
|
||||
days.forEach(([open, close, low, high], i) => {
|
||||
const x = 0.7 + i;
|
||||
b.push({ ...block(x + 0.33, 2.73, 0.14, 0.14, lift(high) - lift(low), "soft", lift(low)), beat: 2 + i * 2 });
|
||||
const [bottom, top] = [lift(Math.min(open, close)), lift(Math.max(open, close))];
|
||||
b.push({ ...block(x, 2.4, 0.8, 0.8, Math.max(top - bottom, 0.2), close >= open ? "accent" : "ink", bottom), beat: 3 + i * 2 });
|
||||
b.push({ ...block(x, 5, 0.8, 1.6, 0.2 + ((i * 7) % 5) * 0.22, "soft", 0.3), beat: 3 + i * 2 });
|
||||
});
|
||||
return { blocks: sweep(b), span: [21, 8] };
|
||||
}
|
||||
|
||||
/** Five series of eight months, one in the accent. */
|
||||
export function bars(): Model {
|
||||
const b: Block[] = [block(0, 0, 11, 8, 0.3, "paper")];
|
||||
for (let z = 0; z < 5; z++) {
|
||||
for (let x = 0; x < 8; x++) {
|
||||
const h = 0.4 + 2.2 * (0.5 + 0.5 * Math.sin(x * 0.7 + z * 1.1)) * (1 + z * 0.12);
|
||||
const tone: Tone = z === 2 ? "accent" : z === 4 ? "ink" : "soft";
|
||||
b.push({ ...block(1 + x * 1.2, 1 + z * 1.3, 0.8, 0.8, h, tone, 0.3), beat: x * 2 + z });
|
||||
}
|
||||
}
|
||||
return { blocks: b, span: [11, 8] };
|
||||
}
|
||||
|
||||
/** A conversation: turns left and right, and three dots still typing. */
|
||||
export function chat(): Model {
|
||||
const b: Block[] = [block(0, 0, 10, 12, 0.3, "paper")];
|
||||
const turns: Array<[boolean, number]> = [[false, 6], [true, 4.4], [false, 7.2], [true, 3.2], [false, 5.4]];
|
||||
turns.forEach(([mine, w], i) => {
|
||||
const z = 1 + i * 2;
|
||||
if (!mine) b.push({ ...block(0.8, z + 0.3, 0.8, 0.8, 0.8, "ink", 0.3), beat: i * 4 });
|
||||
b.push({ ...block(mine ? 9.2 - w : 2, z, w, 1.4, 0.45, mine ? "accent" : "soft", 0.3), beat: i * 4 + 1 });
|
||||
});
|
||||
for (let i = 0; i < 3; i++) b.push({ ...block(2.2 + i * 0.7, 10.9, 0.45, 0.45, 0.45, "ink", 0.3), beat: 21 + i });
|
||||
return { blocks: b, span: [10, 12] };
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
import * as THREE from "three";
|
||||
import { BEAT, strike } from "./marimba";
|
||||
import type { Block, Model, Tone } from "./models";
|
||||
|
||||
const GROW = 260;
|
||||
const BUMP = 420;
|
||||
|
||||
interface Scene {
|
||||
host: HTMLElement;
|
||||
model: Model;
|
||||
world: THREE.Scene;
|
||||
camera: THREE.OrthographicCamera;
|
||||
mesh: THREE.InstancedMesh;
|
||||
grid: THREE.LineSegments;
|
||||
started: number | null;
|
||||
struck: number;
|
||||
bumps: Map<number, number>;
|
||||
}
|
||||
|
||||
const box = new THREE.BoxGeometry(1, 1, 1).translate(0.5, 0.5, 0.5);
|
||||
const at = new THREE.Object3D();
|
||||
const ray = new THREE.Raycaster();
|
||||
const tones: Record<Tone, THREE.Color> = { paper: new THREE.Color(), ink: new THREE.Color(), soft: new THREE.Color(), accent: new THREE.Color() };
|
||||
const lines = new THREE.Color();
|
||||
|
||||
/** Back-out easing: a block overshoots, then settles on its height. */
|
||||
const rise = (t: number) => 1 + 2.2 * (t - 1) ** 3 + 1.2 * (t - 1) ** 2;
|
||||
|
||||
/** Isometric dioramas, one canvas over the page, each drawn in its host's box. */
|
||||
export function stage(still: boolean) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.className = "iso";
|
||||
canvas.setAttribute("aria-hidden", "true");
|
||||
document.body.append(canvas);
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||
renderer.setScissorTest(true);
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
const scenes: Scene[] = [];
|
||||
let frame = 0;
|
||||
|
||||
const paint = () => {
|
||||
const css = getComputedStyle(document.documentElement);
|
||||
for (const tone of Object.keys(tones) as Tone[]) tones[tone].set(css.getPropertyValue(`--iso-${tone}`).trim());
|
||||
lines.set(css.getPropertyValue("--line").trim());
|
||||
for (const s of scenes) {
|
||||
s.model.blocks.forEach((b, i) => s.mesh.setColorAt(i, tones[b.tone]));
|
||||
s.mesh.instanceColor!.needsUpdate = true;
|
||||
(s.grid.material as THREE.LineBasicMaterial).color.copy(lines);
|
||||
}
|
||||
};
|
||||
|
||||
const place = (s: Scene, now: number) => {
|
||||
let moving = false;
|
||||
s.model.blocks.forEach((b: Block, i) => {
|
||||
const since = s.started === null ? -1 : now - s.started - b.beat * BEAT;
|
||||
const grown = still ? 1 : since < 0 ? 0 : since >= GROW ? 1 : rise(since / GROW);
|
||||
const bump = s.bumps.get(i);
|
||||
const lift = bump === undefined ? 0 : Math.max(0, 1 - (now - bump) / BUMP);
|
||||
if (bump !== undefined && lift === 0) s.bumps.delete(i);
|
||||
moving ||= (since >= 0 && since < GROW) || lift > 0 || (s.started !== null && since < 0);
|
||||
at.position.set(b.x, b.y, b.z);
|
||||
at.scale.set(b.w, Math.max(b.h * grown * (1 + 0.5 * Math.sin(lift * Math.PI)), 0.0001), b.d);
|
||||
at.updateMatrix();
|
||||
s.mesh.setMatrixAt(i, at.matrix);
|
||||
});
|
||||
s.mesh.instanceMatrix.needsUpdate = true;
|
||||
// One note for each beat that starts a block.
|
||||
if (s.started !== null && !still) {
|
||||
const beat = Math.floor((now - s.started) / BEAT);
|
||||
for (; s.struck <= beat; s.struck++) {
|
||||
const first = s.model.blocks.findIndex((b) => b.beat === s.struck);
|
||||
if (first >= 0) strike(s.struck + Math.round(s.model.blocks[first].h));
|
||||
}
|
||||
}
|
||||
return moving;
|
||||
};
|
||||
|
||||
const draw = (now: number) => {
|
||||
frame = 0;
|
||||
const { innerWidth: w, innerHeight: h } = window;
|
||||
renderer.setSize(w, h, false);
|
||||
renderer.setScissor(0, 0, w, h);
|
||||
renderer.clear();
|
||||
let again = false;
|
||||
for (const s of scenes) {
|
||||
const r = s.host.getBoundingClientRect();
|
||||
if (r.bottom < 0 || r.top > h || r.width === 0) continue;
|
||||
again = place(s, now) || again;
|
||||
fit(s, r.width / r.height);
|
||||
renderer.setViewport(r.left, h - r.bottom, r.width, r.height);
|
||||
renderer.setScissor(r.left, h - r.bottom, r.width, r.height);
|
||||
renderer.render(s.world, s.camera);
|
||||
}
|
||||
if (again) request();
|
||||
};
|
||||
const request = () => {
|
||||
if (!frame) frame = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
const seen = new IntersectionObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
const s = scenes.find((x) => x.host === e.target);
|
||||
if (s && e.isIntersecting && s.started === null) {
|
||||
s.started = performance.now();
|
||||
console.info(`iso: ${s.host.dataset.model} grows`);
|
||||
}
|
||||
}
|
||||
request();
|
||||
}, { threshold: 0.35 });
|
||||
|
||||
addEventListener("scroll", request, { passive: true });
|
||||
addEventListener("resize", request);
|
||||
|
||||
return {
|
||||
/** Grows `model` in `host` once it shows; pointing at a block strikes it. */
|
||||
add(host: HTMLElement, model: Model) {
|
||||
const world = new THREE.Scene();
|
||||
world.add(new THREE.HemisphereLight(0xffffff, 0x9a9a9a, 1.6));
|
||||
const [w, d] = model.span;
|
||||
const sun = new THREE.DirectionalLight(0xffffff, 1.9);
|
||||
sun.position.set(w / 2 - 12, 30, d / 2 + 7);
|
||||
sun.target.position.set(w / 2, 0, d / 2);
|
||||
sun.castShadow = true;
|
||||
sun.shadow.mapSize.set(2048, 2048);
|
||||
sun.shadow.radius = 4;
|
||||
sun.shadow.bias = -0.0006;
|
||||
Object.assign(sun.shadow.camera, { left: -w, right: w, top: w, bottom: -w, near: 1, far: 90 });
|
||||
world.add(sun, sun.target);
|
||||
const mesh = new THREE.InstancedMesh(box, new THREE.MeshLambertMaterial(), model.blocks.length);
|
||||
mesh.castShadow = mesh.receiveShadow = true;
|
||||
// The table: its grid, and a floor that shows only the shadow the model casts.
|
||||
const floor = new THREE.Mesh(new THREE.PlaneGeometry(w * 3, d * 3).rotateX(-Math.PI / 2), new THREE.ShadowMaterial({ opacity: 0.12 }));
|
||||
floor.position.set(w / 2, -0.001, d / 2);
|
||||
floor.receiveShadow = true;
|
||||
const grid = new THREE.LineSegments(table(w, d), new THREE.LineBasicMaterial({ transparent: true, opacity: 0.9 }));
|
||||
world.add(mesh, floor, grid);
|
||||
const s: Scene = { host, model, world, camera: new THREE.OrthographicCamera(), mesh, grid, started: null, struck: 0, bumps: new Map() };
|
||||
scenes.push(s);
|
||||
paint();
|
||||
place(s, performance.now());
|
||||
host.addEventListener("pointermove", (e) => {
|
||||
const r = host.getBoundingClientRect();
|
||||
ray.setFromCamera(new THREE.Vector2(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1), s.camera);
|
||||
const hit = ray.intersectObject(mesh)[0];
|
||||
if (hit?.instanceId === undefined || hit.instanceId === 0 || s.bumps.has(hit.instanceId)) return;
|
||||
s.bumps.set(hit.instanceId, performance.now());
|
||||
strike(hit.instanceId + Math.round(model.blocks[hit.instanceId].h), 0.5);
|
||||
request();
|
||||
});
|
||||
host.addEventListener("click", () => {
|
||||
s.started = performance.now();
|
||||
s.struck = 0;
|
||||
request();
|
||||
});
|
||||
seen.observe(host);
|
||||
},
|
||||
paint: () => (paint(), request()),
|
||||
stop() {
|
||||
cancelAnimationFrame(frame);
|
||||
seen.disconnect();
|
||||
removeEventListener("scroll", request);
|
||||
removeEventListener("resize", request);
|
||||
for (const s of scenes) (s.mesh.material as THREE.Material).dispose();
|
||||
renderer.dispose();
|
||||
canvas.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Grid lines a cell apart around the slab, a drafting table under the model. */
|
||||
function table(w: number, d: number): THREE.BufferGeometry {
|
||||
const points: number[] = [];
|
||||
const [x0, x1, z0, z1] = [-4, w + 4, -4, d + 4];
|
||||
for (let x = x0; x <= x1; x++) points.push(x, 0, z0, x, 0, z1);
|
||||
for (let z = z0; z <= z1; z++) points.push(x0, 0, z, x1, 0, z);
|
||||
return new THREE.BufferGeometry().setAttribute("position", new THREE.Float32BufferAttribute(points, 3));
|
||||
}
|
||||
|
||||
/** A true isometric view that keeps the model's table and tallest block in the box. */
|
||||
function fit(s: Scene, aspect: number) {
|
||||
const [w, d] = s.model.span;
|
||||
const tall = Math.max(...s.model.blocks.map((b) => b.y + b.h));
|
||||
const centre = new THREE.Vector3(w / 2, tall / 3, d / 2);
|
||||
s.camera.position.copy(centre).add(new THREE.Vector3(1, 1, 1).multiplyScalar(60));
|
||||
s.camera.up.set(0, 1, 0);
|
||||
s.camera.lookAt(centre);
|
||||
// The table's diagonal and the tallest block set the half extents.
|
||||
const across = ((w + d) * Math.SQRT1_2) / 2 + 0.6;
|
||||
const high = ((w + d) * 0.41) / 2 + tall * 0.82 / 2 + 0.6;
|
||||
const half = Math.max(across / aspect, high);
|
||||
s.camera.left = -half * aspect;
|
||||
s.camera.right = half * aspect;
|
||||
s.camera.top = half;
|
||||
s.camera.bottom = -half;
|
||||
s.camera.near = 0.1;
|
||||
s.camera.far = 400;
|
||||
s.camera.updateProjectionMatrix();
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
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 { onSound, setSound, soundOn } from "./iso/marimba";
|
||||
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-sound></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();
|
||||
|
||||
const soundButton = bar.querySelector<HTMLButtonElement>("[data-sound]")!;
|
||||
const drawSound = (on: boolean) => {
|
||||
soundButton.innerHTML = icon(on ? "Sound" : "VolumeCross");
|
||||
soundButton.setAttribute("aria-label", on ? "Sound off" : "Sound on");
|
||||
soundButton.setAttribute("aria-pressed", String(on));
|
||||
};
|
||||
soundButton.addEventListener("click", () => setSound(!soundOn()));
|
||||
onSound(drawSound);
|
||||
drawSound(false);
|
||||
|
||||
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,41 @@
|
||||
import "@fontsource-variable/geist";
|
||||
import "@fontsource-variable/geist-mono";
|
||||
import { createRootRoute, createRoute, createRouter, redirect, RouterProvider } from "@tanstack/react-router";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Shell } from "./Shell";
|
||||
import { chapters } from "./data";
|
||||
import { Chapters } from "./pages/Chapters";
|
||||
import { Home } from "./pages/Home";
|
||||
import { NotFound } from "./pages/NotFound";
|
||||
import { Story } from "./pages/Story";
|
||||
import "./styles.css";
|
||||
|
||||
const root = createRootRoute({ component: Shell, notFoundComponent: () => <NotFound /> });
|
||||
const home = createRoute({ getParentRoute: () => root, path: "/", component: Home });
|
||||
const index = createRoute({ getParentRoute: () => root, path: "/components", component: Chapters });
|
||||
const chapter = createRoute({
|
||||
getParentRoute: () => root,
|
||||
path: "/components/$page",
|
||||
// A chapter opens its first story.
|
||||
beforeLoad: ({ params }) => {
|
||||
const found = chapters.find((c) => c.slug === params.page);
|
||||
if (found) throw redirect({ to: "/components/$page/$story/", params: { page: found.slug, story: found.stories[0].slug } });
|
||||
},
|
||||
component: () => <NotFound what="No chapter by that name." />,
|
||||
});
|
||||
const story = createRoute({ getParentRoute: () => root, path: "/components/$page/$story", component: Story });
|
||||
|
||||
const router = createRouter({
|
||||
routeTree: root.addChildren([home, index, chapter, story]),
|
||||
trailingSlash: "always",
|
||||
scrollRestoration: true,
|
||||
defaultViewTransition: true,
|
||||
});
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<RouterProvider router={router} />);
|
||||
@@ -1,124 +0,0 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import shots from "./shots.json";
|
||||
import { esc } from "./data";
|
||||
import { mode, onMode, type Mode } from "./theme";
|
||||
|
||||
const captured = new Set<string>(shots.stories);
|
||||
const windows: Record<string, string[]> = shots.windows;
|
||||
|
||||
/** Whether the site shows this story as a native capture. */
|
||||
export function isCaptured(page: string, story: string): boolean {
|
||||
return captured.has(`${page}/${story}`);
|
||||
}
|
||||
|
||||
const src = (name: string, theme: Mode) => `/shots/${name}-${theme}.jpg`;
|
||||
|
||||
function figure(name: string, alt: string): string {
|
||||
// Captures are at twice the pixels, drawn at their size.
|
||||
return `<img class="shot" data-shot="${name}" srcset="${src(name, mode())} 2x" alt="${esc(alt)}" loading="lazy" />`;
|
||||
}
|
||||
|
||||
/** Draws the story's capture, or the windows it opens, in the site's mode. */
|
||||
export function native(host: HTMLElement, page: string, story: string, title: string): () => void {
|
||||
const key = `${page}/${story}`;
|
||||
if (captured.has(key)) {
|
||||
host.innerHTML = `<p class="label">Native capture · macOS</p>${figure(`${page}--${story}`, `${title}, captured from the native gallery`)}`;
|
||||
} else if (windows[key]) {
|
||||
host.innerHTML = `<p class="label">The windows it opens, natively</p><div class="shots">${windows[key]
|
||||
.map((name) => figure(name, `A window ${title} opens, captured natively`))
|
||||
.join("")}</div>`;
|
||||
} else {
|
||||
return () => {};
|
||||
}
|
||||
return onMode((next) => host.querySelectorAll<HTMLImageElement>("img[data-shot]").forEach((img) => (img.srcset = `${src(img.dataset.shot!, next)} 2x`)));
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
import { chapters, pad } from "../data";
|
||||
|
||||
export function Chapters() {
|
||||
useEffect(() => {
|
||||
document.title = "Components · Ely";
|
||||
}, []);
|
||||
return (
|
||||
<section className="chapters">
|
||||
<h1>Components</h1>
|
||||
<ol>
|
||||
{chapters.map((c) => (
|
||||
<li key={c.slug}>
|
||||
<Link to="/components/$page/$story/" params={{ page: c.slug, story: c.stories[0].slug }}>
|
||||
<span className="n">{pad(c.number)}</span>
|
||||
<span className="name">{c.title}</span>
|
||||
<span className="count">{c.stories.length}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
import { chapters, componentCount } from "../data";
|
||||
|
||||
export function Home() {
|
||||
useEffect(() => {
|
||||
document.title = "Ely · GPUI components";
|
||||
}, []);
|
||||
return (
|
||||
<section className="hero">
|
||||
<h1>Components for GPUI.</h1>
|
||||
<p>
|
||||
{componentCount} components in {chapters.length} chapters, live in the browser.
|
||||
</p>
|
||||
<Link to="/components/" className="button">
|
||||
Browse
|
||||
</Link>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function NotFound({ what = "Nothing lives at this address." }: { what?: string }) {
|
||||
useEffect(() => {
|
||||
console.error(`site: ${what} (${location.pathname})`);
|
||||
document.title = "Not found · Ely";
|
||||
}, [what]);
|
||||
return (
|
||||
<section className="missing">
|
||||
<h1>Not found</h1>
|
||||
<p>{what}</p>
|
||||
<Link to="/components/" className="button">
|
||||
All components
|
||||
</Link>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Link, useParams } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
import { Live } from "../Live";
|
||||
import { Capture, Windows, isCaptured } from "../Native";
|
||||
import { all, chapters } from "../data";
|
||||
import { NotFound } from "./NotFound";
|
||||
|
||||
export function Story() {
|
||||
const { page, story } = useParams({ strict: false });
|
||||
const chapter = chapters.find((c) => c.slug === page);
|
||||
const found = chapter?.stories.find((s) => s.slug === story);
|
||||
useEffect(() => {
|
||||
if (chapter && found) document.title = `${found.title} · ${chapter.title} · Ely`;
|
||||
}, [chapter, found]);
|
||||
if (!chapter || !found) return <NotFound what={`No story at ${page}/${story}.`} />;
|
||||
const at = all.findIndex((e) => e.story === found);
|
||||
const [prev, next] = [all[at - 1], all[at + 1]];
|
||||
return (
|
||||
<article className="story">
|
||||
<Link to="/components/" className="crumb">
|
||||
{chapter.title}
|
||||
</Link>
|
||||
<h1>{found.title}</h1>
|
||||
{isCaptured(chapter.slug, found.slug) ? (
|
||||
<Capture page={chapter.slug} story={found.slug} title={found.title} />
|
||||
) : (
|
||||
<>
|
||||
<Live key={`${chapter.slug}/${found.slug}`} page={chapter.slug} story={found.slug} title={found.title} />
|
||||
<Windows page={chapter.slug} story={found.slug} title={found.title} />
|
||||
</>
|
||||
)}
|
||||
<nav className="pager" aria-label="More stories">
|
||||
{prev && (
|
||||
<Link to="/components/$page/$story/" params={{ page: prev.chapter.slug, story: prev.story.slug }}>
|
||||
{prev.story.title}
|
||||
</Link>
|
||||
)}
|
||||
{next && (
|
||||
<Link className="next" to="/components/$page/$story/" params={{ page: next.chapter.slug, story: next.story.slug }}>
|
||||
{next.story.title}
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
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 = "";
|
||||
};
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
:root {
|
||||
--bg: #fcfaf7;
|
||||
--fg: #181818;
|
||||
--muted: #6a6a6a;
|
||||
--line: #e1dfdd;
|
||||
--hover: #f2f0ed;
|
||||
--accent: #3772bb;
|
||||
--overlay: #ffffff;
|
||||
--iso-paper: #ffffff;
|
||||
--iso-soft: #e3dfda;
|
||||
--iso-ink: #2b2927;
|
||||
--iso-accent: #3772bb;
|
||||
--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;
|
||||
--iso-paper: #2e2b29;
|
||||
--iso-soft: #45413d;
|
||||
--iso-ink: #d8d4cf;
|
||||
--iso-accent: #69a1e8;
|
||||
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); }
|
||||
|
||||
/* Geometry: circle, square, triangle, the site's three marks. */
|
||||
i.circle, i.square, i.triangle { display: inline-block; width: 16px; height: 16px; flex: none; }
|
||||
i.circle { border-radius: 50%; background: var(--accent); }
|
||||
i.square { background: var(--fg); }
|
||||
i.triangle { background: var(--fg); clip-path: polygon(50% 0, 100% 100%, 0 100%); }
|
||||
|
||||
/* The dioramas' one canvas; each draws in its host's box. */
|
||||
canvas.iso { position: fixed; inset: 0; width: 100vw; height: 100vh; pointer-events: none; z-index: 5; }
|
||||
.iso-host { position: relative; cursor: crosshair; }
|
||||
|
||||
/* 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) 88%, 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); }
|
||||
.tool { width: 32px; height: 32px; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; color: var(--muted); }
|
||||
.tool:hover, .tool[aria-pressed="true"] { color: var(--fg); }
|
||||
.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 { color: var(--fg); }
|
||||
.find kbd { font: 500 12px/16px var(--mono); }
|
||||
|
||||
/* 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; }
|
||||
|
||||
/* 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 16px 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 0; min-height: 32px; font-size: 14px; line-height: 20px; color: var(--muted); }
|
||||
.side .chapter .n { font: 500 12px/16px var(--mono); width: 24px; }
|
||||
.side .chapter: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 32px; }
|
||||
.side li a { display: block; padding: 4px 0; font-size: 14px; line-height: 20px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.side li a:hover { color: var(--fg); }
|
||||
.side li a[aria-current="page"] { color: var(--fg); font-weight: 500; }
|
||||
.side .none { padding: 8px 0; 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 4px; font-size: 18px; line-height: 28px; font-weight: 600; }
|
||||
.names { margin: 0 0 32px; font: 500 12px/16px var(--mono); color: var(--muted); }
|
||||
|
||||
.stage { position: relative; height: 640px; 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: 8px; right: 8px; 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); }
|
||||
.stage.full { position: fixed; inset: 0; z-index: 40; height: auto; }
|
||||
.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); }
|
||||
|
||||
img.shot { display: block; max-width: 100%; height: auto; margin-top: 12px; }
|
||||
.native .label { margin-top: 48px; }
|
||||
.shots { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 24px; }
|
||||
.shots img.shot { max-width: min(100%, 560px); }
|
||||
.pager { display: flex; justify-content: space-between; gap: 16px; margin-top: 32px; padding-top: 16px; border-top: 1px solid var(--line); }
|
||||
.pager a { display: flex; flex-direction: column; gap: 4px; max-width: 48%; font-size: 14px; line-height: 20px; }
|
||||
.pager a:hover { color: var(--accent); }
|
||||
.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: 0 24px; border-top: 1px solid var(--line); }
|
||||
.grid a { display: flex; gap: 12px; align-items: baseline; padding: 16px 0; border-bottom: 1px solid var(--line); }
|
||||
.grid a:hover strong { color: var(--accent); }
|
||||
.grid .n { font: 500 12px/16px var(--mono); color: var(--muted); width: 24px; }
|
||||
.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 { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); align-items: center; gap: 48px; min-height: calc(100vh - 56px); padding: 48px; }
|
||||
.hero-iso { height: min(720px, calc(100vh - 152px)); }
|
||||
.words { max-width: 440px; }
|
||||
.glyphs { display: flex; gap: 8px; margin-bottom: 32px; }
|
||||
.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 32px; 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; }
|
||||
.button:hover { color: var(--accent); }
|
||||
.button.primary { background: var(--fg); color: var(--bg); }
|
||||
.button.primary:hover { color: var(--bg); opacity: 0.88; }
|
||||
|
||||
.show { padding: 96px 48px 48px; border-top: 1px solid var(--line); }
|
||||
.show .mark { display: grid; grid-template-columns: 16px 32px auto; align-items: center; column-gap: 16px; row-gap: 8px; margin-bottom: 48px; }
|
||||
.show .mark .n { font: 500 12px/16px var(--mono); color: var(--muted); }
|
||||
.show .mark h2 { margin: 0; font-size: 18px; line-height: 28px; font-weight: 600; }
|
||||
.show .mark p { grid-column: 3; margin: 0; max-width: 560px; color: var(--muted); }
|
||||
.pair { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: 48px; align-items: center; margin-bottom: 48px; }
|
||||
.show .mark { margin-bottom: 0; }
|
||||
.pair .iso-host { height: 400px; }
|
||||
.show .stage { height: 640px; }
|
||||
|
||||
.facts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 48px; padding: 96px 48px; border-top: 1px solid var(--line); }
|
||||
.facts strong { display: block; font: 500 18px/28px var(--mono); }
|
||||
.facts span { color: var(--muted); font-size: 14px; line-height: 20px; }
|
||||
|
||||
.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: 1023px) {
|
||||
.hero, .pair { grid-template-columns: 1fr; }
|
||||
.hero-iso { height: 60vh; }
|
||||
.pair .iso-host { height: 320px; }
|
||||
.facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@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, .show .stage { height: 480px; }
|
||||
.hero, .show, .facts { padding: 48px 16px; }
|
||||
.foot { padding: 24px 16px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { transition: none !important; animation: none !important; }
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
:root {
|
||||
--bg: #fcfaf7;
|
||||
--fg: #141312;
|
||||
--muted: #8a8784;
|
||||
--faint: #b3b0ac;
|
||||
--line: #e6e3df;
|
||||
--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: #f4f2f0;
|
||||
--muted: #86827e;
|
||||
--faint: #5b5854;
|
||||
--line: #262422;
|
||||
--accent: #69a1e8;
|
||||
--overlay: #1b1917;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body { margin: 0; background: var(--bg); color: var(--fg); }
|
||||
|
||||
body { font: 300 16px/26px var(--sans); -webkit-font-smoothing: antialiased; }
|
||||
|
||||
h1, h2, h3 { margin: 0; font-weight: 700; letter-spacing: -0.02em; }
|
||||
|
||||
p { margin: 0; color: var(--muted); }
|
||||
|
||||
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: 3px; border-radius: 6px; }
|
||||
|
||||
.skip { position: absolute; left: 16px; top: -64px; padding: 8px 12px; background: var(--fg); color: var(--bg); border-radius: 8px; z-index: 60; }
|
||||
.skip:focus { top: 12px; }
|
||||
|
||||
.icon { display: inline-flex; width: 24px; height: 24px; flex: none; }
|
||||
.icon svg { width: 24px; height: 24px; }
|
||||
|
||||
.tool { width: 36px; height: 36px; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; color: var(--muted); transition: color 160ms ease-out; }
|
||||
.tool:hover { color: var(--fg); }
|
||||
|
||||
.text { color: var(--fg); text-decoration: underline; text-underline-offset: 3px; margin-left: 8px; }
|
||||
|
||||
.button { display: inline-flex; align-items: center; gap: 10px; height: 48px; padding: 0 22px; border-radius: 999px; background: var(--fg); color: var(--bg); font-weight: 500; font-size: 15px; transition: opacity 160ms ease-out; }
|
||||
.button:hover { opacity: 0.86; }
|
||||
|
||||
/* Header */
|
||||
.bar { position: fixed; inset: 0 0 auto; z-index: 40; height: 64px; display: flex; align-items: center; gap: 32px; padding: 0 32px; background: color-mix(in srgb, var(--bg) 82%, transparent); backdrop-filter: blur(18px); }
|
||||
.brand { font-weight: 800; font-size: 20px; letter-spacing: -0.03em; }
|
||||
.bar nav { display: flex; gap: 24px; }
|
||||
.link { font-size: 14px; font-weight: 400; color: var(--muted); transition: color 160ms ease-out; }
|
||||
.link:hover, .link[aria-current="page"] { color: var(--fg); }
|
||||
.bar .end { margin-left: auto; display: flex; gap: 4px; }
|
||||
|
||||
main { min-height: 100vh; }
|
||||
|
||||
/* Pages */
|
||||
.hero { min-height: 100vh; display: grid; align-content: center; justify-items: start; gap: 24px; padding: 96px 64px; }
|
||||
.hero h1 { font-size: clamp(48px, 8vw, 128px); line-height: 0.95; font-weight: 800; letter-spacing: -0.045em; }
|
||||
.hero p { font-size: 20px; line-height: 30px; }
|
||||
|
||||
.chapters { padding: 160px 64px 128px; }
|
||||
.chapters h1 { font-size: clamp(40px, 6vw, 96px); letter-spacing: -0.04em; margin-bottom: 96px; }
|
||||
.chapters ol { list-style: none; margin: 0; padding: 0; }
|
||||
.chapters a { display: grid; grid-template-columns: 64px 1fr auto; align-items: baseline; padding: 20px 0; border-top: 1px solid var(--line); }
|
||||
.chapters .n, .chapters .count { font: 400 13px/20px var(--mono); color: var(--faint); }
|
||||
.chapters .name { font-size: 28px; line-height: 36px; font-weight: 700; letter-spacing: -0.02em; }
|
||||
|
||||
.story { padding: 128px 64px 96px; }
|
||||
.crumb { font-size: 14px; color: var(--muted); }
|
||||
.story h1 { font-size: clamp(32px, 4vw, 56px); line-height: 1.05; letter-spacing: -0.035em; margin: 12px 0 64px; max-width: 18ch; }
|
||||
|
||||
.live { position: relative; height: 72vh; min-height: 520px; background: var(--bg); }
|
||||
.live iframe { display: block; width: 100%; height: 100%; border: 0; background: var(--bg); }
|
||||
.live-state { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 10px; font-size: 14px; color: var(--muted); background: var(--bg); }
|
||||
.live-tools { position: absolute; top: 8px; right: 8px; display: flex; gap: 2px; opacity: 0; transition: opacity 160ms ease-out; }
|
||||
.live:hover .live-tools, .live:focus-within .live-tools { opacity: 1; }
|
||||
.live.full { position: fixed; inset: 0; z-index: 70; height: auto; }
|
||||
.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); } }
|
||||
.skip-live { position: absolute; left: -9999px; }
|
||||
.skip-live:focus { position: static; display: inline-block; margin-bottom: 12px; color: var(--accent); }
|
||||
|
||||
.capture { margin: 0; }
|
||||
.capture figcaption { margin-top: 16px; font: 400 12px/18px var(--mono); color: var(--faint); }
|
||||
img.shot { display: block; max-width: 100%; height: auto; }
|
||||
.windows { margin-top: 96px; }
|
||||
.shots { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 32px; }
|
||||
|
||||
.pager { display: flex; justify-content: space-between; gap: 24px; margin-top: 96px; font-size: 18px; font-weight: 600; }
|
||||
.pager a { color: var(--muted); transition: color 160ms ease-out; }
|
||||
.pager a:hover { color: var(--fg); }
|
||||
.pager .next { margin-left: auto; text-align: right; }
|
||||
|
||||
.missing { min-height: 70vh; display: grid; align-content: center; justify-items: start; gap: 24px; padding: 160px 64px; }
|
||||
.missing h1 { font-size: 64px; letter-spacing: -0.04em; }
|
||||
|
||||
.foot { display: flex; flex-wrap: wrap; gap: 8px 28px; padding: 48px 64px; font-size: 13px; color: var(--faint); }
|
||||
.foot a { text-decoration: underline; text-underline-offset: 3px; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.bar { padding: 0 16px; gap: 20px; }
|
||||
.hero, .chapters, .story, .missing { padding-left: 20px; padding-right: 20px; }
|
||||
.foot { padding: 32px 20px; }
|
||||
.chapters .name { font-size: 22px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { transition: none !important; animation: none !important; }
|
||||
::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; }
|
||||
}
|
||||
+13
-4
@@ -1,6 +1,8 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export type Mode = "light" | "dark";
|
||||
|
||||
const listeners = new Set<(mode: Mode) => void>();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function mode(): Mode {
|
||||
return document.documentElement.classList.contains("dark") ? "dark" : "light";
|
||||
@@ -10,11 +12,18 @@ 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));
|
||||
} catch (error) {
|
||||
console.warn("theme: could not keep the mode", error);
|
||||
}
|
||||
listeners.forEach((listen) => listen());
|
||||
}
|
||||
|
||||
export function onMode(listen: (mode: Mode) => void): () => void {
|
||||
function subscribe(listen: () => void): () => void {
|
||||
listeners.add(listen);
|
||||
return () => listeners.delete(listen);
|
||||
}
|
||||
|
||||
/** The site's mode, redrawn when it turns. */
|
||||
export function useMode(): Mode {
|
||||
return useSyncExternalStore(subscribe, mode);
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "e2e"]
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// The manifest lives beside the gallery, one folder up.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { fs: { allow: [".."] } },
|
||||
// three.js is a chunk of its own, for the home page.
|
||||
build: { chunkSizeWarningLimit: 600 },
|
||||
});
|
||||
Reference in new issue
Block a user