# Phase 17: UI Optimization & Polish — Pattern Map **Mapped:** 2026-06-18 **Files analyzed:** 13 new/modified files **Analogs found:** 13 / 13 --- ## File Classification | New/Modified File | Role | Data Flow | Closest Analog | Match Quality | |-------------------|------|-----------|----------------|---------------| | `apps/pwa/src/App.tsx` | component (shell) | request-response | self (modify existing) | self | | `apps/pwa/src/components/CalendarShell.tsx` | component | event-driven | self (modify FAB block lines 463-491) | self | | `apps/pwa/src/components/BottomTabBar.tsx` | component | event-driven | self (optional token ref) | self | | `apps/pwa/src/styles/tokens.css` | config | transform | self (selector restructure only) | self | | `apps/pwa/src/components/BrandSlot.tsx` | component | transform | self (swap div→img) | self | | `apps/pwa/index.html` | config | transform | self (add icon links) | self | | `apps/pwa/vite.config.ts` | config | transform | self (update icons[]) | self | | `apps/pwa/src/components/SettingsSheet.tsx` | component | request-response | self (add logout row + desktop centering) | self | | `apps/pwa/src/routes/AdminPage.tsx` | route/component | CRUD | self (add tabs + success toasts) | self | | `apps/pwa/src/components/ChangePasswordSheet.tsx` (inside SettingsSheet.tsx) | component | request-response | `SettingsSheet.tsx` `ChangePasswordSheet` (lines 583-815) | exact | | `apps/pwa/src/components/LinkOidcSheet.tsx` (inside SettingsSheet.tsx) | component | request-response | `SettingsSheet.tsx` `LinkOidcSheet` (lines 876-1025) | exact | | `apps/pwa/e2e/layout.spec.ts` | test | request-response | self (add overlap assertion) | self | | `apps/pwa/e2e/admin.spec.ts` | test | request-response | `apps/pwa/e2e/layout.spec.ts` | role-match | | `apps/pwa/public/logo.svg` (+ generated icon set) | asset | transform | none (new AI-generated asset) | none | | `apps/pwa/pwa-assets.config.ts` | config | transform | none (new build-time config) | none | --- ## Pattern Assignments ### `apps/pwa/src/App.tsx` — add `paddingBottom` to phone `contentStyle` **Analog:** self, lines 154–163 **Current contentStyle (lines 155–163) — the gap to fill:** ```ts // App.tsx lines 155–163 const contentStyle: React.CSSProperties = { flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', position: 'relative', // ← paddingBottom is ABSENT — this is the defect site }; ``` **Phone branch pattern (isPhone() defined at line 64):** ```ts // App.tsx lines 64, 84 — the phone boolean already exists function isPhone(): boolean { return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches; } // ... const phone = isPhone(); // line 84 ``` **After fix — spread operator pattern (matches existing outerStyle pattern at lines 144-152):** ```ts const contentStyle: React.CSSProperties = { flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', position: 'relative', // Phone-only: reserve space for the fixed BottomTabBar ...(phone ? { paddingBottom: 'var(--bottom-chrome-h)' } : {}), }; ``` --- ### `apps/pwa/src/components/CalendarShell.tsx` — fix FAB `bottom` offset **Analog:** self, lines 463–491 (the FAB block) **Current FAB style (lines 468–487) — the defect site:** ```ts // CalendarShell.tsx lines 463-491 {phone && ( )} ``` **After fix — change only the `bottom` line:** ```ts bottom: 'calc(var(--bottom-chrome-h) + var(--space-6))', // FAB sits var(--space-6) (24px) above the BottomTabBar top edge ``` --- ### `apps/pwa/src/components/BottomTabBar.tsx` — optional token reference **Analog:** self, line 73 **Current inline height (line 73) — unchanged but optionally can reference token:** ```ts // BottomTabBar.tsx line 73 height: 'calc(56px + env(safe-area-inset-bottom, 0px))', // This resolves identically to var(--bottom-chrome-h) — token reference is optional ``` **isPhone() pattern (lines 23–25) — same function, confirmed project-wide:** ```ts function isPhone(): boolean { return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches; } ``` **Tab active/inactive style pattern (lines 27–50) — for admin two-tab analog:** ```ts const tabBase: React.CSSProperties = { flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '3px', textDecoration: 'none', fontSize: 'var(--text-label-size, 13px)', fontWeight: 400, lineHeight: 'var(--text-label-line-height, 1.4)', fontFamily: 'var(--font-family-base)', color: 'var(--color-text-muted)', minHeight: '44px', borderBottom: '2px solid transparent', transition: 'color 0.1s ease, border-color 0.1s ease', }; const tabActiveOverride: React.CSSProperties = { color: 'var(--color-member-0)', borderBottom: '2px solid var(--color-member-0)', }; ``` --- ### `apps/pwa/src/styles/tokens.css` — selector restructure + add `--bottom-chrome-h` **Analog:** self (selector-only change) **Current structure:** ```css :root { /* all tokens */ } ``` **After restructure — combined selector, no value changes:** ```css :root, [data-theme="light"] { /* All existing :root declarations move here verbatim */ /* Add new token at the top of the spacing group: */ --bottom-chrome-h: calc(56px + env(safe-area-inset-bottom, 0px)); /* ...all existing tokens unchanged... */ /* Schedule-X overrides stay INSIDE this same rule block (critical — see pitfall 3) */ --sx-color-primary: var(--color-member-0); /* etc. */ } /* Dark theme stub — values intentionally absent (Phase 17 groundwork only). Phase 999.20 fills these values and wires prefers-color-scheme. */ /* [data-theme="dark"] { ... } */ ``` **Key constraint:** `--sx-color-*` overrides must remain inside the same combined rule block — do NOT split into a separate selector. --- ### `apps/pwa/src/components/BrandSlot.tsx` — swap placeholder div for `` **Analog:** self, lines 29–50 (the placeholder div to replace) **Current placeholder (lines 29–50):** ```tsx {/* Phase 17 replaces this div with */} ``` **After swap — keep same token surface, swap element:** ```tsx ``` **`--brand-logo-bg` is no longer applied** (no background div). Update `--brand-logo-border-radius` in tokens.css from `50%` to the checkpoint-determined value (likely `12px` for warm/rounded brief or `0` if the SVG draws its own shape). --- ### `apps/pwa/index.html` — add favicon links **Analog:** self (additive changes only) **Current state (one apple-touch-icon link, no favicon links):** ```html ``` **After Phase 17:** ```html ``` Order matters: SVG first (modern browsers), ICO second (legacy fallback). `{CHECKPOINT_ACCENT_HEX}` = `#4A90D9` (default) or warm variant pending checkpoint. --- ### `apps/pwa/vite.config.ts` — fix maskable icon + add `icon-maskable-512.png` **Analog:** self, lines 38–42 **Current defective icons array (lines 38–42):** ```ts icons: [ { src: '/icon-192.png', sizes: '192x192', type: 'image/png' }, { src: '/icon-512.png', sizes: '512x512', type: 'image/png' }, { src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, // ← DEFECT: same file ], ``` **After fix — separate maskable file:** ```ts icons: [ { src: '/icon-192.png', sizes: '192x192', type: 'image/png' }, { src: '/icon-512.png', sizes: '512x512', type: 'image/png' }, { src: '/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, ], ``` Also update `theme_color` (line 33) to match checkpoint accent: `'#4A90D9'` default. --- ### `apps/pwa/src/components/SettingsSheet.tsx` — add logout row + desktop centering **Analog:** self **Current sheet outer `
` (lines 184–202) — the centering defect site:** ```tsx // SettingsSheet.tsx lines 184-202 — currently bottom-only positioning
``` **After fix — phone/desktop style branch:** ```tsx // isPhone() from App.tsx pattern (same function defined identically in BottomTabBar.tsx) const phone = window.matchMedia('(max-width: 767px)').matches;
``` **This exact phone/desktop pattern applies to ALL sheets:** - `SettingsSheet` outer `
` (lines 184–202) - `ChangePasswordSheet` outer `
` (lines 598–616) - `LinkOidcSheet` outer `
` (lines 894–909) - `AdminPage.ResetPasswordSheet` outer `
` (lines 1262–1280) - `CredentialSheet` (separate file — apply same branch) **Logout row pattern — add after the Account section (after line 437 close tag, before the backdrop close):** ```tsx {/* Sign out — bottom of sheet, after all other sections */}
``` **handleSignOut function — try/catch with navigate in both branches (pitfall 4):** ```ts // Import: import { LogOut } from 'lucide-react'; (add to existing X, Bell, AlertCircle, Loader2) // Import: import { fetchLocalLogout } from '../api/client.js'; (add to existing fetchMe, etc.) // Import: import { useNavigate } from 'react-router'; (or use window.location.replace) async function handleSignOut() { try { await fetchLocalLogout(); } catch { // Fire-and-best-effort: navigate regardless of whether the API call succeeded } // Always navigate — server-side cookie was cleared (or already expired) navigate('/login'); } ``` **Existing section divider pattern analog (lines 372–376) — same style for logout separator:** ```tsx
``` --- ### `apps/pwa/src/routes/AdminPage.tsx` — success toasts + two-tab navigation **Analog:** self + `SyncStateToast.tsx` **Existing `createMemberMutation.onSuccess` (lines 213–221) — hook point for toast:** ```ts onSuccess: () => { // Clear form + refresh member list setCreateDisplayName(''); setCreateUsername(''); setCreatePassword(''); setCreateConfirmPassword(''); setCreateError(null); void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] }); void queryClient.invalidateQueries({ queryKey: ['me'] }); // ← ADD: setToast('Member added.') }, ``` **Existing `resetMutation.onSuccess` (lines 1230–1232) — hook point for toast:** ```ts onSuccess: () => { handleClose(); // ← ADD: setToast('Password reset.') — must propagate up from ResetPasswordSheet }, ``` **Toast state pattern — inline in AdminPage (no new component needed; single use):** ```ts // Local state — add at top of AdminPage() const [toast, setToast] = useState(null); // Auto-dismiss after 3 seconds — same useEffect pattern as SyncStateToast lines 72-79 useEffect(() => { if (!toast) return; const timer = setTimeout(() => setToast(null), 3000); return () => clearTimeout(timer); }, [toast]); ``` **Toast render — copy visual structure from SyncStateToast.tsx lines 159–189:** ```tsx // SyncStateToast.tsx lines 159-189 — extract the wrapper div pattern {toast && (
)} ``` Note: `CheckCircle` is already imported in AdminPage.tsx line 28. `phone` constant needs adding (use `isPhone()` or inline `window.matchMedia`). **Two-tab strip pattern — add above MEMBERS section (after `

