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>
This commit is contained in:
hsiegeln
2026-04-22 19:13:00 +02:00
parent e6dcad1e07
commit 88b003d4f0

View File

@@ -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<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:
```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 `<EnvironmentSelector …>` with `<EnvironmentSwitcherButton envs={environments} value={selectedEnv} onChange={setSelectedEnv} />`.
- Mount `<EnvironmentSwitcherModal …>` separately.
- Render a 3px fixed top bar:
```jsx
<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 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`.