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:
hsiegeln
2026-04-09 19:39:33 +02:00
parent 5c4a84e64c
commit 1133763520
3 changed files with 1115 additions and 0 deletions

View File

@@ -0,0 +1,269 @@
# Cameleer SaaS Platform UI Audit Findings
**Date:** 2026-04-09
**Auditor:** Claude Opus 4.6
**URL:** https://desktop-fb5vgj9.siegeln.internal/
**Credentials:** admin/admin
**Browser:** Playwright (Chromium)
---
## 1. Login Page (`/sign-in`)
**Screenshot:** `03-login-page.png`, `04-login-error.png`
### What works well
- Clean, centered card layout with consistent design system components
- Fun rotating subtitle taglines (e.g., "No ticket, no caravan") add personality
- Cameleer logo is displayed correctly
- Error handling works -- "Invalid username or password" alert appears on bad credentials (red alert banner)
- Sign in button is correctly disabled until both fields are populated
- Loading state on button during authentication
- Uses proper `autoComplete` attributes (`username`, `current-password`)
### Issues found
| Severity | Issue | Element |
|----------|-------|---------|
| Important | **No password visibility toggle** -- the Password input uses `type="password"` with no eye icon to reveal. Most modern login forms offer this. | Password field |
| Important | **Branding says "cameleer3"** not "Cameleer" or "Cameleer SaaS" -- the product name on the login page is the internal repo name, not the user-facing brand | `.logo` text content |
| Nice-to-have | **No "Forgot password" link** -- even if it goes to a "contact admin" page, users expect this | Below password field |
| Nice-to-have | **No Enter-key submit hint** -- though Enter does work via form submit, there's no visual affordance | Form area |
| Nice-to-have | **Page title is "Sign in -- cameleer3"** -- should match product branding ("Cameleer SaaS") | `<title>` tag |
---
## 2. Platform Dashboard (`/platform/`)
**Screenshots:** `05-platform-dashboard-loggedin.png`, `15-dashboard-desktop-1280.png`, `19-tenant-info-detail.png`, `20-kpi-strip-detail.png`
### What works well
- Clear tenant name as page heading ("Example Tenant")
- Tier badge next to tenant name provides immediate context
- KPI strip with Tier, Status, License cards is visually clean and well-structured
- License KPI card shows expiry date in green "expires 8.4.2027" trend indicator
- "Server Management" card provides clear description of what the server dashboard does
### Issues found
| Severity | Issue | Element |
|----------|-------|---------|
| **Critical** | **Label/value collision in Tenant Information card** -- "Slugdefault", "Created8.4.2026" have no visual separation between label and value. The source uses `flex justify-between` but the deployed Card component doesn't give the inner `div` full width, so items stack/collapse. | Tenant Information card |
| **Critical** | **"Open Server Dashboard" appears 3 times** on one page: (1) primary button in header area below tenant name, (2) "Server Management" card with secondary button, (3) sidebar footer link. This is redundant and clutters the page. Reduce to 1-2 locations max. | Header area, Server Management card, sidebar footer |
| Important | **Breadcrumb is always empty** -- the `breadcrumb` prop is passed as `[]`. Platform pages should have breadcrumbs like "Platform > Dashboard" or "Platform > License". | TopBar breadcrumb nav |
| Important | **Massive empty space below content** -- the dashboard only has ~4 cards but the page extends far below with blank white/cream space. The page feels sparse and "stub-like." | Below Server Management card |
| Important | **Tier badge color is misleading** -- "LOW" tier uses `primary` (orange) color, which doesn't convey it's the lowest/cheapest tier. The `tierColor()` function in DashboardPage maps to enterprise=success, pro=primary, starter=warning, but the actual data uses LOW/MID/HIGH/BUSINESS tiers (defined in LicensePage). Dashboard and License pages have different tier color mappings. | Tier badge |
| Important | **Status is shown redundantly** -- "ACTIVE" appears in (1) KPI strip Status card, (2) Tenant Information card with badge, and (3) header area badge. This is excessive for a single piece of information. | Multiple locations |
| Nice-to-have | **No tenant ID/slug in breadcrumb or subtitle** -- the slug "default" only appears buried in the Tenant Information card | Page header area |
---
## 3. License Page (`/platform/license`)
**Screenshots:** `06-license-page.png`, `07-license-token-revealed.png`, `16-license-features-detail.png`, `17-license-limits-detail.png`, `18-license-validity-detail.png`
### What works well
- Well-structured layout with logical sections (Validity, Features, Limits, License Token)
- Tier badge in header provides context
- Feature matrix clearly shows enabled vs disabled features
- "Days remaining" with color-coded badge (green for healthy, warning for <30 days, red for expired)
- Token show/hide toggle works correctly
- Token revealed in monospace code block with appropriate styling
### Issues found
| Severity | Issue | Element |
|----------|-------|---------|
| **Critical** | **Label/value collision in Validity section** -- "Issued8. April 2026" and "Expires8. April 2027" have no separation. Source code uses `flex items-center justify-between` but the flex container seems to not be stretching to full width. | Validity card rows |
| **Critical** | **Label/value collision in Limits section** -- "Max Agents3", "Retention Days7", "Max Environments1" have labels and values mashed together. Source uses `flex items-center justify-between` layout but the same rendering bug prevents proper spacing. | Limits card rows |
| Important | **No "Copy to clipboard" button** for the license token -- users need to manually select and copy. A copy button with confirmation toast is standard UX for tokens/secrets. | License Token section |
| Important | **Feature badge text mismatch** -- Source code says `'Not included'` for disabled features, but deployed version shows "DISABLED". This suggests the deployed build is out of sync with the source. | Features card badges |
| Important | **"Disabled" badge color** -- disabled features use `color='auto'` (which renders as a neutral/red-ish badge), while "Enabled" uses green. Consider using a muted gray for "Not included" to make it feel less like an error state. Red implies something is wrong, but a feature simply not being in the plan is not an error. | Features card disabled badges |
| Nice-to-have | **Limits values are not right-aligned** -- due to the label/value collision, the numeric values don't align in a column, making comparison harder | Limits card |
| Nice-to-have | **No units on limits** -- "Retention Days7" should be "7 days", "Max Agents3" should be "3 agents" or just "3" with clear formatting | Limits card values |
---
## 4. Admin Pages (`/platform/admin/tenants`)
**No screenshot available -- page returns HTTP error**
### Issues found
| Severity | Issue | Element |
|----------|-------|---------|
| **Critical** | **Admin page returns HTTP error (net::ERR_HTTP_RESPONSE_CODE_FAILURE)** -- navigating to `/platform/admin/tenants` fails with an HTTP error. The route exists in the router (`AdminTenantsPage`), but the admin section is not visible in the sidebar (no "Platform" item shown). | Admin route |
| Important | **Admin section not visible in sidebar** -- the `platform:admin` scope check in Layout.tsx hides the "Platform" sidebar item. Even though the user is "admin", they apparently don't have the `platform:admin` scope in their JWT. This may be intentional (scope not assigned) or a bug. | Sidebar Platform section |
| Important | **No graceful fallback for unauthorized admin access** -- if a user manually navigates to `/admin/tenants` without the scope, the page should show a "Not authorized" message rather than an HTTP error. | Admin route error handling |
---
## 5. Navigation
**Screenshots:** `21-sidebar-detail.png`, `12-sidebar-collapsed.png`
### What works well
- Clean sidebar with Cameleer SaaS branding and logo
- "Open Server Dashboard" in sidebar footer is a good location
- Sidebar has only 2 navigation items (Dashboard, License) which keeps it simple
### Issues found
| Severity | Issue | Element |
|----------|-------|---------|
| **Critical** | **No active state on sidebar navigation items** -- when on the Dashboard page, neither Dashboard nor License is highlighted/active. The sidebar uses `Sidebar.Section` components with `open={false}` as navigation links via `onToggle`, but `Section` is designed for expandable/collapsible groups, not navigation links. There is no visual indicator of the current page. | Sidebar items |
| Important | **Sidebar collapse doesn't work visually** -- clicking "Collapse sidebar" toggles the `active` state on the button but the sidebar doesn't visually collapse. The Layout component passes `collapsed={false}` as a hardcoded prop and `onCollapseToggle={() => {}}` as a no-op. | Sidebar collapse button |
| Important | **No clear distinction between "platform" and "server" levels** -- there's nothing in the sidebar header that says "Platform" vs "Server". The sidebar says "Cameleer SaaS" but when you switch to the server dashboard, it becomes a completely different app. A user might not understand the relationship. | Sidebar header |
| Nice-to-have | **"Open Server Dashboard" opens in new tab** -- `window.open('/server/', '_blank', 'noopener')` is used. While reasonable, there's no visual indicator (external link icon) that it will open a new tab. | Sidebar footer link, dashboard buttons |
---
## 6. Header Bar (TopBar)
**Screenshot:** `22-header-bar-detail.png`
### Issues found
| Severity | Issue | Element |
|----------|-------|---------|
| **Critical** | **Server-specific controls shown on platform pages** -- the TopBar always renders: (1) Search (Ctrl+K), (2) Status filters (OK/Warn/Error/Running), (3) Time range pills (1h/3h/6h/Today/24h/7d), (4) Auto-refresh toggle (MANUAL/AUTO). None of these are relevant to the platform dashboard or license page. They are observability controls designed for the server's exchange/route monitoring. | Entire TopBar filter area |
| Important | **Search button does nothing** -- clicking "Search..." on the platform does not open a search modal. The CommandPaletteProvider is likely not configured for the platform context. | Search button |
| Important | **Status filter buttons are interactive but meaningless** -- clicking OK/Warn/Error/Running on platform pages toggles state (global filter provider) but has no effect on the displayed content. | Status filter buttons |
| Important | **Time range selector is interactive but meaningless** -- similarly, changing the time range from 1h to 7d has no effect on platform pages. | Time range pills |
| Important | **Auto-refresh toggle is misleading** -- shows "MANUAL" toggle on platform pages where there's nothing to auto-refresh. | Auto-refresh button |
---
## 7. User Menu
**Screenshot:** `02-user-menu-dropdown.png`
### What works well
- User name "admin" and avatar initials "AD" displayed correctly
- Dropdown appears on click with Logout option
### Issues found
| Severity | Issue | Element |
|----------|-------|---------|
| Important | **User menu only has "Logout"** -- there's no "Profile", "Settings", "About", or "Switch Tenant" option. For a SaaS platform, users should at minimum see their role and tenant context. | User dropdown menu |
| Nice-to-have | **Avatar shows "AD" for "admin"** -- the Avatar component appears to use first 2 characters of the name. For "admin" this produces "AD" which looks like initials for a different name. | Avatar component |
---
## 8. Dark Mode
**Screenshots:** `08-dashboard-dark-mode.png`, `09-license-dark-mode.png`
### What works well
- Dark mode toggle works and applies globally
- Background transitions to dark brown/charcoal
- Text colors adapt appropriately
- Cards maintain visual distinction from background
- Design system tokens handle the switch smoothly
### Issues found
| Severity | Issue | Element |
|----------|-------|---------|
| Nice-to-have | **Dark mode is warm-toned (brown)** rather than the more common cool dark gray/charcoal. This is consistent with the design system's cameleer branding but may feel unusual to users accustomed to dark mode in other apps. | Global dark theme |
| Nice-to-have | **The same label/value collision issues appear in dark mode** -- these are layout bugs, not color bugs, so dark mode doesn't help or hurt. | Card content |
---
## 9. Responsiveness
**Screenshots:** `13-responsive-tablet.png`, `14-responsive-mobile.png`
### Issues found
| Severity | Issue | Element |
|----------|-------|---------|
| **Critical** | **Mobile layout is broken** -- at 375px width, the sidebar overlaps the main content. The KPI strip cards are truncated ("LO...", "AC..."). The header bar overflows. Content is unreadable. | Full page at mobile widths |
| Important | **Tablet layout (768px) is functional but crowded** -- sidebar takes significant width, header bar items are compressed ("Se..." for Search), but content is readable. KPI strip wraps correctly. | Full page at tablet widths |
| Important | **Sidebar doesn't collapse on mobile** -- there's no hamburger menu or responsive sidebar behavior. The sidebar is always visible, eating screen space on narrow viewports. | Sidebar |
---
## 10. Cross-cutting Concerns
### Loading States
- Dashboard and License pages both show a centered `Spinner` during loading -- this works well.
- `EmptyState` component used for "No tenant associated" and "License unavailable" -- good error handling in components.
### Error States
- Login page error handling is good (alert banner)
- No visible error boundary for unexpected errors on platform pages
- Admin route fails silently with HTTP error -- no user-facing error message
### Toast Notifications
- No toast notifications observed during the audit
- License token copy should trigger a toast confirmation (if a copy button existed)
### Confirmation Dialogs
- No destructive actions available on the platform (no delete/deactivate buttons) so no confirmation dialogs needed currently
---
## Summary of Issues by Severity
### Critical (5)
1. **Label/value collision** throughout Tenant Information card, License Validity, and License Limits sections -- labels and values run together without spacing
2. **"Open Server Dashboard" appears 3 times** on the dashboard page -- excessive redundancy
3. **No active state on sidebar navigation items** -- users can't tell which page they're on
4. **Server-specific header controls shown on platform pages** -- search, status filters, time range, auto-refresh are all meaningless on platform pages
5. **Mobile layout completely broken** -- sidebar overlaps content, content truncated
### Important (17)
1. No password visibility toggle on login
2. Branding says "cameleer3" instead of product name on login
3. Breadcrumbs always empty on platform pages
4. Massive empty space below dashboard content
5. Tier badge color mapping inconsistent between Dashboard and License pages
6. Status shown redundantly in 3 places on dashboard
7. No clipboard copy button for license token
8. Feature badge text mismatch between source and deployed build
9. "Disabled" badge uses red-ish color (implies error, not "not in plan")
10. Admin page returns HTTP error with no graceful fallback
11. Admin section invisible in sidebar despite being admin user
12. Sidebar collapse button doesn't work (no-op handler)
13. No clear platform vs server level distinction
14. Search button does nothing on platform
15. Status filters and time range interactive but meaningless on platform
16. User menu only has Logout (no profile/settings)
17. Sidebar doesn't collapse/hide on mobile
### Nice-to-have (8)
1. No "Forgot password" link on login
2. Login page title uses "cameleer3" branding
3. No external link icon on "Open Server Dashboard"
4. Avatar shows "AD" for "admin"
5. No units on limit values
6. Dark mode warm-toned (not standard cool dark)
7. No Enter-key submit hint
8. No tenant ID in breadcrumb/subtitle
---
## Overarching Assessment
The platform UI currently feels like a **thin shell** around the server dashboard. It has only 2 functioning pages (Dashboard and License), and both suffer from the same fundamental layout bug (label/value collision in Card components). The header bar is entirely borrowed from the server observability UI without any platform-specific adaptation, making 70% of the header controls irrelevant.
**Key architectural concerns:**
1. The TopBar component from the design system is monolithic -- it always renders server-specific controls (status filters, time range, search). The platform needs either a simplified TopBar variant or the ability to hide these sections.
2. The sidebar uses `Sidebar.Section` (expandable groups) as navigation links, which prevents active-state highlighting. It should use `Sidebar.Link` or a similar component.
3. The platform provides very little actionable functionality -- a user can view their tenant info and license, but can't manage anything. The "Server Management" card is just a link to another app.
**What works well overall:**
- Design system integration is solid (same look and feel as server)
- Dark mode works correctly
- Loading and error states are handled
- Login page is clean and functional
- KPI strip component is effective at summarizing key info
**Recommended priorities:**
1. Fix the label/value collision bug (affects 3 cards across 2 pages)
2. Hide or replace server-specific header controls on platform pages
3. Add sidebar active state and fix the collapse behavior
4. Add clipboard copy for license token
5. Fix mobile responsiveness

