diff --git a/docs/superpowers/plans/2026-04-09-saas-ux-polish-plan.md b/docs/superpowers/plans/2026-04-09-saas-ux-polish-plan.md new file mode 100644 index 0000000..459120d --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-saas-ux-polish-plan.md @@ -0,0 +1,760 @@ +# 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 `