Compare commits

...

5 Commits

Author SHA1 Message Date
hsiegeln
2835d08418 ui(env): explicit switcher button+modal, forced selection, 3px color bar
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m6s
CI / docker (push) Successful in 1m18s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 38s
- Replace EnvironmentSelector "All Envs" dropdown with Button+Modal (DS Modal, forced on first-use).
- Add 8-swatch preset color picker in the Environment settings "Appearance" section; commits via useUpdateEnvironment.
- Render a 3px fixed top bar in the current env's color across every page (z-index 900, below DS modals).
- New env-colors tokens (--env-color-*, light + dark) and envColorVar() helper with slate fallback.
- Vitest coverage for button, modal, and color helpers (13 new specs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:24:48 +02:00
hsiegeln
79fa4c097c api(schema): regenerate OpenAPI + schema.d.ts for env color field
UpdateEnvironmentRequest gains an optional color; Environment schema
surfaces color on GET responses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:24:35 +02:00
hsiegeln
c2eab71a31 env(admin): per-environment color field + V2 migration
- V2__add_environment_color.sql adds a CHECK-constrained VARCHAR color column (default 'slate'); existing rows backfill to slate.
- Environment record + EnvironmentColor constants (8 preset values) flow through repository, service, and admin API.
- UpdateEnvironmentRequest.color nullable: null preserves existing; unknown values → 400.
- ITs cover valid / invalid / null-preserves behaviour; existing Environment constructor call-sites updated with the new color arg.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:24:30 +02:00
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
hsiegeln
e6dcad1e07 config(app): silence MustacheAutoConfiguration templates-dir warning
jmustache on the classpath (for alert notification templates) triggers
Spring Boot's MustacheAutoConfiguration, which warns about the missing
classpath:/templates/ folder we don't use. Disable its check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:47:46 +02:00
35 changed files with 957 additions and 59 deletions

View File

@@ -72,7 +72,7 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
### Env admin (env-slug-parameterized, not env-scoped data)
- `EnvironmentAdminController``/api/v1/admin/environments`. GET list / POST create / GET `{envSlug}` / PUT `{envSlug}` / DELETE `{envSlug}` / PUT `{envSlug}/default-container-config` / PUT `{envSlug}/jar-retention`. Slug immutable — PUT body has no slug field; any slug supplied is dropped by Jackson. Slug validated on POST.
- `EnvironmentAdminController``/api/v1/admin/environments`. GET list / POST create / GET `{envSlug}` / PUT `{envSlug}` / DELETE `{envSlug}` / PUT `{envSlug}/default-container-config` / PUT `{envSlug}/jar-retention`. Slug immutable — PUT body has no slug field; any slug supplied is dropped by Jackson. Slug validated on POST. `UpdateEnvironmentRequest` carries `color` (nullable); unknown values rejected with 400 via `EnvironmentColor.isValid`. Null/absent color preserves the existing value.
### Agent-only (JWT-authoritative, intentionally flat)

View File

@@ -26,7 +26,8 @@ paths:
- `App` — record: id, environmentId, slug, displayName, containerConfig (JSONB)
- `AppVersion` — record: id, appId, version, jarPath, detectedRuntimeType, detectedMainClass
- `Environment` — record: id, slug, jarRetentionCount
- `Environment` — record: id, slug, displayName, production, enabled, defaultContainerConfig, jarRetentionCount, color, createdAt. `color` is one of the 8 preset palette values validated by `EnvironmentColor.VALUES` and CHECK-constrained in PostgreSQL (V2 migration).
- `EnvironmentColor` — constants: `DEFAULT = "slate"`, `VALUES = {slate,red,amber,green,teal,blue,purple,pink}`, `isValid(String)`.
- `Deployment` — record: id, appId, appVersionId, environmentId, status, targetState, deploymentStrategy, replicaStates (JSONB), deployStage, containerId, containerName
- `DeploymentStatus` — enum: STOPPED, STARTING, RUNNING, DEGRADED, STOPPING, FAILED
- `DeployStage` — enum: PRE_FLIGHT, PULL_IMAGE, CREATE_NETWORK, START_REPLICAS, HEALTH_CHECK, SWAP_TRAFFIC, COMPLETE

View File

@@ -25,6 +25,8 @@ The UI has 4 main tabs: **Exchanges**, **Dashboard**, **Runtime**, **Deployments
- `ui/src/auth/auth-store.ts` — Zustand: accessToken, user, roles, login/logout
- `ui/src/api/environment-store.ts` — Zustand: selected environment (localStorage)
- `ui/src/components/ContentTabs.tsx` — main tab switcher
- `ui/src/components/EnvironmentSwitcherButton.tsx` + `EnvironmentSwitcherModal.tsx` — explicit env picker (button in TopBar; DS `Modal`-based list). Replaces the retired `EnvironmentSelector` (All-Envs dropdown). When `envRecords.length > 0` and the stored `selectedEnv` no longer matches any env, `LayoutShell` opens the modal in `forced` mode (non-dismissible). Switcher pulls env records from `useEnvironments()` (admin endpoint; readable by VIEWER+).
- `ui/src/components/env-colors.ts` + `ui/src/styles/env-colors.css` — 8-swatch preset palette for the per-environment color indicator. Tokens `--env-color-slate/red/amber/green/teal/blue/purple/pink` are defined for both light and dark themes. `envColorVar(name)` falls back to `slate` for unknown values. `LayoutShell` renders a 3px fixed top bar in the current env's color (z-index 900, below DS modals).
- `ui/src/components/ExecutionDiagram/` — interactive trace view (canvas)
- `ui/src/components/ProcessDiagram/` — ELK-rendered route diagram
- `ui/src/hooks/useScope.ts` — TabKey type, scope inference

View File

@@ -1,6 +1,7 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.core.runtime.Environment;
import com.cameleer.server.core.runtime.EnvironmentColor;
import com.cameleer.server.core.runtime.EnvironmentService;
import com.cameleer.server.core.runtime.RuntimeType;
import io.swagger.v3.oas.annotations.Operation;
@@ -58,16 +59,22 @@ public class EnvironmentAdminController {
}
@PutMapping("/{envSlug}")
@Operation(summary = "Update an environment's mutable fields (displayName, production, enabled)",
@Operation(summary = "Update an environment's mutable fields (displayName, production, enabled, color)",
description = "Slug is immutable after creation and cannot be changed. "
+ "Any slug field in the request body is ignored.")
+ "Any slug field in the request body is ignored. "
+ "If color is null or absent, the existing color is preserved.")
@ApiResponse(responseCode = "200", description = "Environment updated")
@ApiResponse(responseCode = "400", description = "Unknown color value")
@ApiResponse(responseCode = "404", description = "Environment not found")
public ResponseEntity<?> updateEnvironment(@PathVariable String envSlug,
@RequestBody UpdateEnvironmentRequest request) {
try {
Environment current = environmentService.getBySlug(envSlug);
environmentService.update(current.id(), request.displayName(), request.production(), request.enabled());
String nextColor = request.color() == null ? current.color() : request.color();
if (!EnvironmentColor.isValid(nextColor)) {
return ResponseEntity.badRequest().body(Map.of("error", "unknown environment color: " + request.color()));
}
environmentService.update(current.id(), request.displayName(), request.production(), request.enabled(), nextColor);
return ResponseEntity.ok(environmentService.getBySlug(envSlug));
} catch (IllegalArgumentException e) {
if (e.getMessage().contains("not found")) {
@@ -149,6 +156,6 @@ public class EnvironmentAdminController {
}
public record CreateEnvironmentRequest(String slug, String displayName, boolean production) {}
public record UpdateEnvironmentRequest(String displayName, boolean production, boolean enabled) {}
public record UpdateEnvironmentRequest(String displayName, boolean production, boolean enabled, String color) {}
public record JarRetentionRequest(Integer jarRetentionCount) {}
}

View File

@@ -1,6 +1,7 @@
package com.cameleer.server.app.storage;
import com.cameleer.server.core.runtime.Environment;
import com.cameleer.server.core.runtime.EnvironmentColor;
import com.cameleer.server.core.runtime.EnvironmentRepository;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -24,7 +25,8 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository {
this.objectMapper = objectMapper;
}
private static final String SELECT_COLS = "id, slug, display_name, production, enabled, default_container_config, jar_retention_count, created_at";
private static final String SELECT_COLS =
"id, slug, display_name, production, enabled, default_container_config, jar_retention_count, color, created_at";
@Override
public List<Environment> findAll() {
@@ -58,9 +60,9 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository {
}
@Override
public void update(UUID id, String displayName, boolean production, boolean enabled) {
jdbc.update("UPDATE environments SET display_name = ?, production = ?, enabled = ?, updated_at = now() WHERE id = ?",
displayName, production, enabled, id);
public void update(UUID id, String displayName, boolean production, boolean enabled, String color) {
jdbc.update("UPDATE environments SET display_name = ?, production = ?, enabled = ?, color = ?, updated_at = now() WHERE id = ?",
displayName, production, enabled, color, id);
}
@Override
@@ -93,6 +95,10 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository {
} catch (Exception e) { /* use empty default */ }
int retentionRaw = rs.getInt("jar_retention_count");
Integer jarRetentionCount = rs.wasNull() ? null : retentionRaw;
String color = rs.getString("color");
if (color == null || color.isBlank()) {
color = EnvironmentColor.DEFAULT;
}
return new Environment(
UUID.fromString(rs.getString("id")),
rs.getString("slug"),
@@ -101,6 +107,7 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository {
rs.getBoolean("enabled"),
config,
jarRetentionCount,
color,
rs.getTimestamp("created_at").toInstant()
);
}

View File

@@ -18,6 +18,8 @@ spring:
mvc:
async:
request-timeout: -1
mustache:
check-template-location: false
jackson:
serialization:
write-dates-as-timestamps: false

View File

@@ -0,0 +1,6 @@
-- V2: per-environment color for UI indicator
-- Added after V1 baseline (2026-04-22). 8-swatch preset palette; default 'slate'.
ALTER TABLE environments
ADD COLUMN color VARCHAR(16) NOT NULL DEFAULT 'slate'
CHECK (color IN ('slate','red','amber','green','teal','blue','purple','pink'));

View File

@@ -37,7 +37,7 @@ class AgentLifecycleEvaluatorTest {
events = mock(AgentEventRepository.class);
envRepo = mock(EnvironmentRepository.class);
when(envRepo.findById(ENV_ID)).thenReturn(Optional.of(
new Environment(ENV_ID, ENV_SLUG, "Prod", true, true, Map.of(), 5, Instant.EPOCH)));
new Environment(ENV_ID, ENV_SLUG, "Prod", true, true, Map.of(), 5, "slate", Instant.EPOCH)));
eval = new AgentLifecycleEvaluator(events, envRepo);
}

View File

@@ -41,7 +41,7 @@ class ExchangeMatchEvaluatorTest {
null, null, null, null, null, null, null, null, null, null, null, null, null, null);
eval = new ExchangeMatchEvaluator(searchIndex, envRepo, props);
var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, null);
var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, "slate", null);
when(envRepo.findById(ENV_ID)).thenReturn(Optional.of(env));
}

