Navigation

Environment Switcher

A four-state environment switcher that tints the target you are pointed at, compares deploy versions as real semver so a stale preview says so, blocks what your role cannot enter with the reason, and gates production behind a typed confirm.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  ExternalLink,
  FlaskConical,
  GitBranch,
  Laptop,
  Lock,
  RefreshCw,
  Server,
  ShieldAlert,
  TriangleAlert,
} from "lucide-react"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/environment-switcher.json

Prompt

Build a React + TypeScript + Tailwind "EnvironmentSwitcher" control
(lucide-react icons, zod, and a type-to-confirm AlertDialog wrapper).

Contract
- One zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    environments: { id, label, kind: "production" | "staging" | "preview" |
      "local", region?, version?, url?, blockedReason?, typeToConfirm? }[];
    currentId: string; requireConfirmFor: string[]; errorMessage? }.
- Props = z.infer of that schema plus onSelect?(id) , onRetry?() and the
  native attributes of the root element. The component owns NO selection
  state: currentId only moves when the consumer moves it. requireConfirmFor
  holds ids, not kinds, so one specific sandbox can be guarded too.

Behavior
- Four first-class branches. loading = a skeleton shaped like the real
  anatomy (banner + three rows) with an sr-only status line; empty = "no
  environments configured", claiming no current target; error = the payload's
  message plus the sentence that matters — with no list the panel cannot say
  which environment you are pointed at — and a Try again button only when
  onRetry exists; ready = banner + listbox + summary.
- The banner is the always-visible "you are here": tier tint, label, region,
  version, host link. A currentId that matches nothing renders "not in this
  list" and quotes the unresolved id instead of silently showing row one.
- The list is a single-select listbox (role=listbox / role=option,
  aria-selected on the current row) with ROVING TABINDEX: Arrow keys, Home
  and End move DOM focus, Enter and Space activate. Blocked rows stay
  arrow-reachable on purpose — the reason is the thing the reader needs — but
  are aria-disabled and inert on activation.
- Selecting an id in requireConfirmFor opens a confirm step instead of
  committing: production wording says it serves real customers and that what
  follows lands on live data; anything else guarded says it is deliberately
  two steps. typeToConfirm additionally demands the label typed back exactly.
  The dialog's open state is DERIVED from the payload, so if the target
  vanishes, gets blocked or becomes current while it is up, it closes
  itself — and the pending target is cleared in the same pass, so a forced
  close reads exactly like a cancel and nothing re-opens on its own when
  the payload swings back.
- Version staleness is real Semantic Versioning 2.0.0: the semver.org regex
  (plus an optional leading "v"), §11 precedence including prerelease rules
  (1.6.0-rc.1 < 1.6.0, numeric identifiers numerically, numeric below
  alphanumeric), and §10's rule that build metadata is ignored. The yardstick
  is the single versioned production environment, falling back to the current
  one when there are two, and every sentence names it: "behind Production
  (1.6.0)", "ahead of", "same version as". A version that is not semver — a
  snapshot id, a git sha — reports "not comparable with …" and never gets
  string-ordered. Only a strictly lower semver earns the Stale badge.
- URLs: only http/https ever become an href (remote registry data must never
  turn into a javascript: link), and userinfo — the credential a basic-auth
  preview URL carries — is stripped from the href and never printed; the row
  says "credentials hidden" instead.
- The footer counts are computed from the same array that renders the rows
  ("5 of 6 environments open to you · 1 needs a confirm step"), so the list
  and the summary cannot disagree.

Rendering & styling
- Semantic tokens only, loudness tracking blast radius: production
  destructive/10 + text-destructive, staging primary/10 + text-primary,
  preview bg-secondary, local a dashed muted box. Keep each tier's border +
  glyph colour separate from its fill: the row's icon tile takes the fill,
  the banner's stays bg-card so it lifts off the tinted banner and the same
  translucent fill is never stacked on itself. Tier is never colour-only —
  each tier also has its own icon, and the tier word joins the meta line
  whenever the label does not already contain it.
- cn() merges className onto the root, remaining props spread there, and the
  forwarded ref points at it. focus-visible rings on rows and on the banner
  link; transitions carry motion-reduce:transition-none and the skeleton
  motion-reduce:animate-none.
- Every string that mixes a figure with a unit or word (meta line, summary,
  confirm copy) is joined in JS, not assembled from adjacent JSX nodes.

Customization levers
- Tiers: KIND_META is the whole colour/icon/word story — add an "edge" or
  "canary" kind by extending the zod enum and that one record.
- Guard policy: requireConfirmFor is producer-side, so "every production" is
  environments.filter(e => e.kind === "production").map(e => e.id); flip
  typeToConfirm per environment to trade friction for speed.
- Density: drop the url line, the region, or the tier word from the meta
  array for a compact topbar; the row keeps its shape.
- Yardstick: swap the reference rule (single production → current) for "the
  highest semver in the list" without touching the sentences.
- Surface: the panel is self-contained, so it drops into a popover, a sheet
  or a settings page unchanged — wrap it, do not rebuild it.
- Copy: the production sentence and the confirm lines are plain strings in
  one place, ready to be swapped for your compliance wording or translated.

Concepts

  • Blast-radius tint — how loud a target looks is decided by its tier, not by taste: production wears the destructive tint, local a dashed muted box. Icon shape and the tier word carry the same signal, so the ordering survives colour-blindness and greyscale screenshots.
  • Guarded switch — a switch into a listed environment is a two-step commit: the first click only opens a confirm that spells out the consequence, and typeToConfirm escalates that to typing the name. Everything else commits on the first click, so the friction stays where the risk is.
  • Derived dialog state — the confirm's open state is computed from the payload rather than stored, so a refresh that blocks, removes or already-selects the target closes the box — and drops the pending target with it, so a forced close is indistinguishable from a cancel — instead of leaving a confirmation standing over data it no longer describes, or one that re-opens itself when the payload swings back.
  • Comparable vs not comparable — two versions are only ordered when both parse as semver; a snapshot id or a git sha is reported as not comparable against the named yardstick rather than being string-sorted into a confident lie.
  • Blocked, not hidden — an environment the role cannot enter stays listed, keyboard-reachable and captioned with its reason; hiding it would leave the reader with nothing to request access for.
  • Credential-safe host — a URL that arrives carrying basic-auth userinfo is shown as a host with "credentials hidden", and only http/https ever becomes a real link.

On This Page