ui(env): explicit switcher button+modal, forced selection, 3px color bar
- 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>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
/* Layout wrapper — DS Select handles its own appearance */
|
||||
.select {
|
||||
min-width: 100px;
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
44
ui/src/components/EnvironmentSwitcherButton.module.css
Normal file
44
ui/src/components/EnvironmentSwitcherButton.module.css
Normal 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;
|
||||
}
|
||||
57
ui/src/components/EnvironmentSwitcherButton.test.tsx
Normal file
57
ui/src/components/EnvironmentSwitcherButton.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
30
ui/src/components/EnvironmentSwitcherButton.tsx
Normal file
30
ui/src/components/EnvironmentSwitcherButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
ui/src/components/EnvironmentSwitcherModal.module.css
Normal file
82
ui/src/components/EnvironmentSwitcherModal.module.css
Normal 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;
|
||||
}
|
||||
111
ui/src/components/EnvironmentSwitcherModal.test.tsx
Normal file
111
ui/src/components/EnvironmentSwitcherModal.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
66
ui/src/components/EnvironmentSwitcherModal.tsx
Normal file
66
ui/src/components/EnvironmentSwitcherModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
27
ui/src/components/env-colors.test.ts
Normal file
27
ui/src/components/env-colors.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
29
ui/src/components/env-colors.ts
Normal file
29
ui/src/components/env-colors.ts
Normal 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})`;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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}>
|
||||
|
||||
34
ui/src/styles/env-colors.css
Normal file
34
ui/src/styles/env-colors.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user