Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  Anchor,
  ArrowRight,
  CircleAlert,
  CircleDashed,
  CircleHelp,
  Equal,
  EqualNot,
  KeyRound,
  ListFilter,
  Lock,

Installation

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

Prompt

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.

On This Page