# Annals — Animated Section Block for React

> Changelog section block: release entries on a scroll-linked spine with sticky version and date, change items grouped into colored New / Improved / Fixed / Deprecated chips, procedural screenshot plates and a collapsible "Show N more changes". Multi-select kind filters collapse non-matching items with height animation, search highlights matches with <mark>, the newest release wears a single-pulse Latest badge, and a Subscribe button morphs into an inline email field with async pending/success/error states.

- Page: https://crucibleui.com/components/sections/annals
- Category: [sections](https://crucibleui.com/components/sections)
- Tier: free (full source)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/annals
```

Registry JSON: https://crucibleui.com/r/annals.json

Import from `@/components/crucible/annals`.

## Props (`AnnalsProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `entries?` | `AnnalsEntry[]` | `a seeded sample changelog` | Releases, newest first. The first entry carries the "Latest" badge. |
| `title?` | `React.ReactNode` | `"What's new"` | Section headline (`<h2>`). |
| `eyebrow?` | `React.ReactNode` | `"Changelog"` | Small label above the headline. |
| `description?` | `React.ReactNode` | — | Supporting copy under the headline. |
| `onSubscribe?` | `(email: string) => void \| Promise<unknown>` | — | Called with the email from the inline subscribe field. Return a Promise to hold the pending state — resolve shows "Subscribed", reject shows the rejection message (or a generic one) inline. |
| `rssHref?` | `string \| false` | `"/changelog/rss.xml"` | Href of the RSS ghost link; `false` hides it. |
| `collapseAfter?` | `number` | `4` | Items shown per release before "Show N more changes" (ignored while searching). |
| `showMedia?` | `boolean` | `true` | Render media plates (real or procedural). |
| `searchable?` | `boolean` | `true` | Show the search field in the toolbar. |
| `scroller?` | `React.RefObject<HTMLElement \| null>` | — | Scroll container that drives the spine's progress beam. Auto-detects the nearest scrollable ancestor (or the window) when omitted; pass a ref to pin it explicitly. |
| `stickyBackground?` | `string` | `"rgb(10 10 10 / 0.86)"` | Backdrop painted behind the sticky toolbar so entries scroll beneath it cleanly. Match your surface. |
| `accentColor?` | `string` | `"#bef264"` | Accent hex: "New" chips, the spine beam, Latest badge, subscribe button, marks and focus rings. |
| `ref?` | `React.Ref<AnnalsHandle>` | — | Imperative handle (`replay()`, `clearFilters()`, `focusSearch()`). React 19 ref-as-prop. |
| `className?` | `string` | — | Extra classes for the `<section>` root. |

Also accepts: `Omit<React.ComponentPropsWithoutRef<"section">, "children" | "title">`.

## Usage example

```tsx
"use client";

import * as React from "react";

import { Annals, type AnnalsHandle } from "@/components/crucible/annals";

export default function AnnalsDemo() {
  const ref = React.useRef<AnnalsHandle>(null);
  const scrollerRef = React.useRef<HTMLDivElement>(null);

  return (
    <div className="relative isolate flex h-[600px] w-full flex-col overflow-hidden rounded-xl bg-neutral-950">
      <div
        aria-hidden
        className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-72"
        style={{ background: "radial-gradient(60% 100% at 20% 0%, rgba(190,242,100,0.07), transparent 70%)" }}
      />
      <div ref={scrollerRef} className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain">
        <Annals
          ref={ref}
          scroller={scrollerRef}
          onSubscribe={() => new Promise<void>((resolve) => window.setTimeout(resolve, 900))}
        />
      </div>
      <div className="relative z-10 flex items-center justify-between gap-3 border-t border-white/[0.06] bg-neutral-950 px-4 py-2.5 text-xs text-white/45">
        <span>Filter by change type.</span>
        <button
          type="button"
          onClick={() => {
            scrollerRef.current?.scrollTo({ top: 0 });
            ref.current?.replay();
          }}
          className="rounded underline underline-offset-4 transition-colors hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
        >
          Replay intro
        </button>
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.

## Source (`annals.tsx`)

```tsx
"use client";

import {
  AnimatePresence,
  motion,
  useMotionValue,
  useSpring,
  type Transition,
} from "motion/react";
import * as React from "react";

import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";

/* ---------------------------------------------------------------- types */

/** The four change kinds, in the order groups render. */
export type AnnalsKind = "new" | "improved" | "fixed" | "deprecated";

/** One line in a release. */
export interface AnnalsItem {
  /** Which group (and chip color) the change belongs to. */
  kind: AnnalsKind;
  /** The change itself, one sentence. Search matches and highlights within it. */
  text: string;
}

/** Optional screenshot plate for a release. */
export interface AnnalsMedia {
  /** Image URL. Omit for a procedural gradient "screenshot" placeholder seeded from the version. */
  src?: string;
  /** Accessible description of the image. @default "<title> preview" */
  alt?: string;
  /** Small caption under the plate. */
  caption?: string;
}

/** One release in the changelog. Pass entries newest first. */
export interface AnnalsEntry {
  /** Version label, e.g. `"v3.8.0"`. Also the stable key unless `id` is set. */
  version: string;
  /** Release date — ISO `YYYY-MM-DD` is formatted ("Oct 8, 2026"); anything else renders as-is. */
  date: string;
  /** Release headline. */
  title: string;
  /** Optional one-line summary under the headline. */
  summary?: string;
  /** The changes. Rendered grouped by kind: New, Improved, Fixed, Deprecated. */
  items: AnnalsItem[];
  /** Screenshot plate. `true` renders the procedural placeholder. */
  media?: AnnalsMedia | true;
  /** Stable identity. @default version */
  id?: string;
}

/** Imperative verbs exposed through the `ref` prop. */
export interface AnnalsHandle {
  /** Replay the designed intro (header rise, spine draw, entry cascade, Latest pulse). */
  replay: () => void;
  /** Clear the kind filters and the search query. */
  clearFilters: () => void;
  /** Focus the search field. */
  focusSearch: () => void;
}

export interface AnnalsProps extends Omit<React.ComponentPropsWithoutRef<"section">, "children" | "title"> {
  /** Releases, newest first. The first entry carries the "Latest" badge. Defaults to a seeded sample changelog. */
  entries?: AnnalsEntry[];
  /** Section headline (`<h2>`). @default "What's new" */
  title?: React.ReactNode;
  /** Small label above the headline. @default "Changelog" */
  eyebrow?: React.ReactNode;
  /** Supporting copy under the headline. */
  description?: React.ReactNode;
  /**
   * Called with the email from the inline subscribe field. Return a Promise to
   * hold the pending state — resolve shows "Subscribed", reject shows the
   * rejection message (or a generic one) inline.
   */
  onSubscribe?: (email: string) => void | Promise<unknown>;
  /** Href of the RSS ghost link; `false` hides it. @default "/changelog/rss.xml" */
  rssHref?: string | false;
  /** Items shown per release before "Show N more changes" (ignored while searching). @default 4 */
  collapseAfter?: number;
  /** Render media plates (real or procedural). @default true */
  showMedia?: boolean;
  /** Show the search field in the toolbar. @default true */
  searchable?: boolean;
  /**
   * Scroll container that drives the spine's progress beam. Auto-detects the
   * nearest scrollable ancestor (or the window) when omitted; pass a ref to
   * pin it explicitly.
   */
  scroller?: React.RefObject<HTMLElement | null>;
  /** Backdrop painted behind the sticky toolbar so entries scroll beneath it cleanly. Match your surface. @default "rgb(10 10 10 / 0.86)" */
  stickyBackground?: string;
  /** Accent hex: "New" chips, the spine beam, Latest badge, subscribe button, marks and focus rings. @default "#bef264" */
  accentColor?: string;
  /** Imperative handle (`replay()`, `clearFilters()`, `focusSearch()`). React 19 ref-as-prop. */
  ref?: React.Ref<AnnalsHandle>;
  /** Extra classes for the `<section>` root. */
  className?: string;
}

/* ---------------------------------------------------------------- constants */

const DEFAULT_ACCENT = "#bef264";
const EASE: [number, number, number, number] = [0.22, 1, 0.36, 1];
const SPRING: Transition = { type: "spring", stiffness: 380, damping: 36, mass: 0.8 };
const INSTANT: Transition = { duration: 0 };
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
const KINDS: AnnalsKind[] = ["new", "improved", "fixed", "deprecated"];
const KIND_LABEL: Record<AnnalsKind, string> = {
  new: "New",
  improved: "Improved",
  fixed: "Fixed",
  deprecated: "Deprecated",
};
const KIND_COLOR: Record<AnnalsKind, string> = {
  new: "var(--annals-accent)",
  improved: "#7dd3fc",
  fixed: "#fda4af",
  deprecated: "#a3a3a3",
};
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];

