Changelog
What shipped and when: dated entries with the change and why it matters.
Changelog timeline
changelog-01Releases on the kit's Timeline, newest first — version and day on the rail, then the name, why it matters and every change tagged New, Improved or Fixed — beside the ways to follow along.
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon, MailIcon, RssIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatDate } from "@/lib/format"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { StatusBadge, type StatusVariant } from "@/components/ui/status-badge"
import { Timeline, TimelineItem } from "@/components/ui/timeline"
import { ARCHIVE, EYEBROW, FOLLOW, HEADLINE, LATEST_LABEL, LEDE, READ_MORE, RELEASES, TAG_LABELS, type ChangeTag } from "./changelog-01.data"
/** A change's tone: the same three the kit's Changelog paints its tags in. The word carries the kind; the tint only repeats it. */
const TONES: Record<ChangeTag, StatusVariant> = { new: "success", improved: "info", fixed: "warning" }
/** "Sep 2, 2026", for the day as it was in UTC. */
const day = (iso: string) => formatDate(`${iso}T00:00:00Z`, "medium", { timeZone: "UTC" })
/**
* The changelog as a timeline: the claim and the two ways to follow along on
* the left, holding still from `lg` while the releases scroll past on the
* right, newest first. Each release is a stop on the kit's Timeline — its
* version and the day it shipped on the rail, then its name, why it matters,
* and every change with its kind, New, Improved or Fixed, as a kit badge in a
* column of its own so the words line up. The newest is marked Latest.
*
* Every date is a `time` printed in UTC, so the day never shifts with the
* reader's zone. Each release links to its notes, and the link names the
* release to a screen reader. A server component; every word is in
* `./changelog-01.data`.
*/
export function Changelog01() {
const titleId = React.useId()
return (
<section
data-section="changelog-01"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto grid max-w-7xl gap-x-16 gap-y-12 lg:grid-cols-12">
<div className="lg:sticky lg:top-24 lg:col-span-4 lg:self-start">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-4 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-5 text-base/7 text-pretty text-muted-foreground">{LEDE}</p>
<p className="mt-8 text-sm font-medium">{FOLLOW.title}</p>
<div className="mt-3 flex flex-wrap gap-2">
{FOLLOW.links.map((link) => (
<Link key={link.href} href={link.href} className={buttonVariants({ variant: "outline" })}>
{link.icon === "rss" ? (
<RssIcon aria-hidden="true" data-icon="inline-start" />
) : (
<MailIcon aria-hidden="true" data-icon="inline-start" />
)}
{link.label}
</Link>
))}
</div>
</div>
<div className="lg:col-span-8">
<Timeline>
{RELEASES.map((release, index) => (
<TimelineItem
key={release.version}
className="pb-12 last:pb-0"
title={
<span className="flex items-center gap-2">
<span className="font-mono">{release.version}</span>
{index === 0 ? <Badge variant="secondary">{LATEST_LABEL}</Badge> : null}
</span>
}
time={<time dateTime={release.date}>{day(release.date)}</time>}
>
<h3 className="mt-3 text-xl font-semibold text-balance">{release.title}</h3>
<p className="mt-2 text-base/7 text-pretty text-muted-foreground">{release.summary}</p>
<ul className="mt-5 flex flex-col gap-3">
{release.changes.map((change) => (
<li key={change.text} className="flex items-start gap-3 text-sm">
<StatusBadge
status={change.tag}
variant={TONES[change.tag]}
label={TAG_LABELS[change.tag]}
className="mt-px w-20 shrink-0 justify-center"
/>
<span className="text-pretty">{change.text}</span>
</li>
))}
</ul>
<Link href={release.href} className={cn(buttonVariants({ variant: "link" }), "group/notes mt-4 h-auto gap-1 self-start px-0")}>
{READ_MORE.label}
<span className="sr-only">, {READ_MORE.release.replace("{version}", release.version)}</span>
<ArrowRightIcon
aria-hidden="true"
data-icon="inline-end"
className="transition-transform duration-(--duration-fast) ease-(--ease-standard) group-hover/notes:translate-x-0.5"
/>
</Link>
</TimelineItem>
))}
</Timeline>
<Link href={ARCHIVE.href} className={cn(buttonVariants({ variant: "outline" }), "mt-10")}>
{ARCHIVE.label}
</Link>
</div>
</div>
</section>
)
}
export default Changelog01/**
* Every word `changelog-01` sets: Northwind's releases this summer, newest
* first — each its version, the day it shipped, its name, one line on why it
* matters, and its changes, each tagged New, Improved or Fixed — and the two
* ways to hear about the next one. A date is an ISO day, printed in UTC, so a
* reader in any time zone sees the day the release went out.
*/
export type ChangeTag = "new" | "improved" | "fixed"
export type Change = { tag: ChangeTag; text: string }
export type Release = {
version: string
/** The day it shipped, as an ISO date. */
date: string
title: string
summary: string
changes: readonly Change[]
href: string
}
export type FollowLink = { icon: "rss" | "email"; label: string; href: string }
export const EYEBROW = "Changelog"
export const HEADLINE = "What changed in Northwind"
export const LEDE = "Every release this summer, newest first: what it adds, what it improves and what it fixes."
/** The words a tag prints, in the order a release lists its changes. */
export const TAG_LABELS: Record<ChangeTag, string> = { new: "New", improved: "Improved", fixed: "Fixed" }
export const FOLLOW = {
title: "Hear about the next one",
links: [
{ icon: "rss", label: "RSS feed", href: "/changelog/rss.xml" },
{ icon: "email", label: "Email me each release", href: "/changelog/subscribe" },
] satisfies FollowLink[],
}
/** The link under each release; `{version}` is said only to a screen reader, so four links never share a name. */
export const READ_MORE = { label: "Read the release notes", release: "Northwind {version}" }
/** The mark on the newest release. */
export const LATEST_LABEL = "Latest"
export const ARCHIVE = { label: "Every earlier release", href: "/changelog/archive" }
export const RELEASES: readonly Release[] = [
{
version: "4.8",
date: "2026-09-02",
title: "Cohort reports in every plan",
summary: "Retention by signup month used to be a Business report. Now every workspace has it, built from the billing it already reads.",
changes: [
{ tag: "new", text: "Cohort reports on Starter and Team, by month or by week" },
{ tag: "improved", text: "The retention chart opens three times faster on long histories" },
{ tag: "fixed", text: "Cohorts no longer count a reactivated account twice" },
],
href: "/changelog/4-8",
},
{
version: "4.7",
date: "2026-08-19",
title: "Alerts that say why",
summary: "An alert now names the accounts behind the change, so the first reply is an answer rather than a question.",
changes: [
{ tag: "new", text: "Every alert lists the accounts that moved the figure most" },
{ tag: "new", text: "Quiet hours hold alerts until the morning in each person's time zone" },
{ tag: "fixed", text: "Weekly alerts no longer arrive a day early in time zones ahead of UTC" },
],
href: "/changelog/4-7",
},
{
version: "4.6",
date: "2026-08-05",
title: "Exports on a schedule",
summary: "Any table or report can now land as a CSV or PDF on the day and hour you choose, with a link back to the live version.",
changes: [
{ tag: "new", text: "Scheduled exports, daily, weekly or monthly" },
{ tag: "improved", text: "CSV exports of 100,000 rows finish in under 20 seconds" },
{ tag: "fixed", text: "Currency columns keep two decimals in exported files" },
],
href: "/changelog/4-6",
},
{
version: "4.5",
date: "2026-07-22",
title: "Permissions down to the row",
summary: "Rules decide which accounts each person sees, so a regional team or a partner can share a dashboard without seeing anyone else's customers.",
changes: [
{ tag: "new", text: "Row rules by region, owner or any account field" },
{ tag: "improved", text: "The audit log can be searched by person and by dashboard" },
],
href: "/changelog/4-5",
},
]npx shadcn@latest add @vibra/changelog-01Release cards
changelog-02The last six releases as cards, the newest large with its highlights, each with its version, its day and how many of its changes are new, improved or fixed.
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon, CheckIcon, MailIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatDate } from "@/lib/format"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { StatusBadge, type StatusVariant } from "@/components/ui/status-badge"
import {
EYEBROW,
HEADLINE,
HIGHLIGHTS,
HIGHLIGHTS_LABEL,
LATEST_LABEL,
LEDE,
OPEN_LABEL,
RELEASES,
SUBSCRIBE,
TAG_ORDER,
TAG_WORDS,
type ChangeTag,
type ReleaseCard,
} from "./changelog-02.data"
/** A kind's tone: the same three the kit's Changelog paints its tags in; the word carries the kind. */
const TONES: Record<ChangeTag, StatusVariant> = { new: "success", improved: "info", fixed: "warning" }
/** "Sep 2, 2026", for the day as it was in UTC. */
const day = (iso: string) => formatDate(`${iso}T00:00:00Z`, "medium", { timeZone: "UTC" })
/**
* One release as a card that is one link: its name is the link, stretched
* over the card, so a keyboard stops once per release and hears its name, and
* the focus outline is drawn round the whole card. The newest card is larger
* and lists its highlights.
*/
function ReleaseTile({ release, latest, className }: { release: ReleaseCard; latest?: boolean; className?: string }) {
return (
<li className={cn("flex min-w-0", className)}>
<Card variant="interactive" className="relative w-full">
<CardHeader className="gap-2 py-3.5">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline" className="font-mono">
{release.version}
</Badge>
<time dateTime={release.date} className="tabular-nums">
{day(release.date)}
</time>
{latest ? <Badge variant="secondary">{LATEST_LABEL}</Badge> : null}
</div>
<CardTitle>
<h3 className={latest ? "text-xl text-balance md:text-2xl" : "text-base text-balance"}>
<Link
href={release.href}
className="outline-none after:absolute after:inset-0 after:rounded-xl focus-visible:after:focus-outline"
>
{release.title}
</Link>
</h3>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-5">
<p className={cn("text-pretty text-muted-foreground", latest ? "text-base/7" : "text-sm")}>{release.summary}</p>
{latest ? (
<div className="flex flex-col gap-2.5">
<p className="type-eyebrow">{HIGHLIGHTS_LABEL}</p>
<ul className="flex flex-col gap-2 text-sm">
{HIGHLIGHTS.map((line) => (
<li key={line} className="flex items-start gap-2">
<CheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<span className="text-pretty">{line}</span>
</li>
))}
</ul>
</div>
) : null}
<div className="mt-auto flex items-end justify-between gap-3">
<ul className="flex flex-wrap gap-1.5">
{TAG_ORDER.filter((tag) => release.counts[tag]).map((tag) => (
<li key={tag}>
<StatusBadge status={tag} variant={TONES[tag]} label={`${release.counts[tag]} ${TAG_WORDS[tag]}`} />
</li>
))}
</ul>
<span aria-hidden="true" className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
{latest ? OPEN_LABEL : null}
<ArrowRightIcon className="size-3.5" />
</span>
</div>
</CardContent>
</Card>
</li>
)
}
/**
* The changelog as a grid of release cards: the newest large, two columns by
* two rows from `lg`, with its highlights; the five before it around it, each
* its version, its day, its name, one line and how many of its changes are
* new, improved or fixed — counts on the kit's badges, in the tone the kit's
* Changelog gives each kind. Every card is one link to its notes.
*
* Three columns from `lg`, two on a tablet, one on a phone. Dates are `time`s
* printed in UTC. A server component; every word is in `./changelog-02.data`.
*/
export function Changelog02() {
const titleId = React.useId()
const [latest, ...earlier] = RELEASES
return (
<section
data-section="changelog-02"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="flex flex-wrap items-end justify-between gap-x-10 gap-y-6">
<div className="max-w-2xl">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-4 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-5 text-base/7 text-pretty text-muted-foreground md:text-lg/8">{LEDE}</p>
</div>
<Link href={SUBSCRIBE.href} className={buttonVariants({ variant: "outline" })}>
<MailIcon aria-hidden="true" data-icon="inline-start" />
{SUBSCRIBE.label}
</Link>
</div>
<ul className="mt-12 grid gap-4 md:mt-14 md:grid-cols-2 lg:grid-cols-3">
{latest ? <ReleaseTile release={latest} latest className="md:col-span-2 lg:row-span-2" /> : null}
{earlier.map((release, index) => (
<ReleaseTile
key={release.version}
release={release}
// Five cards leave one alone on a tablet's last row; it takes the row.
className={index === earlier.length - 1 ? "md:col-span-2 lg:col-span-1" : undefined}
/>
))}
</ul>
</div>
</section>
)
}
export default Changelog02/**
* Every word `changelog-02` sets: Northwind's last six releases as cards,
* newest first — each its version, its day, its name, one line on what it
* does and how many of its changes are new, improved or fixed. The newest
* also lists its three highlights. A date is an ISO day, printed in UTC.
*/
export type ChangeTag = "new" | "improved" | "fixed"
export type ReleaseCard = {
version: string
/** The day it shipped, as an ISO date. */
date: string
title: string
summary: string
/** How many changes of each kind it holds; a kind it has none of is left out. */
counts: Partial<Record<ChangeTag, number>>
href: string
}
export const EYEBROW = "Releases"
export const HEADLINE = "Six releases since June"
export const LEDE = "A release every two weeks, each one small enough to read in a minute and switched on for every workspace the day it ships."
export const SUBSCRIBE = { label: "Get each release by email", href: "/changelog/subscribe" }
/** How a count reads on its badge: "2 new", "1 fixed". */
export const TAG_WORDS: Record<ChangeTag, string> = { new: "new", improved: "improved", fixed: "fixed" }
export const TAG_ORDER: readonly ChangeTag[] = ["new", "improved", "fixed"]
export const LATEST_LABEL = "Latest"
export const HIGHLIGHTS_LABEL = "Highlights"
export const OPEN_LABEL = "Read the release"
/** The newest release's three highlights, shown on its card. */
export const HIGHLIGHTS = [
"Cohort reports on Starter and Team, by month or by week",
"The retention chart opens three times faster on long histories",
"Reactivated accounts are counted once in every cohort",
]
export const RELEASES: readonly ReleaseCard[] = [
{
version: "4.8",
date: "2026-09-02",
title: "Cohort reports in every plan",
summary: "Retention by signup month, once a Business report, now in every workspace and built from the billing it already reads.",
counts: { new: 1, improved: 1, fixed: 1 },
href: "/changelog/4-8",
},
{
version: "4.7",
date: "2026-08-19",
title: "Alerts that say why",
summary: "Each alert names the accounts behind the change.",
counts: { new: 2, fixed: 1 },
href: "/changelog/4-7",
},
{
version: "4.6",
date: "2026-08-05",
title: "Exports on a schedule",
summary: "Any table as a CSV or PDF, on the day and hour you choose.",
counts: { new: 1, improved: 1, fixed: 1 },
href: "/changelog/4-6",
},
{
version: "4.5",
date: "2026-07-22",
title: "Permissions down to the row",
summary: "Rules decide which accounts each person sees.",
counts: { new: 1, improved: 1 },
href: "/changelog/4-5",
},
{
version: "4.4",
date: "2026-07-08",
title: "Forecasts with a range",
summary: "Every forecast shows how far it could move, and why.",
counts: { new: 1, improved: 2 },
href: "/changelog/4-4",
},
{
version: "4.3",
date: "2026-06-24",
title: "Faster imports",
summary: "A 100,000-row CSV now imports in under a minute.",
counts: { improved: 2, fixed: 2 },
href: "/changelog/4-3",
},
]npx shadcn@latest add @vibra/changelog-02Changelog with a filter
changelog-03Eight releases on the kit's Changelog under a filter by kind — all, new, improved or fixed — whose options say how many releases each keeps, with a line that counts what is showing.
import * as React from "react"
import { ChangelogFilter } from "./changelog-03.client"
import { EYEBROW, FILTER, HEADLINE, LEDE, RELEASES } from "./changelog-03.data"
/**
* The filterable changelog: eight releases on the kit's Changelog — each its
* day, its version, the kinds of change it holds as kit badges, its name, one
* line and what it changed — under a filter that keeps only the releases with
* a kind the reader chose: new, improved or fixed. Each option says how many
* releases it keeps, and the line beside it says how many of all of them are
* showing.
*
* One column at a readable measure; on a phone the filter takes the full
* width, its four options sharing it. A server component; the filter and the
* list are the one island, and every word is in `./changelog-03.data`.
*/
export function Changelog03() {
const titleId = React.useId()
return (
<section
data-section="changelog-03"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="mx-auto max-w-3xl">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-4 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-5 text-base/7 text-pretty text-muted-foreground md:text-lg/8">{LEDE}</p>
<div className="mt-10 md:mt-12">
<ChangelogFilter releases={RELEASES} copy={FILTER} />
</div>
</div>
</div>
</section>
)
}
export default Changelog03"use client"
import * as React from "react"
import Link from "next/link"
import { Changelog } from "@/components/ui/changelog"
import { SegmentedControl } from "@/components/ui/segmented-control"
import type { FilterCopy, FilterValue, Release } from "./changelog-03.data"
const fill = (template: string, values: Record<string, number>) =>
template.replace(/\{(\w+)\}/g, (_, key: string) => String(values[key] ?? ""))
/**
* The filter and the list it narrows. The filter is the kit's
* SegmentedControl — one choice, read as a radio group, the arrow keys moving
* and choosing — and each option carries how many releases it keeps, a figure
* to the eye and words to a screen reader. The list is the kit's Changelog,
* handed only the releases that hold the chosen kind; each release's name is
* an `h3` holding the link to its notes. The line between them says how many
* of all the releases are showing, in a status region that is always in the
* page, so a change of filter is heard where the focus stays.
*
* Every release and word arrives as plain props from the section.
*/
export function ChangelogFilter({ releases, copy }: { releases: readonly Release[]; copy: FilterCopy }) {
const [filter, setFilter] = React.useState<FilterValue>("all")
const statusId = React.useId()
const keep = (value: FilterValue) => (value === "all" ? releases : releases.filter((release) => release.tags.includes(value)))
const shown = keep(filter)
const total = releases.length
const options = copy.options.map((option) => {
const count = keep(option.value).length
return {
value: option.value,
label: (
<>
{option.label}
<span aria-hidden="true" className="ms-1.5 text-muted-foreground tabular-nums">
{count}
</span>
<span className="sr-only">, {fill(count === 1 ? copy.count.one : copy.count.other, { count })}</span>
</>
),
}
})
return (
<div className="flex flex-col">
<div className="flex flex-col gap-3 border-b border-rule pb-5 sm:flex-row sm:items-center sm:justify-between">
<SegmentedControl
options={options}
value={filter}
onValueChange={(value) => setFilter(value as FilterValue)}
aria-label={copy.label}
aria-describedby={statusId}
className="max-sm:w-full max-sm:[&>button]:flex-1 max-sm:[&>button]:px-1"
/>
<p id={statusId} role="status" aria-live="polite" className="text-sm text-muted-foreground tabular-nums">
{fill(copy.status[filter], { count: shown.length, total })}
</p>
</div>
<Changelog
// On a phone the date sits over its release: the list in one column.
className="mt-6 max-sm:grid-cols-1"
entries={shown.map((release) => ({
version: release.version,
date: `${release.date}T00:00:00Z`,
tags: [...release.tags],
title: (
<h3 className="text-base font-semibold text-balance">
<Link href={release.href} className="rounded-sm focus-ring hover:underline">
{release.title}
</Link>
</h3>
),
description: release.description,
items: [...release.items],
}))}
/>
</div>
)
}/**
* Every word `changelog-03` sets: eight Northwind releases, newest first, each
* with the kinds of change it holds — new, improved, fixed — which is what the
* filter reads, and the words of the filter itself. `{count}` and `{total}`
* are filled in by the island. A date is an ISO day, printed in UTC.
*/
export type ChangeTag = "new" | "improved" | "fixed"
export type Release = {
version: string
/** The day it shipped, as an ISO date. */
date: string
title: string
description: string
tags: readonly ChangeTag[]
items: readonly string[]
href: string
}
export type FilterValue = "all" | ChangeTag
export const EYEBROW = "Changelog"
export const HEADLINE = "Every change, filtered to the kind you care about"
export const LEDE =
"Finance reads the fixes, the product team reads what is new. Pick a kind and the list keeps only the releases that hold one."
export const FILTER = {
label: "Show releases with",
options: [
{ value: "all", label: "All" },
{ value: "new", label: "New" },
{ value: "improved", label: "Improved" },
{ value: "fixed", label: "Fixed" },
] satisfies { value: FilterValue; label: string }[],
/** How an option's count is read out: "5 releases". */
count: { one: "{count} release", other: "{count} releases" },
/** The line under the filter, always in the page and read out when it changes. */
status: {
all: "All {total} releases, newest first",
new: "{count} of {total} releases add something new",
improved: "{count} of {total} releases improve something",
fixed: "{count} of {total} releases fix something",
} satisfies Record<FilterValue, string>,
}
export type FilterCopy = typeof FILTER
export const RELEASES: readonly Release[] = [
{
version: "4.8",
date: "2026-09-02",
title: "Cohort reports in every plan",
description: "Retention by signup month, now in every workspace.",
tags: ["new", "improved", "fixed"],
items: ["Cohort reports on Starter and Team", "The retention chart opens three times faster", "A reactivated account is counted once"],
href: "/changelog/4-8",
},
{
version: "4.7",
date: "2026-08-19",
title: "Alerts that say why",
description: "Each alert names the accounts behind the change.",
tags: ["new", "fixed"],
items: ["Alerts list the accounts that moved the figure", "Weekly alerts arrive on the right day in every time zone"],
href: "/changelog/4-7",
},
{
version: "4.6",
date: "2026-08-05",
title: "Exports on a schedule",
description: "Any table as a CSV or PDF, on the day and hour you choose.",
tags: ["new", "improved", "fixed"],
items: ["Daily, weekly or monthly exports", "100,000-row CSVs in under 20 seconds", "Currency columns keep two decimals"],
href: "/changelog/4-6",
},
{
version: "4.5",
date: "2026-07-22",
title: "Permissions down to the row",
description: "Rules decide which accounts each person sees.",
tags: ["new", "improved"],
items: ["Row rules by region, owner or any account field", "Search the audit log by person and dashboard"],
href: "/changelog/4-5",
},
{
version: "4.4",
date: "2026-07-08",
title: "Forecasts with a range",
description: "Every forecast shows how far it could move.",
tags: ["new", "improved"],
items: ["A shaded range around every forecast line", "Forecasts refit when a deal changes stage"],
href: "/changelog/4-4",
},
{
version: "4.3",
date: "2026-06-24",
title: "Faster imports",
description: "A 100,000-row CSV now imports in under a minute.",
tags: ["improved", "fixed"],
items: ["Imports run four times faster", "A CRM merge no longer leaves duplicate accounts"],
href: "/changelog/4-3",
},
{
version: "4.2",
date: "2026-06-10",
title: "Two-step sign-in for everyone",
description: "Owners can require a second step for the whole workspace.",
tags: ["new", "fixed"],
items: ["Require two-step sign-in from Settings, Security", "Recovery codes download as a text file again"],
href: "/changelog/4-2",
},
{
version: "4.1",
date: "2026-05-27",
title: "Compare with last year",
description: "Every chart can draw the same period a year earlier behind it.",
tags: ["new", "improved"],
items: ["A previous-year line on every time chart", "Date ranges remember the last one you used"],
href: "/changelog/4-1",
},
]npx shadcn@latest add @vibra/changelog-03Release notes
changelog-04One release in full: its name and day, the feature drawn from kit parts, every change with its kind, the facts beside it, the release before, and a question that asks whether it helped.
import * as React from "react"
import Link from "next/link"
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatDate } from "@/lib/format"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { HeatmapGrid } from "@/components/ui/heatmap-grid"
import { StatusBadge, type StatusVariant } from "@/components/ui/status-badge"
import { ReleaseFeedback } from "./changelog-04.client"
import { CHANGES, CHANGES_LABEL, FACTS, FEEDBACK, NEIGHBOURS, RELEASE, SCREEN, TAG_LABELS, type ChangeTag } from "./changelog-04.data"
/** A change's tone: the same three the kit's Changelog paints its tags in; the word carries the kind. */
const TONES: Record<ChangeTag, StatusVariant> = { new: "success", improved: "info", fixed: "warning" }
/** "Sep 2, 2026", for the day as it was in UTC. */
const day = (iso: string) => formatDate(`${iso}T00:00:00Z`, "medium", { timeZone: "UTC" })
/**
* The feature, as the workspace shows it, built from the kit rather than
* photographed: a card holding the cohort grid — the kit's HeatmapGrid, each
* cell the share of a signup month still paying — so it takes the reader's
* palette and never goes stale. It is a picture, hidden and inert; the
* caption under it says what it shows.
*/
function CohortScreen() {
return (
<div aria-hidden="true" inert className="frame p-2 md:p-3">
<Card className="bg-transparent ring-0">
<CardHeader>
<CardTitle>{SCREEN.title}</CardTitle>
<CardDescription>{SCREEN.description}</CardDescription>
<CardAction>
<span className="inline-flex rounded-lg bg-muted p-0.5 text-xs font-medium text-muted-foreground">
{SCREEN.modes.map((mode, index) => (
<span key={mode} className={cn("rounded-md px-2 py-1", index === 0 && "bg-card text-foreground shadow-(--shadow-sheet)")}>
{mode}
</span>
))}
</span>
</CardAction>
</CardHeader>
<CardContent className="flex justify-center py-6">
<HeatmapGrid
rows={SCREEN.rows}
columns={SCREEN.columns}
values={SCREEN.values.map((row) => [...row])}
min={60}
max={100}
showValues
cellSize={44}
className="w-fit"
/>
</CardContent>
</Card>
</div>
)
}
/**
* One release, told in full: its version and day over its name, the question
* it answers, the feature as the workspace draws it, and every change with its
* kind as a kit badge — then whether it helped. Beside it, from `lg`, the
* release's facts as a description list and the way to the release before,
* holding still while the notes scroll.
*
* The picture is built from the kit and hidden from assistive technology; its
* caption carries its figures. Dates are `time`s printed in UTC. A server
* component; the feedback question is the one island, and every word and
* figure is in `./changelog-04.data`.
*/
export function Changelog04() {
const titleId = React.useId()
const changesId = React.useId()
const captionId = React.useId()
return (
<section
data-section="changelog-04"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="max-w-3xl">
<p className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<Badge variant="outline" className="font-mono">
{RELEASE.version}
</Badge>
<time dateTime={RELEASE.date}>{day(RELEASE.date)}</time>
</p>
<h2 id={titleId} className="type-display mt-4 text-3xl text-balance md:text-4xl">
{RELEASE.title}
</h2>
<p className="mt-5 text-base/7 text-pretty text-muted-foreground md:text-lg/8">{RELEASE.lede}</p>
</div>
<div className="mt-12 grid gap-x-16 gap-y-12 md:mt-14 lg:grid-cols-12">
<div className="flex min-w-0 flex-col gap-12 lg:col-span-8">
<figure aria-labelledby={captionId} className="flex flex-col gap-3">
<CohortScreen />
<figcaption id={captionId} className="text-sm text-pretty text-muted-foreground">
{SCREEN.caption}
</figcaption>
</figure>
<div>
<h3 id={changesId} className="text-lg font-semibold">
{CHANGES_LABEL}
</h3>
<ul aria-labelledby={changesId} className="mt-4 flex flex-col border-t border-border">
{CHANGES.map((change) => (
<li key={change.title} className="grid gap-x-4 gap-y-2 border-b border-border py-5 sm:grid-cols-[6rem_minmax(0,1fr)]">
<StatusBadge status={change.tag} variant={TONES[change.tag]} label={TAG_LABELS[change.tag]} className="w-20 justify-center sm:mt-0.5" />
<div>
<h4 className="text-base font-medium text-pretty">{change.title}</h4>
<p className="mt-1 text-sm/6 text-pretty text-muted-foreground">{change.body}</p>
</div>
</li>
))}
</ul>
</div>
<ReleaseFeedback copy={FEEDBACK} />
</div>
<aside aria-label={FACTS.label} className="flex flex-col gap-6 lg:sticky lg:top-24 lg:col-span-4 lg:self-start">
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-6 gap-y-3 frame p-5 text-sm">
<dt className="text-muted-foreground">{FACTS.terms.version}</dt>
<dd className="font-mono">{RELEASE.version}</dd>
<dt className="text-muted-foreground">{FACTS.terms.released}</dt>
<dd>
<time dateTime={RELEASE.date}>{day(RELEASE.date)}</time>
</dd>
<dt className="text-muted-foreground">{FACTS.terms.plans}</dt>
<dd>{FACTS.plans}</dd>
<dt className="text-muted-foreground">{FACTS.terms.rollout}</dt>
<dd>{FACTS.rollout}</dd>
</dl>
<nav aria-label={NEIGHBOURS.label} className="flex flex-col gap-2">
<Link href={NEIGHBOURS.previous.href} className="group/prev flex items-center gap-3 rounded-lg p-3 ring-1 ring-border transition-colors duration-(--duration-fast) ease-(--ease-standard) focus-ring hover:bg-muted/50">
<ArrowLeftIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground transition-transform duration-(--duration-fast) ease-(--ease-standard) group-hover/prev:-translate-x-0.5" />
<span className="flex min-w-0 flex-col">
<span className="text-xs text-muted-foreground">
{NEIGHBOURS.previousLabel}, <span className="font-mono">{NEIGHBOURS.previous.version}</span>
</span>
<span className="truncate text-sm font-medium">{NEIGHBOURS.previous.title}</span>
</span>
</Link>
<Link href={NEIGHBOURS.all.href} className={cn(buttonVariants({ variant: "link" }), "group/all h-auto gap-1 self-start px-0")}>
{NEIGHBOURS.all.label}
<ArrowRightIcon aria-hidden="true" data-icon="inline-end" className="transition-transform duration-(--duration-fast) ease-(--ease-standard) group-hover/all:translate-x-0.5" />
</Link>
</nav>
</aside>
</div>
</div>
</section>
)
}
export default Changelog04"use client"
import * as React from "react"
import { ThumbsDownIcon, ThumbsUpIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
export type FeedbackCopy = { question: string; yes: string; no: string; thanks: string; follow: string }
/**
* The one question under a release: did it help. Two buttons that each say
* which answer they are and whether they are the one given — a reader can
* change their mind — and a status line that is always in the page, empty
* until an answer, then thanking them in words that fit the answer: a yes
* goes to the team, a not-yet gets the address to say why.
*
* It keeps the answer on the page only. Send it where your product listens —
* an analytics event or a form — from `answer`.
*/
export function ReleaseFeedback({ copy }: { copy: FeedbackCopy }) {
const [answer, setAnswer] = React.useState<"yes" | "no" | null>(null)
const questionId = React.useId()
return (
<div className="flex flex-col gap-3">
<div role="group" aria-labelledby={questionId} className="flex flex-wrap items-center gap-x-4 gap-y-3">
<p id={questionId} className="text-sm font-medium">
{copy.question}
</p>
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm" aria-pressed={answer === "yes"} onClick={() => setAnswer("yes")} className="aria-pressed:border-transparent aria-pressed:bg-brand-muted">
<ThumbsUpIcon aria-hidden="true" data-icon="inline-start" />
{copy.yes}
</Button>
<Button type="button" variant="outline" size="sm" aria-pressed={answer === "no"} onClick={() => setAnswer("no")} className="aria-pressed:border-transparent aria-pressed:bg-brand-muted">
<ThumbsDownIcon aria-hidden="true" data-icon="inline-start" />
{copy.no}
</Button>
</div>
</div>
<p role="status" aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
{answer === "yes" ? copy.thanks : answer === "no" ? copy.follow : null}
</p>
</div>
)
}/**
* Every word `changelog-04` sets: one Northwind release told in full — its
* facts, what it adds, improves and fixes, the picture of the feature it ships
* and the figures in that picture, the question that asks whether it helped
* and the releases either side of it. The picture's figures are copy, like
* the headline: a sample workspace's cohorts, written once here. A date is an
* ISO day, printed in UTC.
*/
export type ChangeTag = "new" | "improved" | "fixed"
export type Change = { tag: ChangeTag; title: string; body: string }
export type ReleaseLink = { version: string; title: string; href: string }
export const RELEASE = {
version: "4.8",
date: "2026-09-02",
title: "Cohort reports, in every plan",
lede:
"How many of the customers who signed up in April are still paying in September? Until this release only Business could ask. Now every workspace can, from the billing it already reads.",
}
/** The release's facts, in the order a reader checks them; the version and the day come from `RELEASE`. */
export const FACTS = {
label: "About this release",
terms: { version: "Version", released: "Released", plans: "Plans", rollout: "Rollout" },
plans: "Starter, Team and Business",
rollout: "Every workspace by September 4",
}
/** What the tags print. */
export const TAG_LABELS: Record<ChangeTag, string> = { new: "New", improved: "Improved", fixed: "Fixed" }
export const CHANGES_LABEL = "What changed"
export const CHANGES: readonly Change[] = [
{
tag: "new",
title: "Cohort reports on Starter and Team",
body: "Group customers by the month or week they signed up and follow each group's revenue, accounts or seats from there.",
},
{
tag: "new",
title: "Cohorts by plan and by region",
body: "Split any cohort report by the plan a customer started on or the region they bill from.",
},
{
tag: "improved",
title: "The retention chart opens three times faster",
body: "Workspaces with five years of billing history now draw it in under a second.",
},
{
tag: "fixed",
title: "A reactivated account is counted once",
body: "A customer who cancelled and came back used to appear in two cohorts. It now stays in the one it started in.",
},
]
/** The picture of the feature: a cohort grid as a workspace shows it, with what a screen reader hears instead. */
export const SCREEN = {
title: "Cohort retention",
description: "Share of each signup month still paying, by months since signup",
modes: ["By month", "By week"],
rows: ["Apr", "May", "Jun", "Jul", "Aug"],
columns: ["0", "1", "2", "3", "4", "5"],
/** Percent of each cohort still paying; a month that has not happened yet is left out. */
values: [
[100, 86, 79, 75, 73, 72],
[100, 88, 81, 78, 76],
[100, 87, 82, 79],
[100, 90, 84],
[100, 91],
],
caption:
"A cohort report in Northwind 4.8. Of the customers who signed up in April, 86% were still paying a month later and 72% five months later; the May to August cohorts are on the same path or ahead of it.",
}
export const FEEDBACK = {
question: "Did this release help your team?",
yes: "Yes",
no: "Not yet",
thanks: "Thank you. Your answer goes to the team that built it.",
follow: "Thank you. Tell us what would help at feedback@northwind.example.",
}
export const NEIGHBOURS = {
label: "More releases",
previousLabel: "Previous release",
previous: { version: "4.7", title: "Alerts that say why", href: "/changelog/4-7" } satisfies ReleaseLink,
all: { label: "Every release", href: "/changelog" },
}npx shadcn@latest add @vibra/changelog-04