docs(17): add phase verification report (10/10 decisions delivered; human_needed for device-only checks)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
9730d3dcdb
commit
fcc02e3833
@@ -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 && (
|
||||
<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 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 `<img>`
|
||||
|
||||
**Analog:** self, lines 29–50 (the placeholder div to replace)
|
||||
|
||||
**Current placeholder (lines 29–50):**
|
||||
```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 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 `<div role="dialog">` (lines 184–202) — 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 184–202)
|
||||
- `ChangePasswordSheet` outer `<div role="dialog">` (lines 598–616)
|
||||
- `LinkOidcSheet` outer `<div role="dialog">` (lines 894–909)
|
||||
- `AdminPage.ResetPasswordSheet` outer `<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):**
|
||||
```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 372–376) — 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 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<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:**
|
||||
```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 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 */}
|
||||
<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
|
||||
|
||||
```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 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):
|
||||
```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 390–410 (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 144–152) 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
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
phase: 17-ui-optimization-polish
|
||||
verified: 2026-06-18T00:00:00Z
|
||||
status: human_needed
|
||||
score: 10/10 decisions delivered in code (1 WARNING-class resize limitation, 4 device-only checks)
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
human_verification:
|
||||
- test: "On a phone (≤767px, e.g. iPhone 14 / Pixel 7) load /calendar and /lists. Confirm the New Event FAB sits fully above the BottomTabBar and the color-legend chips are not clipped behind the bar."
|
||||
expected: "FAB floats above the tab bar (not on the Admin tab); legend chips fully visible; CI overlap guard (layout.spec.ts) corroborates geometry on iphone/pixel profiles."
|
||||
why_human: "Pixel-accurate fixed-chrome overlap + safe-area-inset rendering on a real notched device cannot be fully judged from CSS source; CI guard runs headless WebKit/Chromium only."
|
||||
- test: "Resize a desktop browser window narrower than 768px (or rotate a tablet/foldable across the 767px breakpoint) with the Settings sheet, a credential sheet, or the admin reset sheet OPEN."
|
||||
expected: "Sheet should switch between centered-modal (desktop) and bottom-sheet (phone) geometry. NOTE: 17-REVIEW WR-01 documents that the `phone` snapshot is computed once per render with no matchMedia listener, so the sheet keeps stale geometry until an unrelated re-render. Confirm severity for this household's actual devices."
|
||||
why_human: "Resize-crossing-breakpoint re-render behavior is a runtime interaction; the static branches are correct at mount but do not re-evaluate. Operator decides if this WARNING blocks (real phones are always phones; impact is tablets/foldables/desktop-resize)."
|
||||
- test: "Install the PWA to a device home screen and inspect the app icon (especially Android adaptive/maskable rendering and iOS apple-touch-icon)."
|
||||
expected: "Maskable icon (icon-maskable-512.png) shows the family-house logo inside the safe zone with no clipping; favicon shows in browser tab; apple-touch-icon shows on iOS home screen."
|
||||
why_human: "Maskable safe-zone correctness and home-screen icon rendering are device/installer-specific (iOS-Safari standalone is device-only per CLAUDE.md); cannot be driven headless."
|
||||
- test: "Sign in (local auth, non-dev-bypass) and open Settings; tap 'Sign out'."
|
||||
expected: "Session is cleared and the app routes to /login; signing back in works. (Endpoint + client wiring verified in code; live round-trip confirms cookie clearing end-to-end.)"
|
||||
why_human: "Full logout round-trip (cookie cleared server-side + redirect) is a runtime/auth flow; code path is verified but live confirmation is prudent."
|
||||
---
|
||||
|
||||
# Phase 17: UI Optimization & Polish Verification Report
|
||||
|
||||
**Phase Goal:** A visual-identity & polish pass for the PWA spanning three workstreams — (A) phone-layout polish (no fixed-chrome overlap), (B) branding assets (real logo + complete icon set), (C) theme-token groundwork — plus (D) UAT-surfaced UI fixes (logout, admin toasts, sheet centering, admin nav rework).
|
||||
**Verified:** 2026-06-18
|
||||
**Status:** human_needed
|
||||
**Re-verification:** No — initial verification
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
Decisions D-01..D-10 stand in for REQ-IDs (no Success Criteria array in ROADMAP; goal is prose + decisions). All 10 decisions have corresponding, substantive, wired code in the codebase. The status is `human_needed` (not `passed`) because device-only checks (phone overlap on real hardware, PWA icon install, logout round-trip) and one WARNING-class resize limitation (17-REVIEW WR-01) require operator confirmation.
|
||||
|
||||
### Observable Truths (by Decision)
|
||||
|
||||
| # | Decision / Truth | Status | Evidence |
|
||||
| ---- | ---------------- | ------ | -------- |
|
||||
| D-01 | Phone FAB no longer overlaps BottomTabBar; legend not occluded | ✓ VERIFIED | FAB `bottom: calc(var(--bottom-chrome-h) + var(--space-6))` (CalendarShell.tsx:470); phone `paddingBottom: var(--bottom-chrome-h)` (App.tsx:164); `--bottom-chrome-h: calc(56px + env(safe-area-inset-bottom,0px))` (tokens.css:69). Matches ROADMAP fix sketch exactly. |
|
||||
| D-02 | Permanent overlap regression guard in CI | ✓ VERIFIED | layout.spec.ts:209-231 "New Event FAB does not overlap BottomTabBar" — asserts `fabBox.bottom ≤ navBox.top`, phone-only (iphone/pixel), skipped on desktop. Shared-token approach chosen (the planner's default lean). |
|
||||
| D-03 | Real FamilySync logo (warm/rounded/at-home) committed as SVG; operator-approved | ✓ VERIFIED | logo.svg (warm peach gradient bg, rounded family-house mark, 512 viewBox). Accent #e8915a + `--brand-logo-border-radius: 0` documented as operator-approved (tokens.css:41,106). |
|
||||
| D-04 | Complete 7-asset icon set; proper separate maskable | ✓ VERIFIED | All 7 files present in public/ (logo.svg, favicon.svg, favicon.ico, icon-192/512, icon-maskable-512, apple-touch-icon) at real sizes (not stubs). Maskable is its own 8627B file, distinct from icon-512 (12056B). Manifest references it with `purpose: 'maskable'` (vite.config.ts:41). |
|
||||
| D-05 | Logo wired into BrandSlot, decorative, no LoginPage layout change | ✓ VERIFIED | BrandSlot.tsx swaps placeholder div for `<img src="/logo.svg" alt="" aria-hidden="true">`; `<h1>FamilySync</h1>` retained as page title; tokens drive size/radius. Seam contract honored. |
|
||||
| D-06 | tokens.css restructured to themeable layer; Schedule-X overrides intact; no new hardcoded colors | ⚠️ PARTIAL | Combined `:root, [data-theme="light"]` selector (tokens.css:16-17); `--sx-color-*` overrides remain inside the block after the theme-default import (tokens.css:127+). Groundwork-only (no dark palette/toggle) per scope. **Caveat:** sheet-centering work (17-05/06) added hardcoded px literals (`12px`, `480px`, boxShadow rgba) in component files — but these match pre-existing patterns and the color invariant (hex in tokens.css) holds. |
|
||||
| D-07 | Reachable Sign out clears session + routes to /login; best-effort on API failure | ✓ VERIFIED | SettingsSheet.tsx:522-554 always-visible "Sign out" control → `handleSignOut` (137-146): try `fetchLocalLogout()`, catch swallows error, then unconditional `navigate('/login')`. |
|
||||
| D-08 | Admin create-member + reset-password success toasts, ~3s auto-dismiss | ✓ VERIFIED | AdminPage.tsx: toast state + 3000ms `setTimeout` auto-dismiss (62-69); "Member added." (261), "Password reset." (1090); `role="status"` live region (1030). |
|
||||
| D-09 | Dialogs/sheets centered on desktop, bottom-sheet on phone | ⚠️ PARTIAL | SettingsSheet (204-233), CredentialSheet (178+), AdminPage reset sheet (1419-1443) all have phone (`bottom:0`) vs desktop (`translate(-50%,-50%)`, maxWidth 480px) branches. **Caveat:** branch is a one-shot render snapshot (17-REVIEW WR-01) — does not re-evaluate on resize across 767px. Correct for real phones; stale on tablet/foldable/desktop-resize. |
|
||||
| D-10 | Two-tab ARIA strip with roving tabindex + ArrowLeft/Right keyboard nav | ✓ VERIFIED | AdminPage.tsx: `role="tablist"` (321), `role="tab"`+`aria-selected`+roving `tabIndex` 0/-1 (331-358), `handleTabKeyDown` ArrowRight/Left with focus management (205-220), `role="tabpanel"` ×2. CI-tested in admin.spec.ts (ArrowRight/Left switch + default selection). |
|
||||
|
||||
**Score:** 10/10 decisions delivered in code (8 fully VERIFIED, 2 PARTIAL with documented WARNING-class caveats). 0 behavior-unverified, 0 FAILED, 0 BLOCKER.
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
| -------- | -------- | ------ | ------- |
|
||||
| `apps/pwa/src/styles/tokens.css` | Themeable layer + `--bottom-chrome-h` | ✓ VERIFIED | Combined selector + token defined; sx overrides intact |
|
||||
| `apps/pwa/public/logo.svg` + 6 icons | Brand mark + complete set | ✓ VERIFIED | All 7 real files; maskable separate |
|
||||
| `apps/pwa/pwa-assets.config.ts` + package.json | generator config + script | ✓ VERIFIED | Both exist; reads logo.svg |
|
||||
| `apps/pwa/e2e/layout.spec.ts` | FAB↔BottomTabBar overlap guard | ✓ VERIFIED | D-01 regression test present |
|
||||
| `apps/pwa/src/components/BrandSlot.tsx` | logo img swapped in | ✓ VERIFIED | Decorative img, h1 retained |
|
||||
| `apps/pwa/index.html` | favicon links + theme-color | ✓ VERIFIED | favicon.svg + .ico + apple-touch + #e8915a |
|
||||
| `apps/pwa/src/components/SettingsSheet.tsx` | Sign out + centering | ✓ VERIFIED | handleSignOut + desktop branch |
|
||||
| `apps/pwa/src/components/CredentialSheet.tsx` | centering branch | ✓ VERIFIED | phone/desktop branch present |
|
||||
| `apps/pwa/src/routes/AdminPage.tsx` | toasts + tablist + reset centering | ✓ VERIFIED | All present |
|
||||
| `apps/pwa/e2e/admin.spec.ts` | tab ARIA + keyboard + toast tests | ✓ VERIFIED | 9 tab assertions + toast structure test |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
| ---- | -- | --- | ------ | ------- |
|
||||
| tokens.css | @schedule-x/theme-default | sx overrides after import | ✓ WIRED | tool-verified |
|
||||
| pwa-assets.config.ts | public/logo.svg | images: ['public/logo.svg'] | ✓ WIRED | tool-verified |
|
||||
| CalendarShell.tsx | tokens.css | FAB reads `var(--bottom-chrome-h)` | ✓ WIRED | **Manually verified** (CalendarShell.tsx:470) — gsd-tools reported false-negative due to over-escaped regex `var\\(--bottom-chrome-h\\)`; pattern is present and correct. |
|
||||
| App.tsx | tokens.css | phone paddingBottom reads token | ✓ WIRED | **Manually verified** (App.tsx:164) — same false-negative; pattern present. |
|
||||
| vite.config.ts | icon-maskable-512.png | manifest `purpose: maskable` | ✓ WIRED | tool-verified |
|
||||
| BrandSlot.tsx | public/logo.svg | img src /logo.svg | ✓ WIRED | tool-verified |
|
||||
| SettingsSheet.tsx | api/client.ts | handleSignOut calls fetchLocalLogout | ✓ WIRED | tool-verified |
|
||||
| AdminPage.tsx | SyncStateToast.tsx | reuse toast visual pattern | ✓ WIRED (pattern) | gsd-tools reported "target not referenced" — the plan `via` says reuse the *visual pattern*, not import. Toast uses `role="status"` + 3s auto-dismiss as specified (AdminPage.tsx:1030,66-69). Intent satisfied. |
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
| -------- | ------- | ------ | ------ |
|
||||
| Overlap guard test exists | grep "New Event FAB does not overlap BottomTabBar" layout.spec.ts | 1 match | ✓ PASS |
|
||||
| Admin tab tests exist | grep "getByRole('tab'" admin.spec.ts | 9 matches | ✓ PASS |
|
||||
| Tab keyboard switching exercised in CI | ArrowRight/ArrowLeft → aria-selected assertions | present (admin.spec.ts:134-151) | ✓ PASS (behavior-dependent truth has CI coverage) |
|
||||
| All 7 branding assets present + real sizes | ls public/ + file | logo.svg, favicon.svg/.ico, icon-192/512, maskable-512, apple-touch all >900B PNG/SVG | ✓ PASS |
|
||||
|
||||
Production build / typecheck / eslint (0 warnings) / 266 unit tests already passing per phase context — relied upon, not re-run.
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
No REQ-IDs; D-01..D-10 serve as requirements. Coverage: D-01/D-02→17-03 ✓, D-03/D-04→17-02 ✓, D-04/D-05→17-04 ✓, D-06→17-01 ✓(⚠), D-07/D-09→17-05 ✓(⚠), D-08/D-09/D-10→17-06 ✓. All decisions mapped to a plan and delivered.
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
| ---- | ---- | ------- | -------- | ------ |
|
||||
| SettingsSheet/CredentialSheet/AdminPage | sheet branches | Hardcoded px (`12px`, `480px`, boxShadow rgba) in component files | ℹ️ Info | Layout primitives, not colors; matches pre-existing patterns; color invariant (hex→tokens.css) intact |
|
||||
| SettingsSheet.tsx | various | `phone = matchMedia(...)` one-shot, no listener | ⚠️ Warning | 17-REVIEW WR-01 — stale sheet/FAB geometry on resize across 767px (tablet/foldable/desktop-resize). Not a blocker for real phones. |
|
||||
| AdminPage / dialogs | — | Hardcoded z-index literals (300/301/302/303) | ℹ️ Info | 17-REVIEW WR — duplicated magic numbers, no current layering bug |
|
||||
|
||||
No TBD/FIXME/XXX debt markers found in phase-modified files. No BLOCKER-class issues (17-REVIEW: 0 critical).
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
See frontmatter `human_verification` — 4 items: (1) phone overlap on real device, (2) **resize-across-breakpoint sheet geometry [WR-01 severity call]**, (3) PWA maskable/home-screen icon install, (4) logout round-trip. Items 1 and 3 are genuinely device-only (per CLAUDE.md iOS-Safari/install exception). Items 1, 2, 4 could alternatively be spot-checked via playwright-cli on Chromium if desired.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No goal-blocking gaps. Every decision D-01..D-10 is delivered with substantive, wired code, and the two automated CI guards the phase promised (FAB overlap in layout.spec.ts, admin tab ARIA/keyboard in admin.spec.ts) exist and assert real geometry/state transitions. The phase achieves its goal in the codebase.
|
||||
|
||||
Two WARNING-class caveats (both pre-documented in 17-REVIEW, both WARNING not BLOCKER) and four human/device confirmations keep the verdict at `human_needed` rather than `passed`:
|
||||
- **WR-01 (resize snapshot)** is the one substantive behavioral caveat — sheet/FAB geometry does not re-evaluate when the viewport crosses 767px after mount. For the two-person household's actual phones this is invisible (a phone is always a phone); it only manifests on tablet rotation or desktop-window narrowing. Operator should confirm this is acceptable for the milestone or fold the `useIsPhone()` fix (already sketched in 17-REVIEW) into a follow-up.
|
||||
- The hardcoded-px additions in sheet branches are a minor invariant softening (D-06 is primarily a *color* invariant, which holds), recorded as Info.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-06-18_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
Reference in New Issue
Block a user