const DEFAULT_ENTRIES: AnnalsEntry[] = [
  {
    version: "v3.8.0",
    date: "2026-10-08",
    title: "Shared views and a faster command bar",
    summary: "Save any filtered table as a view and hand it to a teammate as a link.",
    media: true,
    items: [
      { kind: "new", text: "Shared views — save filters, sort and column layout, then share them by link." },
      { kind: "new", text: "The command bar now searches settings, teammates and recent exports." },
      { kind: "new", text: "Keyboard shortcut sheet: press ? anywhere to see every binding." },
      { kind: "improved", text: "Command bar opens 40% faster on workspaces with more than 10k records." },
      { kind: "improved", text: "Column widths persist per view instead of per browser." },
      { kind: "fixed", text: "The date picker skipped the 31st when moving between months." },
      { kind: "fixed", text: "CSV exports now keep leading zeros in postcode and ID columns." },
      { kind: "fixed", text: "Tooltips no longer flicker when a row re-renders underneath them." },
      { kind: "deprecated", text: "The v1 webhook payload format — it stops sending on January 15, 2027." },
    ],
  },
  {
    version: "v3.7.2",
    date: "2026-09-24",
    title: "Sync reliability patch",
    items: [
      { kind: "fixed", text: "Offline edits made on two devices no longer overwrite each other on reconnect." },
      { kind: "fixed", text: "Large attachments resume uploading after a dropped connection." },
      { kind: "improved", text: "Conflict banners now show who made the competing change and when." },
      { kind: "fixed", text: "The sync indicator stayed amber after a successful retry." },
    ],
  },
  {
    version: "v3.7.0",
    date: "2026-09-10",
    title: "Automations, round two",
    summary: "Branches, run history and smarter retries for every workflow.",
    media: true,
    items: [
      { kind: "new", text: "Conditional branches — route a run down different steps based on any field." },
      { kind: "new", text: "Run history with per-step timings and the exact payload each step received." },
      { kind: "improved", text: "Retry backoff is configurable per step, from 10 seconds to 6 hours." },
      { kind: "improved", text: "The step picker groups actions by app and remembers your last five." },
      { kind: "fixed", text: "Scheduled runs fired twice when the workspace time zone changed." },
      { kind: "deprecated", text: "The “Daily digest” template — rebuild it with the new schedule trigger." },
    ],
  },
  {
    version: "v3.6.0",
    date: "2026-08-20",
    title: "Granular permissions",
    summary: "Custom roles down to the field, with an exportable audit trail.",
    items: [
      { kind: "new", text: "Custom roles with per-field read and write access." },
      { kind: "new", text: "Audit log export to CSV or JSON for any date range." },
      { kind: "improved", text: "Invites can pre-assign a role and a set of projects." },
      { kind: "improved", text: "Guests see a clear notice instead of an empty page on restricted views." },
      { kind: "fixed", text: "Removing a member now revokes their API tokens immediately." },
      { kind: "fixed", text: "Role names longer than 24 characters were truncated in the sidebar." },
    ],
  },
  {
    version: "v3.5.1",
    date: "2026-08-02",
    title: "Polish and small fixes",
    items: [
      { kind: "improved", text: "Empty states now suggest a next step based on the view type." },
      { kind: "fixed", text: "Dragging a card to the last column scrolled the board back to the start." },
      { kind: "fixed", text: "Mentions inside code blocks no longer send notifications." },
    ],
  },
];

/* ---------------------------------------------------------------- helpers */

/** FNV-1a string hash → 32-bit seed. */
function hashString(s: string): number {
  let h = 0x811c9dc5;
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i);
    h = Math.imul(h, 0x01000193);
  }
  return h >>> 0;
}

/** Seeded PRNG — deterministic on server and client. */
function mulberry32(seed: number) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

/** Black or white text, whichever reads against the accent fill. */
function readableOn(hex: string): string {
  const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
  if (!m) return "#0a0a0a";
  let s = m[1];
  if (s.length === 3) s = s.replace(/./g, (c) => c + c);
  const n = parseInt(s, 16);
  const lum = 0.2126 * ((n >> 16) & 255) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255);
  return lum > 140 ? "#0a0a0a" : "#ffffff";
}

/** Deterministic date label (no locale/time-zone drift between server and client). */
function formatDate(date: string): string {
  const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(date);
  if (!m) return date;
  const month = MONTHS[Number(m[2]) - 1];
  return month ? `${month} ${Number(m[3])}, ${m[1]}` : date;
}

