e3b64f92551514f35ef3a2bc885e867ca6f55662
429
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
32d4dc2c82 |
feat(player): slim the phone's mini player to three controls
Shuffle, repeat and the queue button leave the phone's bottom bar. They are not gone: all three are on the full-screen Now Playing view, one tap away through the mini player's art, which is the "reachable only from Now Playing" this issue asks for. #55 is what makes the queue half safe -- it is a screen with an entry in the back stack now, rather than a panel with no way out but the button being removed here. Removing a control is only allowed because it is still reachable, which is plan 018's matrix promise, so that is what the spec walks rather than counting buttons. It found that the route did not exist in the state that matters: `now-playing` renders two branches and the no-track one had no `.expand` button on its placeholder, so with nothing loaded there was no way to the full-screen view at all -- and once the queue button left the bar, no way to the queue. The queue is persisted across restarts, so "tracks queued, nothing playing" is a state the app launches into, not a corner. The favourite stays on the bar and was 18x14px, the smallest control in the app, against the 48x48 art beside it. One CSS trap, because it failed silently. The phone block is last in index.css on purpose -- a media query adds no specificity -- but the rule it overrides here is written *nested* inside `.bottom-bar`, so it builds to a descendant selector one class more specific and a bare `#queue-button` lost to it. Being last is not enough when the thing above is more specific. Closes #59 |
||
|
|
218e4f5e99 |
feat(player): give the transport a context, and thumb-sized controls
Measured at the reference device's 424x439, every button here was 33x21px -- in the bottom bar and on the full-screen view alike. #56 reports them as "the most important thing in the mobile app and they are tiny", and that is the number behind it. The context is a **property, not a media query**, and that is the whole design. Everywhere else in this app a component states what it drops at phone width itself, because a media query inside a shadow root is answered by the viewport and that is the honest signal. Here the two hosts want different answers at the *same* viewport: on a phone the bar wants three controls sized for a thumb and now-playing-view wants five, larger still. So the host says which context and the viewport says which size band, and neither alone can express it. Play/pause alone goes above the 44px floor. A row of five identical squares says every action is equally likely, which is not true of play -- "large play/pause, adequate prev/next" is the Direction, and a spec caught that the first version had sized all three the same. Two things that fail silently: The desktop bar must not move, and a `<button>` does not inherit its font from its parent -- the UA stylesheet gives it one. So a generic `font-size: inherit` is not the no-op it reads as: it took every desktop control from 33x21 to 36x24. The box rules take a zero fallback and the font-size rules are scoped to the two contexts that set one. And the art on now-playing-view overflowed its own box, drawing over the header above and the title below, because `aspect-ratio: 1` with a definite width derives a height that nothing bounds -- 60vh bounds the viewport, not the room left over. `max-height: 100%`. Pre-existing; found by reading a screenshot, which is the only tier that can see it. What is left is #172: with the transport at 172px of a 439px screen the art is a 39px sliver. Closes #56 |
||
|
|
a70a7ed9eb |
fix(queue): size the queue screen's way out for a thumb
Measured at 424x439: the three header actions were 25x21px. That matters more than it looks, because with the panel spanning the whole width the scrim underneath it has no uncovered pixels at all -- so the close button is the only pointer route out of a full-screen surface, and it was below the 24x24 floor in one dimension. Sized only in overlay mode. Inline these sit in a 320px column beside the content, where a mouse is what reaches them and 44px of header is 44px the queue does not get. |
||
|
|
de2cb2693a |
feat(queue): give an overlaid queue a place in the back stack
The queue's pixels were already right. Measured at the reference device's 424x439, #24's overlay is 424x318 -- `.main-panel`'s rect exactly -- so the `DETAIL_LOADERS` mount the issue's Direction asks for would draw the same rectangle in the same place. What was missing was the navigation model: opening the queue on Artists and pressing back moved the page *underneath* to Albums and left the queue up, which is a press that changes something the user cannot see and costs them their place. So the queue is a *place* exactly while it is an overlay, and a *control* while it is a column. A column is a thing the user docked -- back must not undock it and a navigation must not take it away -- and that reuses #24's computed mode rather than adding a breakpoint, so the drag-resizable panel width keeps deciding it. It is in neither `VIEW_TAGS` nor `DETAIL_LOADERS`, because there is nothing to mount and moving it would cost something. `.main-panel > *` computes `contain: content` under a `.main-panel` that does too, and paint containment clips the `position: fixed` a `wa-popup` falls back to on Chrome 113 (#60) -- so the detail-view mount would have broken `queue-panel`'s working context menu on the one device this is about. The panel's ancestry today is paint-free to `body`. Two details that fail silently otherwise. The entry is unwound from the panel's `open` attribute in the observer that already ran for `aria-expanded`, not at each of the four ways out -- without that the entry is orphaned and the *next* back press is the one that closes the queue, which is this defect moved one press later. And the navigation writes neither `dataset.activeView` nor `searchStore.setCurrentView`, because both describe what is *in* the main panel and the queue covers that panel without replacing it. `now-playing-view`'s copy of the button went through the helper too: it set `open` directly, so on a phone it produced exactly the queue with no entry behind it that this removes. Closes #55 |
||
|
|
1ab767a317 |
feat(shell): take the top bar out of the phone's layout
The row is deleted from the grid template below 600px, not the header hidden. That is 3.25em of a 439 CSS px viewport -- the single biggest vertical win the reference device has to give, and the reason the issue asks for the row rather than for a smaller bar. Each of the five things the bar held has somewhere else to be there: nav-history is the platform's own back gesture and was already gone from 899 down, the job indicator is <job-band> (#62, which is what this was blocked on), the search box is a modal opened from the view's own header, the library filter is Settings -> Libraries, and the wordmark stays where it is. Three things are load-bearing. **The header is visually hidden rather than display: none**, because that h1 is the document's top-level heading and several pages have no other one -- page-header renders no h1 when its heading is empty, and Settings has no page-header at all. Its four controls are display: none *inside* it, which is what keeps them out of the tab order: a visually-hidden container is still focusable, and tabbing into a search box nobody can see is worse than not having one. **The fit pass stands down**, from the bar's computed position rather than from a width. With the bar out of flow there is no content box to measure children against, and a pass that ran would collapse the wordmark on every resize and report success about a 1px box. **top-bar-fit.spec.ts keeps 390 and asserts the stronger property.** "Nothing hangs out of the bar" is trivially true of a bar with no row and would pass on a build that merely broke it, so what that width asks now is that the content starts where the row above it ends. Measuring against the window instead would have been asserting "and no background job is running", which that spec is not about and cannot arrange. Closes #57 |
||
|
|
ac8f86eb00 |
fix(settings): give the library selection a home that is not the top bar
library-filter is the only control in the app that calls setSelectedLibrary, and the phone already hid it with a comment saying it was "reachable from the drawer's Settings". It was not: Settings adds, removes, renames and scans libraries, and does not set the view filter, which is a different thing -- it decides what Albums, Artists and Genres show. A phone therefore inherited whatever a desktop session last chose and could neither change nor see it, which is #24's sentence broken in the band it was written for. It is a second *placement* of the same component, not a second control, and it is at every width rather than below 600px. A phone-only copy is the cheaper answer and is the fault rather than the fix: "where do I change which library I am browsing" having two answers by viewport is exactly what one control in two places avoids. Closes #148 |
||
|
|
47bd9ef211 |
fix(header): let the count yield before an action is clipped
Adding the phone's search button to this header is 43px more than the row has at 320px, which is a width the app promises and which header-action-overflow.spec.ts asks about. Measured on Playlists there, after the fit pass had already collapsed all three actions into "More actions" and truncated the title to nothing: title 0, count 50, sort 143, search 40, More 38, five 12px gaps and 32px of gutters -- 363 in 320, with the More button ending 27px past the edge. That is an action clipped, which is the exact defect this pass exists to prevent. The count is what yields, last, because it is the only item on that row that is neither an identity nor an action. The title yields first and may ellipsis away entirely, since the navigation also says which page you are on; the sort control and the buttons are each the only place they are said. An empty page says it is empty in its empty state and a full one is being looked at. With the count gone the header is 304 in 304, and the title comes back to 19px. It is rendered and hidden with an attribute rather than returned as `nothing`, for the reason the action buttons are: every pass starts from all-visible and needs a node to un-hide, or the first 320px window costs the count for the rest of the session. |
||
|
|
b801fa533a |
feat(shell): make search a button and a modal where searching applies
The phone's top bar is about to go, and the search box is the one thing in it that is an action rather than chrome. It becomes a button in the row that already says which page you are on, opening a wa-dialog with the real search box in it. Three decisions worth the words. **A wa-dialog, and that is a mechanism rather than a taste.** wa-popup renders `<div popover="manual">` and feature-detects the Popover API, falling back to `strategy: "fixed"` where there is none -- which is Chrome 113, the reference device, since `popover` is Chrome 114. And `position: fixed` escapes ancestor overflow but not `contain: paint`, which `.main-panel` carries, so a popup-shaped search panel opened from a view's header is structurally clipped on that device. `<dialog>` / `showModal()` is Chrome 37 and uses the real top layer. No tier here can see the difference -- CI's Chromium and WebKit both have the Popover API -- so the component test asserts the *mechanism*, a native `<dialog>` in the tree, rather than the symptom. **An element, not a PageAction.** Two of the seven searchable views are detail views with no page-header; they filter on the term and say so in their own headers. Declaring search as an action would mean seven hosts each writing it out, which is a second list of searchable views, and it would put a phone mode for actions inside page-header, which that component documents its refusal to grow. search-store's own map is the condition, asked by one component placed three times. **The modal carries the real search-bar**, so there is still one debounce, one clear button and one view-scoped placeholder. Escape closes it and *keeps* the term -- the input treats Escape as "clear the search", which is right in a header where the box stays on screen and wrong in a surface whose dismissal would then discard the search. |
||
|
|
23f5a0c53a |
feat(shell): show background jobs in the phone's layout, not a popover
The header indicator is a disclosure anchored to a bar 3.25em tall on a screen 439 CSS px tall, and it was reported as unreadable behind other UI. Background work is the one thing a phone should not make you open something to see, and #57 deletes the bar it hangs from and is blocked on it having somewhere else to live. Below 600px the indicator stands down and <job-band> takes over. It is the existing job-panel at `kinds="*"`, so pause, cancel, Details and the log come along, and so does applyJobControl. **It is in the layout, not over it**, and that was measured rather than assumed. The first version put the panel in notification-host's fixed band: it renders correctly, sits on top and stays inside the viewport, and is unusable -- at 424x439 a compact panel showing two jobs is ~216px of a 439px screen, drawn over the content and swallowing every tap under it. Four e2e specs caught it, and none of them was about jobs: two phone-shell journeys and the header's action menu, all failing on clicks the band was intercepting. As a grid row above the main panel it pushes instead, which is #24's one sentence deciding a layout question -- a band that hides the app to say the app is busy has traded the popover's fault for a worse one. It renders nothing above 600px, from matchMedia rather than a media query, because that decides whether the element exists: Settings already holds four job-panels and a fifth answering for every kind is bottom-nav's "resolved to 2 elements" trap again. index.css keeps it display:none off the phone for a second reason -- an in-flow grid child with no named area is auto-placed into one of the shell's rows, which is what the skip link is absolutely positioned to avoid. top-bar-fit's 390px case asserted the indicator was up, so that it could not pass by measuring the idle case under another name. At phone width it is now deliberately away, so the assertion takes the other branch of the same rule -- the indicator is hidden, the band has the row, and the bar still has nothing hanging out of it -- rather than the width being quietly dropped from the list. The report's own symptom is deliberately not asserted anywhere: it did not reproduce in this tier. Measured at 424x439 the popover was neither clipped nor covered, so a spec claiming a stacking fix would be asserting something that was never true here. The spec says so. Closes #62 |
||
|
|
502b814a65 |
feat(jobs): let a panel answer for every kind, at either density
Three properties the phone's band needs, added here so it is the same panel rather than a second job UI -- which is what keeps `applyJobControl` and its "you will discard hours of downloading" confirmation in the picture. `kinds="*"` is every kind, which is what the header indicator was for. Spelled as a star rather than taken as the meaning of an empty attribute, because empty is what a typo and a dropped binding both produce and "show everything" is the wrong thing to do by accident; empty still shows nothing. `density` is passed to `job-row`, whose `compact` variant its own source calls "the popover density" -- which is exactly what the band replaces. `full` stays the default, so the four settings call sites are untouched. `active-only` drops terminal rows. The band is in the layout, so a finished row there holds the content down after the work is done; Settings keeps them, because that is where "did the last scan work" is asked and a finished row there dismisses itself. |
||
|
|
fe1fbefee7 |
fix(player): give the seek bar's interval one owner
`handleInput()` called `stopProgress()` and mutated no reactive state, so Lit scheduled no update, `updated()` never ran, and the tail of `updated()` that restarts the interval never executed. Only a `change` event or the next backend report could bring it back — so an `input` that never commits froze the interpolation: a drag cancelled outside the element, a pointer taken by a scroll, or a touch on the track treated as a scrub, all ordinary gestures on a phone. While playing the 1 Hz report papered over it within a second; with reports not arriving it was permanent. The drag is `@state` now and `updated()` decides whether the interval runs, so there is one place that knows. `handleChange` no longer starts it directly for the same reason. A flag set on `input` can strand, which would turn a stall of up to a second into a permanent one — the failure this removes. `change` is the ordinary end; `pointerup`/`pointercancel`/`touchend`/`touchcancel` on the document are the ends that are not, attached with the drag and dropped with it, because the pointer is routinely released outside the element it started in. The other half is that a report arriving mid-drag used to overwrite `seekValue` and pull the thumb out from under the finger once a second. It is skipped while dragging, and its seq is deliberately left unrecorded so the first report after the drag still counts as fresh. Three tests, all exercised against the fault: two fail on the old component, and the third fails if the drag flag is left set — which is the failure mode the fix introduces and the listeners exist to prevent. Verified on the device too (Chrome 113): mid-drag the bar holds its value and ignores reports, and on release it adopts the backend's real position and resumes ticking. Closes #164 |
||
|
|
ffc9490a32 |
fix(player): put the phone's Now Playing button above the artwork
`.expand` is the phone's only route into the full-screen now-playing view. It is absolutely positioned with `z-index: auto` over `.cover-art`, which is a *later* sibling with the same z-index, so the two tie on paint order and the later one wins. An `<img>` costs nothing there; a track with no artwork renders a placeholder `wa-icon`, which takes every click aimed at the button underneath it. So the control did not work whenever the current song had no cover, on the one platform that has no other way in. Nothing to do with the fixture: any library has untagged files. Measured at 390px with elementFromPoint at the button's centre — the icon with a placeholder, the button with an image, and the button either way with the z-index. Chosen over `pointer-events: none` on the art, which would take the cover preview's mouseenter with it, and over reordering the DOM, which leaves the same tie to be won by the same accident in the other direction. This was filed as an e2e flake, and the diagnosis was wrong: it failed on both engines three times across two branches that could not have caused it, and passed on re-run each time, because the spec starts the *first* row of the track list and which track that is depends on the order the scan inserted rows — the same root cause as #156. The new spec picks a track *for* having no artwork, and asserts the placeholder is rendered rather than assuming it, so it cannot quietly go back to measuring the easy case. Two things it has to get right, both already documented traps: the track must be the 90-second one, since a 2-second one finishes before the assertions run; and `library.Track.CoverArt` is empty for all 31 fixture rows, so "the first track with no cover art" selects nothing in particular and picked a short one. Verified by mutation: without the z-index the new spec fails on the click in 30s, and the pre-existing one beside it passes, which is exactly how this survived. Closes #150 |
||
|
|
dc8db159f9 |
feat(player): centre the transport and show the volume inline
Two issues over one bar, because they are one relayout. #42's own findings say so: giving wa-slider a label grows it 6px to 14px and moves the transport, which is #23's subject, so doing them in sequence means measuring the bar twice and throwing the first set away. The bar was `320px 1fr auto`, so the transport sat in the middle of what the metadata and the queue button did not use — its centre was ~140px right of the window's at every width. The outer two tracks are the same expression now, so the middle is centred by construction. The side width is the metadata's, capped at a quarter of the bar, and the cap was measured as a regression before it was a decision: reserving the full `--now-playing-width` on both sides is perfectly centred and takes the seek bar's track from 257px to 61px at 800px, and to 0 at 200% text. The control you drag was paying for the symmetry. With the cap it is 246, which is parity. It is a `min()` rather than a breakpoint because that variable is user state — the metadata has a drag handle — and tying both sides to it is also what keeps dragging meaningful; a plain `1fr … 1fr` centres just as well and silently makes the handle a no-op. The volume moved out of `audio-player` into the bar because the transport column has to hold the transport and nothing else, and it joins the queue button in one cell rather than a second column, since the centring compares columns. It is a slider by default and a popup by setting. The stored flag names the *popup*, which is this config's polarity rule — the zero value has to be the intended answer, so an existing config.toml gets the new default with no migration. Inline, the icon is the mute toggle and is named after that action rather than the state, because with the slider beside it there is nothing to disclose; the component tier now covers both presentations rather than whichever is default. Three nested rules in this block began with a bare element selector, which Chrome 120 relaxed and the phone's Chrome 113 **silently drops** — including the ellipsis on the bar's own title and artist, which has therefore never truncated on the device. They are `&`-prefixed now. Filed as #154 for the class and for a check. `bottom-bar.spec.ts` pins both halves separately on purpose: an uncapped build is perfectly centred and fails only the seek-bar width, so a spec asserting centring alone would have passed the regression above. Both were verified by mutation. Closes #23 Closes #42 |
||
|
|
4f7529c315 |
test(queue): pin the panel's mouse model, and bound the highlight
Single click selects, ctrl and shift extend, double click plays from that row — all four already worked, and nothing in either tier pinned any of them, which is why the report could be made and could not be settled. `queue-reorder.spec.ts` covers the keyboard and `queue-overlay.spec.ts` the panel's mode; the pointer path had no coverage at all, so "selection is broken here" and "selection is fine here" were equally consistent with a green suite. Measured with real mouse events rather than dispatched ones, because a synthetic click aimed at the row bypasses the only thing that could be swallowing it: click row 1 selects 1, ctrl+click 4 gives 1 and 4, shift+click 7 extends to 1,4,5,6,7, a plain click collapses to one, and a double click on row 3 leaves the backend playing row 3. The three candidates the issue lists are all answered. The repaint was already correct, and already correct on the day the issue was filed. `resolveTrackIndexFromEvent` reads data-index, and DOM order matches data order. A row control does swallow the click — `explore-link` stops propagation on purpose, so a click on a name navigates and selects nothing — but a hit-scan across a row makes the queue 12% link against the track list's 21%, so the panel called broken is *less* covered by links than the list called correct. That measurement killed the fix this started out as. Two traps are written into the spec because both faked a defect while measuring. Fixture tracks are 2 seconds, so "double click row 3" read a moment later reports whatever auto-advance moved on to — recorded twice as an off-by-one that is not one, which is what `LONG_TRACK` exists for. And the selection assertions are bounded at 500ms rather than polled with the default 5s: `queue-panel` repaints two ways, the explicit `requestUpdate()` and a per-render `keyFunction` arrow, and with *both* removed the highlight still arrives — at 134ms, 3.9s and 5.8s against 5-17ms healthy. Four seconds is indistinguishable from broken to a user and invisible to a generous poll. Mutation-tested rather than trusted: `playAtIndex(index + 1)` fails both double-click tests, treating every click as ctrl+click fails both selection tests, and removing both repaint mechanisms fails all three selection tests — the last only because of the bound. Closes #43 |
||
|
|
ae85df0dad |
fix(shell): give the top bar a measured fit at every width
The bar was 611px inside a 600px viewport at the bottom of the Compact band, and 862px while a scan with a real library's title ran, because `job-indicator` is `hidden` when idle and 235px wide when it is not. `body` is `overflow-x: auto`, so a user got a horizontal scrollbar on a shell #24 promised would not need one — and the band is 600 to 899 with work in flight, not the 600 to 610 the idle measurement suggested. `services/top-bar-fit.ts` is `page-header`'s treatment one bar up: a ResizeObserver, every pass starting from all-visible, hiding the lowest-priority child until it fits. Measured rather than breakpointed because three of the five children are as wide as their content — the library filter by the longest library name, the indicator by the running job's title, the search box by its view-scoped placeholder — so any width picked is right for one library, one job and one view. What yields is decided by #24's own sentence, which rules out the two cheapest candidates in the Direction. Hiding the library filter takes away an action, since it is the only control in the app that selects a library (filed as #148, which is the phone already doing it), and collapsing search to an icon is #57's, which is blocked behind #62. So the wordmark yields first — a brand the window title bar repeats, and visually-hidden rather than `display: none` because that h1 is the document's heading — and then the indicator's label, leaving the ring, which the component already does below 600px and whose live region announces the state either way. "Fits" is the children against the content box, not `scrollWidth` against `clientWidth`: `scrollWidth` counts the left padding and not the right, so the first version read 700/700 with the indicator sitting in the whole right gutter. And the bar does not resize when a job starts, which is the case this is for, so every child is observed too. Pinned before it was fixed, as the issue asks. On the unfixed build the new spec fails at 600 idle and at 600, 800 and 900 with a job, and passes at 390, 899 and 1440; `layout-overflow.spec.ts` gains 600x600 and failed there. That spec asserts on the *shell*, so it was green throughout this defect — the per-child measurement is #69's lesson, and it is what caught the gutter case above. Closes #143 |
||
|
|
c79d4d47a3 |
test: cover jobs in Settings, and unpick two shared selectors
The spec worth having is not that the tab is gone -- that is one line of a table -- but that nothing became unreachable when it went. #24 promises that no action is ever unreachable at any supported size, and deleting a destination is exactly the change that quietly breaks it. Two existing selectors had to give. `config-section .header` is ambiguous the moment a section holds a job, because `job-details-drawer` carries that class too -- so `settings-reach.spec.ts` locates a disclosure by role and name instead. And `page-header`'s and `offline-icons`'s view lists lose an entry each. Closes #27 |
||
|
|
8efed2dd2b |
refactor(shell): retire the Jobs destination
Nothing it carried is gone -- the two commits before this put all of it somewhere the work is already being done. A retired destination is the one shape #25's storage decision does not make free. A visibility entry is a map key and an unknown key is dropped on load; a launch page is a *value*, and an unknown one fails validation -- which on the load path means the app refuses to start for whoever had Jobs selected. `RetiredViews` is that list, read by `ApplyDefaults`, which treats a retired name as a zero value. An unknown-but-not-retired name still errors, because that is a typo and saying so is the useful answer. |
||
|
|
12af6ec1f7 |
feat(settings): scan from Settings, and watch every job in place
Scanning goes back where libraries are managed -- the Jobs tab's own comment said the per-library controls had been taken out of Settings to build it. "Scan All" and "Full Rescan" join "Add Library", "Scan now" joins the per-library overflow menu beside Rename and Remove, and a library being scanned says so where its track count goes. Each surface then gets the job rows for its own kind: scans under Libraries, index and enrichment under Search Index, downloads under the download clients, and the autotag apply in the Autotag view -- where stopping a run matters most, since applying rewrites tags on disk and that view had no cancel at all. The autotag panel shares the header's grid row through a wrapper rather than taking a third row: it is display:none while nothing is applying, which is nearly always, and a grid row would still spend the container's gap on it. The Launch Page select is derived from `VIEW_META` instead of listing its ten options, since removing a destination is exactly the change that leaves two hand-written copies disagreeing. |
||
|
|
f3d1ae1c8c |
feat(jobs): show background work where the work is started
Reading the app before moving anything turned up that four of the five job kinds already have a home showing their work: Settings → Search Index draws per-tier index progress, `downloads-view` draws every download's lifecycle state, `autotag-view` draws its own apply ring, and only `library-scan` had nowhere but the Jobs tab. What none of the four had is the *generic* affordances — pause, cancel, Details, the log, and a finished job you can dismiss. So this is a panel embedded beside each of them rather than one "Background jobs" section in Settings, which would have been the tab again under another name. Three rules in it. The controls are `applyJobControl`, not a reimplementation, which is what keeps the index build's "you will discard hours of downloading" confirmation alive across the move. A panel with nothing to say is `hidden` rather than empty, host margin included, because an idle panel in four places is four pieces of furniture describing an absence. And there is no "Clear finished": `ClearFinishedJobs` is global, so a Clear under Libraries would discard the index build's history too. `JobKind` also gains `download`, which the backend has had all along. |
||
|
|
43d78a731a |
feat(shell): draw only the destinations the user kept
The navigation reads the resolved map from the backend rather than holding a copy of the defaults, which would be the copy that shipped in the binary rather than the one being edited. Hiding takes away the nav item and nothing else: `navigate` still resolves a hidden view, which detail views and the launch page depend on. No special case was needed for the highlight, because #72 moved that onto `active-view-store` -- the sidebar asks `isActive(id)` per *rendered* item, so a hidden view lights nothing exactly as a detail view does. Downloads is gated at the nav on `downloadStore.available` rather than in the config, so switching it on in Settings still means what it says once a client exists, and the tab appears without a restart. `available` is false until the providers have loaded, which makes the item appear on a fresh launch rather than appearing and then vanishing. The tab bar honours the toggles too, and the reason is local rather than a general rule about phones: "More" opens the *same* `<app-sidebar>`, which filters, so an unfiltered bar would contradict its own drawer one tap away. Which four tabs is still plan 016's subset; this only removes from it, and "More" is never filtered. `services/view-meta.ts` is the destination list, on `shortcut-meta.ts`'s pattern, because Settings is now a second reader of the same labels in the same order. Two existing sidebar tests had to say which world they describe: eleven destinations now assumes a configured download client. |
||
|
|
a3926704cc |
feat(config): make the shell's destinations configurable
The sidebar's eleven entries are more than most libraries need, and Autotag rewrites tags on disk, which is not what a fresh install should be one click from. Stored as a map keyed by view id, where an absent key means that view's own default. A `HiddenViews []string` cannot express "Autotag off by default" -- its zero value is *hide nothing* -- and a boolean per view turns a view that later stops existing into stored garbage. With a map, an unknown key is dropped on load, a view added later gets its own default, and no install needs migrating in either direction. Same polarity as AllowMeteredCatalogDownload: the zero value is the intended answer. `Views` is also what DefaultPage now validates against, so which views exist and which may be the launch page are one list rather than two. Two states the user could not get out of are refused rather than allowed: Settings is never hideable, and the launch page is not hideable while it is the launch page. Both refuse in the *config*, not in the UI, because `config.toml` is hand-editable. On load the launch page is instead un-hidden -- there is nobody to tell, and the honest reading of "my launch page is Autotag" is that this user wants Autotag, not that their launch page should be silently reset. |
||
|
|
603728a3fb |
fix(shell): let the landing page replace the launch entry
The app navigates twice on startup and both are deliberate: the eager `navigate -> home` that paints without waiting for the backend, and the configured page `GetDefaultPage()` resolves to a moment later. Only the first replaced the launch entry, so the second stacked on it and a fresh session was already one entry deep before the user had touched anything. The first back press therefore replayed home over home. On desktop that was invisible until this branch drew a Back button, which rendered live at the root and did nothing; on Android `webView.canGoBack()` was true, so the press that should have exited the app was swallowed -- the exact fault the replace-the-launch-entry rule exists to prevent, defeated by there being two launch navigations rather than one. Guarded on still being at index 0 rather than on a flag: that call is asynchronous and the user can navigate while it is in flight, so past the root this is an ordinary navigation and a slow answer cannot overwrite an entry they made. Closes #142 |
||
|
|
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 |
||
|
|
2b84bc53e9 |
fix(player): stop reporting one track's state against another
Five faults found while auditing the play/pause and position path for a desktop report of the pause icon showing over a seek bar that was not moving. They are one commit because they are one file's worth of tangled state, and two of them do not compile apart. The finished callback did not know which chain it came from. It is dispatched as a goroutine from the beep callback and then queues for p.mu, so a user pressing Next in the last second of a track had it wake up holding the lock for a player that had loaded something else -- and rewind it, stop it, and hand a stale finish to the queue's auto-advance. updateStreamers now stamps a chainID and the callback carries the one it was registered with. (#123) It also emitted PlaybackFinished and PlaybackStateChanged(stopped) *after* releasing p.mu, alone in this file, so a Play() taking the lock in that gap emitted `playing` first and the stale `stopped` landed last -- the button showing play over a track that was audibly running. Both emits are back under the lock. (#123) A source that failed mid-track was reported to the queue as a natural end, so a broken file auto-advanced in silence and was counted as played. The handler takes the reason now: the player cannot name the track, because the metadata is the queue's, so the queue emits PlaybackFailed and skips recording the play. (#123) p.format was assigned once, in the constructor, to the *speaker's* rate, and never again -- so it claimed 44.1 kHz for every file. The replay-after-finish path resamples from it, meaning a finished track played a second time was resampled from a rate the decoder never produced: audibly wrong speed and pitch, and the length and position fallbacks wrong with it. The fixtures are 22050 Hz, which is what lets a test see this at all. (#124) p.trackLengthMs was written only when the database had a row and cleared only by UnloadTrack, so a file with no row inherited the previous track's duration -- and every position report is scaled by it, so the bar reported one track's progress on another's scale. (#125) Queue.OnPlaybackFinished indexed q.tracks[currentIndex] having checked only that the queue was non-empty. currentIndex is -1 whenever the queue has been exhausted, and onQueueExhausted deliberately leaves the finished track loaded -- so playing it from there and letting it end panicked, on a goroutine with no caller to recover it. (#126) The position readers guarded the decoder with the speaker lock, which the read-ahead goroutine has no reason to hold and never takes -- so Position() raced readAhead's Stream() on every position emit, once a second for the whole of playback. srcMu is the lock that excludes that goroutine, and taking it naively deadlocks, because seekLocked already holds it and then emits the landing position from inside that region. seekSourceLocked is that region extracted, so the lock is released before anything is emitted. Found by the race detector, via the test added here for the chain guard: the existing suite never loads a file outside the integration guard, so make test was green over it. (#127) OnPlaybackFinished picks up //wails:ignore along with its error parameter: v3's generator segfaults on a bound method taking an error, and this was never IPC. That removes a binding the frontend could have called to force an auto-advance. Closes #123 Closes #124 Closes #125 Closes #126 Closes #127 |
||
|
|
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 |
||
|
|
9118c16fe3 |
feat(autotag): answer whether an album has a confident match
`MatchForAlbum(albumID)` is the question the album detail page needs to ask on open: does the autotagger already have something confident to say about this album, and what would applying it do. **It costs no MusicBrainz request.** Everything it needs is on disk — `tagging_items` carries the top score and release from the background prefetch, `tagging_candidates` durably holds the scored list. The rate limiters here are shared with every page the user can open, so a lookup that fires on page load must not join that queue; a folder nobody has scored yet answers "nothing", rather than scoring it now. **The tier is computed, not read.** `tagging_items.score` is the raw number and `Recommend` is what turns it into a claim, capping it for an ambiguous runner-up, an incomplete alignment or a folder too small to corroborate itself. Filtering on the stored score would promise confidence the scorer had explicitly withheld — which the two-track test pins. **Nothing is said about an album the user has already answered for.** Only a `pending` group qualifies: `confirmed` covers both a finished apply and an explicit "leave as is", and arguing with the second would be actively wrong. The join is `audio_files.group_key`, not a key derived from the folder path, because a group carved out of a mixed-bag folder is keyed on its tags — so a path-derived key would find nothing for exactly the messiest libraries this helps. `GroupCount` is returned because a multi-disc album is one group per disc: a caller that applied to "the album" from a single button would retag one disc of three. |
||
|
|
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 |
||
|
|
10eca353ab |
fix(explore): gate playback on the same answer the row is drawn from
Two play paths still accepted `inLibrary`, so a row drawn dimmed and `aria-disabled` by the new rule would still attempt to play and fail with "this track could not be found in your library" — the disagreement this pass exists to remove, one layer down from the badge. |
||
|
|
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. |
||
|
|
41c41a860e |
feat(explore): carry the local row id on a top result
`TopResult` was the one projection here that shipped `inLibrary` and no local id, so the top-results cards had no choice but to read the weaker flag. Every sibling model — `MBArtist`, `MBReleaseGroup`, `MBRecording` — already carries `LocalID`, and the candidate builders had the value in hand at every construction site. `LocalID` is set and cleared by a test against `audio_files`, so it means "there is something of mine here". `InLibrary` is written by the same pass but is a one-way ratchet the prune can only clear alongside a local id; it stays for scoring, which is where an approximate answer is fine. |
||
|
|
4bf59b45b7 |
feat(library): answer album completeness for a screenful in one query
A card grid has to know how much of an album is here — an album held 2 tracks of 10 wearing the same green tick as one held whole is the complaint the badge-accuracy work was filed about — and `GetAlbumCompleteness` is one query per album, which is fifty round trips for a grid of fifty. `GetAlbumsCompleteness` is the same question over a slice. It is two grouping levels rather than the single-album form's correlated subqueries, because a correlated subquery in the FROM clause is not something SQLite will reliably do, and because the slice may only be spelled once or sqlc expands it twice with independently numbered placeholders. An album with no files is absent from the result rather than zeroed: "I have none of this" and "I have no idea" are the third state `Known` exists to keep apart. The test that matters is that the two spellings never disagree — they are genuinely different SQL, so the risk is a drift in meaning (a disc's total counted once per file, a duplicate counted twice) rather than a typo. |
||
|
|
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 |
||
|
|
e16bd245bd | Merge remote-tracking branch 'origin/fix/queue-toggle-state' into integration/small-fixes | ||
|
|
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 |