Admin Settings

`):** ```tsx // Local state — add at top of AdminPage() const [activeTab, setActiveTab] = useState<'members' | 'settings'>('members'); // Keyboard nav for roving tabindex function handleTabKeyDown(e: React.KeyboardEvent, current: 'members' | 'settings') { if (e.key === 'ArrowRight') { e.preventDefault(); const next = current === 'members' ? 'settings' : 'members'; setActiveTab(next); (e.currentTarget.parentElement?.querySelector(`[id="admin-tab-${next}"]`) as HTMLElement)?.focus(); } else if (e.key === 'ArrowLeft') { e.preventDefault(); const prev = current === 'settings' ? 'members' : 'settings'; setActiveTab(prev); (e.currentTarget.parentElement?.querySelector(`[id="admin-tab-${prev}"]`) as HTMLElement)?.focus(); } } // Tab strip render
{(['members', 'settings'] as const).map((id) => ( ))}
{/* Tab panels */} ``` **Existing `sectionLabelStyle` (lines 44–51) — reuse unchanged inside tab panels:** ```ts const sectionLabelStyle: React.CSSProperties = { fontSize: 'var(--text-label-size, 13px)', fontWeight: 600, color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 'var(--space-2, 8px)', }; ``` --- ### `apps/pwa/e2e/layout.spec.ts` — add FAB/BottomTabBar overlap assertion **Analog:** self, lines 30–60 (existing test structure pattern) **Existing test structure to follow (lines 31-60):** ```ts test.describe('Rule 1/3/4 — BottomTabBar tap targets and in-viewport position', () => { test.beforeEach(async ({ page }) => { await page.goto('/calendar'); }); test('Calendar tab meets 44×44px touch-target minimum (Rule 1)', async ({ page }) => { const nav = page.getByRole('navigation', { name: 'Main navigation' }); const calTab = nav.getByRole('link', { name: 'Calendar' }); const box = await calTab.boundingBox(); expect(box, 'Calendar tab bounding box must not be null').not.toBeNull(); expect(box!.width, 'Calendar tab width ≥ 44px').toBeGreaterThanOrEqual(44); expect(box!.height, 'Calendar tab height ≥ 44px').toBeGreaterThanOrEqual(44); }); ``` **New overlap assertion to add:** ```ts test('New Event FAB does not overlap BottomTabBar (A — phone only)', async ({ page }, testInfo) => { test.skip(testInfo.project.name === 'desktop', 'Phone-only assertion'); await page.goto('/calendar'); const fab = page.getByRole('button', { name: 'New Event' }); const nav = page.getByRole('navigation', { name: 'Main navigation' }); const fabBox = await fab.boundingBox(); const navBox = await nav.boundingBox(); expect(fabBox, 'FAB bounding box must not be null').not.toBeNull(); expect(navBox, 'BottomTabBar bounding box must not be null').not.toBeNull(); // FAB bottom edge must be at or above the BottomTabBar top edge expect(fabBox!.y + fabBox!.height).toBeLessThanOrEqual(navBox!.y); }); ``` --- ### `apps/pwa/e2e/admin.spec.ts` (new file) **Analog:** `apps/pwa/e2e/layout.spec.ts` — copy file header pattern, test.describe structure, page.goto pattern **File header + structure pattern (layout.spec.ts lines 1–27):** ```ts /** * admin.spec.ts — TEST-XX * * Admin page UI assertions: two-tab ARIA pattern, success toasts, sheet centering. * ... * * Runs on all three device profiles unless skipped via testInfo.project.name. */ import { test, expect } from '@playwright/test'; test.describe('Admin tab strip — ARIA tabs pattern (D-10)', () => { test.beforeEach(async ({ page }) => { // Note: requires an admin session — playwright.config.ts storageState for admin await page.goto('/admin'); }); test('Tab strip has correct ARIA roles', async ({ page }) => { await expect(page.getByRole('tablist')).toBeVisible(); await expect(page.getByRole('tab', { name: 'Members & Accounts' })).toBeVisible(); await expect(page.getByRole('tab', { name: 'Settings' })).toBeVisible(); }); test('ArrowRight switches to Settings tab', async ({ page }) => { const membersTab = page.getByRole('tab', { name: 'Members & Accounts' }); await membersTab.focus(); await page.keyboard.press('ArrowRight'); await expect(page.getByRole('tab', { name: 'Settings' })).toHaveAttribute('aria-selected', 'true'); }); }); ``` --- ## Shared Patterns ### Phone/Desktop Breakpoint **Source:** `apps/pwa/src/components/BottomTabBar.tsx` lines 23–25; `apps/pwa/src/App.tsx` line 64 **Apply to:** All sheet centering fixes (SettingsSheet, ChangePasswordSheet, LinkOidcSheet, ResetPasswordSheet, CredentialSheet), admin toast positioning, admin tab strip phone layout check ```ts // Identical function defined in BottomTabBar.tsx, App.tsx, CalendarShell.tsx function isPhone(): boolean { return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches; } // Or inline: window.matchMedia('(max-width: 767px)').matches ``` ### Sheet/Dialog Pattern **Source:** `apps/pwa/src/components/SettingsSheet.tsx` lines 170–202 (backdrop + dialog wrapper) **Apply to:** All sheets that need desktop-centering fix ```tsx {/* Backdrop — unchanged across all sheets */}