diff --git a/.gitignore b/.gitignore index 87939b3..60643d5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ /frontend/dist/ .DS_Store +/frontend-reference/ diff --git a/AGENTS.md b/AGENTS.md index 203dd55..c95bccf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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=
scripts/web.sh ` builds the gallery for the browser, profile `web`, and fails past Cloudflare's 25 MiB. It opens at `?page=&story=&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. diff --git a/TASKS.md b/TASKS.md index 4086e37..c3b865f 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 diff --git a/frontend/index.html b/frontend/index.html index 972e1cc..7044307 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -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> diff --git a/frontend/package.json b/frontend/package.json index 06f45db..c4101b6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 0d9fce8..6275621 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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 diff --git a/frontend/src/Live.tsx b/frontend/src/Live.tsx new file mode 100644 index 0000000..e9645a6 --- /dev/null +++ b/frontend/src/Live.tsx @@ -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} /> + </> + ); +} diff --git a/frontend/src/Native.tsx b/frontend/src/Native.tsx new file mode 100644 index 0000000..28e00a2 --- /dev/null +++ b/frontend/src/Native.tsx @@ -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> + ); +} diff --git a/frontend/src/Shell.tsx b/frontend/src/Shell.tsx new file mode 100644 index 0000000..08885e1 --- /dev/null +++ b/frontend/src/Shell.tsx @@ -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 /> + </> + ); +} diff --git a/frontend/src/components.ts b/frontend/src/components.ts deleted file mode 100644 index be64fa2..0000000 --- a/frontend/src/components.ts +++ /dev/null @@ -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 () => {}; -} diff --git a/frontend/src/data.ts b/frontend/src/data.ts index 7f51fdf..79e4ced 100644 --- a/frontend/src/data.ts +++ b/frontend/src/data.ts @@ -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"); diff --git a/frontend/src/home.ts b/frontend/src/home.ts deleted file mode 100644 index 2c970fd..0000000 --- a/frontend/src/home.ts +++ /dev/null @@ -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(); - }; -} diff --git a/frontend/src/icons.ts b/frontend/src/icons.ts deleted file mode 100644 index 27298a5..0000000 --- a/frontend/src/icons.ts +++ /dev/null @@ -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>`; -} diff --git a/frontend/src/icons.tsx b/frontend/src/icons.tsx new file mode 100644 index 0000000..6fa6ace --- /dev/null +++ b/frontend/src/icons.tsx @@ -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 }} />; +} diff --git a/frontend/src/iso/marimba.ts b/frontend/src/iso/marimba.ts deleted file mode 100644 index 64b7ae4..0000000 --- a/frontend/src/iso/marimba.ts +++ /dev/null @@ -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); - } -} diff --git a/frontend/src/iso/models.ts b/frontend/src/iso/models.ts deleted file mode 100644 index 7c9345f..0000000 --- a/frontend/src/iso/models.ts +++ /dev/null @@ -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] }; -} diff --git a/frontend/src/iso/stage.ts b/frontend/src/iso/stage.ts deleted file mode 100644 index 7231d8f..0000000 --- a/frontend/src/iso/stage.ts +++ /dev/null @@ -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(); -} diff --git a/frontend/src/main.ts b/frontend/src/main.ts deleted file mode 100644 index 473a60a..0000000 --- a/frontend/src/main.ts +++ /dev/null @@ -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(); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..a52d731 --- /dev/null +++ b/frontend/src/main.tsx @@ -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} />); diff --git a/frontend/src/menu.ts b/frontend/src/menu.ts deleted file mode 100644 index 478d2d1..0000000 --- a/frontend/src/menu.ts +++ /dev/null @@ -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); - }); -} diff --git a/frontend/src/native.ts b/frontend/src/native.ts deleted file mode 100644 index b3a710f..0000000 --- a/frontend/src/native.ts +++ /dev/null @@ -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`))); -} diff --git a/frontend/src/pages/Chapters.tsx b/frontend/src/pages/Chapters.tsx new file mode 100644 index 0000000..b594588 --- /dev/null +++ b/frontend/src/pages/Chapters.tsx @@ -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> + ); +} diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx new file mode 100644 index 0000000..0e5816a --- /dev/null +++ b/frontend/src/pages/Home.tsx @@ -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> + ); +} diff --git a/frontend/src/pages/NotFound.tsx b/frontend/src/pages/NotFound.tsx new file mode 100644 index 0000000..b87287f --- /dev/null +++ b/frontend/src/pages/NotFound.tsx @@ -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> + ); +} diff --git a/frontend/src/pages/Story.tsx b/frontend/src/pages/Story.tsx new file mode 100644 index 0000000..ebe1153 --- /dev/null +++ b/frontend/src/pages/Story.tsx @@ -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> + ); +} diff --git a/frontend/src/preview.ts b/frontend/src/preview.ts deleted file mode 100644 index 8a0f5c5..0000000 --- a/frontend/src/preview.ts +++ /dev/null @@ -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 = ""; - }; -} diff --git a/frontend/src/search.ts b/frontend/src/search.ts deleted file mode 100644 index 2f19793..0000000 --- a/frontend/src/search.ts +++ /dev/null @@ -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; -} diff --git a/frontend/src/style.css b/frontend/src/style.css deleted file mode 100644 index a0693db..0000000 --- a/frontend/src/style.css +++ /dev/null @@ -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; } -} diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..0d60008 --- /dev/null +++ b/frontend/src/styles.css @@ -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; } +} diff --git a/frontend/src/theme.ts b/frontend/src/theme.ts index e3954cc..f6ab753 100644 --- a/frontend/src/theme.ts +++ b/frontend/src/theme.ts @@ -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); +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index c84f3eb..2a6521d 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -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"] } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 5f48a92..023d9ee 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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 }, });