Add SaaS platform UX polish design spec with audit findings
Playwright audit (22 screenshots) + source code audit covering all platform pages. Spec defines 4 batches: layout fixes (label/value collision, hardcoded colors), header/navigation (hide server controls, sidebar active state), error handling & components (DS adoption, copy-to-clipboard, error states), and polish (tier colors, badges). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
413
docs/superpowers/specs/2026-04-09-saas-ux-polish-design.md
Normal file
413
docs/superpowers/specs/2026-04-09-saas-ux-polish-design.md
Normal file
@@ -0,0 +1,413 @@
|
||||
# SaaS Platform UX Polish — Design Spec
|
||||
|
||||
**Date:** 2026-04-09
|
||||
**Scope:** Bug fixes, design consistency, error handling, component quality for the cameleer-saas platform UI
|
||||
**Out of scope:** Mobile responsiveness (deferred), new features (billing, team management), admin tenant creation (#37)
|
||||
|
||||
## Context
|
||||
|
||||
Playwright-driven audit of the live SaaS platform (22 screenshots) plus source code audit of `ui/src/` (3 pages, sign-in page, layout, auth components). The platform has only 3 pages — Dashboard, License, Admin Tenants — plus a custom Logto sign-in page. Issues are concentrated and structural.
|
||||
|
||||
Audit artifacts in `audit/`:
|
||||
- `platform-ui-findings.md` — 30 issues from live UI audit
|
||||
- `source-code-findings.md` — 22 issues from source code analysis
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
4 batches, ordered by impact. Smaller scope than the server UI polish (~25 items vs ~52).
|
||||
|
||||
---
|
||||
|
||||
## Batch 1: Layout Fixes
|
||||
|
||||
**Effort:** 0.5 days
|
||||
|
||||
### 1.1 Fix Label/Value Collision
|
||||
|
||||
**Problem:** Throughout Dashboard and License pages, labels and values run together: "Slugdefault", "Max Agents3", "Issued8. April 2026". The code uses `flex justify-between` but the flex container doesn't stretch to full Card width.
|
||||
|
||||
**Root cause (source audit):** The `<div className="flex justify-between">` elements are inside Card components. If the Card's inner container doesn't apply `w-full` or the flex children don't have enough space, `justify-between` collapses.
|
||||
|
||||
**Fix:** Ensure the container divs inside Cards have `w-full` (or `className="flex justify-between w-full"`). Check all label/value rows in:
|
||||
- `DashboardPage.tsx:95-112` — Tenant Information section
|
||||
- `LicensePage.tsx:94-115` — Validity section
|
||||
- `LicensePage.tsx:145-158` — Limits section
|
||||
|
||||
If the Card component's children wrapper is the constraint, wrap the content in `<div className="w-full space-y-2">`.
|
||||
|
||||
**Files:** `ui/src/pages/DashboardPage.tsx`, `ui/src/pages/LicensePage.tsx`
|
||||
|
||||
### 1.2 Replace Hardcoded `text-white` with DS Variables
|
||||
|
||||
**Problem:** Every page uses Tailwind `text-white`, `text-white/60`, `text-white/80`, `bg-white/5`, `border-white/10` instead of DS CSS variables. This breaks light theme (TopBar has a working theme toggle).
|
||||
|
||||
**Fix:** Replace all hardcoded color classes with DS CSS variable equivalents using inline styles or a CSS module:
|
||||
|
||||
| Tailwind class | DS variable |
|
||||
|---------------|-------------|
|
||||
| `text-white` | `var(--text-primary)` |
|
||||
| `text-white/80` | `var(--text-secondary)` |
|
||||
| `text-white/60` | `var(--text-muted)` |
|
||||
| `text-white/40` | `var(--text-faint)` |
|
||||
| `bg-white/5` | `var(--bg-hover)` |
|
||||
| `bg-white/10` | `var(--bg-inset)` |
|
||||
| `border-white/10` | `var(--border-subtle)` |
|
||||
| `divide-white/10` | `var(--border-subtle)` |
|
||||
|
||||
**Approach:** Create a shared CSS module (`ui/src/styles/platform.module.css`) with classes mapping to DS variables, or switch to inline `style={{ color: 'var(--text-primary)' }}`. The sign-in page already demonstrates the correct pattern with CSS modules + DS variables.
|
||||
|
||||
**Files:** `ui/src/pages/DashboardPage.tsx`, `ui/src/pages/LicensePage.tsx`, `ui/src/pages/AdminTenantsPage.tsx`
|
||||
|
||||
### 1.3 Reduce Redundant Dashboard Content
|
||||
|
||||
**Problem:** "Open Server Dashboard" appears 3 times. Status "ACTIVE" appears 3 times. Tier badge appears 2 times.
|
||||
|
||||
**Fix:**
|
||||
- Remove the "Open Server Dashboard" primary button from the header area (keep the Server Management card + sidebar footer link — 2 locations max)
|
||||
- Remove the status badge from the header area (keep KPI strip + Tenant Information)
|
||||
- The tier badge next to the heading is fine (quick context)
|
||||
|
||||
**Files:** `ui/src/pages/DashboardPage.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Batch 2: Header & Navigation
|
||||
|
||||
**Effort:** 1 day
|
||||
|
||||
### 2.1 Hide Server Controls on Platform Pages
|
||||
|
||||
**Problem:** TopBar always renders status filters (OK/Warn/Error/Running), time range pills (1h-7d), auto-refresh toggle, and command palette search. All are irrelevant on platform pages.
|
||||
|
||||
**Fix options (pick one):**
|
||||
|
||||
**Option A (recommended): Use TopBar props to hide sections.**
|
||||
Check if the DS `TopBar` component accepts props to control which sections render. If it has `showFilters`, `showTimeRange`, `showAutoRefresh`, `showSearch` props — set them all to `false` in `Layout.tsx`.
|
||||
|
||||
**Option B: Remove providers that feed the controls.**
|
||||
Don't wrap the platform app in `GlobalFilterProvider` and `CommandPaletteProvider` (in `main.tsx`). This may cause runtime errors if TopBar assumes they exist — test carefully.
|
||||
|
||||
**Option C: Custom simplified header.**
|
||||
Replace `TopBar` with a simpler platform-specific header that only renders: breadcrumb, theme toggle, user menu. Use DS primitives (`Breadcrumb`, `Avatar`, `Dropdown`, `Button`) to compose it.
|
||||
|
||||
Investigate which option is viable by checking the DS `TopBar` component API.
|
||||
|
||||
**Files:** `ui/src/components/Layout.tsx`, possibly `ui/src/main.tsx`
|
||||
|
||||
### 2.2 Fix Sidebar Active State
|
||||
|
||||
**Problem:** `Sidebar.Section` used as navigation links via `onToggle` hack. No `active` prop set. Users can't tell which page they're on.
|
||||
|
||||
**Fix:** Pass `active={true}` to the current page's `Sidebar.Section` based on the route:
|
||||
|
||||
```tsx
|
||||
const location = useLocation();
|
||||
const isActive = (path: string) => location.pathname === path || location.pathname === path + '/';
|
||||
|
||||
<Sidebar.Section
|
||||
icon={<DashboardIcon />}
|
||||
label="Dashboard"
|
||||
open={false}
|
||||
active={isActive('/') || isActive('/platform')}
|
||||
onToggle={() => navigate('/')}
|
||||
>
|
||||
{null}
|
||||
</Sidebar.Section>
|
||||
|
||||
<Sidebar.Section
|
||||
icon={<LicenseIcon />}
|
||||
label="License"
|
||||
open={false}
|
||||
active={isActive('/license')}
|
||||
onToggle={() => navigate('/license')}
|
||||
>
|
||||
{null}
|
||||
</Sidebar.Section>
|
||||
```
|
||||
|
||||
Check the DS `Sidebar.Section` props — if `active` doesn't exist on Section, check if there's a `Sidebar.Link` or `Sidebar.NavItem` component that supports it.
|
||||
|
||||
**Files:** `ui/src/components/Layout.tsx`
|
||||
|
||||
### 2.3 Add Breadcrumbs
|
||||
|
||||
**Problem:** `breadcrumb={[]}` is always empty.
|
||||
|
||||
**Fix:** Set breadcrumbs per page:
|
||||
|
||||
```tsx
|
||||
// Layout.tsx:
|
||||
const location = useLocation();
|
||||
const breadcrumb = useMemo(() => {
|
||||
if (location.pathname.includes('/license')) return [{ label: 'License' }];
|
||||
if (location.pathname.includes('/admin')) return [{ label: 'Admin' }, { label: 'Tenants' }];
|
||||
return [{ label: 'Dashboard' }];
|
||||
}, [location.pathname]);
|
||||
|
||||
<TopBar breadcrumb={breadcrumb} ... />
|
||||
```
|
||||
|
||||
Check the DS `TopBar` breadcrumb prop type to match the expected shape.
|
||||
|
||||
**Files:** `ui/src/components/Layout.tsx`
|
||||
|
||||
### 2.4 Remove One "Open Server Dashboard" Button
|
||||
|
||||
**Problem:** 3 locations for the same action.
|
||||
|
||||
**Fix:** Keep:
|
||||
1. Sidebar footer link (always accessible)
|
||||
2. Server Management card on Dashboard (contextual with description)
|
||||
|
||||
Remove: The primary "Open Server Dashboard" button in the header area of DashboardPage (line ~81-87).
|
||||
|
||||
**Files:** `ui/src/pages/DashboardPage.tsx`
|
||||
|
||||
### 2.5 Fix Sidebar Collapse
|
||||
|
||||
**Problem:** `collapsed={false}` hardcoded, `onCollapseToggle` is no-op.
|
||||
|
||||
**Fix:** Add state:
|
||||
|
||||
```tsx
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
|
||||
<Sidebar collapsed={sidebarCollapsed} onCollapseToggle={() => setSidebarCollapsed(c => !c)}>
|
||||
```
|
||||
|
||||
**Files:** `ui/src/components/Layout.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Batch 3: Error Handling & Components
|
||||
|
||||
**Effort:** 1 day
|
||||
|
||||
### 3.1 OrgResolver Error State
|
||||
|
||||
**Problem:** Returns `null` on error — blank screen with sidebar/TopBar but no content.
|
||||
|
||||
**Fix:** Replace `return null` with an error display:
|
||||
|
||||
```tsx
|
||||
if (isError) return (
|
||||
<EmptyState
|
||||
title="Unable to load account"
|
||||
description="Failed to retrieve your organization. Please try again."
|
||||
action={<Button onClick={() => refetch()}>Retry</Button>}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
Import `EmptyState` and `Button` from DS.
|
||||
|
||||
**Files:** `ui/src/auth/OrgResolver.tsx`
|
||||
|
||||
### 3.2 DashboardPage Error Handling
|
||||
|
||||
**Problem:** No `isError` check. Silently renders with `-` fallback values.
|
||||
|
||||
**Fix:** Add error state similar to LicensePage:
|
||||
|
||||
```tsx
|
||||
if (tenantError || licenseError) return (
|
||||
<EmptyState
|
||||
title="Unable to load dashboard"
|
||||
description="Failed to retrieve tenant information. Please try again."
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
**Files:** `ui/src/pages/DashboardPage.tsx`
|
||||
|
||||
### 3.3 Replace Raw HTML with DS Components
|
||||
|
||||
**Problem:** LicensePage uses raw `<button>` and `<code>` where DS components exist.
|
||||
|
||||
**Fix:**
|
||||
|
||||
Replace raw button (line ~166-170):
|
||||
```tsx
|
||||
// BEFORE:
|
||||
<button type="button" className="text-sm text-primary-400 ...">Show token</button>
|
||||
|
||||
// AFTER:
|
||||
<Button variant="ghost" size="sm" onClick={() => setTokenExpanded(v => !v)}>
|
||||
{tokenExpanded ? 'Hide token' : 'Show token'}
|
||||
</Button>
|
||||
```
|
||||
|
||||
Replace raw code block (line ~174-178) with DS `CodeBlock` if available, or at minimum use DS CSS variables instead of hardcoded Tailwind colors.
|
||||
|
||||
**Files:** `ui/src/pages/LicensePage.tsx`
|
||||
|
||||
### 3.4 Add Copy-to-Clipboard for License Token
|
||||
|
||||
**Problem:** Users must manually select and copy the token.
|
||||
|
||||
**Fix:** Add a copy button next to the token:
|
||||
|
||||
```tsx
|
||||
<Button variant="ghost" size="sm" onClick={() => {
|
||||
navigator.clipboard.writeText(license.token);
|
||||
toast({ title: 'Token copied', variant: 'success' });
|
||||
}}>
|
||||
<Copy size={14} /> Copy
|
||||
</Button>
|
||||
```
|
||||
|
||||
Import `Copy` from `lucide-react` and `useToast` from DS.
|
||||
|
||||
**Files:** `ui/src/pages/LicensePage.tsx`
|
||||
|
||||
### 3.5 Fix Username Null = No Logout
|
||||
|
||||
**Problem:** When `username` is null, no user indicator or logout button appears.
|
||||
|
||||
**Fix:** Always pass a user object to TopBar — fallback to email or "User":
|
||||
|
||||
```tsx
|
||||
const displayName = username || user?.email || 'User';
|
||||
<TopBar user={{ name: displayName }} onLogout={logout} />
|
||||
```
|
||||
|
||||
**Files:** `ui/src/components/Layout.tsx`
|
||||
|
||||
### 3.6 Add Password Visibility Toggle to Sign-In
|
||||
|
||||
**Problem:** No eye icon to reveal password.
|
||||
|
||||
**Fix:** The DS `Input` component may support a `type` toggle. If not, wrap with a show/hide toggle:
|
||||
|
||||
```tsx
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Input type={showPassword ? 'text' : 'password'} ... />
|
||||
<Button variant="ghost" size="sm"
|
||||
style={{ position: 'absolute', right: 4, top: '50%', transform: 'translateY(-50%)' }}
|
||||
onClick={() => setShowPassword(!showPassword)}>
|
||||
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</Button>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Files:** `ui/sign-in/src/SignInPage.tsx`
|
||||
|
||||
### 3.7 Admin Page Error Fallback
|
||||
|
||||
**Problem:** `/platform/admin/tenants` returns HTTP error with no graceful fallback.
|
||||
|
||||
**Fix:** Add error boundary or error state in AdminTenantsPage:
|
||||
|
||||
```tsx
|
||||
if (isError) return (
|
||||
<EmptyState
|
||||
title="Unable to load tenants"
|
||||
description="You may not have admin permissions, or the server is unavailable."
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
**Files:** `ui/src/pages/AdminTenantsPage.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Batch 4: Polish
|
||||
|
||||
**Effort:** 0.5 days
|
||||
|
||||
### 4.1 Unify `tierColor()` Mapping
|
||||
|
||||
**Problem:** Defined twice with different tier names:
|
||||
- `DashboardPage.tsx:12-18` maps enterprise/pro/starter
|
||||
- `LicensePage.tsx:25-33` maps BUSINESS/HIGH/MID/LOW
|
||||
|
||||
**Fix:** Extract a single `tierColor()` to a shared utility (`ui/src/utils/tier.ts`). Map all known tier names:
|
||||
|
||||
```typescript
|
||||
export function tierColor(tier: string): BadgeColor {
|
||||
switch (tier?.toUpperCase()) {
|
||||
case 'BUSINESS': case 'ENTERPRISE': return 'success';
|
||||
case 'HIGH': case 'PRO': return 'primary';
|
||||
case 'MID': case 'STARTER': return 'warning';
|
||||
case 'LOW': case 'FREE': return 'auto';
|
||||
default: return 'auto';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Import from both pages.
|
||||
|
||||
**Files:** New `ui/src/utils/tier.ts`, modify `DashboardPage.tsx`, `LicensePage.tsx`
|
||||
|
||||
### 4.2 Fix Feature Badge Colors
|
||||
|
||||
**Problem:** Disabled features use `color="auto"` (hash-based, inconsistent). Should use muted neutral.
|
||||
|
||||
**Fix:** Check if DS Badge supports a `neutral` or `default` color variant. If not, use the closest muted option. The goal: enabled = green success, disabled = gray/muted (not red, not random).
|
||||
|
||||
**Files:** `ui/src/pages/LicensePage.tsx`
|
||||
|
||||
### 4.3 AdminTenantsPage Improvements
|
||||
|
||||
**Problem:** Row click silently switches tenant context. `createdAt` renders raw ISO. No empty state.
|
||||
|
||||
**Fix:**
|
||||
- Add confirmation before tenant switch: `if (!confirm('Switch to tenant "X"?')) return;` (or DS AlertDialog)
|
||||
- Format date: `new Date(row.createdAt).toLocaleDateString()`
|
||||
- Add empty state: `<EmptyState title="No tenants" description="Create a tenant to get started." />`
|
||||
|
||||
**Files:** `ui/src/pages/AdminTenantsPage.tsx`
|
||||
|
||||
### 4.4 Replace Custom SVG Icons with Lucide
|
||||
|
||||
**Problem:** Layout.tsx has 4 inline SVG icon components instead of using lucide-react.
|
||||
|
||||
**Fix:** Replace with lucide icons:
|
||||
- `DashboardIcon` -> `<LayoutDashboard size={18} />`
|
||||
- `LicenseIcon` -> `<ShieldCheck size={18} />`
|
||||
- `PlatformIcon` -> `<Building size={18} />`
|
||||
- `ServerIcon` -> `<Server size={18} />`
|
||||
|
||||
Import from `lucide-react`.
|
||||
|
||||
**Files:** `ui/src/components/Layout.tsx`
|
||||
|
||||
### 4.5 Sign-In Branding
|
||||
|
||||
**Problem:** Login says "cameleer3" — internal repo name, not product brand.
|
||||
|
||||
**Fix:** Change to "Cameleer" (product name). Update the page title from "Sign in — cameleer3" to "Sign in — Cameleer".
|
||||
|
||||
**Files:** `ui/sign-in/src/SignInPage.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
| Order | Batch | Items | Effort |
|
||||
|-------|-------|-------|--------|
|
||||
| 1 | **Batch 1: Layout Fixes** | 3 | 0.5 days |
|
||||
| 2 | **Batch 2: Header & Navigation** | 5 | 1 day |
|
||||
| 3 | **Batch 3: Error Handling & Components** | 7 | 1 day |
|
||||
| 4 | **Batch 4: Polish** | 5 | 0.5 days |
|
||||
|
||||
**Total: ~20 items across 4 batches, ~3 days of work.**
|
||||
|
||||
---
|
||||
|
||||
## Related Issues
|
||||
|
||||
| Issue | Relevance |
|
||||
|-------|-----------|
|
||||
| #1 | Epic: SaaS Management Platform — this spec covers polish only |
|
||||
| #37 | Admin: Tenant creation UI — not covered (feature work) |
|
||||
| #38 | Cross-app session management — not covered (parked) |
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Mobile responsiveness (deferred per user request)
|
||||
- New features (billing, team management, tenant creation)
|
||||
- Admin tenant CRUD workflow (#37)
|
||||
- Cross-app session sync (#38)
|
||||
Reference in New Issue
Block a user