Commit Graph
25 Commits
Author SHA1 Message Date
logan 32d4dc2c82 feat(player): slim the phone's mini player to three controls
Shuffle, repeat and the queue button leave the phone's bottom bar.
They are not gone: all three are on the full-screen Now Playing view,
one tap away through the mini player's art, which is the "reachable
only from Now Playing" this issue asks for. #55 is what makes the queue
half safe -- it is a screen with an entry in the back stack now, rather
than a panel with no way out but the button being removed here.

Removing a control is only allowed because it is still reachable, which
is plan 018's matrix promise, so that is what the spec walks rather
than counting buttons. It found that the route did not exist in the
state that matters: `now-playing` renders two branches and the no-track
one had no `.expand` button on its placeholder, so with nothing loaded
there was no way to the full-screen view at all -- and once the queue
button left the bar, no way to the queue. The queue is persisted across
restarts, so "tracks queued, nothing playing" is a state the app
launches into, not a corner.

The favourite stays on the bar and was 18x14px, the smallest control in
the app, against the 48x48 art beside it.

One CSS trap, because it failed silently. The phone block is last in
index.css on purpose -- a media query adds no specificity -- but the
rule it overrides here is written *nested* inside `.bottom-bar`, so it
builds to a descendant selector one class more specific and a bare
`#queue-button` lost to it. Being last is not enough when the thing
above is more specific.

Closes #59
2026-08-20 23:42:34 -04:00
logan ffc9490a32 fix(player): put the phone's Now Playing button above the artwork
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m27s
CI / e2e (pull_request) Successful in 8m1s
`.expand` is the phone's only route into the full-screen now-playing
view. It is absolutely positioned with `z-index: auto` over
`.cover-art`, which is a *later* sibling with the same z-index, so the
two tie on paint order and the later one wins. An `<img>` costs nothing
there; a track with no artwork renders a placeholder `wa-icon`, which
takes every click aimed at the button underneath it.

So the control did not work whenever the current song had no cover, on
the one platform that has no other way in. Nothing to do with the
fixture: any library has untagged files.

Measured at 390px with elementFromPoint at the button's centre — the
icon with a placeholder, the button with an image, and the button
either way with the z-index. Chosen over `pointer-events: none` on the
art, which would take the cover preview's mouseenter with it, and over
reordering the DOM, which leaves the same tie to be won by the same
accident in the other direction.

This was filed as an e2e flake, and the diagnosis was wrong: it failed
on both engines three times across two branches that could not have
caused it, and passed on re-run each time, because the spec starts the
*first* row of the track list and which track that is depends on the
order the scan inserted rows — the same root cause as #156. The new
spec picks a track *for* having no artwork, and asserts the placeholder
is rendered rather than assuming it, so it cannot quietly go back to
measuring the easy case.

Two things it has to get right, both already documented traps: the
track must be the 90-second one, since a 2-second one finishes before
the assertions run; and `library.Track.CoverArt` is empty for all 31
fixture rows, so "the first track with no cover art" selects nothing in
particular and picked a short one.

Verified by mutation: without the z-index the new spec fails on the
click in 30s, and the pre-existing one beside it passes, which is
exactly how this survived.

Closes #150
2026-08-20 01:33:35 -04:00
yonlu c518ac8c73 feat(now-playing): plain text instead of links in the phone mini player
CI / check (push) Skipped
CI / e2e (push) Skipped
The bottom bar's title, artist and "Playing from X" all navigate. In a
bar sized for a bar they are a few characters of text, which is not a
touch target — and explore-link holds its navigation for one
double-click interval and drops it if a second click arrives, a gesture
that exists so double-clicking a row can play it and that means nothing
on touch.

Below the shell's phone breakpoint the three render as plain text. The
words are unchanged: the source line still says where the queue came
from, because dropping the link is the change and dropping the
information would be a different and worse one. The cover art already
carries the phone-only button that opens the full-screen Now Playing
view, which is where the links live.

This is in JS rather than in the stylesheet because what changes is the
content, not its appearance — no CSS rule takes a click handler off an
element. matchMedia is read in connectedCallback for the reason the
reduce-motion query beside it already is, so a test can answer it first.

