diff --git a/product-notes/spectrum-seeker.md b/product-notes/spectrum-seeker.md
new file mode 100644
index 0000000..7d4bfd1
--- /dev/null
+++ b/product-notes/spectrum-seeker.md
@@ -0,0 +1,605 @@
+# WaveformSeeker — loudness-waveform seekbar to replace the MudSlider
+
+Status: approved. Decisions resolved 2026-06-05. Author: product-designer. Date: 2026-06-05.
+**Plan only — no code edits made by this doc.**
+
+---
+
+## 1. Summary
+
+Replace the `MudSlider`-based scrub bar in `PlayerSeekZone.razor` with a new
+`` component that renders the track's **loudness profile** as a
+high-density vertical bar chart and serves as the seek surface (click / drag to seek).
+
+The point is to make the seekbar *informative*: instead of a featureless line, the
+listener sees the track's energy shape — the quiet intro, the drop, the breakdown, the
+outro — and can scrub against that shape. This is the established "waveform scrubber"
+idiom from SoundCloud, Overcast, and most DAW transport bars. We are borrowing it
+deliberately; the novel part for us is only that the profile is **preprocessed server-side
+and shipped as a small quantized array**, so the visual paints the instant a track loads rather
+than waiting for the audio to decode.
+
+The loudness measure is **not hardcoded to RMS**. The first implementation computes RMS, but
+the compute path is built around a swappable `ILoudnessAlgorithm` abstraction (§5a) so a
+different perceptual loudness profile (e.g. LUFS) can be substituted later without touching the
+component, the wire format, or the storage. The component and the data are named for the
+*concept* (waveform / loudness profile), not the algorithm.
+
+Two visualizations currently coexist in the seek zone. They are being separated by
+*kind*:
+
+- **Real-time spectrum** (FFT frequency bars, `SpectrumVisualizer.razor`) — a *live* readout
+ of "what is sounding right now." This moves **up, above the volume slider**.
+- **Static loudness-over-time** (the new `WaveformSeeker`) — a *whole-track* readout of "how loud
+ is each moment." This takes over the seek area.
+
+This is a clean conceptual split: live-frequency lives with the output level (volume),
+whole-track-amplitude lives with the transport position (seek). The current arrangement
+(real-time spectrum behind the seek slider) conflates the two.
+
+### Naming (decided)
+
+"Spectrum" properly means frequency content; what this component shows is **amplitude over
+time**, not spectrum. The component is named honestly: **`WaveformSeeker`** (decided), which
+reads correctly against the live `SpectrumVisualizer` (frequency) without implying FFT data is
+in the payload. The *data* is named for the concept, not the algorithm: **`WaveformProfile`** /
+`WaveformProfileDto` / `waveformBuckets` / a `profile` field — so substituting the loudness
+algorithm (RMS → LUFS, §5a) never forces a rename of the type that carries it.
+
+---
+
+## 2. Current state (what we're changing)
+
+The seek zone today (`PlayerSeekZone.razor`):
+
+```razor
+
+ @* live FFT bars, sits on top *@
+
+ @* the scrub bar *@
+
+ @* time text *@
+
+```
+
+Relevant mechanics already in place that the new component must preserve:
+
+- **Seek gesture plumbing** lives in `PlayerSeekZone.razor.cs`: `OnSeekStart` /
+ `OnSeekChange` / `OnSeekEnd` callbacks bubble to `AudioPlayerBar.razor.cs`, which sets
+ `_isSeeking`, tracks `_seekPosition`, and calls `PlayerService.Seek(position)` on release.
+ `DisplayTime` shows the drag position while seeking, real `CurrentTime` otherwise.
+- **`CanSeek`** = `IsLoaded && Duration.HasValue && Duration > 0`. Seek is allowed during
+ streaming, including beyond the buffer (the offset-refetch path in
+ `StreamingAudioPlayerService` / `AudioPlayer.ts.seekBeyondBuffer`). The new component does
+ **not** touch that path — it only produces a target time and hands it to the same
+ `Seek(double)` call.
+- **`SpectrumVisualizer`** is driven entirely by `AudioInteropService.StartSpectrumAnimationAsync`,
+ which subscribes a callback to the TS `SpectrumAnalyzer` (live FFT, ~30fps). It already
+ self-manages animation lifecycle off `PlayerService.StateChanged`. Moving it is a pure
+ layout move — no logic change.
+- **Player layout** (`AudioPlayerBar.razor.css`) is pure-CSS responsive: at ≥600px the row is
+ `[transport] [seek grows] [volume]`; at <600px it's `[transport][volume]` then full-width
+ seek below. Wherever the spectrum lands, it must respect this.
+
+---
+
+## 3. UI layout changes
+
+### 3a. What moves
+
+| Element | Today | After |
+|---|---|---|
+| Live FFT spectrum (`SpectrumVisualizer`) | Inside `PlayerSeekZone`, above the slider | Inside the **volume cluster**, above the volume slider |
+| Scrub bar (`MudSlider`) | `PlayerSeekZone` | Replaced by `WaveformSeeker` (loudness bars + playhead) |
+| Timestamp (`TimestampLabel`) | Below the slider in `PlayerSeekZone` | Stays with the seeker (below or overlaid on the bars) |
+| Volume slider (`VolumeControls`) | Right cluster | Unchanged position; now has the live spectrum stacked above it |
+
+### 3b. Resulting zones
+
+- **Transport zone** — unchanged (play/pause/stop + load spinner).
+- **Volume zone** — becomes a small vertical stack: live FFT spectrum on top, volume
+ slider below. This is a natural pairing ("here's the live output, here's how loud").
+ `VolumeControls.razor` gets the `` stacked above its existing
+ `MudStack`. The wrapper is renamed `VolumeZone` (**decided**) for symmetry with the other
+ two zones.
+- **Seek zone** — becomes the `WaveformSeeker`: a wide loudness bar chart that grows to fill the
+ available width (it inherits the `flex-grow:1` the seek zone has today), with the
+ timestamp beneath.
+
+### 3c. Layout risk
+
+The live spectrum is currently a wide element. Stacking it above the *volume* slider
+constrains it to the narrow right cluster — at ≥600px the volume cluster is only as wide as
+the slider (the CSS halves and flex-start-pins it per commit `78c6803`). A 32-bucket FFT bar
+chart squeezed into ~120px will look cramped.
+
+**Decided: 24 buckets in the volume cluster, parameterized.** The live spectrum renders **24
+buckets** in the narrow volume slot, set via the existing `BucketCount` parameter on
+`SpectrumVisualizer` so the count can be tuned without a code change to the component. 24 reads
+denser than 16 while still fitting the ~120px cluster comfortably.
+
+---
+
+## 4. WaveformSeeker component design
+
+### 4a. Data → geometry
+
+The component receives a normalized loudness profile: `double[] profile`, each value in `[0,1]`,
+representing the loudness measure of a contiguous time slice. Profile length is **N buckets**
+covering the whole track regardless of duration (fixed bucket count, variable bucket
+*duration*). Each bucket renders as one vertical bar; bar height = `profile[i]` scaled to the
+component height (with a small floor, ~2%, so silence is still visible as a hairline — mirrors
+`SpectrumVisualizer.GetBarHeight`).
+
+**Bar count.** Two regimes:
+
+- **Preprocessed resolution (N):** how many buckets the backend computes and stores. **N is
+ configurable** (e.g. via `WaveformProfileOptions` bound from DI/config), **default 512**. A
+ high source resolution lets the front end downsample to whatever fits the rendered width
+ without re-fetching. Storage is tiny regardless of N (see §5).
+- **Rendered resolution:** how many bars actually draw, = pixels-available / (bar + gap). **The
+ front end derives its rendered bar count from the available width, regardless of N** — it does
+ not assume the stored N is the bar count. At a typical ~600px seek zone with 2px bars + 1px
+ gaps that's ~200 bars. The component **downsamples N → rendered count** by max-or-mean over
+ each rendered bucket's source range. Use **max** (peak) for the visual — peak-per-bucket gives
+ the punchy DAW look; mean flattens transients.
+
+**Decided: N configurable, default 512; rendered count derived from width; downsample by peak.**
+512 is a clean power-of-two, downsamples evenly to 256/128/64, and is ~512B on the wire as
+quantized bytes (§5b). The wire format is the quantized `byte[]` base64 either way; N being
+configurable does not change the format.
+
+### 4b. Playhead / progress indication
+
+The current position is shown two ways simultaneously (both cheap, both standard):
+
+1. **Played/unplayed split** — bars left of the playhead render in the played colour (moss
+ green `--deepdrft-green-accent`, matching the house waveform identity called out in
+ `track-card-theming.md`), bars right render muted. The split point = `CurrentTime / Duration`.
+2. **Playhead line** — a 1–2px vertical rule at the split, for precision.
+
+While dragging, the split/line follow the pointer (`DisplayTime`), not playback — same
+`_isSeeking` discipline as today.
+
+### 4c. Interaction model
+
+Pointer-based, reusing the existing callback contract so `AudioPlayerBar.razor.cs` is barely
+touched:
+
+- **Hover** → a faint preview line at the cursor + a tooltip/label showing the time under the
+ cursor (`hoverTime = (cursorX / width) * Duration`). Preview only; no seek. (New affordance;
+ the MudSlider had none. Borrowed from SoundCloud/YouTube scrubbers.)
+- **Click** → seek to `clickX / width * Duration`. Fires `OnSeekStart` then immediately
+ `OnSeekEnd(clickTime)`.
+- **Drag** → `pointerdown` starts seeking (`OnSeekStart`), `pointermove` updates the preview
+ position and fires `OnSeekChange(t)` (so `DisplayTime` and the played/unplayed split track
+ the drag live), `pointerup` commits (`OnSeekEnd(t)` → `PlayerService.Seek(t)`).
+ `pointerleave` while dragging commits at the last position (matches current
+ `HandlePointerLeave` behaviour) — or, better, use **pointer capture** (`setPointerCapture`)
+ so a drag that leaves the element keeps tracking until release. Recommend pointer capture;
+ it's the more forgiving gesture and avoids the "lost the drag" feel.
+
+Position math needs the element's pixel width and the pointer's offset. Two implementations:
+
+- **Pure Blazor:** use `@onpointermove`/`@onpointerdown` with `PointerEventArgs.OffsetX` and a
+ cached bounding width (one JS `getBoundingClientRect` call on resize). Simple, no per-frame
+ interop.
+- **Thin JS helper:** a tiny interop that does hit-testing and returns a normalized `[0,1]`
+ fraction. Only worth it if `OffsetX` proves unreliable across the responsive reflows.
+
+**Recommend pure-Blazor pointer events first**, with `OffsetX`/cached width; fall back to a JS
+helper only if hit-testing is flaky. Keeps the new surface out of the TS bundle (see §7).
+
+### 4d. Rendering approach
+
+- **DOM bars** (one `
` per rendered bar, CSS `--bar-height`) — exactly how
+ `SpectrumVisualizer` works today, so it's consistent and themeable via existing
+ `deepdrft-` tokens. At ~200 bars this is fine; Blazor diffing over 200 static divs that only
+ change a CSS var on seek is cheap.
+- **Canvas** — one `