ea16e07c4626af5c193914cb2d01459f8950cad1
71
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
018d857746 |
feat(shell): global back and forward in the top bar
The history stack has been global since the Android back gesture landed -- every navigation is an entry and `popstate` restores any of them in either direction. What the report describes as "back is tab-scoped" is that the only way back was a detail view's own button, which leaves the screen with the view it belongs to: click over to Tracks and the album you were reading is still one entry away with nothing on screen saying so. `<nav-history>` is that affordance, plus `nav.back` / `nav.forward` on Alt+Left / Alt+Right -- the browser's own combination, and clear of the bare arrows that seek, since a binding matches on its full canonical string. Forward is not back negated, which is why the old `pushedEntries` counter is gone rather than extended: `popstate` carries no direction and fires identically both ways, so one counter decremented on every pop reads a forward as a second back. Each entry carries its index and the shell keeps the current one and a high-water mark, which also survives a jump of more than one. The buttons dispatch the events the rest of the app already dispatches rather than calling `history` themselves -- the shell owns the guard that stops a press at the root leaving the app, and a second caller reaching for history is how the old `navStack` came to disagree with the platform. Below 900px the control stands down: the top bar is what runs out of room first below that, and nothing becomes unreachable -- the shortcuts are global at every width and the phone has the platform's gesture. Closes #6 |
||
|
|
f18691560d |
fix(shell): publish the active view, so both navs follow the back path
The nav components learned where the user was from the `navigate` CustomEvent, which only the outbound path dispatches: `popstate` calls `handleNavigate()` directly. So a back-navigation left both of them highlighting the view just left — desktop included, at any width, on any back across two primary views. Opening a detail view was the same cause wearing a different symptom: `app-sidebar` guarded on its own item list and kept its highlight, `bottom-nav` did not and lit nothing. It cannot be fixed by re-dispatching `navigate` — `index.ts` is that event's document listener, so that is an infinite loop, and "please go to X" is not the statement being made. `activeViewStore` is the shell saying "the active view is now X", once per navigation, `popstate` included; both navs read it through a controller and hold no `activeView` of their own. A store rather than an event because a component that mounts *after* a navigation still has to know: `bottom-nav`'s drawer builds its `app-sidebar` on open, and that copy had heard nothing at all, so the drawer opened on Home from any page in the app. Closes #72 |
||
|
|
f967916550 |
fix(page-header): collapse the actions that do not fit into a menu
Playlists slotted three buttons totalling 390px into a header that gets 700px at 900x600, so "New Smart Playlist" rendered 114 of its 162px with the queue closed, and 158 of 162 at the 800x600 enforced minimum. On a phone none of the three could be reached at all, which is what the Android report said. Plan 018's size matrix promises the opposite: no action is ever unreachable at any supported size. The header could not fix that for slotted markup, and that is a fact about the API rather than an effort estimate — a component cannot move another component's light-DOM children into a dropdown and keep their behaviour, and arbitrary markup offers nothing generic to render as a menu item. So a host passes `PageAction[]` and the header chooses the rendering; the slot survives for markup a data list cannot express, at the stated cost that a slotted action does not collapse. All three hosts that slot actions migrated, which also normalises the plain-<button>/<wa-button> split between them onto one shape the header styles — and lets it measure a button that has already upgraded, rather than a wa-button whose shadow DOM arrives in its own first update. Four things in it are load-bearing: - Every measuring pass starts from all-visible, so the collapsed set is a pure function of the current width and an action comes back when the window grows. It flips `hidden` imperatively rather than re-rendering between steps, or the intermediate state paints and the fix flashes the overflow it exists to prevent. - "Fits" means nothing is clipped, not that the header does not overflow. Once the title can ellipsis it absorbs the pressure and scrollWidth reports a perfect fit while the heading reads "Playlis…" — this bug moved from the button to the title, and invisible to the same measurement that missed it the first time. - New Playlist has the highest priority because it is the drop target and a closed menu cannot be one. `PageAction.drop` therefore carries the host's own handlers; the affordance is absent from the overflow rather than approximated there. - The overflow trigger is a named button with aria-expanded and an aria-controls naming a panel that is always in the DOM, and the keyboard model is the shared `MenuKeyboard`. `layout-overflow.spec.ts` passes on the broken build — it asserts the shell needs no sideways scrolling, and clipping inside a component is invisible to it, which is why this defect survived a spec named for it. The new spec measures each button against its own header at four viewports and asserts buttons plus menu account for every declared action, without which it would pass vacuously on a build rendering none. Closes #69 |
||
|
|
ff3c4003cb | Merge branch 'fix/61-mini-player-plain-text' into fix/quick-wins-batch | ||
|
|
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 |
||
|
|
977f624123 |
fix(home): gate the card play button on the device having hover
CI / check (push) Skipped
CI / e2e (push) Skipped
The play button on a home shelf's cover cards is revealed by :hover, and a touch long-press synthesises a hover state in the WebView — so on a phone it flashed into view during the 500ms hold that utils/long-press.ts is measuring for a context menu. A control appearing because the user was reaching for a different one. It is gated on `(hover: hover) and (pointer: fine)` rather than on width, so it is absent on any touch device and present on a desktop with a small window. A phone user taps the album and plays from the detail view, so nothing replaces it. The default outside the query is display:none, not opacity:0. An opacity-0 button still takes taps and is still in the accessibility tree, so leaving the reveal as the only guarded part would keep the hit area for a control the phone can never show. The test asserts the parsed stylesheet rather than rendering as a phone, and says so: CDP's Emulation.setEmulatedMedia does not reach this tier's iframe, so matchMedia still answers `hover: hover` after it is set. The regression worth catching is someone hoisting the rule back out of the query as a tidy-up — a change no desktop assertion can see. Closes #68 |
||
|
|
4025106234 |
fix(queue): overlay the content instead of taking its width
The panel is flex-shrink: 0 in the flow of .content-area, so an open queue was paid for by the main panel rather than covering it. Measured on Playlists: 379px of content left at 900x600 with all three of the page header's actions clipped, 69px at 390px, and 0px at 320px — where the content was not degraded but gone. It goes to an overlay with a scrim when the content cannot spare the width, and the rule is computed rather than breakpointed: `available - panelWidth < 480`, where available is .content-area's width and so already accounts for the sidebar's collapse at 900. A media query cannot express this, which is the reason for the property: the panel is drag-resizable between 200 and 500px and persisted, so a viewport breakpoint silently assumes the default 320 and is wrong by up to 180px for a user who widened it — in the direction that hurts, since a wider queue is exactly when the content can least afford it. 480 is a judgement and the comment says so: there is no cliff to derive it from (the track list rescales continuously, 213px to 124px columns with no row overflow), so it is anchored to keep the default 1100px window inline while putting every measured-broken case on the overlay side. The overlay is a presentation and not a fork — #55 asks for one component with two mount points — so the roving tab stop, Alt+Arrow reorder, drag reorder and selection semantics are untouched. Escape closes it and returns focus, attached only while the overlay is up: it is a dismissal rather than a shortcut, which is why it is not a panel-scoped binding. The scrim covers the content area only, not the sidebar or the transport, because the queue is not modal. Refs #24 |
||
|
|
b5d70ac1cd |
feat(explore): offer the autotag match on the album page
The complaint was having to notice the metadata was missing, then go and hunt the album down on the Autotag page. The album page now says it while you are looking at the thing: "MusicBrainz has a match for this album: <release> by <artist>", with Apply tags and Review in Autotag. Four things about it are load-bearing. **Applying is offered only where it would do the whole album.** A tagging group is a folder, so a multi-disc album is several, and one button that applied to the best-scoring group would leave the album holding a mix of old and new tags — the exact case the app's Blocking notification level exists for. `groupCount` is the test, and the answer there is review rather than apply. **It rewrites files, so it asks.** `confirmAction()` with an impact line that says it cannot be undone and that nothing is moved or deleted, because "rewrites your files" reads worse than it is. The apply goes through `ApplyAsync`, the registered-job path, so progress belongs to the jobs indicator and this page does not grow a second one — what it owes the user is the acknowledgement, because the button is here. The suggestion clears itself on success rather than inviting a second click while the job runs. **The banner does not quote a percentage.** The backend has a score and deliberately keeps it out of the sentence: 0.95 reads as a probability and is not one. Which release it is, is the part a person can judge. **"Review in Autotag" lands on that album.** The queue is sorted by score so the intended folder is often near the top, and "often" is a link that sometimes opens a different album. Autotag is a cached primary view, so there is no construction to hand a payload to: the request goes on as an attribute and the view *consumes* it, or every later visit would reopen a folder the user finished with long ago. `ICON_AUTOTAG` joins the vocabulary at the same time, on the rule `ICON_PLAYLIST` was chosen by — an icon names the noun it acts on, so a suggestion pointing at Autotag wears the Autotag destination's own mark. It was written inline in the sidebar; two call sites is where a name stops being one component's detail, so the sweep governs it now. Verified against the running app with a staged match: the banner, the confirm dialog's wording, and the navigation landing on the right folder with the attribute consumed. Closes #28 |
||
|
|
21b303ba7c |
fix(ui): stop a closing dialog answering the next question
`confirm-dialog` is one singleton for every confirmation in the app, and `wa-dialog` reports its close asynchronously: `open = false` starts an animation and `wa-hide` arrives after it. So a hide belonging to a question already answered can land after the *next* question has opened, and cancel it — the user is asked something, the dialog vanishes on its own, and the call site is told they said no. Each ask now carries an id. `close` ignores an id that no longer names the question on screen, the button handlers pass none (they always mean the current one), and only the `wa-hide` handler carries one, because only `wa-hide` can arrive late. Found by writing two `confirmAction()` tests in one file: the second could not be accepted at all, because the first one's hide had cancelled it before the click landed. Reaching it in the app needs two confirmations close together, which the album page's "Apply tags" makes possible. |
||
|
|
905654cc84 |
feat(explore): demote the album page's version selector to a disclosure
Choosing which pressing you are looking at is an advanced, metadata-repair task, and it sat directly above the tracklist with a heading, a `<select>` and a paragraph explaining how our clustering picks a "standard version" by weighing release count, status and date. That is a sentence about our own heuristic in the most valuable space on the page. It is now "Other versions of this album (N)" below the tracklist: a real `<button aria-expanded aria-controls>` inside the heading that names the section, with the body rendered unconditionally and toggled with `hidden`, because `aria-controls` has to name an element that is in the DOM. Both rules are `config-section`'s rather than new ones. It is demoted, not removed — matching the wrong release is a real problem and this is how it gets fixed. **Two more blocks shared that slot and neither was guarded.** The selector at least had `distinctTracklistCount() <= 1`; the `Versions / Loading releases…` spinner and the `Versions / <error>` block did not, so both took the primary position on every album regardless of whether there was ever going to be a choice. The spinner said what `renderTracklist` was already saying about the same fetch, so it is gone. The error was the one `catalog-scope-notice` shows at the top of the page with a retry — every path that sets `errorReleases` also sets `catalogFailed`, the only route to `unavailable`. That error is what made this a rewrite rather than a move. `renderTracklist` returned `nothing` on `errorReleases` and leaned on the selector's own block to have said it, and a control inside a collapsed disclosure cannot be a page's error surface. The failure belongs to the list that is missing because of it, so that is where it is drawn. **What must not be lost is which version is on screen.** The default is what the header already describes, so saying it on every album would be this issue's own complaint one size smaller. `defaultVersionKey` is the test: a line appears above the tracklist only once someone has chosen another, naming it and offering the way back. The ★ and the words "in your library" survive unchanged inside the panel, and the panel does not close when the selection changes — a panel that shuts on use cannot be used twice. The `<select>` also loses an `aria-label` of "Select release version" that outranked its own visible `<label>Version</label>`, which is a label not in the name. Verified against the running app as well as the suite: the collapsed page, the open panel, a chosen version and 390px width all read correctly, and the shell still measures 390 in a 390 viewport. Closes #17 |
||
|
|
88fc50afb8 |
feat(explore): mark what is not owned, everywhere it can be shown
`explore-album-details` had the rule right for one tracklist and nothing else did: Explore's cards, `top-results-row` and the artist page's three card shapes all mixed owned and unowned with a small badge as the only difference, and drew a green tick on the *common* case — which is the treatment that tracklist's own green ticks were removed for. `utils/ownership.ts` is the rule written once, so eight call sites stop each holding their own version: - owned is plain, and draws no badge at all; - unowned is dimmed *and* says so in its accessible name, because dimming is a colour and cannot be the only signal; - a partly-held album says how partly. **Ownership is a file, and `localId` is the flag that says so.** The album page answers with `filePaths`, a real file per displayed track; a card grid cannot afford that and does not need to, because `local_*_id` is built by queries that all join `audio_files` and cleared by a prune whose existence test is a file test in every case. `inLibrary` is written by the same pass, so the two agree in a healthy database — but it is a one-way ratchet (`MAX(in_library, excluded)`) whose only clearing pass is gated on a non-null local id, so it cannot be un-set on its own. Where they already diverged was the client. Both `explore-view` and `explore-artist-details` kept a `libraryMBIDs` set that accumulated every MBID ever seen with `inLibrary` and cleared it never, in views that never unmount. Both are deleted. And one card answered the question twice and got two answers: `renderReleaseMenuItems` gates Play on `localId > 0` while the badge and `albumTarget.owned` used `inLibrary`, so an album with the flag and no local row drew a tick saying it was in your library, offered no Play, and — the request item being gated on *not* owned — offered no way to ask for it either. The count comes from `completenessStore`, shaped like `credit-store`: `request()` is per-card and coalesces a screenful into one `GetAlbumsCompleteness`, absence is cached as an answer, and the whole cache is dropped on a scan, a retag or a removal rather than aged. `aria-disabled` goes on rows that cannot be activated and deliberately not on cards: an unowned card still navigates to the catalog page for it, which is a perfectly good thing to do with something you do not own. Audited and unchanged: `home-view`, `downloads-view`, `cover-grid`, `artist-details` and `genre-details` cannot show catalog content, so everything on them is owned and "owned is plain" is already what they do. The album page's own header badge stays, because that page is about one entity and the badge is its answer rather than a mark on one of many. Closes #38 |
||
|
|
19c68d73a7 |
fix(ui): keep the count in a partial badge that can act
A control is named after what activating it does, so an actionable badge said "Request album X" — and `partial` is actionable, because an album you hold nine of twelve tracks of has three left to ask for. That made the one state the ring exists for the one state whose name did not mention it. The argument the `partial` branch already carries does not stop applying because the badge became clickable: a ring says "some" to a sighted user and nothing to anyone else. The name is now the action and the count. |
||
|
|
89882b4863 |
refactor(ui): give the icons one vocabulary and sweep the call sites
`plus` meant "add to the queue", "add to a playlist", "make a new playlist" and "you do not own this" -- the first two adjacent in the same context menu, so two neighbouring items were the same glyph doing different things. `list` meant the queue (the button that opens it), the Playlists destination, and adding to the queue in `queue-panel` alone. Two icons carrying seven meanings is not a vocabulary, and nothing catches it: a wrong-but-real icon renders perfectly. `utils/icon-language.ts` is the table, beside `library-status.ts` as the issue suggested. The rule it is built on is that an icon names the **noun** it acts on, not the verb: "add to queue" and "add to playlist" are one verb on two nouns, so the noun is what differs -- which is why adding to a playlist wears the Playlists destination's own icon, and why the queue took `bars-staggered` and stopped wearing Playlists'. `plus` keeps the one meaning it is unambiguous about, making something that is not there yet, which covers New Playlist and the drop zones. `bars-staggered` is the only new glyph, vendored through names.txt and fetch-icons.mjs after confirming it is in Font Awesome **Free** 7.3.1. Two things this found rather than changed: - The request toggle's outline/solid pair was already in the app and already right -- `explore-album-details`'s "Request this" button has used `regular/bookmark` -> `solid/bookmark` since it was written -- while the badge forty pixels away showed a **plus** for the same state. That is `utils/library-status.ts`'s fault one layer down: it made the two surfaces agree on what wanting *means* and left them disagreeing on what it looks like. - `explore-artist-details`'s Follow button was `bookmark-check`, which is Font Awesome **Pro** and has never been bundled, so it has drawn the missing-icon fallback -- a circled question mark -- for every followed artist since it was written. `requested-badge.spec.ts` was written for exactly this bug on the album button and says so in its docstring; this is the same bug one component over, still live, because `offline-icons.spec.ts` sweeps `__yjIconMisses` and no spec had ever followed an artist. So the test does what reaching the state cannot. `icon-language.test.ts` reads every `src/**/*.ts` as raw text and fails on a governed name written outside the table, and separately asserts every `ICON_*` is a *bundled* name -- which is what makes a Pro name a failing test rather than a runtime report from a state something has to reach first. Its first assertion is that it read any source at all, because a sweep over an empty glob passes. `chrome.test.ts` asserted `['check', 'bookmark', 'plus']` and so pinned the badge's glyphs against the vocabulary they were meant to follow; it names them from the table now, and keeps the assertion that the three differ, which is the property the states actually need. Downloads keeps the solid bookmark on purpose. That is one word twice, not two words: the badge says the entity is on your list and the nav item is that list. Closes #34 |
||
|
|
aa59773d22 |
feat(explore): let the album page be asked for the whole tracklist
An album the user holds part of showed only the tracks on disk, with nothing to say the rest existed. The page could already draw the full release with the missing rows dimmed -- it just could not be asked: the automatic rule fires on `completeness.known`, which depends on the files declaring a per-disc total, or failing that on the catalog's own `total_tracks`. Neither reaches most albums. #16 fixed the first input for anything tagged from now on, and the second is worse than it looks: the published artifact is from 2026-08-10 and the column landed on 08-16, so `completenessAnswer()`'s catalog fallback answers 0 for every user until the index job republishes. Measured, and noted on #88, which is the publish that carries it. So the control is explicit. A "Show the whole album" switch flips the synthetic "Your Library" entry between the local files and the release, which is the same rendering, reached deliberately rather than inferred. Three things about it are load-bearing: - `showFullTracklist` is a tri-state, `null` meaning "follow the automatic rule". The rule is right when it fires, and the switch has to agree with the page it is sitting on rather than starting out contradicting it -- a plain boolean would need its default recomputed every time the completeness answer moved underneath it. The user outranks the rule in both directions. - `fullReleaseCluster()` falls back to the highest-scoring cluster. `findLibraryCluster` is a guess over the `inLibrary` flags and returns nothing at all when none are set, which is exactly the untagged library this exists for -- without the fallback the control would be absent precisely where it is needed. The sublabel names the release either way rather than leaving the user to wonder whose tracklist they are reading. - It appears only where it can change what is on screen: against the library entry, with a release to switch to, and only when the two tracklists differ. A complete album's release has the same rows as its files, so the switch would redraw the same list and read as broken -- the same test the version dropdown one section up already answers. The accessible name is asserted rather than assumed, through the browser's own computation. `wa-switch` happens to get it right, and for a third reason again: its `<input role="switch">` sits inside a native `<label>` that also holds the `<slot>`, so the name is computed across the flattened tree from light-DOM text. This app has shipped the opposite twice. Closes #7 |
||
|
|
887a9324b4 | Merge remote-tracking branch 'origin/fix/drag-count-badge' into integration/small-fixes | ||
|
|
fcb484ead5 | Merge remote-tracking branch 'origin/fix/album-card-year' into integration/small-fixes | ||
|
|
48de41cd69 | Merge remote-tracking branch 'origin/fix/album-tracklist-heading' into integration/small-fixes | ||
|
|
66a6ee63ab | Merge remote-tracking branch 'origin/fix/seek-bar-clock-width' into integration/small-fixes | ||
|
|
10660c8168 | Merge remote-tracking branch 'origin/fix/wanted-without-client' into integration/small-fixes | ||
|
|
441b67daaa | Merge remote-tracking branch 'origin/fix/album-track-request-badge' into integration/small-fixes | ||
|
|
73dc80bdc9 |
fix(explore): stop hiding the request badge until the row is hovered
The badge on a row you do not own was transparent until the row was hovered or focused. That rule was inherited from the green ticks it replaced, and it does not survive the reason those went: a tick marked the *common* case, while this marks the rows that are not here. A mark on the exception is the information on this page, and one that appears only under the pointer cannot be seen, counted, or reached by anyone driving the app with a finger. The repaint half of #33 is fixed in #82; this is only the visibility, rebased to leave that alone. Refs #33 |
||
|
|
760021ea5a |
fix(downloads): stop searching a list there is nothing to search with
Every pass attempted every request, each came back "no download clients are enabled", and RecordAttempt wrote that down as an attempt and put a retry on the clock -- so a wanted list built deliberately without a client accrued failures and announced "next check in 6 hours" about a check that cannot happen. Wanting something with no way to fetch it is supported. Being told it is being looked for is a lie, and the row says what is true instead. Everything above the attempt still runs: an artist subscription still expands, and a request satisfied by some other route -- ripped, bought, copied in -- is still retired. Neither needs a provider. TestReconcileRespectsBatchSize now installs a client that finds nothing, because a batch size is about how many requests one pass searches for and that only means something when there is something to search with. Refs #37 |
||
|
|
63ec068add | Merge branch 'main' into fix/small-issue-batch | ||
|
|
12e75ee24c |
feat(ui): badge an album drag with how many tracks it carries
Dragging an album to the queue put its cover under the cursor and said nothing about how much that was -- an album is 1 track or 30 and the thumbnail is the same picture either way, so the one number the drop is about was the one thing the drag did not show. Every other drag in the app already says it; this was the exception, because it had a picture to show instead. A count of 1 draws no badge: "1" over a single cover is noise, and the absence reads clearly beside a badge that only appears above one. The badge sits inside the cover's box rather than overhanging it, because setDragImage snapshots the element and anything outside it risks being clipped -- while padding the box instead would move the cover away from the cursor. Refs #19 |
||
|
|
792e87298b |
fix(ui): stop the album grid eating the year it was sorted by
The year sat inside the same ellipsis box as the title, so it was the first thing truncation took: a card wide enough for a long album name never showed its year, and browsing the grid *by year* showed years only for the albums with short names. The sort said one thing and the cards showed another. Title and year are now a flex row where only the title gives way. A row rather than a second line, because the card's height is what the virtualizer measures rows by. Refs #29 |
||
|
|
266e7032dd |
fix(explore): stop labelling the album tracklist "TRACKLIST"
A list of numbered titles with durations, under the album's cover, was the one thing on the page carrying a word above it saying what it is. What goes is the ink and not the element: the section is a landmark and the page's heading structure runs through it, so the h3 stays and is clipped the way sr-only clips -- never display:none, which would take it out of the accessibility tree along with the layout. Refs #9 |
||
|
|
d6b48fb3ac |
fix(player): stop the seek bar resizing as its clocks count
Two different things moved it and they need different answers. Digits in a proportional font are different widths, so 1:11 is narrower than 4:08 and the bar breathed once a second -- tabular figures fix that. The character *count* changes too, at the hundredth minute and whenever the right-hand clock is toggled to remaining and grows a minus sign, which a figure width cannot fix -- so each clock reserves the widest string this track can put in it. The budget is per track rather than a constant: reserving six characters on every track would push the slider in by a character at each end to buy nothing. Measured in the component tier: 4.5px of drift across three positions before, none after. Refs #13 |
||
|
|
bf4f352117 |
fix(queue): stop claiming a queue came from somewhere it no longer does
`q.source` was written by SetQueue and cleared in exactly one place, Clear, so no append path touched it: adding a track to a queue built from an album left the page still offering "Playing from <that album>", and since the source is persisted alongside the queue state the wrong label outlived the session that earned it. Every add and insert path drops it now. Removing and reordering deliberately do not — a queue with a track taken out of it is still that album, and the link still goes somewhere true. Only the arrival of a track from elsewhere makes the claim false. The delta event carries the source for the same reason it carries the current index: an append emits nothing else, so the frontend would keep the label it was last given until something forced a full state. Closes #14 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
1062b7c0bc |
fix(explore): tell Lit that a track request changed something
The album page's tracklist badges read `libraryStatusFor(false, track.mbid)` at render time, which is a dependency on `downloadStore` that Lit cannot see. The page did subscribe to that store, but its callback only assigned `canDownload` and `isRequested` — neither of which a *track* request changes — so no reactive field moved and the component never re-rendered. The request was filed, the plus stayed a plus, and clicking again cancelled it. The other three hosts rendering these badges have always asked for the repaint in the same place, which is what made this one look correct on inspection. Closes #33 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e3d492e130 |
fix(downloads): call a request a request, and mark it with a bookmark
The feature was renamed to requests and the copy was not. The badge on every Explore card and track row still offered "Want track X", the album page's button read "Want this" / "Wanted", the artist page's release menu said "Want This", and the Downloads empty state told the user to look for a control by a name nothing rendered. The `queued` badge is a bookmark rather than an hourglass. An hourglass says "wait, this is under way", which overstates what a request is: nothing may be downloading, nothing may ever be found, and the list is somewhere a user can leave one indefinitely. A bookmark says the honest thing -- it is on your list -- and reads as the opposite of the plus that put it there, which is what a toggle's two states have to do. The backend's `'wanted'` request state is deliberately untouched: it is a stored enum, not copy. Also removes a dead duplicate branch in the badge's `render()`. The first `if (this.actionable)` returned before the ring was built, so a partly-held album that could still be requested drew a plus instead of its progress arc. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MeQt5hgXg5YGoNZQ9ozG7L |
||
|
|
b505959934 | Merge remote-tracking branch 'origin/main' into wails-v3 | ||
|
|
2c78b58207 |
feat(ui): the track list a phone can read
B2 phase 4, and the last of it. Measured on the device: at 424 CSS px the four configured columns fit the row *exactly* -- `--grid-cols` came out `24px 102px 101px 101px 80px` -- and not one of them fit its content, with "Duration" too narrow for its own header. The columns were never too wide; there were too many of them. So a phone draws `titleArtist` (the title with the artist under it, across the row's whole width) plus the duration, and drops the column headers and the resize handles, which are a click-to-sort and a drag with no touch equivalent. It is a **column set, not a second row template**: the row, its delegated events, the selection semantics, the playing marker and the virtualizer never learn anything changed, because from their side only the number of columns did. Three rules come with it. The row height is in two places (`PHONE_ROW_HEIGHT` and the CSS rule) and must agree, since the virtualizer positions rows from that number and a taller row overlaps its neighbour. What is drawn and what can be sorted are different questions, so the sort list is built from `configuredColumns` -- a phone has no headers either, and building it from the drawn columns would leave it able to sort by title and duration alone. And a phone's column widths are neither loaded nor saved. That third rule is the bug the device found with the arrangement already passing five component tests and five e2e specs at the phone's own viewport. `loadColumnWidths` is keyed by column *id* and fills a gap with `MIN_COLUMN_WIDTH`, so the stacked column -- which nothing can ever have saved a width for -- came out at 148px beside a duration column of 236. The mirror image was worse and unreachable from a phone at all: saving would have written those widths back under the same ids, replacing the width the user dragged on a desktop. The specs asserted shape, and the fault depended on what `localStorage` held for a different column set; the unit test now carries that map as a fixture. Verified: 809 component tests, 112 e2e specs, and on the phone at 424x439 -- `24px 304px 80px`, 52px rows, no truncation, no overflow. One full e2e run of three saw an unrelated autotag keypress spec flake and pass on retry. |
||
|
|
4fc0cdeab7 | Merge remote-tracking branch 'origin/main' into wails-v3 | ||
|
|
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 |
||
|
|
e8690476bd |
feat(ui): long-press opens the menus a right-click opens
Every context menu in the app opens from a `contextmenu` event, bound three different ways across six components -- delegated on a virtualizer, per row, per card. A phone has no right-click, so a phone reached none of them (plan 016 B2 phase 3). This is one document-capture listener installed once from `index.ts`, not six components' worth of touch handling: a touch that holds still for 500ms dispatches a synthetic `contextmenu` at the touch point, and every existing handler runs unchanged. A seam no component has to opt into is one no future component can forget. Four details are load-bearing, each a way the obvious version fails. The target is `composedPath()[0]`, not `elementFromPoint`, which stops at the outermost shadow host -- every menu here is bound inside one, so a host-targeted event reaches a delegated listener and no per-row one. A browser that fires its own long-press `contextmenu` (Chromium does; WebKit and the Android WebView vary) wins, and ours is told from theirs by identity rather than `isTrusted`: `isTrusted` works in the app and is untestable, which would leave the suppression path as the one thing with no coverage. And the click ending the gesture is swallowed, keyed on the gesture rather than a time window, or the first tap on the menu it just opened is eaten too. The e2e spec presses `.track-row`, not `[role="row"]`: the column header is a row too, and it is the first one -- a press on it is correctly ignored, which reads exactly like the gesture not working. |
||
|
|
1b05dde382 |
feat(ui): the full-screen now playing a phone needs
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`. |
||
|
|
57fbbdf0d2 |
feat(ui): a shell a phone can be held in
Plan 016 B2, phase 1. Below 600px the grid drops its sidebar column, `bottom-nav` becomes the primary navigation, and the shell fits the viewport instead of scrolling sideways out of it. 600 rather than the sidebar's own 900, because 900 is a laptop and the answer there is a narrower sidebar, which is still a sidebar. Under 600 there is no room for one at all: 360px of viewport over a 200px nav is not a layout. **The tab bar is four destinations and a way to everything else.** Three to five is where touch targets stop being thumb-sized -- eleven over 360px is 32px each -- so the four are the ones plan 016's subset says a phone is for, and "More" opens the *existing* `app-sidebar` in a drawer rather than listing the destinations a second time. Two lists is two places to add the next view to. That reuse has a cost this found the hard way: a shared component brings its `data-testid`s with it, so rendering the drawer's sidebar unconditionally put a second `nav-home` (and ten siblings) in the DOM and **failed 30 existing specs** with "resolved to 2 elements" -- on a desktop viewport, where this element is `display: none` and the drawer can never open. It renders only while the drawer is open, and the component test asserts the absence, because the failure is invisible from inside the component and lands in files nobody touched. **What made the shell overflow was minimums, not padding.** Measured at 360px: the body was 652px wide, because a `min-width` in a flex row is a hard floor and a grid item's implicit minimum is its content. So `min-width: 0` on the boxes between the viewport and the content, and each component stands its own non-essential parts down in its *own* stylesheet -- search-bar's 200px floor, job-indicator's label (the visible one; the live region that announces it is untouched), audio-player's seek bar and volume. A media query inside a shadow root is answered by the viewport, so this is the component saying what it drops rather than the shell reaching in. Volume goes because the hardware keys own it on a phone, which is the same reason mediacontrols' Android handler implements no volume callback. Seeking goes because 4px is not a thumb target; it belongs to the full-screen now-playing view, which is the next phase. An existing spec therefore asserts the opposite of what it did: layout-overflow's 320px case used to require that the 464px behind `overflow: hidden` could be *scrolled to*, which was the remedy available while the shell had one layout. It reflows now -- 320px in a 320px viewport, exactly -- and reflow is what WCAG 1.4.10 asked for. |
||
|
|
e14a34fccf |
fix(android): let the app reach the user's music
Three of plan 016's four blockers. Each is a different reason the app could not work at all on a phone. **It had no permission to read anything.** The generated manifest asked for INTERNET, VIBRATE, biometrics, location and a camera, and nothing whatever about storage -- so at targetSdk 35 the app could see its own private directory and no music. It now declares READ_MEDIA_AUDIO, the two capped legacy storage permissions, and MANAGE_EXTERNAL_STORAGE. That last one is deliberate and is the load-bearing choice. This app is a library manager: audio_files.file_path is the primary key of ownership, the scanner walks a directory the user chose, and tagwriter rewrites files in place. MediaStore offers no stable directory to walk and no in-place write, so scoped storage is not "more work" here, it is a different application. MANAGE_EXTERNAL_STORAGE is Play-restricted, which is acceptable only because this ships as an APK through the package registry -- if it ever targets Play, that line is what has to go, and plan 016 says what replaces it. It is granted on a Settings screen rather than in a dialog, so it cannot be requested with requestPermissions(). MainActivity opens that screen on every cold start until access exists -- there is no degraded mode worth offering -- and re-checks in onResume, because the way back from another task is a resume, emitting android:storageAccess so the frontend can react. **The first-run flow could not complete.** All three call sites asked for a folder through the Wails dialog, which returns an error on Android: SAF yields tree URIs and this app is keyed on paths. So the app browses the filesystem itself, which it can now do. ListDirectories lists directories only (the thing being chosen is a library root), skips what it cannot stat rather than failing the listing (Android's storage root holds directories no app may enter), follows symlinks (os.DirEntry reports the link, so a symlinked music folder would silently vanish), and hides dotted entries. utils/pick-directory.ts is the one place that chooses between the two, so the three call sites changed by one line each. **Which platform is asked of the backend**, not of System.IsAndroid(): the dialog is backend code, so the backend is what knows whether it can open one; it answers for iOS at the same time; and it keeps the fallback testable through the ordinary transport fake rather than a module mock of the Wails runtime, whose platform helpers read build constants. **And MPRIS was compiled into the Android build**, because android implies the linux build tag, so it went looking for a session bus that does not exist. mpris_linux.go is `linux && !android` now and the stub covers Android, which means no lock-screen transport there yet -- a missing feature rather than a broken one, and the remaining blocker. The foreground service is typed mediaPlayback rather than the scaffold's dataSync, with the matching permission, so playback can survive the screen locking once there is a MediaSession to drive it. The type in the manifest and the one passed to startForeground must agree or startForeground throws. |
||
|
|
e7748f1fd5 |
feat(database): shape the library like files, and shrink the catalog
Plans 013 and 014, the album page that prompted them, and the smaller fixes they turned up. Changelog, largest first. ## The local library is shaped like files, not like MusicBrainz `audio_files` carries its own tags and points at `albums` and `artists`; `file_genres` is the one real many-to-many. `recordings`, `release_group_recordings`, `artist_credit`, `artist_credit_artist`, `recording_genres`, `release_groups` and `release_to_rg` are gone from the local side, and with them a six-way join in every read, a `MIN(release_group_id)` subquery in eleven queries and a first-credited-artist subquery in nine. Measured on a real 25,966-file library, every many-to-many that model expressed was 1:1 in the data. - Ownership is a file. `GetFilePathsByRecordingMBIDs`, `LibraryMBIDIndex.CheckMBIDs`, `collectLibraryEntities` and `pruneStaleLocalCrossReferences` all join `audio_files`, so the 812 orphaned recordings, 216 release groups and 260 artists that library carried are now structurally impossible. - One projection: every track query selects from the `track_metadata` view, one row type, one mapper. Nine hand-rolled copies had drifted far enough to report different years on different screens. - `library_id = 0` means every library, so each list query exists once instead of scoped and unscoped with a branch at every call site. - No migration chain. `sql/schemas/` is the one description of the shape; `sql/migrations/`, `applyMigrations` and `schema_migrations` are squashed away, along with the drift between them that had sqlc generating against a stale schema. - `database.InsertTestTrack` is the one test seeder; twenty test files had been assembling the old FK chain each in its own order. ## The catalog stores its ids as bytes `explore_index`'s three 36-char MBID columns and its entity-type text are 16 raw bytes and a small integer. The table and its six indexes go 780 MB to 405 MB on a real 2,052,200-row catalog, which is why a fresh install is ~0.6 GB rather than ~1.0 GB. - `backend/explore/mbid.go` is the only place the encoding is known; everything above it speaks dashed strings. - `CHECK(length(mbid) = 16)` makes a stringly write fail at the insert rather than silently returning no rows, since SQLite does not coerce between TEXT and BLOB. - The importer asks the artifact what encoding it carries and converts on the way in, so the artifact already published keeps working and no format bump is needed. - `indexRowColumns`/`scanIndexRow` replace four copies of a 22-column list, and `TestStoredEncodingRoundTrips` sweeps every read path. ## An album page that says how much of the album is yours - One question, asked once: is there a file. `filePaths` is filled by a single batched lookup when the tracklist settles, and the badge, the Play count, the dimmed rows and every menu item read it — replacing four claims of decreasing confidence that could show a green tick on an album whose every action did nothing. - Play, Play 7 of 12, or no play button at all. - `total_tracks` on `explore_index` (~2 bytes over 400,677 release groups) and on `audio_files` from tags that have always carried it: a complete MBID-matched album now makes no catalog call at all, where it used to spend the most expensive request the app makes. - A merged cluster shows the running order the most releases agree on, and the version list marks the release you own rather than standing a synthetic entry in for it. - `AlbumReleasesFailed`: a slow fetch is no longer reported as a failed one by a 12-second timer. - Rows not in the library are dimmed in place (with `aria-disabled`) instead of the owned ones wearing a green tick and a legend. ## Caches and cover art get ceilings - Only the three tiers of a cover are stored; the full-resolution copy nothing rendered was 1,134 MB of a 1.4 GB covers directory. - One artist portrait is downloaded and the rest are remembered as URLs — 4.1 GB of a 5.3 GB cache was candidates no code path reads. - `browsedArtBudget` and `httpCacheBudget` bound what an age cannot: the same install held art for 5,770 artists in a 1,301-artist library. - `OrphanedArtistImagesJob` joined a bare MBID onto a sharded directory, so it deleted the rows that were the only record of the files it left behind. `explore.ArtistImageDir` is that layout's one definition now. ## The autotag queue asks whether there is work `tagging_items` was a row per album folder, not a queue, and no query read the `tag_status` column that held the answer. The four queue queries ask the files, which matters most where it is least visible: `startPrefetch` was scoring every album in a tagged library against MusicBrainz. ## Phantom playlist tracks resolve in place An M3U8 imported before its files leaves phantom rows; they now match by path and fall back to position, keep their place in the playlist when resolved, and pair best-first so two phantoms cannot claim the same file. ## Playing a track plays the list it is in Double-click, and Play on a single row's menu, queue the list as displayed with `startIndex` on that row — the album page and the track list used to queue one track and discard the album around it. A multi-row selection still plays exactly itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh |
||
|
|
a4ada725a2 |
feat(wails): rebuild the Vitest fake on v3's transport seam
v2 installed two globals and the fake replaced both. v3 has neither — the runtime is an npm module and the generated bindings call into it. What it has instead is better: setTransport() is a public seam for replacing the IPC transport, and *every* runtime call goes through it, so the fake is smaller than v2's and covers strictly more. The event dispatcher is no longer mirrored at all. v2's fake reimplemented desktop/events.js — the listener list, maxCallbacks expiry, the reverse iteration — because there was no way to reach the real one; emit() now goes through window._wails.dispatchWailsEvent, which is the entry point the backend's own push uses. What is mirrored instead is one line of Go: how EventManager.Emit packs variadic data into an event's single data field. Registration and unregistration are the public Events API. The one non-public thing left is the listener registry, aliased in vitest.config.mts and used only by listenerNames() — a test asks whether importing a store subscribed it, which nothing public can answer. A binding carries a method ID, not a name, so the fake derives the ID -> path map from the generated tree: FNV-1a over the FQN, with the Go type's casing recovered from each package's index.ts, which is the only place it survives (library/library.ts cannot tell you it is FrontendUtil). The map has to be complete rather than lazy because 21 assertions read calls() with no argument and compare the whole list. Two things had to move that are not the fake. fixture() drains microtasks between two renders: a v3 binding settles several hops later than v2's, and tests were already written as though fixture() meant "mounted and loaded". Microtasks and not a timer, which would hang under the suites that install fake ones. tracklist-store keeps its defaults on an empty answer instead of emptying the column list. GetTrackListColumns substitutes DefaultColumns only when the whole config section is missing; a section that exists with no columns returns nothing. Until now this was accidental — the binding was typed Column[], an absent answer arrived as undefined, and .map threw into the catch. 757 tests pass across all 63 files. They are run in batches: a single browser session dies partway through the 58 it queues, which reproduces unchanged at the pre-migration commit and is a resource limit on this machine rather than anything here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm |
||
|
|
162c68769f |
feat(wails): move the frontend onto v3's generated bindings
frontend/wailsjs/ is deleted and frontend/bindings/ takes its place — a real TypeScript module tree nested by Go import path, generated by wails3's static analyser rather than by building the app and running it. The @go alias absorbs the constant prefix, so a call site imports '@go/library/library.js' and the codemod over all 93 sites was a specifier rewrite plus splitting @go/models' namespaces into one import per package. The 12 SetContext bindings and the fake `context` model are gone, as Phase 2's ServiceStartup port promised: 272 methods across 12 services, none of them plumbing. @runtime/runtime is now a local shim (src/wails/runtime.ts) over @wailsio/runtime, so the 22 EventsOn imports are untouched. It unwraps v3's WailsEvent into v2's callback shape, which is exact here: nothing in backend/events passes more than one data argument, and v3 only packs arguments into a slice when there is more than one. v3 tells the truth about two things v2 lied about, and that is most of the diff. A Go nil slice really does arrive as JSON null, and a Go named string type really is an enum; v2 typed them as T[] and string. utils/binding.ts states the app's actual contract — an absent list is an empty list — once, at the boundary where it is true, and also drops the CancellablePromise the app never cancels. Four test fixtures widen an enum field back to its value union. Not done, and Phase 5's to fix: frontend/test/support/wails-fake.ts still fakes window.go, which v3 does not have, so `make ui-test` is broken and harness.test.ts fails to compile on EventsEmit. That test also asserts v2 ordering that no longer holds — v3's Events.Emit calls the backend and does not notify in-page listeners at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm |
||
|
|
edb13a6f39 |
perf(explore): ask the disk once, prefetch once, and menu the releases
Three things on the Explore surfaces, all about not asking twice. A portrait already on disk costs no network call. explore-view seeded only from the library store — owned artists, which on a catalog search is nearly none of the results — and sent everything else to GetArtistImageURL, the resolving entry point, one await at a time. GetArtistImagesCachedPaths asks the disk about every unresolved artist in one call, and only what it does not answer reaches the resolver, in parallel. The artist page's two sections both wanted PrefetchReleases and each called it, so the most expensive call the app makes was issued twice for an overlapping set on a 1 req/s limiter. They are collected and sent once on a microtask, and prefetchRequested stops the cold-artist refetch re-asking for what it already asked for. The release cards — most of the artist page — had no context menu at all. They have one now on both release shapes, normalised to a ReleaseMenuTarget when the menu opens so the union does not reach the action handlers. It is a discriminated union rather than one nullable field per kind because the panel is shared with the track menu: that is what keeps aria-label moving with the target, which is the fault cover-grid shipped. Which items appear is three different questions — playback is gated on a local album id, not on "owned", and the request needs a catalog MBID, so it is absent for a library-only release. Note on the docs: the CLAUDE.md and NOTES.md prose here was reconstructed after a mishandled `git stash --keep-index` destroyed the uncommitted originals. One NOTES.md section is marked as incomplete where its text could not be recovered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm |
||
|
|
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 |
||
|
|
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 |
||
|
|
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. |
||
|
|
2b41c27616 |
fix(a11y): let a clipped value be read, and name a row's own buttons
a11y.24: `text-overflow: ellipsis` in 40+ places, and the four highest-density lists were the ones with no `title` — the queue panel (whose width is user-resizable down to MIN_WIDTH), track-info, every track-list cell, and the playlist sidebar. In track-list the attribute is on the *cell*, not on what is inside it: the value may be a link, a highlighted search match or plain text, and a tooltip is inherited by descendants either way. One binding rather than three, and the same value the accessor already computed. a11y.32: every queue row's remove button was named "Remove from queue", so a list whose entire purpose is which track is where had four identically named controls. |
||
|
|
f00d0c4655 |
fix(a11y): name every form control in Settings
Measured with Accessibility.getFullAXTree against the running app with all seven sections expanded: 24 of 93 controls computed an empty name. Every config-field select and toggle, and all eighteen track-list column checkboxes, had a <label> sitting right beside them with nothing associating the two. Now 0 of 93. Not in the audit, and a11y.6 says why in its own line: it scanned every <button>, and none of these is one. Same shape as the count that sent Phase 1 looking for an unnamed sort control — the claim was answering a narrower question than it reads as. The fields use `for`/`id` rather than aria-label, for what it buys beyond the name: the label text becomes a click target for the control. A fixed id is safe only because each config-field is its own shadow root. Two more are named but identify nothing, which is a11y.32's complaint one page over: three shortcut buttons announced themselves as "S", and thirty-six column arrows as "Move up". |
||
|
|
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. |
||
|
|
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. |
||
|
|
533c084f8a |
fix(a11y): make every text colour clear WCAG AA on every ramp
a11y.md flagged --yj-text-tertiary on --yj-bg-surface as 'borderline (~4.1:1) but that needs a real measurement', and plan 007 parked it as 'worth measuring before planning'. Measured, against the rendered app and then across all three background ramps: it failed AA in nine of twelve text/surface combinations, as low as 2.31:1 on dark's overlay and 2.55:1 on light's -- the app's most-used secondary text colour, failing on every view. Not borderline. 110 failing nodes across twelve views, now 0 of 659. Three separate mechanisms, and only the first is the finding: - The ramps. Tertiary is raised per ramp (#a6a6a6 dark, #949494 darker, #5c636a light), sized to the lightest surface it actually sits on and keeping its hue. Sizing it to bgOverlay too would need a grey lighter than secondary, so bgOverlay is documented as not a text surface and the one component that put text there uses primary. - The avatar generator. hsl(hue, 45%, 35%) behind white initials failed for 35 of the 360 hues -- the yellow-green band -- so which artists were unreadable depended on how their names hashed. The two a sweep found were not the finding. 32% clears every hue. - Jobs' local #ff6b6b, at 4.15:1 on elevated. Pinned by a unit test over the palette table rather than a DOM sweep: the ramps are pure data, and checking only what happens to be on screen is exactly how the light ramp went unexamined. Note that make ui-visual cannot see any of this -- the component tier renders the fallbacks, because theme-store sets :root only in the real app. |