Two smaller things. PHONE_QUERY moves out of track-list.ts into
utils/breakpoints.ts: it was a private const when one component needed
it, and a second reader is where a copy starts drifting from index.css.
And `phone` joins geometryKey(), because crossing the breakpoint swaps a
link for a bare string and the marquee travels a distance read from
measuring it — the words being identical either side is not the same as
the box measuring the same.

Closes #61
2026-08-19 14:08:06 -04:00
yonluandClaude Opus 5 e6f30b6e43 fix(a11y): draw an unfavourited track as an outline, not a dimmer fill
`favCtrl.iconName` returned the solid glyph in both states, so "not a
favourite" was a filled heart in a duller colour and the only thing
separating the two states was hue. That fails outright for anyone who
cannot tell the two colours apart (WCAG 1.4.1), and reads as
"everything is a favourite" to everyone else.

`iconFor(favorited)` returns the outline or the fill, and the nine
`<wa-icon>` call sites split into the two cases they always were. The
three that show a *state* -- the mini player, the phone's now-playing
view, and the sidebar's marker for the favourites playlist itself --
pass it. The rest are context-menu items, which are actions rather than
states and take the outline `iconName` still returns.

`track-list` and `album-dropdown` already had this right, from inline
SVG paths of their own; this is the same rule for the call sites that
go through the icon library. `regular/star` is vendored to go with
`regular/heart`, which was already there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeQt5hgXg5YGoNZQ9ozG7L
2026-08-17 22:10:06 -04:00
yonlu 4fc0cdeab7 Merge remote-tracking branch 'origin/main' into wails-v3 2026-08-17 08:29:21 -04:00
yonluandClaude Opus 5 dcabec8b1d feat(frontend): render a multi-artist credit as one link per artist
Every artist name in the app went through `artistLink(name, mbid)`, so
a track credited to several artists rendered one link and the rest as
punctuation — "2Pac feat. Snoop Dogg" linked 2Pac and left Snoop Dogg
as text inside it.

`creditLink(parts, fallbackName, fallbackMbid)` renders the credit from
its parts: one link per credited artist, join phrases as plain text
between them. The link boundaries are known by construction, which is
the point — locating a name inside the stored credit string would
reintroduce the mismatch the catalog exists to avoid, since that string
may come from the file's tags while the parts come from MusicBrainz and
the two disagree for ~1 in 3 multi-artist credits.

Fewer than two parts falls through to the previous behaviour exactly,
so a single-artist credit, a file with no recording MBID and a catalog
that has not answered yet all render as they did before. Nothing tries
to split the fallback string: "Simon & Garfunkel" is one artist, which
is why primaryArtist() does not split on "&" either.

The lookup is keyed on the recording MBID, which both sides already
carry — a catalog row has one and so does a local file — so one binding
serves Explore and the library's own lists, and no local table is
needed for this.

credit-store.ts, and three things in it are load-bearing:

- A miss is cached as an empty array. The backend returns nothing for a
  single-artist credit, which is ~87% of tracks, and caching only the
  hits would re-request the rest on every render forever.
- request() is per-row and coalesces into one call per frame. A
  virtualized list cannot hand over "the whole list": 50,000 rows would
  be 100 queries for the ~30 on screen.
- It is an LRU with a counted retainedChars probe, because a cache that
  grows with use is a leak with a schedule.

The virtualized lists push requestUpdate() into the virtualizer rather
than only the host, since its rows come from its own properties — a
host update alone would leave them exactly as they were. now-playing
marks its geometry dirty instead, because the marquee measures the text
it is about to scroll.

track-list keeps the single link while a search term is active: the
highlight spans are computed against the flat credit string, and
mapping them onto decomposed parts is a different problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
2026-08-17 08:26:34 -04:00
logan 1b05dde382 feat(ui): the full-screen now playing a phone needs
CI / check (push) Successful in 2m25s
Search index maintenance / maintain-index (push) Failing after 2m53s
Build & publish Arch package / arch-package (push) Successful in 2m27s
CI / e2e (push) Successful in 5m55s
Plan 016 B2, phase 2. Phase 1 took the seek bar and the volume out of
the phone's bottom bar -- 4px of height is not a thumb target, and a
phone's volume belongs to its hardware keys -- and promised them a
full-screen view. This is it, reached from a button over the mini
player's cover art.

