Files
cameleer-server/docs/superpowers/specs/2026-04-22-env-switcher-and-color-design.md
hsiegeln 88b003d4f0 docs(spec): explicit env switcher + per-env color (design)
Replace env dropdown with button+modal pattern, remove All Envs,
add 8-swatch preset color palette per env rendered as 3px top bar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:13:00 +02:00

8.5 KiB

Design — explicit environment switcher + per-env color

Date: 2026-04-22

Goals

  1. Replace the EnvironmentSelector dropdown with an explicit button + modal pattern so switching environments is unambiguous.
  2. Remove the "All Envs" option. Every session has exactly one environment selected.
  3. On first login (or if the stored selection no longer exists), force the user to pick an env via a non-dismissible modal — no auto-selection.
  4. Per-environment color (8-swatch preset palette, default slate), edited on the Environment admin settings page.
  5. Render a 3px fixed top bar in the current env's color on every page — a passive reminder of "which environment am I in?"

Non-goals

  • Per-user default environment preference (could be added later).
  • Free-form HEX colors or custom tokens.
  • Modifying @cameleer/design-system — tokens live in a new app-level CSS file.
  • Animated transitions on bar color change.

Palette

Eight named swatches, stored as plain strings:

token typical use
slate (default) neutral / unset
red production / high-alert
amber staging
green dev success
teal QA
blue sandbox
purple experimental
pink personal / scratch

All 8 have WCAG-AA contrast against both light and dark app surfaces at 3px thickness. Unknown values in the DB (e.g. manual insert of neon) fall back to slate in the UI; the admin PUT rejects unknown values with HTTP 400.

Backend

Migration

New V2__add_environment_color.sql:

ALTER TABLE environments
  ADD COLUMN color VARCHAR(16) NOT NULL DEFAULT 'slate'
    CHECK (color IN ('slate','red','amber','green','teal','blue','purple','pink'));

Existing rows backfill to 'slate' via the DEFAULT. No data migration otherwise.

Domain

  • Environment record (core): add String color.
  • New EnvironmentColor class in core (runtime/EnvironmentColor.java): exposes Set<String> VALUES and boolean isValid(String); plus String DEFAULT = "slate".
  • EnvironmentRepository / PostgresEnvironmentRepository: select/insert/update include color.
  • EnvironmentService:
    • create(slug, displayName, production) unchanged — color comes from DB default.
    • update(...) — signature gains a String color parameter.

API

  • EnvironmentAdminController.UpdateEnvironmentRequest+ String color (nullable).
  • PUT /api/v1/admin/environments/{envSlug}:
    • color == null → preserve existing value.
    • color != null && !EnvironmentColor.isValid(color) → 400 "unknown environment color: {color}".
    • color != null && EnvironmentColor.isValid(color) → persist.
  • CreateEnvironmentRequest intentionally does not take a color. New envs always start at slate; user picks later on the settings page.
  • GET responses include color.

Frontend

Tokens

New ui/src/styles/env-colors.css imported by ui/src/main.tsx. Defines 8 CSS variables for both light and dark themes:

:root {
  --env-color-slate:  #94a3b8;
  --env-color-red:    #ef4444;
  --env-color-amber:  #f59e0b;
  --env-color-green:  #10b981;
  --env-color-teal:   #14b8a6;
  --env-color-blue:   #3b82f6;
  --env-color-purple: #a855f7;
  --env-color-pink:   #ec4899;
}
[data-theme='dark'] {
  --env-color-slate:  #a1a9b8;
  /* adjusted shades with equivalent-or-better contrast on dark surfaces */
  ...
}

These tokens live at the app level because @cameleer/design-system is consumed as an external npm package (^0.1.56). The app owns this vocabulary.

Helpers

ui/src/components/env-colors.ts:

export const ENV_COLORS = ['slate','red','amber','green','teal','blue','purple','pink'] as const;
export type EnvColor = typeof ENV_COLORS[number];
export function isEnvColor(v: string | undefined): v is EnvColor { ... }
export function envColorVar(c: string | undefined): string {
  return `var(--env-color-${isEnvColor(c) ? c : 'slate'})`;
}

Types

ui/src/api/queries/admin/environments.ts:

  • Environment.color: string
  • UpdateEnvironmentRequest.color?: string

schema.d.ts regenerated via npm run generate-api:live (backend must be up).

Components

Delete:

  • ui/src/components/EnvironmentSelector.tsx
  • ui/src/components/EnvironmentSelector.module.css

