Commit Graph
11 Commits
Author SHA1 Message Date
KeelrLeek d67df6f55e artist page: float the load status instead of shoving the page down
"Refining release details…" rendered as a paragraph in the content flow, above
the action row. It cost about 32px of layout, so the play button and everything
under it visibly jumped up the instant the page finished resolving -- a shift
announcing nothing more than that a background stage had ended. It also read as
debug output: 12px secondary text and a bullet, in a page that otherwise speaks
in pills and chips.

It is now an absolutely positioned pill at the pane's top-right with a spinning
accent ring. Measured: the play button and the Popular heading sit at identical
offsets whether it is showing or not, so appearing and resolving move nothing.

Two placement constraints, both found the hard way:

- It must NOT live in .fade-flow with the rest of the body. That carries a
  transform, so the pill would anchor to it rather than the page, and it sits
  inside the clipped .fade-frame, which would mask it.
- z-index has to clear 40, not merely .scrollbody's 3. The sticky .results-head
  shares this top band, and at any lower value it paints straight over the pill
  -- which looks exactly like the pill failing to render, with no other symptom.

.artist-page was already position:relative, so this introduces no new containing
block into a scroll pipeline that is sensitive to exactly that.

The pill is pointer-events:none since it is never a target, carries
role="status" aria-live="polite" so the stage change is announced rather than
silently repainted, and holds the spinner still under prefers-reduced-motion
rather than dropping the only sign that work is happening.
2026-07-25 20:10:44 -05:00
KeelrLeek 21ef73770c cover logo: keep the source's anti-aliasing, then trace it lightly
The wordmark segmentation mapped alpha with smoothstep(46, 120) over distance
from the background colour. That ceiling is fixed, but the ink distance is not:
white text on a black band sits at ~438, so every edge pixel brighter than about
27% grey saturated to fully opaque. A hard threshold wearing a smoothstep's
clothing. Measured on Camping in Alaska's "Needlemouth" cover, the extracted
mask's edges transitioned over 0.10px against the source's 0.88px -- nine times
harder than the artwork it came from.

The ramp's top now tracks the measured ink distance (95th percentile of
distance-from-background, so it reads the glyph body rather than the brightest
speck), with the old constant kept as a floor so low-contrast marks are
untouched. Retained anti-aliasing on that cover goes from 10.4% of inked pixels
to 22.2%, and the glyphs thin back toward the source instead of being dilated by
the saturation.

That unlocks the trace side, which is the visible half. The blur there was never
a goal -- it exists to synthesise anti-aliasing a mask lacks, and it melts serif
terminals into blobs. With real gradients in the alpha, the cover-logo branch
drops from ~0.93px of blur to a 0.5px floor and from eps 0.5 to 0.2, at a 2400px
raster instead of 1400. Lowering eps ALONE was tried and rejected: against the
old near-binary mask it just captured the pixel staircase in finer detail.

Camping in Alaska's traced path goes 900 -> ~2500 vertices with no staircasing,
and the serif terminals keep their shape. Cost is 35ms -> 65ms of main-thread
work, once per cover-derived logo at page load, not per frame.
2026-07-25 18:12:46 -05:00
KeelrLeek 52b1b8e622 artist page: keep the album reveal's target reachable
Opening an album juddered: the page ran at the clicked row, then stopped
dead just short of it.

revealIntoView lifts the row to the banner line, which needs a pane-height of
content beneath that row. Until the previous commit the pane's ~2000px of dead
scroll always supplied it. With the range ending at the content, a row in the
last screenful no longer has it, so the reveal eased toward a scrollTop the
range could not hold and slammed into the clamp — measured on a 29-album artist:
target 1846 against a max of 1594, 272px in a single frame, then 39 frames
pinned, and the row parked 598px below the line instead of on it. Not a race
with the expander either; it finishes growing at ~500ms and the target is still
252px out of reach.

Reserve the row's own pane-height, the same way the grid already reserves one
for the tab bar so a tab switch cannot clamp the scroll — extended to the open
expander's row and released when it closes.

- On the grid's min-height, because that is what moves this page: .scrollbody's
  height does not answer to padding at all (border-box against a fixed height —
  0px, 200px and 600px of padding-bottom all measured the same 1787px box).
