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:
@@ -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`.
|
||||
Reference in New Issue
Block a user