diff --git a/docs/superpowers/plans/2026-04-09-ux-polish-plan.md b/docs/superpowers/plans/2026-04-09-ux-polish-plan.md
new file mode 100644
index 00000000..8dbefa39
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-09-ux-polish-plan.md
@@ -0,0 +1,1688 @@
+# 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: },
+```
+
+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:///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: `cameleer3-server-app/src/main/java/com/cameleer3/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 && (
+
+ Local user creation is disabled while OIDC is enabled.
+ Switch to OIDC to pre-register a user, or disable OIDC first.
+
+)}
+```
+
+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 cameleer3-server-app/src/main/java/com/cameleer3/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
+
+```
+
+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 `
` with DataTable in OverviewSubTab**
+
+Find the manual `
` in `OverviewSubTab` (lines 623-680). Replace with:
+
+```tsx
+
+```
+
+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
+
+```
+
+```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:
+
+ Group Membership
+ {/* existing membership tags */}
+
+
+
+ Effective Roles
+ {/* existing role tags */}
+
+```
+
+- [ ] **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 ? (
+
+
+
+
+) : (
+
+)}
+```
+
+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:
+
+
+// Add ConfirmDialog near the bottom of the component:
+ 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
+
+
+ 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(null);
+```
+
+```tsx
+
+
+ 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
+ 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:
+
+// AFTER:
+
+```
+
+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
+
+```
+
+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(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 ? (
+ <>
+
+
+ >
+) : (
+ <>
+
+
+ >
+)}
+```
+
+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 `` (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 `` returns with ``:
+
+**UsersTab.tsx** — find `if (usersLoading) return ;` and replace:
+```typescript
+import { PageLoader } from '../../components/PageLoader';
+// ...
+if (usersLoading) return ;
+```
+
+**GroupsTab.tsx** — same pattern:
+```typescript
+import { PageLoader } from '../../components/PageLoader';
+if (groupsLoading) return ;
+```
+
+**RolesTab.tsx:**
+```typescript
+import { PageLoader } from '../../components/PageLoader';
+if (rolesLoading) return ;
+```
+
+**EnvironmentsPage.tsx:**
+```typescript
+import { PageLoader } from '../../components/PageLoader';
+if (envsLoading) return ;
+```
+
+**OidcConfigPage.tsx** — currently returns `null`, change to:
+```typescript
+import { PageLoader } from '../../components/PageLoader';
+if (!form) return ;
+```
+
+**AppsTab.tsx** (AppListView and AppDetailView) — find `` returns and replace with ``.
+
+- [ ] **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) => (
+ {row.executionId}
+),
+```
+
+Change to truncated display with tooltip:
+
+```typescript
+// AFTER:
+render: (_: unknown, row: Row) => (
+ {
+ e.stopPropagation();
+ navigator.clipboard.writeText(row.executionId);
+ }}>
+ ...{row.executionId.slice(-8)}
+
+),
+```
+
+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 —;
+ return (
+
+ {Object.entries(attrs).map(([k, v]) => (
+ {k}={v}
+ ))}
+
+ );
+ },
+ }] : []),
+ // ...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 "cameleer3-sample-8c0affadb860-1"
+ return parts.slice(-2).join('-');
+ }
+ return name;
+}
+```
+
+Update the Agent column render:
+
+```typescript
+render: (_: unknown, row: Row) => (
+ {shortAgentName(row.agentId)}
+),
+```
+
+- [ ] **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:
+
+
+// AFTER:
+
+```
+
+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
+
+```
+
+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 && (
+ {agent.state}
+)}
+```
+
+Or add a label: `Container: {agent.containerState}`
+
+- [ ] **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 {
+ 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 " https:///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' && (
+ <>
+ setNewPassword(e.target.value)}
+ />
+ setNewPasswordConfirm(e.target.value)}
+ />
+ {passwordMismatch && Passwords do not match}
+ Min 12 characters, 3 of 4: uppercase, lowercase, number, special
+ >
+)}
+```
+
+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
+
+```
+
+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 `