- Reserved, not predicted. The expander's height is not the page's growth: the
  slack in this very reserve swallowed 435 of a 486px panel, so a 486px expander
  grew the page by 66.
- Stood up synchronously by the reveal as well as by measureCols, which only
  recomputes off a ResizeObserver — that fires after the 420ms animation has
  already parked against the old clamp.
- Released on the expander's destroy. measureCols cannot notice the panel
  leaving: the raised min-height is exactly what stops the grid's box from
  changing, so the observer sees nothing and the reserve would stick for the
  rest of the visit, leaving a screenful of empty page under the grid.
- Re-measured across the settle after an open. What moves the target row is the
  card art this very click asked for; those images land over the following few
  hundred ms and rows above the row grow as they do (measured: the row sat 488px
  into the grid when the reveal read it, 555px once settled). Only the first
  open of a page pays this.
- The reveal clamps its target regardless and idles on it for up to a second, so
  a corrected reserve still completes the travel instead of ending short.

Verified on a 29-album artist and an 11-album one: every open lands the row at
exactly the 280px line, first open of a fresh page included, with no direction
reversals and no frames against the clamp. Frame timing improves on both —
cold 13 frames over 16ms (worst 79.6) to 2 (worst 60.6, the album fetch and
decode, which predates this), warm 2 (worst 18.4) to 0 (worst 9). The pane's
scroll range still ends exactly at the content with no expander open, and
returns to it on close.
2026-07-24 18:25:58 -05:00
KeelrLeek e58410d9f8 artist page: always open at the top
.content is the app's one scroll pane and it outlives the view swap, so its
scrollTop was inherited by whatever loaded next: leave one artist 900px down
their discography, open another, and you arrived 900px down the new one —
mid-grid, hero already squeezed into the bar, with no hint the page had a top.

Reset it on catalog.artistOpenSeq, a counter bumped once per openCatalogArtist
call, rather than on the artist's id. The id cannot distinguish a re-open of the
artist already on screen — clicking their name from Now Playing while parked
deep in their own page, where nothing moved and the click read as dead — from no
navigation at all. Nor can this key on the page object: the cold path replaces
it stage by stage under the same id, which would haul a reader back to the top
mid-read. The counter bumps once per open and never per stage.

The bump sits after openCatalogArtist's invalid-id bail-out, so a request that
never opens anything cannot scroll the page the reader is still on.

Verified live: three artist-to-artist hops parked at 1222 each land at 0 and
stay 0 through every cold stage; re-opening the artist already on screen from
1070 lands at 0; mounting from another view with the pane genuinely parked at
1500 mounts at 0; and a reader parked at 600 on the artist they are already on
holds at 600 through six seconds of stage churn.
2026-07-24 17:51:41 -05:00
KeelrLeek 0878659f6a artist page: end the pane's scroll at the content (clip the 300vh sheet out of the scroll range)
The pane could scroll ~850px (1971px at a tall window) past the last content
pixel: at max scroll ZERO of 11 release rows were on screen, the whole viewport
empty page.

The sheet is 300vh of absolutely positioned decoration, and an abs-positioned
descendant extends its scroller's scrollable overflow. .content's scrollHeight
tracked the sheet's box — pin flow origin + the sheet's live translate + 300vh —
instead of the page. Because that translate is scroll-driven, the range also
breathed with scroll position and depended on scroll history: the same scrollTop
measured 4167, 4329 and 4368 depending on the route taken. Hiding the sheet gave
exactly 1797, the page plus the pane's padding-top.

Wrap the sheet in a .sheetclip box with overflow: clip, sized to the pane's
client box, so the sheet paints exactly the region the pane can show and
contributes nothing to the scroll range.

- The clip box is ABSOLUTE and the pin stays height: 0. Sizing the pin itself
  and cancelling with a negative margin-bottom leaves +24px behind — the margin
  collapses against the neighbouring height-0 pins and walks the hero zone from
  24 to 48. Out of flow there is no margin to collapse, and the sticky
  constraint is untouched.
- overflow-clip-margin is not an option for the sheet's 28px bleed: it
  re-expands the overflow region right back to 2440. The bleed moves onto the
  clip box instead.
