---
phase: 06-ux-polish
plan: 04
type: execute
wave: 1
depends_on: []
files_modified:
  - apps/pwa/src/styles/tokens.css
  - apps/pwa/src/components/PushPermissionPrompt.tsx
autonomous: false
requirements: []
must_haves:
  truths:
    - 'Sync indicators actually animate — the SyncStateToast spinner spins and the LiveSyncIndicator reconnecting dot pulses (D-13)'
    - "@keyframes pulse exists globally in tokens.css so LiveSyncIndicator's reconnecting dot animates regardless of which components are mounted (D-13)"
  artifacts:
    - path: 'apps/pwa/src/styles/tokens.css'
      provides: 'global @keyframes pulse (added) alongside the existing @keyframes spin'
      contains: '@keyframes pulse'
    - path: 'apps/pwa/src/components/PushPermissionPrompt.tsx'
      provides: 'redundant local @keyframes spin <style> block removed'
  key_links:
    - from: 'apps/pwa/src/components/LiveSyncIndicator.tsx'
      to: 'apps/pwa/src/styles/tokens.css'
      via: "animation: 'pulse 1.4s ease-in-out infinite' resolves to the global keyframe"
      pattern: '@keyframes pulse'
---

<objective>
Make every sync indicator actually animate (D-13). RESEARCH corrected the original CONTEXT.md assumption: `@keyframes spin` is ALREADY global in `tokens.css` (lines 140–147) and loads before any component mounts — so the spinner works. The real bugs are (1) `@keyframes pulse` is MISSING, so `LiveSyncIndicator`'s reconnecting dot (`animation: 'pulse 1.4s ease-in-out infinite'`) never animates, and (2) `PushPermissionPrompt.tsx` carries a redundant local `<style>` redefinition of `@keyframes spin` that should be removed for hygiene.

Purpose: Pure CSS/markup fix — no business logic, so a standard (non-TDD) plan. The animation presence is verified with `playwright-cli` (desktop Chromium) per the CLAUDE.md verification convention, with a grep gate confirming the keyframe is in the stylesheet.
Output: `@keyframes pulse` added to `tokens.css`; redundant `<style>` block removed from `PushPermissionPrompt.tsx`.
</objective>

<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/06-ux-polish/06-RESEARCH.md
@.planning/phases/06-ux-polish/06-PATTERNS.md
@.planning/phases/06-ux-polish/06-UI-SPEC.md
</context>

<artifacts_this_plan_produces>
NEW symbols introduced here (exclude from drift/convergence checks):

- `@keyframes pulse` in `apps/pwa/src/styles/tokens.css` (0%,100% opacity:1 / 50% opacity:0.4)
- Removal of the redundant local `@keyframes spin` `<style>` block in `PushPermissionPrompt.tsx` (deletion, not a new symbol)
  </artifacts_this_plan_produces>

<tasks>

<task type="auto">
  <name>Task 1: Add @keyframes pulse globally; remove redundant spin redefinition</name>
  <files>apps/pwa/src/styles/tokens.css, apps/pwa/src/components/PushPermissionPrompt.tsx</files>
  <read_first>
    - apps/pwa/src/styles/tokens.css — existing `@keyframes spin` (lines 140–147) and `@keyframes shimmer` (~131–138); match their format (no vendor prefixes, no `animation-fill-mode` inside the keyframe block)
    - apps/pwa/src/components/PushPermissionPrompt.tsx — the redundant local `<style>{` @keyframes spin ... `}</style>` block (lines ~357–363) to delete; the inline `animation: 'spin 1s linear infinite'` stays
    - apps/pwa/src/components/LiveSyncIndicator.tsx — `animation: 'pulse 1.4s ease-in-out infinite'` (~line 69) — the consumer that needs the new keyframe
    - apps/pwa/src/components/SyncStateToast.tsx — `animation: 'spin 1s linear infinite'` (~line 158) — already-working consumer, confirm unchanged
    - .planning/phases/06-ux-polish/06-RESEARCH.md §"Focus 5" (the corrected diagnosis) + .planning/phases/06-ux-polish/06-UI-SPEC.md §"Animation Contract" (exact pulse keyframe)
  </read_first>
  <action>
    In `tokens.css`, add a `@keyframes pulse` block (0%,100% opacity 1; 50% opacity 0.4) directly after the existing `@keyframes spin` block, matching the surrounding format. In `PushPermissionPrompt.tsx`, delete ONLY the redundant local `<style>` block that redefines `@keyframes spin` (lines ~357–363) — leave the component's inline `animation: 'spin ...'` style and all other markup intact, since the global definition in `tokens.css` already covers it. Do NOT touch `LiveSyncIndicator.tsx` or `SyncStateToast.tsx` (their inline `animation` references are correct and now resolve to global keyframes). Commit: `fix(06-04): add global pulse keyframe and drop redundant spin redefinition`.
  </action>
  <verify>
    <automated>grep -v '^#' apps/pwa/src/styles/tokens.css | grep -c '@keyframes pulse' | grep -qx 1 && ! grep -q '@keyframes spin' apps/pwa/src/components/PushPermissionPrompt.tsx && cd apps/pwa && pnpm test -- run 2>&1 | tail -3</automated>
  </verify>
  <acceptance_criteria>
    - `@keyframes pulse` is present exactly once in tokens.css (grep gate passes).
    - No `@keyframes spin` remains in PushPermissionPrompt.tsx (the redundant block is gone).
    - The `@keyframes spin` block in tokens.css is unchanged; LiveSyncIndicator.tsx and SyncStateToast.tsx are unmodified.
    - Existing PWA test suite still passes (no regression).
  </acceptance_criteria>
  <done>Global pulse keyframe added; redundant spin redefinition removed; PWA tests green.</done>
