Env Diff
A key × environment configuration diff — every cell judged against one baseline column, secrets compared by fingerprint so no value is ever rendered, and a live count of what promoting one environment into another would change.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/env-diff.jsonPrompt
Build a React + TypeScript + Tailwind "EnvDiff" component with zod and
lucide-react — configuration compared across environments, read-only.
Contract
- A sibling zod contract file is the single source of truth:
environment = { id, label, hint? } (array order = column order)
value = { value?: string, fingerprint?: string }
key = { name, secret?, note?, values: Record<envId, value> }
data = { status: "loading" | "empty" | "error" | "ready",
environments[], items[], capturedAt?, errorMessage? }
Props are z.infer of that object plus: baselineId / defaultBaselineId /
onBaselineChange, targetId / defaultTargetId / onTargetChange,
differencesOnly / defaultDifferencesOnly / onDifferencesOnlyChange (all three
controllable the Radix way: the prop wins when set, otherwise internal state,
and the handler always fires), now, locale = "en-US", onRetry?, onPromote?,
emptyState?, skeletonRows = 6, keyColumnWidth = "16rem",
valueColumnWidth = "13rem", maxHeight?, caption?, className, native div props.
- `values` is SPARSE on purpose: a missing record entry means "not set in that
environment". Do not add a `present` boolean — { value: "" } (set to the empty
string) and "no entry" are different operational facts and a boolean invites
collapsing them.
- A secret key MUST NOT carry `value` anywhere; a `superRefine` rejects the
snapshot if it does, and the renderer never reads `value` when `secret` is
true. That is what makes "the secret never reaches the DOM" checkable instead
of merely intended. The same superRefine rejects duplicate environment ids,
duplicate key names, and values for undeclared environments — guard each check
separately, because zod runs every check even after one has failed.
Behavior
- Four first-class branches on `status`; `ready` with zero keys OR zero
environments resolves to `empty`.
- One baseline column is the pivot; every other cell is a verdict RELATIVE to
it. Comparison runs on a token, never on the raw record: token = fingerprint
for a secret key, value (falling back to fingerprint) for a public one, and
null when there is nothing to compare with.
cell = "missing" entry absent here, present in the baseline (a real gap)
| "absent" entry absent here AND in the baseline (agreement, not a gap)
| "baseline" this is the reference column
| "added" present here, absent from the baseline
| "unknown" present both sides, either token null (secret, no digest)
| "same" / "different" tokens equal / unequal
Two distinctions carry the whole component: "absent" is not "missing" (folding
them inflates every difference count with keys nobody has), and "unknown" is
not "same" ("we could not check" is not "we checked and they match").
- Counts: each non-baseline column header shows how many keys definitely differ
plus, separately, how many could not be verified. The footer answers "what
would promoting the baseline into the target do": add = in the baseline,
missing in the target; update = in both and different; unverifiable = in both,
not comparable; onlyInTarget = target-only, which promotion leaves behind
rather than deleting; changes = add + update. Fewer than two environments =
no target: say so instead of printing a comparison against nothing.
- Filter: "Only differences" keeps rows where at least one cell disagrees with
the baseline (unknown counts as disagreement — it might differ). Filtering to
zero rows gets its OWN copy ("all N keys match <baseline>") plus a way back,
never the "no configuration yet" empty state.
- Keyboard: Tab reaches the baseline group (one stop, roving tabindex), the
filter toggle, the scroll container, each non-baseline column header, then the
promote button. Inside the baseline group Left/Up and Right/Down wrap,
Home/End jump to the ends, and every move re-runs the whole comparison and
moves DOM focus with the selection. Space/Enter activate whatever is focused;
the table itself is read-only, so no cell is focusable and no fake grid role
is claimed.
- ARIA: role="radiogroup" + aria-checked for the baseline chips (labelled by an
element whose id comes from useId, so several of these can share a page),
aria-pressed on the filter toggle and on each column header acting as the
promotion target, aria-label on those headers spelling out the counts the
badge shows visually, one polite live region announcing baseline, target,
filter and promote changes, sr-only text per cell naming the verdict, and an
sr-only sentence carrying the promotion numbers while the visible chips are
aria-hidden so nothing is read twice.
- Never native `disabled`: promote with nothing to copy is aria-disabled plus a
handler guard, so a reader focused on it keeps focus and still hears it.
Retry is one-shot — a ref read and written in the same synchronous handler, so
a double click sends one request — and the lock lifts when `status` changes.
When the retry button unmounts under the user, focus moves to the panel that
replaced it (tabIndex={-1}), never to <body>. The same handoff applies to
"show every key", which unmounts itself: focus its successor first.
- Time: `capturedAt` is rendered relative to an INJECTED `now` through
Intl.RelativeTimeFormat. No `now`, no label — render never reads the clock, so
SSR and hydration agree byte for byte.
- Edge cases that must stay visibly distinct: empty string, whitespace-only
(print the character count), a value the API did not return, a multi-line
value, a 300-character unbroken token, and an unset key.
Rendering & styling
- A real table: th scope="col" per environment, th scope="row" per key, an
sr-only caption, colgroup + table-fixed so long values wrap inside their
column instead of winning the width fight. The table sits in a
`relative overflow-auto` card with tabIndex={0} + role="group" + aria-label,
because a grid only a mouse can pan is a WCAG 2.1.1 failure, and because
sr-only text is absolutely positioned and needs a positioned overflow parent
or it escapes and gives the page invisible horizontal scroll. Key column and
header row are sticky; border-separate, not border-collapse, or the pinned
column loses its right edge the moment you scroll sideways.
- Secrets: a FIXED-LENGTH mask (8 bullets) plus the digest. A mask that mirrors
the real length hands out the length of every secret in the table.
- Semantic tokens only: bg-card / border (shell), bg-muted + text-muted-foreground
(baseline column, secondary text, skeletons), bg-primary/10 and text-primary
(different / added / live counts), bg-destructive/5 + text-destructive
(missing, error branch), bg-foreground/5 for the row hover band,
focus-visible:ring-2 ring-ring on every control. cn() merges the consumer
className into the root.
- Verdicts are never colour alone: each state has its own icon (anchor, equal,
not-equal, minus, dashed circle, plus, question), an sr-only label, and a
data-state attribute. Only the pulse and colour transitions animate, both with
motion-reduce:*-none; nothing stops working with motion off.
- The legend names only the verdicts actually on screen, and the "secrets are
compared by fingerprint" line only appears when the snapshot has secrets.
Customization levers
- Density: same-cells currently print their (dimmed) value next to an equals
marker. Swap that body for a bare marker and a wide table compresses hard
without touching the state machine. py-2.5 rows read as comfortable; py-1.5
with the notes dropped gives an audit-console feel.
- Columns: `valueColumnWidth` and `keyColumnWidth` set the geometry (the key
column is capped at 42vw so it cannot eat a phone screen). Two environments or
eight; the table's min-width follows from the count.
- Comparison rule: `comparableToken` is the single hook for normalisation —
trim, case-fold, sort a comma list, or parse JSON before comparing, and every
count, badge and filter follows automatically.
- Promotion policy: this build never deletes, so target-only keys are reported
rather than counted as changes. If your promoter mirrors exactly, move
onlyInTarget into `changes` and rename it "remove".
- Extra columns: owner, last-changed-by, source file. Add them to the schema and
the colgroup together.
- Wiring: onPromote receives the whole summary object and is the consumer's job
(open a confirmation, POST a plan); omit it and no button renders. Same for
onRetry. Filtering is client-side; with a server-side filter, pass
differencesOnly as a controlled prop and hand in the filtered items.Concepts
- Baseline as the pivot — no cell has an opinion of its own; every verdict is measured against one chosen column, so switching the baseline with the arrow keys re-runs the whole table, the per-column counts and the promotion arithmetic in one pass. That is what turns a static grid into "compare anything to anything".
- Fingerprint comparison — secrets are compared through a short digest computed where the value is still in the clear. The component receives that digest and never the value, and prints a mask in its place, so equality is provable without the secret ever reaching the DOM, the RSC payload or a screenshot. The mask is a fixed length, because a mask that mirrors the real length is itself a leak.
- Absent is not missing — two environments that both leave a key unset agree with each other. Folding "unset here" and "unset in both" into one verdict quietly inflates every difference badge and every promotion count with keys nobody has.
- Unverifiable is not equal — a secret with no digest cannot be compared, and the honest answer is a question mark. The counts keep it in its own bucket instead of rounding it up into "in sync", which is the exact rounding that ships a stale production credential.
- Promotion arithmetic — the footer answers a decision, not a statistic: what would change if this baseline were copied into that target. Adds and updates are work; target-only keys are reported separately because copying forward does not delete, and pretending otherwise would overstate the blast radius.
- Filtered-empty is not empty — "your filter hides everything because nothing differs" is good news and gets its own copy plus a way back; "this project has no configuration yet" is a different screen entirely.
Code Tour
An annotated walkthrough of one file — numbered steps that each highlight a line range, a note beside the code, and a pane that scrolls the active range into view.
Data List
A read-only key/value list that folds from two columns to stacked pairs on container width, with per-row copy, focus-revealed truncation and an explicit em dash for empty fields.