View File

@@ -35,7 +35,7 @@ class LogPatternEvaluatorTest {
envRepo = mock(EnvironmentRepository.class);
eval = new LogPatternEvaluator(logStore, envRepo);
var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, null);
var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, "slate", null);
when(envRepo.findById(ENV_ID)).thenReturn(Optional.of(env));
}

View File

@@ -36,7 +36,7 @@ class RouteMetricEvaluatorTest {
envRepo = mock(EnvironmentRepository.class);
eval = new RouteMetricEvaluator(statsStore, envRepo);
var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, null);
var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, "slate", null);
when(envRepo.findById(ENV_ID)).thenReturn(Optional.of(env));
}

View File

@@ -28,7 +28,7 @@ class NotificationContextBuilderTest {
// ---- helpers ----
private Environment env() {
return new Environment(ENV_ID, "prod", "Production", true, true, Map.of(), 5, Instant.EPOCH);
return new Environment(ENV_ID, "prod", "Production", true, true, Map.of(), 5, "slate", Instant.EPOCH);
}
private AlertRule rule(ConditionKind kind) {

View File

@@ -88,9 +88,80 @@ class EnvironmentAdminControllerIT extends AbstractPostgresIT {
assertThat(body.path("displayName").asText()).isEqualTo("Staging");
assertThat(body.path("production").asBoolean()).isFalse();
assertThat(body.path("enabled").asBoolean()).isTrue();
assertThat(body.path("color").asText()).isEqualTo("slate");
assertThat(body.has("id")).isTrue();
}
@Test
void updateEnvironment_withValidColor_persists() throws Exception {
restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.POST,
new HttpEntity<>("""
{"slug": "color-ok", "displayName": "Color OK", "production": false}
""", securityHelper.authHeaders(adminJwt)),
String.class);
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/environments/color-ok", HttpMethod.PUT,
new HttpEntity<>("""
{"displayName": "Color OK", "production": false, "enabled": true, "color": "amber"}
""", securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.path("color").asText()).isEqualTo("amber");
}
@Test
void updateEnvironment_withNullColor_preservesExisting() throws Exception {
restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.POST,
new HttpEntity<>("""
{"slug": "color-preserve", "displayName": "Keep", "production": false}
""", securityHelper.authHeaders(adminJwt)),
String.class);
// Set color to teal
restTemplate.exchange(
"/api/v1/admin/environments/color-preserve", HttpMethod.PUT,
new HttpEntity<>("""
{"displayName": "Keep", "production": false, "enabled": true, "color": "teal"}
""", securityHelper.authHeaders(adminJwt)),
String.class);
// Update without color field → teal preserved
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/environments/color-preserve", HttpMethod.PUT,
new HttpEntity<>("""
{"displayName": "Still Keep", "production": false, "enabled": true}
""", securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.path("displayName").asText()).isEqualTo("Still Keep");
assertThat(body.path("color").asText()).isEqualTo("teal");
}
@Test
void updateEnvironment_withUnknownColor_returns400() throws Exception {
restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.POST,
new HttpEntity<>("""
{"slug": "color-bad", "displayName": "Bad", "production": false}
""", securityHelper.authHeaders(adminJwt)),
String.class);
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/environments/color-bad", HttpMethod.PUT,
new HttpEntity<>("""
{"displayName": "Bad", "production": false, "enabled": true, "color": "neon"}
""", securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void updateEnvironment_asAdmin_returns200() throws Exception {
// Create an environment first

View File

@@ -12,5 +12,6 @@ public record Environment(
boolean enabled,
Map<String, Object> defaultContainerConfig,
Integer jarRetentionCount,
String color,
Instant createdAt
) {}

View File

@@ -0,0 +1,28 @@
package com.cameleer.server.core.runtime;
import java.util.Set;
/**
* Preset palette for the per-environment UI color indicator. Stored as a plain
* lowercase string on {@link Environment#color()}. The eight values are
* CHECK-constrained in PostgreSQL (V2 migration) and validated again here on
* the write path so the controller can return a 400 with a readable message.
*
* <p>Unknown values are silently tolerated on read (the UI falls back to
* {@link #DEFAULT}), so a manual DB tweak won't break rendering — but the API
* refuses to persist anything outside this set.
*/
public final class EnvironmentColor {
public static final String DEFAULT = "slate";
public static final Set<String> VALUES = Set.of(
"slate", "red", "amber", "green", "teal", "blue", "purple", "pink"
);
private EnvironmentColor() {}
public static boolean isValid(String color) {
return color != null && VALUES.contains(color);
}
}

View File

@@ -10,7 +10,7 @@ public interface EnvironmentRepository {
Optional<Environment> findById(UUID id);
Optional<Environment> findBySlug(String slug);
UUID create(String slug, String displayName, boolean production);
void update(UUID id, String displayName, boolean production, boolean enabled);
void update(UUID id, String displayName, boolean production, boolean enabled, String color);
void updateDefaultContainerConfig(UUID id, Map<String, Object> defaultContainerConfig);
void updateJarRetentionCount(UUID id, Integer jarRetentionCount);
void delete(UUID id);

View File

@@ -43,9 +43,17 @@ public class EnvironmentService {
return repo.create(slug, displayName, production);
}
public void update(UUID id, String displayName, boolean production, boolean enabled) {
/**
* Update mutable environment fields. Color is validated against
* {@link EnvironmentColor#VALUES}. Unknown colors raise
* {@link IllegalArgumentException}; the controller maps that to 400.
*/
public void update(UUID id, String displayName, boolean production, boolean enabled, String color) {
getById(id); // verify exists
repo.update(id, displayName, production, enabled);
if (!EnvironmentColor.isValid(color)) {
throw new IllegalArgumentException("unknown environment color: " + color);
}
repo.update(id, displayName, production, enabled, color);
}
public void updateDefaultContainerConfig(UUID id, Map<String, Object> defaultContainerConfig) {

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`.

File diff suppressed because one or more lines are too long

View File

@@ -9,6 +9,7 @@ export interface Environment {
enabled: boolean;
defaultContainerConfig: Record<string, unknown>;
jarRetentionCount: number | null;
color: string;
createdAt: string;
}
@@ -22,6 +23,7 @@ export interface UpdateEnvironmentRequest {
displayName: string;
production: boolean;
enabled: boolean;
color?: string;
}
export function useEnvironments() {

View File

@@ -237,8 +237,8 @@ export interface paths {
/** Get environment by slug */
get: operations["getEnvironment"];
/**
* Update an environment's mutable fields (displayName, production, enabled)
* @description Slug is immutable after creation and cannot be changed. Any slug field in the request body is ignored.
* Update an environment's mutable fields (displayName, production, enabled, color)
* @description Slug is immutable after creation and cannot be changed. Any slug field in the request body is ignored. If color is null or absent, the existing color is preserved.
*/
put: operations["updateEnvironment"];
post?: never;
@@ -2111,6 +2111,7 @@ export interface components {
};
/** Format: int32 */
jarRetentionCount?: number;
color?: string;
/** Format: date-time */
createdAt?: string;
};
@@ -2581,6 +2582,7 @@ export interface components {
displayName?: string;
production?: boolean;
enabled?: boolean;
color?: string;
};
JarRetentionRequest: {
/** Format: int32 */
@@ -4551,6 +4553,15 @@ export interface operations {
"*/*": Record<string, never>;
};
};
/** @description Unknown color value */
400: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": Record<string, never>;
};
};
/** @description Environment not found */
404: {
headers: {

View File

@@ -1,4 +0,0 @@
/* Layout wrapper — DS Select handles its own appearance */
.select {
min-width: 100px;
}

View File

@@ -1,30 +0,0 @@
import { useMemo } from 'react';
import { Select } from '@cameleer/design-system';
import styles from './EnvironmentSelector.module.css';
interface EnvironmentSelectorProps {
environments: string[];
value: string | undefined;
onChange: (env: string | undefined) => void;
}
export function EnvironmentSelector({ environments, value, onChange }: EnvironmentSelectorProps) {
if (environments.length === 0) return null;
const options = useMemo(
() => [
{ value: '', label: 'All Envs' },
...environments.map((env) => ({ value: env, label: env })),
],
[environments],
);
return (
<Select
className={styles.select}
options={options}
value={value ?? ''}
onChange={(e) => onChange(e.target.value || undefined)}
/>
);
}

View File

@@ -0,0 +1,44 @@
.button {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
height: 32px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 6px;
color: var(--text-primary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
}
.button:hover {
background: var(--bg-surface-hover, var(--bg-surface));
border-color: var(--border-strong, var(--border-subtle));
}
.button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.name {
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
color: var(--text-muted);
flex-shrink: 0;
}

View File

@@ -0,0 +1,57 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { EnvironmentSwitcherButton } from './EnvironmentSwitcherButton';
import type { Environment } from '../api/queries/admin/environments';
const envs: Environment[] = [
{
id: '11111111-1111-1111-1111-111111111111',
slug: 'dev',
displayName: 'Development',
production: false,
enabled: true,
defaultContainerConfig: {},
jarRetentionCount: 5,
color: 'amber',
createdAt: '2026-04-22T00:00:00Z',
},
{
id: '22222222-2222-2222-2222-222222222222',
slug: 'prod',
displayName: 'Production',
production: true,
enabled: true,
defaultContainerConfig: {},
jarRetentionCount: 10,
color: 'red',
createdAt: '2026-04-22T00:00:00Z',
},
];
describe('EnvironmentSwitcherButton', () => {
it('renders the selected env display name', () => {
render(<EnvironmentSwitcherButton envs={envs} value="dev" onClick={() => {}} />);
expect(screen.getByText('Development')).toBeInTheDocument();
});
it('shows placeholder text when no env is selected', () => {
render(<EnvironmentSwitcherButton envs={envs} value={undefined} onClick={() => {}} />);
expect(screen.getByText(/select environment/i)).toBeInTheDocument();
});
it('fires onClick when pressed', () => {
const onClick = vi.fn();
render(<EnvironmentSwitcherButton envs={envs} value="dev" onClick={onClick} />);
fireEvent.click(screen.getByRole('button', { name: /switch environment/i }));
expect(onClick).toHaveBeenCalledOnce();
});
it('paints the color dot with the env color CSS variable', () => {
const { container } = render(
<EnvironmentSwitcherButton envs={envs} value="prod" onClick={() => {}} />,
);
const dot = container.querySelector('span[aria-hidden]');
expect(dot).toBeTruthy();
expect((dot as HTMLElement).style.background).toContain('env-color-red');
});
});

View File

@@ -0,0 +1,30 @@
import { ChevronDown } from 'lucide-react';
import type { Environment } from '../api/queries/admin/environments';
import { envColorVar } from './env-colors';
import styles from './EnvironmentSwitcherButton.module.css';
interface EnvironmentSwitcherButtonProps {
envs: Environment[];
value: string | undefined;
onClick: () => void;
}
export function EnvironmentSwitcherButton({ envs, value, onClick }: EnvironmentSwitcherButtonProps) {
const current = envs.find((e) => e.slug === value);
const displayName = current?.displayName ?? value ?? 'Select environment';
const color = envColorVar(current?.color);
return (
<button
type="button"
className={styles.button}
onClick={onClick}
aria-label="Switch environment"
aria-haspopup="dialog"
>
<span className={styles.dot} style={{ background: color }} aria-hidden />
<span className={styles.name}>{displayName}</span>
<ChevronDown size={14} className={styles.chevron} aria-hidden />
</button>
);
}

View File

@@ -0,0 +1,82 @@
.list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 12px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 6px;
color: var(--text-primary);
cursor: pointer;
text-align: left;
transition: background 0.15s ease, border-color 0.15s ease;
}
.row:hover {
background: var(--bg-surface-hover, var(--bg-elevated));
border-color: var(--border-strong, var(--border-subtle));
}
.row:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.rowSelected {
border-color: var(--accent);
}
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.labels {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.name {
font-size: 13px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.slug {
color: var(--text-muted);
margin-top: 2px;
}
.badges {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.check {
color: var(--accent);
flex-shrink: 0;
}
.empty {
padding: 20px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
}

View File

@@ -0,0 +1,111 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '@cameleer/design-system';
import type { ReactNode } from 'react';
import { EnvironmentSwitcherModal } from './EnvironmentSwitcherModal';
import type { Environment } from '../api/queries/admin/environments';
function wrap(ui: ReactNode) {
return render(<ThemeProvider>{ui}</ThemeProvider>);
}
const envs: Environment[] = [
{
id: '11111111-1111-1111-1111-111111111111',
slug: 'dev',
displayName: 'Development',
production: false,
enabled: true,
defaultContainerConfig: {},
jarRetentionCount: 5,
color: 'amber',
createdAt: '2026-04-22T00:00:00Z',
},
{
id: '22222222-2222-2222-2222-222222222222',
slug: 'prod',
displayName: 'Production',
production: true,
enabled: true,
defaultContainerConfig: {},
jarRetentionCount: 10,
color: 'red',
createdAt: '2026-04-22T00:00:00Z',
},
];
describe('EnvironmentSwitcherModal', () => {
it('renders one row per env when open', () => {
wrap(
<EnvironmentSwitcherModal
open
onClose={() => {}}
envs={envs}
value="dev"
onPick={() => {}}
/>,
);
expect(screen.getByText('Development')).toBeInTheDocument();
expect(screen.getByText('Production')).toBeInTheDocument();
});
it('calls onPick with slug when a row is clicked', () => {
const onPick = vi.fn();
wrap(
<EnvironmentSwitcherModal
open
onClose={() => {}}
envs={envs}
value="dev"
onPick={onPick}
/>,
);
fireEvent.click(screen.getByRole('option', { name: /production/i }));
expect(onPick).toHaveBeenCalledWith('prod');
});
it('marks the current env with aria-selected', () => {
wrap(
<EnvironmentSwitcherModal
open
onClose={() => {}}
envs={envs}
value="dev"
onPick={() => {}}
/>,
);
const selected = screen.getByRole('option', { name: /development/i });
expect(selected).toHaveAttribute('aria-selected', 'true');
});
it('renders empty state when no envs exist', () => {
wrap(
<EnvironmentSwitcherModal
open
onClose={() => {}}
envs={[]}
value={undefined}
onPick={() => {}}
/>,
);
expect(screen.getByText(/no environments/i)).toBeInTheDocument();
});
it('forced mode swaps the title and ignores onClose', () => {
const onClose = vi.fn();
wrap(
<EnvironmentSwitcherModal
open
onClose={onClose}
envs={envs}
value={undefined}
onPick={() => {}}
forced
/>,
);
expect(screen.getByText(/select an environment/i)).toBeInTheDocument();
// Simulate ESC — DS Modal forwards this to onClose, which we wrapped in a no-op.
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,66 @@
import { Modal, Badge, MonoText } from '@cameleer/design-system';
import { Check } from 'lucide-react';
import type { Environment } from '../api/queries/admin/environments';
import { envColorVar } from './env-colors';
import styles from './EnvironmentSwitcherModal.module.css';
interface EnvironmentSwitcherModalProps {
open: boolean;
onClose: () => void;
envs: Environment[];
value: string | undefined;
onPick: (slug: string) => void;
/** When true, ESC/backdrop do nothing — the user must pick one. */
forced?: boolean;
}
export function EnvironmentSwitcherModal({
open,
onClose,
envs,
value,
onPick,
forced = false,
}: EnvironmentSwitcherModalProps) {
const title = forced ? 'Select an environment' : 'Switch environment';
const handleClose = forced ? () => { /* locked */ } : onClose;
return (
<Modal open={open} onClose={handleClose} title={title} size="sm">
{envs.length === 0 ? (
<div className={styles.empty}>
No environments ask an admin to create one.
</div>
) : (
<ul className={styles.list} role="listbox" aria-label="Environments">
{envs.map((env) => {
const selected = env.slug === value;
return (
<li key={env.id}>
<button
type="button"
className={`${styles.row} ${selected ? styles.rowSelected : ''}`}
onClick={() => onPick(env.slug)}
aria-selected={selected}
role="option"
>
<span className={styles.dot} style={{ background: envColorVar(env.color) }} aria-hidden />
<div className={styles.labels}>
<div className={styles.name}>{env.displayName}</div>
<MonoText size="xs" className={styles.slug}>{env.slug}</MonoText>
</div>
<div className={styles.badges}>
{env.production && <Badge label="PROD" color="error" />}
{!env.production && <Badge label="NON-PROD" color="auto" />}
{!env.enabled && <Badge label="DISABLED" color="warning" />}
</div>
{selected && <Check size={16} className={styles.check} aria-label="current" />}
</button>
</li>
);
})}
</ul>
)}
</Modal>
);
}

View File

@@ -39,7 +39,9 @@ import { useEnvironmentStore } from '../api/environment-store';
import { useState, useMemo, useCallback, useEffect, useRef, createElement } from 'react';
import type { ReactNode } from 'react';
import { ContentTabs } from './ContentTabs';
import { EnvironmentSelector } from './EnvironmentSelector';
import { EnvironmentSwitcherButton } from './EnvironmentSwitcherButton';
import { EnvironmentSwitcherModal } from './EnvironmentSwitcherModal';
import { envColorVar } from './env-colors';
import { useScope } from '../hooks/useScope';
import { formatDuration } from '../utils/format-utils';
import {
@@ -428,6 +430,25 @@ function LayoutContent() {
queryClient.invalidateQueries();
}, [setSelectedEnvRaw, navigate, location.pathname, location.search, queryClient]);
// --- Env switcher modal -------------------------------------------
const [switcherOpen, setSwitcherOpen] = useState(false);
// Force-open the switcher when we have envs loaded but no valid selection.
// This replaces the old "All Envs" fallback: every session must pick one.
const selectionInvalid =
envRecords.length > 0 &&
(selectedEnv === undefined || !envRecords.some((e) => e.slug === selectedEnv));
const switcherForced = selectionInvalid;
useEffect(() => {
if (selectionInvalid) {
if (selectedEnv !== undefined) setSelectedEnvRaw(undefined);
setSwitcherOpen(true);
}
}, [selectionInvalid, selectedEnv, setSelectedEnvRaw]);
const currentEnvRecord = envRecords.find((e) => e.slug === selectedEnv);
const envBarColor = envColorVar(currentEnvRecord?.color);
// --- Section open states ------------------------------------------
const [appsOpen, setAppsOpen] = useState(() => (isAdminPage || isAlertsPage) ? false : readCollapsed(SK_APPS, true));
const [adminOpen, setAdminOpen] = useState(() => isAdminPage ? true : readCollapsed(SK_ADMIN, false));
@@ -954,14 +975,38 @@ function LayoutContent() {
return (
<AppShell sidebar={sidebarElement}>
<div
aria-hidden
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
height: 3,
background: envBarColor,
zIndex: 900,
pointerEvents: 'none',
}}
/>
<EnvironmentSwitcherModal
open={switcherOpen}
onClose={() => setSwitcherOpen(false)}
envs={envRecords}
value={selectedEnv}
onPick={(slug) => {
setSelectedEnv(slug);
setSwitcherOpen(false);
}}
forced={switcherForced}
/>
<TopBar
breadcrumb={breadcrumb}
environment={
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<EnvironmentSelector
environments={environments}
<EnvironmentSwitcherButton
envs={envRecords}
value={selectedEnv}
onChange={setSelectedEnv}
onClick={() => setSwitcherOpen(true)}
/>
<NotificationBell />
</div>

View File

@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { envColorVar, isEnvColor, ENV_COLORS, DEFAULT_ENV_COLOR } from './env-colors';
describe('env-colors', () => {
it('maps known colors to their css variables', () => {
for (const c of ENV_COLORS) {
expect(envColorVar(c)).toBe(`var(--env-color-${c})`);
}
});
it('falls back to slate for unknown values', () => {
expect(envColorVar('neon')).toBe('var(--env-color-slate)');
expect(envColorVar(undefined)).toBe('var(--env-color-slate)');
expect(envColorVar(null)).toBe('var(--env-color-slate)');
expect(envColorVar('')).toBe('var(--env-color-slate)');
});
it('isEnvColor narrows types', () => {
expect(isEnvColor('amber')).toBe(true);
expect(isEnvColor('neon')).toBe(false);
expect(isEnvColor(undefined)).toBe(false);
});
it('default is slate', () => {
expect(DEFAULT_ENV_COLOR).toBe('slate');
});
});

View File

@@ -0,0 +1,29 @@
// Preset palette for per-environment color indicator. Mirrors the backend
// EnvironmentColor enum. Add/remove entries here AND in:
// - backend V*__add_environment_color.sql CHECK constraint
// - cameleer-server-core/.../EnvironmentColor.java VALUES set
// - ui/src/styles/env-colors.css (token definitions)
export const ENV_COLORS = [
'slate',
'red',
'amber',
'green',
'teal',
'blue',
'purple',
'pink',
] as const;
export type EnvColor = (typeof ENV_COLORS)[number];
export const DEFAULT_ENV_COLOR: EnvColor = 'slate';
export function isEnvColor(v: string | null | undefined): v is EnvColor {
return typeof v === 'string' && (ENV_COLORS as readonly string[]).includes(v);
}
// Safe for any string input — unknown/missing values resolve to the default
// token. Use this whenever rendering an env's color; never concat directly.
export function envColorVar(color: string | null | undefined): string {
return `var(--env-color-${isEnvColor(color) ? color : DEFAULT_ENV_COLOR})`;
}

View File

@@ -1,5 +1,6 @@
import '@cameleer/design-system/style.css';
import './index.css';
import './styles/env-colors.css';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router';

View File

@@ -23,6 +23,8 @@ import {
useUpdateJarRetention,
} from '../../api/queries/admin/environments';
import type { Environment } from '../../api/queries/admin/environments';
import { ENV_COLORS, envColorVar, type EnvColor } from '../../components/env-colors';
import { Check } from 'lucide-react';
import { PageLoader } from '../../components/PageLoader';
import styles from './UserManagement.module.css';
import sectionStyles from '../../styles/section-card.module.css';
@@ -120,6 +122,7 @@ export default function EnvironmentsPage() {
displayName: newName,
production: selected.production,
enabled: selected.enabled,
color: selected.color,
});
toast({ title: 'Environment renamed', variant: 'success' });
} catch {
@@ -135,6 +138,7 @@ export default function EnvironmentsPage() {
displayName: selected.displayName,
production: value,
enabled: selected.enabled,
color: selected.color,
});
toast({ title: value ? 'Marked as production' : 'Marked as non-production', variant: 'success' });
} catch {
@@ -150,6 +154,7 @@ export default function EnvironmentsPage() {
displayName: selected.displayName,
production: selected.production,
enabled: value,
color: selected.color,
});
toast({ title: value ? 'Environment enabled' : 'Environment disabled', variant: 'success' });
} catch {
@@ -157,6 +162,22 @@ export default function EnvironmentsPage() {
}
}
async function handleColorChange(color: EnvColor) {
if (!selected || selected.color === color) return;
try {
await updateEnv.mutateAsync({
slug: selected.slug,
displayName: selected.displayName,
production: selected.production,
enabled: selected.enabled,
color,
});
toast({ title: 'Environment color updated', variant: 'success' });
} catch {
toast({ title: 'Failed to update color', variant: 'error', duration: 86_400_000 });
}
}
if (isLoading) return <PageLoader />;
return (
@@ -279,6 +300,44 @@ export default function EnvironmentsPage() {
</div>
</div>
<div className={sectionStyles.section}>
<SectionHeader>Appearance</SectionHeader>
<p className={styles.inheritedNote}>
This color is shown as a 3px bar across every page while this environment is active.
</p>
<div role="radiogroup" aria-label="Environment color" style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{ENV_COLORS.map((c) => {
const isSelected = selected.color === c;
return (
<button
key={c}
type="button"
role="radio"
aria-checked={isSelected}
aria-label={c}
title={c}
onClick={() => handleColorChange(c)}
style={{
width: 36,
height: 36,
borderRadius: '50%',
background: envColorVar(c),
border: isSelected ? '2px solid var(--text-primary)' : '1px solid var(--border-subtle)',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
padding: 0,
}}
>
{isSelected && <Check size={16} aria-hidden />}
</button>
);
})}
</div>
</div>
<div className={sectionStyles.section}>
<SectionHeader>Status</SectionHeader>
<div className={styles.securitySection}>

View File

@@ -0,0 +1,34 @@
/*
* Per-environment preset color tokens. Used by:
* - LayoutShell's 3px top-bar indicator
* - EnvironmentSwitcherButton / EnvironmentSwitcherModal (color dot)
* - EnvironmentsPage "Appearance" swatch grid
*
* Light-mode and dark-mode values are tuned for WCAG-AA contrast against the
* respective surface tokens (`--bg-surface`, `--text-primary`).
*
* Storage: plain lowercase string ("slate", "red", ...). Unknown values fall
* back to `slate` via `envColorVar()`.
*/
: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;
--env-color-red: #f87171;
--env-color-amber: #fbbf24;
--env-color-green: #34d399;
--env-color-teal: #2dd4bf;
--env-color-blue: #60a5fa;
--env-color-purple: #c084fc;
--env-color-pink: #f472b6;
}