Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
27 KiB
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:
// 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):
// 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):
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:
// 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:
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:
// 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:
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:
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:
:root {
/* all tokens */
}
After restructure — combined selector, no value changes:
: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 29–50 (the placeholder div to replace)
Current placeholder (lines 29–50):
{/* 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:
<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):
<meta name="theme-color" content="#4A90D9" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
After Phase 17:
<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 38–42
Current defective icons array (lines 38–42):
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:
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 184–202) — the centering defect site:
// 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:
// 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:
SettingsSheetouter<div role="dialog">(lines 184–202)ChangePasswordSheetouter<div role="dialog">(lines 598–616)LinkOidcSheetouter<div role="dialog">(lines 894–909)AdminPage.ResetPasswordSheetouter<div role="dialog">(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):
{/* 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):
// 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:
<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 213–221) — hook point for toast:
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:
onSuccess: () => {
handleClose();
// ← ADD: setToast('Password reset.') — must propagate up from ResetPasswordSheet
},
Toast state pattern — inline in AdminPage (no new component needed; single use):
// 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 159–189:
// 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>):
// 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 44–51) — reuse unchanged inside tab panels:
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):
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:
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):
/**
* 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
// 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
{/* 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 372–376
Apply to: Logout row separator in SettingsSheet
<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 197–235 (createMemberMutation), apps/pwa/src/components/SyncStateToast.tsx lines 72–79 (auto-dismiss), lines 159–189 (toast render)
Apply to: AdminPage create-member and reset-password success feedback
Auto-dismiss pattern from SyncStateToast (lines 72–79):
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 390–410 (Change password button row)
Apply to: Logout button in SettingsSheet
<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)
-
Maskable icon must be a separate file.
icon-maskable-512.pngis a distinct generated asset with safe-zone padding — the defect is reusingicon-512.pngfor the maskable purpose. Fix by generatingicon-maskable-512.pngvia@vite-pwa/assets-generator. -
paddingBottomincontentStyleis phone-only. The...(phone ? {...} : {})spread pattern (from outerStyle at App.tsx lines 144–152) ensures desktop gets no extra bottom padding. -
--sx-color-*overrides must stay inside the combinedtokens.cssrule block. Do not split them to a separate selector — they must override the@schedule-x/theme-defaultvalues by staying in the same specificity context. -
fetchLocalLogouterror must not prevent navigation. Wrap in try/catch and callnavigate('/login')in both branches — fire-and-best-effort semantics per UI-SPEC §D-07. -
--brand-logo-border-radiusupdate required after logo approval. The current50%value (circle) clips an SVG logo that draws its own shape. Update to12px(warm/rounded) or0(if SVG has own border-radius) at the checkpoint — before wiring. -
Admin tab state is not URL-persisted. Tab state is
useStateonly — 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