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)
path
provides
contains
apps/pwa/src/styles/tokens.css
global @keyframes pulse (added) alongside the existing @keyframes spin
@keyframes pulse
path
provides
apps/pwa/src/components/PushPermissionPrompt.tsx
redundant local @keyframes spin <style> block removed
from
to
via
pattern
apps/pwa/src/components/LiveSyncIndicator.tsx
apps/pwa/src/styles/tokens.css
animation: 'pulse 1.4s ease-in-out infinite' resolves to the global keyframe
@keyframes pulse
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.
<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>
Task 1: Add @keyframes pulse globally; remove redundant spin redefinition
apps/pwa/src/styles/tokens.css, apps/pwa/src/components/PushPermissionPrompt.tsx
- 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)
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`.
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
- `@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).
Global pulse keyframe added; redundant spin redefinition removed; PWA tests green.
Task 2: playwright-cli — confirm spinner spins and reconnecting dot pulses
(verification only — no files modified)
- .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
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.
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.
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.
Spinner rotates and reconnecting dot pulses in desktop Chromium; both animationName values are non-'none'.
Type "approved" or describe which indicator did not animate.
- 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.
Both animations verified live in desktop Chromium via playwright-cli.
<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.