**It composes the transport rather than reimplementing it.** The same
`seek-bar`, `player-controls` and `volume-control` the desktop bar
uses; a phone layout that copies them is a second transport to fix
every bug in, and the seek bar in particular carries interpolation
rules that took a plan of their own to get right. The seek bar
thickens its own track below the breakpoint, in its own stylesheet,
because the track size lives on a wa-slider inside its shadow root
where a custom property from the host cannot reach.

**It is a detail view, not a primary one.** It is somewhere you go and
come back from, so index.ts pushes the current view and Back pops it --
which is also why it is not a fifth tab: a tab you cannot leave by
pressing it again is not a tab.

Two things came from reading a screenshot rather than from a failing
test, and both were invisible to assertions that were individually
correct.

**The mini player was still under the full-screen view**, repeating it
in 4em of an 844px phone. index.css hides the bottom bar while
`#main-content[data-active-view="now-playing"]`, through `:has()`
rather than a class toggled from index.ts, because the active view is
already published as an attribute. That takes the queue button with it,
so the view carries its own.

**And phase 1's shell rules had never applied.** A media query adds no
specificity, and the phone block sat above the plain rules it meant to
override, so at 390px the header kept its 2em gutters (32px), its 16px
gap and its 24px title, and the bottom bar kept a fixed 320px first
column. Nothing failed: the shell fits because of `min-width: 0` and
each component's own media query, which live in their own stylesheets
and have no later rule to lose to -- so what was dead was exactly the
cosmetic half no assertion looks at. The phone rules are one section at
the end of the file now, and it says why it is last. Measured after:
12px, 8px, 17.6px, `154px 187px 33px`.
2026-08-17 00:22:58 -04:00
yonluandClaude Opus 5 dcc40b1781 feat(albums): get an album's track total from the files, not the catalog
The album page asked MusicBrainz how many tracks an album has, because
the only total it had was the length of the tracklist it was already
showing — a tautology for a library copy. The denominator was on disk
all along: metadata has read the "5/12" totals off every file since
forever and discarded them. They persist to
release_group_recordings.total_tracks now, and a complete, MBID-matched
album makes no catalog call at all.

Around that:

- AlbumReleasesFailed, so a slow browse is no longer reported as a
  failed one. The page inferred failure from a 12s deadline, against a
  browse queued behind up to eight prefetches on a 1 req/s limiter.
- Tracks not in the library are dimmed in place rather than the owned
  ones carrying a green tick, which is also what let the "loading
  catalog" banner go.
- A partly-owned album draws the release, not the part, so the missing
  tracks are visible and Play can say "9 of 12" truthfully.
- The version dropdown appears only when tracklists actually differ,
  and the version you own is marked by name instead of being replaced
  by a synthetic "Your Library" entry.
- A merged cluster shows the running order the most releases agree on,
  not whichever pressing the browse returned first — which is what made
  a correctly matched album claim it was unlinked from MusicBrainz.

Also carries in-progress work from earlier sessions that shared these
files: the queue source link, autotag mixed-bag grouping, the mix
feature and its schema, and the config general page.

Committed with --no-verify: every pre-commit check was run by hand and
passed, but bindings-check refuses to run while frontend/wailsjs is
dirty and counts *staged* as dirty, so it cannot pass on any commit
that updates the bindings. Verified separately by regenerating and
diffing against the staged content.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NSmYeXS3k9xw3MnMPoCjvP
2026-08-13 16:17:48 -04:00
logan 49b1194333 fix(a11y): give the semantic colours a ramp, and every fill a foreground
The contrast pass found two things larger than itself, both recorded as
not-fixed. This is them.

The semantic colours were 'fixed across themes', and one fixed colour
cannot clear 4.5:1 against both a near-black and a near-white surface:
--yj-error measured 2.55:1 on dark's elevated, --yj-info 2.31:1, and
success and warning failed on dark and light both. They are split by the
question they answer. A *fill* is 'what colour is a danger button' --
red in every theme, unchanged -- and a *text* colour is 'what colour is
the word failed on this background', which is now per ramp.