- The sheet's offsets are now clip-box relative. --band-from-pin is
  pin-relative and the box sits --pin-origin above the pin, so the two cancel:
  --band-from-pin + --pin-origin is --band-top.
- --frame-h (measure()'s scroller.clientHeight) becomes unconditional. It fed
  the scroll-driven-only .fade-frame; .sheetclip needs it on both branches.
  Setting it on the fallback path is inert — .fade-frame's rule lives inside
  the @supports block and never matches there.

Verified live against the pre-change rendering at identical scrollTop, each
comparison bracketed by two baseline captures so the hero's 20s rotation could
not be mistaken for a difference: 0 differing pixels at four of five stops (the
fifth is one chip's hover ring, 83px), hero zone and scrollbody unmoved at every
stop. Scroll range is now content-exact with the clip box flush to the pane at
1280x800, 1600x1100, 1100x620 and 1920x1400, and across artists with 4, 10, 11
and 22 rows. At max scroll all 11 rows are on screen instead of none.
2026-07-24 17:51:24 -05:00
KeelrLeek 2eb1cf57e3 artist page: compositor scroll pipeline + final hero design (crossfade twins, mirror seam, bloom retired)
Scroll-driven animation conversion
- apply() writes nothing per frame on the supported path; the hero
  squeeze, fade line and header curves all ride scroll-driven animations
  on the compositor.
- Section boundaries flip via IntersectionObserver sentinels; the scroll
  listener is gone from the hot path.
- The scrollbar thumb rides its own scroll-driven animation.
- @supports-not fallback keeps the previous rAF --p path verbatim, so
  unsupported engines render identically.
- Curves verified exact to 1e-6 against the old path, 0.000px continuity
  across boundary flips, correct retarget on resize.

Perf fixes
- Forced-layout reads pulled out of both scroll handlers.
- Resize settle-reflow: a second reflow 200ms after resize quiesces, and
  the pane is observed too. This was the root cause of the fullscreen
  fade-wedge and of the long-standing intermittent where rows stayed
  hidden after a resize.

Glass
- backdrop-filter removed from chips and preview surfaces.
- Bar glass blur 20 -> 12.
- Choose-hero pill removed from the UI; pickArtistHero/clearArtistHero
  backend is untouched and the control can be re-homed later.

Hero design (final)
- Crossfade twins are permanent. Twin blur is baked via bakeBlur()
  (clamp-to-edge pad -> blur -> crop) so the frames stay pixel-aligned
  with no edge fringe and no geometry scaling.
- Bloom strip retired everywhere; HeroGpuCanvas's internal bloom canvases
  are hidden by one scoped rule and the component is unchanged.
- Seam is a pure mirror: the artwork's top strip flipped inside the glass
  band, clipped at the bar's top line, riding the squeeze with the hero
  and dissolving in lockstep on rotation.
- GPU twin overlay crossfades on rotation instead of snapping — the
  incoming baked frame fades in on hero-banner-in with a 2-frame cap.
- ~930 lines of experimental variants and the perf-flag system deleted;
  the surviving path is a pixel no-op against the approved configuration.

Debug overlay
- PERF/SEAM rows dropped; the overlay keeps stats plus an FPS/drops/worst
  counter (Ctrl+Shift+D).
2026-07-24 17:02:21 -05:00
KeelrLeek 790a9c60c0 artist page: compositor-locked fade line (sticky host + clipped frame + scroll-driven counter-translate) 2026-07-24 09:15:56 -05:00
KeelrLeek 67d98dfe79 fix: artist page fade line no longer lags fast scroll (compositor-driven)
The content fade mask on .scrollbody lives in the scrolling element's local
box, so its cut stop (--flow-cut) must ride scroll to stay at a fixed screen
line. Driving that from heroScroll's rAF lagged the native compositor scroll
by a frame on fast flicks, so the cut slid up and rows briefly showed above
where they should clip (user-reported).

Move --flow-cut to a CSS scroll-driven animation (@property + @keyframes +
animation-timeline: scroll(nearest block), @supports-gated), so it's a pure
function of scroll position evaluated on the compositor and can't lag. The
keyframe delta and animation-range share one oversized constant (100000px)
so the value is base + scrollTop for ANY scroll distance — necessary because
the artist page changes its own content height as it scrolls, making the
total range non-constant. apply() keeps the per-frame path only as the
unsupported-engine fallback.

Verified live: screen cut constant at 217px across scrollTop 300..2500,
--flow-cut == base+scrollTop exactly.
2026-07-23 17:46:04 -05:00
KeelrLeek 793f78370e feat: crossfade preview suite, loudness normalization, debug overlay
Preview modal audio, five rounds:
- True track-change crossfade: scheduleSettle no longer silences the
  outgoing clip at t=0; preview_load's equal-power fade owns both ramps,
  gated on the incoming voice's first Playing event (audible_marks), with
  failure/overstay fallbacks. CROSSFADE_MS = 340 — locked to the reel's
  card-slide so audio and visuals land together.
- Two-neighbour lease prefetch + a 15-min on-disk clip byte-cache
  (recent_clips): steps in BOTH directions crossfade in ~15 ms; revisits
  never re-download.
- Adaptive settle (isolated steps fire immediately, bursts coalesce) and
  anticipatory auto-advance (step at clip end minus 400 ms) so auto-play
  crossfades over the tail instead of gapping.
- Session resume memory: revisited tracks continue where they left off
  (decoder-level start offset via play_local_from/resume_at); finished
  tracks restart from the top.
- Open/close crossfades with the MAIN player: fade-pause with the
  desired-paused latch landing at ramp end, fade-resume from the paused
  position; MAIN_FADE_GEN guards; focus handshake relaxation documented
  in focusBridge.

Volume normalization (Settings > Playback, default on):
- Dependency-free BS.1770/EBU R128 meter in the player crate (K-weighting,
  400 ms gated blocks, incremental) + analyzer that reads the same growing
  file playback streams from — first plays are measured at download speed.
- Gain layered under user volume; audible start GATED on a preliminary
  measurement (10 s of audio or 800 ms budget) so the level is right from
  the first sample and never changes mid-track; prelim boosts capped 3 dB,
  peak-aware clipping guard, persistent loudness-cache.json.
- Gapless boundaries get their own sample-accurate gain via
  SetNextNormGainDb, fed by preload analysis.

Debug overlay (Ctrl+Shift+D): process-tree CPU / PDH GPU / working-set +
private-commit RAM / network rates / FPS / current-track download progress
read from the backend's own StreamProgress / live normalization state /
preview session phase. Polls only while visible.

131 JS + player + app tests green; every path verified live over CDP.
2026-07-23 16:05:14 -05:00
KeelrLeek d051f0db28 Phase 2C WIP: PreviewOverlay modal + root integration (reel centering bug)
Working:
- PreviewOverlay.svelte mounted at app root; opens over a fixture manifest
  (window.__previewFixture(n) in dev), inert background, player bar hidden,
  focus trap, keyboard nav, F3 rail, T3 swaps, R1b capsule, accent extraction.
- previewSession.svelte.js: store + service wiring (production + fixture).
- Root integration in App.svelte; revision-gateway bumps + notePlaybackGeneration
  threaded through state.svelte.js player entry points; api.win guarded so plain
  vite browser mounts.
- Launcher on the artist page builds a real artist manifest and opens the modal
  with a play-full starter.
- 70 JS tests still green.

Two bugs found and fixed during bring-up:
- effect_update_depth_exceeded: bleed-crossfade and bump effects read AND wrote
  the same $state; wrapped writes in untrack(). This loop was breaking Svelte's
  scheduler globally (even ui.* stopped reacting).
- accentFromImage now sets crossOrigin for remote covers.

KNOWN BROKEN (handoff): reel vertical centering. The active card stays centred
at index 0 but drifts by one stride per step. Root cause isolated — see
TODO-Feature-Docs/scrollable-track-previews/HANDOFF-PHASE2C.md.
2026-07-22 19:26:41 -05:00
KeelrLeek a667b4b28e Baseline: full tree snapshot after git repository loss
The previous .git directory was found gutted (only info/ remained), so
history is unrecoverable locally. This commit restores version control
with the tree exactly as it stands: the working app, the preview-tracks
launcher on the artist hero, the scrollable-track-previews design bundle
(docs + approved mockups), and the Phase 0 validation artifacts.
2026-07-22 17:57:00 -05:00