Files
cameleer-saas/docs/superpowers/specs/2026-04-09-saas-ux-polish-design.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

14 KiB

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:

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:

// 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:

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:

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:

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):

// 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:

<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":

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:

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:

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:

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 "cameleer" — internal repo name, not product brand.

Fix: Change to "Cameleer" (product name). Update the page title from "Sign in — cameleer" 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.


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)