Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
3cee702
docs(handoff): the worker-script rule, and the one stale load after a…
PSheon Sep 23, 2026
471fdad
fix(ui): the outline button lost its edge on the light theme
PSheon Sep 23, 2026
c603c75
chore(lint): do not lint a neighbouring session's worktree
PSheon Sep 23, 2026
f9b12e7
fix(theme): the browser bar followed the OS, not the theme the reader…
PSheon Sep 23, 2026
80618ce
fix(layout): wide figures were cut off between 1280 and 1304
PSheon Sep 23, 2026
51e326c
fix(layout): the table of contents now appears at 1024, not 1312
PSheon Sep 23, 2026
771de2e
fix(search): keyboard hints for keyboards, and a way out with a thumb
PSheon Sep 23, 2026
efb0025
fix(home): the hero box now follows the station that is showing
PSheon Sep 23, 2026
df1096d
fix(nav): contain overscroll in the phone drawer
PSheon Sep 23, 2026
0937b63
feat(index): the tag filter now lives in the URL
PSheon Sep 23, 2026
7dd6479
fix(index): #tags in a row are now the filter they look like
PSheon Sep 23, 2026
7a076c0
fix(404): somewhere to go, not just a way back
PSheon Sep 23, 2026
72f68b1
fix(a11y): copying code now says what happened, and survives being re…
PSheon Sep 23, 2026
4df7754
fix(home): even columns for the hero readouts
PSheon Sep 23, 2026
2ada635
fix(home): the samples row and the eraser stay on one line
PSheon Sep 23, 2026
a55cc9e
refactor(home): drop framer-motion, transition the height in CSS
PSheon Sep 23, 2026
9764fea
feat(dev): a bench for the hero's think station, four candidates side…
PSheon Sep 23, 2026
e4c4eee
feat(dev): bench all four hero stations, and give them one reset control
PSheon Sep 23, 2026
d199653
fix(home): one height for the four stations again, and think stays si…
PSheon Sep 23, 2026
7021600
fix(home): a drawn cover no longer sits on a black rectangle in the l…
PSheon Sep 23, 2026
f4213ca
fix(home): the flock gets six-tenths of its row, and every reset says…
PSheon Sep 23, 2026
fe29ba3
feat(theme): fade the colours instead of cutting to them
PSheon Sep 23, 2026
8ff5942
fix(labs): every "start over" button in the site carries its icon
PSheon Sep 23, 2026
099ff2a
fix(labs): one button height across every figure, and the bench comes…
PSheon Sep 23, 2026
35b219e
fix(labs): one word for "start over", in both languages
PSheon Sep 23, 2026
39fd3e0
docs(handoff): what the UI/UX batch changed, and what not to undo
PSheon Sep 23, 2026
1b2016e
fix(ui): what CI caught in the production build
PSheon Sep 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,6 @@ scripts/**/data/

# PyTorch checkpoints of the VLA article: regenerable (scripts/train-vla/pipeline.sh), 2 MB each
/scripts/train-vla/runs/

# Another session's checkout, made by `git worktree` and never ours to commit.
.claude/worktrees/
11 changes: 6 additions & 5 deletions app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ export function generateStaticParams() {
// never our own 404. Unknown values are rendered on demand instead and end in notFound() in the page: for a
// locale, a slug or a tag alike. Known pages are still prerendered (generateStaticParams).

// One colour, not a prefers-color-scheme pair: the theme is a class next-themes puts on <html> and the default is
// dark whatever the OS says, so this is the right answer for the first paint. ThemeColor in components/theme-provider
// keeps it on whatever the reader actually chose after that.
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: dark)", color: "#070918" },
{ media: "(prefers-color-scheme: light)", color: "#fbfbfe" },
],
themeColor: "#070918",
};

/**
Expand Down Expand Up @@ -67,7 +67,8 @@ export default async function LocaleLayout({ children, params }: LayoutProps<"/[
return (
<html lang={htmlLang[locale]} data-scroll-behavior="smooth" className={`${fontVariables} antialiased`} suppressHydrationWarning>
<body id="top" className="flex min-h-dvh flex-col">
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
{/* No disableTransitionOnChange: the colours are meant to fade, and components/theme-provider scopes that to the moment of the change. */}
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
<a
href="#content"
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-50 focus:rounded-md focus:bg-primary focus:px-3 focus:py-2 focus:text-primary-foreground"
Expand Down
13 changes: 11 additions & 2 deletions app/[locale]/not-found.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { NotFoundBody } from "@/components/site/not-found-body";
import { getDictionary } from "@/lib/i18n";
import { getAllPosts } from "@/lib/content/posts";
import { type Locale, getDictionary } from "@/lib/i18n";

