"use client";
import { useEffect, useId, useRef, useState, type PointerEvent } from "react";
import { useReducedMotion } from "motion/react";
type ReleaseNote = { kind: "new" | "fix" | "up"; text: string };
type Release = { version: string; date: string; title: string; notes: ReleaseNote[] };
type ChangelogWallProps = {
/** Newest first. Each one is a poster on the wall. */
releases?: Release[];
/** Called with the release now on top. */
onChange?: (release: Release, index: number) => void;
/** Set the width here. Everything scales with it. */
className?: string;
};
const RELEASES: Release[] = [
{
version: "4.2",
date: "2 Nov 2026",
title: "Offline mode",
notes: [
{ kind: "new", text: "Works with no signal" },
{ kind: "up", text: "Search twice as fast" },
{ kind: "fix", text: "Double invoices are gone" },
],
},
{
version: "4.1",
date: "9 Sep 2026",
title: "Team seats",
notes: [
{ kind: "new", text: "Invite your whole team" },
{ kind: "new", text: "Roles for each member" },
{ kind: "fix", text: "CSV dates read right" },
],
},
{
version: "4.0",
date: "1 Jul 2026",
title: "All new look",
notes: [
{ kind: "new", text: "Dark theme" },
{ kind: "up", text: "Pages load in half the time" },
{ kind: "up", text: "Tamil and Hindi menus" },
],
},
{
version: "3.9",
date: "12 Apr 2026",
title: "Reports",
notes: [
{ kind: "new", text: "Monthly report by mail" },
{ kind: "fix", text: "Totals round the right way" },
],
},
{
version: "3.8",
date: "3 Feb 2026",
title: "First print",
notes: [
{ kind: "new", text: "Billing, stock and customers" },
{ kind: "new", text: "UPI payment links" },
],
},
];
const PAPERS = [
{ bg: "#f2b632", ink: "#1b1a18", hot: "#c8321f" },
{ bg: "#d8402a", ink: "#fbf1dc", hot: "#1b1a18" },
{ bg: "#14756e", ink: "#fbf1dc", hot: "#f2b632" },
{ bg: "#f0e4c8", ink: "#1b1a18", hot: "#d8402a" },
{ bg: "#27306b", ink: "#fbf1dc", hot: "#f09aa8" },
];
const TAGS = { new: "NEW", fix: "FIX", up: "UP" };
const TILT = [-1.4, 1.1, -0.7, 1.6, -1.1];
// In cqw, measured from the bottom right corner: u leftwards, v upwards.
const PW = 84;
const PH = 54;
const REST = 9; // the corner that has come loose by itself
const END = PW + PH - 7; // what stays on the wall after a tear
const STOP = 2.6;
const rip = (s: number, c: number) => (Math.sin(s * 1.7) * 1.1 + Math.sin(s * 4.3 + 1) * 0.7) * Math.min(1, c / 9);
const at = ([u, v]: number[], pad = 0) => `${(PW - u + pad).toFixed(2)}cqw ${(PH - v + pad).toFixed(2)}cqw`;
/** The two shapes for a tear c long: what stays pasted, and the torn paper folded back over it. */
function tear(c: number) {
const a = c <= PW ? [c, 0] : [PW, c - PW];
const b = c <= PH ? [0, c] : [c - PH, PH];
const line = [a];
// Sampled on a fixed grid so the rough edge stays put as the tear moves.
for (let s = Math.floor((a[0] - a[1]) / STOP) * STOP; s > b[0] - b[1]; s -= STOP) {
const j = rip(s, c);
line.push([(c + s) / 2 + j, (c - s) / 2 + j]);
}
line.push(b);
const keep = [...(c < PW ? [[PW, 0]] : []), [PW, PH], ...(c < PH ? [[0, PH]] : [])];
const gone = [...(c > PH ? [[0, PH]] : []), [0, 0], ...(c > PW ? [[PW, 0]] : [])];
const back = [...line, ...gone].map(([u, v]) => [c - v, c - u]);
return {
keep: `polygon(${[...line.slice().reverse(), ...keep].map((p) => at(p)).join(",")})`,
flap: `polygon(${back.map((p) => at(p, 100)).join(",")})`,
fade: Math.max(0, Math.min(1, (END - c) / (END * 0.3))),
};
}
function Poster({ release, paper }: { release: Release; paper: (typeof PAPERS)[number] }) {
return (
Release
{release.version}
{release.date}
{release.title}
{release.notes.map((note) => (
{TAGS[note.kind]}
{note.text}
))}
{/* Wheat paste: slack paper dries in soft ridges. */}
);
}
/* ChangelogWall: every release is a poster pasted over the last one on a brick wall. The newest has one corner come loose; pull it, or press Older, and it rips away along a rough edge to show the release under it, leaving a scrap behind. */
export function ChangelogWall({ releases = RELEASES, onChange, className = "w-full max-w-2xl" }: ChangelogWallProps) {
const reduce = useReducedMotion();
const id = useId().replace(/[^a-zA-Z0-9]/g, "");
const wall = useRef(null);
const raf = useRef(0);
const busy = useRef(false);
const drag = useRef<{ x: number; y: number; moved: boolean; c: number } | null>(null);
const [top, setTop] = useState(0);
const last = releases.length - 1;
useEffect(() => () => cancelAnimationFrame(raf.current), []);
const paint = (i: number, c: number) => {
const root = wall.current;
if (!root) return;
const t = tear(c);
const keep = root.querySelector(`[data-poster="${i}"]`);
const flap = root.querySelector(`[data-flap="${i}"]`);
if (keep) keep.style.clipPath = t.keep;
if (flap) {
flap.style.clipPath = t.flap;
flap.style.opacity = String(t.fade);
}
};
const run = (i: number, from: number, to: number, ms: number, done?: () => void) => {
cancelAnimationFrame(raf.current);
const begun = performance.now();
const tick = (now: number) => {
const p = ms ? Math.min(1, (now - begun) / ms) : 1;
paint(i, from + (to - from) * (p * p * (3 - 2 * p)));
if (p < 1) raf.current = requestAnimationFrame(tick);
else done?.();
};
raf.current = requestAnimationFrame(tick);
};
const show = (i: number) => {
busy.current = false;
setTop(i);
const r = releases[i];
if (r) onChange?.(r, i);
};
const older = (from = REST) => {
if (busy.current || top >= last) return;
busy.current = true;
run(top, from, END, reduce ? 0 : 620, () => show(top + 1));
};
const newer = () => {
if (busy.current || top <= 0) return;
busy.current = true;
const i = top - 1;
run(i, END, REST, reduce ? 0 : 480, () => show(i));
};
const down = (e: PointerEvent) => {
if (busy.current) return;
e.currentTarget.setPointerCapture(e.pointerId);
drag.current = { x: e.clientX, y: e.clientY, moved: false, c: REST };
};
const move = (e: PointerEvent) => {
const d = drag.current;
const root = wall.current;
if (!d || !root) return;
const pull = d.x - e.clientX + (d.y - e.clientY);
if (Math.abs(pull) > 5) d.moved = true;
d.c = Math.max(REST, Math.min(END, REST + (pull / root.offsetWidth) * 100 * 0.85));
paint(top, d.c);
};
const up = () => {
const d = drag.current;
drag.current = null;
if (!d) return;
if (!d.moved || d.c > REST + 15) {
older(d.c);
} else {
busy.current = true;
run(top, d.c, REST, 240, () => {
busy.current = false;
});
}
};
const brick = `url(#${id}b)`;
const current = releases[top];
return (
{releases.map((release, i) => {
if (i > top + 2) return null;
const torn = i < top;
const live = i === top && i < last;
const shape = torn ? tear(END) : live ? tear(REST) : null;
return (
{/* Back of the paper, folded over the tear. */}
{i === top && i < last && (
)}
);
})}
v{current?.version} ยท {top + 1} of {releases.length}
{releases.map((r) => (
Version {r.version}, {r.date}. {r.title}. {r.notes.map((n) => `${TAGS[n.kind]}: ${n.text}`).join(". ")}.