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.
@crucible
registry/default/sections/annals/annals.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>
);
}






























