From fba90c22e657647138fc5f56d536077ed2fcfdad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 1 Oct 2026 11:40:50 -0400 Subject: [PATCH] =?UTF-8?q?T48a=20Docs=20=C2=B7=20Frame:=20a=20Docs=20tab,?= =?UTF-8?q?=20/docs/=20and=20/docs/$page/,=20Markdown=20guides=20with=20a?= =?UTF-8?q?=20rail,=20an=20on-this-page=20list,=20previous=20and=20next=20?= =?UTF-8?q?and=20code=20blocks;=20full=20Rust=20samples=20are=20compiled?= =?UTF-8?q?=20examples=20shown=20as=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- Cargo.toml | 4 ++ TASKS.md | 18 ++++++- examples/docs/hello.rs | 55 +++++++++++++++++++++ frontend/package.json | 1 + frontend/pnpm-lock.yaml | 10 ++++ frontend/src/Shell.tsx | 5 +- frontend/src/docs/guides.ts | 67 +++++++++++++++++++++++++ frontend/src/docs/introduction.md | 14 ++++++ frontend/src/main.tsx | 5 +- frontend/src/pages/Docs.tsx | 81 +++++++++++++++++++++++++++++++ frontend/src/styles.css | 49 ++++++++++++++++++- 12 files changed, 305 insertions(+), 6 deletions(-) create mode 100644 examples/docs/hello.rs create mode 100644 frontend/src/docs/guides.ts create mode 100644 frontend/src/docs/introduction.md create mode 100644 frontend/src/pages/Docs.tsx diff --git a/AGENTS.md b/AGENTS.md index ce75c42..3e4900d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Pictures: `image` 0.25 (MIT OR Apache-2.0), the crate gpui decodes with, default features off; it turns decoded frames and rims map tiles. - Maps: Natural Earth's 110m countries (public domain). `assets/maps/countries.geojson`, trimmed by `scripts/countries.py` to an ISO code, a name and positions to 0.01°, backs `maps::WorldMap`; the gallery's tiles are cut from the same file, z0 to z3, by `scripts/tiles.py` with Pillow. - Web: on wasm32, `web-time` 1.1 (MIT OR Apache-2.0) gives the `Instant` gpui's executor returns, std's own natively; jiff takes `js`, rust-embed `debug-embed`, similar `wasm32_web_time`. The gallery's web entry takes `gpui_web`, `wasm-bindgen`, `js-sys` and `web-sys` as wasm-only dev-dependencies, and a Hebrew cut of Noto Sans Hebrew 2.003 (OFL, `examples/gallery/fonts`): gpui_web asks the browser for emoji and CJK alone. The `wasm-bindgen` CLI at Cargo.lock's version and binaryen's `wasm-opt` finish the bundle. -- Gallery: `examples/gallery`. Website: `frontend/` (Vite 8.3, pnpm 11, TypeScript, React 19.3, TanStack Router 1.170 in code-based routes, Lenis 1.3), in serro.ai's language: heavy titles over thin faint text, hairlines, one accent, full-screen sections, isometric line miniatures on a canvas. Pages name a story by its title's first part (`src/data.ts`'s `name`); scroll bars stay hidden; layouts step at 1024 and 768. Each story runs live from the gallery built for wasm32 through gpui_web, in a same-origin iframe, or, where a browser cannot run it, as a native capture. 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), in serro.ai's language: heavy titles over thin faint text, hairlines, one accent, full-screen sections, isometric line miniatures on a canvas. Pages name a story by its title's first part (`src/data.ts`'s `name`); scroll bars stay hidden; layouts step at 1024 and 768. Each story runs live from the gallery built for wasm32 through gpui_web, in a same-origin iframe, or, where a browser cannot run it, as a native capture. The Docs tab is Markdown in `frontend/src/docs/`, read in its `ORDER` and rendered at build by `marked` 18 (MIT); a fenced block tagged `example=NAME` shows `examples/docs/NAME.rs`, an example `scripts/check.sh` compiles as `docs_NAME`, so every full Rust sample on the site builds. Below 360px the header drops the sound switch. Geist and Geist Mono (OFL, `@fontsource-variable`), Reicon icons (MIT, based on Solar Icons, CC BY 4.0, credited in the footer), simple-icons' GitHub mark (CC0). It deploys to Cloudflare Workers static assets through `npx wrangler` (4.143). ## Commands diff --git a/Cargo.toml b/Cargo.toml index c801b67..f015351 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,3 +87,7 @@ codegen-units = 1 [[example]] name = "gallery" path = "examples/gallery/main.rs" + +[[example]] +name = "docs_hello" +path = "examples/docs/hello.rs" diff --git a/TASKS.md b/TASKS.md index a8d8506..a914af9 100644 --- a/TASKS.md +++ b/TASKS.md @@ -162,8 +162,22 @@ Per-component lines live in `tasks/`. Tags there: - [x] T45e Site · Local: the gallery bundle built for localhost and the site served for the owner to see; not published - [x] T46 E2E: Playwright against the built site - [x] T46a Site · Owner's notes: the city title whole, header icons on one line, less space above a story, no visible scroll bars, each story named by its first component, every page laid out at phone, tablet and desktop widths -- [ ] T47 Ship: GitHub repo (public), CI, Cloudflare deploy through `npx wrangler` -- [ ] T48 Acceptance: live URL and MVP checklist +- [x] T47 Ship: GitHub repo (public), CI, Cloudflare deploy through `npx wrangler` +- [ ] T48 Docs: a Docs tab after gpui-kit.com/docs, a rail of guides, an on-this-page list, previous and next; every claim checked against the code, every full Rust sample an example `scripts/check.sh` compiles and the page imports as text +- [ ] T48a Docs · Frame: the Docs tab, `/docs/` and `/docs/$page/`, Markdown pages, the rail, the on-this-page list, previous and next, code blocks, light and dark, phone to desktop +- [ ] T48b Docs · Introduction: what Ely is, the order to read, what is stable, a first example, license +- [ ] T48c Docs · Installation: Rust, the git dependencies, gpui_platform's fonts, runtime shaders, the gallery as a check, what `init` refuses +- [ ] T48d Docs · Getting started: an app from `init` to a window under a root `FocusScope`, with a Button +- [ ] T48e Docs · Theme: modes, tokens through `cx.theme()`, high contrast, color-blind safe charts, an owner's palette +- [ ] T48f Docs · Focus and keyboard: Tab and Shift-Tab, the root scope, tab stops, the focus ring, overlays that take and give back focus +- [ ] T48g Docs · Motion: durations, reduced motion, springs, motion from a change +- [ ] T48h Docs · Icons, fonts and assets: `Assets`, `IconName`, adding icons, the embedded fonts, file icons +- [ ] T48i Docs · Overlays: what draws over the page, dialogs, popovers, toasts +- [ ] T48j Docs · Internationalization: the `I18n` global, catalogs, right-to-left +- [ ] T48k Docs · WebAssembly: the web gallery build, its URL, the host protocol, what stays native +- [ ] T48l Docs · Testing: the `test-support` feature, frames, clocks and reduced motion in tests +- [ ] T48m Docs · E2E: the Docs routes, rail, list, previous and next, and code blocks under Playwright +- [ ] T49 Acceptance: live URL and MVP checklist ## T00 detail diff --git a/examples/docs/hello.rs b/examples/docs/hello.rs new file mode 100644 index 0000000..cdaafba --- /dev/null +++ b/examples/docs/hello.rs @@ -0,0 +1,55 @@ +use ely_gpui_component::{Assets, buttons::Button, primitives::FocusScope, theme::ActiveTheme}; +use gpui::{ + App, AppContext, Context, FocusHandle, IntoElement, ParentElement, Render, Styled, Window, + WindowOptions, +}; + +struct Hello { + focus: FocusHandle, + presses: usize, +} + +impl Render for Hello { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = &cx.theme().colors; + let this = cx.entity(); + FocusScope::new(&self.focus) + .root() + .size_full() + .flex() + .flex_col() + .items_center() + .justify_center() + .gap_4() + .bg(colors.bg) + .text_color(colors.fg) + .child(format!("Pressed {} times", self.presses)) + .child( + Button::new("press", "Press") + .primary() + .on_click(move |_, _, cx| { + this.update(cx, |hello, cx| { + hello.presses += 1; + cx.notify(); + }); + }), + ) + } +} + +fn main() { + gpui_platform::application() + .with_assets(Assets) + .run(|cx: &mut App| { + ely_gpui_component::init(cx); + cx.open_window(WindowOptions::default(), |window, cx| { + cx.new(|cx| { + let focus = cx.focus_handle(); + window.focus(&focus, cx); + Hello { focus, presses: 0 } + }) + }) + .expect("window failed to open"); + cx.activate(true); + }); +} diff --git a/frontend/package.json b/frontend/package.json index 46e9d28..8ac7423 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,6 +27,7 @@ "@fontsource-variable/geist-mono": "5.3.0", "@tanstack/react-router": "1.170.40", "lenis": "1.3.26", + "marked": "18.0.14", "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 fea3f33..5ca7417 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: lenis: specifier: 1.3.26 version: 1.3.26(react@19.3.0) + marked: + specifier: 18.0.14 + version: 18.0.14 react: specifier: 19.3.0 version: 19.3.0 @@ -870,6 +873,11 @@ packages: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} + marked@18.0.14: + resolution: {integrity: sha512-mBHK6FBHuBAlhgRe88w9F0O1AbwwXJUcQibUbC/QcdTbVGAD7aWza+xt3N6oT/jCZx3/OMeS+8rnuiHZcQ9s7A==} + engines: {node: '>= 20'} + hasBin: true + miniflare@5.20260926.0-alpha: resolution: {integrity: sha512-m/M3rpLnaouw6QSoKzylfw6Z1dnOwRM48idnZ7kc5ivRqBXANFQ8XqKCyQ/V+xqQ9Rg1L99j4WkZGVU9uT4L2w==} engines: {node: '>=22.0.0'} @@ -1570,6 +1578,8 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0 + marked@18.0.14: {} + miniflare@5.20260926.0-alpha(@types/node@24.19.0): dependencies: '@cspotcode/source-map-support': 0.8.1 diff --git a/frontend/src/Shell.tsx b/frontend/src/Shell.tsx index dc0470a..3756973 100644 --- a/frontend/src/Shell.tsx +++ b/frontend/src/Shell.tsx @@ -20,9 +20,12 @@ function Header() { Components + + Docs +
-
${escape(code)}
`; + }, + }, + }); + const html = marked.parse(source, { async: false }); + if (!title) throw new Error(`docs: ${file}.md has no # title`); + return { slug: file === "introduction" ? "" : file, title, html, toc }; +} + +export const guides: Guide[] = ORDER.map(read); diff --git a/frontend/src/docs/introduction.md b/frontend/src/docs/introduction.md new file mode 100644 index 0000000..a327427 --- /dev/null +++ b/frontend/src/docs/introduction.md @@ -0,0 +1,14 @@ +# Introduction + +Ely is a component library for [GPUI](https://www.gpui.rs), the Rust UI framework behind Zed. Every component draws in light and dark, and every one runs live on the [components page](/components/). + +## A first window + +The example opens a window with one counter and one Button. `cargo run --example docs_hello` runs it from a clone of the repository. + +```rust example=hello +``` + +## License + +MIT or Apache-2.0, at your option. diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index ee8062c..c430b59 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client"; import { Shell } from "./Shell"; import { chapters } from "./data"; import { Chapters } from "./pages/Chapters"; +import { Docs } from "./pages/Docs"; import { Home } from "./pages/Home"; import { NotFound } from "./pages/NotFound"; import { Story } from "./pages/Story"; @@ -24,9 +25,11 @@ const chapter = createRoute({ component: () => , }); const story = createRoute({ getParentRoute: () => root, path: "/components/$page/$story", component: Story }); +const docs = createRoute({ getParentRoute: () => root, path: "/docs", component: Docs }); +const doc = createRoute({ getParentRoute: () => root, path: "/docs/$page", component: Docs }); const router = createRouter({ - routeTree: root.addChildren([home, index, chapter, story]), + routeTree: root.addChildren([home, index, chapter, story, docs, doc]), trailingSlash: "always", scrollRestoration: true, }); diff --git a/frontend/src/pages/Docs.tsx b/frontend/src/pages/Docs.tsx new file mode 100644 index 0000000..8ecb8fa --- /dev/null +++ b/frontend/src/pages/Docs.tsx @@ -0,0 +1,81 @@ +import { Link, useNavigate, useParams } from "@tanstack/react-router"; +import { useEffect, type MouseEvent } from "react"; +import { guides, type Guide } from "../docs/guides"; +import { NotFound } from "./NotFound"; + +const to = (guide: Guide) => (guide.slug ? `/docs/${guide.slug}/` : "/docs/"); + +async function copy(button: HTMLButtonElement) { + const code = button.parentElement?.querySelector("code")?.textContent; + if (code == null) throw new Error("docs: a copy button without code"); + try { + await navigator.clipboard.writeText(code); + button.textContent = "Copied"; + } catch (error) { + console.error("docs: copy failed", error); + button.textContent = "Copy failed"; + } + setTimeout(() => (button.textContent = "Copy"), 1600); +} + +export function Docs() { + const { page } = useParams({ strict: false }); + const navigate = useNavigate(); + const at = guides.findIndex((g) => g.slug === (page ?? "")); + const guide = guides[at]; + useEffect(() => { + if (guide) document.title = `${guide.title} · Docs · Ely`; + }, [guide]); + if (!guide) return ; + const [prev, next] = [guides[at - 1], guides[at + 1]]; + + // Links and copy buttons inside the rendered Markdown. + const press = (e: MouseEvent) => { + const target = e.target as HTMLElement; + const button = target.closest("button.copy"); + if (button) return void copy(button); + const link = target.closest("a[href^='/']"); + if (!link || e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return; + e.preventDefault(); + navigate({ to: link.getAttribute("href")! }); + }; + + return ( +
+ +
+

{guide.title}

+
+ +
+ {guide.toc.length > 0 && ( + + )} +
+ ); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 7544262..65730bf 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -9,6 +9,7 @@ --iso-line: #1a1918; --iso-side: #f3f0ec; --iso-shade: #e9e5df; + --code: #f3f0ec; --sans: "Geist Variable", system-ui, sans-serif; --mono: "Geist Mono Variable", ui-monospace, monospace; color-scheme: light; @@ -25,6 +26,7 @@ --iso-line: #d9d5d0; --iso-side: #1a1816; --iso-shade: #201e1c; + --code: #1b1917; color-scheme: dark; } @@ -162,13 +164,51 @@ img.shot { display: block; max-width: 100%; height: auto; } .pager a:hover { color: var(--fg); } .pager .next { margin-left: auto; text-align: right; } +.docs { display: grid; grid-template-columns: 200px minmax(0, 720px) 200px; justify-content: center; gap: 64px; padding: 0 64px; } +.docs-rail, .doc-toc { position: sticky; top: 64px; height: calc(100vh - 64px); overflow-y: auto; padding: 96px 0 64px; } +.docs-rail ol, .doc-toc ol { list-style: none; margin: 0; padding: 0; } +.docs-rail li { padding: 5px 0; font-size: 15px; } +.doc-toc li { padding: 4px 0; font-size: 13px; line-height: 20px; } +.docs-rail a, .doc-toc a { color: var(--muted); transition: color 160ms ease-out; } +.doc-toc a { color: var(--faint); } +.docs-rail a:hover, .doc-toc a:hover, .docs-rail a[aria-current="page"] { color: var(--fg); } +.docs-rail a[aria-current="page"] { font-weight: 500; } +.doc { padding: 96px 0 128px; min-width: 0; } +.doc h1 { font-size: clamp(36px, 4vw, 56px); line-height: 1.05; font-weight: 800; letter-spacing: -0.035em; margin-bottom: 40px; } +.prose { font-size: 17px; line-height: 30px; } +.prose p, .prose li { color: color-mix(in srgb, var(--fg) 82%, var(--bg)); } +.prose p { margin: 0 0 20px; } +.prose h2 { font-size: 28px; line-height: 36px; font-weight: 700; letter-spacing: -0.02em; margin: 64px 0 16px; scroll-margin-top: 96px; } +.prose h3 { font-size: 20px; line-height: 28px; font-weight: 600; margin: 40px 0 12px; scroll-margin-top: 96px; } +.prose a { color: var(--fg); text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--line); } +.prose a:hover { text-decoration-color: var(--fg); } +.prose ul, .prose ol { margin: 0 0 20px; padding-left: 22px; } +.prose code { font: 400 0.86em/1 var(--mono); background: var(--code); padding: 3px 6px; border-radius: 6px; } +.prose table { width: 100%; border-collapse: collapse; margin: 8px 0 28px; font-size: 15px; line-height: 24px; } +.prose th, .prose td { text-align: left; vertical-align: top; padding: 12px 16px 12px 0; border-top: 1px solid var(--line); } +.prose th { font-weight: 600; } +.prose td { color: color-mix(in srgb, var(--fg) 82%, var(--bg)); } +.code { position: relative; margin: 28px 0; background: var(--code); border-radius: 12px; } +.code figcaption { padding: 14px 88px 0 20px; font: 400 12px/18px var(--mono); color: var(--faint); overflow-wrap: anywhere; } +.code pre { margin: 0; padding: 14px 20px 20px; overflow-x: auto; } +.code code { display: block; font: 400 13.5px/22px var(--mono); background: none; padding: 0; color: var(--fg); } +.code .copy { position: absolute; top: 10px; right: 12px; padding: 4px 8px; font-size: 12px; color: var(--muted); transition: color 160ms ease-out; } +.code .copy:hover { color: var(--fg); } + .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: 1279px) { + .docs { grid-template-columns: 200px minmax(0, 720px); } + .doc-toc { display: none; } +} + @media (max-width: 1023px) { + .docs { grid-template-columns: minmax(0, 1fr); } + .docs-rail { display: none; } .reader { grid-template-columns: minmax(0, 1fr); } .side { display: none; } .feature, .feature:nth-of-type(odd), .city { grid-template-columns: minmax(0, 1fr); grid-template-rows: none; gap: 32px; } @@ -182,7 +222,7 @@ img.shot { display: block; max-width: 100%; height: auto; } .bar { padding: 0 16px; gap: 12px; } .bar .end { gap: 0; } .hero, .chapters, .missing { padding-left: 20px; padding-right: 20px; } - .reader { padding: 0 20px; } + .reader, .docs { padding: 0 20px; } .chapters li { grid-template-columns: 40px minmax(0, 1fr); } .chapters .titles { grid-column: 2; margin-top: 8px; } .foot { padding: 32px 20px; } @@ -199,6 +239,13 @@ img.shot { display: block; max-width: 100%; height: auto; } .close { padding: 112px 20px; } } +@media (max-width: 359px) { + .bar { padding: 0 12px; gap: 10px; } + .bar nav { gap: 14px; } + .bar .tool { width: 32px; } + .bar .sound { display: none; } +} + @media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }