# UX Polish & Bug Fixes — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Fix critical UI bugs, standardize layout/interaction patterns across all pages, improve contrast/readability, and polish data formatting and admin UX.

**Architecture:** All changes are in the existing UI codebase (`ui/src/`) and one backend controller. No new components or architecture changes — this is about adopting existing design system patterns consistently and fixing bugs. The design system package `@cameleer/design-system` provides shared CSS modules and components that many pages don't yet use.

**Tech Stack:** React 18, TypeScript, CSS Modules, `@cameleer/design-system`, React Router v6, React Query, Spring Boot (backend)

**Spec:** `docs/superpowers/specs/2026-04-09-ux-polish-design.md`

**Audit artifacts:** `audit/design-consistency-findings.md`, `audit/interaction-patterns-findings.md`, `audit/monitoring-pages-findings.md`, `audit/admin-lifecycle-findings.md`

---

## Task 1: Fix `/server/deployments` 404 and GC Pauses chart

**Spec items:** 1.3, 1.4

**Files:**
- Modify: `ui/src/router.tsx`
- Modify: `ui/src/pages/AgentInstance/AgentInstance.tsx`

- [ ] **Step 1: Add deployments redirect in router.tsx**

In `ui/src/router.tsx`, add a redirect route for `/server/deployments`. Find the existing legacy redirects (around lines 63-67 where `logs` redirects to `/runtime` and `config` redirects to `/apps`). Add a `deployments` redirect in the same block:

```tsx
{ path: 'deployments', element: <Navigate to="apps" replace /> },
```

Add this alongside the existing legacy redirects. The `Navigate` component should already be imported from `react-router-dom`.

- [ ] **Step 2: Fix GC Pauses chart X-axis in AgentInstance.tsx**

In `ui/src/pages/AgentInstance/AgentInstance.tsx`, find the GC Pauses series builder (around line 113):

```typescript
// BEFORE (line 113):
return [{ label: 'GC ms', data: pts.map((p: any) => ({ x: String(p.time ?? ''), y: p.value })) }];
```

Change to use numeric index like all other charts:

```typescript
// AFTER:
return [{ label: 'GC ms', data: pts.map((p: any, i: number) => ({ x: i, y: p.value })) }];
```

This matches the pattern used by CPU (line 95), Heap (line 101), Threads (line 107), Throughput (line 120), and Error Rate (line 127) — all use `x: i`.

- [ ] **Step 3: Verify visually**

Open the app in a browser:
1. Navigate to `https://<host>/server/deployments` — should redirect to `/server/apps`
2. Navigate to Runtime > click an agent > scroll to GC Pauses chart — X-axis should show numeric labels, not ISO timestamps

- [ ] **Step 4: Commit**

```bash
git add ui/src/router.tsx ui/src/pages/AgentInstance/AgentInstance.tsx
git commit -m "fix: add /deployments redirect and fix GC Pauses chart X-axis"
```

---

## Task 2: Fix user creation in OIDC mode

**Spec items:** 1.2

**Files:**
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/UserAdminController.java`
- Modify: `ui/src/pages/Admin/UsersTab.tsx`

- [ ] **Step 1: Run impact analysis on UserAdminController.createUser**

```bash
npx gitnexus impact --target "UserAdminController.createUser" --direction upstream
```

Review blast radius before editing.

- [ ] **Step 2: Fix backend — return error body when OIDC enabled**

In `UserAdminController.java`, find the OIDC check (around line 92-93):

```java
// BEFORE:
if (oidcEnabled) {
    return ResponseEntity.badRequest().build();
}
```

Change to return a descriptive error:

```java
// AFTER:
if (oidcEnabled) {
    return ResponseEntity.badRequest()
        .body(java.util.Map.of("error", "Local user creation is disabled when OIDC is enabled. Users are provisioned automatically via SSO."));
}
```

- [ ] **Step 3: Fix frontend — hide create form when OIDC local creation would fail**

In `ui/src/pages/Admin/UsersTab.tsx`, the `+ Add user` button is on the `EntityList` component (around line 318). The OIDC config state needs to be checked. Find where OIDC state is available (it may already be fetched for the Local/OIDC radio toggle).

Add a check: when the OIDC provider is enabled AND the user tries to create a `local` user, show a message instead of the form. The simplest approach: keep the form but when `newProvider === 'local'` and OIDC is the only provider, show an info callout explaining local creation is disabled. The existing `InfoCallout` for OIDC users (lines 247-251) provides the pattern.

After the password field (around line 246), add a check for the case where local creation fails:

```tsx
{newProvider === 'local' && oidcEnabled && (
  <InfoCallout variant="amber">
    Local user creation is disabled while OIDC is enabled.
    Switch to OIDC to pre-register a user, or disable OIDC first.
  </InfoCallout>
)}
```

Also update the error toast handler (in `handleCreate`) to surface the API error message. Find the catch block and use the response body:

```typescript
onError: (err: any) => {
  const message = err?.body?.error || err?.message || 'Unknown error';
  toast({ title: 'Failed to create user', description: message, variant: 'error', duration: 86_400_000 });
},
```

- [ ] **Step 4: Verify visually**

1. Navigate to Admin > Users & Roles
2. Click "+ Add user", select "Local" provider
3. If OIDC is enabled, the info callout should appear
4. Attempting to create should show the descriptive error message, not just "Failed to create user"

- [ ] **Step 5: Commit**

```bash
git add cameleer-server-app/src/main/java/com/cameleer/server/app/controller/UserAdminController.java ui/src/pages/Admin/UsersTab.tsx
git commit -m "fix: show descriptive error when creating local user with OIDC enabled"
```

---

## Task 3: Investigate and fix SSE navigation bug

**Spec item:** 1.1

**Files:**
- Investigate: `ui/src/components/LayoutShell.tsx`
- Investigate: `ui/src/pages/Exchanges/ExchangesPage.tsx`
- Investigate: `ui/src/hooks/` (any SSE or polling hooks)

- [ ] **Step 1: Identify the navigation trigger**

The audit found that admin pages sporadically redirect to `/server/exchanges`. LayoutShell.tsx has a path normalization at line 444-449:

```typescript
const effectiveSelectedPath = useMemo(() => {
  const raw = sidebarRevealPath ?? location.pathname;
  const match = raw.match(/^\/(exchanges|dashboard|apps|runtime)\/([^/]+)(\/.*)?$/);
  if (match) return `/exchanges/${match[2]}${match[3] ?? ''}`;
  return raw;
}, [sidebarRevealPath, location.pathname]);
```

This rewrites ALL tab paths to `/exchanges/...` for sidebar highlighting. But this is a `useMemo`, not a navigation call. Search for:

1. Any `useNavigate()` or `navigate()` calls triggered by data updates
2. Any `useEffect` that calls `navigate` based on exchange/catalog data changes
3. Any auto-refresh callback that might trigger navigation
4. The `sidebarRevealPath` state — what sets it?

```bash
cd ui/src && grep -rn "navigate(" components/LayoutShell.tsx | head -20
cd ui/src && grep -rn "sidebarRevealPath" components/LayoutShell.tsx | head -10
```

- [ ] **Step 2: Apply fix based on investigation**

The exact fix depends on what Step 1 reveals. The principle: SSE/polling data updates must NEVER trigger `navigate()` when the user is on an admin page. Common patterns to look for:

- A `useEffect` that watches exchange data and navigates to show the latest exchange
- A sidebar tree item click handler that fires on data refresh (re-render causes focus/activation)
- An auto-refresh timer that resets the route

If it's a `useEffect` with `navigate`, add a route guard:

```typescript
// Only navigate if we're already on the exchanges tab
if (!location.pathname.startsWith('/server/exchanges')) return;
```

If it's a sidebar focus issue, prevent navigation on programmatic focus:

```typescript
// Only navigate on explicit user clicks, not focus events
onClick={(e) => { if (e.isTrusted) navigate(path); }}
```

- [ ] **Step 3: Verify by navigating admin pages while data is flowing**

1. Open Admin > Users & Roles
2. Wait 30-60 seconds while agents are sending data
3. Interact with the form (click fields, open dropdowns)
4. Confirm no redirect to /server/exchanges occurs

- [ ] **Step 4: Commit**

```bash
git add ui/src/components/LayoutShell.tsx  # and any other modified files
git commit -m "fix: prevent SSE data updates from triggering navigation on admin pages"
```

---

## Task 4: Exchanges table containment and Dashboard padding

**Spec items:** 2a.1, 2a.4

**Files:**
- Modify: `ui/src/pages/Dashboard/Dashboard.tsx`
- Modify: `ui/src/pages/Dashboard/Dashboard.module.css`
- Modify: `ui/src/pages/DashboardTab/DashboardTab.module.css`
- Modify: `ui/src/pages/AppsTab/AppsTab.module.css`

- [ ] **Step 1: Wrap exchanges table in shared table-section**

In `ui/src/pages/Dashboard/Dashboard.tsx`, add the shared table-section import:

```typescript
import tableStyles from '../../styles/table-section.module.css';
```

Find the table rendering section (around line 237-285). Wrap the table header and DataTable in the shared `tableSection`:

```tsx
<div className={tableStyles.tableSection}>
  <div className={tableStyles.tableHeader}>
    <span className={tableStyles.tableTitle}>Recent Exchanges</span>
    <div className={tableStyles.tableRight}>
      <span className={tableStyles.tableMeta}>{exchanges.length} of {formatNumber(total)} exchanges</span>
      {/* existing auto-refresh indicator */}
    </div>
  </div>
  <DataTable ... />
</div>
```

Replace the custom `.tableHeader`, `.tableTitle`, `.tableRight`, `.tableMeta` class usages with the shared module equivalents.

- [ ] **Step 2: Remove custom table classes from Dashboard.module.css**

In `ui/src/pages/Dashboard/Dashboard.module.css`, remove the custom `.tableHeader`, `.tableTitle`, `.tableRight`, `.tableMeta` classes (they're now provided by the shared module). Keep any other custom classes that aren't table-related.

- [ ] **Step 3: Add side padding to DashboardTab**

In `ui/src/pages/DashboardTab/DashboardTab.module.css`, update `.content`:

```css
/* BEFORE: */
.content {
  display: flex;
  flex-direction: column;
  gap: 20px;
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding-bottom: 20px;
}

/* AFTER: */
.content {
  display: flex;
  flex-direction: column;
  gap: 20px;
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: 0 24px 20px;
}
```

- [ ] **Step 4: Normalize AppsTab container padding**

In `ui/src/pages/AppsTab/AppsTab.module.css`, update `.container`:

```css
/* BEFORE: */
.container {
  padding: 16px;
  overflow-y: auto;
  flex: 1;
}

/* AFTER: */
.container {
  padding: 20px 24px 40px;
  overflow-y: auto;
  flex: 1;
}
```

- [ ] **Step 5: Verify visually**

1. Exchanges tab: table should have card wrapper with border/shadow, matching Audit Log style
2. Dashboard: content should have side margins (24px), no longer flush against sidebar
3. Deployments tab: spacing should match Admin pages (20px top, 24px sides)

- [ ] **Step 6: Commit**

```bash
git add ui/src/pages/Dashboard/Dashboard.tsx ui/src/pages/Dashboard/Dashboard.module.css ui/src/pages/DashboardTab/DashboardTab.module.css ui/src/pages/AppsTab/AppsTab.module.css
git commit -m "fix: standardize table containment and container padding across pages"
```

---

## Task 5: App detail section cards and deployment DataTable

**Spec items:** 2a.2, 2a.3

**Files:**
- Modify: `ui/src/pages/AppsTab/AppsTab.tsx`
- Modify: `ui/src/pages/AppsTab/AppsTab.module.css`

- [ ] **Step 1: Import shared section-card and table-section modules**

At the top of `ui/src/pages/AppsTab/AppsTab.tsx`, add:

```typescript
import sectionStyles from '../../styles/section-card.module.css';
import tableStyles from '../../styles/table-section.module.css';
```

- [ ] **Step 2: Wrap config sub-tab content in section cards**

Find each configuration group in the `ConfigSubTab` component (around lines 722-860). Each logical section (Monitoring settings, Resources, Variables, etc.) should be wrapped in a section card:

```tsx
<div className={sectionStyles.section}>
  <SectionHeader>Monitoring</SectionHeader>
  {/* existing monitoring controls: Engine Level, Payload Capture, etc. */}
</div>
```

Apply this to each sub-tab's content area. The existing `SectionHeader` components mark where sections begin — wrap each section header + its controls in `sectionStyles.section`.

- [ ] **Step 3: Replace manual `<table>` with DataTable in OverviewSubTab**

Find the manual `<table>` in `OverviewSubTab` (lines 623-680). Replace with:

```tsx
<div className={tableStyles.tableSection}>
  <div className={tableStyles.tableHeader}>
    <span className={tableStyles.tableTitle}>Deployments</span>
  </div>
  <DataTable
    columns={[
      { key: 'environment', header: 'Environment', render: (_, row) => <Badge>{row.environmentSlug}</Badge> },
      { key: 'version', header: 'Version' },
      { key: 'status', header: 'Status', render: (_, row) => <Badge variant={statusVariant(row.status)}>{row.status}</Badge> },
      { key: 'deployStage', header: 'Deploy Stage' },
      { key: 'actions', header: '', render: (_, row) => (
        row.status === 'RUNNING' || row.status === 'STARTING' ? (
          <Button size="sm" variant="danger" onClick={() => onStop(row.id)}>Stop</Button>
        ) : null
      )},
    ]}
    data={deployments}
    emptyMessage="No deployments yet."
  />
</div>
```

Adapt the column definitions to match the existing manual table columns. Import `DataTable` from `@cameleer/design-system` if not already imported.

- [ ] **Step 4: Remove custom `.table` styles from AppsTab.module.css**

Remove the manual table CSS classes (`.table`, `.table th`, `.table td`, etc.) from `AppsTab.module.css` since they're replaced by DataTable + shared table-section.

- [ ] **Step 5: Verify visually**

1. Navigate to Deployments tab > click an app
2. Configuration sections should have card wrappers (border, shadow, background)
3. Deployment table should use DataTable with card wrapper, matching other tables

- [ ] **Step 6: Commit**

```bash
git add ui/src/pages/AppsTab/AppsTab.tsx ui/src/pages/AppsTab/AppsTab.module.css
git commit -m "fix: wrap app config in section cards, replace manual table with DataTable"
```

---

## Task 6: Deduplicate card CSS and wrap remaining flat content

**Spec items:** 2a.5, 2a.6, 2a.7

**Files:**
- Modify: `ui/src/pages/DashboardTab/DashboardTab.module.css`
- Modify: `ui/src/pages/DashboardTab/DashboardL1.tsx` (or L2/L3 — whichever uses errorsSection/diagramSection)
- Modify: `ui/src/pages/AgentHealth/AgentHealth.tsx`
- Modify: `ui/src/pages/AgentHealth/AgentHealth.module.css`
- Modify: `ui/src/pages/AgentInstance/AgentInstance.tsx`
- Modify: `ui/src/pages/AgentInstance/AgentInstance.module.css`
- Modify: `ui/src/pages/Admin/ClickHouseAdminPage.module.css`
- Modify: `ui/src/pages/Admin/ClickHouseAdminPage.tsx`
- Modify: `ui/src/pages/Admin/DatabaseAdminPage.tsx`
- Modify: `ui/src/pages/Admin/UsersTab.tsx`
- Modify: `ui/src/pages/Admin/GroupsTab.tsx`
- Modify: `ui/src/pages/Admin/EnvironmentsPage.tsx`

- [ ] **Step 1: Replace duplicated card CSS in DashboardTab**

In `DashboardTab.module.css`, the `.errorsSection` and `.diagramSection` classes duplicate the card pattern. In the TSX files that use them, replace with the shared module:

```typescript
import tableStyles from '../../styles/table-section.module.css';
```

Replace `className={styles.errorsSection}` with `className={tableStyles.tableSection}` (since these are table-like sections). Remove `.errorsSection` and `.diagramSection` from `DashboardTab.module.css`. Keep any non-card properties (like `height: 280px` on diagramSection) as a separate class composed with the shared one:

```tsx
<div className={`${tableStyles.tableSection} ${styles.diagramHeight}`}>
```

```css
.diagramHeight { height: 280px; }
```

- [ ] **Step 2: Replace duplicated card CSS in AgentHealth**

In `AgentHealth.module.css`, remove the card pattern from `.configBar` and `.eventCard`. Import `sectionStyles`:

```typescript
import sectionStyles from '../../styles/section-card.module.css';
```

Replace `className={styles.configBar}` with `className={sectionStyles.section}` (keep any custom padding/margin in a composed class if needed). Same for `.eventCard`.

- [ ] **Step 3: Replace duplicated card CSS in AgentInstance**

Same pattern for `.processCard` and `.timelineCard` in `AgentInstance.module.css`. Import `sectionStyles` and replace.

- [ ] **Step 4: Replace duplicated card CSS in ClickHouseAdminPage**

Replace `.pipelineCard` with `sectionStyles.section`.

- [ ] **Step 5: Wrap Database admin tables in tableSection**

In `DatabaseAdminPage.tsx`, import `tableStyles` and wrap each `DataTable` in a `tableStyles.tableSection` div with a `tableStyles.tableHeader`.

- [ ] **Step 6: Wrap RBAC and Environments detail sections in section cards**

In `UsersTab.tsx`, `GroupsTab.tsx`, and `EnvironmentsPage.tsx`, import `sectionStyles` and wrap detail panel sections in `sectionStyles.section`. Each section header + its content becomes a card:

```tsx
import sectionStyles from '../../styles/section-card.module.css';

// In the detail panel:
<div className={sectionStyles.section}>
  <SectionHeader>Group Membership</SectionHeader>
  {/* existing membership tags */}
</div>

<div className={sectionStyles.section}>
  <SectionHeader>Effective Roles</SectionHeader>
  {/* existing role tags */}
</div>
```

- [ ] **Step 7: Verify visually**

1. Dashboard: errors section and diagram section should still look the same (card styling from shared module now)
2. Runtime > Agent detail: process card and timeline card should have consistent card styling
3. Admin > Database: tables should have card wrappers
4. Admin > Users & Roles: detail panel sections should have card backgrounds
5. Admin > Environments: detail panel sections should have card backgrounds

- [ ] **Step 8: Commit**

```bash
git add -A ui/src/pages/DashboardTab/ ui/src/pages/AgentHealth/ ui/src/pages/AgentInstance/ ui/src/pages/Admin/ClickHouseAdminPage.* ui/src/pages/Admin/DatabaseAdminPage.* ui/src/pages/Admin/UsersTab.tsx ui/src/pages/Admin/GroupsTab.tsx ui/src/pages/Admin/EnvironmentsPage.tsx
git commit -m "fix: deduplicate card CSS, use shared section-card and table-section modules"
```

---

## Task 7: Button order, confirmation dialogs, and destructive action guards

**Spec items:** 2b.1, 2b.2, 2b.3, 2b.4, 2b.5, 2b.6, 2b.8

**Files:**
- Modify: `ui/src/pages/Admin/AppConfigDetailPage.tsx`
- Modify: `ui/src/pages/AppsTab/AppsTab.tsx`
- Modify: `ui/src/components/TapConfigModal.tsx`
- Modify: `ui/src/pages/Admin/DatabaseAdminPage.tsx`
- Modify: `ui/src/pages/Admin/UsersTab.tsx`
- Modify: `ui/src/pages/Routes/RouteDetail.tsx`
- Modify: `ui/src/pages/Admin/OidcConfigPage.tsx`

- [ ] **Step 1: Fix AppConfigDetailPage button order**

In `ui/src/pages/Admin/AppConfigDetailPage.tsx`, find lines 310-319. Change from Save|Cancel to Cancel|Save with correct variants:

```tsx
{editing ? (
  <div className={styles.toolbarActions}>
    <Button variant="ghost" size="sm" onClick={cancelEditing}>Cancel</Button>
    <Button variant="primary" size="sm" onClick={handleSave} loading={updateConfig.isPending}>Save</Button>
  </div>
) : (
  <Button variant="secondary" size="sm" onClick={startEditing}><Pencil size={14} /> Edit</Button>
)}
```

This also fixes spec items 2b.13 (uses `loading` prop instead of "Saving..." text).

- [ ] **Step 2: Add confirmation dialog for deployment stop**

In `ui/src/pages/AppsTab/AppsTab.tsx`, find `handleStop` (around line 526). Add state for the confirmation dialog:

```typescript
const [stopTarget, setStopTarget] = useState<{ id: string; name: string } | null>(null);
```

Replace the immediate stop with a dialog trigger:

```tsx
// In the OverviewSubTab, change the Stop button to:
<Button size="sm" variant="danger" onClick={() => setStopTarget({ id: row.id, name: app.displayName })}>Stop</Button>

// Add ConfirmDialog near the bottom of the component:
<ConfirmDialog
  open={!!stopTarget}
  onCancel={() => setStopTarget(null)}
  onConfirm={async () => {
    if (!stopTarget) return;
    try {
      await stopDeployment.mutateAsync({ appId: appSlug, deploymentId: stopTarget.id });
      toast({ title: 'Deployment stopped', variant: 'warning' });
    } catch {
      toast({ title: 'Failed to stop deployment', variant: 'error', duration: 86_400_000 });
    }
    setStopTarget(null);
  }}
  title="Stop deployment?"
  message={`Stop deployment for "${stopTarget?.name}"? This will take the service offline.`}
  confirmText={appSlug}
  loading={stopDeployment.isPending}
/>
```

Import `ConfirmDialog` from `@cameleer/design-system` if not already imported.

- [ ] **Step 3: Add confirmation dialog for tap deletion in TapConfigModal**

In `ui/src/components/TapConfigModal.tsx`, find the delete handler (around line 117). Add state:

```typescript
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
```

Change the delete button (around line 249) from immediate to dialog trigger:

```tsx
<Button variant="danger" onClick={() => setShowDeleteConfirm(true)}>Delete</Button>

<ConfirmDialog
  open={showDeleteConfirm}
  onCancel={() => setShowDeleteConfirm(false)}
  onConfirm={() => {
    onDelete?.();
    setShowDeleteConfirm(false);
    onClose();
  }}
  title="Delete tap?"
  message={`Delete tap "${attributeName}"? This will remove it from the configuration.`}
  confirmText={attributeName}
/>
```

- [ ] **Step 4: Add AlertDialog for kill query in DatabaseAdminPage**

In `ui/src/pages/Admin/DatabaseAdminPage.tsx`, find the Kill button (around line 30). Add state and dialog:

```typescript
const [killTarget, setKillTarget] = useState<string | null>(null);
```

```tsx
<Button variant="danger" size="sm" onClick={() => setKillTarget(query.pid)}>Kill</Button>

<AlertDialog
  open={!!killTarget}
  onCancel={() => setKillTarget(null)}
  onConfirm={async () => {
    try {
      await killQuery(killTarget!);
      toast({ title: 'Query killed', variant: 'warning' });
    } catch {
      toast({ title: 'Failed to kill query', variant: 'error', duration: 86_400_000 });
    }
    setKillTarget(null);
  }}
  title="Kill query?"
  description={`This will terminate the running query (PID: ${killTarget}). Continue?`}
  confirmLabel="Kill"
  variant="warning"
/>
```

Import `AlertDialog` from `@cameleer/design-system`.

- [ ] **Step 5: Add AlertDialog for role removal from user**

In `ui/src/pages/Admin/UsersTab.tsx`, find the role tag `onRemove` handler (around line 508-526). Add state:

```typescript
const [removeRoleTarget, setRemoveRoleTarget] = useState<{ userId: string; roleId: string; roleName: string } | null>(null);
```

Change the `onRemove` to open a dialog instead of immediate mutation:

```tsx
onRemove={() => setRemoveRoleTarget({ userId: selected.userId, roleId: r.id, roleName: r.name })}
```

Add the AlertDialog:

```tsx
<AlertDialog
  open={!!removeRoleTarget}
  onCancel={() => setRemoveRoleTarget(null)}
  onConfirm={() => {
    if (!removeRoleTarget) return;
    removeRole.mutate(
      { userId: removeRoleTarget.userId, roleId: removeRoleTarget.roleId },
      {
        onSuccess: () => toast({ title: 'Role removed', description: removeRoleTarget.roleName, variant: 'success' }),
        onError: () => toast({ title: 'Failed to remove role', variant: 'error', duration: 86_400_000 }),
      },
    );
    setRemoveRoleTarget(null);
  }}
  title="Remove role?"
  description={`Remove role "${removeRoleTarget?.roleName}"? This may revoke access for this user. Continue?`}
  confirmLabel="Remove"
  variant="warning"
/>
```

- [ ] **Step 6: Standardize Cancel button variant to ghost**

In `ui/src/components/TapConfigModal.tsx` (around line 255), change:
```tsx
// BEFORE:
<Button variant="secondary" onClick={onClose}>Cancel</Button>
// AFTER:
<Button variant="ghost" onClick={onClose}>Cancel</Button>
```

In `ui/src/pages/Routes/RouteDetail.tsx`, find the tap modal footer Cancel button and change `variant="secondary"` to `variant="ghost"`.

- [ ] **Step 7: Add loading prop to ConfirmDialogs that lack it**

In `ui/src/pages/Admin/OidcConfigPage.tsx` (around line 258), find the ConfirmDialog for OIDC delete. Add loading prop — track the delete operation state:

```tsx
<ConfirmDialog
  ...existing props...
  loading={deleting}  // add this prop
/>
```

In `ui/src/pages/Routes/RouteDetail.tsx` (around line 992), find the tap delete ConfirmDialog. Add `loading` prop if a mutation state is available.

- [ ] **Step 8: Verify**

1. AppConfigDetailPage: Edit mode shows Cancel (left) | Save (right, primary, with spinner)
2. Deployments: Stop button shows type-to-confirm dialog
3. TapConfigModal: Delete shows confirmation dialog
4. Database: Kill shows AlertDialog with warning
5. Users: Removing a role shows AlertDialog
6. All Cancel buttons use ghost variant

- [ ] **Step 9: Commit**

```bash
git add ui/src/pages/Admin/AppConfigDetailPage.tsx ui/src/pages/AppsTab/AppsTab.tsx ui/src/components/TapConfigModal.tsx ui/src/pages/Admin/DatabaseAdminPage.tsx ui/src/pages/Admin/UsersTab.tsx ui/src/pages/Routes/RouteDetail.tsx ui/src/pages/Admin/OidcConfigPage.tsx
git commit -m "fix: standardize button order, add confirmation dialogs for destructive actions"
```

---

## Task 8: OIDC edit mode, loading states, and error toast format

**Spec items:** 2b.7, 2b.9, 2b.10, 2b.13, 2b.14

**Files:**
- Modify: `ui/src/pages/Admin/OidcConfigPage.tsx`
- Modify: `ui/src/pages/Admin/UsersTab.tsx`
- Modify: `ui/src/pages/Admin/GroupsTab.tsx`
- Modify: `ui/src/pages/Admin/RolesTab.tsx`
- Modify: `ui/src/pages/Admin/EnvironmentsPage.tsx`
- Modify: `ui/src/pages/AppsTab/AppsTab.tsx`
- Modify: `ui/src/pages/Admin/AppConfigDetailPage.tsx`

- [ ] **Step 1: Add edit mode to OidcConfigPage**

In `ui/src/pages/Admin/OidcConfigPage.tsx`, add editing state:

```typescript
const [editing, setEditing] = useState(false);
const [formDraft, setFormDraft] = useState<OidcFormData | null>(null);

function startEditing() {
  setFormDraft(form ? { ...form } : null);
  setEditing(true);
}

function cancelEditing() {
  setFormDraft(null);
  setEditing(false);
  setError(null);
}
```

Update the toolbar (around line 130-137):
```tsx
{editing ? (
  <>
    <Button variant="ghost" size="sm" onClick={cancelEditing}>Cancel</Button>
    <Button variant="primary" size="sm" onClick={handleSave} loading={saving}>Save</Button>
  </>
) : (
  <>
    <Button variant="secondary" size="sm" onClick={handleTestConnection}>Test Connection</Button>
    <Button variant="secondary" size="sm" onClick={startEditing}>Edit</Button>
  </>
)}
```

Make all form fields read-only when `!editing`. The form should display values from `form` in read mode and `formDraft` in edit mode. Update `handleSave` to use `formDraft` and call `cancelEditing` on success (after updating `form` with saved data).

Remove the inline `<Alert>` (line 138-139) — keep only the toast for errors (fixes 2b.14).

- [ ] **Step 2: Replace Spinner with PageLoader across admin pages**

In each of these files, replace bare `<Spinner>` returns with `<PageLoader />`:

**UsersTab.tsx** — find `if (usersLoading) return <Spinner size="md" />;` and replace:
```typescript
import { PageLoader } from '../../components/PageLoader';
// ...
if (usersLoading) return <PageLoader />;
```

**GroupsTab.tsx** — same pattern:
```typescript
import { PageLoader } from '../../components/PageLoader';
if (groupsLoading) return <PageLoader />;
```

**RolesTab.tsx:**
```typescript
import { PageLoader } from '../../components/PageLoader';
if (rolesLoading) return <PageLoader />;
```

**EnvironmentsPage.tsx:**
```typescript
import { PageLoader } from '../../components/PageLoader';
if (envsLoading) return <PageLoader />;
```

**OidcConfigPage.tsx** — currently returns `null`, change to:
```typescript
import { PageLoader } from '../../components/PageLoader';
if (!form) return <PageLoader />;
```

**AppsTab.tsx** (AppListView and AppDetailView) — find `<Spinner size="md" />` returns and replace with `<PageLoader />`.

- [ ] **Step 3: Standardize error toast titles**

In `ui/src/pages/AppsTab/AppsTab.tsx`, find error toasts with format "[Noun] failed" and change to "Failed to [verb] [noun]":

- `'Save failed'` -> `'Failed to save configuration'`
- `'Upload failed'` -> `'Failed to upload JAR'`
- `'Deploy failed'` -> `'Failed to deploy application'`
- `'Stop failed'` -> `'Failed to stop deployment'`

In `ui/src/pages/Admin/AppConfigDetailPage.tsx`:
- `'Save failed'` -> `'Failed to save configuration'`

- [ ] **Step 4: Verify**

1. OIDC page: starts in read-only mode, Edit button enters edit mode, Cancel discards changes
2. All admin pages show centered PageLoader spinner (not small inline Spinner)
3. Error toasts use "Failed to [verb] [noun]" format

- [ ] **Step 5: Commit**

```bash
git add ui/src/pages/Admin/OidcConfigPage.tsx ui/src/pages/Admin/UsersTab.tsx ui/src/pages/Admin/GroupsTab.tsx ui/src/pages/Admin/RolesTab.tsx ui/src/pages/Admin/EnvironmentsPage.tsx ui/src/pages/AppsTab/AppsTab.tsx ui/src/pages/Admin/AppConfigDetailPage.tsx
git commit -m "fix: add OIDC edit mode, standardize PageLoader and error toast format"
```

---

## Task 9: WCAG contrast fixes and font size floor

**Spec items:** 3.1, 3.2, 3.3

**Files:**
- Modify: Design system theme CSS (find the file defining `--text-muted` and `--text-faint`)
- Modify: Multiple CSS modules (font-size changes)

- [ ] **Step 1: Find where design system tokens are defined**

```bash
cd ui && grep -rn "\-\-text-muted" node_modules/@cameleer/design-system/dist/ 2>/dev/null | head -5
# Or check if tokens are defined in the project itself:
grep -rn "\-\-text-muted" src/ --include="*.css" | grep -v "module.css" | head -10
```

If the tokens are in the design system package, they need to be overridden at the app level. If they're in a local theme file, edit directly.

- [ ] **Step 2: Update --text-muted values**

In the theme/token CSS file (wherever `--text-muted` is defined):

```css
/* Light mode: */
--text-muted: #766A5E;  /* was #9C9184, now 4.5:1 on white */

/* Dark mode: */
--text-muted: #9A9088;  /* was #7A7068, now 4.5:1 on #242019 */
```

- [ ] **Step 3: Update --text-faint dark mode value**

```css
/* Dark mode: */
--text-faint: #6A6058;  /* was #4A4238 (1.4:1!), now 3:1 on #242019 */
```

- [ ] **Step 4: Audit and fix --text-faint usage on readable text**

```bash
grep -rn "text-faint" ui/src/ --include="*.css" --include="*.module.css"
```

For each usage, check if it's on readable text (not just decorative borders/dividers). If on readable text, change to `--text-muted`.

- [ ] **Step 5: Fix all sub-12px font sizes**

```bash
grep -rn "font-size: 10px\|font-size: 11px" ui/src/ --include="*.css" --include="*.module.css"
```

For each match, change to `font-size: 12px`. This includes StatCard labels, overview labels, table meta, sidebar tree labels, chart titles, pagination text, etc.

- [ ] **Step 6: Verify visually**

1. Check light mode: muted text should be noticeably darker
2. Check dark mode: muted text should be clearly readable, faint text should be visible
3. All labels and meta text should be at least 12px
4. Run a contrast checker browser extension to verify ratios

- [ ] **Step 7: Commit**

```bash
git add -A ui/src/
git commit -m "fix: WCAG AA contrast compliance for --text-muted/--text-faint, 12px font floor"
```

---

## Task 10: Duration formatter and exchange ID truncation

**Spec items:** 4.1, 4.5

**Files:**
- Modify: `ui/src/utils/format-utils.ts`
- Modify: `ui/src/pages/Dashboard/Dashboard.tsx`

- [ ] **Step 1: Improve the shared duration formatter**

In `ui/src/utils/format-utils.ts`, find `formatDuration` (around line 1-5):

```typescript
// BEFORE:
export function formatDuration(ms: number): string {
  if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`;
  if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`;
  return `${ms}ms`;
}
```

Replace with:

```typescript
// AFTER:
export function formatDuration(ms: number): string {
  if (ms >= 60_000) {
    const minutes = Math.floor(ms / 60_000);
    const seconds = Math.round((ms % 60_000) / 1000);
    return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
  }
  if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
  return `${ms}ms`;
}
```

Changes: `>= 60s` now shows `Xm Ys` instead of raw seconds. `1-60s` now shows one decimal (`6.7s`) instead of two (`6.70s`).

Also update `formatDurationShort` to match:

```typescript
export function formatDurationShort(ms: number | undefined): string {
  if (ms == null) return '-';
  if (ms >= 60_000) {
    const minutes = Math.floor(ms / 60_000);
    const seconds = Math.round((ms % 60_000) / 1000);
    return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
  }
  if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
  return `${ms}ms`;
}
```

- [ ] **Step 2: Truncate Exchange IDs in the table**

In `ui/src/pages/Dashboard/Dashboard.tsx`, find the Exchange ID column (around line 114-116):

```typescript
// BEFORE:
render: (_: unknown, row: Row) => (
  <MonoText size="xs">{row.executionId}</MonoText>
),
```

Change to truncated display with tooltip:

```typescript
// AFTER:
render: (_: unknown, row: Row) => (
  <MonoText size="xs" title={row.executionId} style={{ cursor: 'pointer' }}
    onClick={(e: React.MouseEvent) => {
      e.stopPropagation();
      navigator.clipboard.writeText(row.executionId);
    }}>
    ...{row.executionId.slice(-8)}
  </MonoText>
),
```

This shows the last 8 characters with an ellipsis prefix, the full ID on hover, and copies to clipboard on click.

- [ ] **Step 3: Verify**

1. Exchange table: IDs should show `...0001E75C` format
2. Hovering should show full ID
3. Clicking the ID should copy to clipboard
4. Durations: `321000ms` should show as `5m 21s`, `6700ms` as `6.7s`, `178ms` as `178ms`

- [ ] **Step 4: Commit**

```bash
git add ui/src/utils/format-utils.ts ui/src/pages/Dashboard/Dashboard.tsx
git commit -m "fix: improve duration formatting (Xm Ys) and truncate exchange IDs"
```

---

## Task 11: Attributes column, status terminology, and agent names

**Spec items:** 4.2, 4.3, 4.4

**Files:**
- Modify: `ui/src/pages/Dashboard/Dashboard.tsx`
- Modify: `ui/src/utils/format-utils.ts` (if `statusLabel` is defined there)

- [ ] **Step 1: Hide Attributes column when empty**

In `ui/src/pages/Dashboard/Dashboard.tsx`, find the Attributes column definition (around line 96-108). Make it conditional based on whether any row has attributes:

```typescript
const hasAttributes = exchanges.some(e => e.attributes && Object.keys(e.attributes).length > 0);
```

In the columns array, conditionally include the Attributes column:

```typescript
const columns = [
  // ...status, route, application columns...
  ...(hasAttributes ? [{
    key: 'attributes' as const,
    header: 'Attributes',
    render: (_: unknown, row: Row) => {
      const attrs = row.attributes;
      if (!attrs || Object.keys(attrs).length === 0) return <span className={styles.dim}>&mdash;</span>;
      return (
        <span className={styles.attrBadges}>
          {Object.entries(attrs).map(([k, v]) => (
            <Badge key={k} size="sm">{k}={v}</Badge>
          ))}
        </span>
      );
    },
  }] : []),
  // ...Exchange ID, Started, Duration, Agent columns...
];
```

- [ ] **Step 2: Standardize status labels**

Find `statusLabel` in `ui/src/utils/format-utils.ts` (or wherever it's defined). It should map:

```typescript
export function statusLabel(status: string): string {
  switch (status) {
    case 'COMPLETED': return 'OK';
    case 'FAILED': return 'ERR';
    case 'RUNNING': return 'RUN';
    default: return status;
  }
}
```

Verify this function is used in BOTH the exchange table AND the exchange detail panel. If the detail panel uses raw `status` instead of `statusLabel()`, update it to use the same function.

Search for where the detail panel displays status:
```bash
grep -rn "COMPLETED\|FAILED" ui/src/pages/Exchanges/ --include="*.tsx"
```

Update any raw status display to use `statusLabel()`.

- [ ] **Step 3: Truncate agent names**

In `ui/src/pages/Dashboard/Dashboard.tsx`, find the Agent column (around line 135-140). Add a truncation helper:

```typescript
function shortAgentName(name: string): string {
  // If name contains multiple dashes (K8s pod name pattern), take the last segment
  const parts = name.split('-');
  if (parts.length >= 3) {
    // Show last 2 segments: "8c0affadb860-1" from "cameleer-sample-8c0affadb860-1"
    return parts.slice(-2).join('-');
  }
  return name;
}
```

Update the Agent column render:

```typescript
render: (_: unknown, row: Row) => (
  <span title={row.agentId}>{shortAgentName(row.agentId)}</span>
),
```

- [ ] **Step 4: Verify**

1. Exchange table: Attributes column should be hidden (all "---" currently)
2. Status shows "OK"/"ERR" in both table and detail panel
3. Agent names show truncated form with full name on hover

- [ ] **Step 5: Commit**

```bash
git add ui/src/pages/Dashboard/Dashboard.tsx ui/src/utils/format-utils.ts ui/src/pages/Exchanges/
git commit -m "fix: hide empty attributes column, standardize status labels, truncate agent names"
```

---

## Task 12: Chart Y-axis scaling and agent state display

**Spec items:** 5.1, 5.2, 5.3, 5.4, 5.5

**Files:**
- Modify: `ui/src/pages/AgentInstance/AgentInstance.tsx`
- Modify: `ui/src/pages/DashboardTab/DashboardTab.module.css`

- [ ] **Step 1: Fix agent chart Y-axis auto-scaling**

In `ui/src/pages/AgentInstance/AgentInstance.tsx`, check how the chart components accept Y-axis configuration. The `LineChart` and `BarChart` components likely accept a `yMax` or `yDomain` prop.

For the Throughput chart (around line 339), if it uses a fixed max, remove it or set it dynamically:

```typescript
// If the chart has a yMax prop, compute it from data:
const throughputMax = Math.max(...throughputSeries[0].data.map(d => d.y), 1) * 1.2;
```

Pass this to the chart: `yMax={throughputMax}`

Apply the same pattern to Error Rate (line 353) and all other charts.

- [ ] **Step 2: Standardize Error Rate unit**

Find the Error Rate chart (around line 353). Change `yLabel` from `"err/h"` to match the KPI display:

```typescript
// BEFORE:
<LineChart series={errorSeries} height={160} yLabel="err/h" />

// AFTER:
<LineChart series={errorSeries} height={160} yLabel="%" />
```

Ensure the error rate data is in percentage format (not absolute count). If the data is in errors/hour, convert:
```typescript
// Convert errors per hour to percentage:
const errorPctSeries = errorSeries.map(s => ({
  ...s,
  data: s.data.map(d => ({ ...d, y: totalThroughput > 0 ? (d.y / totalThroughput * 100) : 0 })),
}));
```

- [ ] **Step 3: Add memory reference line**

For the Memory chart (around line 325), add a reference line at max heap. Check if the chart component supports a `referenceLine` or `threshold` prop:

```typescript
<LineChart
  series={heapSeries}
  height={160}
  yLabel="MB"
  referenceLine={maxHeapMb}  // Add reference line at max heap
  referenceLabel="Max Heap"
/>
```

If the chart component doesn't support reference lines, this may need to be deferred or the component extended.

- [ ] **Step 4: Fix agent state "UNKNOWN" display**

Find where the dual state (LIVE + UNKNOWN) is displayed. In the agent detail header area, there's likely a state badge showing both the agent state and a container state.

If the secondary state is "UNKNOWN" while the primary is "LIVE", hide it:

```tsx
{agent.state && agent.state !== 'UNKNOWN' && agent.state !== agent.primaryState && (
  <Badge variant="neutral">{agent.state}</Badge>
)}
```

Or add a label: `<Badge variant="neutral">Container: {agent.containerState}</Badge>`

- [ ] **Step 5: Fix Dashboard table pointer events**

In `ui/src/pages/DashboardTab/DashboardTab.module.css`, find `.chartGrid` or `._tableSection` classes. Add explicit pointer-events and z-index:

```css
/* Ensure table rows are clickable above chart overlays */
.tableSection {
  position: relative;
  z-index: 1;
}

.chartGrid {
  pointer-events: none;  /* Don't intercept clicks meant for the table */
}

.chartGrid > * {
  pointer-events: auto;  /* But chart elements themselves are still interactive */
}
```

The exact fix depends on the DOM structure. Inspect the layout to see which element is intercepting clicks.

- [ ] **Step 6: Verify**

1. Agent Throughput chart: Y-axis scales to actual data range (not 1.2k when data is ~2)
2. Agent Error Rate chart: shows "%" label
3. Agent Memory chart: shows reference line at max heap
4. Agent state: no confusing "UNKNOWN" alongside "LIVE"
5. Dashboard: Application Health table rows clickable without CSS interception

- [ ] **Step 7: Commit**

```bash
git add ui/src/pages/AgentInstance/AgentInstance.tsx ui/src/pages/DashboardTab/DashboardTab.module.css
git commit -m "fix: chart Y-axis auto-scaling, error rate unit, memory reference line, pointer events"
```

---

## Task 13: Error toast API details, unicode fix, password confirmation

**Spec items:** 6.1, 6.2, 6.3

**Files:**
- Modify: `ui/src/pages/Admin/UsersTab.tsx`
- Modify: `ui/src/pages/Admin/RolesTab.tsx`
- Modify: Multiple files with API error handlers

- [ ] **Step 1: Surface API error details in toasts**

Create a shared error extraction utility. In `ui/src/utils/format-utils.ts`, add:

```typescript
export async function extractApiError(err: unknown): Promise<string> {
  if (err && typeof err === 'object') {
    const e = err as any;
    // If the error has a response body with a message
    if (e.body?.error) return e.body.error;
    if (e.body?.message) return e.body.message;
    if (e.message) return e.message;
    // Try to read the response body if it's a fetch Response
    if (e.response?.text) {
      try {
        const text = await e.response.text();
        const json = JSON.parse(text);
        return json.error || json.message || text;
      } catch { return 'Unknown error'; }
    }
  }
  return 'Unknown error';
}
```

Update error toast handlers in key files to use the description field. For example in `UsersTab.tsx`:

```typescript
onError: async (err) => {
  const description = await extractApiError(err);
  toast({ title: 'Failed to create user', description, variant: 'error', duration: 86_400_000 });
},
```

Apply this pattern to the most visible error handlers across RBAC pages, AppsTab, and AppConfigDetailPage.

- [ ] **Step 2: Fix unicode escape in role descriptions**

In `ui/src/pages/Admin/RolesTab.tsx`, find line 180:

```typescript
// BEFORE:
{role.description || '\u2014'} \u00b7 {getAssignmentCount(role)} assignments
```

The `\u00b7` and `\u2014` in template literals should render correctly as actual characters. But if they're showing literally, it means they're likely being escaped somewhere upstream. Check if `role.description` contains literal `\u00b7` strings from the backend.

If the backend returns literal `\\u00b7` (double-escaped), the fix is on the backend in the role seed data or the API serialization. If it's a frontend template issue, the existing code should work (JS template literals process unicode escapes at parse time).

Check what the API returns:
```bash
curl -s -H "Authorization: Bearer <token>" https://<host>/api/v1/admin/roles | jq '.[].description'
```

If the backend returns literal `\u00b7`, fix the seed data or migration that creates the system roles.

- [ ] **Step 3: Add password confirmation field**

In `ui/src/pages/Admin/UsersTab.tsx`, find the create form (around line 238-252). Add a confirm password field:

```typescript
const [newPasswordConfirm, setNewPasswordConfirm] = useState('');
const passwordMismatch = newPassword.length > 0 && newPasswordConfirm.length > 0 && newPassword !== newPasswordConfirm;
```

After the existing password input:

```tsx
{newProvider === 'local' && (
  <>
    <Input
      placeholder="Password *"
      type="password"
      value={newPassword}
      onChange={(e) => setNewPassword(e.target.value)}
    />
    <Input
      placeholder="Confirm Password *"
      type="password"
      value={newPasswordConfirm}
      onChange={(e) => setNewPasswordConfirm(e.target.value)}
    />
    {passwordMismatch && <span className={styles.errorText}>Passwords do not match</span>}
    <span className={styles.hintText}>Min 12 characters, 3 of 4: uppercase, lowercase, number, special</span>
  </>
)}
```

Add the `passwordMismatch` check to the Create button's `disabled` condition:

```tsx
disabled={!newUsername.trim() || (newProvider === 'local' && (!newPassword.trim() || passwordMismatch)) || duplicateUsername}
```

Reset `newPasswordConfirm` when the form closes.

Add `.hintText` to the CSS module:
```css
.hintText {
  font-size: 12px;
  color: var(--text-muted);
}
```

- [ ] **Step 4: Verify**

1. Create user form: shows password confirmation field, validation message on mismatch, policy hint
2. API errors show descriptive messages in toasts
3. Role descriptions show rendered characters (middle dot), not escape sequences

- [ ] **Step 5: Commit**

```bash
git add ui/src/utils/format-utils.ts ui/src/pages/Admin/UsersTab.tsx ui/src/pages/Admin/RolesTab.tsx ui/src/pages/AppsTab/AppsTab.tsx ui/src/pages/Admin/AppConfigDetailPage.tsx
git commit -m "fix: surface API errors in toasts, fix unicode in roles, add password confirmation"
```

---

## Task 14: OIDC client secret masking and empty state standardization

**Spec items:** 6.6, 2b.11

**Files:**
- Modify: `ui/src/pages/Admin/OidcConfigPage.tsx`
- Modify: `ui/src/pages/AppsTab/AppsTab.tsx`
- Modify: `ui/src/pages/Admin/UsersTab.tsx`
- Modify: `ui/src/pages/Admin/GroupsTab.tsx`
- Modify: `ui/src/pages/Routes/RouteDetail.tsx`

- [ ] **Step 1: Mask OIDC client secret**

In `ui/src/pages/Admin/OidcConfigPage.tsx`, find the Client Secret input field. Add a show/hide toggle:

```typescript
const [showSecret, setShowSecret] = useState(false);
```

```tsx
<div style={{ position: 'relative' }}>
  <Input
    type={showSecret ? 'text' : 'password'}
    value={formDraft?.clientSecret ?? form?.clientSecret ?? ''}
    onChange={(e) => updateField('clientSecret', e.target.value)}
    readOnly={!editing}
  />
  <Button
    variant="ghost"
    size="sm"
    style={{ position: 'absolute', right: 4, top: '50%', transform: 'translateY(-50%)' }}
    onClick={() => setShowSecret(!showSecret)}
  >
    {showSecret ? <EyeOff size={14} /> : <Eye size={14} />}
  </Button>
</div>
```

Import `Eye` and `EyeOff` from `lucide-react`.

- [ ] **Step 2: Standardize empty states**

Replace ad-hoc empty state patterns with the DS `EmptyState` component or a consistent pattern. Import from the design system:

```typescript
import { EmptyState } from '@cameleer/design-system';
```

In `AppsTab.tsx`, replace all `<p className={styles.emptyNote}>...</p>` with:
```tsx
<EmptyState title="No deployments" description="Deploy this application to see deployment history." />
```

In `GroupsTab.tsx`, replace `<span className={styles.inheritedNote}>(no members)</span>` with:
```tsx
<EmptyState title="No members" description="Add members to this group." />
```

In `RouteDetail.tsx`, replace `<div className={styles.emptyText}>...</div>` and `<div className={styles.emptyState}>...</div>` with `EmptyState` component.

Use the same pattern everywhere: `<EmptyState title="No X" description="..." />`

- [ ] **Step 3: Verify**

1. OIDC page: client secret is masked by default, eye icon toggles visibility
2. Empty states across all pages show consistent centered component

- [ ] **Step 4: Commit**

```bash
git add ui/src/pages/Admin/OidcConfigPage.tsx ui/src/pages/AppsTab/AppsTab.tsx ui/src/pages/Admin/UsersTab.tsx ui/src/pages/Admin/GroupsTab.tsx ui/src/pages/Routes/RouteDetail.tsx
git commit -m "fix: mask OIDC client secret, standardize empty states across pages"
```

---

## Task 15: Number formatting and locale consistency

**Spec items:** 4.6

**Files:**
- Modify: `ui/src/utils/format-utils.ts`
- Modify: Files that display numbers with units

- [ ] **Step 1: Add shared number formatting utility**

In `ui/src/utils/format-utils.ts`, add:

```typescript
export function formatMetric(value: number, unit: string, decimals = 1): string {
  if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(decimals)}M ${unit}`;
  if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(decimals)}K ${unit}`;
  if (Number.isInteger(value)) return `${value} ${unit}`;
  return `${value.toFixed(decimals)} ${unit}`;
}

export function formatCount(value: number): string {
  if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
  if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
  return String(value);
}

export function formatPercent(value: number, decimals = 1): string {
  return `${value.toFixed(decimals)} %`;
}
```

- [ ] **Step 2: Apply to key display locations**

Search for locations where numbers are displayed with units and use the shared formatters. Focus on the KPI strips and dashboard metrics where inconsistencies were observed.

```bash
grep -rn "msg/s\|/s\|ms\b" ui/src/pages/ --include="*.tsx" | head -20
```

Update the most visible locations to use consistent formatting with space before unit.

- [ ] **Step 3: Verify**

1. KPI values show consistent formatting: "6.7 s", "1.9 %", "7.1 msg/s"
2. Large numbers use K/M suffixes consistently

- [ ] **Step 4: Commit**

```bash
git add ui/src/utils/format-utils.ts ui/src/pages/
git commit -m "fix: standardize number formatting with consistent unit spacing and K/M suffixes"
```

---

## Task 16: Platform label/value spacing and license badge colors (SaaS repo)

**Spec items:** 6.4, 6.5

**Note:** These items are in the `cameleer-saas` repository, not `cameleer-server`. If the SaaS platform UI code is in a separate repo, this task needs to be executed there. If it's co-located, proceed with these files.

- [ ] **Step 1: Identify platform component files**

```bash
# Check if SaaS platform UI is in this repo:
find . -name "*.tsx" | xargs grep -l "Slugdefault\|Max Agents" 2>/dev/null
# Or search for the platform dashboard component:
grep -rn "Tenant Information\|Server Management" ui/src/ --include="*.tsx"
```

If not found in this repo, note that these fixes belong to the `cameleer-saas` repo and skip to commit.

- [ ] **Step 2: Fix label/value spacing**

Find the key-value display components. Change from:
```tsx
<span>Slug{tenant.slug}</span>
```
To:
```tsx
<span>Slug: {tenant.slug}</span>
```

Or better, use a definition list pattern:
```tsx
<dl className={styles.meta}>
  <dt>Slug</dt><dd>{tenant.slug}</dd>
  <dt>Status</dt><dd><Badge>{tenant.status}</Badge></dd>
  <dt>Created</dt><dd>{formatDate(tenant.createdAt)}</dd>
</dl>
```

- [ ] **Step 3: Fix license badge colors**

Find the license features display. Change DISABLED badges from error/red to neutral:

```tsx
// BEFORE:
<Badge variant={feature.enabled ? 'success' : 'error'}>{feature.enabled ? 'ENABLED' : 'DISABLED'}</Badge>