/** Title and number only: enough to recognise an article, and nothing else to ship. */
const latest = (locale: Locale) => getAllPosts(locale).slice(0, 5).map((p) => ({ slug: p.slug, no: p.no, title: p.title }));

// not-found is given no params. The body reads the language from the URL instead (see NotFoundBody).
export default function NotFound() {
return <NotFoundBody zh={getDictionary("zh").notFound} en={getDictionary("en").notFound} />;
return (
<NotFoundBody
zh={{ ...getDictionary("zh").notFound, posts: latest("zh") }}
en={{ ...getDictionary("en").notFound, posts: latest("en") }}
/>
);
}
19 changes: 13 additions & 6 deletions app/[locale]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,13 @@ export default async function HomePage({ params }: PageProps<"/[locale]">) {
{t.hero.ctaSecondary}
</Link>
</div>
{/* Three numbers lettered like an instrument's readouts; counted from the posts, not typed in. */}
<dl className="order-5 flex gap-6 sm:gap-8 border-t border-rule pt-5 lg:mt-10 lg:max-w-md">
{/* Three numbers lettered like an instrument's readouts; counted from the posts, not typed in. The English
labels are long enough to wrap, so they get even columns and a balanced break rather than one word
stranded on a line of its own. */}
<dl className="order-5 grid grid-cols-3 gap-5 border-t border-rule pt-5 sm:gap-8 lg:mt-10 lg:max-w-md">
{readouts.map((r, i) => (
<div key={r.label} className="flex flex-col-reverse justify-end gap-1">
<dt className="label">{r.label}</dt>
<div key={r.label} className="flex min-w-0 flex-col-reverse justify-end gap-1">
<dt className="label text-pretty">{r.label}</dt>
<dd className="font-mono text-2xl leading-none tabular" style={{ color: RAIL_COLORS[i === 2 ? 3 : i] }}>
<NumberTicker value={r.value} pad={r.pad} />
</dd>
Expand Down Expand Up @@ -129,8 +131,13 @@ export default async function HomePage({ params }: PageProps<"/[locale]">) {
<ArrowRight className="size-4 transition-transform group-hover:translate-x-1" aria-hidden />
</p>
</div>
{/* The stage is dark in both themes, like the article's own: yellow points vanish on white. */}
<div className="dark relative m-3 flex items-center overflow-hidden rounded-md border-border bg-[#070918] dark:m-0 dark:rounded-none dark:border-t md:dark:border-t-0 md:dark:border-l">
{/*
A LIVE preview keeps a dark stage in both themes, like the article it comes from — article 007's
yellow points vanish on white. A drawn cover is made of theme tokens and belongs on the page's own
panel: forcing the dark stage under it put a black rectangle in the middle of the light theme for
every article that has no live preview, which is most of them.
*/}
<div className={`relative m-3 flex items-center overflow-hidden rounded-md border-border dark:m-0 dark:rounded-none dark:border-t md:dark:border-t-0 md:dark:border-l ${hasPreview(latest.slug) ? "dark bg-[#070918]" : "bg-panel"}`}>
<div className="dot-grid absolute inset-0 opacity-60" aria-hidden />
<div className="relative w-full p-4">
{hasPreview(latest.slug) ? <PostPreview slug={latest.slug} /> : <PostCover slug={latest.slug} no={latest.no} />}
Expand Down
10 changes: 5 additions & 5 deletions app/[locale]/posts/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,16 +89,16 @@ export default async function PostPage({ params }: PageProps<"/[locale]/posts/[s
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }} />

<article
className="mx-auto w-full max-w-7xl px-5 pt-10 sm:px-8 lg:pt-16 xl:grid xl:grid-cols-[13rem_minmax(0,42.5rem)_17rem] xl:gap-x-12"
className="mx-auto w-full max-w-7xl px-5 pt-10 sm:px-8 lg:grid lg:justify-center lg:grid-cols-[13rem_minmax(0,42.5rem)] lg:gap-x-12 lg:pt-16 rail:grid-cols-[13rem_minmax(0,42.5rem)_17rem]"
style={{ "--margin-w": "17rem", "--margin-gap": "3rem" } as React.CSSProperties}
>
<aside className="hidden xl:block">
<aside className="hidden lg:block">
<div className="sticky top-24 max-h-[calc(100dvh-8rem)] overflow-y-auto pb-8">
<Toc items={toc} label={t.post.toc} />
</div>
</aside>

<div className="mx-auto min-w-0 max-w-[42.5rem] xl:col-span-2 xl:mx-0 xl:max-w-none">
<div className="mx-auto min-w-0 max-w-[42.5rem] lg:mx-0 rail:col-span-2 rail:max-w-none">
{post.draft && (
// Only ever rendered by `next dev`: a production build has no drafts. To the author, in the page's language.
<p role="note" data-testid="draft-banner" className="mb-6 max-w-[48rem] rounded-md border border-signal-2 bg-signal-2/10 px-4 py-3 font-sans text-sm leading-relaxed">
Expand All @@ -118,7 +118,7 @@ export default async function PostPage({ params }: PageProps<"/[locale]/posts/[s
</p>
{/* Same name as the title in the post index: the browser morphs one into the other. */}
<ViewTransition name={`post-title-${slug}`} share="title-morph" default="none">
<h1 lang={post.isFallback ? htmlLang[post.locale] : undefined} className="mt-5 font-heading text-[clamp(2.125rem,5vw,3.25rem)] leading-[1.12] font-semibold tracking-tight text-balance [&:lang(zh)]:tracking-normal">
<h1 lang={post.isFallback ? htmlLang[post.locale] : undefined} className="mt-5 font-heading text-[clamp(2.125rem,4.2vw,3.25rem)] leading-[1.12] font-semibold tracking-tight text-balance [&:lang(zh)]:tracking-normal">
{post.title}
</h1>
</ViewTransition>
Expand Down Expand Up @@ -162,7 +162,7 @@ export default async function PostPage({ params }: PageProps<"/[locale]/posts/[s
)}

{/* Sticks just under the site header (h-14) for the whole article. */}
<div className="sticky top-14 z-30 mt-6 xl:hidden">
<div className="sticky top-14 z-30 mt-6 lg:hidden">
<TocDisclosure items={toc} label={t.post.toc} />
</div>

Expand Down
31 changes: 31 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ body:has([data-expanded="true"]) .back-to-top { display: none; }
}

@theme inline {
/*
* The article's three-column layout needs 13rem of rail + 3rem + 42.5rem of body + 3rem + 17rem of margin, plus
* 2rem of page padding on each side: 80.5rem before a scrollbar. Tailwind's `xl` is 80rem, which is 8px short —
* at exactly 1280 the wide figures ran past the viewport and `main`'s overflow-x-clip silently cut their right
* border off, with no scrollbar to show it. This breakpoint is that sum, rounded up past a scrollbar.
*/
--breakpoint-rail: 82rem;
--color-background: var(--background);
--color-foreground: var(--foreground);
/* Latin from web fonts; Chinese from whatever the system ships (Apple → Android/Linux → Windows). */
Expand Down Expand Up @@ -720,6 +727,30 @@ html:not(.dark) {
}
}

/*
* Changing the theme repaints every colour on the page at once. Left alone that is a hard cut; for the third of a
* second around the switch, every element is allowed to interpolate its colours instead. The class is added by the
* control that changes the theme and taken off again when the fade is over, so nothing carries a transition it did
* not ask for the rest of the time — and a reader who asked for less motion never gets the class at all.
*
* Canvases are left out. A canvas paints itself, so fading its CSS `color` gains nothing — and the 3-D figures read
* that very property to know what ink to draw in (followInk, content/posts/slam-2d/components/stage3d.ts). Mid-fade
* they would read a colour on its way somewhere and keep it for good.
*/
html.theme-fade,
html.theme-fade *:not(canvas),
html.theme-fade *::before,
html.theme-fade *::after {
transition:
background-color 260ms ease,
border-color 260ms ease,
color 260ms ease,
fill 260ms ease,
stroke 260ms ease,
box-shadow 260ms ease,
opacity 260ms ease !important;
}

/*
* Page transitions (React <ViewTransition> → the browser's View Transitions API).
* The routed content moves; header and footer are not part of it.
Expand Down
29 changes: 22 additions & 7 deletions components/mdx/code-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,41 @@ import { useLocaleLabels } from "@/components/lab/use-locale-labels";

export function CodeBlock(props: ComponentProps<"pre">) {
const ref = useRef<HTMLPreElement>(null);
const [copied, setCopied] = useState(false);
const t = useLocaleLabels({ copy: "複製程式碼", copied: "已複製" }, { copy: "Copy code", copied: "Copied" });
const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
const t = useLocaleLabels(
{ copy: "複製程式碼", copied: "已複製", failed: "複製失敗,請自己選取" },
{ copy: "Copy code", copied: "Copied", failed: "Could not copy — select it yourself" },
);

/*
* The clipboard can refuse: an insecure origin has no `navigator.clipboard` at all, and a browser may deny the
* permission. Unhandled, that was an uncaught rejection and a reader left pressing a button that did nothing.
* Either way the outcome is spoken as well as drawn — the icon alone says nothing to a screen reader.
*/
const copy = async () => {
await navigator.clipboard.writeText(ref.current?.textContent ?? "");
setCopied(true);
setTimeout(() => setCopied(false), 1600);
try {
await navigator.clipboard.writeText(ref.current?.textContent ?? "");
setState("copied");
} catch {
setState("failed");
}
setTimeout(() => setState("idle"), 2400);
};

const label = state === "copied" ? t.copied : state === "failed" ? t.failed : t.copy;

return (
<div className="group/code relative">
<pre ref={ref} {...props} />
<button
type="button"
onClick={copy}
aria-label={copied ? t.copied : t.copy}
aria-label={label}
className="absolute right-2 top-2 grid size-7 place-items-center rounded-md border border-border bg-background/80 text-muted-foreground transition-opacity before:absolute before:-inset-[9px] hover:text-foreground focus-visible:opacity-100 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover/code:opacity-100"
>
{copied ? <Check className="size-3.5 text-signal" /> : <Copy className="size-3.5" />}
{state === "copied" ? <Check className="size-3.5 text-signal" /> : <Copy className={state === "failed" ? "size-3.5 text-signal-2" : "size-3.5"} />}
</button>
<p className="sr-only" role="status" aria-live="polite">{state === "idle" ? "" : label}</p>
</div>
);
}
4 changes: 2 additions & 2 deletions components/mdx/figure.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ export type FigureSize = "inline" | "wide" | "full";
/** Width classes shared by figures and instruments. `wide` reaches into the sidenote margin. */
export const figureSize: Record<FigureSize, string> = {
inline: "",
wide: "xl:w-[calc(100%+var(--margin-w)+var(--margin-gap))]",
full: "-mx-5 w-[calc(100%+2.5rem)] sm:mx-0 sm:w-full xl:w-[calc(100%+var(--margin-w)+var(--margin-gap))]",
wide: "rail:w-[calc(100%+var(--margin-w)+var(--margin-gap))]",
full: "-mx-5 w-[calc(100%+2.5rem)] sm:mx-0 sm:w-full rail:w-[calc(100%+var(--margin-w)+var(--margin-gap))]",
};

interface Props {
Expand Down
6 changes: 3 additions & 3 deletions components/mdx/sidenote.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function Sidenote({ children }: { children: ReactNode }) {
aria-controls={id}
onClick={() => setOpen((v) => !v)}
// The number itself is about 7×12 px. The invisible ::before (::after is the number, see globals.css) gives a finger 25×26 px without moving a glyph.
className="sidenote-ref relative mx-0.5 rounded-sm align-super font-mono text-[0.65em] leading-none text-signal before:absolute before:-inset-x-[9px] before:-inset-y-[7px] before:content-[''] xl:pointer-events-none"
className="sidenote-ref relative mx-0.5 rounded-sm align-super font-mono text-[0.65em] leading-none text-signal before:absolute before:-inset-x-[9px] before:-inset-y-[7px] before:content-[''] rail:pointer-events-none"
>
<span className="sr-only">{noteWord} </span>
</button>
Expand All @@ -31,8 +31,8 @@ export function Sidenote({ children }: { children: ReactNode }) {
"sidenote relative font-sans text-sm leading-relaxed text-muted-foreground",
"my-3 border-l-2 border-signal/50 pl-3",
open ? "block" : "hidden",
"xl:float-right xl:clear-right xl:my-0 xl:mb-4 xl:block xl:border-l-0 xl:pl-0",
"xl:mr-[calc(-1*(var(--margin-w)+var(--margin-gap)))] xl:w-(--margin-w)",
"rail:float-right rail:clear-right rail:my-0 rail:mb-4 rail:block rail:border-l-0 rail:pl-0",
"rail:mr-[calc(-1*(var(--margin-w)+var(--margin-gap)))] rail:w-(--margin-w)",
)}
>
{children}
Expand Down
64 changes: 49 additions & 15 deletions components/site/hero-stations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import dynamic from "next/dynamic";
import Link from "next/link";
import { useId } from "react";
import { useEffect, useId, useRef, useState } from "react";
import { CornerMarks } from "@/components/lab/corner-marks";
import { ErrorBoundary } from "@/components/lab/error-boundary";
import { cn } from "@/lib/utils";
Expand Down Expand Up @@ -32,20 +32,47 @@ interface Props {
hint: string;
t: {
think: { steps: string; input: string; output: string; attention: string; again: string };
generate: { note: string };
act: { generation: string; alive: string; best: string };
generate: { note: string; again: string };
act: { generation: string; alive: string; best: string; again: string };
};
}

/**
* The hero as four stations of the same rail that runs under it: see, think, generate, act, each the live model of
* one article. The classifier is what the page opens on and the only one loaded with it; the others arrive when
* their tab is first chosen. Its panel stays mounted (invisible) underneath the others, so the box keeps one height
* whatever is showing and nothing on the page moves.
* their tab is first chosen, and it stays mounted (invisible) underneath them so its model and whatever the reader
* drew survive a trip through the other three.
*
* The box used to take its height from the classifier whatever was showing. Three of the four fill that height —
* they are built to fill the box they are given, and two of them need a definite height to size a canvas against —
* but `think` sizes itself from its own content, and on a 390 phone that left 163 px of empty panel under a small
* attention map. So the box follows the station that has a height of its own, and animates between the two.
*/
/*
* One height for all four, and it is the classifier's.
*
* Two of the stations size their canvas against the box (`container-type: size`), so given no height they collapse
* — 55 px for generate, 69 px for act, measured on /zh/dev/hero. That rules out letting each station be as tall as
* its own content, which is why the box follows the one station that has a height of its own to give.
*/
export function HeroStations({ stations, label, hint, t }: Props) {
const active = useStation(), setActive = setStation;
const seeRef = useRef<HTMLDivElement>(null);
const [seeHeight, setSeeHeight] = useState<number>();
const id = useId(), current = stations.find((s) => s.key === active) ?? stations[0];

// The classifier is always in flow (it is only invisible), so its height is known at every width.
useEffect(() => {
const element = seeRef.current;
if (!element) return;
const measure = () => setSeeHeight(element.offsetHeight);
const observer = new ResizeObserver(measure);
observer.observe(element);
measure();
return () => observer.disconnect();
}, []);

const height = seeHeight;
const move = (from: number, by: number) => {
const next = stations[(from + by + stations.length) % stations.length];
setActive(next.key);
Expand Down Expand Up @@ -99,18 +126,25 @@ export function HeroStations({ stations, label, hint, t }: Props) {
</div>
</div>
</div>
<div id={`${id}-panel`} role="tabpanel" aria-labelledby={`${id}-${active}`} className="dot-grid relative p-4 font-sans sm:p-5">
<div id={`${id}-panel`} role="tabpanel" aria-labelledby={`${id}-${active}`} className="dot-grid p-4 font-sans sm:p-5">
<ErrorBoundary fallback={<p className="py-10 text-center text-sm text-muted-foreground">This instrument hit an error. The rest of the page is unaffected.</p>}>
<div className={cn(active !== "see" && "invisible")} inert={active !== "see"} data-lab>
<HeroInstrumentLazy hint={hint} />
</div>
{active !== "see" && (
<div className="absolute inset-4 sm:inset-5">
{active === "think" && <Think t={t.think} />}
{active === "generate" && <Generate t={t.generate} />}
{active === "act" && <Act t={t.act} />}
{/* A plain CSS transition on an explicit height: the site installs no animation library (DESIGN §7),
and one number moving between two known values does not need one. */}
<div
className="relative overflow-hidden transition-[height] duration-300 ease-out motion-reduce:transition-none"
style={{ height: height ?? "auto" }}
>
<div ref={seeRef} className={cn(active !== "see" && "invisible")} inert={active !== "see"} data-lab>
<HeroInstrumentLazy hint={hint} />
</div>
)}
{active !== "see" && (
<div className="absolute inset-0">
{active === "think" && <Think t={t.think} />}
{active === "generate" && <Generate t={t.generate} />}
{active === "act" && <Act t={t.act} />}
</div>
)}
</div>
</ErrorBoundary>
</div>
</div>
Expand Down
Loading
Loading