"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}

{/* 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) => (
  1. Version {r.version}, {r.date}. {r.title}. {r.notes.map((n) => `${TAGS[n.kind]}: ${n.text}`).join(". ")}.
  2. ))}
); }