// AFTER:
<Badge variant={feature.enabled ? 'success' : 'neutral'}>{feature.enabled ? 'ENABLED' : 'NOT INCLUDED'}</Badge>
```

- [ ] **Step 4: Commit**

```bash
# If in this repo:
git add <platform-ui-files>
git commit -m "fix: platform label/value spacing and neutral license badge colors"
# If in cameleer-saas repo, note the fix for that repo
```

---

## Task 17: Audit log CSV export

**Spec items:** 6.7

**Files:**
- Modify: `ui/src/pages/Admin/AuditLogPage.tsx`
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AuditLogController.java`

- [ ] **Step 1: Add client-side CSV export for current page**

In `ui/src/pages/Admin/AuditLogPage.tsx`, add an export button to the table header area:

```typescript
function exportCsv(events: AuditEvent[]) {
  const headers = ['Timestamp', 'User', 'Category', 'Action', 'Target', 'Result', 'Details'];
  const rows = events.map(e => [
    e.timestamp, e.username, e.category, e.action, e.target, e.result, e.details ?? '',
  ]);
  const csv = [headers, ...rows].map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n');
  const blob = new Blob([csv], { type: 'text/csv' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = `cameleer-audit-${new Date().toISOString().slice(0, 16).replace(':', '-')}.csv`;
  a.click();
  URL.revokeObjectURL(url);
}
```

Add the button in the table header alongside existing controls:

```tsx
<Button variant="ghost" size="sm" onClick={() => exportCsv(events)}>
  <Download size={14} /> Export CSV
</Button>
```

Import `Download` from `lucide-react`.

- [ ] **Step 2: Verify**

1. Navigate to Admin > Audit Log
2. Click "Export CSV" — should download a CSV file with current page data
3. Open CSV and verify columns match the table

- [ ] **Step 3: Commit**

```bash
git add ui/src/pages/Admin/AuditLogPage.tsx
git commit -m "feat: add CSV export to audit log"
```

---

## Task 18: Unsaved changes indicators (was Task 17)

**Spec items:** 2b.12

**Files:**
- Modify: `ui/src/pages/Admin/AppConfigDetailPage.tsx`
- Modify: `ui/src/pages/Admin/EnvironmentsPage.tsx`

- [ ] **Step 1: Add unsaved changes banner to AppConfigDetailPage**

Borrow the banner pattern from AppsTab's ConfigSubTab. In `AppConfigDetailPage.tsx`, add a banner above the form when in edit mode:

```tsx
{editing && (
  <div className={styles.editBanner}>
    Editing configuration. Changes are not saved until you click Save.
  </div>
)}
```

Add the CSS:
```css
.editBanner {
  padding: 8px 16px;
  background: var(--amber-bg, rgba(198, 130, 14, 0.08));
  border: 1px solid var(--amber);
  border-radius: var(--radius-sm);
  font-size: 13px;
  color: var(--text-primary);
  margin-bottom: 16px;
}
```

- [ ] **Step 2: Add unsaved changes banner to Environment editing sections**

In `EnvironmentsPage.tsx`, find the Default Resources and JAR Retention edit sections. When `editing` is true, show the same banner pattern:

```tsx
{editingResources && (
  <div className={styles.editBanner}>
    Editing resource defaults. Changes are not saved until you click Save.
  </div>
)}
```

- [ ] **Step 3: Verify**

1. AppConfigDetailPage: amber banner appears when in edit mode
2. Environments: amber banner appears when editing resource defaults or JAR retention

- [ ] **Step 4: Commit**

```bash
git add ui/src/pages/Admin/AppConfigDetailPage.tsx ui/src/pages/Admin/EnvironmentsPage.tsx
git commit -m "fix: add unsaved changes banners to edit mode forms"
```

---

## Task 19: Nice-to-have polish (batch 7)

**Spec items:** 7.1-7.10 (implement time-permitting)

**Files:**
- Various

- [ ] **Step 1: Deployment list status badges**

In `AppsTab.tsx`, add a Status column to the app list DataTable showing the deployment status (RUNNING/STOPPED/FAILED) as a colored badge. This data may need to be fetched with the app list or joined from deployments.

- [ ] **Step 2: Breadcrumb update on exchange selection**

In `ExchangesPage.tsx`, when an exchange is selected, update the breadcrumb to show: All Applications > {appName} > Exchange ...{last8chars}

- [ ] **Step 3: Close button on exchange detail panel**

Add an X button or "Close" button to the top-right of the exchange detail panel for explicit dismissal.

- [ ] **Step 4: Command palette exchange ID truncation**

Find the command palette component and apply the same `...{last8chars}` truncation pattern used in the exchange table.

- [ ] **Step 5: 7-Day Pattern heatmap insufficient data**

In the DashboardTab heatmap component, check if data spans less than 2 days. If so, show an overlay or message: "More data needed — heatmap requires at least 2 days of history."

- [ ] **Step 6: Commit all nice-to-have changes**

```bash
git add -A ui/src/
git commit -m "fix: nice-to-have polish — status badges, breadcrumbs, close button, heatmap message"
```

---

## Summary

| Task | Batch | Key Changes | Commit |
|------|-------|-------------|--------|
| 1 | 1 (Bugs) | Deployments redirect, GC Pauses chart | `fix: add /deployments redirect and fix GC Pauses chart X-axis` |
| 2 | 1 (Bugs) | User creation OIDC fix (backend + frontend) | `fix: show descriptive error when creating local user with OIDC enabled` |
| 3 | 1 (Bugs) | SSE navigation bug investigation + fix | `fix: prevent SSE data updates from triggering navigation on admin pages` |
| 4 | 2a (Layout) | Exchange table containment, padding normalization | `fix: standardize table containment and container padding across pages` |
| 5 | 2a (Layout) | App detail section cards, deployment DataTable | `fix: wrap app config in section cards, replace manual table with DataTable` |
| 6 | 2a (Layout) | Deduplicate card CSS, wrap flat admin detail sections | `fix: deduplicate card CSS, use shared section-card and table-section modules` |
| 7 | 2b (Interaction) | Button order, confirmation dialogs, destructive guards | `fix: standardize button order, add confirmation dialogs for destructive actions` |
| 8 | 2b (Interaction) | OIDC edit mode, PageLoader, error toast format | `fix: add OIDC edit mode, standardize PageLoader and error toast format` |
| 9 | 3 (Contrast) | WCAG contrast fixes, font size floor | `fix: WCAG AA contrast compliance, 12px font floor` |
| 10 | 4 (Formatting) | Duration formatter, exchange ID truncation | `fix: improve duration formatting, truncate exchange IDs` |
| 11 | 4 (Formatting) | Attributes column, status labels, agent names | `fix: hide empty attributes, standardize status labels, truncate agent names` |
| 12 | 5 (Charts) | Y-axis scaling, error rate unit, memory ref line, pointer events | `fix: chart Y-axis auto-scaling, error rate unit, pointer events` |
| 13 | 6 (Admin) | API error details, unicode fix, password confirmation | `fix: surface API errors, fix unicode in roles, add password confirmation` |
| 14 | 6 (Admin) | OIDC secret masking, empty state standardization | `fix: mask OIDC client secret, standardize empty states` |
| 15 | 4 (Formatting) | Number formatting utility | `fix: standardize number formatting with unit spacing` |
| 16 | 6 (Admin) | Platform label/value spacing, license badges | `fix: platform label/value spacing and badge colors` |
| 17 | 6 (Admin) | Audit log CSV export | `feat: add CSV export to audit log` |
| 18 | 2b (Interaction) | Unsaved changes banners | `fix: add unsaved changes banners to edit mode forms` |
| 19 | 7 (Polish) | Nice-to-have items | `fix: nice-to-have polish` |
