Files
familysync/.planning/phases/17-ui-optimization-polish/17-PATTERNS.md
T

837 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 154163
**Current contentStyle (lines 155163) — the gap to fill:**
```ts
// App.tsx lines 155163
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 463491 (the FAB block)
**Current FAB style (lines 468487) — the defect site:**
```ts
// CalendarShell.tsx lines 463-491
{phone && (
<button
aria-label="New Event"
onClick={() => setEventForm(true, 'create')}
style={{
position: 'fixed',
bottom: 'var(--space-6)', // ← DEFECT: 24px — behind the 56px BottomTabBar
right: 'var(--space-6)',
width: '56px',
height: '56px',
minWidth: '56px',
minHeight: '56px',
borderRadius: '50%',
background: 'var(--color-text-primary)',
color: '#ffffff',
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 4px 16px rgba(0,0,0,0.18)',
zIndex: 100,
fontFamily: 'var(--font-family-base)',
}}
>
<Plus size={24} aria-hidden="true" />
</button>
)}
```
**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 2325) — 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 2750) — 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 `<img>`
**Analog:** self, lines 2950 (the placeholder div to replace)
**Current placeholder (lines 2950):**
```tsx
{/* Phase 17 replaces this div with <img src="..." alt="" /> */}
<div
aria-hidden="true"
style={{
width: 'var(--brand-logo-size, 48px)',
height: 'var(--brand-logo-size, 48px)',
borderRadius: 'var(--brand-logo-border-radius, 50%)',
background: 'var(--brand-logo-bg, var(--color-member-0, #4a90d9))',
color: 'var(--brand-logo-text, #ffffff)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto var(--space-2, 8px)',
fontSize: 'var(--text-display-size, 24px)',
fontWeight: 600,
fontFamily: 'var(--font-family-base)',
flexShrink: 0,
aspectRatio: '1 / 1',
}}
>
FS
</div>
```
**After swap — keep same token surface, swap element:**
```tsx
<img
src="/logo.svg"
alt=""
aria-hidden="true"
style={{
width: 'var(--brand-logo-size, 48px)',
height: 'var(--brand-logo-size, 48px)',
borderRadius: 'var(--brand-logo-border-radius)',
margin: '0 auto var(--space-2, 8px)',
display: 'block',
aspectRatio: '1 / 1',
objectFit: 'contain',
flexShrink: 0,
}}
/>
```
**`--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
<meta name="theme-color" content="#4A90D9" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
```
**After Phase 17:**
```html
<meta name="theme-color" content="{CHECKPOINT_ACCENT_HEX}" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="FamilySync" />
```
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 3842
**Current defective icons array (lines 3842):**
```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 `<div role="dialog">` (lines 184202) — the centering defect site:**
```tsx
// SettingsSheet.tsx lines 184-202 — currently bottom-only positioning
<div
role="dialog"
aria-modal="true"
aria-label="Settings"
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
background: 'var(--color-surface-raised, #ffffff)',
borderRadius: '12px 12px 0 0',
boxShadow: '0 -4px 24px rgba(0,0,0,0.15)',
padding: 'var(--space-6, 24px)',
zIndex: 301,
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
maxWidth: '480px',
margin: '0 auto',
// ← Desktop renders this bottom-center (defect D-09)
}}
>
```
**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;
<div
role="dialog"
aria-modal="true"
aria-label="Settings"
style={
phone
? {
// Phone: unchanged bottom-sheet
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
background: 'var(--color-surface-raised, #ffffff)',
borderRadius: '12px 12px 0 0',
boxShadow: '0 -4px 24px rgba(0,0,0,0.15)',
padding: 'var(--space-6, 24px)',
zIndex: 301,
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
}
: {
// Desktop: centered modal
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
maxWidth: '480px',
width: 'calc(100% - var(--space-8, 32px))',
maxHeight: 'calc(100dvh - var(--space-8, 32px))',
overflowY: 'auto',
background: 'var(--color-surface-raised, #ffffff)',
borderRadius: '12px',
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
padding: 'var(--space-6, 24px)',
zIndex: 301,
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
}
}
>
```
**This exact phone/desktop pattern applies to ALL sheets:**
- `SettingsSheet` outer `<div role="dialog">` (lines 184202)
- `ChangePasswordSheet` outer `<div role="dialog">` (lines 598616)
- `LinkOidcSheet` outer `<div role="dialog">` (lines 894909)
- `AdminPage.ResetPasswordSheet` outer `<div role="dialog">` (lines 12621280)
- `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 */}
<div
style={{
height: '1px',
background: 'var(--color-border-subtle, var(--color-border))',
margin: 'var(--space-4, 16px) 0',
}}
/>
<button
type="button"
onClick={handleSignOut}
aria-label="Sign out"
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2, 8px)',
width: '100%',
minHeight: '44px',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 'var(--space-2, 8px) 0',
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 400,
color: 'var(--color-destructive, #dc2626)',
fontFamily: 'var(--font-family-base)',
textAlign: 'left',
}}
>
<LogOut size={16} aria-hidden="true" />
Sign out
</button>
```
**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 372376) — same style for logout separator:**
```tsx
<div
style={{
height: '1px',
background: 'var(--color-border-subtle, var(--color-border))',
margin: 'var(--space-4, 16px) 0',
}}
/>
```
---
### `apps/pwa/src/routes/AdminPage.tsx` — success toasts + two-tab navigation
**Analog:** self + `SyncStateToast.tsx`
**Existing `createMemberMutation.onSuccess` (lines 213221) — 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 12301232) — 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<string | null>(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 159189:**
```tsx
// SyncStateToast.tsx lines 159-189 — extract the wrapper div pattern
{toast && (
<div
role="status"
aria-live="polite"
aria-atomic="true"
style={{
position: 'fixed',
bottom: phone
? 'calc(var(--bottom-chrome-h) + var(--space-4))'
: 'var(--space-6)',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 300,
background: 'var(--color-surface-raised, #ffffff)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--space-2, 8px)',
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2, 8px)',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 'var(--text-label-weight, 400)',
lineHeight: 'var(--text-label-line-height, 1.4)',
fontFamily: 'var(--font-family-base)',
color: 'var(--color-text-primary)',
whiteSpace: 'nowrap',
maxWidth: '90vw',
}}
>
<CheckCircle size={16} aria-hidden="true" style={{ color: 'var(--color-member-0)', flexShrink: 0 }} />
<span>{toast}</span>
</div>
)}
```
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 `<h1>Admin Settings</h1>`):**
```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
<div
role="tablist"
style={{
display: 'flex',
borderBottom: '1px solid var(--color-border-subtle, var(--color-border))',
marginBottom: 'var(--space-6, 24px)',
}}
>
{(['members', 'settings'] as const).map((id) => (
<button
key={id}
role="tab"
id={`admin-tab-${id}`}
aria-selected={activeTab === id}
aria-controls={`admin-panel-${id}`}
tabIndex={activeTab === id ? 0 : -1}
onClick={() => setActiveTab(id)}
onKeyDown={(e) => handleTabKeyDown(e, id)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
minHeight: '44px',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: activeTab === id ? 600 : 400,
color: activeTab === id ? 'var(--color-text-primary)' : 'var(--color-text-secondary)',
borderBottom: activeTab === id ? '2px solid var(--color-member-0)' : '2px solid transparent',
transition: 'color 0.1s ease, border-color 0.1s ease',
fontFamily: 'var(--font-family-base)',
}}
>
{id === 'members' ? 'Members & Accounts' : 'Settings'}
</button>
))}
</div>
{/* Tab panels */}
<div
role="tabpanel"
id="admin-panel-members"
aria-labelledby="admin-tab-members"
tabIndex={0}
hidden={activeTab !== 'members'}
>
{/* MEMBERS section + LOCAL ACCOUNTS section */}
</div>
<div
role="tabpanel"
id="admin-panel-settings"
aria-labelledby="admin-tab-settings"
tabIndex={0}
hidden={activeTab !== 'settings'}
>
{/* SHARED CALENDAR section + TIMEZONE section */}
</div>
```
**Existing `sectionLabelStyle` (lines 4451) — 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 3060 (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 127):**
```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 2325; `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 170202 (backdrop + dialog wrapper)
**Apply to:** All sheets that need desktop-centering fix
```tsx
{/* Backdrop — unchanged across all sheets */}
<div
onClick={onClose}
aria-hidden="true"
style={{
position: 'fixed',
inset: 0,
background: 'var(--color-overlay, rgba(0,0,0,0.32))',
zIndex: 300, // or appropriate z-index per stacking context
}}
/>
{/* Sheet outer wrapper — gains phone/desktop branch */}
<div
role="dialog"
aria-modal="true"
aria-label="..."
style={phone ? PHONE_BOTTOM_SHEET_STYLE : DESKTOP_CENTERED_STYLE}
>
```
### Section Divider Pattern
**Source:** `apps/pwa/src/components/SettingsSheet.tsx` lines 372376
**Apply to:** Logout row separator in SettingsSheet
```tsx
<div
style={{
height: '1px',
background: 'var(--color-border-subtle, var(--color-border))',
margin: 'var(--space-4, 16px) 0',
}}
/>
```
### Mutation + Toast Pattern
**Source:** `apps/pwa/src/routes/AdminPage.tsx` lines 197235 (`createMemberMutation`), `apps/pwa/src/components/SyncStateToast.tsx` lines 7279 (auto-dismiss), lines 159189 (toast render)
**Apply to:** AdminPage create-member and reset-password success feedback
Auto-dismiss pattern from SyncStateToast (lines 7279):
```ts
useEffect(() => {
if (status !== 'done') return; // adapt: if (!toast) return;
const timer = setTimeout(() => {
setLastSyncedUid(null); // adapt: setToast(null)
}, 2000); // use 3000ms for admin toasts per UI-SPEC
return () => clearTimeout(timer);
}, [status, setLastSyncedUid]);
```
### Accessible Button Row Pattern
**Source:** `apps/pwa/src/components/SettingsSheet.tsx` lines 390410 (Change password button row)
**Apply to:** Logout button in SettingsSheet
```tsx
<button
type="button"
onClick={...}
style={{
display: 'flex',
alignItems: 'center',
width: '100%',
minHeight: '44px', // WCAG tap target
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 'var(--space-2, 8px) 0',
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 400,
color: 'var(--color-text-primary, #111318)', // logout: var(--color-destructive)
fontFamily: 'var(--font-family-base)',
textAlign: 'left',
}}
>
...label
</button>
```
---
## No Analog Found
| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| `apps/pwa/public/logo.svg` | asset | transform | New AI-generated SVG logo — no existing brand mark in codebase |
| `apps/pwa/public/favicon.svg`, `favicon.ico`, `icon-maskable-512.png` | asset | transform | New generated assets — none exist in `public/` yet |
| `apps/pwa/pwa-assets.config.ts` | config | transform | New `@vite-pwa/assets-generator` config — no prior asset-generation config in repo |
---
## Critical Pitfalls (for Planner)
1. **Maskable icon must be a separate file.** `icon-maskable-512.png` is a distinct generated asset with safe-zone padding — the defect is reusing `icon-512.png` for the maskable purpose. Fix by generating `icon-maskable-512.png` via `@vite-pwa/assets-generator`.
2. **`paddingBottom` in `contentStyle` is phone-only.** The `...(phone ? {...} : {})` spread pattern (from outerStyle at App.tsx lines 144152) ensures desktop gets no extra bottom padding.
3. **`--sx-color-*` overrides must stay inside the combined `tokens.css` rule block.** Do not split them to a separate selector — they must override the `@schedule-x/theme-default` values by staying in the same specificity context.
4. **`fetchLocalLogout` error must not prevent navigation.** Wrap in try/catch and call `navigate('/login')` in both branches — fire-and-best-effort semantics per UI-SPEC §D-07.
5. **`--brand-logo-border-radius` update required after logo approval.** The current `50%` value (circle) clips an SVG logo that draws its own shape. Update to `12px` (warm/rounded) or `0` (if SVG has own border-radius) at the checkpoint — before wiring.
6. **Admin tab state is not URL-persisted.** Tab state is `useState` only — navigating away and back resets to Tab 1 ("Members & Accounts"). This is intentional per UI-SPEC §D-10.
---
## Metadata
**Analog search scope:** `apps/pwa/src/components/`, `apps/pwa/src/routes/`, `apps/pwa/src/api/`, `apps/pwa/e2e/`, `apps/pwa/`
**Files read:** 10 source files
**Pattern extraction date:** 2026-06-18