Files
cameleer-saas/docs/superpowers/plans/2026-04-09-saas-ux-polish-plan.md
hsiegeln 63c194dab7
Some checks failed
CI / build (push) Failing after 18s
CI / docker (push) Has been skipped
chore: rename cameleer3 to cameleer
Rename Java packages from net.siegeln.cameleer3 to net.siegeln.cameleer,
update all references in workflows, Docker configs, docs, and bootstrap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:28:44 +02:00

761 lines
21 KiB
Markdown

# 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
<Sidebar.Section
icon={<DashboardIcon />}
label="Dashboard"
open={false}
active={location.pathname === '/' || location.pathname === ''}
onToggle={() => navigate('/')}
>
{null}
</Sidebar.Section>
<Sidebar.Section
icon={<LicenseIcon />}
label="License"
open={false}
active={location.pathname === '/license'}
onToggle={() => navigate('/license')}
>
{null}
</Sidebar.Section>
{scopes.has('platform:admin') && (
<Sidebar.Section
icon={<PlatformIcon />}
label="Platform"
open={false}
active={location.pathname.startsWith('/admin')}
onToggle={() => navigate('/admin/tenants')}
>
{null}
</Sidebar.Section>
)}
```
- [ ] **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
<TopBar breadcrumb={breadcrumb} ... />
```
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
<Sidebar collapsed={sidebarCollapsed} onCollapseToggle={() => setSidebarCollapsed(c => !c)}>
```
- [ ] **Step 5: Fix username null fallback**
Update the user prop (line ~125):
```tsx
const displayName = username || 'User';
<TopBar
breadcrumb={breadcrumb}
user={{ name: displayName }}
onLogout={logout}
/>
```
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={<LayoutDashboard size={18} />} // was <DashboardIcon />
icon={<ShieldCheck size={18} />} // was <LicenseIcon />
icon={<Building size={18} />} // was <PlatformIcon />
```
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 (
<div className="flex flex-col items-center justify-center h-64 gap-4">
<EmptyState
title="Unable to load account"
description="Failed to retrieve your organization. Please try again or contact support."
/>
<Button variant="secondary" size="sm" onClick={() => refetch()}>
Retry
</Button>
</div>
);
}
```
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 (
<div className="p-6">
<EmptyState
title="Unable to load dashboard"
description="Failed to retrieve tenant information. Please try again later."
/>
</div>
);
}
```
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 (
<div className="p-6">
<EmptyState
title="Unable to load tenants"
description="You may not have admin permissions, or the server is unavailable."
/>
</div>
);
}
```
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) ? (
<EmptyState title="No tenants" description="No tenants have been created yet." />
) : (
<DataTable columns={columns} data={tenants} onRowClick={handleRowClick} />
)}
```
- [ ] **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:
<button
type="button"
className="text-sm text-primary-400 hover:text-primary-300 underline underline-offset-2 focus:outline-none"
onClick={() => setTokenExpanded((v) => !v)}
>
{tokenExpanded ? 'Hide token' : 'Show token'}
</button>
// AFTER:
<Button variant="ghost" size="sm" onClick={() => setTokenExpanded((v) => !v)}>
{tokenExpanded ? 'Hide token' : 'Show token'}
</Button>
```
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
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<Button variant="ghost" size="sm" onClick={() => setTokenExpanded((v) => !v)}>
{tokenExpanded ? 'Hide token' : 'Show token'}
</Button>
{tokenExpanded && (
<Button variant="ghost" size="sm" onClick={() => {
navigator.clipboard.writeText(license.token);
toast({ title: 'Token copied to clipboard', variant: 'success' });
}}>
<Copy size={14} /> Copy
</Button>
)}
</div>
```
- [ ] **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
<FormField label="Password" htmlFor="login-password">
<div style={{ position: 'relative' }}>
<Input
id="login-password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
autoComplete="current-password"
disabled={loading}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
style={{
position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)',
background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)',
padding: 4, display: 'flex', alignItems: 'center',
}}
tabIndex={-1}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</FormField>
```
Note: Using a raw `<button>` here because the sign-in page may not have the full DS Button available (it's a separate Vite build). Use inline styles for positioning since the sign-in page uses CSS modules.
- [ ] **Step 2: Fix branding text**
In `ui/sign-in/src/SignInPage.tsx`, find the logo text (line ~61):
```tsx
// BEFORE:
<div className={styles.logo}>
<img src={cameleerLogo} alt="" className={styles.logoImg} />
cameleer
</div>
// AFTER:
<div className={styles.logo}>
<img src={cameleerLogo} alt="" className={styles.logoImg} />
Cameleer
</div>
```
Also update the page title if it's set anywhere (check `index.html` in `ui/sign-in/`):
```html
<title>Sign in — Cameleer</title>
```
- [ ] **Step 3: Commit**
```bash
git add ui/sign-in/src/SignInPage.tsx ui/sign-in/index.html
git commit -m "fix: add password visibility toggle and fix branding to 'Cameleer'"
```
---
## Task 7: Unify tier colors and fix badges
**Spec items:** 4.1, 4.2
**Files:**
- Create: `ui/src/utils/tier.ts`
- Modify: `ui/src/pages/DashboardPage.tsx`
- Modify: `ui/src/pages/LicensePage.tsx`
- [ ] **Step 1: Create shared tier utility**
Create `ui/src/utils/tier.ts`:
```typescript
export type TierColor = 'primary' | 'success' | 'warning' | 'error' | 'auto';
export function tierColor(tier: string): TierColor {
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';
}
}
```
- [ ] **Step 2: Replace local tierColor in both pages**
In `DashboardPage.tsx`, remove the local `tierColor` function (lines 12-19) and add:
```typescript
import { tierColor } from '../utils/tier';
```
In `LicensePage.tsx`, remove the local `tierColor` function (lines 25-33) and add:
```typescript
import { tierColor } from '../utils/tier';
```
- [ ] **Step 3: Fix feature badge color**
In `LicensePage.tsx`, find the feature badge (line ~131-132):
```tsx
// BEFORE:
color={enabled ? 'success' : 'auto'}
// Check what neutral badge colors the DS supports.
// If 'auto' hashes to inconsistent colors, use a fixed muted option.
// AFTER:
color={enabled ? 'success' : 'warning'}
```
Use `'warning'` (amber/muted) for "Not included" — it's neutral without implying error. If the DS has a better neutral option, use that.
- [ ] **Step 4: Commit**
```bash
git add ui/src/utils/tier.ts ui/src/pages/DashboardPage.tsx ui/src/pages/LicensePage.tsx
git commit -m "fix: unify tier color mapping, fix feature badge colors"
```
---
## Task 8: AdminTenantsPage confirmation and polish
**Spec items:** 4.3
**Files:**
- Modify: `ui/src/pages/AdminTenantsPage.tsx`
- [ ] **Step 1: Add confirmation before tenant context switch**
In `ui/src/pages/AdminTenantsPage.tsx`, add state and import:
```typescript
import { AlertDialog } from '@cameleer/design-system';
const [switchTarget, setSwitchTarget] = useState<TenantResponse | null>(null);
```
Update the row click handler:
```tsx
// BEFORE:
const handleRowClick = (tenant: TenantResponse) => {
const orgs = useOrgStore.getState().organizations;
const match = orgs.find((o) => o.name === tenant.name || o.slug === tenant.slug);
if (match) {
setCurrentOrg(match.id);
navigate('/');
}
};
// AFTER:
const handleRowClick = (tenant: TenantResponse) => {
setSwitchTarget(tenant);
};
const confirmSwitch = () => {
if (!switchTarget) return;
const orgs = useOrgStore.getState().organizations;
const match = orgs.find((o) => o.name === switchTarget.name || o.slug === switchTarget.slug);
if (match) {
setCurrentOrg(match.id);
navigate('/');
}
setSwitchTarget(null);
};
```
Add the AlertDialog at the bottom of the component return:
```tsx
<AlertDialog
open={!!switchTarget}
onCancel={() => setSwitchTarget(null)}
onConfirm={confirmSwitch}
title="Switch tenant?"
description={`Switch to tenant "${switchTarget?.name}"? Your dashboard context will change.`}
confirmLabel="Switch"
variant="warning"
/>
```
- [ ] **Step 2: Commit**
```bash
git add ui/src/pages/AdminTenantsPage.tsx
git commit -m "fix: add confirmation dialog before tenant context switch"
```
---
## Summary
| Task | Batch | Key Changes | Commit |
|------|-------|-------------|--------|
| 1 | Layout | CSS module with DS variables, fix label/value, replace text-white | `fix: replace hardcoded text-white with DS variables, fix label/value layout` |
| 2 | Layout | Remove redundant "Open Server Dashboard" button | `fix: remove redundant Open Server Dashboard button` |
| 3 | Navigation | Sidebar active state, breadcrumbs, collapse, username fallback, lucide icons | `fix: sidebar active state, breadcrumbs, collapse, username fallback, lucide icons` |
| 4 | Error Handling | OrgResolver error UI, DashboardPage error state, AdminTenantsPage error + date format | `fix: add error states to OrgResolver, DashboardPage, AdminTenantsPage` |
| 5 | Components | DS Button for token toggle, copy-to-clipboard with toast | `fix: replace raw button with DS Button, add token copy-to-clipboard` |
| 6 | Sign-in | Password visibility toggle, branding fix to "Cameleer" | `fix: add password visibility toggle and fix branding to 'Cameleer'` |
| 7 | Polish | Shared tierColor(), fix feature badge colors | `fix: unify tier color mapping, fix feature badge colors` |
| 8 | Polish | Confirmation dialog for admin tenant switch | `fix: add confirmation dialog before tenant context switch` |