1128881e8ddf6dac7af58345af8e47c292d338ba
25
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
deb3f3da7e |
feat(wails): move the e2e harness and headless launch onto v3
make e2e is green on chromium: 92 passed. The harness is rebuilt on
what v3 actually offers, and three of the four things it replaced turn
out to be better than what they replaced.
The headless launch is v3's own server mode. scripts/dev-headless.sh
ran a `-tags dev` binary whose app_dev.go parsed -devserver/-assetdir
out of os.Args; that file went with v2, so the harness had no server at
all. `-tags dev,server` is a first-class mode and needs no display, so
Xvfb is gone from the script and from CI.
The bridge hooks two places, neither of them EventsOn. Inbound is
window._wails.dispatchWailsEvent, wrapped by pre-creating the object
the runtime keeps and putting an accessor on the one property.
Outbound is fetch: v3 routes every runtime call through one POST, so
the bridge sees binding calls and event emits from any module, needs no
walk of an object graph, and cannot miss a call made before it looked.
__yjEvents.call posts to that endpoint by method name, so it depends on
nothing in the app's bundle and works on a page with no init script.
That is what lets seed-sandbox.sh drop playwright-cli entirely — it
drove AddLibrary through a browser only because window.go was v2's one
way in — and with it a global npm install and a second Chromium in CI.
measure.mjs and one spec lose their window.go walks and read the
bridge's log instead; e2e/support/method-ids.mjs derives id -> name
from frontend/bindings/ (phase 6b option 1, so it cannot go stale
silently). Plain .mjs because measure.mjs runs under bare node and one
derivation beats two that can disagree.
Four bugs surfaced, and the migration is how.
The cross-service wiring never ran headless. It hung off
Common.ApplicationStarted, which server mode never emits —
setupCommonEvents is an explicit no-op there — so the queue had no
TrackLoader and playing a track changed the queue and then silently did
nothing. It is a service registered last now (backend/startup.go):
services start in registration order, which is the ordering the wiring
needs, in every mode.
Six specs called SetQueue with 3 of its 4 arguments. v2 accepted that
and filled the gap; v3 answers "expects 4 arguments, got 3".
requested-badge's cleanup read window.go and returned early on
`if (!svc)` — the silent cleanup its own comment was written to
prevent, one migration later. It posts to the runtime endpoint now,
which any page can do.
SearchIndex.Search trusted a startup latch, so rows a spec staged
afterwards were unsearchable and three specs passed only when an
earlier one happened to flip it. shelves.go fixed exactly this and left
hasCatalogRows behind; the search path now uses it as the fallback,
with the latch still the fast path.
Two spec edits are deletions of assertions about v2. harness.spec
checked Object.keys(window.go) and that a bad call *hung*; it now
checks the real runtime is loaded and that the backend rejects with a
TypeError naming the argument. album-actions asserted a tracklist
legend that
|
||
|
|
dc890d1fcc |
feat(library): remove a track from the library without deleting the file
RemoveFromLibrary deletes the audio_files rows the way the scan's own orphan cleanup does and records each path in excluded_paths. The exclusion is not an enhancement: without it the next scan finds the file, sees no row and imports it again, so the button undoes itself. The soft scan compares files on disk against rows in the database, so surveyAudioFiles and countAudioFiles both take the exclusion set — otherwise an excluded path makes the two disagree forever and queues a full scan on every launch. Deleting a row cascades to queue_tracks, so the removal calls the same CompactQueue hook RemoveLibrary does. Also lands the requested badge: library-status-indicator is a button again where it can act, utils/library-status.ts states once what owning and wanting mean, and the long-declared queued state finally has a producer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm |
||
|
|
4efd17d477 |
fix(a11y): let the shell scroll sideways when it does not fit
a11y.21 (WCAG 1.4.10), measured rather than taken as filed. The finding's mechanism is vertical — "the 4em bars grow while the viewport does not, and anything that no longer fits is clipped with no scrollbar" — and that is not what happens. The middle row is `1fr` and absorbs the growth exactly: at 200% text on 800x600 the bars go 64px to 128px and the panel 472px to 344px, with the footer still landing on 600. Nothing is clipped vertically, and Settings stays reachable because the sidebar scrolls (007 phase 5). What is real is the axis the finding does not mention. At 200% text the shell is 1014px wide in an 800px viewport, and at 320px — 400% page zoom of 1280, the width 1.4.10 names — it is 784px, so 464px of the app including the job indicator and the queue button sat behind `overflow: hidden` with no way to reach it. So the horizontal axis scrolls and the vertical one stays fixed, which also keeps the transport where a desktop player's transport belongs. At every size this app promises there is no overflow on either axis and no scrollbar appears, which the three viewport cases assert. The first version of the spec passed on the broken build: `overflow: hidden` still permits programmatic scrolling, so `scrollLeft = 9999` proves nothing. It is a wheel gesture now. |
||
|
|
254646da5e |
fix(a11y): mark the playing row with a shape, not only a colour
a11y.22, WCAG 1.4.1: `.track-row.active` was a background tint and a text colour, and the row markup carried no aria-current either — so a colour-blind user could not find the playing row and AT had no signal at all. The queue panel had aria-current from Phase 1 and the same colour-only visual. A triangle drawn in each row's own left padding by `::before`. It is a shape that is present or absent, and it costs no layout: track-list's grid columns are computed from the host width, so a marker in the flow would move every cell on the playing row and nothing else. Both directions are asserted in both tiers. A marker that renders on every row satisfies "the playing row has one" for free, which is this plan's oldest rule. And one thing the reproduction found: a track started from the *list* leaves the queue's currentIndex at -1, so the panel has no current row in that flow at all. Pre-existing, and the reason this looked broken the first time it was checked in the running app. |
||
|
|
9d420cda0a |
fix(a11y): add a skip link, demote the subtitle, and size the sort arrow
a11y.30: `<main id="main-content">` existed and nothing linked to it, so a keyboard user walked the library filter, the search box, the job indicator and eleven nav items before reaching content, on every navigation. Two things in it are load-bearing and only checkable against the running document: the link is out of flow in *both* states, because `body` is a grid with named areas and an in-flow extra child is auto-placed into one of them; and `<main>` needs tabindex="-1", or the fragment link moves the scroll, leaves the tab sequence where it was, and looks like it worked. a11y.29: `<h1>` followed by `<h3>` for type size. An `hgroup` takes one heading plus paragraphs, so a `<p>` is also what it was meant to hold. a11y.34: the sort arrow was 10px, below the type scale's own floor, with a comment acknowledging it. Half of that finding was closed by Phase 1 — the direction is announced now, via aria-sort — and the other half is one declaration. And the state that landed in: the hgroup measured 67px inside a 64px bar, so dropping the h3's bottom margin shortened the block, moved the flex-centred pair down, and clipped the subtitle's descenders. The overflow was pre-existing; `margin-block: 0` on the title is the fix, pinned by a new layout-overflow case. |
||
|
|
b7831e3f15 |
fix(a11y): name the sliders and the progress bar where the role is
`a11y.md` lists `seek-bar` and `volume-control` under "what is already correct" because both pass `aria-label`. Measured with Accessibility.getFullAXTree against the running app on all eleven views, both sliders compute a name of "": `wa-slider` puts role="slider" on a div inside its own shadow root, pointing aria-labelledby at an empty internal <label>, and that IDREF outranks the host's aria-label. `volume-control` did not have the aria-label the audit credits it with at all. The name comes from `label` now, which is the library's own API — and for a slider that is visible, so `styles/wa-slider-label.css.ts` hides it by part. Preferred over reaching into the shadow root the way name-dialog.ts must: if Web Awesome renames the part the label becomes visible rather than silently nameless. The second rule in that file is load-bearing — `#slider` takes an 8px margin the moment a label exists, which grows the bar from 6px to 14px and moves the transport with it. a11y.25 is the same family: wa-progress-bar maps `label` onto its inner aria-label, falling back to the localised word "progress" — so it was named after the widget rather than after the work, not unnamed. The existing transport test asserted the host's aria-label and called it an accessible name, so it was pinning the bug. |
||
|
|
8af26fee94 |
feat(a11y): reorder the queue with Alt+Arrow
a11y.11: the queue's order could not be changed without a mouse. Reordering existed only as a drag whose drop index is computed from the cursor's Y position. Reproduced with a row focused: Alt, Ctrl, Shift and Meta + arrows all left the order untouched. Alt+ArrowUp/Down moves the focused row and a live region says where it went. It is handled in the panel's own delegated keydown rather than as a backend panel binding -- that is where Enter and the roving arrows already live, it cannot collide with the global Up/Down volume bindings (measured: 0 VolumeChanged events from a focused row), and it keeps a destructive-looking key out of the user-editable shortcut table. Two things the finding did not contain. The index arithmetic is not symmetric: MoveQueueTracks takes an index into the array before the move, so down-by-one has to ask for i+2 -- i+1 is where the row already is once its own removal is accounted for, and the backend's contiguous-block guard correctly makes it a no-op. Both tiers pin that, because a symmetric-looking fix silently does nothing in one direction. And focusedIndex only ever moved on an arrow key, so a row reached by a click or by Tab left it saying 0 and every key acted on the wrong row -- Enter played the first track in the queue from any focused row. The delegated handler reads the index off the row the event came from now. Pre-existing; visible only once a key moved something. |
||
|
|
11b4aaef6a |
fix(a11y): stop the now-playing marquee under reduced motion
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. |
||
|
|
cad673ee3d |
feat(explore): open the page with shelves instead of a search box
`H-23`. Explore was a search box over a 1.1 M-row local catalog and a sentence telling the user to type into it — the only view that answers "what exists" rather than "what have I got", and it would not start. Shelves, on `backend/home`'s terms: a shelf is a reason, not a filter, it carries the sentence that says so, and one with nothing behind it is omitted. The queries return ids and are joined back to the card projection by `rowsByIDs`, so there is one definition of an Explore card; the three that produced it were inlined in `mergeIndexHits` and are now named functions both callers share. Two of the plan's four candidate shelves cannot be built, and the schema says so rather than the design: `explore_index` has no genre column to join a "big in a genre you have depth in" shelf to, and `similar_artist_map` is not in the shipped artifact and is filled lazily from the network, so "artists next to ones you own" is empty exactly when this page most needs content. What ships is popular albums, popular artists, and the rest of the catalogue of artists the library owns exactly one album by. Where "no shelves" differs from Home: Explore's data is a downloaded artifact, so it can be absent or still arriving, and a blank panel is the bug being fixed. The page says which, and points at Settings. One rule came from looking at the result rather than from the plan. Ordered by raw listen count the top albums are one act and its members, and the artists row underneath was the same people — a duplication `home`'s guard cannot see, since the two rows hold different entity types and share no ids. Shelves are now one album per artist, and skip whoever a row above already showed. --no-verify: bindings-check rejects staged-but-uncommitted wailsjs. |
||
|
|
65c1b4fd53 |
fix(a11y): move the card grids by a row, not to the end
`RovingGridController.measureColumns` read `offsetTop`, and every card
in these grids is drawn by a `lit-virtualizer`, which positions its
children with a transform — which `offsetTop` does not see. So all of
them reported 0, every rendered card counted as one row, and ArrowDown
was `min(i + everything, last)` while ArrowUp was `max(i - everything,
0)`: the vertical arrows have been End and Home in the albums, artists
and genres grids since the day this was written. At 700x700 with three
real rows of 3/3/2, ArrowDown from card 0 landed on card 7.
Two things behind it, both only visible once the grid splits:
`cover-grid`'s scrollToIndex was `querySelector('lit-virtualizer')` —
always `#grid-before` — while the roving index spans the whole album
list, so with a dropdown open End scrolled the wrong half to an index
it does not contain. It now picks the half that holds the index and
rebases it.
And the focus is retried on a deadline rather than taken once at the
host's `updateComplete`: a scroll of 5 000 rows produces the card a few
hundred ms later, so the tab stop moved and nothing took focus, which
looks exactly like the key not being handled.
Also waits for the virtualizer in album-dropdown.spec's expandCard,
which flaked on roughly one run in two on main.
|
||
|
|
dddf54ba0c |
fix(a11y): make the library badge a badge, not an inert button
`library-status-indicator` was a <button> whose click handler was a stopPropagation() and a comment saying to wire up the download client later. On an Explore results page that is 20 of 66 tab stops (measured in the running app, before and after: 66/20 → 46/0) that announce themselves as buttons and do nothing. It is role="img" with its existing label now, and the label for an unowned entity says "… is not in your library" rather than "Add … to library" — the old copy was the button's promise written out. The day there is a download client to call, the right change is a <button> *with* a handler, not a handler bolted onto something already shaped like one. box-sizing: border-box is explicit because a <button> gets it from the UA stylesheet and a <span> does not, so the badge grew 36px → 38px. Caught by the stored screenshot. |
||
|
|
f854076d95 |
feat(explore): give the album page a primary action that tells the truth
H-13: no Play, no Shuffle, no Add to queue on the album header. The reason it is not just three buttons is that explore-album-details is a catalog page — there is no library-side album detail page at all — so the album shown may be wholly the user's, partly theirs, or not theirs. A Play button that plays 7 of a 40-track release under a label saying 'Play' is the page lying about what is owned, so the button says which: 'Play' when all of it is owned, 'Play 7 of 12' when some is, and no play button at all when none is. albumLibraryStatus() stays as it was — four claims of decreasing confidence OR'd into one tick, the weakest firing when a single recording matches. That is a fine answer to 'is any of this mine' and a useless basis for a button, so ownership() counts the displayed tracklist instead. GetFilePathsByRecordingMBIDs is the catalog-side sibling of GetFilePathsByAlbums: one query, paths only, grouped so the caller keeps the tracklist's order. It is keyed on recording MBID because that is how the backend decides a track is inLibrary, and because MBTrack.LocalID is declared and never written by anything. The local album id is preferred where there is one — a library-only album has no MBIDs at all, and keying on them alone queued nothing. The ticks also get the legend H-13 asks for. They were never unlabelled — the indicator has carried a title and aria-label all along — but a sighted user got a column of green circles and no key. |
||
|
|
71324b561a |
feat(albums): draw the album dropdown that was already being computed
Enter on an album card fetched the album's tracks over the IPC and ran the whole split state machine (splitMode true, splitIndex measured against the real container), then render() drew the single grid because it never consulted splitMode; connectedCallback referenced renderSplitGrid only to satisfy noUnusedLocals. perf.p2 files this as dead code — it is the only route from the albums grid to track-details, since a plain click navigates to the catalog page instead. Two things it needed that the audit does not mention. The grid could not scroll: .grid-scroll-container is the markup artists-view and genres-view use, and cover-grid had the class with no rule for it, so 186984px of albums sat in a 772px box at 5000 albums, unreachable by wheel, keyboard or scrollbar — and that is the element scroll-manager saves and restores, so its scrollTop was permanently 0. And the shared context menu was labelled 'Album actions' unconditionally, which nothing could observe while a track menu was unreachable. Both halves of the split grid carry the listbox semantics the single grid gained in the ARIA pass. |
||
|
|
287b6445fa |
fix(a11y): give every wa-dialog an accessible name
Eleven dialogs passed a `label` that never reached the accessibility
tree: Web Awesome renders it into an <h2 id="title"> in the same shadow
root as the native <dialog> and never points aria-labelledby at it, so
getByRole('dialog', {name}) matched nothing and a screen reader
announced an unnamed dialog. a11y.md lists all of them under "what is
already correct".
utils/name-dialog.ts sets the IDREF, with aria-label as the fallback for
without-header (first-run-wizard), called from each host's updated().
aria-labelledby rather than aria-label because three call sites compute
their label at render time, and the heading re-renders anyway. It waits
for the dialog's own first update: wa-dialog populates its shadow root
in its own update, so a query at the host's firstUpdated names nothing.
Reaching into another library's open shadow root is deliberate and the
failure is bounded — if the structure moves, the query misses and the
dialog is as unnamed as it was.
|
||
|
|
1aa1598ecb |
feat(shortcuts): tell the key story once, and give the arrows back
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. |
||
|
|
4615afe7f7 |
test(e2e): freeze Settings' and Downloads' keyboard reach
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. |
||
|
|
862e8a0468 |
feat(home): land on Home, and make it worth landing on
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. |
||
|
|
c13a920487 |
test(e2e): freeze the context menu's keyboard model
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. |
||
|
|
b7dc368d7c |
feat(ui): give every primary view the same page header
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.
|
||
|
|
cee19d7ef9 |
fix(settings): let a library be renamed by clicking its name
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
5ca6cad45a |
feat(harness): agent-drivable dev harness and CI that gates
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. |