API Changelog Diff
An endpoint-level diff between two API versions — grouped by added, removed, changed and deprecated, expandable to the field-level story, with breaking counted apart from additive and every sunset window counted in whole days.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/api-changelog-diff.jsonPrompt
Build a React + TypeScript + Tailwind "ApiChangelogDiff" component with zod,
lucide-react and the shadcn Badge / Button / DropdownMenu primitives — what
changed between two versions of an HTTP API, read-only.
Contract
- A sibling zod contract file is the single source of truth:
version = { id, label, releasedAt? } (array order = oldest → newest;
releasedAt is never rendered — the picker shows label — it exists
so superRefine can verify that order)
detail = { location: "request" | "query" | "path" | "header" | "response",
path, effect, before?, after?, note? }
change = { id, endpoint, method, kind: "removed" | "changed" | "deprecated"
| "added", breaking, summary?, detail[], sunsetAt? }
data = { status: "loading" | "empty" | "error" | "ready", versions[],
from, to, changes[], generatedAt?, errorMessage? }
- effect is a closed vocabulary, not free text: field-added-optional,
field-added-required, field-removed, type-changed, requirement-added,
requirement-removed, enum-widened, enum-narrowed, constraint-tightened,
constraint-relaxed, default-changed. Every member has a defined verdict on
both surfaces, so nothing can arrive unclassified.
- Props are z.infer of that object plus: now, locale = "en-US",
timeZone = "UTC", urgentWithinDays = 30, onRangeChange?, onRetry?,
breakingOnly / defaultBreakingOnly / onBreakingOnlyChange,
expandedIds / defaultExpandedIds / onExpandedIdsChange (both controllable the
Radix way: the prop wins when set, otherwise internal state, and the handler
always fires), emptyState?, skeletonRows = 5, className, native div props.
- `from` and `to` are DATA, never internal state. The component cannot diff two
specifications, so picking a version emits onRangeChange and waits for a fresh
payload; rewriting the labels over a stale change list would be a lie.
- superRefine rejects: duplicate version ids, duplicate change ids, a versions[]
whose releasedAt values run newest-first, a from/to that names no declared
version, from === to, a "removed" change flagged non-breaking, and an "added"
change flagged breaking. Guard each check independently — zod runs every check
even after one has failed. Membership is only checked when versions[] is
non-empty, so a failed load can still name the pair it was asked for.
Behavior
- Four first-class branches on `status`; `ready` with zero changes resolves to
`empty`. The version header renders in ALL of them, because the version
catalogue is a different endpoint from the diff.
- The compatibility rule is the whole point, and it follows from two clauses:
(1) Existence — a caller depends only on what already existed. Dropping a
field the API READS is safe; dropping a field it RETURNS is not. Adding to
a request is safe unless required; adding to a response is always safe.
(2) Domain — for a field on both sides, a REQUEST breaks when its accepted set
shrinks, a RESPONSE breaks when its produced set widens.
So enum-widened is additive on a request and BREAKING on a response;
enum-narrowed is the mirror image; constraint-relaxed is safe going in and
breaking coming out. Export the verdict function so a CI gate can reuse it.
- kind === "added" short-circuits every field verdict to additive: on a route
that did not exist in the base version, a new required body field cannot break
anyone. Skipping this is the most common false positive in naive differs.
- Two numbers, kept apart: the headline breaking/additive counts follow the
`breaking` flag PUBLISHED with each change (the API owner's claim), while each
field badge is derived from location + effect. Where they disagree, the
expanded row says so in one line instead of silently overruling either.
- Grouping is by blast radius, not alphabet: removed → changed → deprecated →
added, each group headed by a count and one line about what it means for a
caller.
- Rows are disclosures (aria-expanded + aria-controls). A change with an empty
detail[] renders as a PLAIN row — no chevron, no handler, and a line saying no
field-level detail was reported — because a control that opens onto nothing is
a promise the data cannot keep. Panels stay mounted and use `hidden`, so
aria-controls always points at an element that exists.
- Deprecation column: the announced sunset date plus whole days remaining,
floored, measured against an INJECTED `now`. No `now`, no countdown — dates
still render. Past the date it counts days elapsed instead. It reports what
the payload says and promises nothing about the day itself.
- Filter: "Breaking only". It keeps a row when the published flag says breaking
OR when a field-level entry under it derives breaking, because the reader who
reaches for this filter is asking what will break them and a change shipped as
additive can still carry one. The summary panel is scoped to what is left —
split, bar and the four cells share ONE denominator — while the toggle keeps
naming the total ("5/9"). Filtering to zero rows gets its OWN panel ("no
breaking changes between X and Y", true under both rules by then) plus a way
back — good news is not the same screen as "these versions are identical".
"Expand all" only appears when something is expandable, and its label flips to
"Collapse all".
- The footnotes under the list only describe what is on screen: the derivation
note renders when rows do, and spells out how many changes are published as
additive yet carry a breaking entry — which is exactly the gap between the
toggle count and the headline breaking count; the sunset note renders only when
a VISIBLE row carries a date, and its "counted in whole days" clause only when
`now` is set.
- Version pickers: two dropdown radio groups, newest-first. A version that would
invert the range is disabled WITH the reason printed in the item ("older than
the base version"), never left as a mute dead row — and the item overrides the
50% dim Radix puts on a disabled item, or the one sentence explaining the block
would be the least legible text in the component; the muted label carries the
disabled state instead. Omit onRangeChange and both sides degrade to plain
labels.
- Composition percentages use largest remainder (floor, then hand leftover
units to the biggest fractional remainders, ties to the earlier bucket) so the
bar totals exactly 100 in every filter state; buckets with an exact share —
every empty one among them — are excluded from the hand-out.
- Retry is one-shot: a ref read and written in the same synchronous handler, so
a double click sends one request; the lock is keyed on the PAYLOAD (status,
message and change list together), not on the status string — a retry that
fails again lands as another `error`, and a status-keyed lock would leave the
button enabled and permanently inert. When the retry button unmounts under the
reader, focus moves to the panel that replaced it (tabIndex={-1}), never to
<body>. "Show every change" unmounts itself, so it hands focus to the filter
toggle first.
- No timers, intervals, observers or animation frames anywhere — there is
nothing to tear down, and nothing that can outlive the component.
Rendering & styling
- Semantic tokens only: bg-card / border shell, bg-muted + text-muted-foreground
for secondary text, skeletons and safe (read-only) methods, bg-primary/10 +
text-primary for mutating methods, bg-destructive/10 + text-destructive for
DELETE, breaking badges, urgent sunsets and the error branch. Method tone
follows RFC 9110 safety — safe / mutating / destructive — not a per-verb
palette, and the verb is always spelled out so colour is reinforcement only.
- Row grid: chevron column, a min-w-0 body column, and a third column reserved
for the deprecation window that drops under the body below the sm breakpoint,
so sunset dates line up as a column when there is room and never clip when
there is not. Routes are font-mono with break-all.
- The composition bar is aria-hidden; the same figures are spelled out in the
four stat cells beneath it, so no number is carried by width or colour alone.
- Accessibility: one polite live region for filter, expand and range changes;
each group is a labelled section; focus-visible rings on every control; the
chevron transition and skeleton pulse both carry motion-reduce:*-none.
- cn() merges the consumer className into the root; the rest of the props spread
onto it.
Customization levers
- Policy: EFFECT_META is the single table to tune. `default-changed` on a request
is treated as breaking here (a silent behaviour change for every caller that
omits the field) which is stricter than most spec differs; `field-removed` on a
request is treated as safe, which is wrong under additionalProperties: false.
Flip either boolean and every badge, count and filter follows.
- Grouping: KIND_ORDER sets both the section order and the composition bar order.
Swap in alphabetical or additive-first if your audience is release marketing
rather than upgrade planning.
- Density: rows are py-2.5 with a summary line. Drop `summary` from the payload
and the rows compress to route plus counts for an audit-console feel.
- Deprecation policy: urgentWithinDays sets the destructive threshold; add tiers
by turning the boolean into a band lookup in SunsetCell.
- Extra columns: owner, ticket, SDK version, or a migration link. Add the field
to the contract and one cell to the row grid together.
- Wiring: onRangeChange, onRetry, breakingOnly and expandedIds are all the
consumer's job — deep-link an expanded endpoint, drive the filter from the
URL, or fetch each range on demand. Omit a handler and the affordance it feeds
disappears rather than going dead.Concepts
- Requests and responses break under opposite conditions — an input surface breaks when the set of calls it accepts shrinks; an output surface breaks when the set of values it produces widens. That single asymmetry is why widening an enum is a courtesy on the way in and an outage on the way out, and it is the reason this is not a text diff.
- New endpoints cannot break anyone — on a route that did not exist in the base version, every field is new, so no verdict about it applies to code written against that version. Naive differs report a brand-new required body field as breaking; the kind short-circuit is what keeps the count trustworthy.
- Published flag versus derived verdict — the headline numbers follow the
breakingflag the API owner shipped, because that is the claim their consumers were told. The per-field badges are derived from the rules. Where the two disagree the row prints it, so the reader can weigh a claim instead of being handed a silently corrected one. The "Breaking only" filter is the one place the two are read together: it keeps a row if either says it breaks, because a reader hunting for what will break them should not have to take the flag on trust. - A disclosure needs something behind it — a deleted route often has no field-level story, and that row renders as plain text with a chevron nowhere in sight. Panels that do exist stay mounted and hide with
hidden, soaria-controlsnever points at an element that is not there. - A deprecation window is two facts, not a promise — the date the source announced and the whole days between it and an injected
now, floored so a partial day never rounds up. Past the date it counts elapsed days instead of hiding. What happens on the day is the provider's policy, and this card does not invent one. - Filtered-empty is not empty — "nothing here breaks you" is the best outcome an upgrade review can have and gets its own panel plus a way back; "these two versions describe the same API" is a different sentence about a different fact.
Line Draw on Scroll
SVG paths that draw themselves as the block scrolls through the viewport, dashoffset unwinding stroke by stroke.
QR Login
A phone-scan sign-in panel that walks waiting → scanned → approved → expired, attributes the scan so a wrong one can be rejected, and shows a derived pairing phrase instead of the code.