-
+ {/* Six-four, not half and half: the flock is the thing to look at, and three short numbers do not need an
+ equal share. It is what closes the 73 px of empty panel this station had at 1152 and up. */}
+
{/* As large as fits both ways, at the world's own shape. */}
-
+
{([[t.generation, seen.generation], [t.alive, seen.alive], [t.best, seen.best]] as const).map(([label, value], i) => (
{label}
{value}
))}
+ { setSeen({ generation: 1, alive: 50, best: 0 }); setRun((n) => n + 1); }} testId="hero-act-again" />
diff --git a/components/site/hero/generate.tsx b/components/site/hero/generate.tsx
index 08a022c..05d5e3c 100644
--- a/components/site/hero/generate.tsx
+++ b/components/site/hero/generate.tsx
@@ -1,16 +1,23 @@
"use client";
+import { useState } from "react";
import DiffusionPreview from "@/components/site/diffusion-preview";
+import { StationReset } from "./reset";
/** "Generate": article 007's apple, pushed through its forward process and played backwards. No model runs here. */
-export default function HeroGenerate({ t }: { t: { note: string } }) {
+export default function HeroGenerate({ t }: { t: { note: string; again: string } }) {
+ // Remounting the preview is the restart: it owns its own clock, and nothing else here has state.
+ const [run, setRun] = useState(0);
return (
{/* The picture is 8:5 and the box is whatever the classifier's panel is: as large as fits both ways, never stretched. */}
-
+
-
{t.note}
+
+ {t.note}
+ setRun((n) => n + 1)} testId="hero-generate-again" />
+
);
}
diff --git a/components/site/hero/reset.tsx b/components/site/hero/reset.tsx
new file mode 100644
index 0000000..92dec07
--- /dev/null
+++ b/components/site/hero/reset.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import { RotateCcw } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+/**
+ * One reset control, the same in every station and the same as the classifier's eraser: a ghost button with an
+ * icon and a word.
+ *
+ * They had grown apart — an eraser with a label on the classifier, a dotted-underline link on "think" that only
+ * appeared once training had finished, and no way at all to start the other two over.
+ *
+ * The word always shows, so the rows that hold one are allowed to wrap rather than squeeze it out.
+ */
+export function StationReset({ label, onClick, testId }: { label: string; onClick: () => void; testId?: string }) {
+ return (
+
+
+ {label}
+
+ );
+}
diff --git a/components/site/hero/think.tsx b/components/site/hero/think.tsx
index 70117a0..6e5b56f 100644
--- a/components/site/hero/think.tsx
+++ b/components/site/hero/think.tsx
@@ -5,6 +5,7 @@ import { HeatCanvas } from "@/components/lab/heat-canvas";
import { runWhenSeen } from "@/components/lab/run-when-seen";
import { useReducedMotion } from "@/components/lab/use-reduced-motion";
import { Trainer } from "@/content/posts/transformer-from-scratch/components/task";
+import { StationReset } from "./reset";
import { mulberry32 } from "@/lib/ml";
const PROBLEM = [3, 1, 4, 1, 5, 9], WANT = [...PROBLEM].reverse();
@@ -67,7 +68,7 @@ export default function HeroThink({ t }: { t: { steps: string; input: string; ou
const box = "grid aspect-square w-full place-items-center rounded-sm border bg-background text-[clamp(0.75rem,4.2cqw,1.25rem)] leading-none";
const row = "grid max-w-[12.5rem] grid-cols-6 gap-1";
return (
-
+
{/* Input digits along the top, the answer down the side: a trained model lights the anti-diagonal. */}
@@ -92,10 +93,10 @@ export default function HeroThink({ t }: { t: { steps: string; input: string; ou
})}
-
+
{t.steps}
{view.steps.toLocaleString()}
- {view.done && setRun((r) => r + 1)} className="label tap cursor-pointer underline decoration-dotted underline-offset-4 hover:text-foreground">{t.again} }
+ setRun((r) => r + 1)} testId="hero-think-again" />
diff --git a/components/site/mobile-drawer.tsx b/components/site/mobile-drawer.tsx
index 50364af..f9f9656 100644
--- a/components/site/mobile-drawer.tsx
+++ b/components/site/mobile-drawer.tsx
@@ -38,8 +38,10 @@ export default function MobileDrawer({ locale, links, t, open, onOpenChange }: D
const all = [{ href: home, label: t.nav.home }, ...links];
return (
+ // overscroll-contain: the page behind is already locked while this is open, so nothing leaks today — but a
+ // scroll that reaches the end of the drawer should stop there whatever the surroundings do later.
-
+
diff --git a/components/site/not-found-body.tsx b/components/site/not-found-body.tsx
index ee9bfaf..fcf5089 100644
--- a/components/site/not-found-body.tsx
+++ b/components/site/not-found-body.tsx
@@ -3,8 +3,10 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { buttonVariants } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
-type Strings = { title: string; body: string; home: string };
+interface Entry { slug: string; no: number; title: string }
+type Strings = { title: string; body: string; home: string; browse: string; latest: string; search: string; posts: Entry[] };
/**
* The 404, in the reader's language when the URL says which one that is. `not-found.tsx` is given no route params,
@@ -21,18 +23,43 @@ export function NotFoundBody({ zh, en }: { zh: Strings; en: Strings }) {
<>
{t.title}
{t.body}
- {t.home}
+
+ {t.home}
+ {t.browse}
+ {t.search}
+
>
) : (
<>
{zh.title} / {en.title}
{zh.body} {en.body}
- {zh.home}
- {en.home}
+ {zh.home}
+ {en.home}
>
)}
+
+ {/*
+ A dead link is exactly when a reader needs somewhere to go, and the page used to end here with a single
+ button and 250 px of nothing under it. The five newest articles are the cheapest useful answer.
+ */}
+
+
{t.latest}
+
+ {t.posts.map((post) => (
+
+
+ № {String(post.no).padStart(3, "0")}
+ {post.title}
+
+
+ ))}
+
+
);
}
diff --git a/components/site/post-bento.tsx b/components/site/post-bento.tsx
index 70a773c..6dd80f8 100644
--- a/components/site/post-bento.tsx
+++ b/components/site/post-bento.tsx
@@ -1,9 +1,10 @@
"use client";
import { TagFilter } from "./tag-filter";
+import { useTagFilter } from "./use-tag-filter";
import { htmlLang } from "@/lib/i18n/config";
import Link from "next/link";
-import { ViewTransition, useState } from "react";
+import { ViewTransition } from "react";
import { CornerMarks } from "@/components/lab/corner-marks";
import type { Locale } from "@/lib/i18n";
import { cn } from "@/lib/utils";
@@ -37,7 +38,7 @@ function span(i: number, count: number): string {
/** The home page's index as a bento grid: the most recent articles as tiles with their drawings, the newest one the largest. */
export function PostBento({ locale, rows, tags, labels, limit = 6 }: Props) {
- const [tag, setTag] = useState
(null);
+ const [tag, setTag] = useTagFilter();
const counts: Record = {}; for (const r of rows) for (const name of r.tags) counts[name] = (counts[name] ?? 0) + 1;
const visible = (tag ? rows.filter((r) => r.tags.includes(tag)) : rows).slice(0, limit);
@@ -54,8 +55,9 @@ export function PostBento({ locale, rows, tags, labels, limit = 6 }: Props) {
return (
{big && }
-
- {row.title}
+
+ {row.title}
+
{/* Every tile says what the article is about while tiles are stacked; on the desktop grid only the roomy ones do. */}
{row.description}
{row.interactive && }
- {row.tags.map((name) => #{name} )}
+ {row.tags.map((name) => (
+ setTag(name === tag ? null : name)}
+ aria-pressed={name === tag}
+ className="tap relative z-10 inline-flex min-h-6 cursor-pointer items-center rounded-sm decoration-signal/50 underline-offset-4 hover:text-foreground hover:underline aria-pressed:text-signal"
+ >
+ #{name}
+
+ ))}
{row.langNote && {row.langNote} }
-
+