New:

  • ui/src/components/EnvironmentSwitcherButton.tsx

    • DS Button variant secondary size="sm".
    • Content: 8px color dot (using envColorVar(env.color)) + display name + chevron-down icon.
    • Click → open modal.
  • ui/src/components/EnvironmentSwitcherModal.tsx

    • Wraps DS Modal (size sm).
    • Props: open, onClose, envs, value, onChange, forced?: boolean.
    • Body: clickable vertical list of rows. Each row: color dot + displayName + slug (mono, muted) + PROD/NON-PROD/DISABLED badges + check indicator when current. Empty state: "No environments — ask an admin to create one."
    • forced === true: onClose is a no-op; title changes from "Switch environment" to "Select an environment".

LayoutShell wire-up

ui/src/components/LayoutShell.tsx:

  • Replace <EnvironmentSelector …> with <EnvironmentSwitcherButton envs={environments} value={selectedEnv} onChange={setSelectedEnv} />.
  • Mount <EnvironmentSwitcherModal …> separately.
  • Render a 3px fixed top bar:
    <div style={{
      position: 'fixed', top: 0, left: 0, right: 0,
      height: 3, zIndex: 900,
      background: envColorVar(currentEnvObj?.color),
    }} aria-hidden />
    
    z-index 900 sits above page content but below DS Modal (>= 1000), so modals cover it cleanly.
  • Effect: if environments.length > 0 && (selectedEnv === undefined || !environments.some(e => e.slug === selectedEnv)), clear stale slug and open the modal in forced mode. Stays open until the user picks.

Admin settings

ui/src/pages/Admin/EnvironmentsPage.tsx:

  • New section "Appearance" between Configuration and Status.
  • Row of 8 circular swatches (36px). Selected swatch: 2px outline in --text-primary + small checkmark.
  • Click → updateEnv.mutateAsync({ slug, displayName, production, enabled, color }).
  • Existing handlers (handleRename, handleToggleProduction, handleToggleEnabled) pass through selected.color so they don't wipe it.

Data flow

  • Pick env in modal → useEnvironmentStore.setEnvironment(slug)selectedEnv in LayoutShell changes → top bar re-renders with new color → env-scoped pages refetch via their useSelectedEnv hooks.
  • Change color in settings → useUpdateEnvironment mutation → invalidates ['admin','environments'] → top bar picks up new color on next frame.

Edge cases

  • No envs at all — modal forced, empty state. (Doesn't happen in practice since V1 seeds default.)
  • Stored slug no longer exists (admin deleted it mid-session) — LayoutShell effect clears store + opens forced modal.
  • Migrating from "All Envs" (selectedEnv === undefined after this change ships) — same as above: forced modal on first post-migration render.
  • Bad color in DBenvColorVar falls back to slate; admin PUT rejects invalid values with 400.
  • Modal open while env deleted externally — TanStack list updates; previously-selected row silently disappears.

Testing

Backend

  • EnvironmentAdminControllerIT:
    • Existing tests pass unchanged (default color round-trips).
    • New: PUT with valid color persists; PUT with unknown color → 400; PUT with null/absent color preserves existing.
  • SchemaBootstrapIT (or equivalent) — asserts environments.color exists with default slate.
  • PostgresEnvironmentRepositoryIT — if present, covers round-trip.

Frontend (Vitest + RTL)

  • EnvironmentSwitcherButton.test.tsx — renders dot + name; click opens modal.
  • EnvironmentSwitcherModal.test.tsx — one row per env; click calls onChange; forced=true ignores ESC/backdrop.
  • LayoutShell.test.tsx — when selectedEnv is missing but envs loaded, forced modal mounts; after pick, top bar gets env's color token.
  • EnvironmentsPage.test.tsx — swatch grid renders; click triggers useUpdateEnvironment with {color} in payload.

Rule/doc updates

  • .claude/rules/app-classes.md — note UpdateEnvironmentRequest.color on env admin controller.
  • .claude/rules/core-classes.mdEnvironment record color field.
  • .claude/rules/ui.mdEnvironmentSwitcherButton / EnvironmentSwitcherModal replace EnvironmentSelector; env-colors.css location; 3px top bar in LayoutShell.

OpenAPI regeneration

Required per CLAUDE.md: bring backend up on :8081, run cd ui && npm run generate-api:live, commit openapi.json + schema.d.ts.