T45c Site · Home: full screen stops the glide at once, a starting gallery leaves the host's focus alone, a focused frame stays mounted

This commit is contained in:
ZacharyZhang-NY committed 2026-10-01 08:38:05 -04:00
1 parent d7dbcfbb18
commit 9ea811d4e8
7 files changed
+55 -17

No files matched your search

+1
View File
@@ -224,6 +224,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- A wall clock reads `Timestamp::now` and wakes its view at each whole second through a keyed task, only while the owner passes no moment; a stopwatch reads the executor's clock, so tests advance it (`misc::Clock`, `misc::Stopwatch`).
- A number shown to so many significant digits goes through `typography::format::significant`, which rounds through Rust's exponent form, so no binary noise shows at any size (`misc::Calculator`, `misc::UnitConverter`).
- A component that reads the host's frames reads one at a time off the main thread and passes over frames that come meanwhile; when a read ends it redraws, so the newest frame is read next (`misc::QrCodeScanner`).
- Embedded, the web gallery ignores its own `focus()` calls until a press or key reaches it, so a frame that starts never takes the host page's focus; the site keeps a focused frame mounted (`frontend/src/pages/Home.tsx`'s `Near`).
- A native view laid over the window takes AppKit's first responder on a press, and gpui's view never asks for it back, so typed text would go to the native view for good. The component hands the keyboard back on every press gpui gets and when the view leaves (`misc::WebView`, through wry's `focus_parent`); `Step::NativeKey` proves it, since gpui's own dispatch skips AppKit.
- A map keeps places in `f64`: at zoom 19 the world is 134 million pixels across. Its wheel pans and Command or Control with the wheel zooms about the pointer, as the canvas's does. Its tiles stay in gpui's asset cache, as every `img()` picture does. Its popup is an overlay that is not modal: it takes focus when it shows, and the frame it stops showing, by any way out, hands focus back. Its layers, lines and areas unwrap across the date line and repeat on every world in view. Clustered pins gather by squares of the world, not of the view, so a pan never regroups them; a zoom may, so it hands a focused pin's or cluster's focus to the map.
+5 -1
View File
@@ -157,7 +157,7 @@ Per-component lines live in `tasks/`. Tags there:
- [ ] 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
- [x] 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
- [x] 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
- [x] 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
@@ -494,3 +494,7 @@ Per-component lines live in `tasks/`. Tags there:
| T45a | 3 | PASS | |
| T45b | 1 | FAIL | diagonal cells painted over the cells before them; the pointer struck cells not yet grown; reduced motion read once; replay by pointer only; the header ran 2px past 280 |
| T45b | 2 | PASS | |
| T45c | 1 | FAIL | a glide outlived its route; a wheel closed full screen; skip links shared one id |
| T45c | 2 | FAIL | full screen opened mid-glide unmounted as the glide ran on |
| T45c | 3 | FAIL | the same, with Enter 30 ms into the glide: Lenis stopped a frame late |
| T45c | fix | — | after the cap: full screen stops Lenis at once, the gallery frame no longer takes host focus at start, a focused frame stays mounted; round 3's own repro passes 20 of 20 at Enter delays 0 to 250 ms |
+5 -5
View File
@@ -18,6 +18,11 @@
// Embedded, gpui's hidden field waits for a press or key.
if (window.parent !== window) {
let touched = false;
// Until then its focus calls leave the host's focus alone.
const focus = HTMLElement.prototype.focus;
HTMLElement.prototype.focus = function (options) {
if (touched) focus.call(this, options);
};
const touch = () => {
if (touched) return;
touched = true;
@@ -25,11 +30,6 @@
};
addEventListener("pointerdown", touch, true);
addEventListener("keydown", touch, true);
document.addEventListener("focusin", (event) => {
if (touched) return;
event.target.blur();
window.parent.focus();
}, true);
}
</script>
<script type="module">
+6 -2
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Icon } from "./icons";
import { hold } from "./scroll";
import { mode, useMode } from "./theme";
const SPIN_AFTER = 400;
@@ -45,13 +46,16 @@ export function Live({ page, story, title }: { page: string; story: string; titl
frame.current?.contentWindow?.postMessage({ ely: "theme", theme }, location.origin);
}, [theme]);
useEffect(() => {
// Before paint, so no glide frame lands after the press.
useLayoutEffect(() => {
if (!full) return;
document.documentElement.style.overflow = "hidden";
hold(true);
const escape = (event: KeyboardEvent) => event.key === "Escape" && setFull(false);
addEventListener("keydown", escape);
return () => {
document.documentElement.style.overflow = "";
hold(false);
removeEventListener("keydown", escape);
};
}, [full]);
+3 -5
View File
@@ -1,10 +1,9 @@
import { Link, Outlet, useRouter } from "@tanstack/react-router";
import Lenis from "lenis";
import "lenis/dist/lenis.css";
import { useEffect } from "react";
import { REPO } from "./data";
import { Icon } from "./icons";
import { useStill } from "./iso/Iso";
import { smooth } from "./scroll";
import { setSound, useSound } from "./iso/marimba";
import { setMode, useMode } from "./theme";
@@ -61,13 +60,12 @@ export function Shell() {
const router = useRouter();
useEffect(() => {
if (still) return;
// It halts while the page's overflow is hidden, as full screen sets.
const lenis = new Lenis({ autoRaf: true, autoToggle: true });
const { lenis, end } = smooth();
// A glide in flight must not outlive its page.
const off = router.subscribe("onBeforeNavigate", () => lenis.scrollTo(lenis.animatedScroll, { immediate: true, force: true }));
return () => {
off();
lenis.destroy();
end();
};
}, [still, router]);
return (
+17 -4
View File
@@ -21,14 +21,27 @@ const FEATURES = [
return { ...f, name: story.title };
});
/** Mounts its children only while near the view. */
/** Mounts its children while near the view or holding focus. */
function Near({ className, children }: { className: string; children: ReactNode }) {
const box = useRef<HTMLDivElement>(null);
const [near, setNear] = useState(false);
useEffect(() => {
const seen = new IntersectionObserver(([e]) => setNear(e.isIntersecting), { rootMargin: "100% 0px" });
seen.observe(box.current!);
return () => seen.disconnect();
const el = box.current!;
let seen = false;
const view = new IntersectionObserver(
([e]) => {
seen = e.isIntersecting;
setNear(seen || el.contains(document.activeElement));
},
{ rootMargin: "100% 0px" },
);
const left = (e: FocusEvent) => setNear(seen || el.contains(e.relatedTarget as Node | null));
view.observe(el);
el.addEventListener("focusout", left);
return () => {
view.disconnect();
el.removeEventListener("focusout", left);
};
}, []);
return (
<div ref={box} className={className}>
+18
View File
@@ -0,0 +1,18 @@
import Lenis from "lenis";
import "lenis/dist/lenis.css";
let lenis: Lenis | null = null;
/** Smooth scroll for the page; the returned call ends it. */
export function smooth(): { lenis: Lenis; end: () => void } {
if (lenis) throw new Error("scroll: smooth scroll already runs");
const own = new Lenis({ autoRaf: true });
lenis = own;
return { lenis: own, end: () => (own.destroy(), (lenis = null)) };
}
/** Holds the page still at once, a glide in flight included. */
export function hold(still: boolean): void {
if (still) lenis?.stop();
else lenis?.start();
}