From 88b003d4f0ed6ec5c414e955f6460cb5003ccae1 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:13:00 +0200 Subject: [PATCH] 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) --- ...026-04-22-env-switcher-and-color-design.md | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-22-env-switcher-and-color-design.md diff --git a/docs/superpowers/specs/2026-04-22-env-switcher-and-color-design.md b/docs/superpowers/specs/2026-04-22-env-switcher-and-color-design.md new file mode 100644 index 00000000..45e3004c --- /dev/null +++ b/docs/superpowers/specs/2026-04-22-env-switcher-and-color-design.md @@ -0,0 +1,201 @@ +# 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`: + +```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 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: + +```css +: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`: + +```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 `` with ``. +- Mount `` separately. +- Render a 3px fixed top bar: + ```jsx +
+ ``` + 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 DB** — `envColorVar` 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.md` — `Environment` record `color` field. +- `.claude/rules/ui.md` — `EnvironmentSwitcherButton` / `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`.