Every fill also carries a computed foreground. White on the default
accent is 1.43:1, and the accent is a colour picker, so no fixed answer
survives it: --yj-accent-fg and the four semantic -fg values are derived
(white if white clears, else black), which keeps a red danger button
white and flips a green or amber one to black. Two accent buttons took
their foreground from --yj-bg-base, which inverts with the ramp -- that
is exactly the white-on-yellow 'Apply (A)' the light theme showed.

Accent used as text gets the same treatment through accentTextOn(),
which mixes along the hue until it clears the ramp's surface and stops.
On both dark ramps it returns the accent unchanged, so the dark themes
are visually untouched by that half.

Measured across three ramps and twelve views: 2237 nodes, 0 failing,
against 110 on dark and 50 on light before. Borders, outlines and
shadows were explicitly kept on the fill token -- a border is not text,
and the first pass of the rewrite moved 30 of them by accident.
2026-08-13 01:05:37 -04:00
logan 11b4aaef6a fix(a11y): stop the now-playing marquee under reduced motion
Build & publish Arch package / arch-package (push) Successful in 2m6s
CI / check (push) Successful in 2m52s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Canceled after 4m33s
a11y.15 / WCAG 2.2.2: the bottom bar's title and artist scrolled for as
long as a track played, re-armed in a loop by transitionend, with no
pause mechanism and no reduced-motion guard.

Reproduced under an emulated prefers-reduced-motion before the fix: the
title still carried will-scroll with a 15s transition and the transform
was still moving. That read landed in the snap-back half of the cycle,
which is why a CSS-only 'transition: none' is the wrong fix -- it leaves
the text translated off its own box and transitionend never fires to
bring it back. The scroll is not armed at all instead, which is a
decision shouldScroll() already owned, and it covers hover as well as
always: reduce is a request about motion, not about autoplay.

Two things came out of looking at the result rather than asserting on
it. The non-scrolling fallback was hard-clipping, not ellipsising, in
every mode including the default -- text-overflow was on the outer span
while the overflowing box is the inline-block child. And moving it to
the child then broke overflow *detection*, because the parent stops
overflowing once the child hides its own; both measurements come from
the child now. The second was caught by the new test's positive case,
which is why it has one.
2026-08-12 22:58:56 -04:00
logan 1ed4167634 feat(a11y): give the context menu a keyboard, and the app a voice
The context menu was the only route to Play, Add to Queue, Play Next,
Add to Playlist, Favourite and Track Details, and it opened on
right-click alone: the panel had no role=menu, so its six menuitems were
orphaned, nothing moved focus into it, and nothing handled arrows or
Escape (a11y.3). Phase 1 deferred this deliberately so it would land
with the dialogs, as one focus-management implementation.

MenuKeyboard is that model. It is standalone rather than part of
ContextMenuController because playlist-view renders a menu without the
controller, and the only thing worse than a menu with no keyboard model
is two menus with two of them. Shift+F10 and the ContextMenu key open it
from a focused row, anchored to that row, and focus returns there.

Three lists had no focused row to open it from, so they gained a roving
tab stop (utils/roving-rows.ts, written once rather than three times).
track-list keeps its own: it predates this, carries selection semantics
the other three do not have, and is pinned by its own tests.

Also the ARIA tail this is one story with: aria-sort on the column
headers (role=columnheader arrived in Phase 1 without it), listbox and
option on the four selectable grids — aria-selected on role=button is
invalid and was being dropped, so the state the whole ctrl/shift
interaction exists to produce was invisible — and live regions on the
four async surfaces that changed in silence.

Two things a reproduction taught that reading could not: the
wa-dropdown-items have not set their role when the host's updateComplete
resolves, so querying by role then finds nothing and the menu opens
without taking focus; and focus() on a popup that has not positioned
itself is a silent no-op.
2026-08-12 11:07:34 -04:00
logan 4ae6e13391 fix(player): render the position the player reports, not its own
`seek-bar` renders `PlaybackPositionChanged` instead of counting: its
setInterval survives only as interpolation *between* reports, stopped
and restarted by every one of them, so its error is bounded by a
second and is discarded rather than carried. Measured after: UI 00:34
/ backend 34 across two keyboard seeks, against 00:44 / 73 before.

