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

27 KiB
Raw Blame History

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:

// 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):

// 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 463491 (the FAB block)

Current FAB style (lines 468487) — 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 2325) — same function, confirmed project-wide:

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:

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 2950 (the placeholder div to replace)

Current placeholder (lines 2950):

{/* 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).


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 3842

Current defective icons array (lines 3842):

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 184202) — 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:

  • 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):

{/* 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 372376) — 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 213221) — 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 12301232) — 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 159189:

// 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 4451) — 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 3060 (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 127):

/**
 * 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

// 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

{/* 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

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

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

<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