View File

@@ -0,0 +1,433 @@
# Cameleer SaaS UI — Source Code Audit Findings
**Audit date:** 2026-04-09
**Scope:** `ui/src/` (platform SPA) + `ui/sign-in/src/` (custom Logto sign-in)
**Design system:** `@cameleer/design-system@0.1.38`
---
## 1. Layout and Styling Patterns
### 1.1 Container Padding/Margin
All three page components use an identical outer wrapper pattern:
```tsx
// DashboardPage.tsx:67, LicensePage.tsx:82, AdminTenantsPage.tsx:60
<div className="space-y-6 p-6">
```
**Verdict:** Consistent across all pages. However, this padding is applied by each page individually rather than by the `Layout` component. If a new page omits `p-6`, the layout will be inconsistent. Consider moving container padding to the `Layout` component wrapping `<Outlet />`.
### 1.2 Use of Design System Components vs Custom HTML
| Component | DashboardPage | LicensePage | AdminTenantsPage |
|-----------|:---:|:---:|:---:|
| Badge | Yes | Yes | Yes |
| Button | Yes | - | - |
| Card | Yes | Yes | Yes |
| DataTable | - | - | Yes |
| EmptyState | Yes | Yes | - |
| KpiStrip | Yes | - | - |
| Spinner | Yes | Yes | Yes |
**Issues found:**
- **LicensePage.tsx:166-170** — Raw `<button>` for "Show token" / "Hide token" toggle instead of DS `Button variant="ghost"`:
```tsx
<button
type="button"
className="text-sm text-primary-400 hover:text-primary-300 underline underline-offset-2 focus:outline-none"
onClick={() => setTokenExpanded((v) => !v)}
>
```
This uses hardcoded Tailwind color classes (`text-primary-400`, `hover:text-primary-300`) instead of design tokens or a DS Button.
- **LicensePage.tsx:174** — Raw `<div>` + `<code>` for token display instead of DS `CodeBlock` (which is available and supports `copyable`):
```tsx
<div className="mt-2 rounded bg-white/5 border border-white/10 p-3 overflow-x-auto">
<code className="text-xs font-mono text-white/80 break-all">
{license.token}
</code>
</div>
```
- **AdminTenantsPage.tsx** — No empty state when `tenants` is empty. The DataTable renders with zero rows but no guidance for the admin.
### 1.3 Card/Section Grouping
- **DashboardPage** uses: KpiStrip + "Tenant Information" Card + "Server Management" Card. Good grouping.
- **LicensePage** uses: "Validity" Card + "Features" Card + "Limits" Card + "License Token" Card. Well-structured.
- **AdminTenantsPage** uses: single Card wrapping DataTable. Appropriate for a list view.
### 1.4 Typography
All pages use the same heading pattern:
```tsx
<h1 className="text-2xl font-semibold text-white">...</h1>
```
**Issue:** `text-white` is hardcoded rather than using a DS color token like `var(--text-primary)`. This will break if the design system ever supports a light theme (the DS has `ThemeProvider` and a theme toggle in the TopBar). The same pattern appears:
- `DashboardPage.tsx:73` — `text-white`
- `LicensePage.tsx:85` — `text-white`
- `AdminTenantsPage.tsx:62` — `text-white`
Similarly, muted text uses `text-white/60` and `text-white/80` throughout:
- `DashboardPage.tsx:96` — `text-white/80`
- `LicensePage.tsx:96,106,109` — `text-white/60`, `text-white`
- `LicensePage.tsx:129` — `text-sm text-white`
- `LicensePage.tsx:150` — `text-sm text-white/60`
These should use `var(--text-primary)` / `var(--text-secondary)` / `var(--text-muted)` from the design system.
### 1.5 Color Token Usage
**Positive:** The sign-in page CSS module (`SignInPage.module.css`) correctly uses DS variables:
```css
color: var(--text-primary); /* line 30 */
color: var(--text-muted); /* line 40 */
background: var(--bg-base); /* line 7 */
font-family: var(--font-body); /* line 20 */
```
**Negative:** The platform SPA pages bypass the design system's CSS variables entirely, using Tailwind utility classes with hardcoded dark-theme colors (`text-white`, `text-white/60`, `bg-white/5`, `border-white/10`, `divide-white/10`).
---
## 2. Interaction Patterns
### 2.1 Button Placement and Order
- **DashboardPage.tsx:81-87** — "Open Server Dashboard" button is top-right (standard). Also repeated inside a Card at line 119-125. Two identical CTAs on the same page is redundant.
- No forms exist in the platform pages. No create/edit/delete operations are exposed in the UI (read-only dashboard).
### 2.2 Confirmation Dialogs for Destructive Actions
- The DS provides `ConfirmDialog` and `AlertDialog` — neither is used anywhere.
- **AdminTenantsPage.tsx:47-57** — Row click silently switches tenant context and navigates to `/`. No confirmation dialog for context switching, which could be disorienting. The user clicks a row in the admin table, and their entire session context changes.
### 2.3 Loading States
All pages use the same loading pattern — centered `<Spinner />` in a fixed-height container:
```tsx
<div className="flex items-center justify-center h-64">
<Spinner />
</div>
```
**Issues:**
- Full-page auth loading screens (LoginPage, CallbackPage, ProtectedRoute, OrgResolver) use inline styles instead of Tailwind:
```tsx
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
```
This is inconsistent with the page components which use Tailwind classes.
- The `main.tsx` app bootstrap loading (line 59) also uses inline styles. Six files use this identical inline style pattern — it should be a shared component or consistent class.
- No `Skeleton` components are used anywhere, despite the DS providing `Skeleton`. For the dashboard and license pages which fetch data, skeletons would give better perceived performance than a generic spinner.
### 2.4 Error Handling
- **API client (`api/client.ts`):** Errors are thrown as generic `Error` objects. No toast notifications on failure.
- **LicensePage.tsx:63-69** — Shows `EmptyState` for `isError`. Good.
- **DashboardPage.tsx** — No error state handling at all. If `useTenant()` or `useLicense()` fails, the page renders with fallback `-` values silently. No `isError` check.
- **AdminTenantsPage.tsx** — No error state. If `useAllTenants()` fails, falls through to rendering the table with empty data.
- **OrgResolver.tsx:88-89** — On error, renders `null` (blank screen). The user sees nothing — no error message, no retry option, no redirect. This is the worst error UX in the app.
- No component imports or uses `useToast()` from the DS. Toasts are never shown for any operation.
### 2.5 Empty States
- **DashboardPage.tsx:57-63** — `EmptyState` for no tenant. Good.
- **LicensePage.tsx:54-60** — `EmptyState` for no tenant. Good.
- **LicensePage.tsx:63-69** — `EmptyState` for license fetch error. Good.
- **AdminTenantsPage.tsx** — **Missing.** No empty state when `tenants` array is empty. DataTable will render an empty table body.
---
## 3. Component Usage
### 3.1 DS Imports by File
| File | DS Components Imported |
|------|----------------------|
| `main.tsx` | ThemeProvider, ToastProvider, BreadcrumbProvider, GlobalFilterProvider, CommandPaletteProvider, Spinner |
| `Layout.tsx` | AppShell, Sidebar, TopBar |
| `DashboardPage.tsx` | Badge, Button, Card, EmptyState, KpiStrip, Spinner |
| `LicensePage.tsx` | Badge, Card, EmptyState, Spinner |
| `AdminTenantsPage.tsx` | Badge, Card, DataTable, Spinner + Column type |
| `LoginPage.tsx` | Spinner |
| `CallbackPage.tsx` | Spinner |
| `ProtectedRoute.tsx` | Spinner |
| `OrgResolver.tsx` | Spinner |
| `SignInPage.tsx` (sign-in) | Card, Input, Button, Alert, FormField |
### 3.2 Available but Unused DS Components
These DS components are relevant to the platform UI but unused:
| Component | Could be used for |
|-----------|------------------|
| `AlertDialog` / `ConfirmDialog` | Confirming tenant context switch in AdminTenantsPage |
| `CodeBlock` | License token display (currently raw HTML) |
| `Skeleton` | Loading states instead of spinner |
| `Tooltip` | Badge hover explanations, info about features |
| `StatusDot` | Tenant status indicators |
| `Breadcrumb` / `useBreadcrumb` | Page navigation context (currently empty `[]`) |
| `LoginForm` | Could replace the custom sign-in form (DS already has one) |
| `useToast` | Error/success notifications |
### 3.3 Raw HTML Where DS Components Exist
1. **LicensePage.tsx:166-170** — Raw `<button>` instead of `Button variant="ghost"`
2. **LicensePage.tsx:174-178** — Raw `<div><code>` instead of `CodeBlock`
3. **Layout.tsx:26-62** — Four inline SVG icon components instead of using `lucide-react` icons (the DS depends on lucide-react)
4. **DashboardPage.tsx:95-112** — Manual label/value list with `<div className="flex justify-between">` instead of using a DS pattern (the DS has no explicit key-value list component, so this is acceptable)
### 3.4 Styling Approach
- **Platform SPA pages:** Tailwind CSS utility classes (via class names like `space-y-6`, `p-6`, `flex`, `items-center`, etc.)
- **Sign-in page:** CSS modules (`SignInPage.module.css`) with DS CSS variables
- **Auth loading screens:** Inline `style={{}}` objects
- **No CSS modules** in the platform SPA at all (zero `.module.css` files in `ui/src/`)
This is a three-way inconsistency: Tailwind in pages, CSS modules in sign-in, inline styles in auth components.
---
## 4. Navigation
### 4.1 Sidebar
**File:** `ui/src/components/Layout.tsx:70-118`
The sidebar uses `Sidebar.Section` with `open={false}` and `{null}` children as a workaround to make sections act as navigation links (via `onToggle`). This is a semantic misuse — sections are designed as collapsible containers, not nav links.
```tsx
<Sidebar.Section
icon={<DashboardIcon />}
label="Dashboard"
open={false}
onToggle={() => navigate('/')}
>
{null}
</Sidebar.Section>
```
**Issues:**
- No `active` state is set on any section. The DS supports `active?: boolean` on `SidebarSectionProps` (line 988 of DS types), but it's never passed. The user has no visual indicator of which page they're on.
- `collapsed={false}` is hardcoded with `onCollapseToggle={() => {}}` — the sidebar cannot be collapsed. This is a no-op handler.
- Only three nav items: Dashboard, License, Platform (admin-only). Very sparse.
### 4.2 "Open Server Dashboard"
Two implementations, both identical:
1. **Sidebar footer** (`Layout.tsx:112-116`): `Sidebar.FooterLink` with `window.open('/server/', '_blank', 'noopener')`
2. **Dashboard page** (`DashboardPage.tsx:84`): Primary Button, same `window.open` call
3. **Dashboard page** (`DashboardPage.tsx:120-125`): Secondary Button in a Card, same `window.open` call
Three separate "Open Server Dashboard" triggers on the dashboard. The footer link is good; the two dashboard buttons are redundant.
### 4.3 Breadcrumbs
**File:** `Layout.tsx:124` — `<TopBar breadcrumb={[]} ... />`
Breadcrumbs are permanently empty. The DS provides `useBreadcrumb()` hook (exported, see line 1255 of DS types) that pages can call to set page-specific breadcrumbs, but none of the pages use it. The TopBar renders an empty breadcrumb area.
### 4.4 User Menu / Avatar
**File:** `Layout.tsx:125-126`
```tsx
<TopBar
user={username ? { name: username } : undefined}
onLogout={logout}
/>
```
The TopBar's `user` prop triggers a `Dropdown` with only a "Logout" option. The avatar is rendered by the DS using the `Avatar` component with the user's name.
**Issue:** When `username` is `null` (common if the Logto ID token doesn't have `username`, `name`, or `email` claims), no user indicator is shown at all — no avatar, no logout button. The user has no way to log out from the UI.
---
## 5. Header Bar
### 5.1 Shared TopBar with Server
The platform SPA and the server SPA both use the same `TopBar` component from `@cameleer/design-system`. This means they share identical header chrome.
### 5.2 Irrelevant Controls on Platform Pages
**Critical issue.** The `TopBar` component (DS source, lines 5569-5588 of `index.es.js`) **always** renders:
1. **Status filter pills** (Completed, Warning, Error, Running) — `ButtonGroup` with global filter status values
2. **Time range dropdown** — `TimeRangeDropdown` with presets like "Last 1h", "Last 24h"
3. **Auto-refresh toggle** — "AUTO" / "MANUAL" button
4. **Theme toggle** — Light/dark mode switch
5. **Command palette search** — "Search... Ctrl+K" button
These controls are hardcoded in the DS `TopBar` component. They read from `useGlobalFilters()` and operate on exchange status filters and time ranges — concepts that are **completely irrelevant** to the SaaS platform pages (Dashboard, License, Admin Tenants).
The platform wraps everything in `GlobalFilterProvider` (in `main.tsx:96`), which initializes the filter state, but nothing in the platform UI reads or uses these filters. They are dead UI elements that confuse users.
**Recommendation:** Either:
- The DS should make these controls optional/configurable on `TopBar`
- The platform should use a simpler header component
- The platform should not wrap in `GlobalFilterProvider` / `CommandPaletteProvider` (but this may cause runtime errors if TopBar assumes they exist)
---
## 6. Specific Issues
### 6.1 Label/Value Formatting — "Slugdefault" Concatenation Bug
**Not found in source code.** The source code properly formats label/value pairs with `flex justify-between` layout:
```tsx
// DashboardPage.tsx:96-99
<div className="flex justify-between text-white/80">
<span>Slug</span>
<span className="font-mono">{tenant?.slug ?? '-'}</span>
</div>
```
If "Slugdefault" concatenation is visible in the UI, it's a **rendering/CSS issue** rather than a template bug — the `flex justify-between` may collapse if the container is too narrow, or there may be a DS Card padding issue causing the spans to not separate. The code itself has proper separation.
Similarly for limits on the License page:
```tsx
// LicensePage.tsx:147-155
<span className="text-sm text-white/60">{label}</span>
<span className="text-sm font-mono text-white">{value !== undefined ? value : '—'}</span>
```
Labels and values are in separate `<span>` elements within `flex justify-between` containers. The code is correct.
### 6.2 Badge Colors
**Feature badges (LicensePage.tsx:130-133):**
```tsx
<Badge
label={enabled ? 'Enabled' : 'Not included'}
color={enabled ? 'success' : 'auto'}
/>
```
- Enabled features: `color="success"` (green) — appropriate
- Disabled features: `color="auto"` — this uses the DS's auto-color logic (hash-based). For a disabled/not-included state, `color="error"` or a neutral muted variant would be more appropriate to clearly communicate "not available."
**Tenant status badges (DashboardPage.tsx:102-105, AdminTenantsPage.tsx:24-29):**
```tsx
color={tenant?.status === 'ACTIVE' ? 'success' : 'warning'}
color={row.status === 'ACTIVE' ? 'success' : 'warning'}
```
- ACTIVE: green — appropriate
- Anything else (SUSPENDED, PENDING): yellow/warning — reasonable but SUSPENDED should arguably be `error` (red)
**Tier badges:** Use `tierColor()` function but it's defined differently in each file:
- `DashboardPage.tsx:12-18` maps: enterprise->success, pro->primary, starter->warning
- `LicensePage.tsx:25-33` maps: BUSINESS->success, HIGH->primary, MID->warning, LOW->error
These use **different tier names** (enterprise/pro/starter vs BUSINESS/HIGH/MID/LOW). One is for tenant tiers, the other for license tiers, but the inconsistency suggests either the data model has diverged or one mapping is stale.
### 6.3 Sign-In Page (`ui/sign-in/src/`)
**Positive findings:**
- Uses DS components: `Card`, `Input`, `Button`, `Alert`, `FormField`
- Uses CSS modules with DS CSS variables (`var(--bg-base)`, `var(--text-primary)`, etc.)
- Proper form with `aria-label="Sign in"`, `autoComplete` attributes
- Loading state on submit button via `loading` prop
- Error display via DS `Alert variant="error"`
- Creative rotating subtitle strings — good personality touch
**Issues:**
1. **No `ThemeProvider` wrapper** (`sign-in/src/main.tsx`):
```tsx
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
```
The sign-in page imports `@cameleer/design-system/style.css` which provides CSS variable defaults, so it works. But the theme toggle won't function, and if the DS ever requires `ThemeProvider` for initialization, this will break.
2. **No `ToastProvider`** — if any DS component internally uses `useToast()`, it will throw.
3. **Hardcoded branding** (`SignInPage.tsx:61`):
```tsx
cameleer3
```
The brand name is hardcoded text, not sourced from configuration.
4. **`React` import unused** (`SignInPage.tsx:1`): `useMemo` and `useState` are imported from `react` but the `import React` default import is absent, which is fine for React 19.
5. **No "forgot password" flow** — the form has username + password only. No recovery link. The DS `LoginForm` component supports `onForgotPassword` and `onSignUp` callbacks.
---
## 7. Architecture Observations
### 7.1 Provider Stack Over-provisioning
`main.tsx` wraps the app in:
```
ThemeProvider > ToastProvider > BreadcrumbProvider > GlobalFilterProvider > CommandPaletteProvider
```
`GlobalFilterProvider` and `CommandPaletteProvider` are server-dashboard concepts (exchange status filters, time range, search). They are unused by any platform page but are required because `TopBar` reads from them internally. This creates coupling between the server's observability UI concerns and the SaaS platform pages.
### 7.2 Route Guard Nesting
The route structure is:
```
ProtectedRoute > OrgResolver > Layout > (pages)
```
`OrgResolver` fetches `/api/me` and resolves tenant context. If it fails (`isError`), it renders `null` — a blank screen inside the Layout shell. This means the sidebar and TopBar render but the content area is completely empty with no explanation.
### 7.3 Unused Import
- `LicensePage.tsx:1` imports `React` and `useState` — `React` import is not needed with React 19's JSX transform, and `useState` is used so that's fine. But `React` as a namespace import isn't used.
### 7.4 DataTable Requires `id` Field
`AdminTenantsPage.tsx:67` passes `tenants` to `DataTable`. The DS type requires `T extends { id: string }`. The `TenantResponse` type has `id: string`, so this works, but the `createdAt` column (line 31) renders the raw ISO timestamp string without formatting — unlike DashboardPage which formats it with `toLocaleDateString()`.
---
## 8. Summary of Issues by Severity
### High Priority
| # | Issue | File(s) | Line(s) |
|---|-------|---------|---------|
| H1 | TopBar shows irrelevant status filters, time range, auto-refresh for platform pages | `Layout.tsx` / DS `TopBar` | 122-128 |
| H2 | OrgResolver error state renders blank screen (no error UI) | `OrgResolver.tsx` | 88-89 |
| H3 | Hardcoded `text-white` colors break light theme | All pages | Multiple |
### Medium Priority
| # | Issue | File(s) | Line(s) |
|---|-------|---------|---------|
| M1 | No active state on sidebar navigation items | `Layout.tsx` | 79-108 |
| M2 | Breadcrumbs permanently empty | `Layout.tsx` | 124 |
| M3 | DashboardPage has no error handling for failed API calls | `DashboardPage.tsx` | 23-26 |
| M4 | AdminTenantsPage missing empty state | `AdminTenantsPage.tsx` | 67-72 |
| M5 | AdminTenantsPage row click silently switches tenant context | `AdminTenantsPage.tsx` | 47-57 |
| M6 | Toasts never used despite ToastProvider being mounted | All pages | - |
| M7 | Raw `<button>` and `<code>` instead of DS components in LicensePage | `LicensePage.tsx` | 166-178 |
| M8 | AdminTenantsPage `createdAt` column renders raw ISO string | `AdminTenantsPage.tsx` | 31 |
| M9 | `tierColor()` defined twice with different tier mappings | `DashboardPage.tsx`, `LicensePage.tsx` | 12-18, 25-33 |
| M10 | "Not included" feature badge uses `color="auto"` instead of muted/neutral | `LicensePage.tsx` | 133 |
### Low Priority
| # | Issue | File(s) | Line(s) |
|---|-------|---------|---------|
| L1 | Three "Open Server Dashboard" buttons/links on dashboard | `Layout.tsx`, `DashboardPage.tsx` | 112-116, 81-87, 119-125 |
| L2 | Inconsistent loading style (inline styles vs Tailwind) | Auth files vs pages | Multiple |
| L3 | No Skeleton loading used (all Spinner) | All pages | - |
| L4 | Sidebar collapse disabled (no-op handler) | `Layout.tsx` | 71 |
| L5 | Sign-in page missing ThemeProvider wrapper | `sign-in/src/main.tsx` | 6-9 |
| L6 | Sign-in page has no forgot-password or sign-up link | `sign-in/src/SignInPage.tsx` | - |
| L7 | Custom SVG icons in Layout instead of lucide-react | `Layout.tsx` | 26-62 |
| L8 | Username null = no logout button visible | `Layout.tsx` | 125-126 |
| L9 | Page padding `p-6` repeated per-page instead of in Layout | All pages | - |

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