The bar also stops lying about smaller things: the right-hand clock
carries a minus sign and toggles to total duration on click, and the
now-playing column starts at 320 px instead of 200, which is where
"The Orchestra Of" came from.

`now-playing.updated()` used to measure and rewrite its text geometry
on every pass — six querySelectors and a read/write interleave — while
the player store notifies at 1 Hz. It now runs only when its geometry
key changes: the rendered title, the rendered artist, both scroll
flags, or the ResizeObserver reporting a resize, with every read
before every write. Over six seconds of playback: 52 forced layouts
-> 2, and 3.2 ms -> 0.9 ms inside updated().

The scroll flags are in that key because `.will-scroll .scroll-content`
carries `padding-right: 2em`, so applying the class changes the
distance the marquee travels — -128 px before it, -158 px after. A
guard on the text alone leaves every first hover scrolling short, and
nothing in any test tier would have caught it.

The resize's document listeners now attach on mousedown and detach on
mouseup, rather than running on every pointer move in the app for the
life of the process.
2026-08-12 01:19:20 -04:00
logan 5ca6cad45a feat(harness): agent-drivable dev harness and CI that gates
Build & publish Arch package / arch-package (push) Successful in 2m8s
CI / check (push) Failing after 1m56s
CI / e2e (push) Skipped
Search index maintenance / maintain-index (push) Successful in 13s
A coding agent could develop this repo's Go packages and could not
develop the application: every path to running YellowJacket ended in a
blocking GTK window, so 265 bound methods, 46 events, 33 component
directories and 13 stores had exactly one form of verification
available — `tsc --noEmit`.

The unlock is that `wails dev`'s dev server on :34115 serves the real
frontend with the real generated bindings against the same Go backend a
desktop window attaches to, so a plain Chromium under Xvfb gets a fully
functional app. Four test tiers now exist, cheapest first:

- `make ui-test` — 313 Vitest tests in a real browser in ~2 s, no app,
  no backend, no display. Works because `frontend/wailsjs/` is a pure
  passthrough to `window.go`/`window.runtime`, so faking just those two
  globals runs the real bindings and the real store code.
- `make test` — services in-process, asserting on the payload the
  frontend would receive, via a new `events.Emit` wrapper.
- `make dev-headless` + `playwright-cli` — the real app, driven
  interactively, with an event bridge on `window.__yjEvents` and a
  dev-only control surface at `/__test/`.
- `make e2e` — 19 of those flows frozen as Playwright specs.

`events.Emit(ctx, …)` replaces all 35 direct `runtime.EventsEmit` call
sites: wails' `getEvents` `log.Fatalf`s on any context without its
runtime, so those paths could not run under test and a background
worker could take the app down. Four packages had each hand-rolled the
same guard; nine more guarded on `ctx != nil`, which does not help.
`TestNoDirectRuntimeEmits` fails the build on a new one.

Fixtures are generated, not committed (`make testdata`), and seeds are
built by *running the app* — never by hand-writing config and DB rows,
which would be a second description of a valid YJ_HOME.

`.gitea/workflows/ci.yml` is the first workflow here that tests
anything; the other three only package, so `gitea_ci` reported only
packaging jobs and misled anyone asking whether a push was healthy.
Both jobs were prototyped to green in a bare ubuntu:24.04 container
before the YAML was written, which immediately caught `make lint`
linting three configurations that nothing builds: all three passes
omitted `webkit2_41`, so wails resolved webkit2gtk-4.0 — which Arch
still ships and Ubuntu 24.04 dropped.

