Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  ArrowRight,
  Ban,
  ChevronDown,
  ChevronRight,
  CircleAlert,
  CircleCheck,
  Clock,
  ListFilter,
  Pencil,
  Plus,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/api-changelog-diff.json

Prompt

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 breaking flag 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, so aria-controls never 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.

On This Page