---
phase: quick-260610-jlp
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- apps/pwa/src/components/InstructionSheet.tsx
- apps/pwa/src/components/PermissionDeniedBanner.tsx
- apps/pwa/src/components/SettingsSheet.tsx
- apps/pwa/src/components/InstructionSheet.test.tsx
autonomous: true
requirements: [UAT-05-T4]
must_haves:
truths:
- 'Tapping ''How to enable'' in SettingsSheet''s permission-denied hint opens the OS-specific instruction dialog (role="dialog") instead of closing the sheet'
- 'PermissionDeniedBanner still opens its instruction sheet identically after the extraction (behaviour-preserving)'
- 'The instruction sheet copy/markup/styles are unchanged from the original local InstructionSheet'
artifacts:
- path: 'apps/pwa/src/components/InstructionSheet.tsx'
provides: 'Shared InstructionSheet bottom-sheet dialog + isIOS/IOS_STEPS/ANDROID_STEPS, named export'
contains: 'export function InstructionSheet'
- path: 'apps/pwa/src/components/SettingsSheet.tsx'
provides: 'Permission-denied hint wired to open InstructionSheet'
contains: 'setInstructionsOpen'
- path: 'apps/pwa/src/components/PermissionDeniedBanner.tsx'
provides: 'Banner importing the shared InstructionSheet (local copy removed)'
contains: 'import { InstructionSheet }'
- path: 'apps/pwa/src/components/InstructionSheet.test.tsx'
provides: "Test asserting SettingsSheet 'How to enable' opens role=dialog rather than calling onClose"
key_links:
- from: 'apps/pwa/src/components/SettingsSheet.tsx'
to: 'apps/pwa/src/components/InstructionSheet.tsx'
via: 'named import + instructionsOpen state render'
pattern: "import \\{ InstructionSheet \\}.*InstructionSheet\\.js"
- from: 'apps/pwa/src/components/PermissionDeniedBanner.tsx'
to: 'apps/pwa/src/components/InstructionSheet.tsx'
via: 'named import'
pattern: "import \\{ InstructionSheet \\}.*InstructionSheet\\.js"
---
Fix the broken "How to enable" link in SettingsSheet's notifications-blocked hint (Phase 5 UAT Test 4). On Android the link did nothing except close the settings sheet — `onClick={onClose}` showed no guidance. Extract the already-working `InstructionSheet` from PermissionDeniedBanner into a shared component and wire SettingsSheet's hint to open it.
Purpose: A browser-blocked user cannot re-enable notifications from inside the app (correct), so the ONLY recovery is the OS-specific step-by-step guidance. That guidance was unreachable from Settings — the documented recovery path was dead.
Output: New shared `InstructionSheet.tsx`; PermissionDeniedBanner refactored to import it (behaviour identical); SettingsSheet wired to open it; a focused test proving the wiring.
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
@CLAUDE.md
@apps/pwa/src/components/PermissionDeniedBanner.tsx
@apps/pwa/src/components/SettingsSheet.tsx
@apps/pwa/src/components/InstallPrompt.test.tsx
Task 1: Extract InstructionSheet into a shared component and rewire PermissionDeniedBanner
apps/pwa/src/components/InstructionSheet.tsx, apps/pwa/src/components/PermissionDeniedBanner.tsx
Create `apps/pwa/src/components/InstructionSheet.tsx`. Move VERBATIM from PermissionDeniedBanner.tsx (lines ~25-202): the `isIOS()` function, the `IOS_STEPS` and `ANDROID_STEPS` constants, the `InstructionSheetProps` interface, and the `InstructionSheet` component. Keep markup, inline styles, CSS-var design tokens, role="dialog"/aria-modal/aria-label, the X close button, the numbered steps, and the Done button BYTE-FOR-BYTE identical — this is a behaviour-preserving extraction, NOT a redesign (UI-SPEC §Surface; T-05-24: copy stays plain-text JSX children, no dangerouslySetInnerHTML). Change `function InstructionSheet` to `export function InstructionSheet` (named export). Carry the `import { X } from 'lucide-react'` into the new file (only X is needed there). `isIOS()` MUST move with the component since it selects the step list + platform label.
Edit `apps/pwa/src/components/PermissionDeniedBanner.tsx`: delete the now-moved `isIOS()`, `IOS_STEPS`, `ANDROID_STEPS`, `InstructionSheetProps`, and local `InstructionSheet` (the "OS detection", "Instruction steps", and "Instruction sheet" sections). Add `import { InstructionSheet } from './InstructionSheet.js'` (match the existing `.js`-specifier convention used by `usePushSubscription.js`). The remaining import from `lucide-react` keeps `AlertCircle`; drop `X` from that import ONLY if no longer used in this file (grep confirms — X was only used inside the moved InstructionSheet). Leave the `PermissionDeniedBanner` function body unchanged — it already references `InstructionSheet` and `setInstructionsOpen`, now satisfied by the import. Do NOT touch the banner's render/visibility logic.
cd apps/pwa && grep -q "export function InstructionSheet" src/components/InstructionSheet.tsx && grep -q "import { InstructionSheet } from './InstructionSheet.js'" src/components/PermissionDeniedBanner.tsx && ! grep -q "function InstructionSheet" src/components/PermissionDeniedBanner.tsx && pnpm --filter @familysync/pwa typecheck
InstructionSheet.tsx exists with a named `InstructionSheet` export plus isIOS/IOS_STEPS/ANDROID_STEPS; PermissionDeniedBanner imports it and no longer defines a local InstructionSheet; `pnpm --filter @familysync/pwa typecheck` exits 0.
Task 2: Wire SettingsSheet's "How to enable" to the shared InstructionSheet (with test)
apps/pwa/src/components/SettingsSheet.tsx, apps/pwa/src/components/InstructionSheet.test.tsx
- When SettingsSheet renders with permission==='denied' and the user clicks "How to enable", a role="dialog" element appears (the InstructionSheet) and onClose (the sheet-close prop) is NOT called.
- The shared InstructionSheet renders role="dialog" with the heading "How to enable notifications" and a "Done" button that calls its onClose.
Edit `apps/pwa/src/components/SettingsSheet.tsx`: `useState` is already imported (line 24) — no import change needed there. Add `import { InstructionSheet } from './InstructionSheet.js'` alongside the other component imports. Inside the `SettingsSheet` function, add `const [instructionsOpen, setInstructionsOpen] = useState(false)` near the other useState hooks. Change the broken "How to enable" button (currently `onClick={onClose}`, ~line 373) to `onClick={() => setInstructionsOpen(true)}`. At the end of the returned fragment (after the closing `` of the Sheet, before the fragment's closing `>`), render `{instructionsOpen && ( setInstructionsOpen(false)} />)}`. The InstructionSheet is `position: fixed; zIndex: 1000` so it correctly layers above the settings sheet (zIndex 301). Do NOT change the disabled-toggle behaviour or any other logic — only the guidance link was broken; staying unable to re-enable in-app while browser-blocked is correct.
Create `apps/pwa/src/components/InstructionSheet.test.tsx` following the existing harness style (vitest + @testing-library/react + `.js` import specifiers, see InstallPrompt.test.tsx). Write the test FIRST and watch it fail against the un-wired SettingsSheet (RED), then make it pass (GREEN). Mock the `usePushSubscription` hook (`vi.mock('../hooks/usePushSubscription.js', ...)`) to return `{ permission: 'denied', isSubscribed: false, subscribe: vi.fn(), setEnabled: vi.fn() }` so the permission-denied hint renders. Test: render ``, assert no `role="dialog"` named "How to enable notifications" exists yet, click the "How to enable" button (`screen.getByText('How to enable')`), then assert (a) a dialog with the "How to enable notifications" heading is now visible and (b) `onCloseSpy` was NOT called. Keep it pragmatic — if jsdom lacks `Notification`, define a minimal `globalThis.Notification = { permission: 'denied' }` stub in the test before render. Do not over-engineer; one wiring assertion is sufficient.
cd apps/pwa && grep -q "onClick={() => setInstructionsOpen(true)}" src/components/SettingsSheet.tsx && grep -q "instructionsOpen && " src/components/SettingsSheet.tsx && ! grep -q "onClick={onClose}\s*$" <(grep -A1 "How to enable" src/components/SettingsSheet.tsx) ; pnpm --filter @familysync/pwa test -- src/components/InstructionSheet.test.tsx
SettingsSheet's "How to enable" opens the InstructionSheet via `instructionsOpen` state (no longer calls onClose); InstructionSheet.test.tsx passes asserting the dialog opens and onClose is not called.
Task 3: Full typecheck, build, and test gate
apps/pwa/src/components/InstructionSheet.tsx, apps/pwa/src/components/PermissionDeniedBanner.tsx, apps/pwa/src/components/SettingsSheet.tsx
Run the full PWA quality gate to prove the extraction + wiring compiles, builds, and all tests pass (regression guard — confirms PermissionDeniedBanner still typechecks against the shared import and no other consumer broke). If any check fails, fix the cause in the files above (do not weaken or skip checks). No new dependencies; lucide-react and the CSS-var tokens already in use stay as-is.
cd apps/pwa && pnpm --filter @familysync/pwa typecheck && pnpm --filter @familysync/pwa build && pnpm --filter @familysync/pwa test
`typecheck` exits 0; `build` (tsc && vite build) succeeds; full `test` suite passes.
- `pnpm --filter @familysync/pwa typecheck` exits 0
- `pnpm --filter @familysync/pwa build` succeeds (tsc && vite build)
- `pnpm --filter @familysync/pwa test` passes (including the new InstructionSheet.test.tsx)
- `grep` confirms SettingsSheet's "How to enable" calls `setInstructionsOpen(true)`, not `onClose`
- `grep` confirms PermissionDeniedBanner imports InstructionSheet and no longer defines it locally
- A browser-blocked user opening Settings and tapping "How to enable" sees the OS-specific instruction dialog (the original UAT-05-T4 failure is fixed).
- PermissionDeniedBanner behaves identically to before (behaviour-preserving extraction).
- InstructionSheet markup/copy/styles unchanged; no new dependencies; no dangerouslySetInnerHTML.