function escapeRegExp(s: string) {
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

/** Wrap case-insensitive matches of `query` in `<mark>`. */
function highlight(text: string, query: string): React.ReactNode {
  if (!query) return text;
  const parts = text.split(new RegExp(`(${escapeRegExp(query)})`, "gi"));
  if (parts.length === 1) return text;
  return parts.map((part, i) =>
    i % 2 === 1 ? (
      <mark
        key={i}
        className="rounded-[3px] bg-[color-mix(in_oklab,var(--annals-accent)_30%,transparent)] px-[1px] text-white"
      >
        {part}
      </mark>
    ) : (
      <React.Fragment key={i}>{part}</React.Fragment>
    )
  );
}

/** Nearest scrollable ancestor, or null for the window. */
function findScrollParent(el: HTMLElement | null): HTMLElement | null {
  let node = el?.parentElement ?? null;
  while (node && node !== document.body && node !== document.documentElement) {
    const oy = getComputedStyle(node).overflowY;
    if ((oy === "auto" || oy === "scroll" || oy === "overlay") && node.scrollHeight > node.clientHeight + 1) {
      return node;
    }
    node = node.parentElement;
  }
  return null;
}

/* ---------------------------------------------------------------- glyphs */

function SearchGlyph({ className }: { className?: string }) {
  return (
    <svg aria-hidden viewBox="0 0 20 20" className={className} fill="none">
      <circle cx="9" cy="9" r="5.25" stroke="currentColor" strokeWidth="1.6" />
      <path d="m13 13 3.5 3.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
    </svg>
  );
}

function RssGlyph({ className }: { className?: string }) {
  return (
    <svg aria-hidden viewBox="0 0 20 20" className={className} fill="none">
      <path d="M4.5 9a6.5 6.5 0 0 1 6.5 6.5M4.5 4.5a11 11 0 0 1 11 11" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
      <circle cx="5.25" cy="14.75" r="1.25" fill="currentColor" />
    </svg>
  );
}

function ArrowGlyph({ className }: { className?: string }) {
  return (
    <svg aria-hidden viewBox="0 0 20 20" className={className} fill="none">
      <path d="M4.5 10h11m0 0-4.5-4.5M15.5 10 11 14.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function CheckGlyph({ className }: { className?: string }) {
  return (
    <svg aria-hidden viewBox="0 0 20 20" className={className} fill="none">
      <path d="m4.75 10.5 3.5 3.5 7-8" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function CloseGlyph({ className }: { className?: string }) {
  return (
    <svg aria-hidden viewBox="0 0 20 20" className={className} fill="none">
      <path d="m6 6 8 8M14 6l-8 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
    </svg>
  );
}

function ChevronGlyph({ className }: { className?: string }) {
  return (
    <svg aria-hidden viewBox="0 0 20 20" className={className} fill="none">
      <path d="m6 8 4 4 4-4" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function Spinner({ className }: { className?: string }) {
  return (
    <svg aria-hidden viewBox="0 0 24 24" className={cn("motion-safe:animate-spin", className)} fill="none">
      <circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.3" strokeWidth="3" />
      <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
    </svg>
  );
}

/* ---------------------------------------------------------------- pieces */

function KindChip({ kind, className }: { kind: AnnalsKind; className?: string }) {
  const color = KIND_COLOR[kind];
  return (
    <span
      className={cn(
        "inline-flex h-[22px] items-center gap-1.5 rounded-full px-2 text-[11px] font-medium tracking-wide",
        className
      )}
      style={{
        color,
        backgroundColor: `color-mix(in oklab, ${color} 12%, transparent)`,
        boxShadow: `inset 0 0 0 1px color-mix(in oklab, ${color} 26%, transparent)`,
      }}
    >
      <span aria-hidden className="size-1.5 rounded-full" style={{ backgroundColor: color }} />
      {KIND_LABEL[kind]}
    </span>
  );
}

/** Procedural "screenshot": a seeded faux app window on a soft accent-lit gradient. */
function MediaPlate({ seedKey, label }: { seedKey: string; label: string }) {
  const art = React.useMemo(() => {
    const rand = mulberry32(hashString(seedKey));
    const variant = Math.floor(rand() * 3);
    const gx = 15 + Math.round(rand() * 70);
    const gy = 10 + Math.round(rand() * 40);
    const hue = Math.round(180 + rand() * 120);
    const bars = Array.from({ length: 5 }, () => 40 + Math.round(rand() * 50));
    const points = Array.from({ length: 9 }, (_, i) => {
      const y = 70 - i * 5 - rand() * 22;
      return `${(i / 8) * 100},${Math.max(8, Math.min(92, y))}`;
    });
    const rows = Array.from({ length: 5 }, () => [30 + rand() * 40, 15 + rand() * 25, 10 + rand() * 15]);
    return { variant, gx, gy, hue, bars, points, rows, active: Math.floor(rand() * 3) };
  }, [seedKey]);

  return (
    <div
      role="img"
      aria-label={label}
      className="relative aspect-[16/9] w-full overflow-hidden rounded-xl border border-white/[0.08] bg-[#0d0d10]"
      style={{
        backgroundImage: `radial-gradient(70% 90% at ${art.gx}% ${art.gy}%, color-mix(in oklab, var(--annals-accent) 22%, transparent), transparent 70%), radial-gradient(60% 70% at ${100 - art.gx}% 100%, hsl(${art.hue} 70% 55% / 0.16), transparent 70%)`,
      }}
    >
      {/* faux window */}
      <div className="absolute inset-x-[7%] top-[12%] bottom-0 overflow-hidden rounded-t-lg border border-b-0 border-white/[0.1] bg-[#0b0b0d]/90 shadow-[0_-12px_40px_-12px_rgba(0,0,0,0.6)]">
        <div className="flex h-[9%] min-h-[14px] items-center gap-[3px] border-b border-white/[0.06] px-[2.5%]">
          {[0, 1, 2].map((i) => (
            <span key={i} className="aspect-square h-[34%] rounded-full bg-white/15" />
          ))}
          <span className="mx-auto h-[38%] w-[28%] rounded-full bg-white/[0.06]" />
        </div>
        <div className="flex h-[91%]">
          <div className="flex w-[20%] flex-col gap-[7%] border-r border-white/[0.06] p-[3%] pt-[5%]">
            {art.bars.map((w, i) => (
              <span
                key={i}
                className="h-[5px] rounded-full"
                style={{
                  width: `${w}%`,
                  backgroundColor: i === 1 ? "color-mix(in oklab, var(--annals-accent) 70%, transparent)" : "rgb(255 255 255 / 0.1)",
                }}
              />
            ))}
          </div>
          <div className="flex flex-1 flex-col gap-[5%] p-[3.5%]">
            <div className="flex items-center justify-between">
              <span className="h-[7px] w-[30%] rounded-full bg-white/20" />
              <span className="h-[10px] w-[14%] rounded-full" style={{ backgroundColor: "var(--annals-accent)", opacity: 0.85 }} />
            </div>
            {art.variant === 0 && (
              <div className="grid flex-1 grid-cols-3 gap-[4%]">
                {[0, 1, 2].map((i) => (
                  <div
                    key={i}
                    className="rounded-md border bg-white/[0.025] p-[8%]"
                    style={{
                      borderColor:
                        i === art.active ? "color-mix(in oklab, var(--annals-accent) 55%, transparent)" : "rgb(255 255 255 / 0.07)",
                    }}
                  >
                    <span className="block h-[5px] w-3/5 rounded-full bg-white/20" />
                    <span className="mt-[14%] block h-[4px] w-full rounded-full bg-white/[0.08]" />
                    <span className="mt-[8%] block h-[4px] w-4/5 rounded-full bg-white/[0.08]" />
                  </div>
                ))}
              </div>
            )}
            {art.variant === 1 && (
              <div className="flex-1 rounded-md border border-white/[0.07] bg-white/[0.02] p-[3%]">
                <svg viewBox="0 0 100 100" preserveAspectRatio="none" className="h-full w-full overflow-visible">
                  <polyline
                    points={art.points.join(" ")}
                    fill="none"
                    stroke="var(--annals-accent)"
                    strokeWidth="1.6"
                    vectorEffect="non-scaling-stroke"
                    strokeLinejoin="round"
                  />
                  <polyline
                    points={`0,100 ${art.points.join(" ")} 100,100`}
                    fill="var(--annals-accent)"
                    opacity="0.08"
                    stroke="none"
                  />
                </svg>
              </div>
            )}
            {art.variant === 2 && (
              <div className="flex flex-1 flex-col justify-between rounded-md border border-white/[0.07] bg-white/[0.02] p-[3%]">
                {art.rows.map((r, i) => (
                  <div key={i} className="flex items-center gap-[4%]">
                    <span
                      className="size-[6px] shrink-0 rounded-full"
                      style={{ backgroundColor: i === art.active ? "var(--annals-accent)" : "rgb(255 255 255 / 0.18)" }}
                    />
                    <span className="h-[4px] rounded-full bg-white/[0.14]" style={{ width: `${r[0]}%` }} />
                    <span className="h-[4px] rounded-full bg-white/[0.07]" style={{ width: `${r[1]}%` }} />
                    <span className="ml-auto h-[4px] rounded-full bg-white/[0.07]" style={{ width: `${r[2]}%` }} />
                  </div>
                ))}
              </div>
            )}
          </div>
        </div>
      </div>
    </div>
  );
}

type SubscribeMode = "closed" | "open" | "pending" | "done";

function SubscribeControl({
  onSubscribe,
  reducedMotion,
  accentInk,
  uid,
}: {
  onSubscribe?: (email: string) => void | Promise<unknown>;
  reducedMotion: boolean;
  accentInk: string;
  uid: string;
}) {
  const [mode, setMode] = React.useState<SubscribeMode>("closed");
  const [email, setEmail] = React.useState("");
  const [error, setError] = React.useState<string | null>(null);
  const inputRef = React.useRef<HTMLInputElement>(null);
  const buttonRef = React.useRef<HTMLButtonElement>(null);
  const restoreFocus = React.useRef(false);
  const pendingRef = React.useRef(false);
  const errorId = `${uid}-sub-error`;
  // Controls live inside the clipped capsule, so their focus ring is drawn inset.
  const innerRing = "outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/75";

  React.useEffect(() => {
    if (mode === "open") inputRef.current?.focus({ preventScroll: true });
    if (mode === "closed" && restoreFocus.current) {
      restoreFocus.current = false;
      buttonRef.current?.focus({ preventScroll: true });
    }
  }, [mode]);

  const close = () => {
    if (pendingRef.current) return;
    restoreFocus.current = true;
    setError(null);
    setMode("closed");
  };

  const submit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    if (pendingRef.current) return;
    const trimmed = email.trim();
    if (!EMAIL_RE.test(trimmed)) {
      setError(trimmed ? "Enter a valid email address." : "Enter your email address.");
      inputRef.current?.focus();
      return;
    }
    setError(null);
    pendingRef.current = true;
    setMode("pending");
    try {
      await onSubscribe?.(trimmed);
      pendingRef.current = false;
      setMode("done");
    } catch (err) {
      pendingRef.current = false;
      setMode("open");
      setError(err instanceof Error && err.message ? err.message : "Something went wrong. Try again.");
    }
  };

  const width = mode === "closed" ? 112 : mode === "done" ? 140 : 260;
  const fade: Transition = reducedMotion ? { duration: 0.12 } : { duration: 0.18, ease: EASE };

  return (
    <div className="relative min-w-0 flex-1 @2xl:flex-none">
      <motion.div
        initial={false}
        animate={{ width }}
        transition={reducedMotion ? INSTANT : SPRING}
        className={cn(
          "relative h-9 max-w-full overflow-hidden rounded-full",
          mode === "closed" ? "" : "bg-white/[0.04] ring-1 ring-inset",
          error ? "ring-rose-300/60" : "ring-white/10 focus-within:ring-[var(--annals-accent)]",
          mode === "done" && "bg-[color-mix(in_oklab,var(--annals-accent)_12%,transparent)] ring-[color-mix(in_oklab,var(--annals-accent)_35%,transparent)]"
        )}
      >
        <AnimatePresence initial={false} mode="popLayout">
          {mode === "closed" && (
            <motion.button
              key="btn"
              ref={buttonRef}
              type="button"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={fade}
              onClick={() => setMode("open")}
              className={cn(
                "absolute inset-0 inline-flex items-center justify-center gap-1.5 rounded-full text-sm font-medium transition-[filter] hover:brightness-110",
                innerRing
              )}
              style={{ backgroundColor: "var(--annals-accent)", color: accentInk }}
            >
              Subscribe
            </motion.button>
          )}
          {(mode === "open" || mode === "pending") && (
            <motion.form
              key="form"
              noValidate
              onSubmit={submit}
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={fade}
              onKeyDown={(e) => {
                if (e.key === "Escape") {
                  e.stopPropagation();
                  close();
                }
              }}
              className="absolute inset-0 flex items-center gap-1 pl-3.5 pr-1"
            >
              <label htmlFor={`${uid}-sub-email`} className="sr-only">
                Email address for release updates
              </label>
              <input
                ref={inputRef}
                id={`${uid}-sub-email`}
                type="email"
                inputMode="email"
                autoComplete="email"
                placeholder="you@company.com"
                value={email}
                disabled={mode === "pending"}
                onChange={(e) => {
                  setEmail(e.target.value);
                  if (error) setError(null);
                }}
                aria-invalid={error ? true : undefined}
                aria-describedby={error ? errorId : undefined}
                className="h-full min-w-0 flex-1 bg-transparent text-sm text-white placeholder:text-white/35 focus:outline-none disabled:opacity-60"
              />
              <button
                type="button"
                onClick={close}
                disabled={mode === "pending"}
                aria-label="Cancel subscribing"
                className={cn(
                  "grid size-7 shrink-0 place-items-center rounded-full text-white/45 transition-colors hover:bg-white/[0.06] hover:text-white disabled:opacity-40",
                  innerRing
                )}
              >
                <CloseGlyph className="size-4" />
              </button>
              <button
                type="submit"
                disabled={mode === "pending"}
                aria-label={mode === "pending" ? "Subscribing" : "Subscribe"}
                className={cn(
                  "grid size-7 shrink-0 place-items-center rounded-full transition-[filter] hover:brightness-110",
                  innerRing
                )}
                style={{ backgroundColor: "var(--annals-accent)", color: accentInk }}
              >
                {mode === "pending" ? <Spinner className="size-3.5" /> : <ArrowGlyph className="size-4" />}
              </button>
            </motion.form>
          )}
          {mode === "done" && (
            <motion.div
              key="done"
              initial={{ opacity: 0, scale: reducedMotion ? 1 : 0.92 }}
              animate={{ opacity: 1, scale: 1 }}
              transition={fade}
              className="absolute inset-0 flex items-center justify-center gap-1.5 text-sm font-medium"
              style={{ color: "var(--annals-accent)" }}
            >
              <CheckGlyph className="size-4" />
              Subscribed
            </motion.div>
          )}
        </AnimatePresence>
      </motion.div>
      <p role="status" className="sr-only">
        {mode === "done" ? "Subscribed to release updates." : mode === "pending" ? "Subscribing…" : ""}
      </p>
      <AnimatePresence>
        {error && (
          <motion.p
            id={errorId}
            role="alert"
            initial={{ opacity: 0, y: reducedMotion ? 0 : -4 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0 }}
            transition={fade}
            className="absolute right-0 top-full z-20 mt-1.5 whitespace-nowrap rounded-md bg-neutral-900 px-2 py-1 text-xs text-rose-300 ring-1 ring-white/10"
          >
            {error}
          </motion.p>
        )}
      </AnimatePresence>
    </div>
  );
}

/* ---------------------------------------------------------------- main */

/**
 * Annals — a changelog section block. Releases run down a spine whose accent
 * beam fills as you scroll, igniting each release's node as it passes; at
 * wide sizes the version and date sit in a sticky left rail. Each release
 * groups its changes under colored kind chips (New / Improved / Fixed /
 * Deprecated), can carry a screenshot plate (procedural placeholder when no
 * image is given) and folds long lists behind "Show N more changes". The
 * sticky toolbar's multi-select kind toggles and search field collapse
 * non-matching items with height animation and highlight matches with
 * `<mark>`; the newest release wears a "Latest" badge that pulses once in
 * view. A Subscribe button morphs into an inline email field with async
 * pending / success / error states. Reduced motion: final layout, crossfades
 * only, no pulse.
 */
export function Annals({
  entries = DEFAULT_ENTRIES,
  title = "What's new",
  eyebrow = "Changelog",
  description = "New features, improvements and fixes — shipped every two weeks.",
  onSubscribe,
  rssHref = "/changelog/rss.xml",
  collapseAfter = 4,
  showMedia = true,
  searchable = true,
  scroller,
  stickyBackground = "rgb(10 10 10 / 0.86)",
  accentColor = DEFAULT_ACCENT,
  ref,
  className,
  style,
  ...props
}: AnnalsProps) {
  const reducedMotion = useReducedMotion();
  const uid = React.useId().replace(/[:]/g, "");
  const headingId = `${uid}-heading`;
  const accentInk = readableOn(accentColor);

  const rootRef = React.useRef<HTMLElement>(null);
  const listRef = React.useRef<HTMLDivElement>(null);
  const toolbarRef = React.useRef<HTMLDivElement>(null);
  const searchRef = React.useRef<HTMLInputElement>(null);
  const nodeRefs = React.useRef(new Map<string, HTMLSpanElement>());

  const [kinds, setKinds] = React.useState<AnnalsKind[]>([]);
  const [query, setQuery] = React.useState("");
  const [expanded, setExpanded] = React.useState<Record<string, boolean>>({});
  const [litCount, setLitCount] = React.useState(0);
  const [toolbarH, setToolbarH] = React.useState(64);
  const [announcement, setAnnouncement] = React.useState("");

  /* ---- intro: once in view; replay() snaps back then plays again ---- */
  const [intro, setIntro] = React.useState<"waiting" | "reset" | "play">("waiting");
  const [run, setRun] = React.useState(0);
  React.useEffect(() => {
    const el = rootRef.current;
    if (!el) return;
    const observer = new IntersectionObserver(
      (obs) => {
        if (obs.some((o) => o.isIntersecting)) {
          setIntro((s) => (s === "waiting" ? "play" : s));
          observer.disconnect();
        }
      },
      { rootMargin: "0px 0px -12% 0px" }
    );
    observer.observe(el);
    return () => observer.disconnect();
  }, []);
  React.useEffect(() => {
    if (intro !== "reset") return;
    const id = window.requestAnimationFrame(() => setIntro("play"));
    return () => window.cancelAnimationFrame(id);
  }, [intro]);
  const shown = reducedMotion || intro === "play";
  const snap = intro === "reset";
  const rise = (delay: number) => ({
    initial: false as const,
    animate: { opacity: shown ? 1 : 0, y: shown || reducedMotion ? 0 : 12 },
    transition: reducedMotion || snap ? INSTANT : { duration: 0.55, ease: EASE, delay },
  });

  const replay = React.useCallback(() => {
    setIntro("reset");
    setRun((r) => r + 1);
  }, []);
  const clearFilters = React.useCallback(() => {
    setKinds([]);
    setQuery("");
  }, []);
  React.useImperativeHandle(
    ref,
    () => ({ replay, clearFilters, focusSearch: () => searchRef.current?.focus() }),
    [replay, clearFilters]
  );

  /* ---- filtering ---- */
  const q = query.trim();
  const ql = q.toLowerCase();
  const kindSet = new Set(kinds);

  const filtered = entries.map((entry, index) => {
      const key = entry.id ?? entry.version;
      const titleHit =
        !!ql &&
        (entry.title.toLowerCase().includes(ql) ||
          entry.version.toLowerCase().includes(ql) ||
          (entry.summary?.toLowerCase().includes(ql) ?? false));
      const withIndex = entry.items.map((item, i) => ({ item, i }));
      const kindOk = withIndex.filter(({ item }) => kindSet.size === 0 || kindSet.has(item.kind));
      const matches = titleHit || !ql ? kindOk : kindOk.filter(({ item }) => item.text.toLowerCase().includes(ql));
      const ordered = KINDS.flatMap((k) => matches.filter(({ item }) => item.kind === k));
      return { entry, index, key, ordered, visible: ordered.length > 0 || (!ql && kindSet.size === 0) };
  });

  const kindCounts: Record<AnnalsKind, number> = { new: 0, improved: 0, fixed: 0, deprecated: 0 };
    for (const entry of entries) {
      const titleHit = !!ql && (entry.title.toLowerCase().includes(ql) || entry.version.toLowerCase().includes(ql));
      for (const item of entry.items) {
        if (!ql || titleHit || item.text.toLowerCase().includes(ql)) kindCounts[item.kind]++;
      }
    }

  const visibleEntries = filtered.filter((f) => f.visible);
  const totalVisibleItems = visibleEntries.reduce((n, f) => n + f.ordered.length, 0);
  const filtering = kindSet.size > 0 || !!q;

  // Announce result counts after the user changes filters (debounced for typing).
  React.useEffect(() => {
    const id = window.setTimeout(() => {
      setAnnouncement(
        !filtering
          ? ""
          : totalVisibleItems === 0
          ? "No changes match."
          : `${totalVisibleItems} ${totalVisibleItems === 1 ? "change" : "changes"} in ${visibleEntries.length} ${
              visibleEntries.length === 1 ? "release" : "releases"
            }.`
      );
    }, filtering ? 350 : 0);
    return () => window.clearTimeout(id);
  }, [filtering, totalVisibleItems, visibleEntries.length]);

  const toggleKind = (kind: AnnalsKind) =>
    setKinds((prev) => (prev.includes(kind) ? prev.filter((k) => k !== kind) : [...prev, kind]));

  /* ---- sticky offsets: toolbar height drives the rail's sticky top ---- */
  React.useEffect(() => {
    const el = toolbarRef.current;
    if (!el || typeof ResizeObserver === "undefined") return;
    const ro = new ResizeObserver(() => setToolbarH(Math.round(el.getBoundingClientRect().height)));
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  /* ---- scroll-linked spine progress + node ignition ---- */
  const progress = useMotionValue(0);
  const smooth = useSpring(progress, { stiffness: 220, damping: 34, mass: 0.6 });
  const visibleKeys = visibleEntries.map((f) => f.key).join("|");
  React.useEffect(() => {
    const list = listRef.current;
    if (!list) return;
    const target = scroller?.current ?? findScrollParent(rootRef.current);
    const keys = visibleKeys ? visibleKeys.split("|") : [];
    let raf = 0;
    const measure = () => {
      raf = 0;
      const vp = target ? target.getBoundingClientRect() : { top: 0, height: window.innerHeight };
      const line = vp.top + vp.height * 0.4;
      const rect = list.getBoundingClientRect();
      const p = rect.height > 0 ? (line - rect.top) / rect.height : 0;
      progress.set(Math.max(0, Math.min(1, p)));
      let lit = 0;
      for (const key of keys) {
        const node = nodeRefs.current.get(key);
        if (node && node.getBoundingClientRect().top + 5 <= line) lit++;
        else break;
      }
      setLitCount(lit);
    };
    const schedule = () => {
      if (!raf) raf = window.requestAnimationFrame(measure);
    };
    measure();
    const scrollTarget: HTMLElement | Window = target ?? window;
    scrollTarget.addEventListener("scroll", schedule, { passive: true });
    window.addEventListener("resize", schedule);
    const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(schedule) : null;
    ro?.observe(list);
    return () => {
      if (raf) window.cancelAnimationFrame(raf);
      scrollTarget.removeEventListener("scroll", schedule);
      window.removeEventListener("resize", schedule);
      ro?.disconnect();
    };
  }, [scroller, visibleKeys, progress]);
  const beamScale = reducedMotion ? progress : smooth;

  /* ---- styles ---- */
  const focusRing =
    "outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--annals-accent)] focus-visible:outline-solid";
  const heightT: Transition = reducedMotion
    ? { height: INSTANT, opacity: { duration: 0.15 } }
    : { height: { type: "spring", stiffness: 420, damping: 40 }, opacity: { duration: 0.2 } };

  const latestKey = filtered[0]?.key;

  return (
    <section
      ref={rootRef}
      aria-labelledby={headingId}
      className={cn("@container relative w-full text-white", className)}
      style={
        {
          "--annals-accent": accentColor,
          "--annals-top": `${toolbarH + 20}px`,
          ...style,
        } as React.CSSProperties
      }
      {...props}
    >
      <div className="mx-auto w-full max-w-5xl px-5 pb-16 pt-10 @lg:px-8 @lg:pt-14">
        {/* ---------- header ---------- */}
        <motion.header
          {...rise(0)}
          className="flex flex-col gap-5 @2xl:flex-row @2xl:items-end @2xl:justify-between"
        >
          <div className="max-w-xl">
            <p className="text-xs font-medium uppercase tracking-[0.16em]" style={{ color: "var(--annals-accent)" }}>
              {eyebrow}
            </p>
            <h2 id={headingId} className="mt-2 text-3xl font-semibold tracking-tight text-white @lg:text-4xl">
              {title}
            </h2>
            {description ? <p className="mt-3 text-[15px] leading-relaxed text-white/55">{description}</p> : null}
          </div>
          <div className="flex w-full flex-nowrap items-center justify-start gap-2 @2xl:justify-end @2xl:w-[340px] @2xl:shrink-0">
            {rssHref !== false && (
              <a
                href={rssHref}
                className={cn(
                  "-ml-3 inline-flex h-9 shrink-0 items-center gap-1.5 rounded-full px-3 text-sm text-white/55 @2xl:ml-0 transition-colors hover:bg-white/[0.05] hover:text-white",
                  focusRing
                )}
              >
                <RssGlyph className="size-4" />
                RSS
              </a>
            )}
            <SubscribeControl
              onSubscribe={onSubscribe}
              reducedMotion={reducedMotion}
              accentInk={accentInk}
              uid={uid}
            />
          </div>
        </motion.header>

        {/* ---------- toolbar ---------- */}
        <motion.div
          ref={toolbarRef}
          {...rise(0.08)}
          className="z-20 -mx-5 mt-8 border-b border-white/[0.07] px-5 py-3 backdrop-blur-md @lg:-mx-8 @lg:px-8 @2xl:sticky @2xl:top-0"
          style={{ backgroundColor: stickyBackground }}
        >
          <div className="flex flex-col gap-3 @2xl:flex-row @2xl:items-center">
            <div
              role="group"
              aria-label="Filter by change type"
              className="flex flex-wrap items-center gap-1.5 @2xl:flex-nowrap"
            >
              {KINDS.map((kind) => {
                const on = kindSet.has(kind);
                const color = KIND_COLOR[kind];
                return (
                  <button
                    key={kind}
                    type="button"
                    aria-pressed={on}
                    onClick={() => toggleKind(kind)}
                    className={cn(
                      "inline-flex h-8 shrink-0 items-center gap-2 rounded-full px-3 text-[13px] font-medium transition-[background-color,color,box-shadow] duration-200",
                      on ? "text-white" : "text-white/55 hover:bg-white/[0.06] hover:text-white/85",
                      focusRing
                    )}
                    style={{
                      backgroundColor: on ? `color-mix(in oklab, ${color} 14%, transparent)` : "rgb(255 255 255 / 0.03)",
                      boxShadow: on
                        ? `inset 0 0 0 1px color-mix(in oklab, ${color} 45%, transparent)`
                        : "inset 0 0 0 1px rgb(255 255 255 / 0.07)",
                    }}
                  >
                    <span
                      aria-hidden
                      className="size-2 rounded-full transition-opacity duration-200"
                      style={{ backgroundColor: color, opacity: on ? 1 : 0.55 }}
                    />
                    {KIND_LABEL[kind]}
                    <span className={cn("text-xs tabular-nums", on ? "text-white/60" : "text-white/35")}>
                      {kindCounts[kind]}
                    </span>
                  </button>
                );
              })}
              <AnimatePresence initial={false}>
                {filtering && (
                  <motion.button
                    key="clear"
                    type="button"
                    initial={{ opacity: 0 }}
                    animate={{ opacity: 1 }}
                    exit={{ opacity: 0 }}
                    transition={{ duration: 0.15 }}
                    onClick={() => {
                      clearFilters();
                      searchRef.current?.focus();
                    }}
                    className={cn(
                      "h-8 shrink-0 rounded-full px-2.5 text-[13px] text-white/45 underline-offset-4 hover:text-white hover:underline",
                      focusRing
                    )}
                  >
                    Clear
                  </motion.button>
                )}
              </AnimatePresence>
            </div>
            {searchable && (
              <div className="relative @2xl:ml-auto @2xl:w-64">
                <SearchGlyph className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-white/35" />
                <label htmlFor={`${uid}-search`} className="sr-only">
                  Search changes
                </label>
                <input
                  ref={searchRef}
                  id={`${uid}-search`}
                  type="search"
                  value={query}
                  onChange={(e) => setQuery(e.target.value)}
                  onKeyDown={(e) => {
                    if (e.key === "Escape" && query) {
                      e.preventDefault();
                      setQuery("");
                    }
                  }}
                  placeholder="Search changes"
                  autoComplete="off"
                  className="h-9 w-full rounded-lg bg-white/[0.03] pl-9 pr-3 text-sm text-white ring-1 ring-inset ring-white/[0.08] transition-shadow placeholder:text-white/35 focus:outline-none focus:ring-[var(--annals-accent)] [&::-webkit-search-cancel-button]:hidden"
                />
              </div>
            )}
          </div>
          <p role="status" aria-live="polite" className="sr-only">
            {announcement}
          </p>
        </motion.div>

        {/* ---------- releases on the spine ---------- */}
        <div
          ref={listRef}
          className="relative mt-10 [--rail-x:6px] @2xl:[--rail-x:10.75rem]"
        >
          {/* track + scroll-linked beam */}
          <motion.div
            key={`track-${run}`}
            aria-hidden
            initial={false}
            animate={{ scaleY: shown ? 1 : 0 }}
            transition={reducedMotion || snap ? INSTANT : { duration: 0.8, ease: EASE, delay: 0.1 }}
            className="absolute bottom-0 top-1 w-px origin-top bg-white/[0.08] [mask-image:linear-gradient(to_bottom,black_85%,transparent)]"
            style={{ left: "var(--rail-x)" }}
          />
          <motion.div
            aria-hidden
            className="absolute bottom-0 top-1 w-[2px] origin-top rounded-full"
            style={{
              left: "calc(var(--rail-x) - 0.5px)",
              scaleY: beamScale,
              backgroundImage:
                "linear-gradient(to bottom, color-mix(in oklab, var(--annals-accent) 10%, transparent), var(--annals-accent))",
              boxShadow: "0 0 10px color-mix(in oklab, var(--annals-accent) 45%, transparent)",
              opacity: shown ? 1 : 0,
            }}
          />

          <AnimatePresence initial={false}>
            {visibleEntries.map((f, vIndex) => {
              const { entry, key, ordered } = f;
              const isLatest = key === latestKey;
              const isOpen = !!expanded[key] || !!q;
              const shownItems = isOpen ? ordered : ordered.slice(0, Math.max(0, collapseAfter));
              const hiddenCount = ordered.length - shownItems.length;
              const canCollapse = !q && ordered.length > collapseAfter;
              const groups = KINDS.map((k) => ({ kind: k, rows: shownItems.filter(({ item }) => item.kind === k) })).filter(
                (g) => g.rows.length > 0
              );
              const lit = vIndex < litCount;
              const listId = `${uid}-items-${f.index}`;
              const iso = /^\d{4}-\d{2}-\d{2}/.test(entry.date) ? entry.date.slice(0, 10) : undefined;
              const media = showMedia && entry.media ? (entry.media === true ? {} : entry.media) : null;
              const cascade = Math.min(vIndex, 5) * 0.07;

              return (
                <motion.article
                  key={key}
                  aria-labelledby={`${uid}-title-${f.index}`}
                  initial={{ height: 0, opacity: 0 }}
                  animate={{ height: "auto", opacity: 1 }}
                  exit={{ height: 0, opacity: 0 }}
                  transition={heightT}
                  className="relative [overflow-y:clip]"
                >
                  <motion.div
                    key={`in-${run}`}
                    {...rise(0.16 + cascade)}
                    className="relative pb-14 pl-8 @2xl:grid @2xl:grid-cols-[8.5rem_minmax(0,1fr)] @2xl:gap-x-[4.5rem] @2xl:pl-0"
                  >
                    {/* node */}
                    <span
                      ref={(el) => {
                        if (el) nodeRefs.current.set(key, el);
                        else nodeRefs.current.delete(key);
                      }}
                      aria-hidden
                      className="absolute top-[7px] size-[11px] rounded-full border transition-[background-color,border-color,box-shadow] duration-300"
                      style={{
                        left: "calc(var(--rail-x) - 5px)",
                        backgroundColor: lit ? "var(--annals-accent)" : "rgb(10 10 10)",
                        borderColor: lit ? "var(--annals-accent)" : "rgb(255 255 255 / 0.22)",
                        boxShadow: lit
                          ? "0 0 0 4px color-mix(in oklab, var(--annals-accent) 16%, transparent), 0 0 14px color-mix(in oklab, var(--annals-accent) 55%, transparent)"
                          : "0 0 0 4px rgb(10 10 10)",
                      }}
                    />

                    {/* rail: version + date (sticky at wide sizes) */}
                    <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 @2xl:sticky @2xl:top-[var(--annals-top)] @2xl:flex-col @2xl:items-end @2xl:self-start @2xl:text-right">
                      <span className="font-mono text-sm font-medium tabular-nums text-white">
                        {highlight(entry.version, q)}
                      </span>
                      <time dateTime={iso} className="text-[13px] tabular-nums text-white/45">
                        {formatDate(entry.date)}
                      </time>
                      {isLatest && <LatestBadge play={shown && !snap} reducedMotion={reducedMotion} run={run} />}
                    </div>

                    {/* body */}
                    <div className="mt-3 min-w-0 @2xl:mt-0">
                      <h3
                        id={`${uid}-title-${f.index}`}
                        className="text-xl font-semibold leading-snug tracking-tight text-white @lg:text-2xl"
                      >
                        {highlight(entry.title, q)}
                      </h3>
                      {entry.summary ? (
                        <p className="mt-2 text-[15px] leading-relaxed text-white/55">{highlight(entry.summary, q)}</p>
                      ) : null}

                      {media ? (
                        <figure className="mt-5">
                          {media.src ? (
                            // eslint-disable-next-line @next/next/no-img-element
                            <img
                              src={media.src}
                              alt={media.alt ?? `${entry.title} preview`}
                              className="aspect-[16/9] w-full rounded-xl border border-white/[0.08] object-cover"
                              loading="lazy"
                            />
                          ) : (
                            <MediaPlate seedKey={entry.version} label={media.alt ?? `${entry.title} preview`} />
                          )}
                          {media.caption ? (
                            <figcaption className="mt-2 text-xs text-white/40">{media.caption}</figcaption>
                          ) : null}
                        </figure>
                      ) : null}

                      <div id={listId} className="mt-5">
                        <AnimatePresence initial={false}>
                          {groups.map((g) => (
                            <motion.div
                              key={g.kind}
                              initial={{ height: 0, opacity: 0 }}
                              animate={{ height: "auto", opacity: 1 }}
                              exit={{ height: 0, opacity: 0 }}
                              transition={heightT}
                              className="[overflow-y:clip]"
                            >
                              <div className="border-t border-white/[0.06] py-3.5 @lg:grid @lg:grid-cols-[7.5rem_minmax(0,1fr)] @lg:gap-x-4">
                                <div className="mb-2 @lg:mb-0 @lg:pt-px">
                                  <KindChip kind={g.kind} />
                                </div>
                                <ul className="flex flex-col">
                                  <AnimatePresence initial={false}>
                                    {g.rows.map(({ item, i }) => (
                                      <motion.li
                                        key={i}
                                        initial={{ height: 0, opacity: 0 }}
                                        animate={{ height: "auto", opacity: 1 }}
                                        exit={{ height: 0, opacity: 0 }}
                                        transition={heightT}
                                        className="[overflow-y:clip]"
                                      >
                                        <p className="relative py-1 pl-4 text-[14px] leading-relaxed text-white/70">
                                          <span
                                            aria-hidden
                                            className="absolute left-0 top-[0.85rem] size-1 rounded-full bg-white/25"
                                          />
                                          {highlight(item.text, q)}
                                        </p>
                                      </motion.li>
                                    ))}
                                  </AnimatePresence>
                                </ul>
                              </div>
                            </motion.div>
                          ))}
                        </AnimatePresence>
                      </div>

                      {canCollapse && (hiddenCount > 0 || expanded[key]) ? (
                        <button
                          type="button"
                          aria-expanded={!!expanded[key]}
                          aria-controls={listId}
                          onClick={() => setExpanded((s) => ({ ...s, [key]: !s[key] }))}
                          className={cn(
                            "mt-1 inline-flex h-8 items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.02] pl-3 pr-2.5 text-[13px] text-white/65 transition-colors hover:border-white/15 hover:text-white",
                            focusRing
                          )}
                        >
                          {expanded[key] ? (
                            "Show fewer changes"
                          ) : (
                            <span className="tabular-nums">
                              Show {hiddenCount} more {hiddenCount === 1 ? "change" : "changes"}
                            </span>
                          )}
                          <ChevronGlyph
                            className={cn(
                              "size-4 transition-transform duration-200",
                              expanded[key] && "rotate-180"
                            )}
                          />
                        </button>
                      ) : null}
                    </div>
                  </motion.div>
                </motion.article>
              );
            })}
          </AnimatePresence>

          {/* empty state */}
          <AnimatePresence initial={false}>
            {visibleEntries.length === 0 && (
              <motion.div
                key="empty"
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0 }}
                transition={{ duration: 0.2 }}
                className="ml-8 flex flex-col items-start gap-3 rounded-xl border border-dashed border-white/10 px-5 py-8 @2xl:ml-[12.75rem]"
              >
                <p className="text-sm text-white/70">
                  No changes match{q ? <> “<span className="text-white">{q}</span>”</> : " these filters"}.
                </p>
                <button
                  type="button"
                  onClick={() => {
                    clearFilters();
                    searchRef.current?.focus();
                  }}
                  className={cn(
                    "inline-flex h-8 items-center rounded-full px-3 text-[13px] font-medium transition-[filter] hover:brightness-110",
                    focusRing
                  )}
                  style={{ backgroundColor: "var(--annals-accent)", color: accentInk }}
                >
                  Clear filters
                </button>
              </motion.div>
            )}
          </AnimatePresence>
        </div>
      </div>
    </section>
  );
}

function LatestBadge({ play, reducedMotion, run }: { play: boolean; reducedMotion: boolean; run: number }) {
  return (
    <span className="relative inline-flex">
      {!reducedMotion && play && (
        <motion.span
          key={run}
          aria-hidden
          className="absolute inset-0 rounded-full"
          style={{ boxShadow: "0 0 0 1.5px var(--annals-accent)" }}
          initial={{ opacity: 0.8, scale: 1 }}
          animate={{ opacity: 0, scale: 1.6 }}
          transition={{ duration: 1.1, ease: "easeOut", delay: 0.55 }}
        />
      )}
      <span
        className="relative inline-flex h-[22px] items-center gap-1.5 rounded-full px-2 text-[11px] font-semibold uppercase tracking-[0.08em]"
        style={{
          color: "var(--annals-accent)",
          backgroundColor: "color-mix(in oklab, var(--annals-accent) 14%, transparent)",
          boxShadow: "inset 0 0 0 1px color-mix(in oklab, var(--annals-accent) 40%, transparent)",
        }}
      >
        <span aria-hidden className="size-1.5 rounded-full" style={{ backgroundColor: "var(--annals-accent)" }} />
        Latest
      </span>
    </span>
  );
}
```
