T48a Docs · Frame: a Docs tab, /docs/ and /docs/$page/, Markdown guides with a rail, an on-this-page list, previous and next and code blocks; full Rust samples are compiled examples shown as text

This commit is contained in:
ZacharyZhang-NY committed 2026-10-01 11:40:50 -04:00
1 parent 157176e2fb
commit fba90c22e6
12 files changed
+305 -6

No files matched your search

+1 -1
View File
@@ -17,7 +17,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- Pictures: `image` 0.25 (MIT OR Apache-2.0), the crate gpui decodes with, default features off; it turns decoded frames and rims map tiles.
- Maps: Natural Earth's 110m countries (public domain). `assets/maps/countries.geojson`, trimmed by `scripts/countries.py` to an ISO code, a name and positions to 0.01°, backs `maps::WorldMap`; the gallery's tiles are cut from the same file, z0 to z3, by `scripts/tiles.py` with Pillow.
- Web: on wasm32, `web-time` 1.1 (MIT OR Apache-2.0) gives the `Instant` gpui's executor returns, std's own natively; jiff takes `js`, rust-embed `debug-embed`, similar `wasm32_web_time`. The gallery's web entry takes `gpui_web`, `wasm-bindgen`, `js-sys` and `web-sys` as wasm-only dev-dependencies, and a Hebrew cut of Noto Sans Hebrew 2.003 (OFL, `examples/gallery/fonts`): gpui_web asks the browser for emoji and CJK alone. The `wasm-bindgen` CLI at Cargo.lock's version and binaryen's `wasm-opt` finish the bundle.
- Gallery: `examples/gallery`. Website: `frontend/` (Vite 8.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
+4
View File
@@ -87,3 +87,7 @@ codegen-units = 1
[[example]]
name = "gallery"
path = "examples/gallery/main.rs"
[[example]]
name = "docs_hello"
path = "examples/docs/hello.rs"
+16 -2
View File
@@ -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
+55
View File
@@ -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<Self>) -> 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);
});
}
+1
View File
@@ -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"
+10
View File
@@ -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
+4 -1
View File
@@ -20,9 +20,12 @@ function Header() {
<Link to="/components/" className="link" activeProps={{ "aria-current": "page" }}>
Components
</Link>
<Link to="/docs/" className="link" activeProps={{ "aria-current": "page" }}>
Docs
</Link>
</nav>
<div className="end">
<button className="tool" aria-label={sound ? "Sound off" : "Sound on"} aria-pressed={sound} onClick={() => setSound(!sound)}>
<button className="tool sound" aria-label={sound ? "Sound off" : "Sound on"} aria-pressed={sound} onClick={() => setSound(!sound)}>
<Icon name={sound ? "Sound" : "VolumeCross"} />
</button>
<button className="tool" aria-label={dark ? "Light mode" : "Dark mode"} onClick={() => setMode(dark ? "light" : "dark")}>
+67
View File
@@ -0,0 +1,67 @@
import { Marked, type Tokens } from "marked";
const sources = import.meta.glob<string>("./*.md", { query: "?raw", import: "default", eager: true });
const examples = import.meta.glob<string>("../../../examples/docs/*.rs", { query: "?raw", import: "default", eager: true });
/** The guides in reading order, by file name. */
const ORDER = ["introduction"];
export interface Guide {
/** The path segment; the introduction has none. */
slug: string;
title: string;
html: string;
/** The `##` headings, for the on-this-page list. */
toc: { id: string; text: string }[];
}
const escape = (text: string) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
const slugify = (text: string) =>
text
.toLowerCase()
.replace(/<[^>]+>/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
function read(file: string): Guide {
const source = sources[`./${file}.md`];
if (source === undefined) throw new Error(`docs: no ${file}.md`);
let title = "";
const toc: Guide["toc"] = [];
const marked = new Marked({
renderer: {
heading({ tokens, depth }: Tokens.Heading) {
const html = this.parser.parseInline(tokens);
if (depth === 1) {
title = html.replace(/<[^>]+>/g, "");
return "";
}
const id = slugify(html);
if (depth === 2) {
if (toc.some((h) => h.id === id)) throw new Error(`docs: ${file}.md repeats heading ${id}`);
toc.push({ id, text: html.replace(/<[^>]+>/g, "") });
}
return `<h${depth} id="${id}">${html}</h${depth}>`;
},
code({ text, lang }: Tokens.Code) {
const [language = "", ...rest] = (lang ?? "").split(/\s+/);
const example = rest.find((part) => part.startsWith("example="))?.slice("example=".length);
let code = text;
let caption = language;
if (example) {
const body = examples[`../../../examples/docs/${example}.rs`];
if (body === undefined) throw new Error(`docs: ${file}.md names a missing example ${example}`);
code = body.trimEnd();
caption = `examples/docs/${example}.rs · cargo run --example docs_${example}`;
}
return `<figure class="code"><figcaption>${escape(caption)}</figcaption><button class="copy" type="button">Copy</button><pre><code>${escape(code)}</code></pre></figure>`;
},
},
});
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);
+14
View File
@@ -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.
+4 -1
View File
@@ -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: () => <NotFound what="No chapter by that name." />,
});
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,
});
+81
View File
@@ -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 <NotFound what={`No guide at ${page}.`} />;
const [prev, next] = [guides[at - 1], guides[at + 1]];
// Links and copy buttons inside the rendered Markdown.
const press = (e: MouseEvent<HTMLElement>) => {
const target = e.target as HTMLElement;
const button = target.closest<HTMLButtonElement>("button.copy");
if (button) return void copy(button);
const link = target.closest<HTMLAnchorElement>("a[href^='/']");
if (!link || e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
e.preventDefault();
navigate({ to: link.getAttribute("href")! });
};
return (
<div className="docs">
<nav className="docs-rail" aria-label="Guides" data-lenis-prevent>
<ol>
{guides.map((g) => (
<li key={g.slug}>
<Link to={to(g)} aria-current={g === guide ? "page" : undefined}>
{g.title}
</Link>
</li>
))}
</ol>
</nav>
<article className="doc">
<h1>{guide.title}</h1>
<div className="prose" onClick={press} dangerouslySetInnerHTML={{ __html: guide.html }} />
<nav className="pager" aria-label="More guides">
{prev && <Link to={to(prev)}>← {prev.title}</Link>}
{next && (
<Link className="next" to={to(next)}>
{next.title} →
</Link>
)}
</nav>
</article>
{guide.toc.length > 0 && (
<nav className="doc-toc" aria-label="On this page">
<ol>
{guide.toc.map((h) => (
<li key={h.id}>
<a href={`#${h.id}`}>{h.text}</a>
</li>
))}
</ol>
</nav>
)}
</div>
);
}
+48 -1
View File
@@ -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; }
}