Detailed step-by-step plan covering layout fixes (label/value collision, DS variable adoption), header/navigation (sidebar active state, breadcrumbs, collapse), error handling, DS component adoption, sign-in improvements, and polish (tier colors, badges, confirmations). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
21 KiB
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:
.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:
- Add import:
import s from '../styles/platform.module.css';
- 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) orclassName={s.kvValue} - Line 116:
text-sm text-white/60→className={s.description}
- The label/value collision fix: the
kvRowclass uses explicitdisplay: flex; width: 100%; justify-content: space-betweenwhich 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:
-
Add import:
import s from '../styles/platform.module.css'; -
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 nametext-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
- Open the platform dashboard in browser
- Check label/value pairs have proper spacing (Slug on left, "default" on right)
- Toggle to light theme via TopBar toggle
- Verify all text is readable in light mode (no invisible white-on-white)
- Toggle back to dark mode — should look the same as before
- Step 6: Commit
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
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):
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:
- Check if removing
GlobalFilterProviderandCommandPaletteProviderfrommain.tsxmakes TopBar gracefully hide those sections (test this first) - If that causes errors, add
display: noneCSS overrides for the irrelevant sections - 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:
import { useLocation } from 'react-router';
// Inside the Layout component:
const location = useLocation();
Update each Sidebar.Section:
<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:
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:
<TopBar breadcrumb={breadcrumb} ... />
Import BreadcrumbItem type from @cameleer/design-system if needed.
- Step 4: Fix sidebar collapse
Replace the hardcoded collapse state:
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
<Sidebar collapsed={sidebarCollapsed} onCollapseToggle={() => setSidebarCollapsed(c => !c)}>
- Step 5: Fix username null fallback
Update the user prop (line ~125):
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:
import { LayoutDashboard, ShieldCheck, Building, Server } from 'lucide-react';
Then update sidebar sections:
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
- Sidebar shows active highlight on current page
- Breadcrumbs show "Dashboard", "License", or "Admin > Tenants"
- Sidebar collapse works (click collapse button, sidebar minimizes)
- User avatar/logout always visible
- Icons render correctly from lucide-react
- Check if server controls are hidden (depending on step 1 result)
- Step 8: Commit
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):
// 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:
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:
- Add error handling:
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>
);
}
- Format the
createdAtcolumn (line 31):
// BEFORE:
{ key: 'createdAt', header: 'Created' },
// AFTER:
{ key: 'createdAt', header: 'Created', render: (_, row) => new Date(row.createdAt).toLocaleDateString() },
- Add empty state to DataTable (if supported) or show EmptyState when tenants is empty:
{(!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
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):
// 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:
import { useToast } from '@cameleer/design-system';
import { Copy } from 'lucide-react';
Add toast hook in component:
const { toast } = useToast();
Next to the show/hide button, add a copy button (only when expanded):
<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
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:
import { Eye, EyeOff } from 'lucide-react';
const [showPassword, setShowPassword] = useState(false);
Update the password FormField (lines ~84-94):
<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):
// BEFORE:
<div className={styles.logo}>
<img src={cameleerLogo} alt="" className={styles.logoImg} />
cameleer3
</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/):
<title>Sign in — Cameleer</title>
- Step 3: Commit
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:
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:
import { tierColor } from '../utils/tier';
In LicensePage.tsx, remove the local tierColor function (lines 25-33) and add:
import { tierColor } from '../utils/tier';
- Step 3: Fix feature badge color
In LicensePage.tsx, find the feature badge (line ~131-132):
// 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
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:
import { AlertDialog } from '@cameleer/design-system';
const [switchTarget, setSwitchTarget] = useState<TenantResponse | null>(null);
Update the row click handler:
// 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:
<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
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 |