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

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:

  1. Add import:
import s from '../styles/platform.module.css';
  1. Replace all hardcoded color classes:
  • Line 71: text-2xl font-semibold text-whiteclassName={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/60className={s.description}
  1. 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-betweenclassName={s.kvRow}, labels → className={s.kvLabel}, values → className={s.kvValue}

  • Lines 121-136 (Features): divide-y divide-white/10className={s.dividerList}, rows → className={s.dividerRow}, feature name text-sm text-whiteclassName={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-whiteclassName={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
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:

  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:

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

  1. 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>
  );
}
  1. Format the createdAt column (line 31):
// BEFORE:
{ key: 'createdAt', header: 'Created' },

// AFTER:
{ key: 'createdAt', header: 'Created', render: (_, row) => new Date(row.createdAt).toLocaleDateString() },
  1. 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} />
  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/):

<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