</task>

<task type="checkpoint:human-verify" gate="blocking">
  <name>Task 2: playwright-cli — confirm spinner spins and reconnecting dot pulses</name>
  <files>(verification only — no files modified)</files>
  <read_first>
    - .claude/skills/playwright-cli/SKILL.md — how to drive desktop Chromium and observe computed styles / animation state
    - docs/deployment.md §"Running locally (host-side, no Docker)" — the two-terminal dev run command (DEV_AUTH_BYPASS=true) to bring up the PWA for browser checks
    - apps/pwa/src/components/SyncStateToast.tsx + apps/pwa/src/components/LiveSyncIndicator.tsx — how to trigger the syncing / reconnecting states
  </read_first>
  <action>
    Verification task (no code changes). Using the playwright-cli skill against desktop Chromium: (1) start the dev stack host-side per docs/deployment.md with DEV_AUTH_BYPASS=true; (2) trigger a sync so SyncStateToast renders its Loader2 spinner and observe it rotating (computed `animationName === 'spin'`, transform changing over time); (3) force LiveSyncIndicator into the reconnecting state (drop the SSE connection) and observe the reconnecting dot's opacity pulsing (`animationName === 'pulse'`, not 'none'); (4) confirm PushPermissionPrompt's spinner still rotates after its local keyframe block was removed. Capture the observed `animationName` for both indicators in the summary. This is a blocking human-verify checkpoint — pause for the operator's confirmation.
  </action>
  <what-built>
    Global `@keyframes pulse` in tokens.css and removal of the redundant local spin keyframe. Both animations now resolve from the global stylesheet for every consumer regardless of mount order.
  </what-built>
  <how-to-verify>
    1. Start the dev stack host-side per docs/deployment.md (DEV_AUTH_BYPASS=true), then open the PWA in desktop Chromium via playwright-cli.
    2. Trigger a sync state so SyncStateToast renders its Loader2 spinner; observe the spinner is visibly rotating (computed transform changes over time / animationName === 'spin').
    3. Force the LiveSyncIndicator into the reconnecting state (e.g. drop the SSE connection) and observe the reconnecting dot's opacity pulsing (animationName === 'pulse', not 'none').
    4. Confirm PushPermissionPrompt's spinner (if surfaced) still rotates after removing its local keyframe block.
  </how-to-verify>
  <verify>
    <human-check>Spinner rotates and reconnecting dot pulses in desktop Chromium; both animationName values are non-'none'.</human-check>
  </verify>
  <resume-signal>Type "approved" or describe which indicator did not animate.</resume-signal>
  <acceptance_criteria>
    - SyncStateToast spinner shows a live rotation (animationName 'spin').
    - LiveSyncIndicator reconnecting dot shows a live opacity pulse (animationName 'pulse').
    - No console error about an undefined keyframe.
  </acceptance_criteria>
  <done>Both animations verified live in desktop Chromium via playwright-cli.</done>
</task>

</tasks>

<threat_model>

## Trust Boundaries

| Boundary   | Description                                                          |
| ---------- | -------------------------------------------------------------------- |
| (none new) | UI/CSS only. No data, no network, no input, no auth surface touched. |

## STRIDE Threat Register

| Threat ID | Category | Component                                  | Disposition | Mitigation Plan                                                                                                                                |
| --------- | -------- | ------------------------------------------ | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| T-06-04   | (n/a)    | tokens.css keyframe + style-block deletion | accept      | No new trust boundary — purely a CSS keyframe addition and removal of a redundant inline style. No input, no data flow, no auth path affected. |

</threat_model>

<verification>
- `grep -v '^#' apps/pwa/src/styles/tokens.css | grep -c '@keyframes pulse'` returns 1.
- `grep -q '@keyframes spin' apps/pwa/src/components/PushPermissionPrompt.tsx` returns nothing.
- playwright-cli confirms both animations run.
</verification>

<success_criteria>

- D-13: pulse keyframe present globally; reconnecting dot animates; spinner confirmed animating; redundant redefinition removed.
  </success_criteria>

<output>
Create `.planning/phases/06-ux-polish/06-04-SUMMARY.md` when done (note the playwright-cli observation of both animations).
</output>
