Commit Graph
11 Commits
Author SHA1 Message Date
logan 1aa1598ecb feat(shortcuts): tell the key story once, and give the arrows back
Build & publish Arch package / arch-package (push) Successful in 2m1s
CI / check (push) Successful in 2m24s
Search index maintenance / maintain-index (push) Successful in 6s
CI / e2e (push) Canceled after 8s
Decision 1 keeps the unmodified single-key bindings, and Settings was
the only place they were written down — three of the four categories of
them, because config-page listed the categories by hand, so the autotag
keys were written down nowhere at all. `?` now opens an overlay from
anywhere the app owns the keyboard, and both surfaces read one table
(services/shortcut-meta.ts, moved out of config-page's private static).

The other half is the same explanation from the other side. Phase 1
gave the arrow keys to the grid, correctly — but all six of them, and
no list in this app moves horizontally: track-list's own handler and
utils/roving-rows both take Up/Down/Home/End and ignore Left/Right. So
seeking stopped working from a focused row and nothing gained the keys.
Reproduced in the running app: two ArrowRights on a focused track row,
zero Player.Seek calls, against one per press from the body.

A shifted character no longer reports Shift, so the binding is `?` and
not `Shift+?` — the character already carries the shift, and a layout
where it does not is a layout where "Shift+?" is wrong anyway.
2026-08-12 12:33:50 -04:00
logan 4615afe7f7 test(e2e): freeze Settings' and Downloads' keyboard reach
Build & publish Arch package / arch-package (push) Successful in 2m0s
CI / check (push) Successful in 2m29s
Search index maintenance / maintain-index (push) Successful in 6s
CI / e2e (push) Failing after 6m23s
A component test sees the markup; only this tier sees that the control
is reachable through the app's own tab order. Also stops
failure-voice.spec assuming the Libraries section starts collapsed — it
is the one section that starts open now, so a blind click on the
disclosure closed it and the rest of the spec ran against a hidden row.
2026-08-12 12:17:13 -04:00
logan 862e8a0468 feat(home): land on Home, and make it worth landing on
Build & publish Arch package / arch-package (push) Successful in 2m3s
CI / check (push) Canceled after 10s
CI / e2e (push) Canceled after 0s
Search index maintenance / maintain-index (push) Canceled after 0s
The app opened on Tracks — an alphabetical list of everything, which is
the one entry point that is identical every time and therefore gives the
user nothing to start from. Home is listed first in the nav and is the
page built to answer 'what should I play' (H-8).

Two things had to be true before that was an improvement.

An album with no cover rendered as a small dim icon on a surface the
same colour as the page, so a shelf read as having holes in it, while
the Albums and Artists grids both drew a letter tile (H-9). It draws the
same tile now.

And a shelf that repeats the one above it is suppressed, the way an
empty one already is — 'On repeat' was 'Pick up where you left off'
reordered. The rule fires only when the shelf is not showing the whole
library: a repeat is a fault only if a different row was possible, and
measured against a fixed shelf size instead this let an 11-album library
keep three identical shelves while a 13-album one lost them.

The first two versions of that rule were wrong and the *existing* Go
tests caught both — it collapsed a four-album library to a single shelf.

Nine e2e specs assumed the app starts on Tracks and now navigate there,
and one new spec freezes the landing itself. Home's page-header action
is 'Shuffle suggestions': 'Shuffle' alone was two different controls
with one accessible name, which only became reachable together once a
cached Home was always in the tree.
2026-08-12 11:42:26 -04:00
logan c13a920487 test(e2e): freeze the context menu's keyboard model
Build & publish Arch package / arch-package (push) Successful in 2m4s
CI / check (push) Successful in 2m29s
Search index maintenance / maintain-index (push) Successful in 6s
CI / e2e (push) Failing after 4m19s
Two of the three things that made it work are invisible to a component
test against hand-built markup: the real wa-dropdown-items have not set
their role when the host finishes updating, and the real wa-popup has
not positioned itself, so focus() on an item is a silent no-op. Both
produced a menu that opened and refused to take focus.
2026-08-12 11:15:04 -04:00
logan b7dc368d7c feat(ui): give every primary view the same page header
Build & publish Arch package / arch-package (push) Successful in 2m0s
CI / check (push) Canceled after 14s
CI / e2e (push) Canceled after 0s
Search index maintenance / maintain-index (push) Canceled after 0s
Four views had a heading and four did not, two had a sort control and
none showed a count, so the app changed shape as you moved through it
and "how many albums have I got" could only be answered by counting.
The reason they disagreed is that each had written its own arrangement:
the sort toolbar existed three times, in track-list, cover-grid and
playlist-view, as the same twenty lines with different bugs.

<page-header> is that arrangement once - title, count, sort, actions -
and nine views adopt it. Artists and Genres gain the sort control they
never had; Artists sorts by name only, because library.Artist carries
nothing countable, so the header renders a label and a direction button
rather than a select with one option in it. The header keeps its place
while a view loads: a heading that appears only once the data does is
the shifting layout this is meant to stop. The count is omitted, not
zero, until the view has an answer.

The header search box keeps its slot on every view instead of vanishing
on the ones it cannot serve - which is what moved the library filter
and the job indicator on every navigation. It is view-scoped by
decision and now says so: "Search albums" in the placeholder, the scope
named in the header ("Showing artists matching 'tide'"), and disabled
with a reason where there is nothing to search or the page has a search
of its own.

Also fixes an e2e trap this uncovered: the view-lifecycle spec toggled
shuffle and never toggled it back, so a second run against the same app
failed playback.spec's shuffle assertion - a failure that reads exactly
like a regression in whatever you are holding.
2026-08-12 02:35:35 -04:00
logan cee19d7ef9 fix(settings): let a library be renamed by clicking its name
Build & publish Arch package / arch-package (push) Successful in 1m58s
CI / e2e (push) Failing after 3m44s
CI / check (push) Successful in 2m31s
Search index maintenance / maintain-index (push) Successful in 6s
The name's click bubbled to config-page's own document handler, which
exists to close the rename editor - so it opened and closed the editor
in the same click and the field never appeared. The overflow menu's
Rename was unaffected because it stops propagation, which is why the
feature looked like it worked.

Found while closing Phase 3 and left for the phase that reworks this
file. The e2e guard opens the editor and abandons it rather than
committing a rename: the specs share one backend process, and a
renamed library fails the ones that assert on fixture content.
2026-08-12 01:47:46 -04:00
logan e9ca16362f fix(ui): make the app fit the window it enforces a minimum for
The track list shared out its whole clientWidth across the resizable
columns while every row spends 24px on the favourite column and 2x8px
on its own padding before the first one starts, so the grid was always
exactly 40px wider than the box holding it and the last column was
clipped at every size (scrollWidth 1280 vs clientWidth 1240, measured).
Both numbers now live in one place and are read by the two call sites
that had written them out separately, which is how they came to
disagree.

The enforced minimum was 512x384, which the layout had never
supported: at 700x480 the eleven sidebar items needed 406px of a 352px
pane, overflow:hidden cut the last two off with nothing to scroll, and
Settings and Jobs could not be reached at all. The pane scrolls now,
the sidebar collapses to icons below 900px (its .collapsed mode existed
and only a manual drag ever reached it), the subtitle hides at the same
breakpoint so the title stops wrapping out of the 4em bar, and the
minimum is 800x600 - measured as where the shell still works rather
than picked as a round number.
2026-08-12 01:44:56 -04:00
logan 1ac919d228 test(e2e): freeze the four reproductions this work started from
Each was written first and watched fail first:

- `view-lifecycle` — pressing `s` on Settings must not skip an album
  out of the Autotag queue, and on Autotag the same key must not also
  toggle shuffle.
- `player-truth` — the elapsed clock tracks the backend through steady
  playback and four keyboard seeks (the measurement that failed by
  30 s), a finished queue keeps the track on the bar at 0:00, and
  auto-advance skips a missing file and reaches the next track.
- `offline-icons` — blocks every non-local request and asserts on the
  <svg> *inside* each icon's shadow root, since asserting the element
  exists would have passed before the fix too. Verified red: 24 empty
  icons.
- `failure-voice` — induces a real binding failure through /__test/sql
  and asserts a sentence appears. Its first version renamed the decoy
  library to its own name, which the backend accepts: it failed at the
  right assertion while never inducing the failure, so it now picks the
  row by the seeded library's name from /__test/health.
- `play-count` — a finished track does not refetch the library.

The queue spec now opens the panel before asserting on its rows and
waits for it to close again: the panel's width is animated and the
transport slides with it, so a click issued during the close lands on
whichever button moved under the pointer.
2026-08-12 01:20:03 -04:00
logan da564f9659 build(dev): generate a 50k-track library and measure a running app
Plan 007 phase 4 is verified by measurement, not by assertion, and
there was no way to produce a number: the fixture library is a few
dozen tracks and cannot show any of the findings.

- `cmd/gentestdata -bulk N` (`make bulkdata`) writes a ~50 000-track
  library in 11 s / 466 MB by encoding six clips once and copying
  them, while still tagging every file through `backend/tagwriter` —
  a library the app cannot read back measures nothing.
- `make sandbox-seed-bulk` seeds from it through the same script and
  the same discipline as any other seed: by running the app and
  waiting for the real scan.
- `e2e/perf/measure.mjs` (`make perf LABEL=x`, `make perf-compare`)
  takes fourteen measurements against a running app and writes them
  to a gitignored `.dev/perf/<label>.json`. It wraps every bound Go
  method, so "did that refetch the library" is a fact rather than an
  inference, and records `longtask` entries, which is where a 25 MB
  JSON parse on the main thread shows up and nowhere else.

It is not a spec and does not run in CI.
2026-08-12 01:17:33 -04:00
logan ff687f0bd9 feat(home): populate the home page with start-listening shelves
The sidebar had a Home item that fell through to "Coming soon". What
was missing was not another view of the library — four of those exist,
sorted and complete — but the opposite: a complete, sorted library is
exactly what gives you nothing to play, because every entry point into
it is alphabetical and identical every time you open the app.

So a shelf is a *reason*, not a filter. Each one answers a different
question you might be asking when you do not know what you want (what
was I listening to, what is new, what do I keep coming back to, what
have I forgotten, what fits, what would I never pick myself) and each
says which question it answered — a row of covers with no explanation
is just another grid.

Two consequences run through it. Shelves are built from what the user
actually did — play counts, last played, import order — with random
sampling only where there is no signal to use, so randomness is the
fallback rather than the design. And a shelf with nothing behind it is
omitted instead of rendered empty: a fresh library legitimately gets
three, and an empty row labelled "on repeat" would be a lie.

The queries return album ids and nothing else, joined back to
GetAllAlbumsWithDetails in Go, so the album projection keeps having one
definition rather than one per shelf.
2026-08-11 01:15:34 -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