diff --git a/.planning/phases/17-ui-optimization-polish/17-PATTERNS.md b/.planning/phases/17-ui-optimization-polish/17-PATTERNS.md new file mode 100644 index 0000000..67c873c --- /dev/null +++ b/.planning/phases/17-ui-optimization-polish/17-PATTERNS.md @@ -0,0 +1,836 @@ +# 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 */} +