diff --git a/COMPLETED.md b/COMPLETED.md
index 2952fd5..3e8d430 100644
--- a/COMPLETED.md
+++ b/COMPLETED.md
@@ -6,6 +6,82 @@ Newest entries at the top. Group by phase/wave header (mirroring `PLAN.md` / `CM
---
+## Play-State Icon Normalization
+
+**Status:** Phases 1–4 landed on 2026-06-06 (branches `track-card-play-state-wave1`, `track-card-play-state-wave2`, merged to dev).
+
+### Phase 1 — Fix the gallery bug (correctness, smallest viable change)
+
+**Landed 2026-06-06.**
+
+Bound `TrackCard.IsPlaying` to real playback state instead of selection identity. In `TracksView`/`TracksGallery`, active track is now computed as `PlayerService.IsPlaying && CurrentTrack?.Id == track.Id`. Switched the card glyph from `MusicNote` to the `PlayArrow`/`Pause` vocabulary via `IsPaused` and `OnPause` parameters. Expanded `TracksView.OnPlayerStateChanged` to re-render on any state change, not only on `!IsLoaded` — ensures the gallery correctly reflects pause, play, track-change, and end-of-playback transitions.
+
+**Component changes:**
+- `TrackCard.razor` — added `[Parameter] bool IsPaused`, `[Parameter] EventCallback OnPause` parameters; removed `MusicNote` icon; now conditionally renders `PlayArrow` when not playing or `Pause` when playing.
+- `TracksView.razor` — removed `_selectedTrack` field (selection now fully derived from service); removed `_clickCount`, `_lifecycleStatus`, `TestInteractivity` dev scaffolding; `OnPlayerStateChanged` now calls `StateHasChanged()` unconditionally instead of only on `!IsLoaded`.
+- `TracksGallery.razor` — removed internal `SelectedTrack` mutation and `StateHasChanged` calls on play click; now fully controlled by parent; `SelectedTrack` parameter is read-only.
+
+**Architecture notes:**
+- Resolves the reported bug: gallery card now shows correct play/pause icon reflecting actual playback state.
+- Enabling pause affordance on cards required extending `TrackCard` with `IsPaused` + `OnPause`, preserving the component's presentational contract (stays parameter-driven, lives in shared library).
+- `TracksView.OnPlayerStateChanged` subscription pattern unchanged; expansion from selective to unconditional re-render ensures high-frequency state changes (like spectrum animation or per-sample progress) do not cause visual lag in the gallery.
+
+### Phase 2 — Collapse dual selection state (SRP, prevents regression)
+
+**Landed 2026-06-06.**
+
+Eliminated divergence between `TracksView._selectedTrack` and `PlayerService.CurrentTrack`. `TracksGallery` is now fully controlled — the parent supplies and owns the active-track identity via parameter binding. Selection state is single-sourced from the player service.
+
+**Component changes:**
+- `TracksGallery.razor` — removed parameter-field write in `HandlePlayClick`; no longer calls `StateHasChanged()` on click. Raises `SelectedTrackChanged` callback for the parent to route.
+- `TracksView.razor` — removed `_selectedTrack` backing field and its local mutation.
+
+**Architecture notes:**
+- Resolves the secondary defect: gallery's notion of "active track" can no longer lag the player.
+- `TracksGallery` now a pure presentational component (reads `SelectedTrack`, raises `SelectedTrackChanged`, renders); all state derivation lives in the parent or the service.
+
+### Phase 3 — Introduce the single transport-state resolver (DRY)
+
+**Landed 2026-06-06.**
+
+Introduced a unified glyph-mapping source: `PlaybackIcons.Resolve()` static method in `DeepDrftPublic.Client/Helpers/PlaybackIcons.cs`. This is the sole function responsible for mapping `(IsPlaying, IsPaused, trackId?, CurrentTrackId?)` to the correct transport icon (`PlayArrow`, `Pause`, or null). Replaces all hand-rolled ternaries across `TrackCard`, `PlayerControls`, and other surfaces.
+
+**New code (`DeepDrftPublic.Client/Helpers`):**
+- `PlaybackIcons.cs` — static `Resolve(bool isPlaying, bool isPaused, long? trackId, long? currentTrackId)` method returning `(string? Icon, bool IsActive, bool IsPaused)` tuple. Icon mapping is the single source of truth.
+
+**Component changes:**
+- `PlayerControls.razor(.cs)` — `IsPlaying` parameter removed from the `AudioPlayerBar → PlayerTransportZone → PlayerControls` chain. Instead, `PlayerControls` now subscribes to `IPlayerService.StateChanged` directly and calls `PlaybackIcons.Resolve()` to determine which icon to render and whether buttons are enabled/disabled.
+- `TrackCard.razor` — consumes the tuple returned by `PlaybackIcons.Resolve()` to set `Icon`, `IsActive` (CSS class for highlighting), and `Disabled` state on the FAB.
+
+**Architecture notes:**
+- Eliminates the three-way duplication of "which icon for this state" logic.
+- Icon vocabulary is now standardized across all surfaces (`PlayArrow`/`Pause` pair, no `MusicNote`).
+- Future surfaces (queue list, now-playing chip, etc.) call the same `Resolve()` function instead of re-implementing the mapping.
+
+### Phase 4 (optional, deferred) — Promote to a PlayStateIcon component
+
+**Landed 2026-06-06.**
+
+Created a new `PlayStateIcon.razor` component in `DeepDrftPublic.Client/Controls/` that encapsulates subscription + icon mapping + rendering. Rather than each surface calling `PlaybackIcons.Resolve()` and threading icons through parameters, surfaces now drop in `` and the component handles cascading, state subscription, and icon selection in one place.
+
+**New component (`DeepDrftPublic.Client/Controls/PlayStateIcon.razor`):**
+- Injects `IPlayerService` and subscribes to `StateChanged` on mount.
+- Cascades `[CascadingParameter] DarkModeSettings DarkMode` for theming.
+- Renders an icon button (or FAB) with the correct glyph via `PlaybackIcons.Resolve()`.
+- Forwards `Disabled` parameter to the rendered MudIconButton/MudFab.
+- Raises `OnClick` callback when user clicks.
+
+**Component changes:**
+- `PlayerControls.razor` — refactored to render its play/pause button via `` instead of a parameter-driven button. `IsPlaying` parameter removed from the component signature.
+- The `AudioPlayerBar → PlayerTransportZone → PlayerControls` chain no longer threads `IsPlaying`/`IsPaused` down; subscription happens inside `PlayStateIcon`.
+
+**Architecture notes:**
+- `PlayStateIcon` handles the seam between `IPlayerService` (source of truth) and transport-icon rendering (presentation). This was the third surface (after `TrackCard` and `PlayerControls`); Phase 4 was triggered by the appearance of the third call site.
+- Reduces parameter threading in the component tree (no more passing state flags through intermediate layers).
+- New surfaces that need play/pause icons (queue list, hover-row play button, etc.) now have a reusable, off-the-shelf component instead of re-implementing subscription and mapping.
+
+---
+
## WaveformSeeker Wave 3 — CMS PreProcessing panel
**Status:** W3 (CMS track-preprocessing panel) refactored on 2026-06-05 (branch `waveform-w3-cms`, merged to dev).
diff --git a/DeepDrftPublic.Client/CLAUDE.md b/DeepDrftPublic.Client/CLAUDE.md
index d223180..5413ff8 100644
--- a/DeepDrftPublic.Client/CLAUDE.md
+++ b/DeepDrftPublic.Client/CLAUDE.md
@@ -13,12 +13,16 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
- `Pages/`: Routable components. `Home.razor` (hero/about), `TracksView.razor` (track gallery with pagination/sorting). **No demo pages** (`Counter.razor`, `Weather.razor` do not exist).
- `Layout/`: `MainLayout.razor` (root layout, wraps in `AudioPlayerProvider`, hosts theme switcher), `DeepDrftMenu.razor` (branded menu bar), `NavMenu.razor` (nav list), `Pages.cs` (centralised nav index — `MenuPages` for header, `AllPages` for exhaustive list).
- `Controls/`: Reusable components.
- - `TrackCard.razor`: Individual track display (image, name, artist, album, genre, release date).
- - `TracksGallery.razor`: Responsive grid of `TrackCard` items (MudBlazor `MudGrid` with breakpoints).
+ - `TrackCard.razor`: Individual track display (image, name, artist, album, genre, release date). Play/pause icon controlled via `IsPaused` parameter.
+ - `TracksGallery.razor`: Responsive grid of `TrackCard` items (MudBlazor `MudGrid` with breakpoints). Fully controlled by parent; derives active-track state from cascaded player service.
- `AppNavLink.razor`: Nav link with active-page highlight.
- `AudioPlayerProvider.razor`: Cascading host for `IStreamingPlayerService`. Everything inside it gets the player via `[CascadingParameter]`.
- `AudioPlayerBar.razor`: Dock UI at the bottom (play/pause/seek/volume).
+ - `AudioPlayerBar/PlayerControls.razor`: Play/pause/stop buttons in the transport zone. Renders via ``.
+ - `AudioPlayerBar/PlayStateIcon.razor`: Icon button encapsulating service subscription + transport-state icon selection. Injects `IPlayerService`, subscribes to `StateChanged`, calls `PlaybackIcons.Resolve()` to determine icon and active state.
- `SpectrumVisualizer.razor`: Bar-graph spectrum display, driven by `getSpectrumData` JS callback.
+- `Helpers/`: Utilities and mapper functions.
+ - `PlaybackIcons.cs`: Static `Resolve(isPlaying, isPaused, trackId, currentTrackId)` method — the sole glyph-mapping source for transport icons across all surfaces. Returns `(Icon, IsActive, IsPaused)` tuple.
- `Services/`: Audio player + dark-mode services.
- `IPlayerService` / `IStreamingPlayerService`: Contracts exposed to UI.
- `AudioPlayerService`: Abstract base (lifecycle, initialise, select track, play/pause/stop/seek/volume).
@@ -70,7 +74,7 @@ Both are configured with JSON serializer settings (case-insensitive property mat
- `AudioPlayerProvider.razor` is the cascading host. It injects `IStreamingPlayerService` (resolved to `StreamingAudioPlayerService` in DI), stores it in a cascade with `IsFixed="true"`, and keeps it alive across navigation.
- `AudioPlayerBar.razor` is the dock UI. It cascades the player, binds buttons to `Play()` / `Pause()` / `Seek()` / `SetVolume()`, and displays current time / duration / progress bar. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` (reference-guarded, idempotent) and unsubscribes on dispose to re-render itself when the cascade updates.
- `SpectrumVisualizer.razor` calls `AudioInteropService.GetSpectrumData()` on a timer, receives bar heights, renders via MudBlazor `MudChart` or custom canvas.
-- `TracksView.razor` injects `TracksViewModel` + cascaded `IStreamingPlayerService`. `PlayTrack(track)` calls `PlayerService.SelectTrackStreaming(track)`. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` and clears `_selectedTrack` when `!PlayerService.IsLoaded` (covers Stop, Unload, and end-of-track).
+- `TracksView.razor` injects `TracksViewModel` + cascaded `IStreamingPlayerService`. `PlayTrack(track)` calls `PlayerService.SelectTrackStreaming(track)`. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` and calls `StateHasChanged()` unconditionally on any state change, ensuring the gallery correctly reflects play/pause/track-change transitions. Active-track state is derived from `PlayerService.CurrentTrack` and `PlayerService.IsPlaying` (no local `_selectedTrack` field).
## Dark-mode plumbing