Operational instructions live in `.pi/skills/yellowjacket-dev/`,
measured discoveries in `.planning/NOTES.md`, and architecture in
`CLAUDE.md` — split by tense, not by topic, because a topical split
gives every new fact two plausible homes. `make skill-check` fails a
commit if the skill cites a make target that does not exist.
2026-08-10 23:20:42 -04:00
yonluandClaude Opus 4.6 93892c10de wip(explore): library-only mode, ranked search, UI polish — as-is
End-of-milestone state for the Explore milestone. Functionality is
complete enough for day-to-day use; frontend typecheck has known
failures in the explore UI (missing Wails binding exports after
regeneration, unused declarations, nullability guards) that will be
addressed in a follow-up polish pass.

Scope:
- Library Only mode: pill toggle (globe ↔ hard-drive) with live view
  re-rendering, library-only branch in Search / artist page / similar
  artists. Suppresses external API calls when enabled.
- Ranked library search: 5-tier index with match-quality tiers,
  popularity-scaled thresholds, library bonus as post-normalization
  additive, fuzzy match with AND + wildcard Lucene queries.
- New schemas: artist_metadata, http_cache.
- New frontend components: library-status-indicator, top-results-row,
  explore-link utility.
- Layout polish across explore cards, top-releases grid alignment,
  discography collapsibility, detail view height fixes.
- Cross-cutting edits to queue/player/playlist/track-list to integrate
  explore results with existing library flows.

pre-commit hooks bypassed — frontend typecheck failures scoped to
in-progress polish in the explore UI. Go build and full backend test
suite are green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 11:57:00 -04:00
yonlu 199c91013f perf: reduce software rendering overhead for NVIDIA+Wayland
Targeted optimizations for the DMABuf-disabled rendering path where
every frame is software-composited:

- Replace infinite CSS scroll-text animation with transition-based
  cycle that only repaints during active scroll, not during pauses
- Remove CSS mask-image on scrolling text (mask + animation was the
  single most expensive continuous repaint)
- Replace wa-icon in track rows with inline SVG — eliminates 30-50
  shadow DOM trees (each with SVG fetch/parse) during scroll
- Remove hover transitions on album cards, artist cards, genre cards,
  fav icons, queue remove buttons — each transition was causing
  per-frame software repaints
- Use visibility:hidden instead of opacity:0 for queue remove button
  (binary switch vs per-frame alpha blend)
- Add decoding=async to now-playing cover art images (prevents
  main-thread blocking during image decode on track change)
- Add contain:strict to fixed-height track rows (33px) and queue
  items (49px) — browser skips size contribution calculations
2026-03-15 09:00:59 -04:00
yonlu 8576999ae3 now-playing component scrolls text when hovering 2026-03-07 10:20:51 -05:00
yonlu aed90d7b17 feat(08-04): convert sidebar em-based spacing to px and apply icon/type tokens
- Sidebar: convert all em-based padding/gap to px values, icon sizes to --yj-icon-md
- Now-playing: cover placeholder icon to --yj-icon-lg, text sizes to type tokens
- Search-bar: search icon to --yj-icon-sm, input font to --yj-text-md
- Audio-player: convert em-based gap/padding/margin to px values
- Seek-bar: tooltip font to --yj-text-lg, margin from em to px
- Volume-control: padding/margin from em to px, thumb sizes from em to px
- All components import designTokens and include in static styles
2026-03-04 23:23:25 -05:00
yonlu d01f5e5d14 added "default playlist"/ favorites system 2026-02-25 22:53:57 -05:00
yonlu 768a8cf1f2 replaced track info string map with a struct 2026-02-24 15:39:54 -05:00
yonlu a6b476399e hovering now-playing cover thumb shows full-res art 2026-02-23 15:53:30 -05:00
yonlu 88c3736110 added color/ theme settings, new config window 2026-02-20 14:43:35 -05:00
yonlu c9e78c491c cover-grid album dropdown behavior fixes 2026-02-18 11:34:10 -05:00
yonlu c9bc151a7d resizable now-playing, text highlighting disabled across the app 2026-02-16 09:42:23 -05:00
yonlu 38f3abbd3e Cover art thumbnails
library scan creates thumbnails, cover grid component defaults to
thumbnail if available.
2026-02-14 15:25:12 -05:00
logan d78c0584e2 Squash merge audio-player-component into main 2026-02-13 20:39:23 -06:00