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>
8.5 KiB
Design — explicit environment switcher + per-env color
Date: 2026-04-22
Goals
- Replace the
EnvironmentSelectordropdown with an explicit button + modal pattern so switching environments is unambiguous. - Remove the "All Envs" option. Every session has exactly one environment selected.
- 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.
- Per-environment color (8-swatch preset palette, default
slate), edited on the Environment admin settings page. - 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
Environmentrecord (core): addString color.- New
EnvironmentColorclass in core (runtime/EnvironmentColor.java): exposesSet<String> VALUESandboolean isValid(String); plusString DEFAULT = "slate". EnvironmentRepository/PostgresEnvironmentRepository: select/insert/update includecolor.EnvironmentService:create(slug, displayName, production)unchanged — color comes from DB default.update(...)— signature gains aString colorparameter.
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.
CreateEnvironmentRequestintentionally does not take a color. New envs always start atslate; user picks later on the settings page.GETresponses includecolor.
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: stringUpdateEnvironmentRequest.color?: string
schema.d.ts regenerated via npm run generate-api:live (backend must be up).
Components
Delete:
ui/src/components/EnvironmentSelector.tsxui/src/components/EnvironmentSelector.module.css
New:
-
ui/src/components/EnvironmentSwitcherButton.tsx- DS
Buttonvariantsecondary size="sm". - Content: 8px color dot (using
envColorVar(env.color)) + display name + chevron-down icon. - Click → open modal.
- DS
-
ui/src/components/EnvironmentSwitcherModal.tsx- Wraps DS
Modal(sizesm). - 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:onCloseis a no-op; title changes from "Switch environment" to "Select an environment".
- Wraps DS
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:
z-index 900 sits above page content but below DS
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, height: 3, zIndex: 900, background: envColorVar(currentEnvObj?.color), }} aria-hidden />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 throughselected.colorso they don't wipe it.
Data flow
- Pick env in modal →
useEnvironmentStore.setEnvironment(slug)→selectedEnvin LayoutShell changes → top bar re-renders with new color → env-scoped pages refetch via theiruseSelectedEnvhooks. - Change color in settings →
useUpdateEnvironmentmutation → 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 === undefinedafter this change ships) — same as above: forced modal on first post-migration render. - Bad color in DB —
envColorVarfalls back toslate; 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) — assertsenvironments.colorexists with defaultslate.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 callsonChange;forced=trueignores ESC/backdrop.LayoutShell.test.tsx— whenselectedEnvis missing but envs loaded, forced modal mounts; after pick, top bar gets env's color token.EnvironmentsPage.test.tsx— swatch grid renders; click triggersuseUpdateEnvironmentwith{color}in payload.
Rule/doc updates
.claude/rules/app-classes.md— noteUpdateEnvironmentRequest.coloron env admin controller..claude/rules/core-classes.md—Environmentrecordcolorfield..claude/rules/ui.md—EnvironmentSwitcherButton/EnvironmentSwitcherModalreplaceEnvironmentSelector;env-colors.csslocation; 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.