diff --git a/src/modules/markdown/components/callout.tsx b/src/modules/markdown/components/callout.tsx
index 6907d85..1a96e0d 100644
--- a/src/modules/markdown/components/callout.tsx
+++ b/src/modules/markdown/components/callout.tsx
@@ -5,7 +5,7 @@ import {
OctagonAlertIcon,
TriangleAlertIcon,
} from 'lucide-react';
-import type { ReactNode } from 'react';
+import * as React from 'react';
/**
* The five GitHub alert types, each mapped to its icon and visible label.
@@ -31,7 +31,7 @@ type AlertType = keyof typeof ALERTS;
* @param props.type - One of the five alert types; supplied by `remark-alert`.
* @param props.children - The alert body, already stripped of its `[!TYPE]` marker.
*/
-export function Callout({ type, children }: { type: AlertType; children: ReactNode }) {
+export function Callout({ type, children }: { type: AlertType; children: React.ReactNode }) {
const { icon: Icon, label } = ALERTS[type];
return (
diff --git a/src/modules/markdown/components/code-copy-button.tsx b/src/modules/markdown/components/code-copy-button.tsx
index 407588b..9bf3dea 100644
--- a/src/modules/markdown/components/code-copy-button.tsx
+++ b/src/modules/markdown/components/code-copy-button.tsx
@@ -1,8 +1,7 @@
'use client';
import { CheckIcon, CopyIcon } from 'lucide-react';
-import type { MouseEvent } from 'react';
-import { useRef, useState } from 'react';
+import * as React from 'react';
import { Button } from '#/ui/components/core/button';
@@ -17,10 +16,10 @@ const COPY_FEEDBACK_DURATION_MS = 2000;
* lines are skipped here.
*/
export function CodeCopyButton() {
- const [copied, setCopied] = useState(false);
- const timeoutRef = useRef>(undefined);
+ const [copied, setCopied] = React.useState(false);
+ const timeoutRef = React.useRef>(undefined);
- function handleCopy(event: MouseEvent) {
+ function handleCopy(event: React.MouseEvent) {
const figure = event.currentTarget.closest('figure');
if (!figure) return;
diff --git a/src/modules/markdown/components/tabs.tsx b/src/modules/markdown/components/tabs.tsx
index e77e3fb..35b9e70 100644
--- a/src/modules/markdown/components/tabs.tsx
+++ b/src/modules/markdown/components/tabs.tsx
@@ -1,7 +1,4 @@
-'use client';
-
-import type { ReactNode } from 'react';
-import { Children, isValidElement } from 'react';
+import * as React from 'react';
import { Tabs as TabsRoot, TabsContent, TabsList, TabsTrigger } from '#/ui/components/core/tabs';
@@ -11,9 +8,9 @@ import { Tabs as TabsRoot, TabsContent, TabsList, TabsTrigger } from '#/ui/compo
* remark plugin are plain strings; children hold one wrapper div per
* `::tab[Label]` section, in label order.
*/
-export function Tabs({ labels, children }: { labels: string; children: ReactNode }) {
+export function Tabs({ labels, children }: { labels: string; children: React.ReactNode }) {
const tabLabels = JSON.parse(labels) as string[];
- const panels = Children.toArray(children).filter((child) => isValidElement(child));
+ const panels = React.Children.toArray(children).filter((child) => React.isValidElement(child));
return (
diff --git a/src/modules/toc/components/article-toc.tsx b/src/modules/toc/components/article-toc.tsx
new file mode 100644
index 0000000..85b5f9a
--- /dev/null
+++ b/src/modules/toc/components/article-toc.tsx
@@ -0,0 +1,160 @@
+import { ListIcon } from 'lucide-react';
+import * as React from 'react';
+
+import type { TableOfContents } from '#/modules/markdown/markdown.types';
+import { TocLink, TocList, TocRail, TocRoot, useToc } from '#/modules/toc/components/toc';
+import { parseTocEntries } from '#/modules/toc/toc.entries';
+import { Button } from '#/ui/components/core/button';
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from '#/ui/components/core/drawer';
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+} from '#/ui/components/core/sheet';
+import { cn } from '#/ui/utils';
+
+/**
+ * The article's "On this page" navigation, placed once per article and adapting
+ * to three widths:
+ *
+ * - **Desktop** (`xl` and up): fixed in the gutter just right of the centered
+ * article, always visible ({@link GutterToc}).
+ * - **Tablet** (`md`–`xl`, where that gutter collapses): a floating button
+ * opening the list in a right-side sheet ({@link SheetToc}).
+ * - **Mobile** (below `md`): a full-width bar pinned to the very bottom like a
+ * bottom navigation bar, opening the list in a drawer ({@link DrawerToc}).
+ *
+ * All three tiers share one {@link TocRoot}, so the reading position is
+ * tracked once no matter which tier is visible. Renders nothing when the
+ * article has no h2/h3 headings.
+ */
+export function ArticleToc({ headings }: { headings: TableOfContents }) {
+ const entries = React.useMemo(() => parseTocEntries(headings), [headings]);
+ if (entries.length === 0) return null;
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
+
+const TOC_TITLE = 'On this page';
+
+/** The icon-and-text title shared by every tier's heading. */
+function TocTitle() {
+ return (
+ <>
+
+ {TOC_TITLE}
+ >
+ );
+}
+
+/**
+ * The scrollable list every tier shows: rail, links, and the fading
+ * `.toc-scroll` viewport (see app.css) that hides its scrollbar and fades the
+ * edges while there's more to scroll.
+ */
+function TocPanel({ className, onNavigate }: { className?: string; onNavigate?: () => void }) {
+ const { entries } = useToc();
+
+ return (
+
+
+
+ {entries.map((entry) => (
+
+ ))}
+
+
+ );
+}
+
+/** Desktop tier: always visible in the gutter right of the centered article. */
+function GutterToc() {
+ return (
+
+ );
+}
+
+/**
+ * Tablet tier: a floating outline button in the bottom-right corner opening
+ * the TOC in a sheet that slides in from the right, echoing the desktop
+ * gutter's position. Closes as soon as a link scrolls the page.
+ */
+function SheetToc() {
+ const [open, setOpen] = React.useState(false);
+
+ return (
+
+
+ {TOC_TITLE}
+
+ }
+ />
+
+
+
+
+
+
+ setOpen(false)} />
+
+
+ );
+}
+
+/**
+ * Mobile tier: a full-width bar fixed to the very bottom — styled like a
+ * mobile bottom navigation bar, with a safe-area inset so it clears the home
+ * indicator — opening the TOC in a bottom drawer the reader can swipe down to
+ * dismiss. Closes as soon as a link scrolls the page.
+ */
+function DrawerToc() {
+ const [open, setOpen] = React.useState(false);
+
+ return (
+
+
+
+ {TOC_TITLE}
+
+
+
+
+
+
+
+ setOpen(false)} />
+
+ }>Close
+
+
+
+ );
+}
diff --git a/src/modules/toc/components/toc.tsx b/src/modules/toc/components/toc.tsx
new file mode 100644
index 0000000..62c60e7
--- /dev/null
+++ b/src/modules/toc/components/toc.tsx
@@ -0,0 +1,240 @@
+import * as React from 'react';
+
+import type { TocEntry } from '#/modules/toc/toc.entries';
+import type { RailGeometry } from '#/modules/toc/toc.rail';
+import { DOT_RADIUS, highlightSpan, linkIndent, useRailGeometry } from '#/modules/toc/toc.rail';
+import { scrollToHeading, useKeepActiveInView, useScrollSpy } from '#/modules/toc/toc.scroll';
+import { cn } from '#/ui/utils';
+
+/**
+ * Composable "On this page" primitives. `TocRoot` owns the shared reading
+ * state (one scroll spy, however many lists render under it); each `TocList`
+ * renders one navigable list and measures its own layout for the `TocRail`
+ * highlight. Compose them per surface:
+ *
+ * ```tsx
+ * const entries = parseTocEntries(post.toc);
+ *
+ *
+ *
+ *
+ * {entries.map((entry) => (
+ *
+ * ))}
+ *
+ *
+ * ```
+ *
+ * `ArticleToc` assembles these into the standard responsive shell.
+ */
+
+interface TocContextValue {
+ /** The entries this TOC navigates, in document order. */
+ entries: TocEntry[];
+ /** Ids of the headings whose section is on screen, in document order. */
+ activeIds: string[];
+ /** The reading position is still above the first heading. */
+ atStart: boolean;
+ /** The end of the article's content has scrolled into view. */
+ atEnd: boolean;
+}
+
+const TocContext = React.createContext(null);
+
+/** The TOC's entries and live reading state, provided by {@link TocRoot}. */
+export function useToc(): TocContextValue {
+ const context = React.useContext(TocContext);
+ if (!context) throw new Error('useToc must be used within ');
+ return context;
+}
+
+interface TocRootProps {
+ /**
+ * Parsed entries (see `parseTocEntries`), referentially stable across
+ * renders — memoize at the call site; they key the scroll spy.
+ */
+ entries: TocEntry[];
+ children: React.ReactNode;
+}
+
+/**
+ * Provides the entries and their scroll-derived reading state to every TOC
+ * piece below it. Renders no DOM of its own, so one root can feed several
+ * lists (the responsive shell mounts three) from a single scroll listener.
+ */
+export function TocRoot({ entries, children }: TocRootProps) {
+ const spy = useScrollSpy(entries);
+ const value = React.useMemo(() => ({ entries, ...spy }), [entries, spy]);
+ return {children};
+}
+
+interface TocListContextValue {
+ /** Measured layout of this list, or `null` until it has one (see `useRailGeometry`). */
+ geometry: RailGeometry | null;
+ /** Registers a link's `
` under its entry id, for measurement and follow-scroll. */
+ registerLink: (id: string, element: HTMLLIElement | null) => void;
+ /** Click handler shared by every link: smooth-scrolls and reports navigation. */
+ onLinkClick: (event: React.MouseEvent, id: string) => void;
+}
+
+const TocListContext = React.createContext(null);
+
+function useTocList(): TocListContextValue {
+ const context = React.useContext(TocListContext);
+ if (!context) throw new Error('TOC list pieces must be used within ');
+ return context;
+}
+
+interface TocListProps {
+ className?: string;
+ /** Called after a link scrolls the page — lets a sheet or drawer close. */
+ onNavigate?: () => void;
+ /** `TocLink`s (and optionally a `TocRail`). */
+ children: React.ReactNode;
+}
+
+/**
+ * One rendered TOC list: a `