# SaaS Platform UX Polish — 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 layout bugs, replace hardcoded dark-only colors with design system tokens, improve navigation/header, add error handling, and adopt design system components consistently across the SaaS platform UI. **Architecture:** All changes are in the existing SaaS platform UI (`ui/src/`) and sign-in page (`ui/sign-in/src/`). The platform uses `@cameleer/design-system` components and Tailwind CSS. The key issue is that pages use hardcoded `text-white` Tailwind classes instead of DS CSS variables, and the DS `TopBar` renders server-specific controls that are irrelevant on platform pages. **Tech Stack:** React 19, TypeScript, Tailwind CSS, `@cameleer/design-system`, React Router v6, Logto SDK **Spec:** `docs/superpowers/specs/2026-04-09-saas-ux-polish-design.md` --- ## Task 1: Fix label/value collision and replace hardcoded colors **Spec items:** 1.1, 1.2 **Files:** - Create: `ui/src/styles/platform.module.css` - Modify: `ui/src/pages/DashboardPage.tsx` - Modify: `ui/src/pages/LicensePage.tsx` - Modify: `ui/src/pages/AdminTenantsPage.tsx` - [ ] **Step 1: Create shared platform CSS module** Create `ui/src/styles/platform.module.css` with DS-variable-based classes replacing the hardcoded Tailwind colors: ```css .heading { font-size: 1.5rem; font-weight: 600; color: var(--text-primary); } .textPrimary { color: var(--text-primary); } .textSecondary { color: var(--text-secondary); } .textMuted { color: var(--text-muted); } .mono { font-family: var(--font-mono); } .kvRow { display: flex; align-items: center; justify-content: space-between; width: 100%; } .kvLabel { font-size: 0.875rem; color: var(--text-muted); } .kvValue { font-size: 0.875rem; color: var(--text-primary); } .kvValueMono { font-size: 0.875rem; color: var(--text-primary); font-family: var(--font-mono); } .dividerList { display: flex; flex-direction: column; } .dividerList > * + * { border-top: 1px solid var(--border-subtle); } .dividerRow { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 0; } .dividerRow:first-child { padding-top: 0; } .dividerRow:last-child { padding-bottom: 0; } .description { font-size: 0.875rem; color: var(--text-muted); } .tokenBlock { margin-top: 0.5rem; border-radius: var(--radius-sm); background: var(--bg-inset); border: 1px solid var(--border-subtle); padding: 0.75rem; overflow-x: auto; } .tokenCode { font-size: 0.75rem; font-family: var(--font-mono); color: var(--text-secondary); word-break: break-all; } ``` - [ ] **Step 2: Update DashboardPage to use CSS module + fix label/value** In `ui/src/pages/DashboardPage.tsx`: 1. Add import: ```typescript import s from '../styles/platform.module.css'; ``` 2. Replace all hardcoded color classes: - Line 71: `text-2xl font-semibold text-white` → `className={s.heading}` - Lines 96, 100, 107: `className="flex justify-between text-white/80"` → `className={s.kvRow}` - Inner label spans: wrap with `className={s.kvLabel}` - Inner value spans: wrap with `className={s.kvValueMono}` (for mono) or `className={s.kvValue}` - Line 116: `text-sm text-white/60` → `className={s.description}` 3. The label/value collision fix: the `kvRow` class uses explicit `display: flex; width: 100%; justify-content: space-between` which ensures the flex container stretches to full Card width regardless of Card's inner layout. - [ ] **Step 3: Update LicensePage to use CSS module** In `ui/src/pages/LicensePage.tsx`: 1. Add import: `import s from '../styles/platform.module.css';` 2. Replace all hardcoded color classes: - Line 85: heading → `className={s.heading}` - Lines 95-115 (Validity rows): `flex items-center justify-between` → `className={s.kvRow}`, labels → `className={s.kvLabel}`, values → `className={s.kvValue}` - Lines 121-136 (Features): `divide-y divide-white/10` → `className={s.dividerList}`, rows → `className={s.dividerRow}`, feature name `text-sm text-white` → `className={s.textPrimary}` + `text-sm` - Lines 142-157 (Limits): same dividerList/dividerRow pattern, label → `className={s.kvLabel}`, value → `className={s.kvValueMono}` - Line 163: description text → `className={s.description}` - Lines 174-178: token code block → `className={s.tokenBlock}` on outer div, `className={s.tokenCode}` on code element - [ ] **Step 4: Update AdminTenantsPage to use CSS module** In `ui/src/pages/AdminTenantsPage.tsx`: - Line 62: `text-2xl font-semibold text-white` → `className={s.heading}` - [ ] **Step 5: Verify in both themes** 1. Open the platform dashboard in browser 2. Check label/value pairs have proper spacing (Slug on left, "default" on right) 3. Toggle to light theme via TopBar toggle 4. Verify all text is readable in light mode (no invisible white-on-white) 5. Toggle back to dark mode — should look the same as before - [ ] **Step 6: Commit** ```bash git add ui/src/styles/platform.module.css ui/src/pages/DashboardPage.tsx ui/src/pages/LicensePage.tsx ui/src/pages/AdminTenantsPage.tsx git commit -m "fix: replace hardcoded text-white with DS variables, fix label/value layout" ``` --- ## Task 2: Remove redundant dashboard elements **Spec items:** 1.3, 2.4 **Files:** - Modify: `ui/src/pages/DashboardPage.tsx` - [ ] **Step 1: Remove primary "Open Server Dashboard" button from header** In `ui/src/pages/DashboardPage.tsx`, find the header area (lines ~75-88). Remove the primary Button for "Open Server Dashboard" (lines ~81-87). Keep: - The Server Management Card with its secondary button (lines ~113-126) - The sidebar footer link (in Layout.tsx — don't touch) The header area should just have the tenant name heading + tier badge, no button. - [ ] **Step 2: Commit** ```bash git add ui/src/pages/DashboardPage.tsx git commit -m "fix: remove redundant Open Server Dashboard button from dashboard header" ``` --- ## Task 3: Fix header controls and sidebar navigation **Spec items:** 2.1, 2.2, 2.3, 2.5 **Files:** - Modify: `ui/src/components/Layout.tsx` - Modify: `ui/src/main.tsx` (possibly) - [ ] **Step 1: Investigate TopBar props for hiding controls** The DS `TopBar` interface (from types): ```typescript interface TopBarProps { breadcrumb: BreadcrumbItem[]; environment?: ReactNode; user?: { name: string }; onLogout?: () => void; className?: string; } ``` The TopBar has NO props to hide status filters, time range, auto-refresh, or search. These are hardcoded inside the component. **Options:** 1. Check if removing `GlobalFilterProvider` and `CommandPaletteProvider` from `main.tsx` makes TopBar gracefully hide those sections (test this first) 2. If that causes errors, add `display: none` CSS overrides for the irrelevant sections 3. If neither works, build a simplified platform header Try option 1 first. In `main.tsx`, remove `GlobalFilterProvider` and `CommandPaletteProvider` from the provider stack. Test if the app still renders. If TopBar crashes without them, revert and try option 2. - [ ] **Step 2: Add sidebar active state** In `ui/src/components/Layout.tsx`, add route-based active state: ```typescript import { useLocation } from 'react-router'; // Inside the Layout component: const location = useLocation(); ``` Update each `Sidebar.Section`: ```tsx } label="Dashboard" open={false} active={location.pathname === '/' || location.pathname === ''} onToggle={() => navigate('/')} > {null} } label="License" open={false} active={location.pathname === '/license'} onToggle={() => navigate('/license')} > {null} {scopes.has('platform:admin') && ( } label="Platform" open={false} active={location.pathname.startsWith('/admin')} onToggle={() => navigate('/admin/tenants')} > {null} )} ``` - [ ] **Step 3: Add breadcrumbs** In Layout.tsx, compute breadcrumbs from the current route: ```typescript const breadcrumb = useMemo((): BreadcrumbItem[] => { const path = location.pathname; if (path.startsWith('/admin')) return [{ label: 'Admin' }, { label: 'Tenants' }]; if (path.startsWith('/license')) return [{ label: 'License' }]; return [{ label: 'Dashboard' }]; }, [location.pathname]); ``` Pass to TopBar: ```tsx ``` Import `BreadcrumbItem` type from `@cameleer/design-system` if needed. - [ ] **Step 4: Fix sidebar collapse** Replace the hardcoded collapse state: ```typescript const [sidebarCollapsed, setSidebarCollapsed] = useState(false); ``` ```tsx setSidebarCollapsed(c => !c)}> ``` - [ ] **Step 5: Fix username null fallback** Update the user prop (line ~125): ```tsx const displayName = username || 'User'; ``` This ensures the logout button is always visible. - [ ] **Step 6: Replace custom SVG icons with lucide-react** Replace the 4 custom SVG icon components (lines 25-62) with lucide-react icons: ```typescript import { LayoutDashboard, ShieldCheck, Building, Server } from 'lucide-react'; ``` Then update sidebar sections: ```tsx icon={} // was icon={} // was icon={} // was ``` Remove the 4 custom SVG component functions (DashboardIcon, LicenseIcon, ObsIcon, PlatformIcon). - [ ] **Step 7: Verify** 1. Sidebar shows active highlight on current page 2. Breadcrumbs show "Dashboard", "License", or "Admin > Tenants" 3. Sidebar collapse works (click collapse button, sidebar minimizes) 4. User avatar/logout always visible 5. Icons render correctly from lucide-react 6. Check if server controls are hidden (depending on step 1 result) - [ ] **Step 8: Commit** ```bash git add ui/src/components/Layout.tsx ui/src/main.tsx git commit -m "fix: sidebar active state, breadcrumbs, collapse, username fallback, lucide icons" ``` --- ## Task 4: Error handling and OrgResolver fix **Spec items:** 3.1, 3.2, 3.7 **Files:** - Modify: `ui/src/auth/OrgResolver.tsx` - Modify: `ui/src/pages/DashboardPage.tsx` - Modify: `ui/src/pages/AdminTenantsPage.tsx` - [ ] **Step 1: Fix OrgResolver error state** In `ui/src/auth/OrgResolver.tsx`, find the error handling (lines 88-90): ```tsx // BEFORE: if (isError) { return null; } // AFTER: if (isError) { return (
); } ``` Add imports: `EmptyState`, `Button` from `@cameleer/design-system`. Ensure `refetch` is available from the query hook (check if `useQuery` returns it). - [ ] **Step 2: Add error handling to DashboardPage** In `ui/src/pages/DashboardPage.tsx`, after the loading check (line ~49) and tenant check (line ~57), add error handling: ```tsx const { data: tenant, isError: tenantError } = useTenant(); const { data: license, isError: licenseError } = useLicense(); // After loading spinner check: if (tenantError || licenseError) { return (
); } ``` Check how `useTenant()` and `useLicense()` expose error state — they may use `isError` from React Query. - [ ] **Step 3: Add empty state and date formatting to AdminTenantsPage** In `ui/src/pages/AdminTenantsPage.tsx`: 1. Add error handling: ```tsx if (isError) { return (
); } ``` 2. Format the `createdAt` column (line 31): ```tsx // BEFORE: { key: 'createdAt', header: 'Created' }, // AFTER: { key: 'createdAt', header: 'Created', render: (_, row) => new Date(row.createdAt).toLocaleDateString() }, ``` 3. Add empty state to DataTable (if supported) or show EmptyState when tenants is empty: ```tsx {(!tenants || tenants.length === 0) ? ( ) : ( )} ``` - [ ] **Step 4: Commit** ```bash git add ui/src/auth/OrgResolver.tsx ui/src/pages/DashboardPage.tsx ui/src/pages/AdminTenantsPage.tsx git commit -m "fix: add error states to OrgResolver, DashboardPage, AdminTenantsPage" ``` --- ## Task 5: DS component adoption and license token copy **Spec items:** 3.3, 3.4 **Files:** - Modify: `ui/src/pages/LicensePage.tsx` - [ ] **Step 1: Replace raw button with DS Button** In `ui/src/pages/LicensePage.tsx`, find the token toggle button (lines ~166-172): ```tsx // BEFORE: // AFTER: ``` Ensure `Button` is imported from `@cameleer/design-system`. - [ ] **Step 2: Add copy-to-clipboard button** Add `useToast` import and `Copy` icon: ```typescript import { useToast } from '@cameleer/design-system'; import { Copy } from 'lucide-react'; ``` Add toast hook in component: ```typescript const { toast } = useToast(); ``` Next to the show/hide button, add a copy button (only when expanded): ```tsx
{tokenExpanded && ( )}
``` - [ ] **Step 3: Commit** ```bash git add ui/src/pages/LicensePage.tsx git commit -m "fix: replace raw button with DS Button, add token copy-to-clipboard" ``` --- ## Task 6: Sign-in page improvements **Spec items:** 3.6, 4.5 **Files:** - Modify: `ui/sign-in/src/SignInPage.tsx` - [ ] **Step 1: Add password visibility toggle** In `ui/sign-in/src/SignInPage.tsx`, add state and imports: ```typescript import { Eye, EyeOff } from 'lucide-react'; const [showPassword, setShowPassword] = useState(false); ``` Update the password FormField (lines ~84-94): ```tsx
setPassword(e.target.value)} placeholder="••••••••" autoComplete="current-password" disabled={loading} />
``` Note: Using a raw `